feat: HLS streaming, transcripts, agent course generation & landing-page course previews (#663)
New features: - HLS video encoding/streaming (hls routes, hls-encoder) and a media transcripts pipeline (transcripts route + media-transcript queries) - AI assistant chat vs. run model: agent-course-generation worker/queue, run-monitor pane, chat-changes card, content ask-ai bar - Per-theme course-page previews across org landing-page themes (bold, classic, corporate, editorial, etc.) with a shared course token/component set - View-as-student mode and login-link / link-invite invite flows Refactor: - Extract packages/core consolidating config (env, storage, dashboard-url), services and utils previously duplicated under apps/api/src/config - Remove legacy agent service modules (chat-tools, chat-context, context-window, document, fetch-url, lesson-content) - Relax Cross-Origin-Resource-Policy to allow cross-origin media embedding - Add format script to tenant-router so turbo run format covers it
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { getAccountData, updateUser } from '@api/services/account';
|
||||
import { createViewAsStudentToken, getAccountData, updateUser } from '@api/services/account';
|
||||
|
||||
import { Hono } from '@api/utils/hono';
|
||||
import { ZUpdateProfile } from '@cio/utils/validation/account';
|
||||
@@ -51,6 +51,25 @@ export const accountRouter = new Hono()
|
||||
return handleError(c, error, 'Failed to update profile');
|
||||
}
|
||||
})
|
||||
.post('/view-as-student-token', authMiddleware, async (c) => {
|
||||
const user = c.get('user')!;
|
||||
|
||||
try {
|
||||
// Self sign-in only — mints a login-link token for the current user so they
|
||||
// can be auto-authenticated on the org domain to preview the student experience.
|
||||
const token = await createViewAsStudentToken({ id: user.id, email: user.email });
|
||||
|
||||
return c.json(
|
||||
{
|
||||
success: true,
|
||||
data: { token }
|
||||
},
|
||||
200
|
||||
);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to create view-as-student token');
|
||||
}
|
||||
})
|
||||
.get('/profile', authMiddleware, async (c) => {
|
||||
const user = c.get('user')!;
|
||||
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
import { createBullBoard } from '@bull-board/api';
|
||||
import { BullMQAdapter } from '@bull-board/api/bullMQAdapter';
|
||||
import { HonoAdapter } from '@bull-board/hono';
|
||||
import { serveStatic } from '@hono/node-server/serve-static';
|
||||
import { workbench } from '@getworkbench/hono';
|
||||
import type { Context, Hono, Next } from 'hono';
|
||||
|
||||
import { QUEUE_NAMES, getQueue, isRedisConfigured } from '@cio/jobs';
|
||||
import { env } from '@api/config/env';
|
||||
import { getQueue, isRedisConfigured, listQueueNames } from '@cio/jobs';
|
||||
import { env } from '@cio/core/config/env';
|
||||
|
||||
/**
|
||||
* Mounts the bull-board UI at `/admin/queues` for inspecting BullMQ state
|
||||
* (waiting/active/completed/failed jobs, payloads, retries, etc.).
|
||||
* Mounts the Workbench UI at `/admin/queues` for inspecting BullMQ state
|
||||
* (jobs, flows, metrics, schedulers, retries, etc.).
|
||||
*
|
||||
* Access control:
|
||||
* - Development: open (matches existing dev ergonomics).
|
||||
@@ -29,25 +26,24 @@ export function mountQueueDashboard(app: Hono<any, any, any>): void {
|
||||
const isProduction = env.NODE_ENV === 'production';
|
||||
if (isProduction && !canEnforceProdAuth()) {
|
||||
console.info(
|
||||
'BullMQ dashboard (/admin/queues) not mounted in production. Set QUEUE_DASHBOARD_ADMIN_EMAILS or QUEUE_DASHBOARD_TOKEN to enable it.'
|
||||
'Queue dashboard (/admin/queues) not mounted in production. Set QUEUE_DASHBOARD_ADMIN_EMAILS or QUEUE_DASHBOARD_TOKEN to enable it.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const serverAdapter = new HonoAdapter(serveStatic);
|
||||
serverAdapter.setBasePath('/admin/queues');
|
||||
|
||||
createBullBoard({
|
||||
queues: Object.values(QUEUE_NAMES).map((name) => new BullMQAdapter(getQueue(name))),
|
||||
serverAdapter
|
||||
});
|
||||
|
||||
if (isProduction) {
|
||||
app.use('/admin/queues/*', queueDashboardAuth);
|
||||
app.use('/admin/queues', queueDashboardAuth);
|
||||
}
|
||||
|
||||
app.route('/admin/queues', serverAdapter.registerPlugin());
|
||||
app.route(
|
||||
'/admin/queues',
|
||||
workbench({
|
||||
basePath: '/admin/queues',
|
||||
queues: listQueueNames().map((name) => getQueue(name)),
|
||||
title: 'ClassroomIO Jobs'
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function canEnforceProdAuth(): boolean {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ContentfulStatusCode } from 'hono/utils/http-status';
|
||||
import { Hono } from '@api/utils/hono';
|
||||
import { authMiddleware } from '@api/middlewares/auth';
|
||||
import { orgMemberMiddleware } from '@api/middlewares/org-member';
|
||||
@@ -28,7 +29,7 @@ import {
|
||||
getTokenBalance,
|
||||
isOrgOnPaidPlan,
|
||||
recordTokenUsage
|
||||
} from '@api/services/agent/usage';
|
||||
} from '@cio/core/services/agent/usage';
|
||||
import {
|
||||
enforceStudentTutorPolicy,
|
||||
getStudentTutorStatus,
|
||||
@@ -38,7 +39,7 @@ import {
|
||||
incrementStudentTutorCount
|
||||
} from '@api/services/agent/tutor-usage';
|
||||
import { buildStudentAgentTools } from '@api/services/agent/student-tools';
|
||||
import { parseAndStoreDocument } from '@api/services/agent/document';
|
||||
import { parseAndStoreDocument } from '@cio/core/services/agent/document';
|
||||
import { recordCreditPurchase } from '@api/services/agent/credit-purchase';
|
||||
import { generateCourseMeta } from '@api/services/agent/title-generation';
|
||||
import { generateFieldText } from '@api/services/agent/text-generation';
|
||||
@@ -55,15 +56,15 @@ import {
|
||||
import { createModel, getProviderConfigForProvider, pickAnyConfiguredProvider } from '@cio/ai-assistant/providers';
|
||||
import { AGENT_MODELS, DEFAULT_PICKER_MODEL_ID } from '@cio/utils/agent-models';
|
||||
import { buildSystemPrompt, buildContextMessage } from '@cio/ai-assistant/prompt';
|
||||
import { trackAgentEvent, AgentEvent } from '@api/utils/tinybird';
|
||||
import { redis } from '@api/utils/redis/redis';
|
||||
import { trackAgentEvent, AgentEvent } from '@cio/core/utils/tinybird';
|
||||
import { redis } from '@cio/core/utils/redis/redis';
|
||||
import { db } from '@cio/db';
|
||||
import * as schema from '@cio/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { listCourseSections } from '@api/services/course/section';
|
||||
import { getLesson } from '@api/services/lesson/lesson';
|
||||
import { getExercise } from '@api/services/exercise/exercise';
|
||||
import { sanitizeDanglingToolCalls } from '@api/services/agent/sanitize-tool-calls';
|
||||
import { listCourseSections } from '@cio/core/services/course/section';
|
||||
import { getLesson } from '@cio/core/services/lesson/lesson';
|
||||
import { getExercise } from '@cio/core/services/exercise/exercise';
|
||||
import { sanitizeDanglingToolCalls } from '@cio/core/services/agent/sanitize-tool-calls';
|
||||
import {
|
||||
collectDocumentIds,
|
||||
getActiveCourseTemplateId,
|
||||
@@ -71,10 +72,10 @@ import {
|
||||
loadDocumentsText,
|
||||
verifyExerciseBelongsToCourse,
|
||||
verifyLessonBelongsToCourse
|
||||
} from '@api/services/agent/chat-context';
|
||||
import { buildAgentTools } from '@api/services/agent/chat-tools';
|
||||
import { buildModelContextMessages } from '@api/services/agent/model-context';
|
||||
import { summarizeConversation } from '@api/services/agent/summarize';
|
||||
} from '@cio/core/services/agent/chat-context';
|
||||
import { buildAgentTools, filterToolsForChatMode } from '@cio/core/services/agent/chat-tools';
|
||||
import { buildModelContextMessages } from '@cio/core/services/agent/model-context';
|
||||
import { summarizeConversation } from '@cio/core/services/agent/summarize';
|
||||
import { agentHistoryRouter } from './history';
|
||||
import { agentRunsRouter } from './runs';
|
||||
|
||||
@@ -530,7 +531,7 @@ const agentCoreRouter = new Hono()
|
||||
const agentTools =
|
||||
role === AgentRole.STUDENT
|
||||
? buildStudentAgentTools(orgId, user.id, courseId, studentPolicy!.settings)
|
||||
: buildAgentTools(orgId, user.id, courseId, messages, { isOrgOnPaidPlan: isOrgPaid });
|
||||
: filterToolsForChatMode(buildAgentTools(orgId, user.id, courseId, messages, { isOrgOnPaidPlan: isOrgPaid }));
|
||||
|
||||
const contextManaged = await buildModelContextMessages({
|
||||
conversationId,
|
||||
@@ -677,7 +678,10 @@ const agentCoreRouter = new Hono()
|
||||
});
|
||||
|
||||
if (error instanceof AppError) {
|
||||
return c.json({ success: false, error: error.message, code: error.code }, error.statusCode);
|
||||
return c.json(
|
||||
{ success: false, error: error.message, code: error.code },
|
||||
error.statusCode as ContentfulStatusCode
|
||||
);
|
||||
}
|
||||
|
||||
console.error('Agent chat error:', error);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ZCourseContentDelete, ZCourseContentReorder, ZCourseContentUpdate } from '@cio/utils/validation/course';
|
||||
import { deleteCourseContent, reorderCourseContent, updateCourseContent } from '@api/services/course/content';
|
||||
import { deleteCourseContent, reorderCourseContent, updateCourseContent } from '@cio/core/services/course/content';
|
||||
|
||||
import { Hono } from '@api/utils/hono';
|
||||
import { authOrAutomationKeyMiddleware } from '@api/middlewares/auth-or-automation-key';
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
getCourseAnalytics,
|
||||
getCourseProgress,
|
||||
updateCourse
|
||||
} from '@api/services/course/course';
|
||||
} from '@cio/core/services/course/course';
|
||||
import { assertCertificateDownloadAllowed, evaluateCourseCertification } from '@api/services/course/completion';
|
||||
import { getCourseTags, replaceCourseTags } from '@api/services/tag';
|
||||
|
||||
@@ -62,7 +62,7 @@ import { presignRouter } from '@api/routes/course/presign';
|
||||
import { assertMcpAutomationUsageAllowed, recordMcpAutomationUsage } from '@api/services/organization/automation-usage';
|
||||
import { sectionRouter } from '@api/routes/course/section';
|
||||
import { submissionRouter } from '@api/routes/course/submission';
|
||||
import { updateCourseLandingPageService } from '@api/services/course/landing-page';
|
||||
import { updateCourseLandingPageService } from '@cio/core/services/course/landing-page';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
|
||||
function slugifyForFilename(value: string): string {
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
getExercise,
|
||||
listExercises,
|
||||
updateExerciseService
|
||||
} from '@api/services/exercise/exercise';
|
||||
} from '@cio/core/services/exercise/exercise';
|
||||
import { fetchAllTemplatesMetadata, fetchTemplateById, fetchTemplatesByTag } from '@api/services/exercise/template';
|
||||
import { getGroupMemberIdByCourseAndProfile, isCourseTeamMemberOrOrgAdmin } from '@cio/db/queries/group';
|
||||
import {
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
listLessonLanguages,
|
||||
updateLessonLanguageService,
|
||||
upsertLessonLanguageService
|
||||
} from '@api/services/lesson-language';
|
||||
} from '@cio/core/services/lesson-language';
|
||||
|
||||
import { Hono } from '@api/utils/hono';
|
||||
import type { TLocale } from '@db/types';
|
||||
|
||||
@@ -9,7 +9,7 @@ import { addMembers, deleteMember, listCourseMembers, updateMember } from '@api/
|
||||
import { Hono } from '@api/utils/hono';
|
||||
import { ZCourseUserAnalyticsParam } from '@cio/utils/validation/course';
|
||||
import { courseTeamMemberMiddleware } from '@api/middlewares/course-team-member';
|
||||
import { getUserCourseAnalytics } from '@api/services/course/course';
|
||||
import { getUserCourseAnalytics } from '@cio/core/services/course/course';
|
||||
import { handleError } from '@api/utils/errors';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
|
||||
|
||||
@@ -9,11 +9,11 @@ import {
|
||||
generateDocumentUploadPresignedUrl,
|
||||
generateVideoDownloadPresignedUrls,
|
||||
generateVideoUploadPresignedUrl
|
||||
} from '@api/utils/s3';
|
||||
} from '@cio/core/utils/s3';
|
||||
|
||||
import { Hono } from '@api/utils/hono';
|
||||
import { authMiddleware } from '@api/middlewares/auth';
|
||||
import { generateFileKey } from '@api/utils/upload';
|
||||
import { generateFileKey } from '@cio/core/utils/upload';
|
||||
|
||||
// Response schemas for OpenAPI documentation
|
||||
const PresignUploadResponse = {
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
promoteUngroupedSection,
|
||||
reorderCourseSections,
|
||||
updateCourseSectionService
|
||||
} from '@api/services/course/section';
|
||||
} from '@cio/core/services/course/section';
|
||||
import {
|
||||
ZCourseSectionCreate,
|
||||
ZCourseSectionGetParam,
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* HLS playback streaming.
|
||||
*
|
||||
* Same URL shape as the production Cloudflare Worker that fronts the
|
||||
* tenant-router (`/hls/{assetId}/{path}`). In production the Worker
|
||||
* intercepts these requests at the edge and serves directly from an R2
|
||||
* binding, so Render is never in the bytestream. This Hono route only
|
||||
* fires in environments where the Worker isn't in front of the api —
|
||||
* primarily local development, but it also acts as a defensive fallback
|
||||
* if the Worker route ever misfires.
|
||||
*
|
||||
* Auth: standard session via `authMiddleware`. We then verify the
|
||||
* caller's session belongs to the same organization that owns the
|
||||
* asset. No separate `cio_hls` HMAC cookie is needed here — that's only
|
||||
* how the Worker authorizes requests in production.
|
||||
*/
|
||||
|
||||
import { GetObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3';
|
||||
import { getAssetById } from '@cio/db/queries/assets';
|
||||
import { getS3Client, getStorageConfig } from '@cio/core/config/storage';
|
||||
import { HLS_COOKIE_NAME, verifyHlsToken } from '@cio/core/services/assets/assets';
|
||||
import { resolveTrustedBrowserOriginForCors } from '@api/utils/origins';
|
||||
import { getCookie } from 'hono/cookie';
|
||||
|
||||
import { Hono } from '@api/utils/hono';
|
||||
|
||||
const HLS_CONTENT_TYPES: Record<string, string> = {
|
||||
m3u8: 'application/vnd.apple.mpegurl',
|
||||
ts: 'video/mp2t',
|
||||
m4s: 'video/iso.segment',
|
||||
mp4: 'video/mp4',
|
||||
m4a: 'audio/mp4',
|
||||
aac: 'audio/aac'
|
||||
};
|
||||
|
||||
function contentTypeFor(path: string): string {
|
||||
const dot = path.lastIndexOf('.');
|
||||
if (dot === -1) return 'application/octet-stream';
|
||||
|
||||
const ext = path.slice(dot + 1).toLowerCase();
|
||||
return HLS_CONTENT_TYPES[ext] ?? 'application/octet-stream';
|
||||
}
|
||||
|
||||
export const hlsRouter = new Hono()
|
||||
.options('/:assetId/*', (c) => {
|
||||
const origin = c.req.header('origin');
|
||||
let corsHeaders: Record<string, string> = {};
|
||||
if (origin) {
|
||||
const allowed = resolveTrustedBrowserOriginForCors(origin);
|
||||
if (allowed) {
|
||||
corsHeaders = {
|
||||
'Access-Control-Allow-Origin': allowed,
|
||||
'Access-Control-Allow-Credentials': 'true',
|
||||
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||
'Access-Control-Max-Age': '600',
|
||||
'Cross-Origin-Resource-Policy': 'cross-origin'
|
||||
};
|
||||
}
|
||||
}
|
||||
return new Response(null, { status: 204, headers: corsHeaders });
|
||||
})
|
||||
/**
|
||||
* GET/HEAD /hls/:assetId/* — stream an HLS manifest or segment from R2.
|
||||
*
|
||||
* No `authMiddleware` here — public lessons play through the same route
|
||||
* using only the HMAC cookie. The handler validates either the cookie
|
||||
* or org-member session itself.
|
||||
*/
|
||||
.on(['GET', 'HEAD'], '/:assetId/*', async (c) => {
|
||||
const origin = c.req.header('origin');
|
||||
let corsHeaders: Record<string, string> = {};
|
||||
if (origin) {
|
||||
const allowed = resolveTrustedBrowserOriginForCors(origin);
|
||||
if (allowed) {
|
||||
corsHeaders = {
|
||||
'Access-Control-Allow-Origin': allowed,
|
||||
'Access-Control-Allow-Credentials': 'true',
|
||||
// Override the `Cross-Origin-Resource-Policy: same-origin` that
|
||||
// app-wide `secureHeaders()` sets — the dashboard origin needs to
|
||||
// load `<video>` segments from this api origin in local dev /
|
||||
// self-hosted, and CORP=same-origin blocks the response even
|
||||
// when CORS allows the request.
|
||||
'Cross-Origin-Resource-Policy': 'cross-origin'
|
||||
};
|
||||
}
|
||||
}
|
||||
const withCors = (body: BodyInit | null, status: number, extraHeaders?: Record<string, string>) =>
|
||||
new Response(body, { status, headers: { ...corsHeaders, ...extraHeaders } });
|
||||
|
||||
const assetId = c.req.param('assetId');
|
||||
if (!assetId) return withCors(null, 400, { 'Content-Type': 'application/json' });
|
||||
|
||||
const fullPath = c.req.path;
|
||||
const marker = `/hls/${assetId}/`;
|
||||
const idx = fullPath.indexOf(marker);
|
||||
const relative = idx === -1 ? '' : fullPath.slice(idx + marker.length);
|
||||
if (!relative || relative.includes('..')) {
|
||||
return withCors(null, 400, { 'Content-Type': 'application/json' });
|
||||
}
|
||||
|
||||
let authorized = false;
|
||||
let denyReason: 'no_token_or_session' | 'token_invalid' | 'session_no_role' | 'asset_missing' =
|
||||
'no_token_or_session';
|
||||
const token = getCookie(c, HLS_COOKIE_NAME);
|
||||
if (token) {
|
||||
const verified = await verifyHlsToken(token);
|
||||
if (verified?.assetId === assetId) {
|
||||
authorized = true;
|
||||
} else {
|
||||
denyReason = 'token_invalid';
|
||||
}
|
||||
}
|
||||
if (!authorized) {
|
||||
const orgRoles = (c.get('orgRoles') as Record<string, number> | undefined) ?? {};
|
||||
if (Object.keys(orgRoles).length) {
|
||||
const asset = await getAssetById(assetId);
|
||||
if (!asset) {
|
||||
denyReason = 'asset_missing';
|
||||
} else if (orgRoles[asset.organizationId] !== undefined) {
|
||||
// role 0 still counts as membership — match `orgMemberMiddleware`'s
|
||||
// `roleId === undefined` rejection rule instead of a truthy check.
|
||||
authorized = true;
|
||||
} else {
|
||||
denyReason = 'session_no_role';
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!authorized) {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.warn(
|
||||
`[hls] 403 for asset=${assetId} reason=${denyReason} cookiePresent=${Boolean(token)} hasSession=${Boolean(c.get('user'))}`
|
||||
);
|
||||
}
|
||||
return withCors(null, 403, { 'Content-Type': 'application/json' });
|
||||
}
|
||||
|
||||
const config = getStorageConfig();
|
||||
const client = getS3Client();
|
||||
const objectKey = `${assetId}/${relative}`;
|
||||
|
||||
try {
|
||||
if (c.req.method === 'HEAD') {
|
||||
const head = await client.send(new HeadObjectCommand({ Bucket: config.bucketVideos, Key: objectKey }));
|
||||
const headHeaders: Record<string, string> = {
|
||||
'Content-Type': contentTypeFor(relative),
|
||||
'Cache-Control': relative.endsWith('.m3u8') ? 'private, max-age=10' : 'private, max-age=31536000, immutable'
|
||||
};
|
||||
if (head.ContentLength != null) headHeaders['Content-Length'] = String(head.ContentLength);
|
||||
return withCors(null, 200, headHeaders);
|
||||
}
|
||||
|
||||
const result = await client.send(new GetObjectCommand({ Bucket: config.bucketVideos, Key: objectKey }));
|
||||
if (!result.Body) {
|
||||
return withCors(null, 502);
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': contentTypeFor(relative),
|
||||
'Cache-Control': relative.endsWith('.m3u8') ? 'private, max-age=10' : 'private, max-age=31536000, immutable'
|
||||
};
|
||||
if (result.ContentLength != null) headers['Content-Length'] = String(result.ContentLength);
|
||||
|
||||
return withCors(result.Body as unknown as ReadableStream, 200, headers);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'NoSuchKey') {
|
||||
return withCors(null, 404);
|
||||
}
|
||||
console.error('hls stream error:', error);
|
||||
return withCors(null, 500);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export * from './hls';
|
||||
@@ -2,9 +2,11 @@ import { ZCourseInviteTokenParam } from '@cio/utils/validation/course/invite';
|
||||
import { previewStudentInvite } from '@api/services/course/invite';
|
||||
import { ZOrganizationInviteTokenParam } from '@cio/utils/validation/organization/invite';
|
||||
import {
|
||||
acceptLinkInvite,
|
||||
acceptOrganizationInvite,
|
||||
acceptOrganizationInviteById,
|
||||
getPendingOrgInviteForUser,
|
||||
previewLinkInvite,
|
||||
previewOrganizationInvite
|
||||
} from '@api/services/organization/invite';
|
||||
|
||||
@@ -42,6 +44,24 @@ const acceptOrganizationInviteRateLimit = createRateLimiter({
|
||||
}
|
||||
});
|
||||
|
||||
const previewLinkInviteRateLimit = createRateLimiter({
|
||||
windowMs: 60 * 1000,
|
||||
maxRequests: 30,
|
||||
message: 'Too many link invite preview requests. Please try again later.',
|
||||
keyGenerator: (c) => `link_invite_preview:${extractClientIp(c)}:${c.req.param('token')}`
|
||||
});
|
||||
|
||||
const acceptLinkInviteRateLimit = createRateLimiter({
|
||||
windowMs: 60 * 60 * 1000,
|
||||
maxRequests: 20,
|
||||
message: 'Too many link invite join attempts. Please try again later.',
|
||||
keyGenerator: (c) => {
|
||||
const user = c.get('user');
|
||||
const actor = user?.id ? `user:${user.id}` : `ip:${extractClientIp(c)}`;
|
||||
return `link_invite_accept:${actor}:${c.req.param('token')}`;
|
||||
}
|
||||
});
|
||||
|
||||
const pendingInviteRateLimit = createRateLimiter({
|
||||
windowMs: 60 * 1000,
|
||||
maxRequests: 30,
|
||||
@@ -203,4 +223,50 @@ export const inviteRouter = new Hono()
|
||||
return handleError(c, error, 'Failed to accept organization invite');
|
||||
}
|
||||
}
|
||||
)
|
||||
/**
|
||||
* GET /invite/link/:token/preview
|
||||
* Server-only preview for link invites (API key)
|
||||
*/
|
||||
.get(
|
||||
'/link/:token/preview',
|
||||
apiKeyMiddleware,
|
||||
previewLinkInviteRateLimit,
|
||||
zValidator('param', ZOrganizationInviteTokenParam),
|
||||
async (c) => {
|
||||
try {
|
||||
const { token } = c.req.valid('param');
|
||||
const preview = await previewLinkInvite(token, {
|
||||
ipAddress: extractClientIp(c),
|
||||
userAgent: c.req.header('user-agent') || null
|
||||
});
|
||||
return c.json({ success: true, data: preview }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to load link invite');
|
||||
}
|
||||
}
|
||||
)
|
||||
/**
|
||||
* POST /invite/link/:token/accept
|
||||
* Authenticated link invite acceptance — adds the user to the org
|
||||
*/
|
||||
.post(
|
||||
'/link/:token/accept',
|
||||
authMiddleware,
|
||||
acceptLinkInviteRateLimit,
|
||||
zValidator('param', ZOrganizationInviteTokenParam),
|
||||
async (c) => {
|
||||
try {
|
||||
const { token } = c.req.valid('param');
|
||||
const user = c.get('user')!;
|
||||
const result = await acceptLinkInvite(
|
||||
token,
|
||||
{ id: user.id, email: user.email },
|
||||
{ ipAddress: extractClientIp(c), userAgent: c.req.header('user-agent') || null }
|
||||
);
|
||||
return c.json({ success: true, data: result }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to accept link invite');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { ZPublicCourseBySlugParam, ZPublicCourseItemParam } from '@cio/utils/validation/course';
|
||||
import { getPublicCourseItemService, getPublicCourseTreeService } from '@api/services/course/public-course';
|
||||
import {
|
||||
getPublicCourseItemService,
|
||||
getPublicCourseTreeService,
|
||||
issuePublicHlsCookieService
|
||||
} from '@api/services/course/public-course';
|
||||
|
||||
import { Hono } from '@api/utils/hono';
|
||||
import { handleError } from '@api/utils/errors';
|
||||
import { setCookie } from 'hono/cookie';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
|
||||
/**
|
||||
@@ -52,4 +57,30 @@ export const publicCourseRouter = new Hono()
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to load public course item');
|
||||
}
|
||||
})
|
||||
/**
|
||||
* POST /org-site/course/:courseSlug/item/:itemSlug/hls-cookie
|
||||
* Anonymous-safe. Verifies that the public lesson exposes an HLS asset
|
||||
* and mints the short-lived `cio_hls` cookie the tenant-router Worker
|
||||
* (prod) and API streaming route (local) both validate before serving
|
||||
* segments. Caller is expected to call this once per lesson page load
|
||||
* before attaching hls.js.
|
||||
*/
|
||||
.post('/:courseSlug/item/:itemSlug/hls-cookie', zValidator('param', ZPublicCourseItemParam), async (c) => {
|
||||
try {
|
||||
const { courseSlug, itemSlug } = c.req.valid('param');
|
||||
const result = await issuePublicHlsCookieService(courseSlug, itemSlug);
|
||||
|
||||
setCookie(c, result.cookieName, result.token, {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'Lax',
|
||||
maxAge: result.maxAgeSeconds
|
||||
});
|
||||
|
||||
return c.json({ success: true as const, data: { expiresAt: result.expiresAt } });
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to issue HLS cookie');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -6,23 +6,39 @@ import {
|
||||
ZAssetGetParam,
|
||||
ZAssetListQuery,
|
||||
ZAssetStorageQuery,
|
||||
ZAssetThumbnailSelect,
|
||||
ZAssetUpdate,
|
||||
ZAssetUsageParams,
|
||||
ZBatchPresignHls,
|
||||
ZBatchPresignHls1080,
|
||||
ZFinalizeHls1080,
|
||||
ZFinalizeHlsAsset,
|
||||
ZInitHlsAsset,
|
||||
ZYouTubeMetadataQuery
|
||||
} from '@cio/utils/validation/assets';
|
||||
import { ZUpdateTranscript } from '@cio/utils/validation/media';
|
||||
import {
|
||||
abortHlsAssetService,
|
||||
attachAssetService,
|
||||
batchPresignHlsService,
|
||||
batchPresignHls1080Service,
|
||||
createAssetFromUploadService,
|
||||
deleteAssetService,
|
||||
detachAssetService,
|
||||
exportOrganizationAssetsService,
|
||||
finalizeHls1080Service,
|
||||
finalizeHlsAssetService,
|
||||
getAssetService,
|
||||
getAssetUsageGraphService,
|
||||
getOrganizationAssetStorageService,
|
||||
getTranscriptForOrganizationAssetService,
|
||||
getYouTubeMetadataService,
|
||||
initHlsAssetService,
|
||||
issueHlsCookieService,
|
||||
listOrganizationAssetsService,
|
||||
updateAssetService
|
||||
selectAssetThumbnailService,
|
||||
updateAssetService,
|
||||
updateTranscriptForOrganizationAssetService
|
||||
} from '@api/services/assets';
|
||||
|
||||
import { Hono } from '@api/utils/hono';
|
||||
@@ -31,6 +47,7 @@ import { handleError } from '@api/utils/errors';
|
||||
import { orgAdminMiddleware } from '@api/middlewares/org-admin';
|
||||
import { orgMemberMiddleware } from '@api/middlewares/org-member';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { setCookie } from 'hono/cookie';
|
||||
|
||||
export const assetsRouter = new Hono()
|
||||
/**
|
||||
@@ -82,6 +99,160 @@ export const assetsRouter = new Hono()
|
||||
return handleError(c, error, 'Failed to create asset');
|
||||
}
|
||||
})
|
||||
/**
|
||||
* POST /organization/assets/hls/init
|
||||
* Reserve an assetId for an HLS upload. Returns the asset id and the
|
||||
* key prefix the browser encoder should write segments under.
|
||||
*/
|
||||
.post('/hls/init', authMiddleware, orgMemberMiddleware, zValidator('json', ZInitHlsAsset), async (c) => {
|
||||
try {
|
||||
const orgId = c.req.header('cio-org-id')!;
|
||||
const user = c.get('user')!;
|
||||
const data = c.req.valid('json');
|
||||
const result = await initHlsAssetService(orgId, user.id, data);
|
||||
|
||||
return c.json({ success: true, data: result }, 201);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to init HLS asset');
|
||||
}
|
||||
})
|
||||
/**
|
||||
* POST /organization/assets/:assetId/hls/presign
|
||||
* Batch-presign PUT URLs for segment/playlist/audio/thumbnail uploads.
|
||||
*/
|
||||
.post(
|
||||
'/:assetId/hls/presign',
|
||||
authMiddleware,
|
||||
orgMemberMiddleware,
|
||||
zValidator('param', ZAssetGetParam),
|
||||
zValidator('json', ZBatchPresignHls),
|
||||
async (c) => {
|
||||
try {
|
||||
const orgId = c.req.header('cio-org-id')!;
|
||||
const { assetId } = c.req.valid('param');
|
||||
const data = c.req.valid('json');
|
||||
const result = await batchPresignHlsService(orgId, assetId, data);
|
||||
|
||||
return c.json({ success: true, data: result }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to presign HLS uploads');
|
||||
}
|
||||
}
|
||||
)
|
||||
/**
|
||||
* POST /organization/assets/:assetId/hls/finalize
|
||||
* Flip an HLS asset from `processing` to `active` once the browser
|
||||
* encoder has written everything and persist its metadata.
|
||||
*/
|
||||
.post(
|
||||
'/:assetId/hls/finalize',
|
||||
authMiddleware,
|
||||
orgMemberMiddleware,
|
||||
zValidator('param', ZAssetGetParam),
|
||||
zValidator('json', ZFinalizeHlsAsset),
|
||||
async (c) => {
|
||||
try {
|
||||
const orgId = c.req.header('cio-org-id')!;
|
||||
const user = c.get('user')!;
|
||||
const { assetId } = c.req.valid('param');
|
||||
const data = c.req.valid('json');
|
||||
const result = await finalizeHlsAssetService(orgId, assetId, user.id, data);
|
||||
|
||||
return c.json({ success: true, data: result }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to finalize HLS asset');
|
||||
}
|
||||
}
|
||||
)
|
||||
/**
|
||||
* POST /organization/assets/:assetId/hls/1080/presign
|
||||
* Batch-presign PUT URLs for a manual p1080 rendition on an active HLS asset.
|
||||
*/
|
||||
.post(
|
||||
'/:assetId/hls/1080/presign',
|
||||
authMiddleware,
|
||||
orgMemberMiddleware,
|
||||
zValidator('param', ZAssetGetParam),
|
||||
zValidator('json', ZBatchPresignHls1080),
|
||||
async (c) => {
|
||||
try {
|
||||
const orgId = c.req.header('cio-org-id')!;
|
||||
const { assetId } = c.req.valid('param');
|
||||
const data = c.req.valid('json');
|
||||
const result = await batchPresignHls1080Service(orgId, assetId, data);
|
||||
|
||||
return c.json({ success: true, data: result }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to presign 1080p HLS uploads');
|
||||
}
|
||||
}
|
||||
)
|
||||
/**
|
||||
* POST /organization/assets/:assetId/hls/1080/finalize
|
||||
* Persist p1080 rendition metadata after the browser encoder updates the manifest.
|
||||
*/
|
||||
.post(
|
||||
'/:assetId/hls/1080/finalize',
|
||||
authMiddleware,
|
||||
orgMemberMiddleware,
|
||||
zValidator('param', ZAssetGetParam),
|
||||
zValidator('json', ZFinalizeHls1080),
|
||||
async (c) => {
|
||||
try {
|
||||
const orgId = c.req.header('cio-org-id')!;
|
||||
const { assetId } = c.req.valid('param');
|
||||
const data = c.req.valid('json');
|
||||
const result = await finalizeHls1080Service(orgId, assetId, data);
|
||||
|
||||
return c.json({ success: true, data: result }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to finalize 1080p HLS rendition');
|
||||
}
|
||||
}
|
||||
)
|
||||
/**
|
||||
* DELETE /organization/assets/:assetId/hls
|
||||
* Abort a `processing` HLS asset: best-effort delete R2 objects under
|
||||
* `videos/{assetId}/` and remove the asset row. Called by the encoder
|
||||
* when it errors or is aborted mid-upload, so we don't leak stale
|
||||
* `processing` rows + partial segment trees.
|
||||
*/
|
||||
.delete('/:assetId/hls', authMiddleware, orgMemberMiddleware, zValidator('param', ZAssetGetParam), async (c) => {
|
||||
try {
|
||||
const orgId = c.req.header('cio-org-id')!;
|
||||
const { assetId } = c.req.valid('param');
|
||||
const deleted = await abortHlsAssetService(orgId, assetId);
|
||||
|
||||
return c.json({ success: true, data: deleted }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to abort HLS asset');
|
||||
}
|
||||
})
|
||||
/**
|
||||
* POST /organization/assets/:assetId/hls/cookie
|
||||
* After entitlement check, set a 15-minute HMAC-signed cookie scoped to
|
||||
* this asset id. The tenant-router Worker verifies the cookie locally
|
||||
* on every /hls/{assetId}/* segment request — no round-trip back to Render.
|
||||
*/
|
||||
.post('/:assetId/hls/cookie', authMiddleware, orgMemberMiddleware, zValidator('param', ZAssetGetParam), async (c) => {
|
||||
try {
|
||||
const orgId = c.req.header('cio-org-id')!;
|
||||
const { assetId } = c.req.valid('param');
|
||||
const { token, expiresAt, cookieName, maxAgeSeconds } = await issueHlsCookieService(orgId, assetId);
|
||||
|
||||
setCookie(c, cookieName, token, {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'Lax',
|
||||
maxAge: maxAgeSeconds
|
||||
});
|
||||
|
||||
return c.json({ success: true, data: { expiresAt } }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to issue HLS cookie');
|
||||
}
|
||||
})
|
||||
/**
|
||||
* GET /organization/assets/export
|
||||
* Export organization assets with usage graph
|
||||
@@ -172,6 +343,36 @@ export const assetsRouter = new Hono()
|
||||
return handleError(c, error, 'Failed to fetch transcript');
|
||||
}
|
||||
})
|
||||
/**
|
||||
* PUT /organization/assets/:assetId/transcript
|
||||
* Update transcript segment text (org admins only). Captions re-render from
|
||||
* these segments — no R2 write.
|
||||
*/
|
||||
.put(
|
||||
'/:assetId/transcript',
|
||||
authMiddleware,
|
||||
orgAdminMiddleware,
|
||||
zValidator('param', ZAssetGetParam),
|
||||
zValidator('json', ZUpdateTranscript),
|
||||
async (c) => {
|
||||
try {
|
||||
const orgId = c.req.header('cio-org-id')!;
|
||||
const { assetId } = c.req.valid('param');
|
||||
const { segments } = c.req.valid('json');
|
||||
const data = await updateTranscriptForOrganizationAssetService(orgId, assetId, segments);
|
||||
|
||||
return c.json(
|
||||
{
|
||||
success: true,
|
||||
data
|
||||
},
|
||||
200
|
||||
);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to update transcript');
|
||||
}
|
||||
}
|
||||
)
|
||||
/**
|
||||
* GET /organization/assets/:assetId
|
||||
* Get a single asset by id
|
||||
@@ -222,6 +423,35 @@ export const assetsRouter = new Hono()
|
||||
}
|
||||
}
|
||||
)
|
||||
/**
|
||||
* PUT /organization/assets/:assetId/thumbnail
|
||||
* Choose a thumbnail for an asset (from generated candidates or a fresh upload).
|
||||
*/
|
||||
.put(
|
||||
'/:assetId/thumbnail',
|
||||
authMiddleware,
|
||||
orgMemberMiddleware,
|
||||
zValidator('param', ZAssetGetParam),
|
||||
zValidator('json', ZAssetThumbnailSelect),
|
||||
async (c) => {
|
||||
try {
|
||||
const orgId = c.req.header('cio-org-id')!;
|
||||
const { assetId } = c.req.valid('param');
|
||||
const { thumbnailUrl } = c.req.valid('json');
|
||||
const updated = await selectAssetThumbnailService(orgId, assetId, thumbnailUrl);
|
||||
|
||||
return c.json(
|
||||
{
|
||||
success: true,
|
||||
data: updated
|
||||
},
|
||||
200
|
||||
);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to update asset thumbnail');
|
||||
}
|
||||
}
|
||||
)
|
||||
/**
|
||||
* DELETE /organization/assets/:assetId
|
||||
* Delete an unused asset
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
ZAssignAudienceCourses,
|
||||
ZAudienceInviteByEmail,
|
||||
ZCancelOrgPlan,
|
||||
ZCreateLinkInvite,
|
||||
ZCreateOrgPlan,
|
||||
ZCreateOrganization,
|
||||
ZGetAudienceQuery,
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
ZInviteTeamMembers,
|
||||
ZLMSExercisesParam,
|
||||
ZRemoveTeamMember,
|
||||
ZToggleLinkInvite,
|
||||
ZUpdateOrgPlan,
|
||||
ZUpdateOrganization
|
||||
} from '@cio/utils/validation/organization';
|
||||
@@ -57,7 +59,12 @@ import { automationRouter } from '@api/routes/organization/automation';
|
||||
import { courseImportRouter } from '@api/routes/organization/course-import';
|
||||
import { getLMSExercisesService } from '@api/services/exercise';
|
||||
import { handleError } from '@api/utils/errors';
|
||||
import { inviteTeamMembers } from '@api/services/organization/invite';
|
||||
import {
|
||||
fetchOrgLinkInvite,
|
||||
getOrCreateLinkInvite,
|
||||
inviteTeamMembers,
|
||||
toggleOrgLinkInvite
|
||||
} from '@api/services/organization/invite';
|
||||
import { orgAdminMiddleware } from '@api/middlewares/org-admin';
|
||||
import { orgMemberMiddleware } from '@api/middlewares/org-member';
|
||||
import { orgMemberOrAutomationKeyMiddleware } from '@api/middlewares/org-member-or-automation-key';
|
||||
@@ -178,6 +185,49 @@ export const organizationRouter = new Hono()
|
||||
return handleError(c, error, 'Failed to invite team members');
|
||||
}
|
||||
})
|
||||
/**
|
||||
* GET /organization/link-invite
|
||||
* Returns the current org link invite (null if none created yet)
|
||||
*/
|
||||
.get('/link-invite', authMiddleware, orgAdminMiddleware, async (c) => {
|
||||
try {
|
||||
const orgId = c.req.header('cio-org-id')!;
|
||||
const data = await fetchOrgLinkInvite(orgId);
|
||||
return c.json({ success: true, data }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to fetch link invite');
|
||||
}
|
||||
})
|
||||
/**
|
||||
* POST /organization/link-invite
|
||||
* Get-or-create the org link invite
|
||||
*/
|
||||
.post('/link-invite', authMiddleware, orgAdminMiddleware, zValidator('json', ZCreateLinkInvite), async (c) => {
|
||||
try {
|
||||
const orgId = c.req.header('cio-org-id')!;
|
||||
const user = c.get('user')!;
|
||||
const { roleId } = c.req.valid('json');
|
||||
const data = await getOrCreateLinkInvite(orgId, roleId, user.id);
|
||||
return c.json({ success: true, data }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to create link invite');
|
||||
}
|
||||
})
|
||||
/**
|
||||
* PATCH /organization/link-invite
|
||||
* Enable or disable the org link invite
|
||||
*/
|
||||
.patch('/link-invite', authMiddleware, orgAdminMiddleware, zValidator('json', ZToggleLinkInvite), async (c) => {
|
||||
try {
|
||||
const orgId = c.req.header('cio-org-id')!;
|
||||
const user = c.get('user')!;
|
||||
const { isRevoked } = c.req.valid('json');
|
||||
const data = await toggleOrgLinkInvite(orgId, isRevoked, user.id);
|
||||
return c.json({ success: true, data }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to update link invite');
|
||||
}
|
||||
})
|
||||
/**
|
||||
* DELETE /organization/team/:memberId
|
||||
* Removes a team member from an organization
|
||||
@@ -332,12 +382,17 @@ export const organizationRouter = new Hono()
|
||||
*/
|
||||
.get('/courses/public', zValidator('query', ZGetCoursesBySiteName), async (c) => {
|
||||
try {
|
||||
const { siteName, tags } = c.req.valid('query');
|
||||
const { siteName, tags, page, limit } = c.req.valid('query');
|
||||
const tagSlugs = tags
|
||||
?.split(',')
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean);
|
||||
const courses = await getPublicCourses(siteName, tagSlugs && tagSlugs.length > 0 ? tagSlugs : undefined);
|
||||
const pagination = page !== undefined || limit !== undefined ? { page, limit } : undefined;
|
||||
const courses = await getPublicCourses(
|
||||
siteName,
|
||||
tagSlugs && tagSlugs.length > 0 ? tagSlugs : undefined,
|
||||
pagination
|
||||
);
|
||||
|
||||
return c.json(
|
||||
{
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from './transcripts';
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Transcript VTT proxy.
|
||||
*
|
||||
* `<track>` elements inside `<video crossorigin="anonymous">` can only
|
||||
* load resources that return CORS headers. We serve the VTT through the
|
||||
* api the same way we do HLS segments, rendering it on the fly from the
|
||||
* stored transcript segments (the structured source of truth) so edits
|
||||
* are reflected immediately without an R2 round trip. The path is
|
||||
* same-origin behind the tenant-router Worker in prod and a CORS-allowed
|
||||
* cross-origin call in local dev.
|
||||
*
|
||||
* Auth mirrors `/hls/*`:
|
||||
* 1. Valid `cio_hls` HMAC cookie whose `aid` matches the assetId, OR
|
||||
* 2. Authenticated session with org-member access to the asset's org.
|
||||
*/
|
||||
|
||||
import { getAssetById } from '@cio/db/queries/assets';
|
||||
import { getMediaTranscriptByAsset } from '@cio/db/queries';
|
||||
import { segmentsToWebVtt } from '@cio/utils/functions';
|
||||
import { HLS_COOKIE_NAME, verifyHlsToken } from '@cio/core/services/assets/assets';
|
||||
import { resolveTrustedBrowserOriginForCors } from '@api/utils/origins';
|
||||
import { getCookie } from 'hono/cookie';
|
||||
|
||||
import { Hono } from '@api/utils/hono';
|
||||
|
||||
export const transcriptsRouter = new Hono()
|
||||
.options('/:assetId/track.vtt', (c) => {
|
||||
const origin = c.req.header('origin');
|
||||
let corsHeaders: Record<string, string> = {};
|
||||
if (origin) {
|
||||
const allowed = resolveTrustedBrowserOriginForCors(origin);
|
||||
if (allowed) {
|
||||
corsHeaders = {
|
||||
'Access-Control-Allow-Origin': allowed,
|
||||
'Access-Control-Allow-Credentials': 'true',
|
||||
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||
'Access-Control-Max-Age': '600',
|
||||
'Cross-Origin-Resource-Policy': 'cross-origin'
|
||||
};
|
||||
}
|
||||
}
|
||||
return new Response(null, { status: 204, headers: corsHeaders });
|
||||
})
|
||||
/**
|
||||
* GET/HEAD /transcripts/:assetId/track.vtt — render a transcript VTT
|
||||
* from the stored segments with cross-origin CORS so a `<track crossorigin>`
|
||||
* element in the dashboard can load it.
|
||||
*/
|
||||
.on(['GET', 'HEAD'], '/:assetId/track.vtt', async (c) => {
|
||||
const assetId = c.req.param('assetId');
|
||||
if (!assetId) return c.json({ success: false, message: 'Missing assetId' }, 400);
|
||||
|
||||
const origin = c.req.header('origin');
|
||||
let corsHeaders: Record<string, string> = {};
|
||||
if (origin) {
|
||||
const allowed = resolveTrustedBrowserOriginForCors(origin);
|
||||
if (allowed) {
|
||||
corsHeaders = {
|
||||
'Access-Control-Allow-Origin': allowed,
|
||||
'Access-Control-Allow-Credentials': 'true',
|
||||
'Cross-Origin-Resource-Policy': 'cross-origin'
|
||||
};
|
||||
}
|
||||
}
|
||||
const withCors = (body: BodyInit | null, status: number, extraHeaders?: Record<string, string>) =>
|
||||
new Response(body, { status, headers: { ...corsHeaders, ...extraHeaders } });
|
||||
|
||||
const asset = await getAssetById(assetId);
|
||||
if (!asset) return withCors(null, 404);
|
||||
|
||||
let authorized = false;
|
||||
const token = getCookie(c, HLS_COOKIE_NAME);
|
||||
if (token) {
|
||||
const verified = await verifyHlsToken(token);
|
||||
if (verified?.assetId === assetId) authorized = true;
|
||||
}
|
||||
if (!authorized) {
|
||||
const orgRoles = (c.get('orgRoles') as Record<string, number> | undefined) ?? {};
|
||||
if (orgRoles[asset.organizationId]) authorized = true;
|
||||
}
|
||||
if (!authorized) {
|
||||
return withCors(null, 403);
|
||||
}
|
||||
|
||||
const transcript = await getMediaTranscriptByAsset(assetId, asset.organizationId);
|
||||
if (!transcript) return withCors(null, 404);
|
||||
|
||||
const vttHeaders = {
|
||||
'Content-Type': 'text/vtt; charset=utf-8',
|
||||
'Cache-Control': 'private, no-cache'
|
||||
};
|
||||
|
||||
if (c.req.method === 'HEAD') {
|
||||
return withCors(null, 200, vttHeaders);
|
||||
}
|
||||
|
||||
const vtt = segmentsToWebVtt(transcript.segments);
|
||||
|
||||
return withCors(vtt, 200, vttHeaders);
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Hono } from '@api/utils/hono';
|
||||
import { env } from '@api/config/env';
|
||||
import { env } from '@cio/core/config/env';
|
||||
import { handleError } from '@api/utils/errors';
|
||||
import { z } from 'zod';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user