forked from wangziqi/gongxue-base
feat: harden content asset access
This commit is contained in:
@@ -18,6 +18,12 @@ import {
|
||||
validateObjectKey,
|
||||
type StorageProviderName,
|
||||
} from '../storage/service.js';
|
||||
import {
|
||||
assetAccessTtl,
|
||||
assertCdnAccessAllowed,
|
||||
recordAssetAccessEvent,
|
||||
signedAssetFingerprint,
|
||||
} from '../storage/asset-access.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'];
|
||||
@@ -47,6 +53,8 @@ interface AssetRow {
|
||||
verifiedSizeBytes: number | null;
|
||||
verifiedChecksumSha256: string | null;
|
||||
previewStatus: string;
|
||||
accessRules?: Record<string, unknown>;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function choice(value: unknown, allowed: string[], fallback: string, label: string) {
|
||||
@@ -61,6 +69,12 @@ function nullableUuid(value: unknown) {
|
||||
return nullableString(value);
|
||||
}
|
||||
|
||||
function assertUuidParam(value: string, label: string) {
|
||||
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)) {
|
||||
throw new HttpError(400, `${label} must be a valid UUID`, 'INVALID_UUID');
|
||||
}
|
||||
}
|
||||
|
||||
function safeFileName(fileName: string) {
|
||||
return fileName
|
||||
.trim()
|
||||
@@ -173,6 +187,27 @@ async function recordAssetAudit(auth: TenantContentAuth, action: string, targetI
|
||||
);
|
||||
}
|
||||
|
||||
async function recordDeniedAdminAssetAccess(ctx: RequestContext, auth: TenantContentAuth, asset: AssetRow, accessType: 'admin_download' | 'admin_preview', error: unknown) {
|
||||
const denyCode = error instanceof HttpError ? error.code : 'ASSET_ACCESS_DENIED';
|
||||
await recordAssetAccessEvent({
|
||||
ctx,
|
||||
tenantId: auth.tenantId,
|
||||
assetId: asset.id,
|
||||
userId: auth.userId,
|
||||
actorRole: 'tenant_content_editor',
|
||||
accessType,
|
||||
visibility: asset.visibility,
|
||||
assetType: asset.assetType,
|
||||
storageProvider: asset.storageProvider,
|
||||
result: 'denied',
|
||||
denyCode,
|
||||
metadata: {
|
||||
title: asset.title,
|
||||
fileName: asset.fileName,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function assetsAdminRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantContentEditor(ctx);
|
||||
const limit = intParam(ctx, 'limit', 100, 500);
|
||||
@@ -253,6 +288,40 @@ export async function assetsAdminRoute(ctx: RequestContext) {
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function assetAccessEventsAdminRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantContentEditor(ctx);
|
||||
const limit = intParam(ctx, 'limit', 100, 500);
|
||||
const assetId = stringParam(ctx, 'assetId');
|
||||
|
||||
const params: unknown[] = [auth.tenantId];
|
||||
const filters = ['tenant_id = $1'];
|
||||
if (assetId) {
|
||||
assertUuidParam(assetId, 'assetId');
|
||||
params.push(assetId);
|
||||
filters.push(`asset_id = $${params.length}::uuid`);
|
||||
}
|
||||
params.push(limit);
|
||||
|
||||
const items = await query(
|
||||
`
|
||||
select id, asset_id as "assetId", user_id as "userId",
|
||||
actor_role as "actorRole", access_type as "accessType",
|
||||
visibility, asset_type as "assetType",
|
||||
storage_provider as "storageProvider", disposition,
|
||||
expires_in_sec as "expiresInSec", signature_mode as "signatureMode",
|
||||
result, deny_code as "denyCode", ip_address as "ipAddress",
|
||||
user_agent as "userAgent", metadata, created_at as "createdAt"
|
||||
from public.content_asset_access_events
|
||||
where ${filters.join(' and ')}
|
||||
order by 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);
|
||||
@@ -562,6 +631,25 @@ export async function confirmAssetUploadRoute(ctx: RequestContext) {
|
||||
`,
|
||||
[auth.tenantId, assetId, JSON.stringify(verificationDetails), auth.userId],
|
||||
);
|
||||
await recordAssetAccessEvent({
|
||||
ctx,
|
||||
tenantId: auth.tenantId,
|
||||
assetId,
|
||||
userId: auth.userId,
|
||||
actorRole: 'tenant_content_editor',
|
||||
accessType: 'upload_confirm',
|
||||
visibility: asset.visibility,
|
||||
assetType: asset.assetType,
|
||||
storageProvider: asset.storageProvider,
|
||||
result: 'denied',
|
||||
denyCode: 'UPLOAD_VERIFICATION_FAILED',
|
||||
metadata: {
|
||||
provider,
|
||||
bucket: asset.bucket,
|
||||
objectKey: asset.objectKey,
|
||||
issues,
|
||||
},
|
||||
});
|
||||
throw new HttpError(409, `Upload verification failed: ${issues.join(', ')}`, 'UPLOAD_VERIFICATION_FAILED');
|
||||
}
|
||||
|
||||
@@ -611,6 +699,26 @@ export async function confirmAssetUploadRoute(ctx: RequestContext) {
|
||||
checksumVerified: verificationDetails.checksumVerified,
|
||||
checksumUnavailable: verificationDetails.checksumUnavailable,
|
||||
});
|
||||
await recordAssetAccessEvent({
|
||||
ctx,
|
||||
tenantId: auth.tenantId,
|
||||
assetId,
|
||||
userId: auth.userId,
|
||||
actorRole: 'tenant_content_editor',
|
||||
accessType: 'upload_confirm',
|
||||
visibility: asset.visibility,
|
||||
assetType: asset.assetType,
|
||||
storageProvider: asset.storageProvider,
|
||||
result: 'granted',
|
||||
metadata: {
|
||||
provider,
|
||||
bucket: asset.bucket,
|
||||
objectKey: asset.objectKey,
|
||||
publish,
|
||||
checksumVerified: verificationDetails.checksumVerified,
|
||||
checksumUnavailable: verificationDetails.checksumUnavailable,
|
||||
},
|
||||
});
|
||||
|
||||
return { item, metadata, verification: verificationDetails };
|
||||
}
|
||||
@@ -651,6 +759,27 @@ export async function signAssetUploadRoute(ctx: RequestContext) {
|
||||
mimeType,
|
||||
fileSizeBytes,
|
||||
});
|
||||
await recordAssetAccessEvent({
|
||||
ctx,
|
||||
tenantId: auth.tenantId,
|
||||
userId: auth.userId,
|
||||
actorRole: 'tenant_content_editor',
|
||||
accessType: 'upload_sign',
|
||||
assetType,
|
||||
storageProvider,
|
||||
disposition: 'attachment',
|
||||
expiresInSec: upload.expiresInSec,
|
||||
signatureMode: upload.signatureMode,
|
||||
result: 'granted',
|
||||
metadata: {
|
||||
bucket,
|
||||
objectKey: cleanObjectKey,
|
||||
fileName,
|
||||
mimeType,
|
||||
fileSizeBytes,
|
||||
signature: signedAssetFingerprint(upload),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
upload,
|
||||
@@ -671,7 +800,7 @@ 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 requestedExpiresInSec = intValue(body.expiresInSec, 900);
|
||||
|
||||
const asset = await queryOne<AssetRow>(
|
||||
`
|
||||
@@ -684,7 +813,8 @@ export async function signAssetDownloadAdminRoute(ctx: RequestContext) {
|
||||
visibility, status, upload_status as "uploadStatus",
|
||||
verified_size_bytes as "verifiedSizeBytes",
|
||||
verified_checksum_sha256 as "verifiedChecksumSha256",
|
||||
preview_status as "previewStatus"
|
||||
preview_status as "previewStatus",
|
||||
access_rules as "accessRules", metadata
|
||||
from public.content_assets
|
||||
where tenant_id = $1 and id = $2
|
||||
limit 1
|
||||
@@ -695,15 +825,36 @@ export async function signAssetDownloadAdminRoute(ctx: RequestContext) {
|
||||
if (!asset) {
|
||||
throw new HttpError(404, 'Asset not found', 'ASSET_NOT_FOUND');
|
||||
}
|
||||
const expiresInSec = assetAccessTtl({
|
||||
actorRole: 'tenant_content_editor',
|
||||
accessType: 'admin_download',
|
||||
visibility: asset.visibility,
|
||||
assetType: asset.assetType,
|
||||
disposition: 'attachment',
|
||||
requestedExpiresInSec,
|
||||
});
|
||||
try {
|
||||
assertCdnAccessAllowed({
|
||||
assetId,
|
||||
visibility: asset.visibility,
|
||||
assetType: asset.assetType,
|
||||
storageProvider: asset.storageProvider as StorageProviderName,
|
||||
objectKey: asset.objectKey,
|
||||
cdnUrl: asset.cdnUrl,
|
||||
metadata: asset.metadata,
|
||||
accessRules: asset.accessRules,
|
||||
});
|
||||
} catch (error) {
|
||||
await recordDeniedAdminAssetAccess(ctx, auth, asset, 'admin_download', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
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: await signStorageDownload({
|
||||
const download = await signStorageDownload({
|
||||
tenantId: auth.tenantId,
|
||||
provider: asset.storageProvider as StorageProviderName,
|
||||
bucket: asset.bucket,
|
||||
@@ -712,15 +863,32 @@ export async function signAssetDownloadAdminRoute(ctx: RequestContext) {
|
||||
fileName: asset.fileName,
|
||||
expiresInSec,
|
||||
disposition: 'attachment',
|
||||
}),
|
||||
};
|
||||
});
|
||||
await recordAssetAccessEvent({
|
||||
ctx,
|
||||
tenantId: auth.tenantId,
|
||||
assetId,
|
||||
userId: auth.userId,
|
||||
actorRole: 'tenant_content_editor',
|
||||
accessType: 'admin_download',
|
||||
visibility: asset.visibility,
|
||||
assetType: asset.assetType,
|
||||
storageProvider: asset.storageProvider,
|
||||
disposition: 'attachment',
|
||||
expiresInSec: download.expiresInSec,
|
||||
signatureMode: download.signatureMode,
|
||||
result: 'granted',
|
||||
metadata: { signature: signedAssetFingerprint(download) },
|
||||
});
|
||||
|
||||
return { item: asset, download };
|
||||
}
|
||||
|
||||
export async function signAssetPreviewAdminRoute(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), 3600);
|
||||
const requestedExpiresInSec = intValue(body.expiresInSec, 900);
|
||||
|
||||
const asset = await queryOne<AssetRow>(
|
||||
`
|
||||
@@ -732,7 +900,8 @@ export async function signAssetPreviewAdminRoute(ctx: RequestContext) {
|
||||
visibility, status, upload_status as "uploadStatus",
|
||||
verified_size_bytes as "verifiedSizeBytes",
|
||||
verified_checksum_sha256 as "verifiedChecksumSha256",
|
||||
preview_status as "previewStatus"
|
||||
preview_status as "previewStatus",
|
||||
access_rules as "accessRules", metadata
|
||||
from public.content_assets
|
||||
where tenant_id = $1 and id = $2
|
||||
limit 1
|
||||
@@ -748,6 +917,56 @@ export async function signAssetPreviewAdminRoute(ctx: RequestContext) {
|
||||
const objectKey = asset.previewObjectKey || asset.objectKey;
|
||||
const cdnUrl = asset.previewUrl || asset.cdnUrl;
|
||||
const fileName = asset.fileName || asset.title || 'preview.pdf';
|
||||
const expiresInSec = assetAccessTtl({
|
||||
actorRole: 'tenant_content_editor',
|
||||
accessType: 'admin_preview',
|
||||
visibility: asset.visibility,
|
||||
assetType: asset.assetType,
|
||||
disposition: 'inline',
|
||||
requestedExpiresInSec,
|
||||
});
|
||||
try {
|
||||
assertCdnAccessAllowed({
|
||||
assetId,
|
||||
visibility: asset.visibility,
|
||||
assetType: asset.assetType,
|
||||
storageProvider: asset.storageProvider as StorageProviderName,
|
||||
objectKey,
|
||||
cdnUrl,
|
||||
metadata: asset.metadata,
|
||||
accessRules: asset.accessRules,
|
||||
});
|
||||
} catch (error) {
|
||||
await recordDeniedAdminAssetAccess(ctx, auth, asset, 'admin_preview', error);
|
||||
throw error;
|
||||
}
|
||||
const preview = await signStorageDownload({
|
||||
tenantId: auth.tenantId,
|
||||
provider: asset.storageProvider as StorageProviderName,
|
||||
bucket: asset.bucket,
|
||||
objectKey,
|
||||
cdnUrl,
|
||||
fileName,
|
||||
expiresInSec,
|
||||
disposition: 'inline',
|
||||
});
|
||||
|
||||
await recordAssetAccessEvent({
|
||||
ctx,
|
||||
tenantId: auth.tenantId,
|
||||
assetId,
|
||||
userId: auth.userId,
|
||||
actorRole: 'tenant_content_editor',
|
||||
accessType: 'admin_preview',
|
||||
visibility: asset.visibility,
|
||||
assetType: asset.assetType,
|
||||
storageProvider: asset.storageProvider,
|
||||
disposition: 'inline',
|
||||
expiresInSec: preview.expiresInSec,
|
||||
signatureMode: preview.signatureMode,
|
||||
result: 'granted',
|
||||
metadata: { signature: signedAssetFingerprint(preview) },
|
||||
});
|
||||
|
||||
return {
|
||||
item: {
|
||||
@@ -757,15 +976,6 @@ export async function signAssetPreviewAdminRoute(ctx: RequestContext) {
|
||||
fileName: asset.fileName,
|
||||
previewStatus: asset.previewStatus,
|
||||
},
|
||||
preview: await signStorageDownload({
|
||||
tenantId: auth.tenantId,
|
||||
provider: asset.storageProvider as StorageProviderName,
|
||||
bucket: asset.bucket,
|
||||
objectKey,
|
||||
cdnUrl,
|
||||
fileName,
|
||||
expiresInSec,
|
||||
disposition: 'inline',
|
||||
}),
|
||||
preview,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { RouteDefinition } from '../../core/router.js';
|
||||
import {
|
||||
assetAccessEventsAdminRoute,
|
||||
assetsAdminRoute,
|
||||
confirmAssetUploadRoute,
|
||||
signAssetDownloadAdminRoute,
|
||||
@@ -102,6 +103,7 @@ export const tenantContentRoutes: RouteDefinition[] = [
|
||||
['POST', '/api/tenant-content/questions', createQuestionRoute],
|
||||
['PATCH', '/api/tenant-content/questions', updateQuestionRoute],
|
||||
['GET', '/api/tenant-content/assets', assetsAdminRoute],
|
||||
['GET', '/api/tenant-content/assets/access-events', assetAccessEventsAdminRoute],
|
||||
['PUT', '/api/tenant-content/assets', upsertAssetRoute],
|
||||
['POST', '/api/tenant-content/assets/sign-upload', signAssetUploadRoute],
|
||||
['POST', '/api/tenant-content/assets/confirm-upload', confirmAssetUploadRoute],
|
||||
|
||||
Reference in New Issue
Block a user