diff --git a/config/middlewares.js b/config/middlewares.js index 9987104..c28b4a6 100644 --- a/config/middlewares.js +++ b/config/middlewares.js @@ -1,7 +1,7 @@ module.exports = ({ env }) => { // Build the allowed origins list dynamically const allowedOrigins = [ - "http://127.0.0.1:5173", + // "http://127.0.0.1:5173", // "http://localhost:1338", // "http://localhost:5173", // "https://axe-code.vercel.app", diff --git a/src/api/auth/services/security-logic.js b/src/api/auth/services/security-logic.js index 7752b26..83e13c4 100644 --- a/src/api/auth/services/security-logic.js +++ b/src/api/auth/services/security-logic.js @@ -33,8 +33,8 @@ module.exports = ({ strapi }) => { setAuthCookie(ctx, jwt) { const cookieStr = buildCookieHeader('jwt', jwt, { httpOnly: true, - secure: isProd, - sameSite: isProd ? 'None' : 'Lax', + secure: true, // Production: HTTPS | Dev: localhost is treated as secure context + sameSite: 'None', // Required for cross-origin requests (different domains in prod, different ports in dev) maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days path: '/', domain: domain || undefined, diff --git a/src/api/auth/services/security-pipeline.js b/src/api/auth/services/security-pipeline.js index d0e2900..9854537 100644 --- a/src/api/auth/services/security-pipeline.js +++ b/src/api/auth/services/security-pipeline.js @@ -22,6 +22,7 @@ const sanitizeObject = (obj) => { lowerKey.includes('startercode') || lowerKey.includes('wrappercode') || lowerKey.includes('starter_code') || + lowerKey.includes('embed')|| lowerKey.includes('wrapper_code'); if (lowerKey.includes('password') || isCodeField) { diff --git a/src/api/course/services/course.js b/src/api/course/services/course.js index f720f85..b3a1036 100644 --- a/src/api/course/services/course.js +++ b/src/api/course/services/course.js @@ -147,7 +147,7 @@ module.exports = createCoreService('api::course.course', ({ strapi }) => ({ // 2. Strip sensitive data if no access if (course.hasAccess || lesson.public) return updatedLesson; - const { video, description, ...safeMeta } = updatedLesson; + const { video, description, embed_url, embed_metadata, ...safeMeta } = updatedLesson; return safeMeta; }) })); diff --git a/src/api/lesson/content-types/lesson/schema.json b/src/api/lesson/content-types/lesson/schema.json index 8b1c424..afdab95 100644 --- a/src/api/lesson/content-types/lesson/schema.json +++ b/src/api/lesson/content-types/lesson/schema.json @@ -28,9 +28,24 @@ "default": "video", "enum": [ "video", - "article" + "article", + "embedded" ] }, + "embed_source": { + "type": "enumeration", + "enum": [ + "youtube", + "vimeo", + "custom" + ] + }, + "embed_url": { + "type": "string" + }, + "embed_metadata": { + "type": "json" + }, "isCompleted": { "type": "boolean", "default": false diff --git a/src/api/lesson/controllers/lesson.js b/src/api/lesson/controllers/lesson.js index 452b6ec..6fdf644 100644 --- a/src/api/lesson/controllers/lesson.js +++ b/src/api/lesson/controllers/lesson.js @@ -23,6 +23,24 @@ module.exports = createCoreController('api::lesson.lesson', ({ strapi }) => ({ return ctx.badRequest('Data is required'); } + // Auto-resolve embedded video metadata if type is embedded + if (data.type_of_lesson === 'embedded' && data.embed_url) { + try { + const registry = strapi.service('api::lesson.embed-provider-registry'); + const { provider, metadata } = await registry.resolveUrl(data.embed_url, data.embed_source); + data.embed_source = provider || data.embed_source || 'custom'; + data.embed_metadata = metadata || {}; + // Auto-fill duration from metadata if not manually set + if (!data.duration && metadata?.duration) { + data.duration = metadata.duration; + } + } catch (err) { + strapi.log.warn('[LessonController] Embed metadata resolution failed:', err.message); + // Continue with creation even if metadata fetch fails + data.embed_metadata = { status: 'fetch_failed', fetchedAt: new Date().toISOString() }; + } + } + // Set the user and ensure the lesson is published immediately ctx.request.body.data = { ...data, @@ -32,6 +50,35 @@ module.exports = createCoreController('api::lesson.lesson', ({ strapi }) => ({ return await super.create(ctx); }, + // PUT /lessons/:id - Update an existing lesson + async update(ctx) { + if (!ctx.state.user) { + return ctx.unauthorized('Not authenticated. Please login first.'); + } + + const { data } = ctx.request.body; + if (!data) return ctx.badRequest('Data is required'); + + // Auto-resolve embedded video metadata if type is embedded + if (data.type_of_lesson === 'embedded' && data.embed_url) { + try { + const registry = strapi.service('api::lesson.embed-provider-registry'); + const { provider, metadata } = await registry.resolveUrl(data.embed_url, data.embed_source); + data.embed_source = provider || data.embed_source || 'custom'; + data.embed_metadata = metadata || {}; + // Auto-fill duration from metadata if not manually set + if (!data.duration && metadata?.duration) { + data.duration = metadata.duration; + } + } catch (err) { + strapi.log.warn('[LessonController] Embed metadata resolution failed during update:', err.message); + data.embed_metadata = { status: 'fetch_failed', fetchedAt: new Date().toISOString() }; + } + } + + ctx.request.body.data = data; + return await super.update(ctx); + }, // GET /lessons - جلب الدروس المتاحة للمستخدم (صاحب الدرس، محتوى مجاني، أو مشترك في الكورس) async find(ctx) { diff --git a/src/api/lesson/services/embed-provider-registry.js b/src/api/lesson/services/embed-provider-registry.js new file mode 100644 index 0000000..d2f23cd --- /dev/null +++ b/src/api/lesson/services/embed-provider-registry.js @@ -0,0 +1,219 @@ +'use strict'; + +/** + * Embed Provider Registry + * Abstracts video source detection, metadata extraction, and embed URL generation. + * Adding a new provider = adding a new entry to the providers object. + * + * Design: + * - Each provider implements: match(), extractId(), getEmbedUrl(), getThumbnail(), fetchMetadata() + * - resolveUrl() auto-detects the provider from a raw URL + * - revalidate() re-checks cached metadata for health monitoring + */ + +const providers = { + youtube: { + name: 'youtube', + + /** Detects if a URL belongs to YouTube */ + match(url) { + return /(?:youtube\.com\/(?:watch\?v=|embed\/|shorts\/)|youtu\.be\/)/.test(url); + }, + + /** Extracts the video ID from various YouTube URL formats */ + extractId(url) { + const patterns = [ + /(?:youtube\.com\/watch\?v=)([a-zA-Z0-9_-]{11})/, + /(?:youtu\.be\/)([a-zA-Z0-9_-]{11})/, + /(?:youtube\.com\/embed\/)([a-zA-Z0-9_-]{11})/, + /(?:youtube\.com\/shorts\/)([a-zA-Z0-9_-]{11})/, + ]; + for (const pattern of patterns) { + const match = url.match(pattern); + if (match) return match[1]; + } + return null; + }, + + /** Generates the embeddable iframe URL with JS API enabled */ + getEmbedUrl(videoId) { + return `https://www.youtube.com/embed/${videoId}?enablejsapi=1`; + }, + + /** Generates a high-quality thumbnail URL (no API key needed) */ + getThumbnail(videoId) { + return `https://img.youtube.com/vi/${videoId}/hqdefault.jpg`; + }, + + /** Fetches metadata via oEmbed endpoint (no API key required) */ + async fetchMetadata(url) { + const videoId = this.extractId(url); + try { + const oEmbedUrl = `https://www.youtube.com/oembed?url=${encodeURIComponent(url)}&format=json`; + const response = await fetch(oEmbedUrl); + if (!response.ok) { + return { + provider: 'youtube', + videoId, + status: 'unavailable', + fetchedAt: new Date().toISOString(), + thumbnail: videoId ? this.getThumbnail(videoId) : null, + embedUrl: videoId ? this.getEmbedUrl(videoId) : null, + }; + } + const data = await response.json(); + return { + provider: 'youtube', + videoId, + title: data.title || null, + author: data.author_name || null, + authorUrl: data.author_url || null, + thumbnail: this.getThumbnail(videoId), + embedUrl: this.getEmbedUrl(videoId), + fetchedAt: new Date().toISOString(), + status: 'active', + }; + } catch (error) { + return { + provider: 'youtube', + videoId, + status: 'fetch_failed', + fetchedAt: new Date().toISOString(), + thumbnail: videoId ? this.getThumbnail(videoId) : null, + embedUrl: videoId ? this.getEmbedUrl(videoId) : null, + }; + } + }, + }, + + vimeo: { + name: 'vimeo', + + match(url) { + return /vimeo\.com\/\d+/.test(url); + }, + + extractId(url) { + const match = url.match(/vimeo\.com\/(\d+)/); + return match ? match[1] : null; + }, + + getEmbedUrl(videoId) { + return `https://player.vimeo.com/video/${videoId}`; + }, + + getThumbnail() { + return null; // Vimeo requires API key for thumbnails + }, + + async fetchMetadata(url) { + const videoId = this.extractId(url); + try { + const oEmbedUrl = `https://vimeo.com/api/oembed.json?url=${encodeURIComponent(url)}`; + const response = await fetch(oEmbedUrl); + if (!response.ok) { + return { + provider: 'vimeo', + videoId, + status: 'unavailable', + fetchedAt: new Date().toISOString(), + embedUrl: videoId ? this.getEmbedUrl(videoId) : null, + }; + } + const data = await response.json(); + return { + provider: 'vimeo', + videoId, + title: data.title || null, + author: data.author_name || null, + thumbnail: data.thumbnail_url || null, + duration: data.duration || null, + embedUrl: this.getEmbedUrl(videoId), + fetchedAt: new Date().toISOString(), + status: 'active', + }; + } catch { + return { + provider: 'vimeo', + videoId, + status: 'fetch_failed', + fetchedAt: new Date().toISOString(), + embedUrl: videoId ? this.getEmbedUrl(videoId) : null, + }; + } + }, + }, + + custom: { + name: 'custom', + + match() { return false; }, // Only selected explicitly by admin + + extractId(url) { return url; }, + + getEmbedUrl(url) { return url; }, + + getThumbnail() { return null; }, + + async fetchMetadata(url) { + return { + provider: 'custom', + videoId: url, + embedUrl: url, + status: 'manual', + fetchedAt: new Date().toISOString(), + }; + }, + }, +}; + +module.exports = ({ strapi }) => ({ + /** + * Detects the provider from a URL and returns enriched metadata. + * @param {string} url - The raw video URL pasted by the admin + * @param {string} [forceProvider] - Optional provider override + * @returns {Promise<{ provider: string, metadata: object }>} + */ + async resolveUrl(url, forceProvider = null) { + if (!url) return { provider: null, metadata: null }; + + let provider = null; + + if (forceProvider && providers[forceProvider]) { + provider = providers[forceProvider]; + } else { + provider = Object.values(providers).find(p => p.match(url)); + } + + if (!provider) { + provider = providers.custom; + } + + const metadata = await provider.fetchMetadata(url); + return { provider: provider.name, metadata }; + }, + + /** + * Re-validates cached metadata for a lesson's embedded content. + * Returns fresh metadata from the provider. + * @param {object} lesson - Lesson record with embed_url and embed_source + * @returns {Promise} + */ + async revalidate(lesson) { + if (!lesson.embed_url || !lesson.embed_source) return null; + const provider = providers[lesson.embed_source]; + if (!provider) return null; + + const metadata = await provider.fetchMetadata(lesson.embed_url); + return metadata; + }, + + /** + * Exposes a specific provider for direct usage. + * @param {string} name - Provider name (youtube, vimeo, custom) + * @returns {object|null} + */ + getProvider(name) { + return providers[name] || null; + }, +}); diff --git a/types/generated/contentTypes.d.ts b/types/generated/contentTypes.d.ts index bd0c59a..11230c2 100644 --- a/types/generated/contentTypes.d.ts +++ b/types/generated/contentTypes.d.ts @@ -1009,6 +1009,9 @@ export interface ApiLessonLesson extends Struct.CollectionTypeSchema { Schema.Attribute.Private; description: Schema.Attribute.Blocks; duration: Schema.Attribute.Integer & Schema.Attribute.DefaultTo<0>; + embed_metadata: Schema.Attribute.JSON; + embed_source: Schema.Attribute.Enumeration<['youtube', 'vimeo', 'custom']>; + embed_url: Schema.Attribute.String; isCompleted: Schema.Attribute.Boolean & Schema.Attribute.DefaultTo; isDraft: Schema.Attribute.Boolean & Schema.Attribute.DefaultTo; locale: Schema.Attribute.String & Schema.Attribute.Private; @@ -1024,7 +1027,9 @@ export interface ApiLessonLesson extends Struct.CollectionTypeSchema { public: Schema.Attribute.Boolean; publishedAt: Schema.Attribute.DateTime; title: Schema.Attribute.String; - type_of_lesson: Schema.Attribute.Enumeration<['video', 'article']> & + type_of_lesson: Schema.Attribute.Enumeration< + ['video', 'article', 'embedded'] + > & Schema.Attribute.DefaultTo<'video'>; updatedAt: Schema.Attribute.DateTime; updatedBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &