forked from wangziqi/gongxue-base
feat: add object storage signing providers
This commit is contained in:
@@ -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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user