feat: add object storage signing providers

This commit is contained in:
Codex
2026-06-22 00:58:36 +08:00
parent ef8afcdbc8
commit 492706e24f
13 changed files with 1650 additions and 79 deletions

View File

@@ -4,6 +4,19 @@ import { intParam, readJsonBody, requiredString, stringParam } from '../../core/
import { query, queryOne } from '../../core/db.js';
import { requireTenantContentEditor, type TenantContentAuth } from './auth.js';
import { boolValue, intValue, jsonObjectValue, nullableString } from './utils.js';
import {
assertUploadProvider,
configuredDefaultStorageBucket,
assertWritableLocation,
configuredDefaultStorageProvider,
normalizeStorageProvider,
signStorageDownload,
signStorageUpload,
validateFileSize,
validateMimeType,
validateObjectKey,
type StorageProviderName,
} from '../storage/service.js';
const ASSET_TYPES = ['pdf', 'video', 'image', 'audio', 'document', 'package', 'link', 'other'];
const STORAGE_PROVIDERS = ['external_url', 'supabase_storage', 'aliyun_oss', 'tencent_cos', 'qiniu_kodo', 'local_dev'];
@@ -45,25 +58,6 @@ function safeFileName(fileName: string) {
.slice(0, 160) || 'asset';
}
function placeholderSignedUrl(asset: AssetRow, expiresInSec: number) {
const expiresAt = new Date(Date.now() + expiresInSec * 1000).toISOString();
if (asset.cdnUrl) {
return {
provider: asset.storageProvider,
url: asset.cdnUrl,
expiresAt,
signatureMode: 'public-or-provider-managed',
};
}
return {
provider: asset.storageProvider,
url: `${asset.storageProvider}://${asset.bucket || 'default'}/${asset.objectKey || asset.id}?expiresAt=${encodeURIComponent(expiresAt)}`,
expiresAt,
signatureMode: 'local-placeholder',
};
}
async function assertOptionalReference(tenantId: string, table: string, id: string | null, code: string) {
if (!id) return;
const row = await queryOne<{ id: string }>(
@@ -164,7 +158,12 @@ export async function upsertAssetRoute(ctx: RequestContext) {
const body = await readJsonBody(ctx);
const assetType = choice(body.assetType, ASSET_TYPES, 'document', 'assetType');
const storageProvider = choice(body.storageProvider, STORAGE_PROVIDERS, nullableString(body.cdnUrl) ? 'external_url' : 'local_dev', 'storageProvider');
const storageProvider = choice(
body.storageProvider,
STORAGE_PROVIDERS,
nullableString(body.cdnUrl) ? 'external_url' : configuredDefaultStorageProvider(),
'storageProvider',
) as StorageProviderName;
const visibility = choice(body.visibility, VISIBILITIES, boolValue(body.isPublic, false) ? 'public' : 'tenant', 'visibility');
const status = choice(body.status, ASSET_STATUSES, 'active', 'status');
const bucket = nullableString(body.bucket);
@@ -178,6 +177,12 @@ export async function upsertAssetRoute(ctx: RequestContext) {
const entryId = nullableUuid(body.entryId);
const contentNodeId = nullableUuid(body.contentNodeId);
const cleanObjectKey = objectKey ? validateObjectKey(auth.tenantId, objectKey) : null;
const mimeType = nullableString(body.mimeType);
if (mimeType) validateMimeType(mimeType);
const fileSizeBytes = body.fileSizeBytes === undefined ? null : validateFileSize(intValue(body.fileSizeBytes, 0));
assertWritableLocation({ provider: storageProvider, bucket, objectKey: cleanObjectKey, cdnUrl });
if (status === 'active' && !cdnUrl && !objectKey) {
throw new HttpError(400, 'Active asset requires cdnUrl or objectKey', 'ASSET_LOCATION_REQUIRED');
}
@@ -261,15 +266,15 @@ export async function upsertAssetRoute(ctx: RequestContext) {
assetType,
storageProvider,
bucket,
objectKey,
cleanObjectKey,
title,
nullableString(body.categoryLabel) || nullableString(body.category),
nullableString(body.description),
nullableString(body.fileName),
cdnUrl,
nullableString(body.previewUrl),
nullableString(body.mimeType),
body.fileSizeBytes === undefined ? null : intValue(body.fileSizeBytes, 0),
mimeType,
fileSizeBytes,
nullableString(body.checksumSha256),
visibility,
visibility === 'public',
@@ -307,35 +312,48 @@ export async function signAssetUploadRoute(ctx: RequestContext) {
const body = await readJsonBody(ctx);
const fileName = requiredString(body, 'fileName');
const assetType = choice(body.assetType, ASSET_TYPES, 'document', 'assetType');
const storageProvider = choice(body.storageProvider, STORAGE_PROVIDERS, 'local_dev', 'storageProvider');
const bucket = nullableString(body.bucket) || 'tenant-assets';
const storageProvider = normalizeStorageProvider(nullableString(body.storageProvider), configuredDefaultStorageProvider());
assertUploadProvider(storageProvider);
const bucket = nullableString(body.bucket) || configuredDefaultStorageBucket();
const objectKey =
nullableString(body.objectKey) ||
`${auth.tenantId}/${assetType}/${Date.now()}-${randomUUID()}-${safeFileName(fileName)}`;
const expiresInSec = Math.min(Math.max(intValue(body.expiresInSec, 900), 60), 3600);
const expiresAt = new Date(Date.now() + expiresInSec * 1000).toISOString();
const mimeType = validateMimeType(nullableString(body.mimeType) || 'application/octet-stream');
const fileSizeBytes = body.fileSizeBytes === undefined ? null : validateFileSize(intValue(body.fileSizeBytes, 0));
const cleanObjectKey = validateObjectKey(auth.tenantId, objectKey);
const upload = await signStorageUpload({
tenantId: auth.tenantId,
provider: storageProvider,
bucket,
objectKey: cleanObjectKey,
fileName,
mimeType,
fileSizeBytes,
expiresInSec,
upsert: body.upsert === true,
});
await recordAssetAudit(auth, 'content.asset.upload_signed', null, {
provider: storageProvider,
bucket,
objectKey: cleanObjectKey,
assetType,
fileName,
mimeType,
fileSizeBytes,
});
return {
upload: {
provider: storageProvider,
bucket,
objectKey,
method: 'PUT',
url: `${storageProvider}://${bucket}/${objectKey}?expiresAt=${encodeURIComponent(expiresAt)}`,
headers: {
'content-type': nullableString(body.mimeType) || 'application/octet-stream',
},
expiresAt,
signatureMode: 'local-placeholder',
},
upload,
assetDraft: {
assetType,
storageProvider,
bucket,
objectKey,
objectKey: cleanObjectKey,
fileName,
mimeType: nullableString(body.mimeType),
fileSizeBytes: body.fileSizeBytes === undefined ? null : intValue(body.fileSizeBytes, 0),
mimeType,
fileSizeBytes,
checksumSha256: nullableString(body.checksumSha256),
},
};
@@ -371,6 +389,14 @@ export async function signAssetDownloadAdminRoute(ctx: RequestContext) {
return {
item: asset,
download: placeholderSignedUrl(asset, expiresInSec),
download: await signStorageDownload({
tenantId: auth.tenantId,
provider: asset.storageProvider as StorageProviderName,
bucket: asset.bucket,
objectKey: asset.objectKey,
cdnUrl: asset.cdnUrl,
fileName: asset.fileName,
expiresInSec,
}),
};
}