Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion config/middlewares.js
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
4 changes: 2 additions & 2 deletions src/api/auth/services/security-logic.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/api/auth/services/security-pipeline.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion src/api/course/services/course.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
})
}));
Expand Down
17 changes: 16 additions & 1 deletion src/api/lesson/content-types/lesson/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
47 changes: 47 additions & 0 deletions src/api/lesson/controllers/lesson.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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) {
Expand Down
219 changes: 219 additions & 0 deletions src/api/lesson/services/embed-provider-registry.js
Original file line number Diff line number Diff line change
@@ -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<object|null>}
*/
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;
},
});
7 changes: 6 additions & 1 deletion types/generated/contentTypes.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<false>;
isDraft: Schema.Attribute.Boolean & Schema.Attribute.DefaultTo<true>;
locale: Schema.Attribute.String & Schema.Attribute.Private;
Expand All @@ -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'> &
Expand Down
Loading