Files
smoothschedule/frontend/src/puck/utils/videoEmbed.ts
poduck fbefccf436 Add media gallery with album organization and Puck integration
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>
2025-12-13 19:59:31 -05:00

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;
}
}