forked from wangziqi/gongxue-base
377 lines
14 KiB
TypeScript
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),
|
|
};
|
|
}
|