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

@@ -24,7 +24,7 @@
- 正式 Supabase Auth/JWT 鉴权还没替换迁移期请求头。
- 真实短信、微信登录、QQ 登录、微信支付、支付宝等 provider adapter 还没接完。
- 真实 OSS/COS/Supabase Storage 上传下载签名还没接完
- OSS/COS/Supabase Storage 上传下载签名 provider 已接入上传后校验、PDF 预览、防盗链和视频水印还没完成
- Excel/CSV 导入、分数线/视频批量导入和异步 worker 还没完成。
- Taro 跨端前端还没开始 scaffold。
@@ -34,6 +34,7 @@
- `docs/refactor/backend-progress.md`
- `docs/refactor/backend-handoff-roadmap.md`
- `docs/refactor/content-import-contract.md`
- `docs/refactor/object-storage.md`
- `docs/refactor/next-development-todo.md`
- `docs/refactor/blueprint-coverage.md`
- `docs/refactor/api-structure.md`
@@ -162,7 +163,7 @@ npm run check:refactor
优先继续补:
1. 真实对象存储 adapter阿里云 OSS、腾讯云 COS 或 Supabase Storage
1. 对象存储上传后校验、PDF 预览、防盗链和视频水印
2. Excel/CSV 以及分数线、视频批量导入;把现有 JSON 导入升级为可排队异步执行。
3. Supabase Auth/JWT 正式鉴权和生产 RLS 验证。
4. 微信/QQ 登录、短信、微信支付、支付宝支付 adapter。

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

View File

@@ -28,7 +28,7 @@
| 知识手册 | 可联调 | 科目、章节、条目、Markdown 内容、嵌套 JSON 导入 | 富文本资源、版本管理、附件/PDF 关联 |
| 分数线 | 可联调 | 院校、专业、动态字段、记录、年份、趋势、后台维护 | 批量导入、复杂筛选、AI 择校上下文 |
| 视频解析 | 部分完成 | 单题视频、批量查询、后台视频绑定 | 会员播放权限、播放次数扣减、签名 URL、防盗链、水印 |
| 资料下载 | 部分完成 | 资源台账、SVIP 权限校验、上传/下载签名占位 | 阿里云 OSS/腾讯 COS/Supabase Storage 真实签名、PDF 预览 |
| 资料下载 | 部分完成 | 资源台账、SVIP 权限校验、`local_dev`/阿里云 OSS/腾讯 COS/Supabase Storage 上传下载签名 | 上传后对象校验、PDF 预览、防盗链、视频水印 |
| 会员与订单 | 基础完成 | 下单、手动确认、激活码兑换、权益发放 | 微信支付、支付宝、退款、webhook 验签和幂等 |
| 登录认证 | 迁移期可用 | 短信 mock、迁移期 session、OAuth 配置表 | 阿里云/腾讯云短信、微信小程序/网页登录、QQ 登录、Supabase Auth |
| 销售/代理/CRM | 基础完成 | 邀请码、首绑保护、团队关系、销售统计、CRM 入队 | 小程序码真实生成、分佣结算、钉钉/飞书/企微 worker |
@@ -76,7 +76,7 @@
### P0上云测试和前端主链路前必须处理
- 生产鉴权:用 Supabase Auth/JWT 或服务端 session 替换 `x-tenant-id``x-user-id``x-platform-admin-key`
- 对象存储:接入阿里云 OSS、腾讯云 COSSupabase Storage完成上传、下载、PDF 预览、视频播放签名。
- 对象存储:上传/下载签名已接入阿里云 OSS、腾讯云 COSSupabase Storage;继续完成上传后对象校验、PDF 预览、视频播放签名、防盗链和水印
- 真实数据 dry-run导出 PocketBase 用户、题库、单词、知识手册、分数线、订单、权益,跑迁移和校验报告。
- 生产环境配置:补 `.env` 模板、数据库迁移流程、备份恢复、日志、告警和 API 容器部署说明。
- Taro scaffold建立 `apps/taro`,先完成租户解析、首页、题库、背单词、知识手册、个人中心主链路。

View File

@@ -175,13 +175,13 @@ GET /api/tenant-admin/audit-logs
- 当前默认角色:`tenant_owner`/`tenant_admin` 全权限,`tenant_operator` 可维护内容和活动,`teacher` 可维护内容,`sales` 可维护激活码和优惠券,`agent` 只读部分兑换码/优惠券。
- 销售/代理客资采用首绑保护:普通扫码/分享事件不会覆盖已有归属,只有具备 `referral:write` 的租户成员可手动强制补绑。
- CRM 当前完成配置、密钥入私密表、客资入队和队列查询;真实 webhook 发送、重试、签名在后续 `apps/worker` 中实现。
- 内容资源当前完成台账、租户后台维护、上传/下载签名占位和学生端 SVIP 下载权限真实对象存储签名、PDF 预览渲染防盗链在 provider/worker 中实现
- 内容资源当前完成台账、租户后台维护、学生端 SVIP 下载权限,以及 `local_dev`、阿里云 OSS、腾讯 COS、Supabase Storage 的上传/下载签名 provider。真实对象存在性校验、PDF 预览渲染防盗链、水印和大文件上传后 worker 校验仍需继续补
- 题库内容导航当前以 `content_entries/content_nodes` 为主模型,可表达“入口 -> 多级分类 -> 院校/专业/学科/销售意向标记”;题目集合和练习方式由 `question_collections/practice_blueprints` 管理,练习 session 会保存当次题目 ID 快照。
- 批量导入当前支持题目、单词、知识手册 JSON 预览、逐行 issue、job/item 台账、执行导入、幂等跳过,并可落到新内容入口和分类节点。旧单词模板的 `vocabulary_units_示例数据` / `vocabulary_示例数据`、知识手册的书籍/章节/小节/知识点嵌套结构都由后端规范化。Excel/CSV、分数线/视频导入会继续复用同一套 `content_import_jobs` 管线。
## 下一步
1. 完善内容导入和文件上传Excel/CSV、分数线、视频导入真实 OSS/COS/Supabase Storage 签名
1. 完善内容导入和文件上传Excel/CSV、分数线、视频导入对象存储上传后校验、PDF 预览、防盗链和视频水印
2. 接入真实短信 provider阿里云/腾讯云,密钥放 `app_private.tenant_secrets` 或生产 Vault。
3. 接入真实 OAuth provider微信网页、微信小程序、QQ并处理旧 PocketBase 身份映射。
4. 增加真实支付 providerXPay、微信支付、支付宝并完善 webhook 幂等。

View File

@@ -31,8 +31,8 @@
- 做一轮真实 JWT + RLS 回归测试。
2. 对象存储
- 接阿里云 OSS、腾讯云 COSSupabase Storage。
- 完成上传签名、下载签名、PDF 预览地址、视频播放签名。
- 接阿里云 OSS、腾讯云 COSSupabase Storage 的上传/下载签名 provider
- 继续补上传后对象存在性校验、PDF 预览地址、视频播放签名、防盗链、水印和 worker 校验
- `content_assets` 继续作为资源台账,不允许前端绕过台账直接访问私有资源。
3. 真实导入 dry-run
@@ -167,7 +167,7 @@
## 推荐下一步顺序
1. 先把当前后端代码推到 Gitea。
2. 云服务器部署 Supabase/PostgreSQL 和 API`check:refactor` 的远程等价测试。
2. 云服务器部署 Supabase/PostgreSQL 和 API配置对象存储生产环境变量,`check:refactor` 的远程等价测试。
3. 导出现有 PocketBase 数据,做完整 dry-run 迁移。
4. 开始 `apps/taro`,先接租户解析、首页、题库、背单词、知识手册。
5. 并行补对象存储、真实登录、支付 adapter。

View File

@@ -0,0 +1,108 @@
# 对象存储接入说明
更新时间2026-06-22
## 目标
题库里的图片、PDF、视频、音频、资料包等媒体资源统一走 `content_assets` 台账和后端签名接口。前端不直接保存或读取云厂商密钥,也不直接拼接私有资源 URL。
已接入的 provider
- `local_dev`:本地开发占位签名,便于前后端联调。
- `aliyun_oss`:阿里云 OSS 官方 Node.js SDK `signatureUrl`
- `tencent_cos`:腾讯云 COS XML API V5 签名 URL服务端用 Node `crypto` 实现,避免引入当前 COS Node SDK 的高危依赖。
- `supabase_storage`Supabase Storage 官方 `createSignedUploadUrl` / `createSignedUrl` 语义。
- `external_url`:外部公开或厂商托管 URL只允许作为已管理资源的下载地址不支持后端直传签名。
## API
后台申请上传签名:
```text
POST /api/tenant-content/assets/sign-upload
```
后台登记资源:
```text
PUT /api/tenant-content/assets
```
学生端下载:
```text
GET /api/catalog/assets/download?assetId=...
```
后台管理员下载:
```text
POST /api/tenant-content/assets/sign-download
```
## 安全规则
- 只有租户内容维护权限用户可以申请上传签名。
- `objectKey` 默认必须以当前 `tenantId/` 开头,防止跨租户覆盖或读取。
- 禁止 `..`、反斜杠、编码斜杠等危险 object key。
- 上传会校验 MIME 类型和文件大小。
- 下载必须先经过 API 权限判断,再下发短期签名 URL。
- 云厂商 AccessKey、SecretKey、Service Role Key 只存在服务端环境变量,不返回前端。
- `content_assets` 是资源唯一台账,前端不得绕过台账直接访问私有 bucket。
## 环境变量
通用:
```text
STORAGE_DEFAULT_PROVIDER=local_dev
STORAGE_DEFAULT_BUCKET=tenant-assets
STORAGE_PUBLIC_BASE_URL=
STORAGE_MAX_UPLOAD_BYTES=524288000
STORAGE_ALLOWED_MIME_PREFIXES=image/,video/,audio/
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
STORAGE_REQUIRE_TENANT_PREFIX=true
```
阿里云 OSS
```text
ALIYUN_OSS_REGION=oss-cn-hangzhou
ALIYUN_OSS_ENDPOINT=
ALIYUN_OSS_ACCESS_KEY_ID=
ALIYUN_OSS_ACCESS_KEY_SECRET=
ALIYUN_OSS_STS_TOKEN=
ALIYUN_OSS_INTERNAL=false
```
腾讯云 COS
```text
TENCENT_COS_REGION=ap-shanghai
TENCENT_COS_APP_ID=
TENCENT_COS_SECRET_ID=
TENCENT_COS_SECRET_KEY=
TENCENT_COS_SECURITY_TOKEN=
```
Supabase Storage
```text
SUPABASE_STORAGE_URL=https://your-project.supabase.co/storage/v1
SUPABASE_STORAGE_SERVICE_KEY=
```
## 生产建议
- 阿里云和腾讯云生产环境优先用 STS/临时密钥或 RAM/CAM 最小权限账号。
- bucket 默认私有,公开资源也建议先经过 CDN/防盗链策略,不让前端直接持有写权限。
- 图片、PDF、视频分别设置合理的 CORS只允许前端域名和小程序业务域名访问。
- 开启对象版本控制、生命周期、跨区域复制或定时备份,满足后续容灾要求。
- 视频资源建议后续接入转码、水印、防盗链、播放日志和播放次数扣减。
- 大文件上传后应由 worker 校验对象是否真实存在、大小/hash 是否匹配,再把资源状态从 `draft` 发布为 `active`
## 官方依据
- 阿里云 OSS Node.js SDK 支持通过 `signatureUrl` 为上传或下载生成带过期时间的签名 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。
- Supabase Storage 提供 `createSignedUploadUrl``createSignedUrl`,分别用于签名上传和签名下载;私有 bucket 仍应配合 RLS 和服务端权限控制。

804
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,6 +11,7 @@ const TENANT_OPERATOR_USER_ID = '00000000-0000-0000-0000-000000000103';
const TENANT_SALES_USER_ID = '00000000-0000-0000-0000-000000000104';
const TENANT_AGENT_USER_ID = '00000000-0000-0000-0000-000000000105';
const START_SERVER = process.argv.includes('--start-server');
const ENABLE_REAL_STORAGE_SIGN_TESTS = process.env.ENABLE_REAL_STORAGE_SIGN_TESTS === 'true';
const ids = {
region: '00000000-0000-0000-0000-000000000301',
@@ -633,6 +634,81 @@ async function testTenantContentAssetsAndImports() {
});
assert.equal(upload.assetDraft?.assetType, 'pdf', 'upload signer should return asset draft');
assert.ok(upload.upload?.objectKey?.includes(MAIN_TENANT_ID), 'upload object key should be tenant scoped');
assert.equal(upload.upload?.signatureMode, 'local-placeholder', 'local signer should be explicit in test env');
assert.equal(upload.upload?.headers?.['content-type'], 'application/pdf', 'upload signer should bind content-type');
assert.ok(!JSON.stringify(upload).includes('ACCESS_KEY'), 'upload signer must not leak cloud credentials');
if (ENABLE_REAL_STORAGE_SIGN_TESTS) {
const tencentUpload = await request('/api/tenant-content/assets/sign-upload', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
fileName: 'cos-resource.pdf',
assetType: 'pdf',
storageProvider: 'tencent_cos',
bucket: process.env.TEST_TENCENT_COS_BUCKET || 'tenant-assets',
objectKey: `${MAIN_TENANT_ID}/pdf/cos-resource.pdf`,
mimeType: 'application/pdf',
},
});
assert.ok(tencentUpload.upload?.url?.includes('q-sign-algorithm=sha1'), 'Tencent COS signed URL should expose XML V5 query params');
assert.ok(!tencentUpload.upload?.url?.includes('SecretKey'), 'Tencent COS signed URL must not leak SecretKey');
}
const unsafeUpload = await request('/api/tenant-content/assets/sign-upload', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
fileName: 'unsafe.pdf',
assetType: 'pdf',
storageProvider: 'local_dev',
objectKey: '../escape.pdf',
mimeType: 'application/pdf',
},
expectStatus: 400,
});
assert.equal(unsafeUpload.code, 'INVALID_OBJECT_KEY', 'upload signer should reject path traversal object keys');
const crossTenantUpload = await request('/api/tenant-content/assets/sign-upload', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
fileName: 'cross-tenant.pdf',
assetType: 'pdf',
storageProvider: 'local_dev',
objectKey: `${PARTNER_TENANT_ID}/pdf/cross-tenant.pdf`,
mimeType: 'application/pdf',
},
expectStatus: 400,
});
assert.equal(crossTenantUpload.code, 'OBJECT_KEY_TENANT_PREFIX_REQUIRED', 'upload object key should stay in current tenant prefix');
const deniedMimeUpload = await request('/api/tenant-content/assets/sign-upload', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
fileName: 'script.html',
assetType: 'document',
storageProvider: 'local_dev',
mimeType: 'text/html',
},
expectStatus: 400,
});
assert.equal(deniedMimeUpload.code, 'MIME_TYPE_NOT_ALLOWED', 'upload signer should reject unsupported mime types');
const deniedLargeUpload = await request('/api/tenant-content/assets/sign-upload', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
fileName: 'huge-video.mp4',
assetType: 'video',
storageProvider: 'local_dev',
mimeType: 'video/mp4',
fileSizeBytes: 1024 * 1024 * 1024,
},
expectStatus: 400,
});
assert.equal(deniedLargeUpload.code, 'FILE_TOO_LARGE', 'upload signer should enforce max upload size');
const asset = await request('/api/tenant-content/assets', {
userId: TENANT_ADMIN_USER_ID,
@@ -679,6 +755,50 @@ async function testTenantContentAssetsAndImports() {
assert.equal(download.item?.id, asset.item.id, 'svip student should receive asset download');
assert.equal(download.access?.svip, true, 'asset download should report svip access');
assert.equal(download.download?.url, 'https://example.test/resources/integration.pdf', 'external asset download should use cdn url');
assert.equal(download.download?.signatureMode, 'public-or-provider-managed', 'external asset should be marked provider managed');
const localAsset = 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: upload.assetDraft.objectKey,
fileName: upload.assetDraft.fileName,
mimeType: upload.assetDraft.mimeType,
fileSizeBytes: upload.assetDraft.fileSizeBytes,
visibility: 'tenant',
status: 'active',
},
});
assert.equal(localAsset.item?.objectKey, upload.assetDraft.objectKey, 'asset upsert should keep normalized object key');
const localDownload = await request('/api/catalog/assets/download', {
query: { assetId: localAsset.item.id },
});
assert.equal(localDownload.download?.method, 'GET', 'object asset download should sign GET');
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');
const invalidObjectAsset = await request('/api/tenant-content/assets', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
body: {
title: '非法对象键',
assetType: 'pdf',
storageProvider: 'local_dev',
bucket: 'tenant-assets',
objectKey: `${PARTNER_TENANT_ID}/pdf/unsafe.pdf`,
fileName: 'unsafe.pdf',
mimeType: 'application/pdf',
visibility: 'tenant',
status: 'active',
},
expectStatus: 400,
});
assert.equal(invalidObjectAsset.code, 'OBJECT_KEY_TENANT_PREFIX_REQUIRED', 'asset upsert should reject cross-tenant object keys');
const partnerAssetList = await request('/api/tenant-content/assets', {
tenantId: PARTNER_TENANT_ID,