forked from wangziqi/gongxue-base
feat: harden content asset access
This commit is contained in:
@@ -2,6 +2,12 @@ import { HttpError, type RequestContext } from '../../core/http.js';
|
||||
import { intParam, optionalUserIdFrom, stringParam, tenantIdFrom, userIdFrom } from '../../core/request.js';
|
||||
import { query, queryOne } from '../../core/db.js';
|
||||
import { signStorageDownload, type StorageProviderName } from '../storage/service.js';
|
||||
import {
|
||||
assetAccessTtl,
|
||||
assertCdnAccessAllowed,
|
||||
recordAssetAccessEvent,
|
||||
signedAssetFingerprint,
|
||||
} from '../storage/asset-access.js';
|
||||
|
||||
interface CatalogAssetRow {
|
||||
id: string;
|
||||
@@ -20,6 +26,8 @@ interface CatalogAssetRow {
|
||||
subjectId: string | null;
|
||||
uploadStatus: string;
|
||||
previewStatus: string;
|
||||
accessRules: Record<string, unknown>;
|
||||
metadata: Record<string, unknown>;
|
||||
}
|
||||
|
||||
async function hasActiveMembership(tenantId: string, userId: string) {
|
||||
@@ -83,6 +91,28 @@ async function assertAssetAccess(ctx: RequestContext, asset: CatalogAssetRow) {
|
||||
return { userId, svip: true };
|
||||
}
|
||||
|
||||
async function recordDeniedAssetAccess(ctx: RequestContext, tenantId: string, asset: CatalogAssetRow, accessType: 'download' | 'preview', error: unknown) {
|
||||
const userId = await optionalUserIdFrom(ctx).catch(() => '');
|
||||
const code = error instanceof HttpError ? error.code : 'ASSET_ACCESS_DENIED';
|
||||
await recordAssetAccessEvent({
|
||||
ctx,
|
||||
tenantId,
|
||||
assetId: asset.id,
|
||||
userId: userId || null,
|
||||
actorRole: userId ? 'student' : 'anonymous',
|
||||
accessType,
|
||||
visibility: asset.visibility,
|
||||
assetType: asset.assetType,
|
||||
storageProvider: asset.storageProvider,
|
||||
result: 'denied',
|
||||
denyCode: code,
|
||||
metadata: {
|
||||
title: asset.title,
|
||||
fileName: asset.fileName,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function assetPreviewable(asset: CatalogAssetRow) {
|
||||
const mimeType = asset.mimeType?.split(';')[0]?.trim().toLowerCase() || '';
|
||||
return asset.assetType === 'pdf' || mimeType === 'application/pdf' || asset.assetType === 'image' || mimeType.startsWith('image/');
|
||||
@@ -174,7 +204,8 @@ export async function assetDownloadRoute(ctx: RequestContext) {
|
||||
cdn_url as "cdnUrl", preview_url as "previewUrl",
|
||||
preview_object_key as "previewObjectKey", mime_type as "mimeType",
|
||||
visibility, region_id as "regionId", subject_id as "subjectId",
|
||||
upload_status as "uploadStatus", preview_status as "previewStatus"
|
||||
upload_status as "uploadStatus", preview_status as "previewStatus",
|
||||
access_rules as "accessRules", metadata
|
||||
from public.content_assets
|
||||
where tenant_id = $1 and id = $2 and status = 'active'
|
||||
limit 1
|
||||
@@ -186,12 +217,63 @@ export async function assetDownloadRoute(ctx: RequestContext) {
|
||||
throw new HttpError(404, 'Asset not found', 'ASSET_NOT_FOUND');
|
||||
}
|
||||
|
||||
const access = await assertAssetAccess(ctx, asset);
|
||||
assertPublishedAsset(asset);
|
||||
let access: { userId: string; svip: boolean };
|
||||
try {
|
||||
access = await assertAssetAccess(ctx, asset);
|
||||
assertPublishedAsset(asset);
|
||||
assertCdnAccessAllowed({
|
||||
assetId: asset.id,
|
||||
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 recordDeniedAssetAccess(ctx, tenantId, asset, 'download', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const expiresInSec = assetAccessTtl({
|
||||
actorRole: access.userId ? 'student' : 'anonymous',
|
||||
accessType: 'download',
|
||||
visibility: asset.visibility,
|
||||
assetType: asset.assetType,
|
||||
disposition: 'attachment',
|
||||
requestedExpiresInSec: 900,
|
||||
});
|
||||
const download = await signStorageDownload({
|
||||
tenantId,
|
||||
provider: asset.storageProvider as StorageProviderName,
|
||||
bucket: asset.bucket,
|
||||
objectKey: asset.objectKey,
|
||||
cdnUrl: asset.cdnUrl,
|
||||
fileName: asset.fileName,
|
||||
expiresInSec,
|
||||
disposition: 'attachment',
|
||||
});
|
||||
await query(
|
||||
'update public.content_assets set download_count = download_count + 1, updated_at = now() where tenant_id = $1 and id = $2',
|
||||
[tenantId, assetId],
|
||||
);
|
||||
await recordAssetAccessEvent({
|
||||
ctx,
|
||||
tenantId,
|
||||
assetId: asset.id,
|
||||
userId: access.userId || null,
|
||||
actorRole: access.userId ? 'student' : 'anonymous',
|
||||
accessType: '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: {
|
||||
@@ -205,16 +287,7 @@ export async function assetDownloadRoute(ctx: RequestContext) {
|
||||
visibility: asset.visibility,
|
||||
},
|
||||
access,
|
||||
download: await signStorageDownload({
|
||||
tenantId,
|
||||
provider: asset.storageProvider as StorageProviderName,
|
||||
bucket: asset.bucket,
|
||||
objectKey: asset.objectKey,
|
||||
cdnUrl: asset.cdnUrl,
|
||||
fileName: asset.fileName,
|
||||
expiresInSec: 900,
|
||||
disposition: 'attachment',
|
||||
}),
|
||||
download,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -232,7 +305,8 @@ export async function assetPreviewRoute(ctx: RequestContext) {
|
||||
cdn_url as "cdnUrl", preview_url as "previewUrl",
|
||||
preview_object_key as "previewObjectKey", mime_type as "mimeType",
|
||||
visibility, region_id as "regionId", subject_id as "subjectId",
|
||||
upload_status as "uploadStatus", preview_status as "previewStatus"
|
||||
upload_status as "uploadStatus", preview_status as "previewStatus",
|
||||
access_rules as "accessRules", metadata
|
||||
from public.content_assets
|
||||
where tenant_id = $1 and id = $2 and status = 'active'
|
||||
limit 1
|
||||
@@ -247,8 +321,58 @@ export async function assetPreviewRoute(ctx: RequestContext) {
|
||||
throw new HttpError(400, 'Asset type does not support inline preview', 'ASSET_PREVIEW_NOT_SUPPORTED');
|
||||
}
|
||||
|
||||
const access = await assertAssetAccess(ctx, asset);
|
||||
assertPublishedAsset(asset);
|
||||
let access: { userId: string; svip: boolean };
|
||||
try {
|
||||
access = await assertAssetAccess(ctx, asset);
|
||||
assertPublishedAsset(asset);
|
||||
assertCdnAccessAllowed({
|
||||
assetId: asset.id,
|
||||
visibility: asset.visibility,
|
||||
assetType: asset.assetType,
|
||||
storageProvider: asset.storageProvider as StorageProviderName,
|
||||
objectKey: asset.previewObjectKey || asset.objectKey,
|
||||
cdnUrl: asset.previewUrl || asset.cdnUrl,
|
||||
metadata: asset.metadata,
|
||||
accessRules: asset.accessRules,
|
||||
});
|
||||
} catch (error) {
|
||||
await recordDeniedAssetAccess(ctx, tenantId, asset, 'preview', error);
|
||||
throw error;
|
||||
}
|
||||
const expiresInSec = assetAccessTtl({
|
||||
actorRole: access.userId ? 'student' : 'anonymous',
|
||||
accessType: 'preview',
|
||||
visibility: asset.visibility,
|
||||
assetType: asset.assetType,
|
||||
disposition: 'inline',
|
||||
requestedExpiresInSec: 900,
|
||||
});
|
||||
const preview = await signStorageDownload({
|
||||
tenantId,
|
||||
provider: asset.storageProvider as StorageProviderName,
|
||||
bucket: asset.bucket,
|
||||
objectKey: asset.previewObjectKey || asset.objectKey,
|
||||
cdnUrl: asset.previewUrl || asset.cdnUrl,
|
||||
fileName: asset.fileName || asset.title || 'preview.pdf',
|
||||
expiresInSec,
|
||||
disposition: 'inline',
|
||||
});
|
||||
await recordAssetAccessEvent({
|
||||
ctx,
|
||||
tenantId,
|
||||
assetId: asset.id,
|
||||
userId: access.userId || null,
|
||||
actorRole: access.userId ? 'student' : 'anonymous',
|
||||
accessType: '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: {
|
||||
@@ -261,15 +385,6 @@ export async function assetPreviewRoute(ctx: RequestContext) {
|
||||
visibility: asset.visibility,
|
||||
},
|
||||
access,
|
||||
preview: await signStorageDownload({
|
||||
tenantId,
|
||||
provider: asset.storageProvider as StorageProviderName,
|
||||
bucket: asset.bucket,
|
||||
objectKey: asset.previewObjectKey || asset.objectKey,
|
||||
cdnUrl: asset.previewUrl || asset.cdnUrl,
|
||||
fileName: asset.fileName || asset.title || 'preview.pdf',
|
||||
expiresInSec: 900,
|
||||
disposition: 'inline',
|
||||
}),
|
||||
preview,
|
||||
};
|
||||
}
|
||||
|
||||
153
apps/api/src/features/storage/asset-access.ts
Normal file
153
apps/api/src/features/storage/asset-access.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { getHeader, HttpError, type RequestContext } from '../../core/http.js';
|
||||
import { query } from '../../core/db.js';
|
||||
import type {
|
||||
SignedStorageUrl,
|
||||
StorageContentDisposition,
|
||||
StorageProviderName,
|
||||
} from './service.js';
|
||||
|
||||
export type AssetAccessType = 'download' | 'preview' | 'admin_download' | 'admin_preview' | 'upload_sign' | 'upload_confirm';
|
||||
export type AssetActorRole = 'anonymous' | 'student' | 'tenant_admin' | 'tenant_content_editor' | 'system';
|
||||
export type AssetAccessResult = 'granted' | 'denied';
|
||||
|
||||
export interface AssetAccessRecordInput {
|
||||
ctx: RequestContext;
|
||||
tenantId: string;
|
||||
assetId?: string | null;
|
||||
userId?: string | null;
|
||||
actorRole: AssetActorRole;
|
||||
accessType: AssetAccessType;
|
||||
visibility?: string | null;
|
||||
assetType?: string | null;
|
||||
storageProvider?: string | null;
|
||||
disposition?: StorageContentDisposition | null;
|
||||
expiresInSec?: number | null;
|
||||
signatureMode?: string | null;
|
||||
result: AssetAccessResult;
|
||||
denyCode?: string | null;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface AssetAccessPolicyInput {
|
||||
actorRole: AssetActorRole;
|
||||
accessType: AssetAccessType;
|
||||
visibility: string;
|
||||
assetType: string;
|
||||
disposition: StorageContentDisposition;
|
||||
requestedExpiresInSec?: number | null;
|
||||
}
|
||||
|
||||
export interface AssetCdnPolicyInput {
|
||||
assetId: string;
|
||||
visibility: string;
|
||||
assetType: string;
|
||||
storageProvider: StorageProviderName;
|
||||
objectKey?: string | null;
|
||||
cdnUrl?: string | null;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
accessRules?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
const LOCKED_VISIBILITIES = new Set(['members', 'svip', 'private']);
|
||||
const SHORT_LIVED_ASSET_TYPES = new Set(['video', 'package']);
|
||||
|
||||
function clientIpFrom(ctx: RequestContext) {
|
||||
return (getHeader(ctx.req, 'x-forwarded-for').split(',')[0] || getHeader(ctx.req, 'x-real-ip') || ctx.req.socket.remoteAddress || '').trim();
|
||||
}
|
||||
|
||||
function userAgentFrom(ctx: RequestContext) {
|
||||
return getHeader(ctx.req, 'user-agent');
|
||||
}
|
||||
|
||||
function objectValue(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
function boolFlag(...values: unknown[]) {
|
||||
return values.some(value => value === true || value === 'true');
|
||||
}
|
||||
|
||||
export function assetAccessTtl(input: AssetAccessPolicyInput) {
|
||||
const requested = input.requestedExpiresInSec ?? 900;
|
||||
const minTtl = 60;
|
||||
let maxTtl = 900;
|
||||
|
||||
if (input.actorRole === 'tenant_admin' || input.actorRole === 'tenant_content_editor') {
|
||||
maxTtl = input.accessType === 'admin_preview' ? 3600 : 86_400;
|
||||
} else if (input.disposition === 'inline') {
|
||||
maxTtl = 300;
|
||||
} else if (LOCKED_VISIBILITIES.has(input.visibility) || SHORT_LIVED_ASSET_TYPES.has(input.assetType)) {
|
||||
maxTtl = 300;
|
||||
}
|
||||
|
||||
return Math.min(Math.max(requested, minTtl), maxTtl);
|
||||
}
|
||||
|
||||
export function assertCdnAccessAllowed(input: AssetCdnPolicyInput) {
|
||||
if (!input.cdnUrl) return;
|
||||
|
||||
const metadata = objectValue(input.metadata);
|
||||
const accessRules = objectValue(input.accessRules);
|
||||
const providerManagedAccess = boolFlag(
|
||||
metadata.providerManagedAccess,
|
||||
metadata.providerManagedCdn,
|
||||
metadata.cdnAccessMode === 'signed_by_provider',
|
||||
accessRules.providerManagedAccess,
|
||||
accessRules.providerManagedCdn,
|
||||
accessRules.cdnAccessMode === 'signed_by_provider',
|
||||
);
|
||||
|
||||
if (LOCKED_VISIBILITIES.has(input.visibility) && !providerManagedAccess) {
|
||||
throw new HttpError(
|
||||
409,
|
||||
'Locked asset requires managed object signing or explicit provider-managed CDN access',
|
||||
'ASSET_CDN_ACCESS_NOT_ALLOWED',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function signedAssetFingerprint(signed: SignedStorageUrl) {
|
||||
return {
|
||||
provider: signed.provider,
|
||||
bucket: signed.bucket,
|
||||
objectKey: signed.objectKey,
|
||||
method: signed.method,
|
||||
expiresAt: signed.expiresAt,
|
||||
expiresInSec: signed.expiresInSec,
|
||||
signatureMode: signed.signatureMode,
|
||||
};
|
||||
}
|
||||
|
||||
export async function recordAssetAccessEvent(input: AssetAccessRecordInput) {
|
||||
await query(
|
||||
`
|
||||
insert into public.content_asset_access_events (
|
||||
tenant_id, asset_id, user_id, actor_role, access_type, visibility,
|
||||
asset_type, storage_provider, disposition, expires_in_sec,
|
||||
signature_mode, result, deny_code, ip_address, user_agent, metadata
|
||||
)
|
||||
values (
|
||||
$1, $2::uuid, $3::uuid, $4, $5, $6, $7, $8, $9, $10,
|
||||
$11, $12, $13, $14, $15, $16::jsonb
|
||||
)
|
||||
`,
|
||||
[
|
||||
input.tenantId,
|
||||
input.assetId || null,
|
||||
input.userId || null,
|
||||
input.actorRole,
|
||||
input.accessType,
|
||||
input.visibility || null,
|
||||
input.assetType || null,
|
||||
input.storageProvider || null,
|
||||
input.disposition || null,
|
||||
input.expiresInSec || null,
|
||||
input.signatureMode || null,
|
||||
input.result,
|
||||
input.denyCode || null,
|
||||
clientIpFrom(input.ctx) || null,
|
||||
userAgentFrom(input.ctx) || null,
|
||||
JSON.stringify(input.metadata || {}),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -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],
|
||||
|
||||
@@ -12,6 +12,11 @@ import {
|
||||
} from '../../core/request.js';
|
||||
import { query, queryOne, transaction } from '../../core/db.js';
|
||||
import { signStorageDownload, type StorageProviderName } from '../storage/service.js';
|
||||
import {
|
||||
assetAccessTtl,
|
||||
assertCdnAccessAllowed,
|
||||
signedAssetFingerprint,
|
||||
} from '../storage/asset-access.js';
|
||||
|
||||
interface QuestionVideoRow {
|
||||
questionId: string;
|
||||
@@ -52,6 +57,11 @@ interface VideoPlaybackRow {
|
||||
fileName: string | null;
|
||||
assetTitle: string | null;
|
||||
assetStatus: string | null;
|
||||
assetType: string | null;
|
||||
assetVisibility: string | null;
|
||||
assetUploadStatus: string | null;
|
||||
assetAccessRules: Record<string, unknown> | null;
|
||||
assetMetadata: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
interface EntitlementAccessRow {
|
||||
@@ -150,6 +160,19 @@ async function signVideoPlayback(tenantId: string, video: VideoPlaybackRow) {
|
||||
if (video.assetStatus !== 'active') {
|
||||
throw new HttpError(404, 'Video asset is not active', 'VIDEO_ASSET_NOT_FOUND');
|
||||
}
|
||||
if (video.objectKey && video.assetUploadStatus !== 'verified') {
|
||||
throw new HttpError(409, 'Video asset upload has not been verified', 'VIDEO_ASSET_UPLOAD_NOT_VERIFIED');
|
||||
}
|
||||
assertCdnAccessAllowed({
|
||||
assetId: video.assetId,
|
||||
visibility: video.assetVisibility || 'svip',
|
||||
assetType: video.assetType || 'video',
|
||||
storageProvider: video.storageProvider as StorageProviderName,
|
||||
objectKey: video.objectKey,
|
||||
cdnUrl: video.cdnUrl,
|
||||
metadata: video.assetMetadata,
|
||||
accessRules: video.assetAccessRules,
|
||||
});
|
||||
return signStorageDownload({
|
||||
tenantId,
|
||||
provider: video.storageProvider as StorageProviderName,
|
||||
@@ -157,7 +180,14 @@ async function signVideoPlayback(tenantId: string, video: VideoPlaybackRow) {
|
||||
objectKey: video.objectKey,
|
||||
cdnUrl: video.cdnUrl,
|
||||
fileName: video.fileName || video.assetTitle || video.title,
|
||||
expiresInSec: 600,
|
||||
expiresInSec: assetAccessTtl({
|
||||
actorRole: 'student',
|
||||
accessType: 'download',
|
||||
visibility: video.assetVisibility || 'svip',
|
||||
assetType: video.assetType || 'video',
|
||||
disposition: 'attachment',
|
||||
requestedExpiresInSec: 600,
|
||||
}),
|
||||
});
|
||||
}
|
||||
if (isExternalPublicVideo(video)) {
|
||||
@@ -274,7 +304,10 @@ export async function videoPlaybackRoute(ctx: RequestContext) {
|
||||
v.asset_id as "assetId",
|
||||
a.storage_provider as "storageProvider", a.bucket, a.object_key as "objectKey",
|
||||
a.cdn_url as "cdnUrl", a.file_name as "fileName",
|
||||
a.title as "assetTitle", a.status as "assetStatus"
|
||||
a.title as "assetTitle", a.status as "assetStatus",
|
||||
a.asset_type as "assetType", a.visibility as "assetVisibility",
|
||||
a.upload_status as "assetUploadStatus",
|
||||
a.access_rules as "assetAccessRules", a.metadata as "assetMetadata"
|
||||
from public.video_explanations v
|
||||
left join public.content_assets a on a.tenant_id = v.tenant_id and a.id = v.asset_id
|
||||
left join public.subjects vs on vs.tenant_id = v.tenant_id and vs.id = v.subject_id
|
||||
@@ -359,7 +392,9 @@ export async function videoPlaybackRoute(ctx: RequestContext) {
|
||||
provider: signed.provider,
|
||||
bucket: signed.bucket,
|
||||
objectKey: signed.objectKey,
|
||||
assetId: video.assetId,
|
||||
freePreviewSeconds: video.freePreviewSeconds,
|
||||
signature: signedAssetFingerprint(signed),
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user