Files
gongxue-base/apps/api/src/features/tenant-content/assets.ts

377 lines
14 KiB
TypeScript

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<string, unknown>) {
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<AssetRow>(
`
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),
};
}