import { randomUUID } from 'node:crypto'; import { HttpError, type RequestContext } from '../../core/http.js'; import { intParam, readJsonBody, requiredString, stringParam } from '../../core/request.js'; import { query, queryOne } from '../../core/db.js'; import { requireTenantContentEditor, type TenantContentAuth } from './auth.js'; import { boolValue, intValue, jsonObjectValue, nullableString } from './utils.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']; const VISIBILITIES = ['public', 'tenant', 'members', 'svip', 'private']; const ASSET_STATUSES = ['draft', 'active', 'archived']; interface AssetRow { id: string; tenantId: string; assetType: string; storageProvider: string; bucket: string | null; objectKey: string | null; title: string | null; fileName: string | null; cdnUrl: string | null; previewUrl: string | null; visibility: string; status: string; } function choice(value: unknown, allowed: string[], fallback: string, label: string) { const candidate = nullableString(value) || fallback; if (!allowed.includes(candidate)) { throw new HttpError(400, `Invalid ${label}: ${candidate}`, 'INVALID_FIELD_VALUE'); } return candidate; } function nullableUuid(value: unknown) { return nullableString(value); } function safeFileName(fileName: string) { return fileName .trim() .replace(/[\\/:*?"<>|]+/g, '-') .replace(/\s+/g, '-') .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 }>( `select id from public.${table} where tenant_id = $1 and id = $2 limit 1`, [tenantId, id], ); if (!row) { throw new HttpError(400, `${table} reference is not in this tenant`, code); } } async function recordAssetAudit(auth: TenantContentAuth, action: string, targetId: string | null, details: Record) { await query( ` insert into public.audit_logs (tenant_id, actor_user_id, action, target_type, target_id, details) values ($1, $2, $3, 'content_asset', $4, $5::jsonb) `, [auth.tenantId, auth.userId, action, targetId, JSON.stringify(details)], ); } export async function assetsAdminRoute(ctx: RequestContext) { const auth = await requireTenantContentEditor(ctx); const limit = intParam(ctx, 'limit', 100, 500); const assetType = stringParam(ctx, 'assetType'); const status = stringParam(ctx, 'status'); const visibility = stringParam(ctx, 'visibility'); const regionId = stringParam(ctx, 'regionId'); const subjectId = stringParam(ctx, 'subjectId'); const categoryId = stringParam(ctx, 'categoryId'); const entryId = stringParam(ctx, 'entryId'); const contentNodeId = stringParam(ctx, 'contentNodeId'); const params: unknown[] = [auth.tenantId]; const filters = ['tenant_id = $1']; if (assetType) { params.push(assetType); filters.push(`asset_type = $${params.length}`); } if (status) { params.push(status); filters.push(`status = $${params.length}`); } if (visibility) { params.push(visibility); filters.push(`visibility = $${params.length}`); } if (regionId) { params.push(regionId); filters.push(`region_id = $${params.length}`); } if (subjectId) { params.push(subjectId); filters.push(`subject_id = $${params.length}`); } if (categoryId) { params.push(categoryId); filters.push(`category_id = $${params.length}`); } if (entryId) { params.push(entryId); filters.push(`entry_id = $${params.length}`); } if (contentNodeId) { params.push(contentNodeId); filters.push(`content_node_id = $${params.length}`); } params.push(limit); const items = await query( ` select id, legacy_id as "legacyId", asset_key as "assetKey", asset_type as "assetType", storage_provider as "storageProvider", bucket, object_key as "objectKey", title, category as "categoryLabel", description, file_name as "fileName", cdn_url as "cdnUrl", preview_url as "previewUrl", mime_type as "mimeType", file_size_bytes as "fileSizeBytes", checksum_sha256 as "checksumSha256", visibility, is_public as "isPublic", region_id as "regionId", subject_id as "subjectId", category_id as "categoryId", node_id as "nodeId", entry_id as "entryId", content_node_id as "contentNodeId", status, sort_order as "order", access_rules as "accessRules", source, download_count as "downloadCount", metadata, created_by as "createdBy", updated_by as "updatedBy", created_at as "createdAt", updated_at as "updatedAt" from public.content_assets where ${filters.join(' and ')} order by sort_order asc, created_at desc limit $${params.length} `, params, ); return { items }; } export async function upsertAssetRoute(ctx: RequestContext) { const auth = await requireTenantContentEditor(ctx); 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 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); const objectKey = nullableString(body.objectKey); const cdnUrl = nullableString(body.cdnUrl); const title = requiredString(body, 'title'); const regionId = nullableUuid(body.regionId); const subjectId = nullableUuid(body.subjectId); const categoryId = nullableUuid(body.categoryId); const nodeId = nullableUuid(body.nodeId); const entryId = nullableUuid(body.entryId); const contentNodeId = nullableUuid(body.contentNodeId); if (status === 'active' && !cdnUrl && !objectKey) { throw new HttpError(400, 'Active asset requires cdnUrl or objectKey', 'ASSET_LOCATION_REQUIRED'); } await assertOptionalReference(auth.tenantId, 'regions', regionId, 'REGION_NOT_FOUND'); await assertOptionalReference(auth.tenantId, 'subjects', subjectId, 'SUBJECT_NOT_FOUND'); await assertOptionalReference(auth.tenantId, 'categories', categoryId, 'CATEGORY_NOT_FOUND'); await assertOptionalReference(auth.tenantId, 'module_nodes', nodeId, 'NODE_NOT_FOUND'); await assertOptionalReference(auth.tenantId, 'content_entries', entryId, 'ENTRY_NOT_FOUND'); await assertOptionalReference(auth.tenantId, 'content_nodes', contentNodeId, 'CONTENT_NODE_NOT_FOUND'); const item = await queryOne( ` insert into public.content_assets ( id, tenant_id, legacy_id, asset_key, asset_type, storage_provider, bucket, object_key, title, category, description, file_name, cdn_url, preview_url, mime_type, file_size_bytes, checksum_sha256, visibility, is_public, region_id, subject_id, category_id, node_id, entry_id, content_node_id, status, sort_order, access_rules, metadata, created_by, updated_by, source ) values ( coalesce($1::uuid, gen_random_uuid()), $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20::uuid, $21::uuid, $22::uuid, $23::uuid, $24::uuid, $25::uuid, $26, $27, $28::jsonb, $29::jsonb, $30, $30, $31 ) on conflict (id) do update set legacy_id = excluded.legacy_id, asset_key = excluded.asset_key, asset_type = excluded.asset_type, storage_provider = excluded.storage_provider, bucket = excluded.bucket, object_key = excluded.object_key, title = excluded.title, category = excluded.category, description = excluded.description, file_name = excluded.file_name, cdn_url = excluded.cdn_url, preview_url = excluded.preview_url, mime_type = excluded.mime_type, file_size_bytes = excluded.file_size_bytes, checksum_sha256 = excluded.checksum_sha256, visibility = excluded.visibility, is_public = excluded.is_public, region_id = excluded.region_id, subject_id = excluded.subject_id, category_id = excluded.category_id, node_id = excluded.node_id, entry_id = excluded.entry_id, content_node_id = excluded.content_node_id, status = excluded.status, sort_order = excluded.sort_order, access_rules = excluded.access_rules, metadata = excluded.metadata, updated_by = excluded.updated_by, source = excluded.source, updated_at = now() where public.content_assets.tenant_id = excluded.tenant_id returning id, legacy_id as "legacyId", asset_key as "assetKey", asset_type as "assetType", storage_provider as "storageProvider", bucket, object_key as "objectKey", title, category as "categoryLabel", description, file_name as "fileName", cdn_url as "cdnUrl", preview_url as "previewUrl", mime_type as "mimeType", file_size_bytes as "fileSizeBytes", checksum_sha256 as "checksumSha256", visibility, is_public as "isPublic", region_id as "regionId", subject_id as "subjectId", category_id as "categoryId", node_id as "nodeId", entry_id as "entryId", content_node_id as "contentNodeId", status, sort_order as "order", access_rules as "accessRules", source, download_count as "downloadCount", metadata, created_by as "createdBy", updated_by as "updatedBy", created_at as "createdAt", updated_at as "updatedAt" `, [ nullableString(body.id), auth.tenantId, nullableString(body.legacyId), nullableString(body.assetKey), assetType, storageProvider, bucket, objectKey, 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), nullableString(body.checksumSha256), visibility, visibility === 'public', regionId, subjectId, categoryId, nodeId, entryId, contentNodeId, status, intValue(body.order, 0), jsonObjectValue(body.accessRules), jsonObjectValue(body.metadata), auth.userId, nullableString(body.source) || 'manual', ], ); if (!item) { throw new HttpError(404, 'Asset not found in this tenant', 'ASSET_NOT_FOUND'); } await recordAssetAudit(auth, 'content.asset.upserted', String((item as { id?: string } | null)?.id || ''), { title, assetType, visibility, status, }); return { item }; } export async function signAssetUploadRoute(ctx: RequestContext) { const auth = await requireTenantContentEditor(ctx); 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 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(); 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', }, assetDraft: { assetType, storageProvider, bucket, objectKey, fileName, mimeType: nullableString(body.mimeType), fileSizeBytes: body.fileSizeBytes === undefined ? null : intValue(body.fileSizeBytes, 0), checksumSha256: nullableString(body.checksumSha256), }, }; } export async function signAssetDownloadAdminRoute(ctx: RequestContext) { const auth = await requireTenantContentEditor(ctx); const body = await readJsonBody(ctx); const assetId = requiredString(body, 'assetId'); const expiresInSec = Math.min(Math.max(intValue(body.expiresInSec, 900), 60), 86_400); const asset = await queryOne( ` select id, tenant_id as "tenantId", asset_type as "assetType", storage_provider as "storageProvider", bucket, object_key as "objectKey", title, file_name as "fileName", cdn_url as "cdnUrl", preview_url as "previewUrl", visibility, status from public.content_assets where tenant_id = $1 and id = $2 limit 1 `, [auth.tenantId, assetId], ); if (!asset) { throw new HttpError(404, 'Asset not found', 'ASSET_NOT_FOUND'); } await query( 'update public.content_assets set download_count = download_count + 1, updated_at = now() where tenant_id = $1 and id = $2', [auth.tenantId, assetId], ); return { item: asset, download: placeholderSignedUrl(asset, expiresInSec), }; }