Backend: - Add Album and MediaFile models for tenant-scoped media storage - Add TenantStorageUsage model for per-tenant storage quota tracking - Create StorageQuotaService with EntitlementService integration - Add AlbumViewSet, MediaFileViewSet with bulk operations - Add StorageUsageView for quota monitoring Frontend: - Create MediaGalleryPage with album management and file upload - Add drag-and-drop upload with storage quota validation - Create ImagePickerField custom Puck field for gallery integration - Update Image, Testimonial components to use ImagePicker - Add background image picker to Puck design controls - Add gallery to sidebar navigation Also includes: - Puck marketing components (Hero, SplitContent, etc.) - Enhanced ContactForm and BusinessHours components - Platform login page improvements - Site builder draft/preview enhancements 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
248 lines
6.9 KiB
TypeScript
248 lines
6.9 KiB
TypeScript
/**
|
|
* Video Embed Validation Utilities
|
|
*
|
|
* Security: Only YouTube and Vimeo embeds are allowed.
|
|
* This module validates video URLs and builds safe embed URLs.
|
|
*/
|
|
|
|
// ============================================================================
|
|
// Video Provider Definitions
|
|
// ============================================================================
|
|
|
|
export interface VideoProvider {
|
|
name: string;
|
|
patterns: RegExp[];
|
|
embedTemplate: (videoId: string, hash?: string) => string;
|
|
validateId: (id: string) => boolean;
|
|
}
|
|
|
|
export const VIDEO_PROVIDERS: Record<string, VideoProvider> = {
|
|
youtube: {
|
|
name: 'YouTube',
|
|
patterns: [
|
|
// Standard watch URL: https://www.youtube.com/watch?v=VIDEO_ID
|
|
/^https:\/\/(?:www\.)?youtube\.com\/watch\?v=([a-zA-Z0-9_-]{11})/,
|
|
// Short URL: https://youtu.be/VIDEO_ID
|
|
/^https:\/\/youtu\.be\/([a-zA-Z0-9_-]{11})/,
|
|
// Embed URL: https://www.youtube.com/embed/VIDEO_ID
|
|
/^https:\/\/(?:www\.)?youtube\.com\/embed\/([a-zA-Z0-9_-]{11})/,
|
|
// Nocookie embed: https://www.youtube-nocookie.com/embed/VIDEO_ID
|
|
/^https:\/\/(?:www\.)?youtube-nocookie\.com\/embed\/([a-zA-Z0-9_-]{11})/,
|
|
],
|
|
embedTemplate: (videoId: string) =>
|
|
`https://www.youtube-nocookie.com/embed/${videoId}`,
|
|
validateId: (id: string) => /^[a-zA-Z0-9_-]{11}$/.test(id),
|
|
},
|
|
vimeo: {
|
|
name: 'Vimeo',
|
|
patterns: [
|
|
// Standard URL: https://vimeo.com/VIDEO_ID
|
|
/^https:\/\/(?:www\.)?vimeo\.com\/(\d+)(?:\/([a-zA-Z0-9]+))?/,
|
|
// Player URL: https://player.vimeo.com/video/VIDEO_ID
|
|
/^https:\/\/player\.vimeo\.com\/video\/(\d+)/,
|
|
],
|
|
embedTemplate: (videoId: string, hash?: string) =>
|
|
hash
|
|
? `https://player.vimeo.com/video/${videoId}?h=${hash}`
|
|
: `https://player.vimeo.com/video/${videoId}`,
|
|
validateId: (id: string) => /^\d+$/.test(id),
|
|
},
|
|
};
|
|
|
|
// ============================================================================
|
|
// Validation Result Types
|
|
// ============================================================================
|
|
|
|
export interface VideoValidationResult {
|
|
isValid: boolean;
|
|
provider?: 'youtube' | 'vimeo';
|
|
videoId?: string;
|
|
hash?: string;
|
|
error?: string;
|
|
}
|
|
|
|
export interface ParsedVideo {
|
|
provider: 'youtube' | 'vimeo';
|
|
videoId: string;
|
|
hash?: string;
|
|
}
|
|
|
|
// ============================================================================
|
|
// URL Sanitization
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Check if a string contains potentially dangerous content
|
|
*/
|
|
function containsDangerousContent(str: string): boolean {
|
|
if (!str) return false;
|
|
|
|
const dangerousPatterns = [
|
|
/<script/i,
|
|
/<\/script/i,
|
|
/javascript:/i,
|
|
/onerror=/i,
|
|
/onload=/i,
|
|
/onclick=/i,
|
|
/%00/, // null byte
|
|
/[\u0430-\u04FF]/, // Cyrillic characters (homograph attacks)
|
|
/[\u0600-\u06FF]/, // Arabic characters
|
|
];
|
|
|
|
return dangerousPatterns.some(pattern => pattern.test(str));
|
|
}
|
|
|
|
/**
|
|
* Sanitize a video ID to prevent injection
|
|
*/
|
|
function sanitizeVideoId(id: string, provider: 'youtube' | 'vimeo'): string {
|
|
if (provider === 'youtube') {
|
|
// YouTube IDs: exactly 11 alphanumeric chars, plus - and _
|
|
return id.replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 11);
|
|
}
|
|
// Vimeo IDs: numeric only
|
|
return id.replace(/\D/g, '');
|
|
}
|
|
|
|
// ============================================================================
|
|
// Validation Functions
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Validate a video embed URL
|
|
*
|
|
* @param url - The URL to validate
|
|
* @returns Validation result with provider and video ID if valid
|
|
*/
|
|
export function validateVideoEmbed(url: string): VideoValidationResult {
|
|
// Handle null/undefined
|
|
if (!url || typeof url !== 'string') {
|
|
return { isValid: false, error: 'URL is required' };
|
|
}
|
|
|
|
const trimmedUrl = url.trim();
|
|
|
|
// Check for dangerous content
|
|
if (containsDangerousContent(trimmedUrl)) {
|
|
return { isValid: false, error: 'URL contains invalid characters' };
|
|
}
|
|
|
|
// Must be HTTPS
|
|
if (!trimmedUrl.startsWith('https://')) {
|
|
return { isValid: false, error: 'URL must use HTTPS' };
|
|
}
|
|
|
|
// Check for data: and javascript: protocols
|
|
if (trimmedUrl.startsWith('data:') || trimmedUrl.includes('javascript:')) {
|
|
return { isValid: false, error: 'Invalid URL protocol' };
|
|
}
|
|
|
|
// Try to parse as YouTube
|
|
for (const pattern of VIDEO_PROVIDERS.youtube.patterns) {
|
|
const match = trimmedUrl.match(pattern);
|
|
if (match && match[1]) {
|
|
const videoId = match[1];
|
|
if (!VIDEO_PROVIDERS.youtube.validateId(videoId)) {
|
|
return { isValid: false, error: 'Invalid YouTube video ID' };
|
|
}
|
|
return {
|
|
isValid: true,
|
|
provider: 'youtube',
|
|
videoId,
|
|
};
|
|
}
|
|
}
|
|
|
|
// Try to parse as Vimeo
|
|
for (const pattern of VIDEO_PROVIDERS.vimeo.patterns) {
|
|
const match = trimmedUrl.match(pattern);
|
|
if (match && match[1]) {
|
|
const videoId = match[1];
|
|
const hash = match[2];
|
|
if (!VIDEO_PROVIDERS.vimeo.validateId(videoId)) {
|
|
return { isValid: false, error: 'Invalid Vimeo video ID' };
|
|
}
|
|
return {
|
|
isValid: true,
|
|
provider: 'vimeo',
|
|
videoId,
|
|
hash,
|
|
};
|
|
}
|
|
}
|
|
|
|
// No match found
|
|
return {
|
|
isValid: false,
|
|
error: 'URL must be from YouTube or Vimeo',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Parse a video URL to extract provider and ID
|
|
*
|
|
* @param url - The URL to parse
|
|
* @returns Parsed video info or null if invalid
|
|
*/
|
|
export function parseVideoUrl(url: string): ParsedVideo | null {
|
|
const result = validateVideoEmbed(url);
|
|
if (result.isValid && result.provider && result.videoId) {
|
|
return {
|
|
provider: result.provider,
|
|
videoId: result.videoId,
|
|
hash: result.hash,
|
|
};
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Build a safe embed URL from provider and video ID
|
|
*
|
|
* @param provider - The video provider
|
|
* @param videoId - The video ID
|
|
* @param hash - Optional hash for private Vimeo videos
|
|
* @returns Safe embed URL
|
|
*/
|
|
export function buildSafeEmbedUrl(
|
|
provider: 'youtube' | 'vimeo',
|
|
videoId: string,
|
|
hash?: string
|
|
): string {
|
|
const providerConfig = VIDEO_PROVIDERS[provider];
|
|
|
|
if (!providerConfig) {
|
|
throw new Error(`Unknown video provider: ${provider}`);
|
|
}
|
|
|
|
// Sanitize the video ID
|
|
const sanitizedId = sanitizeVideoId(videoId, provider);
|
|
|
|
// Validate the sanitized ID
|
|
if (!providerConfig.validateId(sanitizedId)) {
|
|
throw new Error(`Invalid video ID for ${provider}`);
|
|
}
|
|
|
|
// Sanitize hash if present (for Vimeo)
|
|
const sanitizedHash = hash ? hash.replace(/[^a-zA-Z0-9]/g, '') : undefined;
|
|
|
|
return providerConfig.embedTemplate(sanitizedId, sanitizedHash);
|
|
}
|
|
|
|
/**
|
|
* Get the embed URL from a video URL
|
|
*
|
|
* @param url - The video URL
|
|
* @returns Safe embed URL or null if invalid
|
|
*/
|
|
export function getEmbedUrl(url: string): string | null {
|
|
const parsed = parseVideoUrl(url);
|
|
if (!parsed) return null;
|
|
|
|
try {
|
|
return buildSafeEmbedUrl(parsed.provider, parsed.videoId, parsed.hash);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|