From 06b1e9ad60141c94a32bf8c531694b1c2a218805 Mon Sep 17 00:00:00 2001 From: chyoo Date: Wed, 23 Sep 2026 11:01:10 +0900 Subject: [PATCH] fix: support overview scene URLs with embedded passwords --- src/curriculum/week-camera.spec.ts | 20 +++++++++++++--- src/curriculum/week-camera.ts | 23 +++++++++++-------- .../dto/update-instructor-curriculum.dto.ts | 4 ---- src/instructor/instructor.service.ts | 6 ++--- 4 files changed, 34 insertions(+), 19 deletions(-) diff --git a/src/curriculum/week-camera.spec.ts b/src/curriculum/week-camera.spec.ts index b9af83e..02de1e6 100644 --- a/src/curriculum/week-camera.spec.ts +++ b/src/curriculum/week-camera.spec.ts @@ -1,6 +1,13 @@ import { normalizeCameraUrl, readWeekCamera } from './week-camera'; describe('week camera settings', () => { + it('accepts a room scene link without a view ID', () => { + const url = new URL(normalizeCameraUrl('https://vdo.ninja/?scene=0&room=Alex_12345&password=xxxx')); + expect(url.searchParams.get('scene')).toBe('0'); + expect(url.searchParams.get('room')).toBe('Alex_12345'); + expect(url.searchParams.get('password')).toBe('xxxx'); + expect(url.searchParams.has('view')).toBe(false); + }); it('keeps only viewing parameters', () => { const url = new URL(normalizeCameraUrl('https://vdo.ninja/?room=track&view=camera&push=bad&director=bad&password=secret')); expect(url.searchParams.get('room')).toBe('track'); @@ -8,7 +15,7 @@ describe('week camera settings', () => { expect(url.searchParams.has('scene')).toBe(true); expect(url.searchParams.has('push')).toBe(false); expect(url.searchParams.has('director')).toBe(false); - expect(url.searchParams.has('password')).toBe(false); + expect(url.searchParams.get('password')).toBe('secret'); }); it.each(['http://vdo.ninja/?room=x&view=y', 'https://example.com/?room=x&view=y', 'https://vdo.ninja/?room=x', 'javascript:alert(1)', 'https://vdo.ninja:8443/?room=x&view=y'])('rejects unsafe or incomplete URL %s', (url) => { @@ -17,7 +24,14 @@ describe('week camera settings', () => { it('supports unset camera settings and preserves password whitespace', () => { expect(normalizeCameraUrl(' ')).toBe(''); - expect(readWeekCamera(null)).toEqual({ url: '', password: '' }); - expect(readWeekCamera({ cameraViewUrl: 'url', cameraPassword: ' a&b ' })).toEqual({ url: 'url', password: ' a&b ' }); + expect(readWeekCamera(null)).toEqual({ url: '' }); + const legacy = readWeekCamera({ cameraViewUrl: 'https://vdo.ninja/?room=x&view=y', cameraPassword: ' a&b ' }); + expect(new URL(legacy.url).searchParams.get('password')).toBe(' a&b '); + }); + + it('preserves encoded passwords and prefers the link over legacy settings', () => { + const url = normalizeCameraUrl('https://vdo.ninja/?room=x&view=y&password=%20a%26b%2B%23%20'); + expect(new URL(url).searchParams.get('password')).toBe(' a&b+# '); + expect(readWeekCamera({ cameraViewUrl: url, cameraPassword: 'old' })).toEqual({ url }); }); }); diff --git a/src/curriculum/week-camera.ts b/src/curriculum/week-camera.ts index 3e34ee3..86cbf79 100644 --- a/src/curriculum/week-camera.ts +++ b/src/curriculum/week-camera.ts @@ -1,9 +1,13 @@ -export function readWeekCamera(settings: unknown): { url: string; password: string } { +export function readWeekCamera(settings: unknown): { url: string } { const value = settings && typeof settings === 'object' ? settings as Record : {}; - return { - url: typeof value.cameraViewUrl === 'string' ? value.cameraViewUrl : '', - password: typeof value.cameraPassword === 'string' ? value.cameraPassword : '', - }; + let url = typeof value.cameraViewUrl === 'string' ? value.cameraViewUrl : ''; + // Keep previously saved separate passwords when reading older settings. + if (url && typeof value.cameraPassword === 'string' && value.cameraPassword) { + const parsed = new URL(url); + if (!parsed.searchParams.has('password')) parsed.searchParams.set('password', value.cameraPassword); + url = parsed.toString(); + } + return { url }; } export function normalizeCameraUrl(input: string): string { @@ -12,13 +16,14 @@ export function normalizeCameraUrl(input: string): string { if (url.protocol !== 'https:' || url.hostname !== 'vdo.ninja' || url.port || url.username || url.password || url.pathname !== '/') { throw new Error('올바른 오버뷰 시청 URL을 입력해주세요.'); } - if (!url.searchParams.get('room')?.trim() || !url.searchParams.get('view')?.trim()) { - throw new Error('시청 URL에 room과 view가 필요합니다.'); + if (!url.searchParams.get('room')?.trim() || (!url.searchParams.get('view')?.trim() && !url.searchParams.has('scene'))) { + throw new Error('시청 URL에 room과 scene 또는 view가 필요합니다.'); } // Only viewer parameters are accepted; never retain publishing/director options. const view = new URL('https://vdo.ninja/'); - view.searchParams.set('scene', ''); + view.searchParams.set('scene', url.searchParams.get('scene') ?? ''); view.searchParams.set('room', url.searchParams.get('room')!); - view.searchParams.set('view', url.searchParams.get('view')!); + if (url.searchParams.get('view')?.trim()) view.searchParams.set('view', url.searchParams.get('view')!); + if (url.searchParams.has('password')) view.searchParams.set('password', url.searchParams.get('password')!); return view.toString(); } diff --git a/src/instructor/dto/update-instructor-curriculum.dto.ts b/src/instructor/dto/update-instructor-curriculum.dto.ts index ab97138..c7cf5a6 100644 --- a/src/instructor/dto/update-instructor-curriculum.dto.ts +++ b/src/instructor/dto/update-instructor-curriculum.dto.ts @@ -5,10 +5,6 @@ export class UpdateInstructorCurriculumDto { @IsString() cameraViewUrl?: string; - @IsOptional() - @IsString() - cameraPassword?: string; - @IsOptional() @IsString() courseId?: string | null; diff --git a/src/instructor/instructor.service.ts b/src/instructor/instructor.service.ts index 804fa79..3db3eaf 100644 --- a/src/instructor/instructor.service.ts +++ b/src/instructor/instructor.service.ts @@ -1601,11 +1601,11 @@ export class InstructorService { try { cameraViewUrl = input.cameraViewUrl === undefined ? undefined : normalizeCameraUrl(input.cameraViewUrl); } catch { - throw new BadRequestException('오버뷰 시청 URL에 올바른 room과 view를 지정해주세요.'); + throw new BadRequestException('오버뷰 시청 URL에 올바른 room과 scene 또는 view를 지정해주세요.'); } const cameraSettings = { cameraViewUrl: cameraViewUrl ?? existingOverride?.cameraViewUrl ?? '', - cameraPassword: cameraViewUrl === '' ? '' : input.cameraPassword ?? existingOverride?.cameraPassword ?? '', + cameraPassword: cameraViewUrl !== undefined ? '' : existingOverride?.cameraPassword ?? '', }; const existingLearningSteps = this.resolveLearningSteps( @@ -1776,7 +1776,7 @@ export class InstructorService { selectedTagLabel = tagOption.label; } - if (!targetWeek || input.cameraViewUrl !== undefined || input.cameraPassword !== undefined || input.tagLabel !== undefined || input.title || input.duration || input.overview || input.deliverable || input.isTeamProject !== undefined || input.learningSteps || input.topics || input.topicDetails || input.topicVideoUrls || input.practiceContents || input.disabledModuleIds) { + if (!targetWeek || input.cameraViewUrl !== undefined || input.tagLabel !== undefined || input.title || input.duration || input.overview || input.deliverable || input.isTeamProject !== undefined || input.learningSteps || input.topics || input.topicDetails || input.topicVideoUrls || input.practiceContents || input.disabledModuleIds) { await this.prisma.instructorCurriculumWeekOverride.upsert({ where: { organizationName_courseId_week: {