feat: harden content asset access

This commit is contained in:
Codex
2026-06-29 18:25:47 +08:00
parent ee578af1f6
commit 79d0d786a0
13 changed files with 762 additions and 67 deletions

View File

@@ -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,
};
}

View 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 || {}),
],
);
}

View File

@@ -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,
};
}

View File

@@ -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],

View File

@@ -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),
}),
],
);