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

@@ -10,6 +10,8 @@
"check": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@supabase/storage-js": "^2.108.2",
"ali-oss": "^6.23.0",
"pg": "^8.16.3"
},
"devDependencies": {

View File

@@ -1,4 +1,4 @@
import { DEFAULT_DATABASE_URL, DEFAULT_TENANT_SLUG, envList, envNumber, envString, loadDotenv } from '../../../../packages/config/src/index.js';
import { DEFAULT_DATABASE_URL, DEFAULT_TENANT_SLUG, envBoolean, envList, envNumber, envString, loadDotenv } from '../../../../packages/config/src/index.js';
export interface ApiConfig {
nodeEnv: string;
@@ -13,6 +13,26 @@ export interface ApiConfig {
authSmsCooldownSeconds: number;
authSessionTtlSeconds: number;
platformAdminApiKey: string;
storageDefaultProvider: string;
storageDefaultBucket: string;
storagePublicBaseUrl: string;
storageMaxUploadBytes: number;
storageAllowedMimePrefixes: string[];
storageAllowedMimeTypes: string[];
storageRequireTenantPrefix: boolean;
aliyunOssRegion: string;
aliyunOssEndpoint: string;
aliyunOssAccessKeyId: string;
aliyunOssAccessKeySecret: string;
aliyunOssStsToken: string;
aliyunOssInternal: boolean;
tencentCosRegion: string;
tencentCosAppId: string;
tencentCosSecretId: string;
tencentCosSecretKey: string;
tencentCosSecurityToken: string;
supabaseStorageUrl: string;
supabaseStorageServiceKey: string;
isProduction: boolean;
}
@@ -33,5 +53,43 @@ export const config: ApiConfig = {
authSmsCooldownSeconds: envNumber('AUTH_SMS_COOLDOWN_SECONDS', 60),
authSessionTtlSeconds: envNumber('AUTH_SESSION_TTL_SECONDS', 60 * 60 * 24 * 7),
platformAdminApiKey: envString('PLATFORM_ADMIN_API_KEY', 'local-platform-admin-key'),
storageDefaultProvider: envString('STORAGE_DEFAULT_PROVIDER', 'local_dev'),
storageDefaultBucket: envString('STORAGE_DEFAULT_BUCKET', 'tenant-assets'),
storagePublicBaseUrl: envString('STORAGE_PUBLIC_BASE_URL', ''),
storageMaxUploadBytes: envNumber('STORAGE_MAX_UPLOAD_BYTES', 1024 * 1024 * 500),
storageAllowedMimePrefixes: envList('STORAGE_ALLOWED_MIME_PREFIXES', 'image/,video/,audio/'),
storageAllowedMimeTypes: envList(
'STORAGE_ALLOWED_MIME_TYPES',
[
'application/pdf',
'application/json',
'application/zip',
'application/x-zip-compressed',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-powerpoint',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'application/octet-stream',
'text/plain',
'text/markdown',
'text/csv',
].join(','),
),
storageRequireTenantPrefix: envBoolean('STORAGE_REQUIRE_TENANT_PREFIX', true),
aliyunOssRegion: envString('ALIYUN_OSS_REGION', ''),
aliyunOssEndpoint: envString('ALIYUN_OSS_ENDPOINT', ''),
aliyunOssAccessKeyId: envString('ALIYUN_OSS_ACCESS_KEY_ID', ''),
aliyunOssAccessKeySecret: envString('ALIYUN_OSS_ACCESS_KEY_SECRET', ''),
aliyunOssStsToken: envString('ALIYUN_OSS_STS_TOKEN', ''),
aliyunOssInternal: envBoolean('ALIYUN_OSS_INTERNAL', false),
tencentCosRegion: envString('TENCENT_COS_REGION', ''),
tencentCosAppId: envString('TENCENT_COS_APP_ID', ''),
tencentCosSecretId: envString('TENCENT_COS_SECRET_ID', ''),
tencentCosSecretKey: envString('TENCENT_COS_SECRET_KEY', ''),
tencentCosSecurityToken: envString('TENCENT_COS_SECURITY_TOKEN', ''),
supabaseStorageUrl: envString('SUPABASE_STORAGE_URL', ''),
supabaseStorageServiceKey: envString('SUPABASE_STORAGE_SERVICE_KEY', ''),
isProduction,
};

View File

@@ -1,6 +1,7 @@
import { getHeader, HttpError, type RequestContext } from '../../core/http.js';
import { intParam, stringParam, tenantIdFrom, userIdFrom } from '../../core/request.js';
import { query, queryOne } from '../../core/db.js';
import { signStorageDownload, type StorageProviderName } from '../storage/service.js';
interface CatalogAssetRow {
id: string;
@@ -25,25 +26,6 @@ function hasUserContext(ctx: RequestContext) {
return !!optionalUserId(ctx);
}
function signedDownload(asset: CatalogAssetRow, 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 hasActiveMembership(tenantId: string, userId: string) {
const row = await queryOne<{ id: string }>(
`
@@ -208,6 +190,14 @@ export async function assetDownloadRoute(ctx: RequestContext) {
visibility: asset.visibility,
},
access,
download: signedDownload(asset, 900),
download: await signStorageDownload({
tenantId,
provider: asset.storageProvider as StorageProviderName,
bucket: asset.bucket,
objectKey: asset.objectKey,
cdnUrl: asset.cdnUrl,
fileName: asset.fileName,
expiresInSec: 900,
}),
};
}

View File

@@ -0,0 +1,452 @@
import crypto from 'node:crypto';
import OSS from 'ali-oss';
import { StorageClient } from '@supabase/storage-js';
import { config } from '../../core/config.js';
import { HttpError } from '../../core/http.js';
export type StorageProviderName = 'external_url' | 'supabase_storage' | 'aliyun_oss' | 'tencent_cos' | 'qiniu_kodo' | 'local_dev';
export type StorageHttpMethod = 'GET' | 'PUT';
export interface AssetLocation {
provider: StorageProviderName;
bucket: string | null;
objectKey: string | null;
cdnUrl?: string | null;
}
export interface UploadSignInput {
tenantId: string;
provider: StorageProviderName;
bucket: string;
objectKey: string;
fileName: string;
mimeType: string;
fileSizeBytes: number | null;
expiresInSec: number;
upsert?: boolean;
}
export interface DownloadSignInput {
tenantId: string;
provider: StorageProviderName;
bucket: string | null;
objectKey: string | null;
cdnUrl?: string | null;
fileName?: string | null;
expiresInSec: number;
}
export interface SignedStorageUrl {
provider: StorageProviderName;
bucket: string | null;
objectKey: string | null;
method: StorageHttpMethod;
url: string;
headers: Record<string, string>;
expiresAt: string;
expiresInSec: number;
signatureMode: string;
}
const REAL_STORAGE_PROVIDERS = new Set<StorageProviderName>(['aliyun_oss', 'tencent_cos', 'supabase_storage']);
const SUPPORTED_STORAGE_PROVIDERS = new Set<StorageProviderName>([
'external_url',
'supabase_storage',
'aliyun_oss',
'tencent_cos',
'qiniu_kodo',
'local_dev',
]);
const UPLOADABLE_PROVIDERS = new Set<StorageProviderName>(['local_dev', 'supabase_storage', 'aliyun_oss', 'tencent_cos']);
const SAFE_OBJECT_KEY_RE = /^[A-Za-z0-9][A-Za-z0-9._~!$&'()+,;=@/-]{0,1023}$/;
function nowSeconds() {
return Math.floor(Date.now() / 1000);
}
function expiresAt(expiresInSec: number) {
return new Date(Date.now() + expiresInSec * 1000).toISOString();
}
function assertProvider(provider: string): asserts provider is StorageProviderName {
if (!SUPPORTED_STORAGE_PROVIDERS.has(provider as StorageProviderName)) {
throw new HttpError(400, `Unsupported storage provider: ${provider}`, 'UNSUPPORTED_STORAGE_PROVIDER');
}
}
function requireConfigured(condition: unknown, provider: StorageProviderName, missing: string) {
if (!condition) {
throw new HttpError(500, `${provider} is not configured: ${missing}`, 'STORAGE_PROVIDER_NOT_CONFIGURED');
}
}
function canonicalObjectKey(objectKey: string) {
return objectKey.replace(/^\/+/, '').replace(/\/{2,}/g, '/');
}
export function configuredDefaultStorageProvider(): StorageProviderName {
const provider = config.storageDefaultProvider.trim() || 'local_dev';
assertProvider(provider);
return provider;
}
export function configuredDefaultStorageBucket() {
return config.storageDefaultBucket.trim() || 'tenant-assets';
}
export function validateObjectKey(tenantId: string, objectKey: string) {
const clean = canonicalObjectKey(objectKey);
if (!clean || clean.includes('..') || clean.includes('\\') || clean.includes('%2f') || clean.includes('%2F')) {
throw new HttpError(400, 'Invalid objectKey', 'INVALID_OBJECT_KEY');
}
if (!SAFE_OBJECT_KEY_RE.test(clean)) {
throw new HttpError(400, 'objectKey contains unsafe characters', 'INVALID_OBJECT_KEY');
}
if (config.storageRequireTenantPrefix && !clean.startsWith(`${tenantId}/`)) {
throw new HttpError(400, 'objectKey must be scoped by tenantId prefix', 'OBJECT_KEY_TENANT_PREFIX_REQUIRED');
}
return clean;
}
export function validateMimeType(mimeType: string) {
const normalized = mimeType.trim().toLowerCase();
if (!normalized || normalized.length > 160 || normalized.includes('\r') || normalized.includes('\n')) {
throw new HttpError(400, 'Invalid mimeType', 'INVALID_MIME_TYPE');
}
const exact = config.storageAllowedMimeTypes.map(item => item.toLowerCase());
const prefixes = config.storageAllowedMimePrefixes.map(item => item.toLowerCase());
if (!exact.includes(normalized) && !prefixes.some(prefix => normalized.startsWith(prefix))) {
throw new HttpError(400, `mimeType is not allowed: ${normalized}`, 'MIME_TYPE_NOT_ALLOWED');
}
return normalized;
}
export function validateFileSize(fileSizeBytes: number | null) {
if (fileSizeBytes === null) return null;
if (!Number.isSafeInteger(fileSizeBytes) || fileSizeBytes < 0) {
throw new HttpError(400, 'fileSizeBytes must be a non-negative safe integer', 'INVALID_FILE_SIZE');
}
if (fileSizeBytes > config.storageMaxUploadBytes) {
throw new HttpError(400, 'file exceeds STORAGE_MAX_UPLOAD_BYTES', 'FILE_TOO_LARGE');
}
return fileSizeBytes;
}
export function normalizeStorageProvider(value: string | null | undefined, fallback?: StorageProviderName): StorageProviderName {
const provider = (value?.trim() || fallback || configuredDefaultStorageProvider()) as StorageProviderName;
assertProvider(provider);
return provider;
}
export function assertUploadProvider(provider: StorageProviderName) {
if (!UPLOADABLE_PROVIDERS.has(provider)) {
throw new HttpError(400, `${provider} does not support managed upload signing`, 'UPLOAD_PROVIDER_NOT_SUPPORTED');
}
}
export function assertWritableLocation(location: AssetLocation) {
if (REAL_STORAGE_PROVIDERS.has(location.provider) && (!location.bucket || !location.objectKey)) {
throw new HttpError(400, `${location.provider} asset requires bucket and objectKey`, 'ASSET_OBJECT_LOCATION_REQUIRED');
}
}
function localSignedUrl(input: {
provider: StorageProviderName;
bucket: string | null;
objectKey: string | null;
method: StorageHttpMethod;
expiresInSec: number;
headers?: Record<string, string>;
}): SignedStorageUrl {
const expires = expiresAt(input.expiresInSec);
const base = config.storagePublicBaseUrl.replace(/\/+$/, '');
const path = `${encodeURIComponent(input.bucket || 'default')}/${encodeURI(input.objectKey || 'missing')}`;
const url = base
? `${base}/${path}?expiresAt=${encodeURIComponent(expires)}`
: `${input.provider}://${input.bucket || 'default'}/${input.objectKey || 'missing'}?expiresAt=${encodeURIComponent(expires)}`;
return {
provider: input.provider,
bucket: input.bucket,
objectKey: input.objectKey,
method: input.method,
url,
headers: input.headers || {},
expiresAt: expires,
expiresInSec: input.expiresInSec,
signatureMode: 'local-placeholder',
};
}
async function signAliyunOss(input: {
bucket: string;
objectKey: string;
method: StorageHttpMethod;
mimeType?: string;
fileName?: string | null;
expiresInSec: number;
}): Promise<SignedStorageUrl> {
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,
});
const headers: Record<string, string> = {};
const options: Record<string, unknown> = {
expires: input.expiresInSec,
method: input.method,
};
if (input.method === 'PUT' && input.mimeType) {
options['Content-Type'] = input.mimeType;
headers['content-type'] = input.mimeType;
}
if (input.method === 'GET' && input.fileName) {
options.response = {
'content-disposition': `attachment; filename="${encodeURIComponent(input.fileName)}"`,
};
}
const url = client.signatureUrl(input.objectKey, options);
return {
provider: 'aliyun_oss',
bucket: input.bucket,
objectKey: input.objectKey,
method: input.method,
url,
headers,
expiresAt: expiresAt(input.expiresInSec),
expiresInSec: input.expiresInSec,
signatureMode: 'aliyun-oss-signature-url-v1',
};
}
function cosEncodePath(objectKey: string) {
return objectKey
.split('/')
.map(part => encodeURIComponent(part).replace(/[!'()*]/g, char => `%${char.charCodeAt(0).toString(16).toUpperCase()}`))
.join('/');
}
function hmacSha1Hex(key: string | Buffer, value: string) {
return crypto.createHmac('sha1', key).update(value).digest('hex');
}
function sha1Hex(value: string) {
return crypto.createHash('sha1').update(value).digest('hex');
}
function cosHost(bucket: string) {
requireConfigured(config.tencentCosRegion, 'tencent_cos', 'TENCENT_COS_REGION');
const bucketWithAppId = config.tencentCosAppId && !bucket.endsWith(`-${config.tencentCosAppId}`)
? `${bucket}-${config.tencentCosAppId}`
: bucket;
return `${bucketWithAppId}.cos.${config.tencentCosRegion}.myqcloud.com`;
}
function signTencentCos(input: {
bucket: string;
objectKey: string;
method: StorageHttpMethod;
mimeType?: string;
expiresInSec: number;
}): SignedStorageUrl {
requireConfigured(config.tencentCosSecretId, 'tencent_cos', 'TENCENT_COS_SECRET_ID');
requireConfigured(config.tencentCosSecretKey, 'tencent_cos', 'TENCENT_COS_SECRET_KEY');
const host = cosHost(input.bucket);
const start = nowSeconds();
const end = start + input.expiresInSec;
const keyTime = `${start};${end}`;
const method = input.method.toLowerCase();
const pathname = `/${cosEncodePath(input.objectKey)}`;
const signedHeaders: Record<string, string> = { host };
if (input.method === 'PUT' && input.mimeType) signedHeaders['content-type'] = input.mimeType;
const headerKeys = Object.keys(signedHeaders).sort();
const headerList = headerKeys.join(';');
const httpHeaders = headerKeys
.map(key => `${encodeURIComponent(key)}=${encodeURIComponent(signedHeaders[key]).toLowerCase()}`)
.join('&');
const urlParamList = config.tencentCosSecurityToken ? 'x-cos-security-token' : '';
const httpParameters = config.tencentCosSecurityToken
? `x-cos-security-token=${encodeURIComponent(config.tencentCosSecurityToken)}`
: '';
const httpString = `${method}\n${pathname}\n${httpParameters}\n${httpHeaders}\n`;
const stringToSign = `sha1\n${keyTime}\n${sha1Hex(httpString)}\n`;
const signKey = hmacSha1Hex(config.tencentCosSecretKey, keyTime);
const signature = hmacSha1Hex(signKey, stringToSign);
const query = new URLSearchParams();
query.set('q-sign-algorithm', 'sha1');
query.set('q-ak', config.tencentCosSecretId);
query.set('q-sign-time', keyTime);
query.set('q-key-time', keyTime);
query.set('q-header-list', headerList);
query.set('q-url-param-list', urlParamList);
query.set('q-signature', signature);
if (config.tencentCosSecurityToken) query.set('x-cos-security-token', config.tencentCosSecurityToken);
const headers: Record<string, string> = {};
if (input.method === 'PUT' && input.mimeType) headers['content-type'] = input.mimeType;
return {
provider: 'tencent_cos',
bucket: input.bucket,
objectKey: input.objectKey,
method: input.method,
url: `https://${host}${pathname}?${query.toString()}`,
headers,
expiresAt: expiresAt(input.expiresInSec),
expiresInSec: input.expiresInSec,
signatureMode: 'tencent-cos-signature-url-v5',
};
}
function supabaseStorageClient() {
requireConfigured(config.supabaseStorageUrl, 'supabase_storage', 'SUPABASE_STORAGE_URL');
requireConfigured(config.supabaseStorageServiceKey, 'supabase_storage', 'SUPABASE_STORAGE_SERVICE_KEY');
return new StorageClient(config.supabaseStorageUrl.replace(/\/+$/, ''), {
apikey: config.supabaseStorageServiceKey,
authorization: `Bearer ${config.supabaseStorageServiceKey}`,
});
}
async function signSupabaseStorageUpload(input: UploadSignInput): Promise<SignedStorageUrl> {
const response = await supabaseStorageClient().from(input.bucket).createSignedUploadUrl(input.objectKey, {
upsert: input.upsert === true,
});
if (response.error || !response.data) {
throw new HttpError(502, response.error?.message || 'Supabase Storage upload signing failed', 'STORAGE_SIGN_FAILED');
}
return {
provider: 'supabase_storage',
bucket: input.bucket,
objectKey: input.objectKey,
method: 'PUT',
url: response.data.signedUrl,
headers: { 'content-type': input.mimeType },
expiresAt: expiresAt(7200),
expiresInSec: 7200,
signatureMode: 'supabase-storage-signed-upload-url',
};
}
async function signSupabaseStorageDownload(input: DownloadSignInput): Promise<SignedStorageUrl> {
if (!input.bucket || !input.objectKey) {
throw new HttpError(400, 'Supabase Storage asset requires bucket and objectKey', 'ASSET_OBJECT_LOCATION_REQUIRED');
}
const response = await supabaseStorageClient().from(input.bucket).createSignedUrl(input.objectKey, input.expiresInSec, {
download: input.fileName || true,
});
if (response.error || !response.data) {
throw new HttpError(502, response.error?.message || 'Supabase Storage download signing failed', 'STORAGE_SIGN_FAILED');
}
return {
provider: 'supabase_storage',
bucket: input.bucket,
objectKey: input.objectKey,
method: 'GET',
url: response.data.signedUrl,
headers: {},
expiresAt: expiresAt(input.expiresInSec),
expiresInSec: input.expiresInSec,
signatureMode: 'supabase-storage-signed-url',
};
}
export async function signStorageUpload(input: UploadSignInput): Promise<SignedStorageUrl> {
assertUploadProvider(input.provider);
validateObjectKey(input.tenantId, input.objectKey);
const mimeType = validateMimeType(input.mimeType);
validateFileSize(input.fileSizeBytes);
if (input.provider === 'local_dev') {
return localSignedUrl({
provider: input.provider,
bucket: input.bucket,
objectKey: input.objectKey,
method: 'PUT',
expiresInSec: input.expiresInSec,
headers: { 'content-type': mimeType },
});
}
if (input.provider === 'aliyun_oss') {
return signAliyunOss({
bucket: input.bucket,
objectKey: input.objectKey,
method: 'PUT',
mimeType,
expiresInSec: input.expiresInSec,
});
}
if (input.provider === 'tencent_cos') {
return signTencentCos({
bucket: input.bucket,
objectKey: input.objectKey,
method: 'PUT',
mimeType,
expiresInSec: input.expiresInSec,
});
}
return signSupabaseStorageUpload({ ...input, mimeType });
}
export async function signStorageDownload(input: DownloadSignInput): Promise<SignedStorageUrl> {
if (input.cdnUrl) {
return {
provider: input.provider,
bucket: input.bucket,
objectKey: input.objectKey,
method: 'GET',
url: input.cdnUrl,
headers: {},
expiresAt: expiresAt(input.expiresInSec),
expiresInSec: input.expiresInSec,
signatureMode: 'public-or-provider-managed',
};
}
if (!input.objectKey) {
throw new HttpError(400, 'Asset requires objectKey or cdnUrl', 'ASSET_LOCATION_REQUIRED');
}
validateObjectKey(input.tenantId, input.objectKey);
if (input.provider === 'local_dev' || input.provider === 'qiniu_kodo') {
return localSignedUrl({
provider: input.provider,
bucket: input.bucket,
objectKey: input.objectKey,
method: 'GET',
expiresInSec: input.expiresInSec,
});
}
if (input.provider === 'aliyun_oss') {
if (!input.bucket) throw new HttpError(400, 'Aliyun OSS asset requires bucket', 'ASSET_OBJECT_LOCATION_REQUIRED');
return signAliyunOss({
bucket: input.bucket,
objectKey: input.objectKey,
method: 'GET',
fileName: input.fileName,
expiresInSec: input.expiresInSec,
});
}
if (input.provider === 'tencent_cos') {
if (!input.bucket) throw new HttpError(400, 'Tencent COS asset requires bucket', 'ASSET_OBJECT_LOCATION_REQUIRED');
return signTencentCos({
bucket: input.bucket,
objectKey: input.objectKey,
method: 'GET',
expiresInSec: input.expiresInSec,
});
}
if (input.provider === 'supabase_storage') {
return signSupabaseStorageDownload(input);
}
throw new HttpError(400, `${input.provider} asset requires cdnUrl`, 'ASSET_LOCATION_REQUIRED');
}

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

24
apps/api/src/types/ali-oss.d.ts vendored Normal file
View File

@@ -0,0 +1,24 @@
declare module 'ali-oss' {
interface ClientOptions {
region?: string;
endpoint?: string;
accessKeyId: string;
accessKeySecret: string;
stsToken?: string;
bucket?: string;
internal?: boolean;
secure?: boolean;
}
interface SignatureUrlOptions {
expires?: number;
method?: string;
response?: Record<string, string>;
[key: string]: unknown;
}
export default class OSS {
constructor(options: ClientOptions);
signatureUrl(name: string, options?: SignatureUrlOptions, strictObjectNameValidation?: boolean): string;
}
}