forked from wangziqi/gongxue-base
feat: add object storage signing providers
This commit is contained in:
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
452
apps/api/src/features/storage/service.ts
Normal file
452
apps/api/src/features/storage/service.ts
Normal 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');
|
||||
}
|
||||
@@ -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
24
apps/api/src/types/ali-oss.d.ts
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user