forked from wangziqi/gongxue-base
feat: add asset watermark access context
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 } });
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user