feat: add asset watermark access context

This commit is contained in:
Codex
2026-06-29 20:18:27 +08:00
parent ce538a57c7
commit e0393126a1
18 changed files with 361 additions and 44 deletions

View File

@@ -8,6 +8,7 @@ import {
import {
assetAccessTtl,
assertCdnAccessAllowed,
buildAssetWatermarkContext,
recordAssetAccessEvent,
signedAssetFingerprint,
} from '../storage/asset-access.js';
@@ -266,6 +267,19 @@ export async function assetDownloadRoute(ctx: RequestContext) {
expiresInSec,
disposition: 'attachment',
});
const watermark = buildAssetWatermarkContext({
tenantId,
assetId: asset.id,
userId: access.userId || null,
actorRole: access.userId ? 'student' : 'anonymous',
accessType: 'download',
visibility: asset.visibility,
assetType: asset.assetType,
fileName: asset.fileName,
title: asset.title,
expiresAt: download.expiresAt,
metadata: asset.metadata,
});
await query(
'update public.content_assets set download_count = download_count + 1, updated_at = now() where tenant_id = $1 and id = $2',
[tenantId, assetId],
@@ -284,7 +298,7 @@ export async function assetDownloadRoute(ctx: RequestContext) {
expiresInSec: download.expiresInSec,
signatureMode: download.signatureMode,
result: 'granted',
metadata: { signature: signedAssetFingerprint(download) },
metadata: { signature: signedAssetFingerprint(download), watermark },
});
return {
@@ -301,6 +315,7 @@ export async function assetDownloadRoute(ctx: RequestContext) {
},
access,
download,
watermark,
};
}
@@ -372,6 +387,19 @@ export async function assetPreviewRoute(ctx: RequestContext) {
expiresInSec,
disposition: 'inline',
});
const watermark = buildAssetWatermarkContext({
tenantId,
assetId: asset.id,
userId: access.userId || null,
actorRole: access.userId ? 'student' : 'anonymous',
accessType: 'preview',
visibility: asset.visibility,
assetType: asset.assetType,
fileName: asset.fileName,
title: asset.title,
expiresAt: preview.expiresAt,
metadata: asset.metadata,
});
await recordAssetAccessEvent({
ctx,
tenantId,
@@ -386,7 +414,7 @@ export async function assetPreviewRoute(ctx: RequestContext) {
expiresInSec: preview.expiresInSec,
signatureMode: preview.signatureMode,
result: 'granted',
metadata: { signature: signedAssetFingerprint(preview) },
metadata: { signature: signedAssetFingerprint(preview), watermark },
});
return {
@@ -402,5 +430,6 @@ export async function assetPreviewRoute(ctx: RequestContext) {
},
access,
preview,
watermark,
};
}

View File

@@ -1,3 +1,4 @@
import crypto from 'node:crypto';
import { getHeader, HttpError, type RequestContext } from '../../core/http.js';
import { query } from '../../core/db.js';
import type {
@@ -9,6 +10,33 @@ import type {
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 type AssetWatermarkMode = 'none' | 'visible_overlay';
export interface AssetWatermarkContextInput {
tenantId: string;
assetId?: string | null;
userId?: string | null;
actorRole: AssetActorRole;
accessType: AssetAccessType | 'video_play';
visibility?: string | null;
assetType?: string | null;
fileName?: string | null;
title?: string | null;
expiresAt?: string | null;
metadata?: Record<string, unknown> | null;
}
export interface AssetWatermarkContext {
mode: AssetWatermarkMode;
required: boolean;
text: string;
traceId: string;
position: 'diagonal' | 'bottom-right' | 'center';
opacity: number;
repeat: boolean;
expiresAt: string | null;
renderHint: string;
}
export interface AssetAccessRecordInput {
ctx: RequestContext;
@@ -50,6 +78,7 @@ export interface AssetCdnPolicyInput {
const LOCKED_VISIBILITIES = new Set(['members', 'svip', 'private']);
const SHORT_LIVED_ASSET_TYPES = new Set(['video', 'package']);
const WATERMARKED_ACCESS_TYPES = new Set(['preview', 'download', 'admin_preview', 'admin_download', 'video_play']);
function clientIpFrom(ctx: RequestContext) {
return (getHeader(ctx.req, 'x-forwarded-for').split(',')[0] || getHeader(ctx.req, 'x-real-ip') || ctx.req.socket.remoteAddress || '').trim();
@@ -67,6 +96,92 @@ function boolFlag(...values: unknown[]) {
return values.some(value => value === true || value === 'true');
}
function stringValue(value: unknown) {
return typeof value === 'string' && value.trim() ? value.trim() : '';
}
function safeWatermarkText(value: string, fallback: string) {
const normalized = value.replace(/[\u0000-\u001f\u007f]/g, ' ').replace(/\s+/g, ' ').trim();
return (normalized || fallback).slice(0, 48);
}
function numberValue(value: unknown, fallback: number, min: number, max: number) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) return fallback;
return Math.min(Math.max(parsed, min), max);
}
function watermarkTraceId(input: Pick<AssetWatermarkContextInput, 'tenantId' | 'assetId' | 'userId' | 'accessType' | 'expiresAt'>) {
const seed = [
input.tenantId,
input.assetId || 'no-asset',
input.userId || 'anonymous',
input.accessType,
input.expiresAt || new Date().toISOString(),
].join(':');
return cryptoHash(seed).slice(0, 12).toUpperCase();
}
function cryptoHash(value: string) {
return crypto.createHash('sha256').update(value).digest('hex');
}
function watermarkEnabledByMetadata(metadata: Record<string, unknown>) {
if (metadata.watermark === false || metadata.watermark === 'false') return false;
if (metadata.disableWatermark === true || metadata.disableWatermark === 'true') return false;
return true;
}
export function buildAssetWatermarkContext(input: AssetWatermarkContextInput): AssetWatermarkContext {
const metadata = objectValue(input.metadata);
const watermarkConfig = objectValue(metadata.watermarkConfig);
const requiredByVisibility = LOCKED_VISIBILITIES.has(input.visibility || '');
const requiredByAssetType = SHORT_LIVED_ASSET_TYPES.has(input.assetType || '');
const requiredByAccessType = WATERMARKED_ACCESS_TYPES.has(input.accessType);
const canDisableByMetadata = !requiredByVisibility && !requiredByAssetType;
const metadataEnabled = watermarkEnabledByMetadata(metadata) || !canDisableByMetadata;
const enabled = metadataEnabled && requiredByAccessType && (requiredByVisibility || requiredByAssetType || input.actorRole !== 'anonymous');
const traceId = watermarkTraceId(input);
const defaultText = input.actorRole === 'tenant_content_editor' || input.actorRole === 'tenant_admin' ? '内部资料' : '仅限本人学习';
const baseText = safeWatermarkText(
stringValue(watermarkConfig.text) || stringValue(metadata.watermarkText),
defaultText,
);
const identity = input.userId ? `账号:${cryptoHash(input.userId).slice(0, 8).toUpperCase()}` : '访客';
const text = `${baseText} ${identity} ${traceId}`;
if (!enabled) {
return {
mode: 'none',
required: false,
text: '',
traceId,
position: 'diagonal',
opacity: 0,
repeat: false,
expiresAt: input.expiresAt || null,
renderHint: 'none',
};
}
const position = ['diagonal', 'bottom-right', 'center'].includes(stringValue(watermarkConfig.position))
? stringValue(watermarkConfig.position) as AssetWatermarkContext['position']
: 'diagonal';
return {
mode: 'visible_overlay',
required: true,
text,
traceId,
position,
opacity: numberValue(watermarkConfig.opacity, 0.16, 0.05, 0.35),
repeat: watermarkConfig.repeat === false || watermarkConfig.repeat === 'false' ? false : true,
expiresAt: input.expiresAt || null,
renderHint: 'render_visible_overlay_before_opening_signed_url',
};
}
export function assetAccessTtl(input: AssetAccessPolicyInput) {
const requested = input.requestedExpiresInSec ?? 900;
const minTtl = 60;

View File

@@ -21,6 +21,7 @@ import {
import {
assetAccessTtl,
assertCdnAccessAllowed,
buildAssetWatermarkContext,
recordAssetAccessEvent,
signedAssetFingerprint,
} from '../storage/asset-access.js';
@@ -1041,6 +1042,19 @@ export async function signAssetDownloadAdminRoute(ctx: RequestContext) {
expiresInSec,
disposition: 'attachment',
});
const watermark = buildAssetWatermarkContext({
tenantId: auth.tenantId,
assetId,
userId: auth.userId,
actorRole: 'tenant_content_editor',
accessType: 'admin_download',
visibility: asset.visibility,
assetType: asset.assetType,
fileName: asset.fileName,
title: asset.title,
expiresAt: download.expiresAt,
metadata: asset.metadata,
});
await recordAssetAccessEvent({
ctx,
tenantId: auth.tenantId,
@@ -1055,10 +1069,10 @@ export async function signAssetDownloadAdminRoute(ctx: RequestContext) {
expiresInSec: download.expiresInSec,
signatureMode: download.signatureMode,
result: 'granted',
metadata: { signature: signedAssetFingerprint(download) },
metadata: { signature: signedAssetFingerprint(download), watermark },
});
return { item: asset, download };
return { item: asset, download, watermark };
}
export async function signAssetPreviewAdminRoute(ctx: RequestContext) {
@@ -1135,6 +1149,19 @@ export async function signAssetPreviewAdminRoute(ctx: RequestContext) {
expiresInSec,
disposition: 'inline',
});
const watermark = buildAssetWatermarkContext({
tenantId: auth.tenantId,
assetId,
userId: auth.userId,
actorRole: 'tenant_content_editor',
accessType: 'admin_preview',
visibility: asset.visibility,
assetType: asset.assetType,
fileName,
title: asset.title,
expiresAt: preview.expiresAt,
metadata: asset.metadata,
});
await recordAssetAccessEvent({
ctx,
@@ -1150,7 +1177,7 @@ export async function signAssetPreviewAdminRoute(ctx: RequestContext) {
expiresInSec: preview.expiresInSec,
signatureMode: preview.signatureMode,
result: 'granted',
metadata: { signature: signedAssetFingerprint(preview) },
metadata: { signature: signedAssetFingerprint(preview), watermark },
});
return {
@@ -1162,5 +1189,6 @@ export async function signAssetPreviewAdminRoute(ctx: RequestContext) {
previewStatus: asset.previewStatus,
},
preview,
watermark,
};
}

View File

@@ -15,6 +15,7 @@ import { signStorageDownload, type StorageProviderName } from '../storage/servic
import {
assetAccessTtl,
assertCdnAccessAllowed,
buildAssetWatermarkContext,
signedAssetFingerprint,
} from '../storage/asset-access.js';
import { assertAssetSecurityScanPassed } from '../tenant-content/assets.js';
@@ -368,6 +369,19 @@ export async function videoPlaybackRoute(ctx: RequestContext) {
}
const signed = await signVideoPlayback(tenantId, video);
const watermark = buildAssetWatermarkContext({
tenantId,
assetId: video.assetId,
userId,
actorRole: 'student',
accessType: 'video_play',
visibility: video.assetVisibility || 'svip',
assetType: video.assetType || 'video',
fileName: video.fileName,
title: video.assetTitle || video.title,
expiresAt: signed.expiresAt,
metadata: video.assetMetadata,
});
const token = createPlayToken();
const tokenHash = hashPlayToken(token);
@@ -403,6 +417,7 @@ export async function videoPlaybackRoute(ctx: RequestContext) {
assetId: video.assetId,
freePreviewSeconds: video.freePreviewSeconds,
signature: signedAssetFingerprint(signed),
watermark,
}),
],
);
@@ -416,6 +431,7 @@ export async function videoPlaybackRoute(ctx: RequestContext) {
playEvent: event.rows[0],
playToken: token,
playback: signed,
watermark,
access: {
mode: accessMode,
entitlementId,

View File

@@ -125,6 +125,18 @@ export interface ContentAsset {
previewStatus?: string;
}
export interface AssetWatermarkContext {
mode: 'none' | 'visible_overlay';
required: boolean;
text: string;
traceId: string;
position: 'diagonal' | 'bottom-right' | 'center';
opacity: number;
repeat: boolean;
expiresAt?: string | null;
renderHint: string;
}
export async function loadRegions() {
return apiRequest<{ items?: RegionItem[] }>('/api/catalog/regions');
}
@@ -192,9 +204,9 @@ export async function loadContentAssets(query: { entryId?: string; contentNodeId
}
export async function signAssetPreview(assetId: string) {
return apiRequest<{ preview?: { url?: string }; item?: ContentAsset }>('/api/catalog/assets/preview', { query: { assetId } });
return apiRequest<{ preview?: { url?: string }; item?: ContentAsset; watermark?: AssetWatermarkContext }>('/api/catalog/assets/preview', { query: { assetId } });
}
export async function signAssetDownload(assetId: string) {
return apiRequest<{ download?: { url?: string }; item?: ContentAsset }>('/api/catalog/assets/download', { query: { assetId } });
return apiRequest<{ download?: { url?: string }; item?: ContentAsset; watermark?: AssetWatermarkContext }>('/api/catalog/assets/download', { query: { assetId } });
}

View File

@@ -1,4 +1,5 @@
import { apiRequest } from './api';
import type { AssetWatermarkContext } from './catalog';
export interface QuestionVideoItem {
id: string;
@@ -21,6 +22,7 @@ export interface VideoPlayback {
expiresInSec?: number;
signatureMode?: string;
};
watermark?: AssetWatermarkContext;
access?: Record<string, unknown>;
}