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>
This commit is contained in:
247
frontend/src/puck/utils/videoEmbed.ts
Normal file
247
frontend/src/puck/utils/videoEmbed.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user