diff --git a/README.md b/README.md index 63375104..1aa41e1f 100644 --- a/README.md +++ b/README.md @@ -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。 diff --git a/apps/api/package.json b/apps/api/package.json index a5fb6c5c..a57b5cbc 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -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": { diff --git a/apps/api/src/core/config.ts b/apps/api/src/core/config.ts index 48b526ff..f55447b5 100644 --- a/apps/api/src/core/config.ts +++ b/apps/api/src/core/config.ts @@ -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, }; diff --git a/apps/api/src/features/catalog/assets.ts b/apps/api/src/features/catalog/assets.ts index 9a4e26ce..127a9a8f 100644 --- a/apps/api/src/features/catalog/assets.ts +++ b/apps/api/src/features/catalog/assets.ts @@ -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, + }), }; } diff --git a/apps/api/src/features/storage/service.ts b/apps/api/src/features/storage/service.ts new file mode 100644 index 00000000..fd669bbd --- /dev/null +++ b/apps/api/src/features/storage/service.ts @@ -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; + expiresAt: string; + expiresInSec: number; + signatureMode: string; +} + +const REAL_STORAGE_PROVIDERS = new Set(['aliyun_oss', 'tencent_cos', 'supabase_storage']); +const SUPPORTED_STORAGE_PROVIDERS = new Set([ + 'external_url', + 'supabase_storage', + 'aliyun_oss', + 'tencent_cos', + 'qiniu_kodo', + 'local_dev', +]); +const UPLOADABLE_PROVIDERS = new Set(['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; +}): 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 { + 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 = {}; + const options: Record = { + 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 = { 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 = {}; + 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 { + 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 { + 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 { + 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 { + 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'); +} diff --git a/apps/api/src/features/tenant-content/assets.ts b/apps/api/src/features/tenant-content/assets.ts index 43f70c48..acd5f6d5 100644 --- a/apps/api/src/features/tenant-content/assets.ts +++ b/apps/api/src/features/tenant-content/assets.ts @@ -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, + }), }; } diff --git a/apps/api/src/types/ali-oss.d.ts b/apps/api/src/types/ali-oss.d.ts new file mode 100644 index 00000000..6d3b8ff4 --- /dev/null +++ b/apps/api/src/types/ali-oss.d.ts @@ -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; + [key: string]: unknown; + } + + export default class OSS { + constructor(options: ClientOptions); + signatureUrl(name: string, options?: SignatureUrlOptions, strictObjectNameValidation?: boolean): string; + } +} diff --git a/docs/refactor/backend-handoff-roadmap.md b/docs/refactor/backend-handoff-roadmap.md index cf897d1c..9768d063 100644 --- a/docs/refactor/backend-handoff-roadmap.md +++ b/docs/refactor/backend-handoff-roadmap.md @@ -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、腾讯云 COS 或 Supabase Storage,完成上传、下载、PDF 预览、视频播放签名。 +- 对象存储:上传/下载签名已接入阿里云 OSS、腾讯云 COS、Supabase Storage;继续完成上传后对象校验、PDF 预览、视频播放签名、防盗链和水印。 - 真实数据 dry-run:导出 PocketBase 用户、题库、单词、知识手册、分数线、订单、权益,跑迁移和校验报告。 - 生产环境配置:补 `.env` 模板、数据库迁移流程、备份恢复、日志、告警和 API 容器部署说明。 - Taro scaffold:建立 `apps/taro`,先完成租户解析、首页、题库、背单词、知识手册、个人中心主链路。 diff --git a/docs/refactor/backend-progress.md b/docs/refactor/backend-progress.md index 68a66f85..1bdc3de8 100644 --- a/docs/refactor/backend-progress.md +++ b/docs/refactor/backend-progress.md @@ -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. 增加真实支付 provider:XPay、微信支付、支付宝,并完善 webhook 幂等。 diff --git a/docs/refactor/next-development-todo.md b/docs/refactor/next-development-todo.md index c914f1a4..67da30d3 100644 --- a/docs/refactor/next-development-todo.md +++ b/docs/refactor/next-development-todo.md @@ -31,8 +31,8 @@ - 做一轮真实 JWT + RLS 回归测试。 2. 对象存储 - - 接阿里云 OSS、腾讯云 COS 或 Supabase Storage。 - - 完成上传签名、下载签名、PDF 预览地址、视频播放签名。 + - 已接阿里云 OSS、腾讯云 COS、Supabase 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。 diff --git a/docs/refactor/object-storage.md b/docs/refactor/object-storage.md new file mode 100644 index 00000000..93180e33 --- /dev/null +++ b/docs/refactor/object-storage.md @@ -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 和服务端权限控制。 diff --git a/package-lock.json b/package-lock.json index 7dd66cfb..fca60970 100644 --- a/package-lock.json +++ b/package-lock.json @@ -54,6 +54,8 @@ "name": "@tiku-saas/api", "version": "0.1.0", "dependencies": { + "@supabase/storage-js": "^2.108.2", + "ali-oss": "^6.23.0", "pg": "^8.16.3" }, "devDependencies": { @@ -1957,6 +1959,25 @@ "win32" ] }, + "node_modules/@supabase/storage-js": { + "version": "2.108.2", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.108.2.tgz", + "integrity": "sha512-TVZPQxXGxY2+A6yTtm77zUHsh70lBhYUEaJL8RQC+BghcX/ygiMG/rmXrNVBce30/WAeNPa8FiG8HbqlGeV05g==", + "license": "MIT", + "dependencies": { + "iceberg-js": "^0.8.1", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/storage-js/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/@tiku-saas/api": { "resolved": "apps/api", "link": true @@ -2169,6 +2190,15 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/address": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/address/-/address-1.2.2.tgz", + "integrity": "sha512-4B/qKCfeE/ODUaAUpSwfzazo5x29WD4r3vXiWsB7I2mSDAihwEqKO+g8GELZUQSSAo5e1XTYh3ZVfLyxBc12nA==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/adler-32": { "version": "1.3.1", "resolved": "https://registry.npmmirror.com/adler-32/-/adler-32-1.3.1.tgz", @@ -2187,11 +2217,58 @@ "node": ">= 14" } }, + "node_modules/agentkeepalive": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-3.5.3.tgz", + "integrity": "sha512-yqXL+k5rr8+ZRpOAntkaaRgWgE5o8ESAj5DyRmVTCSoZxXmqemb9Dd7T4i5UzwuERdLAJUy6XzR9zFVuf0kzkw==", + "license": "MIT", + "dependencies": { + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/ali-oss": { + "version": "6.23.0", + "resolved": "https://registry.npmjs.org/ali-oss/-/ali-oss-6.23.0.tgz", + "integrity": "sha512-FipRmyd16Pr/tEey/YaaQ/24Pc3HEpLM9S1DRakEuXlSLXNIJnu1oJtHM53eVYpvW3dXapSjrip3xylZUTIZVQ==", + "license": "MIT", + "dependencies": { + "address": "^1.2.2", + "agentkeepalive": "^3.4.1", + "bowser": "^1.6.0", + "copy-to": "^2.0.1", + "dateformat": "^2.0.0", + "debug": "^4.3.4", + "destroy": "^1.0.4", + "end-or-error": "^1.0.1", + "get-ready": "^1.0.0", + "humanize-ms": "^1.2.0", + "is-type-of": "^1.4.0", + "js-base64": "^2.5.2", + "jstoxml": "^2.0.0", + "lodash": "^4.17.21", + "merge-descriptors": "^1.0.1", + "mime": "^2.4.5", + "platform": "^1.3.1", + "pump": "^3.0.0", + "qs": "^6.4.0", + "sdk-base": "^2.0.1", + "stream-http": "2.8.2", + "stream-wormhole": "^1.0.4", + "urllib": "^2.44.0", + "utility": "^1.18.0", + "xml2js": "^0.6.2" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/any-promise": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", - "dev": true, "license": "MIT" }, "node_modules/anymatch": { @@ -2307,6 +2384,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bowser": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-1.9.4.tgz", + "integrity": "sha512-9IdMmj2KjigRq6oWhmwv1W36pDuA4STQZ8q6YO9um+x07xgYNCD3Oou+WP/3L1HNz7iqythGet3/p4wvc8AAwQ==", + "license": "MIT" + }, "node_modules/braces": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", @@ -2361,6 +2444,41 @@ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", "license": "BSD-3-Clause" }, + "node_modules/builtin-status-codes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/builtin-status-codes/-/builtin-status-codes-3.0.0.tgz", + "integrity": "sha512-HpGFw18DgFWlncDfjTa2rcQ4W88O1mC8e8yZ2AvQY5KDaktSTwo+KRf6nHK6FRI5FyRyb/5T6+TSxfP7QyGsmQ==", + "license": "MIT" + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/camelcase-css": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", @@ -2486,6 +2604,15 @@ "node": ">= 6" } }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -2493,6 +2620,12 @@ "dev": true, "license": "MIT" }, + "node_modules/copy-to": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/copy-to/-/copy-to-2.0.1.tgz", + "integrity": "sha512-3DdaFaU/Zf1AnpLiFDeNCD4TOWe3Zl2RZaTzUvWiIk5ERzcCodOE20Vqq4fzCbNoHURFHT4/us/Lfq+S2zyY4w==", + "license": "MIT" + }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", @@ -2666,6 +2799,15 @@ "node": ">= 12" } }, + "node_modules/dateformat": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-2.2.0.tgz", + "integrity": "sha512-GODcnWq3YGoTnygPfi02ygEiRxqUxpJwuRHjdhJYuxpcZmDq4rjBiXYmbCCzStxo176ixfLT6i4NPwQooRySnw==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -2689,6 +2831,28 @@ "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", "license": "MIT" }, + "node_modules/default-user-agent": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/default-user-agent/-/default-user-agent-1.0.0.tgz", + "integrity": "sha512-bDF7bg6OSNcSwFWPu4zYKpVkJZQYVrAANMYB8bc9Szem1D0yKdm4sa/rOCs2aC9+2GMqQ7KnwtZRvDhmLF0dXw==", + "license": "MIT", + "dependencies": { + "os-name": "~1.0.3" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -2696,6 +2860,15 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/digest-header": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/digest-header/-/digest-header-1.1.0.tgz", + "integrity": "sha512-glXVh42vz40yZb9Cq2oMOt70FIoWiv+vxNvdKdU8CwjLad25qHM3trLxhl9bVjdr6WaslIXhWpn0NO8T/67Qjg==", + "license": "MIT", + "engines": { + "node": ">= 8.0.0" + } + }, "node_modules/dlv": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", @@ -2748,6 +2921,20 @@ "csstype": "^3.0.2" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/ecdsa-sig-formatter": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", @@ -2757,6 +2944,12 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, "node_modules/electron-to-chromium": { "version": "1.5.336", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.336.tgz", @@ -2764,16 +2957,54 @@ "dev": true, "license": "ISC" }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/end-or-error": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/end-or-error/-/end-or-error-1.0.1.tgz", + "integrity": "sha512-OclLMSug+k2A0JKuf494im25ANRBVW8qsjmwbgX7lQ8P82H21PQ1PWkoYwb9y5yMBS69BPlwtzdIFClo3+7kOQ==", + "license": "MIT", + "engines": { + "node": ">= 0.11.14" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-errors": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" } }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", @@ -2823,6 +3054,12 @@ "node": ">=6" } }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, "node_modules/eventemitter3": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", @@ -2835,6 +3072,18 @@ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "license": "MIT" }, + "node_modules/extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", + "license": "MIT", + "dependencies": { + "is-extendable": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/fast-equals": { "version": "5.4.0", "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz", @@ -2932,6 +3181,18 @@ "node": ">=12.20.0" } }, + "node_modules/formstream": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/formstream/-/formstream-1.5.2.tgz", + "integrity": "sha512-NASf0lgxC1AyKNXQIrXTEYkiX99LhCEXTkiGObXAkpBui86a4u8FjH1o2bGb3PpqI3kafC+yw4zWeK6l6VHTgg==", + "license": "MIT", + "dependencies": { + "destroy": "^1.0.4", + "mime": "^2.5.2", + "node-hex": "^1.0.1", + "pause-stream": "~0.0.11" + } + }, "node_modules/frac": { "version": "1.1.2", "resolved": "https://registry.npmmirror.com/frac/-/frac-1.1.2.tgz", @@ -2974,7 +3235,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -3018,6 +3278,49 @@ "node": ">=6.9.0" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-ready": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/get-ready/-/get-ready-1.0.0.tgz", + "integrity": "sha512-mFXCZPJIlcYcth+N8267+mghfYN9h3EhsDa6JSnbA3Wrhh/XFpuowviFcsDeYZtKspQyWyJqfs4O6P8CHeTwzw==", + "license": "MIT" + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -3057,6 +3360,30 @@ "node": ">=14" } }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hash.js": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", @@ -3071,7 +3398,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -3099,6 +3425,36 @@ "node": ">= 14" } }, + "node_modules/humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.0.0" + } + }, + "node_modules/iceberg-js": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", + "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/immediate": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", @@ -3133,6 +3489,12 @@ "node": ">=8" } }, + "node_modules/is-class-hotfix": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/is-class-hotfix/-/is-class-hotfix-0.0.6.tgz", + "integrity": "sha512-0n+pzCC6ICtVr/WXnN2f03TK/3BfXY7me4cjCAqT8TYXEl0+JBRoqBo94JJHXcyDSLUeWbNX8Fvy5g5RJdAstQ==", + "license": "MIT" + }, "node_modules/is-core-module": { "version": "2.16.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", @@ -3149,6 +3511,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -3182,12 +3553,29 @@ "node": ">=0.12.0" } }, + "node_modules/is-type-of": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/is-type-of/-/is-type-of-1.4.0.tgz", + "integrity": "sha512-EddYllaovi5ysMLMEN7yzHEKh8A850cZ7pykrY1aNRQGn/CDjRDE9qEWbIdt7xGEVJmjBXzU/fNnC4ABTm8tEQ==", + "license": "MIT", + "dependencies": { + "core-util-is": "^1.0.2", + "is-class-hotfix": "~0.0.6", + "isstream": "~0.1.2" + } + }, "node_modules/isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", "license": "MIT" }, + "node_modules/isstream": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", + "integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==", + "license": "MIT" + }, "node_modules/jiti": { "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", @@ -3199,6 +3587,12 @@ "jiti": "bin/jiti.js" } }, + "node_modules/js-base64": { + "version": "2.6.4", + "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-2.6.4.tgz", + "integrity": "sha512-pZe//GGmwJndub7ZghVHz7vjb2LgC1m8B07Au3eYqeqv9emhESByMXxaEgkUkEqJe87oBbSniGYoQNIBklc7IQ==", + "license": "BSD-3-Clause" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -3240,6 +3634,12 @@ "node": ">=6" } }, + "node_modules/jstoxml": { + "version": "2.2.9", + "resolved": "https://registry.npmjs.org/jstoxml/-/jstoxml-2.2.9.tgz", + "integrity": "sha512-OYWlK0j+roh+eyaMROlNbS5cd5R25Y+IUpdl7cNdB8HNrkgwQzIS7L9MegxOiWNBj9dQhA/yAxiMwCC5mwNoBw==", + "license": "MIT" + }, "node_modules/jszip": { "version": "3.10.1", "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", @@ -3370,6 +3770,24 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -3394,12 +3812,45 @@ "node": ">=8.6" } }, + "node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/minimalistic-assert": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", "license": "ISC" }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -3410,7 +3861,6 @@ "version": "2.7.0", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", - "dev": true, "license": "MIT", "dependencies": { "any-promise": "^1.0.0", @@ -3475,6 +3925,15 @@ "url": "https://opencollective.com/node-fetch" } }, + "node_modules/node-hex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/node-hex/-/node-hex-1.0.1.tgz", + "integrity": "sha512-iwpZdvW6Umz12ICmu9IYPRxg0tOLGmU3Tq2tKetejCj3oZd7b2nUXwP3a7QA5M9glWy8wlPS1G3RwM/CdsUbdQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/node-readable-to-web-readable-stream": { "version": "0.4.2", "resolved": "https://registry.npmjs.org/node-readable-to-web-readable-stream/-/node-readable-to-web-readable-stream-0.4.2.tgz", @@ -3518,6 +3977,58 @@ "node": ">= 6" } }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/os-name": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/os-name/-/os-name-1.0.3.tgz", + "integrity": "sha512-f5estLO2KN8vgtTRaILIgEGBoBrMnZ3JQ7W9TMZCnOIGwHe8TRGSpcagnWDo+Dfhd/z08k9Xe75hvciJJ8Qaew==", + "license": "MIT", + "dependencies": { + "osx-release": "^1.0.0", + "win-release": "^1.0.0" + }, + "bin": { + "os-name": "cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/osx-release": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/osx-release/-/osx-release-1.1.0.tgz", + "integrity": "sha512-ixCMMwnVxyHFQLQnINhmIpWqXIfS2YOXchwQrk+OFzmo6nDjQ0E4KXAyyUh0T0MZgV4bUhkRrAbVqlE4yLVq4A==", + "license": "MIT", + "dependencies": { + "minimist": "^1.1.0" + }, + "bin": { + "osx-release": "cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/p-retry": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", @@ -3544,6 +4055,18 @@ "dev": true, "license": "MIT" }, + "node_modules/pause-stream": { + "version": "0.0.11", + "resolved": "https://registry.npmjs.org/pause-stream/-/pause-stream-0.0.11.tgz", + "integrity": "sha512-e3FBlXLmN/D1S+zHzanP4E/4Z60oFAa3O051qt1pxa7DEJWKAyil6upYVXCWadEnuoqa4Pkc9oUx9zsxYeRv8A==", + "license": [ + "MIT", + "Apache2" + ], + "dependencies": { + "through": "~2.3" + } + }, "node_modules/pdf-lib": { "version": "1.17.1", "resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz", @@ -3705,6 +4228,12 @@ "node": ">= 6" } }, + "node_modules/platform": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/platform/-/platform-1.3.6.tgz", + "integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==", + "license": "MIT" + }, "node_modules/pocketbase": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/pocketbase/-/pocketbase-0.21.5.tgz", @@ -3961,6 +4490,16 @@ "node": ">=12.0.0" } }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/qrcode.react": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", @@ -3970,6 +4509,21 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/qs": { + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -4313,6 +4867,12 @@ ], "license": "MIT" }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, "node_modules/sax": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", @@ -4331,6 +4891,15 @@ "loose-envify": "^1.1.0" } }, + "node_modules/sdk-base": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/sdk-base/-/sdk-base-2.0.1.tgz", + "integrity": "sha512-eeG26wRwhtwYuKGCDM3LixCaxY27Pa/5lK4rLKhQa7HBjJ3U3Y+f81MMZQRsDw/8SC2Dao/83yJTXJ8aULuN8Q==", + "license": "MIT", + "dependencies": { + "get-ready": "~1.0.0" + } + }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -4347,6 +4916,78 @@ "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", "license": "MIT" }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -4378,6 +5019,37 @@ "node": ">=0.8" } }, + "node_modules/statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/stream-http": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/stream-http/-/stream-http-2.8.2.tgz", + "integrity": "sha512-QllfrBhqF1DPcz46WxKTs6Mz1Bpc+8Qm6vbqOpVav5odAXwbyzwnEczoWqtxrsmlO+cJqtPrp/8gWKWjaKLLlA==", + "license": "MIT", + "dependencies": { + "builtin-status-codes": "^3.0.0", + "inherits": "^2.0.1", + "readable-stream": "^2.3.6", + "to-arraybuffer": "^1.0.0", + "xtend": "^4.0.0" + } + }, + "node_modules/stream-wormhole": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stream-wormhole/-/stream-wormhole-1.1.0.tgz", + "integrity": "sha512-gHFfL3px0Kctd6Po0M8TzEvt3De/xu6cnRrjlfYNhwbhLPLwigI2t1nc6jrzNuaYg5C4YF78PPFuQPzRiqn9ew==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/string_decoder": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", @@ -4497,7 +5169,6 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", - "dev": true, "license": "MIT", "dependencies": { "any-promise": "^1.0.0" @@ -4507,7 +5178,6 @@ "version": "1.6.0", "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", - "dev": true, "license": "MIT", "dependencies": { "thenify": ">= 3.1.0 < 4" @@ -4516,6 +5186,12 @@ "node": ">=0.8" } }, + "node_modules/through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", + "license": "MIT" + }, "node_modules/tiny-invariant": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", @@ -4571,6 +5247,12 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/to-arraybuffer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/to-arraybuffer/-/to-arraybuffer-1.0.1.tgz", + "integrity": "sha512-okFlQcoGTi4LQBG/PgSYblw9VOyptsz2KJZqc6qtgGdes8VktzUQkj4BI2blit072iS8VODNcMA+tvnS9dnuMA==", + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -5069,6 +5751,18 @@ "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", "license": "MIT" }, + "node_modules/unescape": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/unescape/-/unescape-1.0.1.tgz", + "integrity": "sha512-O0+af1Gs50lyH1nUu3ZyYS1cRh01Q/kUKatTOkSs7jukXE6/NebucDVxyiDsA9AQ4JC1V1jUH9EO8JX2nMDgGQ==", + "license": "MIT", + "dependencies": { + "extend-shallow": "^2.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -5100,12 +5794,59 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/urllib": { + "version": "2.44.1", + "resolved": "https://registry.npmjs.org/urllib/-/urllib-2.44.1.tgz", + "integrity": "sha512-vreOVvFizoiIz5NK9IYMgUknkriHHBVccn2VFfJhgKz6O2qwm0SgjFk4OpXFRDXpdrTx8EzM1DB0/pejrqXwPA==", + "license": "MIT", + "dependencies": { + "any-promise": "^1.3.0", + "content-type": "^1.0.2", + "default-user-agent": "^1.0.0", + "digest-header": "^1.0.0", + "ee-first": "~1.1.1", + "formstream": "^1.1.0", + "humanize-ms": "^1.2.0", + "iconv-lite": "^0.6.3", + "pump": "^3.0.0", + "qs": "^6.4.0", + "statuses": "^1.3.1", + "utility": "^1.16.1" + }, + "engines": { + "node": ">= 0.10.0" + }, + "peerDependencies": { + "proxy-agent": "^5.0.0" + }, + "peerDependenciesMeta": { + "proxy-agent": { + "optional": true + } + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/utility": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/utility/-/utility-1.18.0.tgz", + "integrity": "sha512-PYxZDA+6QtvRvm//++aGdmKG/cI07jNwbROz0Ql+VzFV1+Z0Dy55NI4zZ7RHc9KKpBePNFwoErqIuqQv/cjiTA==", + "license": "MIT", + "dependencies": { + "copy-to": "^2.0.1", + "escape-html": "^1.0.3", + "mkdirp": "^0.5.1", + "mz": "^2.7.0", + "unescape": "^1.0.1" + }, + "engines": { + "node": ">= 0.12.0" + } + }, "node_modules/victory-vendor": { "version": "36.9.2", "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", @@ -5204,6 +5945,27 @@ "node": ">= 8" } }, + "node_modules/win-release": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/win-release/-/win-release-1.1.1.tgz", + "integrity": "sha512-iCRnKVvGxOQdsKhcQId2PXV1vV3J/sDPXKA4Oe9+Eti2nb2ESEsYHRYls/UjoUW3bIc5ZDO8dTH50A/5iVN+bw==", + "license": "MIT", + "dependencies": { + "semver": "^5.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/win-release/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, "node_modules/wmf": { "version": "1.0.2", "resolved": "https://registry.npmmirror.com/wmf/-/wmf-1.0.2.tgz", @@ -5222,6 +5984,12 @@ "node": ">=0.8" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, "node_modules/ws": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", @@ -5282,6 +6050,28 @@ "xml-js": "bin/cli.js" } }, + "node_modules/xml2js": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", + "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==", + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/scripts/api-integration-test.js b/scripts/api-integration-test.js index 050bc704..387a5552 100644 --- a/scripts/api-integration-test.js +++ b/scripts/api-integration-test.js @@ -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,