Site builder: security & data-loss hardening + unified asset picker + audit backlog #3
@@ -0,0 +1,61 @@
|
|||||||
|
import { describe, test, expect } from 'vitest';
|
||||||
|
import { VideoBlock } from './VideoBlock';
|
||||||
|
|
||||||
|
const toHtml = (VideoBlock as any).toHtml;
|
||||||
|
|
||||||
|
function embedSrc(videoUrl: string): string {
|
||||||
|
const { html } = toHtml({ videoUrl }, '');
|
||||||
|
const m = html.match(/<iframe src="([^"]+)"/) || html.match(/<video src="([^"]+)"/);
|
||||||
|
return m ? m[1].replace(/&/g, '&') : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('VideoBlock URL parsing (D4)', () => {
|
||||||
|
test('youtube.com/watch?v=ID (existing case) resolves to embed URL', () => {
|
||||||
|
expect(embedSrc('https://www.youtube.com/watch?v=dQw4w9WgXcQ')).toContain('youtube.com/embed/dQw4w9WgXcQ');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('youtu.be/ID resolves to embed URL', () => {
|
||||||
|
expect(embedSrc('https://youtu.be/dQw4w9WgXcQ')).toContain('youtube.com/embed/dQw4w9WgXcQ');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('youtube.com/embed/ID (existing case) resolves to embed URL', () => {
|
||||||
|
expect(embedSrc('https://www.youtube.com/embed/dQw4w9WgXcQ')).toContain('youtube.com/embed/dQw4w9WgXcQ');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('youtube.com/shorts/ID resolves to embed URL', () => {
|
||||||
|
expect(embedSrc('https://www.youtube.com/shorts/dQw4w9WgXcQ')).toContain('youtube.com/embed/dQw4w9WgXcQ');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('youtube.com/live/ID resolves to embed URL', () => {
|
||||||
|
expect(embedSrc('https://www.youtube.com/live/dQw4w9WgXcQ')).toContain('youtube.com/embed/dQw4w9WgXcQ');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('youtube.com/watch?...&v=ID (v not first param) resolves to embed URL', () => {
|
||||||
|
expect(embedSrc('https://www.youtube.com/watch?list=PLxyz&v=dQw4w9WgXcQ&index=3')).toContain('youtube.com/embed/dQw4w9WgXcQ');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('vimeo.com/ID (existing case) resolves to player URL', () => {
|
||||||
|
expect(embedSrc('https://vimeo.com/123456789')).toContain('https://player.vimeo.com/video/123456789');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('vimeo.com/ID/HASH (private video) resolves to player URL with hash param', () => {
|
||||||
|
const src = embedSrc('https://vimeo.com/123456789/abcdef1234');
|
||||||
|
expect(src).toContain('https://player.vimeo.com/video/123456789');
|
||||||
|
expect(src).toContain('h=abcdef1234');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('direct .mp4 file still works', () => {
|
||||||
|
const { html } = toHtml({ videoUrl: 'https://example.com/clip.mp4' }, '');
|
||||||
|
expect(html).toContain('<video src="https://example.com/clip.mp4"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unrecognized URL yields no output (type "none")', () => {
|
||||||
|
const { html } = toHtml({ videoUrl: 'not-a-real-video-url' }, '');
|
||||||
|
expect(html).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('emitted src is safeUrl-wrapped: javascript: scheme never reaches output', () => {
|
||||||
|
const { html } = toHtml({ videoUrl: 'javascript:alert(1)' }, '');
|
||||||
|
expect(html).not.toContain('javascript:');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -26,18 +26,43 @@ interface VideoBlockProps {
|
|||||||
|
|
||||||
/* ---------- URL detection ---------- */
|
/* ---------- URL detection ---------- */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract a YouTube video ID from any of the URL shapes WHP users paste:
|
||||||
|
* youtu.be/ID, youtube.com/embed/ID, youtube.com/shorts/ID,
|
||||||
|
* youtube.com/live/ID (path-based), and youtube.com/watch?...v=ID where `v`
|
||||||
|
* may appear anywhere in the query string (not just as the first param).
|
||||||
|
*/
|
||||||
|
function extractYouTubeId(url: string): string | null {
|
||||||
|
const pathMatch = url.match(
|
||||||
|
/(?:youtube\.com\/(?:embed|shorts|live)\/|youtu\.be\/)([a-zA-Z0-9_-]+)/
|
||||||
|
);
|
||||||
|
if (pathMatch) return pathMatch[1];
|
||||||
|
|
||||||
|
const queryMatch = url.match(/youtube\.com\/watch\?([^\s#]+)/);
|
||||||
|
if (queryMatch) {
|
||||||
|
const v = new URLSearchParams(queryMatch[1]).get('v');
|
||||||
|
if (v) return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function detectVideoType(url: string): { type: VideoType; embedUrl: string } {
|
function detectVideoType(url: string): { type: VideoType; embedUrl: string } {
|
||||||
if (!url) return { type: 'none', embedUrl: '' };
|
if (!url) return { type: 'none', embedUrl: '' };
|
||||||
|
|
||||||
// YouTube
|
// YouTube
|
||||||
const ytMatch = url.match(
|
const ytId = extractYouTubeId(url);
|
||||||
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([a-zA-Z0-9_-]+)/
|
if (ytId) return { type: 'youtube', embedUrl: `https://www.youtube.com/embed/${ytId}?rel=0` };
|
||||||
);
|
|
||||||
if (ytMatch) return { type: 'youtube', embedUrl: `https://www.youtube.com/embed/${ytMatch[1]}?rel=0` };
|
|
||||||
|
|
||||||
// Vimeo
|
// Vimeo: vimeo.com/ID, or vimeo.com/ID/HASH for unlisted/private videos
|
||||||
const vmMatch = url.match(/vimeo\.com\/(\d+)/);
|
// (the hash becomes the player's `h` query param).
|
||||||
if (vmMatch) return { type: 'vimeo', embedUrl: `https://player.vimeo.com/video/${vmMatch[1]}` };
|
const vmMatch = url.match(/vimeo\.com\/(\d+)(?:\/([a-zA-Z0-9]+))?/);
|
||||||
|
if (vmMatch) {
|
||||||
|
const embedUrl = vmMatch[2]
|
||||||
|
? `https://player.vimeo.com/video/${vmMatch[1]}?h=${vmMatch[2]}`
|
||||||
|
: `https://player.vimeo.com/video/${vmMatch[1]}`;
|
||||||
|
return { type: 'vimeo', embedUrl };
|
||||||
|
}
|
||||||
|
|
||||||
// Direct file
|
// Direct file
|
||||||
if (url.match(/\.(mp4|webm|ogg|mov)(\?|$)/i)) return { type: 'file', embedUrl: url };
|
if (url.match(/\.(mp4|webm|ogg|mov)(\?|$)/i)) return { type: 'file', embedUrl: url };
|
||||||
|
|||||||
Reference in New Issue
Block a user