forked from wangziqi/gongxue-base
feat: protect video playback access
This commit is contained in:
@@ -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],
|
||||
];
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user