feat: protect video playback access

This commit is contained in:
Codex
2026-06-28 23:00:05 +08:00
parent db65295ddc
commit 3647c2bc2d
12 changed files with 581 additions and 24 deletions

View File

@@ -1,8 +1,9 @@
import type { RouteDefinition } from '../../core/router.js';
import { questionVideosBatchRoute, questionVideosRoute, videoSearchRoute } from './routes.js';
import { questionVideosBatchRoute, questionVideosRoute, videoPlaybackRoute, videoSearchRoute } from './routes.js';
export const videoRoutes: RouteDefinition[] = [
['GET', '/api/questions/videos', questionVideosRoute],
['POST', '/api/questions/videos/batch', questionVideosBatchRoute],
['GET', '/api/videos/search', videoSearchRoute],
['POST', '/api/videos/play', videoPlaybackRoute],
];

View File

@@ -1,6 +1,17 @@
import { HttpError, type RequestContext } from '../../core/http.js';
import { intParam, optionalStringArray, readJsonBody, stringParam, tenantIdFrom } from '../../core/request.js';
import { query } from '../../core/db.js';
import crypto from 'node:crypto';
import type pg from 'pg';
import { getHeader, HttpError, type RequestContext } from '../../core/http.js';
import {
intParam,
optionalString,
optionalStringArray,
readJsonBody,
stringParam,
tenantIdFrom,
userIdFrom,
} from '../../core/request.js';
import { query, queryOne, transaction } from '../../core/db.js';
import { signStorageDownload, type StorageProviderName } from '../storage/service.js';
interface QuestionVideoRow {
questionId: string;
@@ -17,6 +28,40 @@ interface QuestionVideoRow {
isGeneral: boolean;
subjectId: string | null;
difficulty: number | null;
accessMode: string;
freePreviewSeconds: number;
playable: boolean;
}
interface VideoPlaybackRow {
id: string;
title: string;
description: string | null;
videoUrl: string | null;
thumbnailUrl: string | null;
duration: number | null;
subjectId: string | null;
regionId: string | null;
accessMode: string;
freePreviewSeconds: number;
assetId: string | null;
storageProvider: string | null;
bucket: string | null;
objectKey: string | null;
cdnUrl: string | null;
fileName: string | null;
assetTitle: string | null;
assetStatus: string | null;
}
interface EntitlementAccessRow {
id: string;
}
interface QuotaAccountRow {
id: string;
total_quota: number;
used_quota: number;
}
function videoSelectSql() {
@@ -24,15 +69,111 @@ function videoSelectSql() {
select qv.question_id as "questionId", qv.video_type as "videoType",
qv.sort_order as "order",
v.id, v.legacy_id as "legacyId", v.title, v.description,
v.video_url as "videoUrl", v.thumbnail_url as "thumbnailUrl",
case when v.access_mode = 'free' then v.video_url else null end as "videoUrl",
v.thumbnail_url as "thumbnailUrl",
v.duration_seconds as "duration", v.knowledge_tags as "knowledgeTags",
v.is_general as "isGeneral", v.subject_id as "subjectId",
v.difficulty
v.difficulty, v.access_mode as "accessMode",
v.free_preview_seconds as "freePreviewSeconds",
(v.access_mode = 'free') as playable
from public.question_videos qv
join public.video_explanations v on v.id = qv.video_id and v.tenant_id = qv.tenant_id
`;
}
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 createPlayToken() {
return `vp_${crypto.randomBytes(32).toString('base64url')}`;
}
function hashPlayToken(token: string) {
return crypto.createHash('sha256').update(token).digest('hex');
}
async function activeSvipEntitlement(tenantId: string, userId: string, video: VideoPlaybackRow) {
const now = new Date().toISOString();
return queryOne<EntitlementAccessRow>(
`
select id
from public.entitlements
where tenant_id = $1
and user_id = $2
and entitlement_type = 'svip'
and status = 'active'
and starts_at <= $3::timestamptz
and (expires_at is null or expires_at > $3::timestamptz)
and (
scope_type = 'tenant'
or ($4::uuid is not null and scope_type = 'region' and scope_id = $4::uuid)
or ($5::uuid is not null and scope_type = 'subject' and scope_id = $5::uuid)
)
order by case when scope_type = 'subject' then 0 when scope_type = 'region' then 1 else 2 end,
expires_at desc nulls first
limit 1
`,
[tenantId, userId, now, video.regionId, video.subjectId],
);
}
async function availableVideoQuotaAccount(client: pg.PoolClient, tenantId: string, userId: string) {
const result = await client.query<QuotaAccountRow>(
`
select id, total_quota, used_quota
from public.video_play_quota_accounts
where tenant_id = $1
and user_id = $2
and quota_type = 'video_play'
and total_quota > used_quota
and (expires_at is null or expires_at > now())
order by expires_at asc nulls last, created_at asc
limit 1
for update
`,
[tenantId, userId],
);
return result.rows[0] || null;
}
function isExternalPublicVideo(video: VideoPlaybackRow) {
return video.videoUrl && /^https?:\/\//i.test(video.videoUrl);
}
async function signVideoPlayback(tenantId: string, video: VideoPlaybackRow) {
if (video.assetId) {
if (video.assetStatus !== 'active') {
throw new HttpError(404, 'Video asset is not active', 'VIDEO_ASSET_NOT_FOUND');
}
return signStorageDownload({
tenantId,
provider: video.storageProvider as StorageProviderName,
bucket: video.bucket,
objectKey: video.objectKey,
cdnUrl: video.cdnUrl,
fileName: video.fileName || video.assetTitle || video.title,
expiresInSec: 600,
});
}
if (isExternalPublicVideo(video)) {
return signStorageDownload({
tenantId,
provider: 'external_url',
bucket: null,
objectKey: null,
cdnUrl: video.videoUrl,
fileName: video.title,
expiresInSec: 600,
});
}
throw new HttpError(409, 'Video does not have a playable asset', 'VIDEO_ASSET_REQUIRED');
}
export async function questionVideosRoute(ctx: RequestContext) {
const tenantId = await tenantIdFrom(ctx);
const questionId = stringParam(ctx, 'questionId');
@@ -91,10 +232,14 @@ export async function videoSearchRoute(ctx: RequestContext) {
const videos = await query(
`
select id, legacy_id as "legacyId", title, description,
video_url as "videoUrl", thumbnail_url as "thumbnailUrl",
case when access_mode = 'free' then video_url else null end as "videoUrl",
thumbnail_url as "thumbnailUrl",
duration_seconds as "duration", knowledge_tags as "knowledgeTags",
is_general as "isGeneral", subject_id as "subjectId",
difficulty, sort_order as "order", created_at as "createdAt"
difficulty, access_mode as "accessMode",
free_preview_seconds as "freePreviewSeconds",
(access_mode = 'free') as playable,
sort_order as "order", created_at as "createdAt"
from public.video_explanations
where tenant_id = $1
and is_active = true
@@ -109,3 +254,144 @@ export async function videoSearchRoute(ctx: RequestContext) {
return { videos, total: videos.length };
}
export async function videoPlaybackRoute(ctx: RequestContext) {
const tenantId = await tenantIdFrom(ctx);
const body = await readJsonBody(ctx);
const userId = await userIdFrom(ctx, body);
const videoId = optionalString(body, 'videoId') || stringParam(ctx, 'videoId');
const questionId = optionalString(body, 'questionId') || null;
if (!videoId) throw new HttpError(400, 'videoId is required', 'VIDEO_ID_REQUIRED');
const video = await queryOne<VideoPlaybackRow>(
`
select v.id, v.title, v.description, v.video_url as "videoUrl",
v.thumbnail_url as "thumbnailUrl", v.duration_seconds as "duration",
v.subject_id as "subjectId",
coalesce(qb.region_id, qs.region_id, vs.region_id) as "regionId",
v.access_mode as "accessMode",
v.free_preview_seconds as "freePreviewSeconds",
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"
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
left join public.questions q on q.tenant_id = v.tenant_id and q.id = $3::uuid
left join public.subjects qs on qs.tenant_id = q.tenant_id and qs.id = q.subject_id
left join public.question_banks qb on qb.tenant_id = q.tenant_id and qb.id = q.question_bank_id
where v.tenant_id = $1
and v.id = $2
and v.is_active = true
and (
$3::uuid is null
or exists (
select 1
from public.question_videos qv
where qv.tenant_id = v.tenant_id
and qv.video_id = v.id
and qv.question_id = $3::uuid
)
)
limit 1
`,
[tenantId, videoId, questionId],
);
if (!video) throw new HttpError(404, 'Video not found', 'VIDEO_NOT_FOUND');
const playback = await transaction(async client => {
let entitlementId: string | null = null;
let quotaAccountId: string | null = null;
let consumedQuota = 0;
const accessMode = video.accessMode || 'svip';
if (accessMode === 'svip') {
const entitlement = await activeSvipEntitlement(tenantId, userId, video);
if (!entitlement) throw new HttpError(403, 'SVIP entitlement is required for this video', 'VIDEO_SVIP_REQUIRED');
entitlementId = entitlement.id;
} else if (accessMode === 'video_quota') {
const account = await availableVideoQuotaAccount(client, tenantId, userId);
if (!account) throw new HttpError(403, 'Video play quota is required', 'VIDEO_QUOTA_REQUIRED');
await client.query(
`
update public.video_play_quota_accounts
set used_quota = used_quota + 1, updated_at = now()
where tenant_id = $1 and id = $2
`,
[tenantId, account.id],
);
quotaAccountId = account.id;
consumedQuota = 1;
}
const signed = await signVideoPlayback(tenantId, video);
const token = createPlayToken();
const tokenHash = hashPlayToken(token);
const event = await client.query(
`
insert into public.video_play_events (
tenant_id, user_id, video_id, question_id, entitlement_id, quota_account_id,
play_token_hash, status, access_mode, consumed_quota,
signed_url_expires_at, ip_address, user_agent, metadata
)
values ($1, $2, $3, $4::uuid, $5::uuid, $6::uuid, $7, 'issued', $8, $9,
$10::timestamptz, $11, $12, $13::jsonb)
returning id, created_at as "createdAt"
`,
[
tenantId,
userId,
video.id,
questionId,
entitlementId,
quotaAccountId,
tokenHash,
accessMode,
consumedQuota,
signed.expiresAt,
clientIpFrom(ctx) || null,
userAgentFrom(ctx) || null,
JSON.stringify({
signatureMode: signed.signatureMode,
provider: signed.provider,
bucket: signed.bucket,
objectKey: signed.objectKey,
freePreviewSeconds: video.freePreviewSeconds,
}),
],
);
await client.query(
'update public.video_explanations set play_count = play_count + 1, updated_at = now() where tenant_id = $1 and id = $2',
[tenantId, video.id],
);
return {
playEvent: event.rows[0],
playToken: token,
playback: signed,
access: {
mode: accessMode,
entitlementId,
quotaAccountId,
consumedQuota,
},
};
});
return {
item: {
id: video.id,
title: video.title,
description: video.description,
thumbnailUrl: video.thumbnailUrl,
duration: video.duration,
accessMode: video.accessMode,
freePreviewSeconds: video.freePreviewSeconds,
},
...playback,
};
}