diff --git a/apps/docs/public/assets/lessons/scorm/create.png b/apps/docs/public/assets/lessons/scorm/create.png new file mode 100644 index 000000000..a0be5ad47 Binary files /dev/null and b/apps/docs/public/assets/lessons/scorm/create.png differ diff --git a/apps/docs/public/assets/lessons/scorm/learner-enter.png b/apps/docs/public/assets/lessons/scorm/learner-enter.png new file mode 100644 index 000000000..5a41ba4bd Binary files /dev/null and b/apps/docs/public/assets/lessons/scorm/learner-enter.png differ diff --git a/apps/docs/public/assets/lessons/scorm/learner-popup.png b/apps/docs/public/assets/lessons/scorm/learner-popup.png new file mode 100644 index 000000000..8a311ca54 Binary files /dev/null and b/apps/docs/public/assets/lessons/scorm/learner-popup.png differ diff --git a/apps/docs/public/assets/lessons/scorm/replace.png b/apps/docs/public/assets/lessons/scorm/replace.png new file mode 100644 index 000000000..72f32d5be Binary files /dev/null and b/apps/docs/public/assets/lessons/scorm/replace.png differ diff --git a/apps/docs/public/assets/lessons/scorm/upload.png b/apps/docs/public/assets/lessons/scorm/upload.png new file mode 100644 index 000000000..4f16b86f2 Binary files /dev/null and b/apps/docs/public/assets/lessons/scorm/upload.png differ diff --git a/apps/docs/public/assets/lessons/scorm/uploaded.png b/apps/docs/public/assets/lessons/scorm/uploaded.png new file mode 100644 index 000000000..a8d19ab9a Binary files /dev/null and b/apps/docs/public/assets/lessons/scorm/uploaded.png differ diff --git a/apps/docs/src/pages/en/courses/add-content.md b/apps/docs/src/pages/en/courses/add-content.md index 210bd94af..f1d2319ea 100644 --- a/apps/docs/src/pages/en/courses/add-content.md +++ b/apps/docs/src/pages/en/courses/add-content.md @@ -16,7 +16,7 @@ Sections are used to group lessons. ## Lessons -A lesson is a container for the actual learning material. CourseLit supports seven types of lessons, which are as follows. +A lesson is a container for the actual learning material. CourseLit supports multiple types of lessons, which are as follows. 1. Text @@ -48,6 +48,12 @@ A lesson is a container for the actual learning material. CourseLit supports sev See the [guide to add a quiz](/en/lessons/add-quiz). +8. SCORM + + For sharing SCORM packages. + + See the [guide to add a SCORM package](/en/lessons/scorm). + ## Steps to add a new lesson 1. From the `Products` section in the dashboard, select your product to open its dashboard. diff --git a/apps/docs/src/pages/en/lessons/scorm.md b/apps/docs/src/pages/en/lessons/scorm.md new file mode 100644 index 000000000..3e142df98 --- /dev/null +++ b/apps/docs/src/pages/en/lessons/scorm.md @@ -0,0 +1,137 @@ +--- +title: Add a SCORM package to a course +description: Add a SCORM package to a course +layout: ../../../layouts/MainLayout.astro +--- + +You can add SCORM packages to your courses in CourseLit. This allows you to import interactive e-learning content created with tools like Articulate Storyline, Rise, Adobe Captivate, iSpring, and more. + +> The feature is currently in alpha, which means you may encounter bugs. Please report them in our Discord group if you run into any. + +## What is SCORM? + +SCORM (Sharable Content Object Reference Model) is an industry standard for e-learning content. It allows content created in one tool to be used in any SCORM-compliant LMS. + +CourseLit supports both **SCORM 1.2** and **SCORM 2004** packages. + +## Add a SCORM lesson + +1. Go to the `Products` page and click on the course you want to add SCORM content to. Click on `Edit content`. + +2. Click on `Add lesson` in any section. + +3. On the New Lesson screen, you'll see a row of lesson type cards. Click on the `SCORM` card to select it. + +4. Enter a title for your lesson and hit `Save`. + +![create SCORM lesson](/assets/lessons/scorm/create.png) + + > **Note:** SCORM lessons cannot be previewed. The `Preview` switch will have no effect. + +5. A SCORM upload area will appear. Click `Choose File` and select your SCORM package (ZIP file). The maximum file size is **300MB**. + +![upload SCORM package](/assets/lessons/scorm/upload.png) + +6. Wait for the upload to complete. CourseLit will automatically validate the package and extract the course structure. + +![uploaded SCORM package](/assets/lessons/scorm/uploaded.png) + +## Replacing a SCORM package + +To update an existing SCORM lesson with a new version of the package: + +1. Open the SCORM lesson for editing +2. Click the `Replace` button +3. Select the new ZIP file +4. Wait for the upload to complete + +## Supported SCORM features + +| Feature | SCORM 1.2 | SCORM 2004 | +| --------------------- | --------- | ---------- | +| Progress tracking | ✅ | ✅ | +| Completion status | ✅ | ✅ | +| Resume (suspend data) | ✅ | ✅ | +| Session time | ✅ | ✅ | +| Score reporting | ✅ | ✅ | + +## How course completion is calculated + +CourseLit uses the data reported by the SCORM package to determine completion. When a learner clicks **Complete and Continue**, CourseLit checks the SCORM status stored in the database. + +A lesson is considered complete if **ANY** of the following conditions are met: + +1. **Explicit Completion:** The package reports a status of `completed` or `passed`. + + - For SCORM 1.2: `cmi.core.lesson_status` is `completed` or `passed`. + - For SCORM 2004: `cmi.completion_status` is `completed` or `cmi.success_status` is `passed`. + +2. **Participation Fallback:** If the package does not report a completion status, CourseLit checks for evidence of participation. The lesson will be marked as complete if any of the following fields are present: + - `cmi.suspend_data` (User made progress) + - `cmi.core.session_time` (Time spent is recorded) + - `cmi.core.exit` (Clean exit occurred) + +> **Note:** If none of these conditions are met, the learner will see an error message asking them to complete the content first. + +## Learner experience + +When a learner opens a SCORM lesson: + +1. An **Enter** button is displayed + +![enter SCORM lesson](/assets/lessons/scorm/learner-enter.png) + +2. Clicking the button opens the SCORM content in a popup window + +![Popup SCORM lesson](/assets/lessons/scorm/learner-popup.png) + +3. Progress is automatically saved as the learner interacts with the content +4. When the learner closes the popup and returns, they can click **Complete and Continue** to proceed + +> **Note:** Progress is preserved even if the browser is closed unexpectedly. When the learner returns, they will resume from where they left off. + +## Technical notes + +### For self-hosted setups + +#### Enabling SCORM + +SCORM requires disk-based caching to be enabled. Set the `CACHE_DIR` environment variable to enable SCORM support: + +| Variable | Description | Required | +| -------------------------- | ------------------------------------------------------- | ------------------- | +| `CACHE_DIR` | Directory path for cache (SCORM uses `CACHE_DIR/scorm`) | **Yes** | +| `SCORM_PACKAGE_SIZE_LIMIT` | Maximum upload size for SCORM packages (in bytes) | No (default: 300MB) | + +> **Note:** If `CACHE_DIR` is not set, SCORM uploads will be disabled and the SCORM lesson type will appear grayed out in the lesson creator. + +#### Docker Compose Example + +```yaml +services: + web: + image: your-app + deploy: + replicas: 3 + volumes: + - cache-data:/app/cache + environment: + - CACHE_DIR=/app/cache + +volumes: + cache-data: +``` + +#### Serverless environments + +For serverless environments (Vercel, AWS Lambda), you can use `/tmp` as the cache directory: + +``` +CACHE_DIR=/tmp +``` + +Note that `/tmp` is ephemeral in serverless - extracted files will be re-extracted on cold starts, but this still works correctly. + +## Stuck somewhere? + +We are always here for you. Come chat with us in our Discord channel or send a tweet at @CourseLit. diff --git a/apps/web/.env b/apps/web/.env index 3efb1d1f7..f276d4e1d 100644 --- a/apps/web/.env +++ b/apps/web/.env @@ -24,4 +24,7 @@ # SUPER_ADMIN_EMAIL=your@email.com # Sequence settings -# SEQUENCE_DELAY_BETWEEN_MAILS = 86400000 # 1 day in milliseconds \ No newline at end of file +# SEQUENCE_DELAY_BETWEEN_MAILS = 86400000 # 1 day in milliseconds + +# Cache directory +# CACHE_DIR=/tmp \ No newline at end of file diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/page.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/page.tsx index 69da003d6..c31a32e3a 100644 --- a/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/page.tsx +++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/page.tsx @@ -27,6 +27,7 @@ import { Video, HelpCircle, ChevronDown, + Droplets, } from "lucide-react"; import Link from "next/link"; import { @@ -52,7 +53,6 @@ import { TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; -import { Droplets } from "lucide-react"; const { permissions } = UIConstants; export default function ContentPage() { diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/section/[section]/lesson/__tests__/page.test.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/section/[section]/lesson/__tests__/page.test.tsx index 6c5be8105..fbbae140f 100644 --- a/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/section/[section]/lesson/__tests__/page.test.tsx +++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/section/[section]/lesson/__tests__/page.test.tsx @@ -64,6 +64,12 @@ jest.mock("@courselit/components-library", () => ({ useToast: () => ({ toast: jest.fn(), }), + Chip: ({ children }: any) =>
{children}
, + useMediaLit: () => ({ + uploadFile: jest.fn(), + isUploading: false, + uploadProgress: 0, + }), })); jest.mock("../lesson-content-renderer", () => ({ diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/section/[section]/lesson/lesson-content-renderer.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/section/[section]/lesson/lesson-content-renderer.tsx index 1b09eedf9..e5ebbbf8a 100644 --- a/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/section/[section]/lesson/lesson-content-renderer.tsx +++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/section/[section]/lesson/lesson-content-renderer.tsx @@ -211,6 +211,45 @@ export function LessonContentRenderer({ )} ); + case Constants.LessonType.SCORM: + return ( +
+
+ {(lesson.content as any)?.mediaId ? ( +
+
+ + SCORM Package: + + + {(lesson.content as any)?.title || + "Uploaded"} + +
+
+ Version:{" "} + {(lesson.content as any)?.version || "1.2"} + {(lesson.content as any)?.fileCount && + ` • ${(lesson.content as any)?.fileCount} files`} +
+
+ ) : ( +
+

+ Save the lesson first, then upload the SCORM + package. +

+
+ )} +
+ {!lesson?.lessonId && ( +

+ + Save the lesson to enable SCORM package upload +

+ )} +
+ ); default: return null; } diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/section/[section]/lesson/page.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/section/[section]/lesson/page.tsx index 60ed59b8f..2990a51c9 100644 --- a/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/section/[section]/lesson/page.tsx +++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/section/[section]/lesson/page.tsx @@ -12,6 +12,7 @@ import { HelpCircle, File, Tv, + Package, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -37,10 +38,11 @@ import { MANAGE_COURSES_PAGE_HEADING, TOAST_TITLE_ERROR, TOAST_TITLE_SUCCESS, + ALPHA_LABEL, } from "@ui-config/strings"; import DashboardContent from "@components/admin/dashboard-content"; import useProduct from "@/hooks/use-product"; -import { AddressContext } from "@components/contexts"; +import { AddressContext, ServerConfigContext } from "@components/contexts"; import { Constants, Lesson, @@ -48,13 +50,20 @@ import { TextEditorContent, UIConstants, } from "@courselit/common-models"; -import { useToast } from "@courselit/components-library"; +import { useToast, Chip } from "@courselit/components-library"; import { FetchBuilder } from "@courselit/utils"; import { LessonContentRenderer } from "./lesson-content-renderer"; import { isTextEditorNonEmpty, truncate } from "@ui-lib/utils"; import { Separator } from "@components/ui/separator"; import { emptyDoc as TextEditorEmptyDoc } from "@courselit/text-editor"; import { LessonSkeleton } from "./skeleton"; +import { ScormLessonUpload } from "./scorm-lesson-upload"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; const { permissions } = UIConstants; @@ -66,6 +75,7 @@ const lessonTypes = [ { value: Constants.LessonType.FILE, label: "File", icon: File }, { value: Constants.LessonType.EMBED, label: "Embed", icon: Tv }, { value: Constants.LessonType.QUIZ, label: "Quiz", icon: HelpCircle }, + { value: Constants.LessonType.SCORM, label: "SCORM", icon: Package }, ] as const; type LessonError = Partial>; @@ -82,6 +92,7 @@ export default function LessonPage() { const { toast } = useToast(); const [errors, setErrors] = useState({}); const address = useContext(AddressContext); + const config = useContext(ServerConfigContext); const { product, loaded: productLoaded } = useProduct(productId); const breadcrumbs = [ { label: MANAGE_COURSES_PAGE_HEADING, href: "/dashboard/products" }, @@ -455,30 +466,80 @@ export default function LessonPage() { disabled={isEditing} > {lessonTypes.map( - ({ value, label, icon: Icon }) => ( - + ); + + if (isScormDisabled) { + return ( + + + + {cardContent} + + +

+ Set + CACHE_DIR + env var to + enable SCORM +

+
+
+
+ ); + } + + return cardContent; + }, )} @@ -640,6 +701,23 @@ export default function LessonPage() { )} + {lesson.type === Constants.LessonType.SCORM && + lesson.lessonId && ( + <> + + { + setLesson({ + ...lesson, + content: newContent, + }); + setContent(newContent); + }} + /> + + )} )} diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/section/[section]/lesson/scorm-lesson-upload.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/section/[section]/lesson/scorm-lesson-upload.tsx new file mode 100644 index 000000000..04e018a3b --- /dev/null +++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/section/[section]/lesson/scorm-lesson-upload.tsx @@ -0,0 +1,198 @@ +"use client"; + +import { useState, useContext } from "react"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { + Upload, + Package, + CheckCircle, + Loader2, + FileWarning, +} from "lucide-react"; +import { useToast, useMediaLit } from "@courselit/components-library"; +import { AddressContext } from "@components/contexts"; +import { Progress as ShadProgress } from "@/components/ui/progress"; +import { ScormContent } from "@courselit/common-models"; +import constants from "@config/constants"; + +interface ScormLessonUploadProps { + lessonId: string; + content?: ScormContent; + onUploadComplete: (content: ScormContent) => void; +} + +export function ScormLessonUpload({ + lessonId, + content, + onUploadComplete, +}: ScormLessonUploadProps) { + const address = useContext(AddressContext); + const { toast } = useToast(); + const [uploading, setUploading] = useState(false); + const [processing, setProcessing] = useState(false); + const [error, setError] = useState(null); + + const { uploadFile, isUploading, uploadProgress } = useMediaLit({ + signatureEndpoint: `${address.backend}/api/media/presigned`, + access: "private", + onUploadComplete: async (response) => { + setUploading(false); + setProcessing(true); + + try { + const result = await fetch( + `${address.backend}/api/lessons/${lessonId}/scorm/upload`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ mediaId: response.mediaId }), + }, + ); + + const data = await result.json(); + + if (!result.ok) { + throw new Error( + data.message || "Failed to process SCORM package", + ); + } + + toast({ + title: "Success", + description: "SCORM package uploaded successfully", + }); + + onUploadComplete({ + mediaId: response.mediaId, + launchUrl: data.packageInfo.entryPoint, + version: data.packageInfo.version, + title: data.packageInfo.title, + scoCount: data.packageInfo.scoCount, + fileCount: data.packageInfo.fileCount, + }); + + setError(null); + } catch (err: any) { + setError(err.message); + toast({ + title: "Error", + description: err.message, + variant: "destructive", + }); + } finally { + setProcessing(false); + } + }, + onUploadError: (err) => { + setUploading(false); + setError(err.message || "Upload failed"); + toast({ + title: "Upload Failed", + description: err.message, + variant: "destructive", + }); + }, + }); + + const handleFileSelect = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + if (!file.name.endsWith(".zip")) { + setError("Please select a ZIP file"); + return; + } + + if (file.size > constants.scormPackageSizeLimit) { + setError( + `File size must be less than ${constants.scormPackageSizeLimit / 1024 / 1024}MB`, + ); + return; + } + + setError(null); + setUploading(true); + await uploadFile(file); + }; + + const hasPackage = content?.mediaId; + const showProgress = uploading || isUploading; + + return ( +
+ + + {error && ( +
+ + {error} +
+ )} + + {hasPackage && ( +
+
+
+
+ +
+
+

+ {content?.title || "SCORM Package"} +

+

+ Version {content?.version || "1.2"} + {content?.fileCount && + ` • ${content.fileCount} files`} +

+
+
+ +
+
+ )} + + {showProgress || processing ? ( +
+
+ + {processing + ? "Processing SCORM package..." + : "Uploading..."} +
+ {showProgress && ( + + )} +
+ ) : ( +
+ + +

+ Upload a SCORM 1.2 or 2004 package (ZIP file) +

+
+ )} +
+ ); +} diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/products/page.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/products/page.tsx index 1c0e1ec4e..124879e8f 100644 --- a/apps/web/app/(with-contexts)/dashboard/(sidebar)/products/page.tsx +++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/products/page.tsx @@ -217,26 +217,27 @@ export default function Page() { - {totalPages > 0 && ( -
- -
- )} +
+ +
+ {totalPages > 0 ? ( +
// spacer + ) : null} {loading ? ( ) : ( diff --git a/apps/web/app/(with-contexts)/layout.tsx b/apps/web/app/(with-contexts)/layout.tsx index abbad60af..373ead0a5 100644 --- a/apps/web/app/(with-contexts)/layout.tsx +++ b/apps/web/app/(with-contexts)/layout.tsx @@ -7,6 +7,7 @@ import { getAddressFromHeaders } from "@/app/actions"; import { defaultState } from "@components/default-state"; import { decode } from "base-64"; import { ServerConfig, SiteInfo } from "@courselit/common-models"; +import constants from "@config/constants"; export default async function Layout({ children, @@ -22,6 +23,7 @@ export default async function Layout({ const config: ServerConfig = { turnstileSiteKey: process.env.TURNSTILE_SITE_KEY || "", queueServer: process.env.QUEUE_SERVER || "", + cacheEnabled: constants.cacheEnabled, recaptchaSiteKey: process.env.RECAPTCHA_SITE_KEY || "", }; diff --git a/apps/web/app/api/config/route.ts b/apps/web/app/api/config/route.ts index 38ec7c716..571f5db14 100644 --- a/apps/web/app/api/config/route.ts +++ b/apps/web/app/api/config/route.ts @@ -1,3 +1,4 @@ +import constants from "@config/constants"; import { NextRequest } from "next/server"; export const dynamic = "force-dynamic"; @@ -7,6 +8,7 @@ export async function GET(req: NextRequest) { { turnstileSiteKey: process.env.TURNSTILE_SITE_KEY, recaptchaSiteKey: process.env.RECAPTCHA_SITE_KEY || "", + cacheEnabled: constants.cacheEnabled, }, { status: 200 }, ); diff --git a/apps/web/app/api/lessons/[id]/scorm/upload/route.ts b/apps/web/app/api/lessons/[id]/scorm/upload/route.ts new file mode 100644 index 000000000..c74b2aec4 --- /dev/null +++ b/apps/web/app/api/lessons/[id]/scorm/upload/route.ts @@ -0,0 +1,176 @@ +import { NextRequest } from "next/server"; +import { auth } from "@/auth"; +import { checkPermission } from "@courselit/utils"; +import { UIConstants as constants, Constants } from "@courselit/common-models"; +import DomainModel, { Domain } from "@models/Domain"; +import User from "@models/User"; +import Lesson from "@models/Lesson"; +import { extractScormPackage } from "@/lib/scorm/extractor"; +import { MediaLit } from "medialit"; +import { error as logError } from "@/services/logger"; +import appConstants from "@config/constants"; + +/** + * POST: Process a SCORM package for a lesson + * Expects: { mediaId: string } - the MediaLit file ID of the uploaded ZIP + * + * Flow: + * 1. Fetch ZIP from MediaLit using mediaId + * 2. Extract and validate imsmanifest.xml + * 3. Update lesson content with mediaId and launchUrl + */ +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + // Check if SCORM is enabled + if (!appConstants.cacheEnabled) { + return Response.json( + { + message: + "SCORM is not enabled. Set CACHE_DIR environment variable.", + }, + { status: 400 }, + ); + } + + const { id: lessonId } = await params; + + // Get domain + const domain = await DomainModel.findOne({ + name: req.headers.get("domain"), + }); + if (!domain) { + return Response.json({ message: "Domain not found" }, { status: 404 }); + } + + // Auth check + const session = await auth.api.getSession({ headers: req.headers }); + if (!session) { + return Response.json({ message: "Unauthorized" }, { status: 401 }); + } + + const user = await User.findOne({ + email: session.user?.email, + domain: domain._id, + active: true, + }); + if (!user) { + return Response.json({ message: "User not found" }, { status: 404 }); + } + + // Permission check + if ( + !checkPermission(user.permissions, [ + constants.permissions.manageCourse, + constants.permissions.manageAnyCourse, + ]) + ) { + return Response.json({ message: "Forbidden" }, { status: 403 }); + } + + // Get lesson + const lesson = await Lesson.findOne({ + lessonId, + domain: domain._id, + }); + if (!lesson) { + return Response.json({ message: "Lesson not found" }, { status: 404 }); + } + + // Check if lesson type is SCORM + if (lesson.type !== Constants.LessonType.SCORM) { + return Response.json( + { message: "Lesson is not a SCORM lesson" }, + { status: 400 }, + ); + } + + try { + // Get mediaId from request body + const body = await req.json(); + const { mediaId } = body; + + if (!mediaId) { + return Response.json( + { message: "mediaId is required" }, + { status: 400 }, + ); + } + + // Initialize MediaLit client + const medialit = new MediaLit({ + apiKey: process.env.MEDIALIT_APIKEY, + endpoint: process.env.MEDIALIT_SERVER, + }); + + // Get the uploaded ZIP file URL from MediaLit + const media = await medialit.get(mediaId); + if (!media?.file) { + return Response.json( + { message: "SCORM package not found in MediaLit" }, + { status: 404 }, + ); + } + + // Fetch the ZIP file content + const zipResponse = await fetch(media.file as string); + if (!zipResponse.ok) { + return Response.json( + { message: "Failed to fetch SCORM package" }, + { status: 500 }, + ); + } + const arrayBuffer = await zipResponse.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + + // Extract and validate SCORM package + const result = await extractScormPackage(buffer); + if (!result.success || !result.packageInfo) { + return Response.json( + { message: result.error || "Invalid SCORM package" }, + { status: 400 }, + ); + } + + const { packageInfo } = result; + + // Update lesson content with SCORM metadata + await Lesson.updateOne( + { lessonId, domain: domain._id }, + { + $set: { + content: { + mediaId, + launchUrl: packageInfo.entryPoint, + version: packageInfo.version, + title: packageInfo.title, + scoCount: packageInfo.scos.length, + fileCount: packageInfo.files.length, + }, + }, + }, + ); + + return Response.json({ + success: true, + message: "SCORM package processed successfully", + packageInfo: { + version: packageInfo.version, + title: packageInfo.title, + entryPoint: packageInfo.entryPoint, + scoCount: packageInfo.scos.length, + fileCount: packageInfo.files.length, + }, + }); + } catch (err: any) { + logError("SCORM processing failed", { + err: err.message, + stack: err.stack, + }); + return Response.json( + { message: "Failed to process SCORM package" }, + { status: 500 }, + ); + } +} diff --git a/apps/web/app/api/scorm/lesson/[lessonId]/content/[...path]/route.ts b/apps/web/app/api/scorm/lesson/[lessonId]/content/[...path]/route.ts new file mode 100644 index 000000000..37d09413e --- /dev/null +++ b/apps/web/app/api/scorm/lesson/[lessonId]/content/[...path]/route.ts @@ -0,0 +1,103 @@ +import { NextRequest } from "next/server"; +import { auth } from "@/auth"; +import DomainModel, { Domain } from "@models/Domain"; +import User from "@models/User"; +import Lesson from "@models/Lesson"; +import { MediaLit } from "medialit"; +import { isEnrolled } from "@/ui-lib/utils"; +import path from "path"; +import { getExtractedFile, MIME_TYPES } from "@/lib/scorm/cache"; +import { error } from "@/services/logger"; + +/** + * GET: Serve SCORM content files from lesson's ZIP package + * Uses lessonId to look up the lesson and get mediaId from content field + */ +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ lessonId: string; path: string[] }> }, +) { + const { lessonId, path: pathParts } = await params; + const filePath = pathParts.join("/"); + + const domain = await DomainModel.findOne({ + name: req.headers.get("domain"), + }); + if (!domain) { + return Response.json({ message: "Domain not found" }, { status: 404 }); + } + + const session = await auth.api.getSession({ headers: req.headers }); + if (!session) { + return Response.json({ message: "Unauthorized" }, { status: 401 }); + } + + const user = await User.findOne({ + email: session.user?.email, + domain: domain._id, + active: true, + }); + if (!user) { + return Response.json({ message: "User not found" }, { status: 404 }); + } + + const lesson = await Lesson.findOne({ + lessonId, + domain: domain._id, + }); + if (!lesson) { + return Response.json({ message: "Lesson not found" }, { status: 404 }); + } + + const enrolled = isEnrolled(lesson.courseId, user); + if (!enrolled) { + return Response.json( + { message: "Enrollment required" }, + { status: 403 }, + ); + } + + const scormContent = lesson.content as { + mediaId?: string; + launchUrl?: string; + }; + if (!scormContent?.mediaId) { + return Response.json( + { message: "SCORM package not found for this lesson" }, + { status: 404 }, + ); + } + + const { mediaId } = scormContent; + const ext = path.extname(filePath).toLowerCase(); + const mimeType = MIME_TYPES[ext] || "application/octet-stream"; + + const medialit = new MediaLit({ + apiKey: process.env.MEDIALIT_APIKEY, + endpoint: process.env.MEDIALIT_SERVER, + }); + + try { + // Get extracted file directly (cached) + const fileContent = await getExtractedFile(mediaId, filePath, medialit); + if (!fileContent) { + return Response.json( + { message: "File not found in SCORM package" }, + { status: 404 }, + ); + } + + return new Response(new Uint8Array(fileContent), { + headers: { + "Content-Type": mimeType, + "Cache-Control": "public, max-age=3600", + }, + }); + } catch (err: any) { + error("SCORM content fetch error", { stack: err.stack }); + return Response.json( + { message: err.message || "Failed to load content" }, + { status: 500 }, + ); + } +} diff --git a/apps/web/app/api/scorm/lesson/[lessonId]/runtime/route.ts b/apps/web/app/api/scorm/lesson/[lessonId]/runtime/route.ts new file mode 100644 index 000000000..1b9156898 --- /dev/null +++ b/apps/web/app/api/scorm/lesson/[lessonId]/runtime/route.ts @@ -0,0 +1,226 @@ +import { NextRequest } from "next/server"; +import { auth } from "@/auth"; +import DomainModel, { Domain } from "@models/Domain"; +import User from "@models/User"; +import Lesson from "@models/Lesson"; +import { isEnrolled } from "@/ui-lib/utils"; +import { error } from "@/services/logger"; + +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ lessonId: string }> }, +) { + const { lessonId } = await params; + + const domain = await DomainModel.findOne({ + name: req.headers.get("domain"), + }); + if (!domain) { + return Response.json({ message: "Domain not found" }, { status: 404 }); + } + + const session = await auth.api.getSession({ headers: req.headers }); + if (!session) { + return Response.json({ message: "Unauthorized" }, { status: 401 }); + } + + const user = await User.findOne({ + email: session.user?.email, + domain: domain._id, + active: true, + }); + if (!user) { + return Response.json({ message: "User not found" }, { status: 404 }); + } + + const lesson = await Lesson.findOne({ + lessonId, + domain: domain._id, + }); + if (!lesson) { + return Response.json({ message: "Lesson not found" }, { status: 404 }); + } + + const { courseId } = lesson; + + const enrolled = isEnrolled(courseId, user); + if (!enrolled) { + return Response.json( + { message: "Enrollment required" }, + { status: 403 }, + ); + } + + const purchase = user.purchases.find((p: any) => p.courseId === courseId); + if (!purchase) { + return Response.json( + { message: "Progress not found" }, + { status: 404 }, + ); + } + + const lessonScormData = purchase.scormData?.lessons?.[lessonId] || {}; + + const result = lessonScormData.cmi + ? lessonScormData + : { cmi: lessonScormData }; + + return Response.json(result); +} + +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ lessonId: string }> }, +) { + const { lessonId } = await params; + + const domain = await DomainModel.findOne({ + name: req.headers.get("domain"), + }); + if (!domain) { + return Response.json({ message: "Domain not found" }, { status: 404 }); + } + + const session = await auth.api.getSession({ headers: req.headers }); + if (!session) { + return Response.json({ message: "Unauthorized" }, { status: 401 }); + } + + const user = await User.findOne({ + email: session.user?.email, + domain: domain._id, + active: true, + }); + if (!user) { + return Response.json({ message: "User not found" }, { status: 404 }); + } + + const lesson = await Lesson.findOne({ + lessonId, + domain: domain._id, + }); + if (!lesson) { + return Response.json({ message: "Lesson not found" }, { status: 404 }); + } + + const { courseId } = lesson; + + const enrolled = isEnrolled(courseId, user); + if (!enrolled) { + return Response.json( + { message: "Enrollment required" }, + { status: 403 }, + ); + } + + const body = await req.json(); + const { element, value, updates } = body; + + // Support both single update and batch updates + const updateEntries: [string, unknown][] = updates + ? Object.entries(updates) + : element + ? [[element, value]] + : []; + + if (updateEntries.length === 0) { + return Response.json( + { message: "No updates provided" }, + { status: 400 }, + ); + } + + try { + const userDoc = await User.findById(user._id); + if (!userDoc) { + return Response.json( + { message: "User not found" }, + { status: 404 }, + ); + } + + const purchase = userDoc.purchases.find( + (p: any) => p.courseId === courseId, + ); + if (!purchase) { + return Response.json( + { message: "Purchase not found" }, + { status: 404 }, + ); + } + + if (!purchase.scormData) { + purchase.scormData = { lessons: {} }; + } + if (!purchase.scormData.lessons) { + purchase.scormData.lessons = {}; + } + if (!purchase.scormData.lessons[lessonId]) { + purchase.scormData.lessons[lessonId] = {}; + } + + // Apply all updates + for (const [elem, val] of updateEntries) { + setNestedValue(purchase.scormData.lessons[lessonId], elem, val); + } + + userDoc.markModified("purchases"); + await userDoc.save(); + + return Response.json({ success: true }); + } catch (err: any) { + error(err.message, { + stack: err.stack, + }); + return Response.json( + { message: "Failed to save runtime data" }, + { status: 500 }, + ); + } +} + +// List of unsafe property keys that could lead to prototype pollution +const UNSAFE_KEYS = new Set(["__proto__", "constructor", "prototype"]); + +// Sanitize key to prevent prototype pollution - returns null for unsafe keys +function sanitizePropertyKey(key: string): string | null { + if (UNSAFE_KEYS.has(key)) { + return null; + } + // Create a new string literal to break taint tracking + return `${key}`; +} + +function setNestedValue(obj: any, path: string, value: unknown): void { + const parts = path.split("."); + + let current = obj; + for (let i = 0; i < parts.length - 1; i++) { + const sanitizedPart = sanitizePropertyKey(parts[i]); + if (sanitizedPart === null) { + // Prevent prototype pollution by ignoring unsafe path segments + return; + } + + // Use Reflect API for safe property access + const existing = Reflect.get(current, sanitizedPart); + if (existing !== undefined && existing !== null) { + current = existing; + } else { + const nextPart = parts[i + 1]; + // Use Object.create(null) to create objects without prototype chain + const newObj = /^\d+$/.test(nextPart) ? [] : Object.create(null); + Reflect.set(current, sanitizedPart, newObj); + current = newObj; + } + } + + const sanitizedLastPart = sanitizePropertyKey(parts[parts.length - 1]); + if (sanitizedLastPart === null) { + // Prevent prototype pollution on final assignment + return; + } + + // Use Reflect.set for the final assignment + Reflect.set(current, sanitizedLastPart, value); +} diff --git a/apps/web/components/default-state.ts b/apps/web/components/default-state.ts index ff514fc8b..36e1bb605 100644 --- a/apps/web/components/default-state.ts +++ b/apps/web/components/default-state.ts @@ -72,6 +72,7 @@ export const defaultState: { config: { turnstileSiteKey: "", queueServer: "", + cacheEnabled: false, }, theme: { id: "", diff --git a/apps/web/components/public/lesson-viewer/index.tsx b/apps/web/components/public/lesson-viewer/index.tsx index 667d7b224..73c9a2769 100644 --- a/apps/web/components/public/lesson-viewer/index.tsx +++ b/apps/web/components/public/lesson-viewer/index.tsx @@ -8,6 +8,7 @@ import { LESSON_TYPE_TEXT, LESSON_TYPE_EMBED, LESSON_TYPE_QUIZ, + LESSON_TYPE_SCORM, } from "@/ui-config/constants"; import { COURSE_PROGRESS_FINISH, @@ -32,6 +33,7 @@ import { ArrowLeft, ArrowRight, ArrowDownward } from "@courselit/icons"; import { isEnrolled } from "../../../ui-lib/utils"; import LessonEmbedViewer from "./embed-viewer"; import QuizViewer from "./quiz-viewer"; +import ScormViewer from "./scorm-viewer"; import { getUserProfile } from "@/app/(with-contexts)/helpers"; import WidgetErrorBoundary from "../base-layout/template/widget-error-boundary"; import { Button, Header1, Text1 } from "@courselit/page-primitives"; @@ -90,7 +92,7 @@ export const LessonViewer = ({ const loadLesson = async (id: string) => { const query = ` query { - lesson: getLessonDetails(id: "${id}") { + lesson: getLessonDetails(id: "${id}", courseId: "${productId}") { lessonId, title, downloadable, @@ -352,6 +354,21 @@ export const LessonViewer = ({
)} + {String.prototype.toUpperCase.call( + LESSON_TYPE_SCORM, + ) === lesson.type && + lesson.content && ( + + )} )} diff --git a/apps/web/components/public/lesson-viewer/scorm-viewer.tsx b/apps/web/components/public/lesson-viewer/scorm-viewer.tsx new file mode 100644 index 000000000..9af69e7e4 --- /dev/null +++ b/apps/web/components/public/lesson-viewer/scorm-viewer.tsx @@ -0,0 +1,350 @@ +"use client"; + +import { useEffect, useRef, useCallback, useState, useContext } from "react"; +import { debounce } from "@courselit/utils"; +import { Button } from "@courselit/page-primitives"; +import { ThemeContext } from "@components/contexts"; + +interface ScormViewerProps { + lessonId: string; + launchUrl: string; +} + +interface CMIData { + [key: string]: unknown; +} + +export function ScormViewer({ lessonId, launchUrl }: ScormViewerProps) { + const [isDataLoaded, setIsDataLoaded] = useState(false); + const scormState = useRef({}); + const pendingUpdates = useRef>(new Map()); + const sessionActive = useRef(false); + const { theme } = useContext(ThemeContext); + + // Centralized GetValue logic + const getValue = useCallback((element: string): string => { + if (!sessionActive.current) { + console.warn(`[SCORM] GetValue rejected - session not active`); + return ""; + } + const value = getNestedValue(scormState.current, element); + return value !== undefined ? String(value) : ""; + }, []); + + // Debounced batch commit function + const commitToServer = useCallback( + debounce(async () => { + if (pendingUpdates.current.size === 0) return; + + const updates = Object.fromEntries(pendingUpdates.current); + pendingUpdates.current.clear(); + + try { + await fetch(`/api/scorm/lesson/${lessonId}/runtime`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ updates }), + }); + } catch (err) { + console.error("Failed to save SCORM data:", err); + // Re-queue failed updates + for (const [k, v] of Object.entries(updates)) { + pendingUpdates.current.set(k, v); + } + } + }, 500), + [lessonId], + ); + + // Centralized SetValue logic + const setValue = useCallback( + (element: string, value: string): string => { + if (!sessionActive.current) { + console.warn(`[SCORM] SetValue rejected - session not active`); + return "false"; + } + + // Fix C: Prevent infinite suspend_data growth (4KB limit for SCORM 1.2) + let safeValue = value; + if ( + element === "cmi.suspend_data" || + element === "cmi.core.suspend_data" + ) { + const maxSize = 4096; + if (value.length > maxSize) { + console.warn( + `[SCORM] suspend_data exceeded ${maxSize} bytes, truncating`, + ); + safeValue = value.slice(0, maxSize); + } + } + + setNestedValue(scormState.current, element, safeValue); + pendingUpdates.current.set(element, safeValue); + commitToServer(); + return "true"; + }, + [commitToServer], + ); + + // Load initial SCORM data & Initialize Standard Fields + useEffect(() => { + async function loadScormData() { + try { + const response = await fetch( + `/api/scorm/lesson/${lessonId}/runtime`, + ); + if (response.ok) { + const data = await response.json(); + scormState.current = data || { cmi: {} }; + + // Tell the SCO the context (Credit, Mode, Entry) + const hasSuspend = !!getNestedValue( + scormState.current, + "cmi.suspend_data", + ); + const entryValue = hasSuspend ? "resume" : "ab-initio"; + + // SCORM 1.2 + setNestedValue( + scormState.current, + "cmi.core.entry", + entryValue, + ); + setNestedValue( + scormState.current, + "cmi.core.lesson_mode", + "normal", + ); + setNestedValue( + scormState.current, + "cmi.core.credit", + "credit", + ); + + // SCORM 2004 + setNestedValue(scormState.current, "cmi.entry", entryValue); + setNestedValue(scormState.current, "cmi.mode", "normal"); + setNestedValue(scormState.current, "cmi.credit", "credit"); + + // Ensure default status is present for 1.2 if missing + if ( + !getNestedValue( + scormState.current, + "cmi.core.lesson_status", + ) + ) { + setNestedValue( + scormState.current, + "cmi.core.lesson_status", + "not attempted", + ); + } + // Ensure default status is present for 2004 if missing + if ( + !getNestedValue( + scormState.current, + "cmi.completion_status", + ) + ) { + setNestedValue( + scormState.current, + "cmi.completion_status", + "unknown", + ); // 2004 default is 'unknown' + } + } + } catch (err) { + console.error("Failed to load SCORM runtime data:", err); + } finally { + setIsDataLoaded(true); + } + } + loadScormData(); + }, [lessonId]); + + // Force immediate flush (no debounce) - used for Finish/Terminate + const forceFlush = useCallback(async () => { + if (pendingUpdates.current.size === 0) return; + const updates = Object.fromEntries(pendingUpdates.current); + pendingUpdates.current.clear(); + await fetch(`/api/scorm/lesson/${lessonId}/runtime`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ updates }), + }); + }, [lessonId]); + + // Fix B: Save on browser close/crash + useEffect(() => { + const onUnload = () => { + if (!sessionActive.current) return; + + setNestedValue(scormState.current, "cmi.core.exit", "suspend"); + pendingUpdates.current.set("cmi.core.exit", "suspend"); + + const payload = JSON.stringify({ + updates: Object.fromEntries(pendingUpdates.current), + }); + navigator.sendBeacon( + `/api/scorm/lesson/${lessonId}/runtime`, + payload, + ); + }; + + window.addEventListener("beforeunload", onUnload); + return () => window.removeEventListener("beforeunload", onUnload); + }, [lessonId]); + + // SCORM 1.2 API implementation - only expose after data is loaded + useEffect(() => { + if (!isDataLoaded) return; + + const API = { + LMSInitialize: () => { + sessionActive.current = true; + return "true"; + }, + LMSGetValue: (element: string) => getValue(element), + LMSSetValue: (element: string, value: string) => + setValue(element, value), + LMSCommit: () => { + commitToServer(); + return "true"; + }, + LMSFinish: async () => { + // SCORM 1.2: If status is 'not attempted', it becomes 'completed' + const status = getNestedValue( + scormState.current, + "cmi.core.lesson_status", + ); + if (status === "not attempted") { + setNestedValue( + scormState.current, + "cmi.core.lesson_status", + "completed", + ); + pendingUpdates.current.set( + "cmi.core.lesson_status", + "completed", + ); + } + + // Always suspend on exit for resume support + setNestedValue(scormState.current, "cmi.core.exit", "suspend"); + pendingUpdates.current.set("cmi.core.exit", "suspend"); + + await forceFlush(); + sessionActive.current = false; + return "true"; + }, + LMSGetLastError: () => "0", + LMSGetErrorString: () => "", + LMSGetDiagnostic: () => "", + }; + + // SCORM 2004 + const API_1484_11 = { + Initialize: () => { + sessionActive.current = true; + return "true"; + }, + GetValue: (element: string) => getValue(element), + SetValue: (element: string, value: string) => + setValue(element, value), + Commit: () => { + commitToServer(); + return "true"; + }, + Terminate: async () => { + // Always suspend on exit for resume support + setNestedValue(scormState.current, "cmi.exit", "suspend"); + pendingUpdates.current.set("cmi.exit", "suspend"); + + await forceFlush(); + sessionActive.current = false; + return "true"; + }, + GetLastError: () => "0", + GetErrorString: () => "", + GetDiagnostic: () => "", + }; + + // Expose APIs on window for SCORM content to find + (window as any).API = API; + (window as any).API_1484_11 = API_1484_11; + + return () => { + delete (window as any).API; + delete (window as any).API_1484_11; + }; + }, [isDataLoaded, getValue, setValue, commitToServer, forceFlush]); + + // Open in popup window + const openInPopup = useCallback(() => { + const url = `/api/scorm/lesson/${lessonId}/content/${launchUrl}`; + const width = window.screen.width * 0.9; + const height = window.screen.height * 0.9; + const left = (window.screen.width - width) / 2; + const top = (window.screen.height - height) / 2; + + window.open( + url, + "scorm_content", + `width=${width},height=${height},left=${left},top=${top},menubar=no,toolbar=no,location=no,status=no,resizable=yes,scrollbars=yes`, + ); + }, [lessonId, launchUrl]); + + return ( +
+ +
+ ); +} + +// Helper to get nested value from object +function getNestedValue(obj: any, path: string): unknown { + return path.split(".").reduce((curr, key) => curr?.[key], obj); +} + +// Guard against prototype pollution by blocking dangerous keys +function isUnsafeKey(key: string): boolean { + return key === "__proto__" || key === "constructor" || key === "prototype"; +} + +// Helper to set nested value in object +function setNestedValue(obj: any, path: string, value: unknown): void { + const parts = path.split("."); + let current: any = obj; + + for (let i = 0; i < parts.length - 1; i++) { + const part = parts[i]; + + // Prevent prototype pollution via unsafe keys + if (isUnsafeKey(part)) { + return; + } + + if (current[part] === undefined || current[part] === null) { + // Use Object.create(null) to create objects without prototype chain + current[part] = Object.create(null); + } + + current = current[part]; + if (typeof current !== "object") { + // Cannot safely nest further into non-object + return; + } + } + + const lastPart = parts[parts.length - 1]; + if (isUnsafeKey(lastPart)) { + return; + } + + current[lastPart] = value; +} + +export default ScormViewer; diff --git a/apps/web/config/constants.ts b/apps/web/config/constants.ts index f4c646673..aa7270548 100644 --- a/apps/web/config/constants.ts +++ b/apps/web/config/constants.ts @@ -23,6 +23,7 @@ export default { quiz: "quiz", file: "file", embed: "embed", + scorm: "scorm", // media access type publicMedia: "public", @@ -108,4 +109,13 @@ export default { // payment plan internalPaymentPlanName: "Internal Payment Plan", + + // scorm configuration + cacheEnabled: !!process.env.CACHE_DIR, + scormCacheDir: process.env.CACHE_DIR + ? `${process.env.CACHE_DIR}/scorm` + : "", + scormPackageSizeLimit: process.env.SCORM_PACKAGE_SIZE_LIMIT + ? +process.env.SCORM_PACKAGE_SIZE_LIMIT + : 300 * 1024 * 1024, // 300MB } as const; diff --git a/apps/web/graphql/courses/logic.ts b/apps/web/graphql/courses/logic.ts index 1bb2b6a7c..d0c6ed860 100644 --- a/apps/web/graphql/courses/logic.ts +++ b/apps/web/graphql/courses/logic.ts @@ -594,31 +594,6 @@ export const getProducts = async ({ products.push(extendedCourse); } - // const products = courses.map(async (course) => ({ - // ...course, - // groups: course.type !== constants.blog ? course.groups : null, - // pageId: course.type !== constants.blog ? course.pageId : undefined, - // customers: - // hasManagePerm && course.type !== constants.blog - // ? await (MembershipModel as any).countDocuments({ - // entityId: course.courseId, - // entityType: Constants.MembershipEntityType.COURSE, - // domain: ctx.subdomain._id, - // }) - // : undefined, - // sales: - // hasManagePerm && course.type !== constants.blog - // ? ( - // await getActivities({ - // entityId: course.courseId, - // type: ActivityType.PURCHASED, - // duration: "lifetime", - // ctx, - // }) - // ).count - // : undefined, - // })); - return products; }; diff --git a/apps/web/graphql/lessons/__tests__/scorm.test.ts b/apps/web/graphql/lessons/__tests__/scorm.test.ts new file mode 100644 index 000000000..925746882 --- /dev/null +++ b/apps/web/graphql/lessons/__tests__/scorm.test.ts @@ -0,0 +1,240 @@ +import { markLessonCompleted } from "../logic"; +import LessonModel from "@/models/Lesson"; +import UserModel from "@/models/User"; +import CourseModel from "@/models/Course"; +import DomainModel from "@/models/Domain"; +import { Constants } from "@courselit/common-models"; +import mongoose from "mongoose"; + +const SUITE_PREFIX = `scorm-tests-${Date.now()}`; +const id = (suffix: string) => `${SUITE_PREFIX}-${suffix}`; +const email = (suffix: string) => `${suffix}-${SUITE_PREFIX}@example.com`; + +describe("SCORM Logic Integration", () => { + let testDomain: any; + let user: any; + let course: any; + let scormLesson: any; + let mockCtx: any; + + beforeAll(async () => { + // Create Domain + testDomain = await DomainModel.create({ + name: id("domain"), + email: email("domain"), + features: [], + }); + + // Create User + user = await UserModel.create({ + domain: testDomain._id, + userId: id("user"), + email: email("user"), + name: "Test User", + active: true, + permissions: [], + unsubscribeToken: id("unsubscribe-user"), + purchases: [], + }); + + const groupId = new mongoose.Types.ObjectId().toString(); + + // Create Course + course = await CourseModel.create({ + domain: testDomain._id, + courseId: id("course"), + title: "SCORM Course", + lessons: [], + creatorId: user.userId, + cost: 0, + privacy: "public", + type: "course", + costType: "free", + slug: id("course-slug"), + published: true, + groups: [ + { + _id: groupId, + name: "Default Group", + lessonsOrder: [], + rank: 1, + collapsed: true, + drip: { + status: false, + type: "relative-date", + }, + }, + ], + }); + + // Create SCORM Lesson + scormLesson = await LessonModel.create({ + domain: testDomain._id, + courseId: course.courseId, + lessonId: id("lesson-scorm"), + title: "SCORM Lesson", + type: Constants.LessonType.SCORM, + requiresEnrollment: true, + content: { + launchUrl: "index.html", + version: "1.2", + }, + creatorId: user.userId, + groupId: groupId, + }); + + // Add lesson to course + course.lessons.push(scormLesson.lessonId); + await course.save(); + + // Enroll user + user.purchases.push({ + courseId: course.courseId, + accessibleGroups: [groupId], // User needs access to the group too! + completedLessons: [], + scormData: { + lessons: {}, + }, + }); + user.markModified("purchases"); + await user.save(); + + mockCtx = { + user: user, + subdomain: testDomain, + } as any; + }); + + afterAll(async () => { + await UserModel.deleteMany({ domain: testDomain._id }); + await LessonModel.deleteMany({ domain: testDomain._id }); + await CourseModel.deleteMany({ domain: testDomain._id }); + await DomainModel.deleteOne({ _id: testDomain._id }); + }); + + beforeEach(async () => { + // Reset user progress for the lesson + const u = await UserModel.findById(user._id); + const purchase = u!.purchases.find( + (p: any) => p.courseId === course.courseId, + ); + if (!purchase) throw new Error("Purchase not found"); + + purchase.completedLessons = []; + ensureScormData(purchase); + purchase.scormData.lessons = {}; // Clear lessons + + u!.markModified("purchases"); + await u!.save(); + mockCtx.user = u; // update context user + }); + + const ensureScormData = (purchase: any) => { + if (!purchase.scormData) purchase.scormData = {}; + if (!purchase.scormData.lessons) purchase.scormData.lessons = {}; + return purchase; + }; + + it("should fail validation if no SCORM data exists", async () => { + await expect( + markLessonCompleted(scormLesson.lessonId, mockCtx), + ).rejects.toThrow("Please complete the SCORM content first"); + }); + + it("should fail if SCORM 1.2 status is incomplete", async () => { + // Update user with incomplete status + const u = await UserModel.findById(user._id); + const purchase = u!.purchases.find( + (p: any) => p.courseId === course.courseId, + ); + ensureScormData(purchase); + + purchase.scormData.lessons[scormLesson.lessonId] = { + cmi: { + core: { + lesson_status: "incomplete", + }, + }, + }; + u!.markModified("purchases"); + await u!.save(); + mockCtx.user = u; + + await expect( + markLessonCompleted(scormLesson.lessonId, mockCtx), + ).rejects.toThrow("Please complete the SCORM content first"); + }); + + it("should succeed if SCORM 1.2 status is completed", async () => { + // Update user with completed status + const u = await UserModel.findById(user._id); + const purchase = u!.purchases.find( + (p: any) => p.courseId === course.courseId, + ); + ensureScormData(purchase); + + purchase.scormData.lessons[scormLesson.lessonId] = { + cmi: { + core: { + lesson_status: "completed", + }, + }, + }; + u!.markModified("purchases"); + await u!.save(); + mockCtx.user = u; + + const result = await markLessonCompleted(scormLesson.lessonId, mockCtx); + expect(result).toBe(true); + + // Verify it was marked as completed in progress + const updatedUser = await UserModel.findById(user._id); + const p = updatedUser!.purchases.find( + (p: any) => p.courseId === course.courseId, + ); + expect(p?.completedLessons).toContain(scormLesson.lessonId); + }); + + it("should succeed if SCORM 2004 completion_status is completed", async () => { + const u = await UserModel.findById(user._id); + const purchase = u!.purchases.find( + (p: any) => p.courseId === course.courseId, + ); + ensureScormData(purchase); + + purchase.scormData.lessons[scormLesson.lessonId] = { + cmi: { + completion_status: "completed", + }, + }; + u!.markModified("purchases"); + await u!.save(); + mockCtx.user = u; + + const result = await markLessonCompleted(scormLesson.lessonId, mockCtx); + expect(result).toBe(true); + }); + + it("should succeed via fallback if interaction data exists", async () => { + const u = await UserModel.findById(user._id); + const purchase = u!.purchases.find( + (p: any) => p.courseId === course.courseId, + ); + ensureScormData(purchase); + + purchase.scormData.lessons[scormLesson.lessonId] = { + cmi: { + core: { + lesson_status: "incomplete", + exit: "suspend", // Has exit data + }, + }, + }; + u!.markModified("purchases"); + await u!.save(); + mockCtx.user = u; + + const result = await markLessonCompleted(scormLesson.lessonId, mockCtx); + expect(result).toBe(true); + }); +}); diff --git a/apps/web/graphql/lessons/helpers.ts b/apps/web/graphql/lessons/helpers.ts index 05fce7fd0..3e410e0a4 100644 --- a/apps/web/graphql/lessons/helpers.ts +++ b/apps/web/graphql/lessons/helpers.ts @@ -84,9 +84,7 @@ export const getGroupedLessons = async ( }); const allLessons = await LessonModel.find( { - lessonId: { - $in: [...course.lessons], - }, + courseId: courseId, domain: domainId, }, { diff --git a/apps/web/graphql/lessons/logic.ts b/apps/web/graphql/lessons/logic.ts index 43ad2587a..2b6ad7f91 100644 --- a/apps/web/graphql/lessons/logic.ts +++ b/apps/web/graphql/lessons/logic.ts @@ -30,8 +30,9 @@ import getDeletedMediaIds, { extractMediaIDs, } from "@/lib/get-deleted-media-ids"; import ActivityModel from "@/models/Activity"; +import UserModel from "../../models/User"; -const { permissions, quiz } = constants; +const { permissions, quiz, scorm } = constants; const getLessonOrThrow = async ( id: string, @@ -69,11 +70,19 @@ export const getLesson = async (id: string, ctx: GQLContext) => { return await getLessonOrThrow(id, ctx); }; -export const getLessonDetails = async (id: string, ctx: GQLContext) => { - const lesson = await LessonModel.findOne({ +export const getLessonDetails = async ( + id: string, + ctx: GQLContext, + courseId?: string, +) => { + const query: any = { lessonId: id, domain: ctx.subdomain._id, - }); + }; + if (courseId) { + query.courseId = courseId; + } + const lesson = await LessonModel.findOne(query); if (!lesson) { throw new Error(responses.item_not_found); @@ -268,9 +277,7 @@ export const getAllLessons = async ( ) => { const lessons = await LessonModel.find( { - lessonId: { - $in: [...course.lessons], - }, + courseId: course.courseId, domain: ctx.subdomain._id, }, { @@ -338,6 +345,45 @@ export const markLessonCompleted = async ( } } + // Check SCORM completion status + if (lesson.type === scorm) { + // Re-fetch user using .lean() to get a plain JS object. + const freshUser: any = await UserModel.findById(ctx.user._id).lean(); + const purchase = freshUser?.purchases?.[enrolledItemIndex]; + const lessonData = (purchase as any)?.scormData?.lessons?.[lessonId]; + + let isCompleted = false; + + if (lessonData?.cmi) { + // SCORM 1.2 + const status12 = lessonData.cmi.core?.lesson_status; + // SCORM 2004 + const completion2004 = lessonData.cmi.completion_status; + const success2004 = lessonData.cmi.success_status; + + isCompleted = + status12 === "completed" || + status12 === "passed" || + completion2004 === "completed" || + success2004 === "passed"; + + // Fallback: Allow completion if user has interacted (saved data exists) + if (!isCompleted) { + const hasData = + !!lessonData.cmi.suspend_data || + !!lessonData.cmi.core?.session_time || + !!lessonData.cmi.core?.exit; + if (hasData) { + isCompleted = true; + } + } + } + + if (!isCompleted) { + throw new Error("Please complete the SCORM content first"); + } + } + await recordProgress({ lessonId, courseId: lesson.courseId, diff --git a/apps/web/graphql/lessons/query.ts b/apps/web/graphql/lessons/query.ts index f6676a043..f63db645a 100644 --- a/apps/web/graphql/lessons/query.ts +++ b/apps/web/graphql/lessons/query.ts @@ -20,8 +20,14 @@ export default { id: { type: new GraphQLNonNull(GraphQLString), }, + courseId: { + type: GraphQLString, + }, }, - resolve: (_: any, { id }: { id: string }, context: GQLContext) => - getLessonDetails(id, context), + resolve: ( + _: any, + { id, courseId }: { id: string; courseId?: string }, + context: GQLContext, + ) => getLessonDetails(id, context, courseId), }, }; diff --git a/apps/web/graphql/lessons/types.ts b/apps/web/graphql/lessons/types.ts index 2169142a8..9a3d41360 100644 --- a/apps/web/graphql/lessons/types.ts +++ b/apps/web/graphql/lessons/types.ts @@ -14,7 +14,7 @@ import mediaTypes from "../media/types"; import { getMedia } from "../media/logic"; import { GraphQLJSONObject } from "graphql-type-json"; -const { text, audio, video, pdf, quiz, file, embed } = constants; +const { text, audio, video, pdf, quiz, file, embed, scorm } = constants; const DESCRIPTION_REQUIRES_ENROLLMENT = "Should the content of this lesson be visible to only enrolled customers."; @@ -32,6 +32,7 @@ const lessontypeType = new GraphQLEnumType({ QUIZ: { value: quiz }, FILE: { value: file }, EMBED: { value: embed }, + SCORM: { value: scorm }, }, }); diff --git a/apps/web/graphql/users/__tests__/delete-user.test.ts b/apps/web/graphql/users/__tests__/delete-user.test.ts index a069fda55..7c5d228d7 100644 --- a/apps/web/graphql/users/__tests__/delete-user.test.ts +++ b/apps/web/graphql/users/__tests__/delete-user.test.ts @@ -51,7 +51,7 @@ jest.mock("../../communities/logic", () => ({ deleteCommunityPosts: jest.fn().mockResolvedValue(true), })); -const DELETE_USER_SUITE_PREFIX = `delete-user-${Date.now()}`; +const DELETE_USER_SUITE_PREFIX = `delete-user-${Date.now()}-${Math.floor(Math.random() * 100000)}`; const duId = (suffix: string) => `${DELETE_USER_SUITE_PREFIX}-${suffix}`; const duEmail = (suffix: string) => `${suffix}-${DELETE_USER_SUITE_PREFIX}@example.com`; diff --git a/apps/web/lib/scorm/__tests__/extractor.test.ts b/apps/web/lib/scorm/__tests__/extractor.test.ts new file mode 100644 index 000000000..9c5a4f736 --- /dev/null +++ b/apps/web/lib/scorm/__tests__/extractor.test.ts @@ -0,0 +1,179 @@ +import { extractScormPackage } from "../extractor"; + +// Mock config +jest.mock("@/config/constants", () => ({ + __esModule: true, + default: { + scormPackageSizeLimit: 10 * 1024 * 1024, + }, +})); + +// Mock adm-zip to strictly test extractor logic without relying on zip binary format +jest.mock("adm-zip", () => { + return jest.fn().mockImplementation((buffer: Buffer) => { + let files: Record = {}; + try { + const str = buffer.toString(); + if (str.startsWith("{")) { + files = JSON.parse(str); + } + // If not JSON, it is treated as invalid zip (empty entries) + } catch (e) { + // ignore + } + + return { + getEntries: () => { + return Object.entries(files).map(([name, content]) => ({ + entryName: name, + isDirectory: false, + getData: () => Buffer.from(content), + })); + }, + }; + }); +}); + +describe("SCORM Extractor", () => { + const createScormPackage = ( + manifestContent: string, + extraFiles: Record = {}, + ) => { + // Create a "mock zip" which is just a JSON object of filenames to content + // The mocked AdmZip will parse this. + const files = { "imsmanifest.xml": manifestContent, ...extraFiles }; + return Buffer.from(JSON.stringify(files)); + }; + + describe("extractScormPackage", () => { + it("should fail if zip buffer is invalid (not our mock format)", async () => { + const result = await extractScormPackage( + Buffer.from("invalid zip data"), + ); + // Mock returns empty entries for invalid data + expect(result.success).toBe(false); + expect(result.error).toContain("imsmanifest.xml not found"); + }); + + it("should fail if imsmanifest.xml is missing", async () => { + const buffer = Buffer.from( + JSON.stringify({ "test.txt": "content" }), + ); + + const result = await extractScormPackage(buffer); + expect(result.success).toBe(false); + expect(result.error).toContain("imsmanifest.xml not found"); + }); + + it("should extract valid SCORM 1.2 package", async () => { + const manifest = ` + + + + SCORM 1.2 Course + + Lesson 1 + + + + + + + + + `; + + const buffer = createScormPackage(manifest, { + "index.html": "", + }); + const result = await extractScormPackage(buffer); + + expect(result.success).toBe(true); + expect(result.packageInfo).toEqual({ + version: "1.2", + title: "SCORM 1.2 Course", + entryPoint: "index.html", + scos: expect.arrayContaining([ + expect.objectContaining({ + identifier: "item1", + title: "Lesson 1", + launchUrl: "index.html", + }), + ]), + files: expect.arrayContaining([ + "imsmanifest.xml", + "index.html", + ]), + }); + }); + + it("should extract valid SCORM 2004 package", async () => { + const manifest = ` + + + ADL SCORM + 2004 3rd Edition + + + + SCORM 2004 Course + + Lesson 1 + + + + + + + + + `; + + const buffer = createScormPackage(manifest, { + "index.html": "", + }); + const result = await extractScormPackage(buffer); + + expect(result.success).toBe(true); + expect(result.packageInfo?.version).toBe("2004"); + }); + + it("should detect entry point mismatch", async () => { + const manifest = ` + + + + Broken Course + + Lesson 1 + + + + + + + + + `; + + // "missing.html" is not in the zip + const buffer = createScormPackage(manifest, { + "other.html": "", + }); + const result = await extractScormPackage(buffer); + + expect(result.success).toBe(false); + expect(result.error).toContain( + 'Entry point "missing.html" not found', + ); + }); + }); +}); diff --git a/apps/web/lib/scorm/cache.ts b/apps/web/lib/scorm/cache.ts new file mode 100644 index 000000000..ad961224d --- /dev/null +++ b/apps/web/lib/scorm/cache.ts @@ -0,0 +1,262 @@ +import { MediaLit } from "medialit"; +import path from "path"; +import AdmZip from "adm-zip"; +import fs from "fs/promises"; +import { error } from "@/services/logger"; +import constants from "@config/constants"; + +/** + * Get an extracted file from SCORM package (main API) + * Extracts ZIP to disk and caches extracted files + * Requires CACHE_DIR env var to be set (via constants.scormCacheDir) + */ +export async function getExtractedFile( + mediaId: string, + filePath: string, + medialit: MediaLit, +): Promise { + if (!constants.cacheEnabled) { + throw new Error( + "SCORM is not enabled. Set CACHE_DIR environment variable.", + ); + } + + const cacheDir = constants.scormCacheDir; + const extractDir = path.join(cacheDir, mediaId); + const targetFile = path.join(extractDir, filePath); + const markerFile = path.join(extractDir, ".extracted"); + const lockFile = path.join(cacheDir, `${mediaId}.lock`); + + await fs.mkdir(cacheDir, { recursive: true }); + + // Check if already extracted (files persist indefinitely on disk) + try { + await fs.stat(markerFile); + try { + return await fs.readFile(targetFile); + } catch { + return await findFileCaseInsensitive(extractDir, filePath); + } + } catch { + // Not extracted yet + } + + // Acquire lock for extraction + let lockHandle; + try { + lockHandle = await fs.open(lockFile, "wx"); + } catch (err: any) { + if (err.code === "EEXIST") { + // Another process is extracting, wait and retry + await new Promise((r) => setTimeout(r, 500)); + return getExtractedFile(mediaId, filePath, medialit); + } + throw err; + } + + try { + // Double-check (another process might have extracted while we waited) + try { + await fs.stat(markerFile); + try { + return await fs.readFile(targetFile); + } catch { + return await findFileCaseInsensitive(extractDir, filePath); + } + } catch { + // Continue to extract + } + + // Fetch ZIP from MediaLit + const zipBuffer = await fetchZipFromMediaLit(mediaId, medialit); + if (!zipBuffer) return null; + + // Extract all files to disk + await extractZipToDisk(zipBuffer, extractDir); + + // Create marker file + await fs.writeFile(markerFile, new Date().toISOString()); + + // Serve the requested file + try { + return await fs.readFile(targetFile); + } catch { + return await findFileCaseInsensitive(extractDir, filePath); + } + } catch (err) { + error("Failed to extract SCORM package", { err: String(err) }); + return null; + } finally { + await lockHandle.close(); + await fs.unlink(lockFile).catch(() => {}); + } +} + +/** + * Fetch ZIP buffer from MediaLit + */ +async function fetchZipFromMediaLit( + mediaId: string, + medialit: MediaLit, +): Promise { + try { + const media = await medialit.get(mediaId); + if (!media?.file) return null; + + const response = await fetch(media.file as string); + if (!response.ok) return null; + + return Buffer.from(await response.arrayBuffer()); + } catch (err) { + error("Failed to fetch ZIP from MediaLit", { err: String(err) }); + return null; + } +} + +/** + * Sanitize ZIP entry name to prevent directory traversal. + * Returns sanitized path or null if the entry is unsafe. + */ +function sanitizeZipEntryName(entryName: string): string | null { + if (!entryName) return null; + + // ZIP specification uses '/' as directory separator; reject backslashes + if (entryName.includes("\\")) return null; + + // Reject absolute paths or Windows drive-letter paths (e.g. "C:..."). + if (entryName.startsWith("/")) return null; + if (/^[a-zA-Z]:/.test(entryName)) return null; + + // Normalize path segments and filter out dangerous ones + const segments = entryName.split("/"); + const safeSegments: string[] = []; + + for (const segment of segments) { + // Reject path traversal + if (segment === "..") { + return null; + } + // Skip empty segments and current directory references + if (segment === "" || segment === ".") { + continue; + } + safeSegments.push(segment); + } + + if (safeSegments.length === 0) return null; + + // Return a new sanitized path string + return safeSegments.join(path.sep); +} + +/** + * Extract ZIP to disk directory + */ +async function extractZipToDisk( + zipBuffer: Buffer, + extractDir: string, +): Promise { + const zip = new AdmZip(zipBuffer); + + // Remove old extraction if exists + await fs.rm(extractDir, { recursive: true, force: true }); + await fs.mkdir(extractDir, { recursive: true }); + + // Extract all files + const resolvedExtractDir = path.resolve(extractDir); + for (const entry of zip.getEntries()) { + if (!entry.isDirectory) { + // Sanitize entry name to prevent directory traversal (Zip Slip) + const sanitizedName = sanitizeZipEntryName(entry.entryName); + if (sanitizedName === null) { + error("Skipping unsafe ZIP entry name during extraction", { + entryName: entry.entryName, + }); + continue; + } + + // Use sanitized name for path construction + const targetPath = path.join(resolvedExtractDir, sanitizedName); + const resolvedTargetPath = path.resolve(targetPath); + + // Defense in depth: ensure target stays within extractDir + if ( + resolvedTargetPath !== resolvedExtractDir && + !resolvedTargetPath.startsWith(resolvedExtractDir + path.sep) + ) { + error("Skipping suspicious ZIP entry path during extraction", { + entryName: entry.entryName, + targetPath: resolvedTargetPath, + }); + continue; + } + + await fs.mkdir(path.dirname(resolvedTargetPath), { + recursive: true, + }); + await fs.writeFile( + resolvedTargetPath, + new Uint8Array(entry.getData()), + ); + } + } +} + +/** + * Find file with case-insensitive matching + */ +async function findFileCaseInsensitive( + baseDir: string, + targetPath: string, +): Promise { + const parts = targetPath.split("/"); + let currentDir = baseDir; + + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + const isLastPart = i === parts.length - 1; + + try { + const entries = await fs.readdir(currentDir); + const match = entries.find( + (e) => e.toLowerCase() === part.toLowerCase(), + ); + + if (!match) return null; + + currentDir = path.join(currentDir, match); + + if (isLastPart) { + return await fs.readFile(currentDir); + } + } catch { + return null; + } + } + + return null; +} + +// MIME types for SCORM content +export const MIME_TYPES: Record = { + ".html": "text/html", + ".htm": "text/html", + ".js": "application/javascript", + ".css": "text/css", + ".json": "application/json", + ".xml": "application/xml", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".svg": "image/svg+xml", + ".mp4": "video/mp4", + ".webm": "video/webm", + ".mp3": "audio/mpeg", + ".wav": "audio/wav", + ".woff": "font/woff", + ".woff2": "font/woff2", + ".ttf": "font/ttf", + ".eot": "application/vnd.ms-fontobject", + ".swf": "application/x-shockwave-flash", +}; diff --git a/apps/web/lib/scorm/extractor.ts b/apps/web/lib/scorm/extractor.ts new file mode 100644 index 000000000..9139e222b --- /dev/null +++ b/apps/web/lib/scorm/extractor.ts @@ -0,0 +1,318 @@ +import AdmZip from "adm-zip"; +import { parseStringPromise } from "xml2js"; +import config from "@/config/constants"; + +export interface SCOInfo { + identifier: string; + title: string; + launchUrl: string; +} + +export interface ScormPackageInfo { + version: "1.2" | "2004"; + title: string; + entryPoint: string; + scos: SCOInfo[]; + files: string[]; +} + +export interface ExtractionResult { + success: boolean; + error?: string; + packageInfo?: ScormPackageInfo; +} + +export async function extractScormPackage( + zipBuffer: Buffer, +): Promise { + if (zipBuffer.length > config.scormPackageSizeLimit) { + return { + success: false, + error: `Package size exceeds maximum allowed (${config.scormPackageSizeLimit / 1024 / 1024}MB)`, + }; + } + + try { + const zip = new AdmZip(zipBuffer); + const entries = zip.getEntries(); + + const manifestEntry = entries.find( + (e) => e.entryName.toLowerCase() === "imsmanifest.xml", + ); + if (!manifestEntry) { + return { + success: false, + error: "Invalid SCORM package: imsmanifest.xml not found", + }; + } + + const manifestContent = manifestEntry.getData().toString("utf8"); + const manifest = await parseStringPromise(manifestContent, { + explicitArray: false, + mergeAttrs: true, + }); + + const version = detectScormVersion(manifest); + + const scos = extractSCOs(manifest, version); + if (scos.length === 0) { + return { + success: false, + error: "Invalid SCORM package: No SCOs found in manifest", + }; + } + + const files = entries + .filter((e) => !e.isDirectory) + .map((e) => e.entryName); + + const entryPoint = scos[0].launchUrl; + if (!files.some((f) => f.toLowerCase() === entryPoint.toLowerCase())) { + return { + success: false, + error: `Invalid SCORM package: Entry point "${entryPoint}" not found`, + }; + } + + const title = extractTitle(manifest) || "Untitled SCORM Course"; + + return { + success: true, + packageInfo: { + version, + title, + entryPoint, + scos, + files, + }, + }; + } catch (error) { + return { + success: false, + error: `Failed to parse SCORM package: ${error instanceof Error ? error.message : "Unknown error"}`, + }; + } +} + +/** + * Detects SCORM version from manifest using industry-standard namespace detection. + * + * SCORM version is determined by checking XML namespace declarations: + * - SCORM 2004 uses: adlcp_v1p3, adlseq_v1p3, adlnav_v1p3, imsss + * - SCORM 1.2 uses: adlcp_rootv1p2 + */ +function detectScormVersion(manifest: any): "1.2" | "2004" { + // Get all namespace attributes from the manifest root element + const attrs = manifest?.manifest || {}; + + // Collect all attribute values that could contain namespace URIs + const namespaceValues: string[] = []; + for (const [key, value] of Object.entries(attrs)) { + if ( + typeof value === "string" && + (key.startsWith("xmlns") || key.includes(":")) + ) { + namespaceValues.push(value); + } + } + const namespaces = namespaceValues.join(" "); + + // Check for SCORM 2004 namespace identifiers + if ( + namespaces.includes("adlcp_v1p3") || + namespaces.includes("adlseq_v1p3") || + namespaces.includes("adlnav_v1p3") || + namespaces.includes("imsss") + ) { + return "2004"; + } + + // Check for SCORM 1.2 namespace identifier + if (namespaces.includes("adlcp_rootv1p2")) { + return "1.2"; + } + + // Fallback: inspect schemaLocation attribute + const schemaLocation = attrs["xsi:schemaLocation"] || ""; + if (schemaLocation.includes("adlcp_v1p3")) { + return "2004"; + } + + // Fallback: check schemaversion in metadata + const schemaVersion = + manifest?.manifest?.metadata?.schemaversion || + manifest?.manifest?.metadata?.schemaVersion; + if (schemaVersion) { + if ( + typeof schemaVersion === "string" && + schemaVersion.includes("2004") + ) { + return "2004"; + } + if (typeof schemaVersion === "string" && schemaVersion === "1.2") { + return "1.2"; + } + } + + return "1.2"; +} + +interface ResourceInfo { + identifier: string; + href: string; + isSco: boolean; +} + +/** + * Determines if a resource is a SCO (vs an Asset) based on adlcp:scormType attribute. + * + * - SCORM 1.2: adlcp:scormtype="sco" (case-insensitive) + * - SCORM 2004: adlcp:scormType="sco" (case-insensitive) + * + * If attribute is missing, we assume it's a SCO if it has an href (for broader compatibility). + */ +function isScoResource(resource: any, version: "1.2" | "2004"): boolean { + // Get scormType from various possible attribute locations + const scormType = ( + resource["adlcp:scormtype"] || + resource["adlcp:scormType"] || + resource["scormtype"] || + resource["scormType"] || + "" + ).toLowerCase(); + + if (scormType === "sco") { + return true; + } + if (scormType === "asset") { + return false; + } + + // If no explicit scormType, assume SCO if it has an href (launchable) + // This provides compatibility with packages that don't specify the attribute + return !!resource.href; +} + +/** + * Recursively walks items in the organization tree to find all SCO references. + */ +function walkItems( + item: any, + scoResources: Map, + scos: SCOInfo[], +): void { + if (!item) return; + + const identifierref = item.identifierref; + if (identifierref && scoResources.has(identifierref)) { + const resource = scoResources.get(identifierref)!; + const title = item.title; + scos.push({ + identifier: item.identifier || identifierref, + title: + typeof title === "string" + ? title + : title?._ || item.identifier || identifierref, + launchUrl: resource.href, + }); + } + + // Recursively process nested items + const nestedItems = item.item; + if (nestedItems) { + const itemArray = Array.isArray(nestedItems) + ? nestedItems + : [nestedItems]; + itemArray.forEach((nestedItem: any) => + walkItems(nestedItem, scoResources, scos), + ); + } +} + +/** + * Extracts SCO information from manifest using industry-standard approach. + * + * This implementation: + * 1. Filters resources by adlcp:scormType to distinguish SCOs from assets + * 2. Respects the default organization specified in the manifest + * 3. Recursively walks nested items to find all SCO references + */ +function extractSCOs(manifest: any, version: "1.2" | "2004"): SCOInfo[] { + const scos: SCOInfo[] = []; + + try { + // Build map of SCO resources (filtering out assets) + const resources = manifest?.manifest?.resources?.resource; + if (!resources) return scos; + + const resourceArray = Array.isArray(resources) + ? resources + : [resources]; + const scoResources = new Map(); + + resourceArray.forEach((r: any) => { + if (r.identifier && r.href && isScoResource(r, version)) { + scoResources.set(r.identifier, { + identifier: r.identifier, + href: r.href, + isSco: true, + }); + } + }); + + if (scoResources.size === 0) return scos; + + // Get organizations + const organizations = manifest?.manifest?.organizations; + if (!organizations?.organization) return scos; + + const orgList = Array.isArray(organizations.organization) + ? organizations.organization + : [organizations.organization]; + + // Respect the default organization attribute + const defaultOrgId = organizations.default; + const org = defaultOrgId + ? orgList.find((o: any) => o.identifier === defaultOrgId) || + orgList[0] + : orgList[0]; + + if (!org?.item) return scos; + + // Walk all items recursively + const items = Array.isArray(org.item) ? org.item : [org.item]; + items.forEach((item: any) => walkItems(item, scoResources, scos)); + } catch (error) { + console.error("Error extracting SCOs:", error); + } + + return scos; +} + +function extractTitle(manifest: any): string | undefined { + try { + const organizations = manifest?.manifest?.organizations?.organization; + if (!organizations) return undefined; + + const org = Array.isArray(organizations) + ? organizations[0] + : organizations; + return org?.title; + } catch { + return undefined; + } +} + +export function extractFiles(zipBuffer: Buffer): Map { + const zip = new AdmZip(zipBuffer); + const entries = zip.getEntries(); + const files = new Map(); + + entries.forEach((entry) => { + if (!entry.isDirectory) { + files.set(entry.entryName, entry.getData()); + } + }); + + return files; +} diff --git a/apps/web/models/Lesson.ts b/apps/web/models/Lesson.ts index 22b63e4d4..73ecd43a8 100644 --- a/apps/web/models/Lesson.ts +++ b/apps/web/models/Lesson.ts @@ -1,9 +1,14 @@ -import { Media, Quiz, TextEditorContent } from "@courselit/common-models"; +import { + Media, + Quiz, + TextEditorContent, + ScormContent, +} from "@courselit/common-models"; import { generateUniqueId } from "@courselit/utils"; import mongoose from "mongoose"; import constants from "../config/constants"; import MediaSchema from "./Media"; -const { text, video, audio, pdf, quiz, file, embed } = constants; +const { text, video, audio, pdf, quiz, file, embed, scorm } = constants; export interface Lesson { id: mongoose.Types.ObjectId; @@ -17,8 +22,9 @@ export interface Lesson { | typeof pdf | typeof quiz | typeof file - | typeof embed; - content?: Quiz | TextEditorContent | { value: string }; + | typeof embed + | typeof scorm; + content?: Quiz | TextEditorContent | ScormContent | { value: string }; media?: Media; downloadable: boolean; creatorId: string; @@ -35,7 +41,7 @@ const LessonSchema = new mongoose.Schema({ type: { type: String, required: true, - enum: [text, video, audio, pdf, quiz, file, embed], + enum: [text, video, audio, pdf, quiz, file, embed, scorm], }, content: { type: mongoose.Schema.Types.Mixed, default: {} }, media: MediaSchema, diff --git a/apps/web/models/Progress.ts b/apps/web/models/Progress.ts index 33d049d3d..d965f1659 100644 --- a/apps/web/models/Progress.ts +++ b/apps/web/models/Progress.ts @@ -7,6 +7,9 @@ const ProgressSchema = new mongoose.Schema( completedLessons: { type: [String] }, downloaded: { type: Boolean }, accessibleGroups: { type: [String] }, + scormData: { + lessons: { type: mongoose.Schema.Types.Mixed }, + }, }, { timestamps: true, diff --git a/apps/web/package.json b/apps/web/package.json index e5dee0dc2..e0c76f350 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -45,6 +45,7 @@ "@radix-ui/react-visually-hidden": "^1.1.0", "@stripe/stripe-js": "^5.4.0", "@types/base-64": "^1.0.0", + "adm-zip": "^0.5.16", "archiver": "^5.3.1", "aws4": "^1.13.2", "base-64": "^1.0.0", @@ -81,11 +82,13 @@ "stripe": "^17.5.0", "tailwind-merge": "^2.5.4", "tailwindcss-animate": "^1.0.7", + "xml2js": "^0.6.2", "zod": "^3.24.1" }, "devDependencies": { "@eslint/eslintrc": "^3.3.1", "@shelf/jest-mongodb": "^5.2.2", + "@types/adm-zip": "^0.5.7", "@types/bcryptjs": "^2.4.2", "@types/cookie": "^0.4.1", "@types/mongodb": "^4.0.7", @@ -93,6 +96,7 @@ "@types/nodemailer": "^6.4.4", "@types/pug": "^2.0.6", "@types/react": "19.2.4", + "@types/xml2js": "^0.4.14", "eslint": "^9.12.0", "eslint-config-next": "16.0.3", "eslint-config-prettier": "^9.0.0", @@ -111,4 +115,4 @@ "@types/react": "19.2.4" } } -} +} \ No newline at end of file diff --git a/apps/web/ui-config/constants.ts b/apps/web/ui-config/constants.ts index 615dae446..aba75094b 100644 --- a/apps/web/ui-config/constants.ts +++ b/apps/web/ui-config/constants.ts @@ -11,6 +11,7 @@ export const LESSON_TYPE_PDF = "pdf"; export const LESSON_TYPE_QUIZ = "quiz"; export const LESSON_TYPE_FILE = "file"; export const LESSON_TYPE_EMBED = "embed"; +export const LESSON_TYPE_SCORM = "scorm"; export const FREE_COURSES_TEXT = "FREE"; diff --git a/deployment/docker/docker-compose.yml b/deployment/docker/docker-compose.yml index 2d1970f4c..4090d398c 100644 --- a/deployment/docker/docker-compose.yml +++ b/deployment/docker/docker-compose.yml @@ -2,7 +2,7 @@ version: "3" services: app: - image: codelit/courselit-app:latest + image: codelit/courselit-app:scorm environment: - NODE_ENV=production @@ -34,6 +34,10 @@ services: # The email that you provide here will become the identifier for that super admin. - SUPER_ADMIN_EMAIL=${SUPER_ADMIN_EMAIL?'SUPER_ADMIN_EMAIL environment variable is not defined'} + # SCORM cache directory. + # Optionally, mount a volume to this directory to persist the cache. + - CACHE_DIR=/tmp + # For long running tasks like sending emails etc., the queue service can be # enabled. For using the queue service, uncomment the following line and the # queue block along with the redis block. diff --git a/packages/common-logic/src/models/user/progress.ts b/packages/common-logic/src/models/user/progress.ts index 8500734e2..0e6b9036c 100644 --- a/packages/common-logic/src/models/user/progress.ts +++ b/packages/common-logic/src/models/user/progress.ts @@ -9,6 +9,9 @@ export const ProgressSchema = new mongoose.Schema( accessibleGroups: { type: [String] }, lastDripAt: { type: Date }, certificateId: { type: String }, + scormData: { + lessons: { type: mongoose.Schema.Types.Mixed }, // Per-lesson structure + }, }, { timestamps: true, diff --git a/packages/common-models/src/constants.ts b/packages/common-models/src/constants.ts index 0e75b5329..42196f9b7 100644 --- a/packages/common-models/src/constants.ts +++ b/packages/common-models/src/constants.ts @@ -116,6 +116,7 @@ export const LessonType = { FILE: "file", EMBED: "embed", QUIZ: "quiz", + SCORM: "scorm", } as const; export const ActivityType = { ENROLLED: "enrolled", diff --git a/packages/common-models/src/index.ts b/packages/common-models/src/index.ts index e48836486..2edf80d94 100644 --- a/packages/common-models/src/index.ts +++ b/packages/common-models/src/index.ts @@ -70,3 +70,4 @@ export * from "./activity-type"; export * from "./email-event-action"; export * from "./login-provider"; export * from "./features"; +export type { ScormContent } from "./scorm-content"; diff --git a/packages/common-models/src/lesson.ts b/packages/common-models/src/lesson.ts index ba9f57b4e..b5529ca2e 100644 --- a/packages/common-models/src/lesson.ts +++ b/packages/common-models/src/lesson.ts @@ -2,12 +2,13 @@ import { Media } from "./media"; import { LessonType } from "./lesson-type"; import type { Quiz } from "./quiz"; import type { TextEditorContent } from "./text-editor-content"; +import type { ScormContent } from "./scorm-content"; export default interface Lesson { lessonId: string; title: string; type: LessonType; - content: Quiz | TextEditorContent | { value: string }; + content: Quiz | TextEditorContent | ScormContent | { value: string }; requiresEnrollment: boolean; courseId: string; groupId: string; diff --git a/packages/common-models/src/progress.ts b/packages/common-models/src/progress.ts index 28dbfa710..59f5c7d6e 100644 --- a/packages/common-models/src/progress.ts +++ b/packages/common-models/src/progress.ts @@ -1,3 +1,7 @@ +export interface ScormData { + lessons?: Record }>; +} + export interface Progress { courseId: string; completedLessons: string[]; @@ -5,6 +9,7 @@ export interface Progress { accessibleGroups: string[]; lastDripAt?: Date; certificateId?: string; + scormData?: ScormData; createdAt?: Date; updatedAt?: Date; } diff --git a/packages/common-models/src/scorm-content.ts b/packages/common-models/src/scorm-content.ts new file mode 100644 index 000000000..24f143528 --- /dev/null +++ b/packages/common-models/src/scorm-content.ts @@ -0,0 +1,8 @@ +export interface ScormContent { + mediaId?: string; + launchUrl?: string; + version?: string; + title?: string; + scoCount?: number; + fileCount?: number; +} diff --git a/packages/common-models/src/server-config.ts b/packages/common-models/src/server-config.ts index 174ba441b..19dc8882f 100644 --- a/packages/common-models/src/server-config.ts +++ b/packages/common-models/src/server-config.ts @@ -1,5 +1,6 @@ export interface ServerConfig { turnstileSiteKey: string; queueServer: string; + cacheEnabled: boolean; recaptchaSiteKey?: string; } diff --git a/packages/components-library/src/lesson-icon.tsx b/packages/components-library/src/lesson-icon.tsx index 5ce136a7c..6b1db3a58 100644 --- a/packages/components-library/src/lesson-icon.tsx +++ b/packages/components-library/src/lesson-icon.tsx @@ -9,6 +9,7 @@ import { Link, Quiz, QuestionMark, + Scorm, } from "@courselit/icons"; export default function LessonIcon({ type }: { type: LessonType }) { @@ -27,6 +28,8 @@ export default function LessonIcon({ type }: { type: LessonType }) { return ; case "embed": return ; + case "scorm": + return ; default: return ; } diff --git a/packages/icons/src/index.tsx b/packages/icons/src/index.tsx index 118d34c74..f066a46ed 100644 --- a/packages/icons/src/index.tsx +++ b/packages/icons/src/index.tsx @@ -56,5 +56,6 @@ export { PauseIcon as Pause } from "@radix-ui/react-icons"; export { ExitIcon as Exit } from "@radix-ui/react-icons"; export { CardStackIcon as MyContent } from "@radix-ui/react-icons"; export { CubeIcon as Products } from "@radix-ui/react-icons"; +export { CubeIcon as Scorm } from "@radix-ui/react-icons"; export { Component1Icon as Overview } from "@radix-ui/react-icons"; export { ChevronRightIcon as ExpandMoreRight } from "@radix-ui/react-icons"; diff --git a/packages/orm-models/src/models/lesson.ts b/packages/orm-models/src/models/lesson.ts index 32b3a6b15..200c37413 100644 --- a/packages/orm-models/src/models/lesson.ts +++ b/packages/orm-models/src/models/lesson.ts @@ -4,6 +4,7 @@ import { Quiz, TextEditorContent, Constants, + ScormContent, } from "@courselit/common-models"; import { generateUniqueId } from "@courselit/utils"; import mongoose from "mongoose"; @@ -15,7 +16,7 @@ export interface InternalLesson { lessonId: string; title: string; type: LessonType; - content?: Quiz | TextEditorContent | { value: string }; + content?: Quiz | TextEditorContent | ScormContent | { value: string }; media?: Media; downloadable: boolean; creatorId: string; diff --git a/packages/orm-models/src/models/user/progress.ts b/packages/orm-models/src/models/user/progress.ts index 8500734e2..7d25693e2 100644 --- a/packages/orm-models/src/models/user/progress.ts +++ b/packages/orm-models/src/models/user/progress.ts @@ -9,6 +9,9 @@ export const ProgressSchema = new mongoose.Schema( accessibleGroups: { type: [String] }, lastDripAt: { type: Date }, certificateId: { type: String }, + scormData: { + lessons: { type: mongoose.Schema.Types.Mixed }, + }, }, { timestamps: true, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1c21bff0f..26b454b7c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -306,6 +306,9 @@ importers: '@types/base-64': specifier: ^1.0.0 version: 1.0.2 + adm-zip: + specifier: ^0.5.16 + version: 0.5.16 archiver: specifier: ^5.3.1 version: 5.3.2 @@ -414,6 +417,9 @@ importers: tailwindcss-animate: specifier: ^1.0.7 version: 1.0.7(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@17.0.21)(typescript@5.9.3))) + xml2js: + specifier: ^0.6.2 + version: 0.6.2 zod: specifier: ^3.24.1 version: 3.24.3 @@ -424,6 +430,9 @@ importers: '@shelf/jest-mongodb': specifier: ^5.2.2 version: 5.2.2(@aws-sdk/credential-providers@3.797.0)(jest-environment-node@29.7.0)(mongodb@6.16.0(@aws-sdk/credential-providers@3.797.0)(socks@2.8.4))(socks@2.8.4) + '@types/adm-zip': + specifier: ^0.5.7 + version: 0.5.7 '@types/bcryptjs': specifier: ^2.4.2 version: 2.4.6 @@ -445,6 +454,9 @@ importers: '@types/react': specifier: 18.3.7 version: 18.3.7 + '@types/xml2js': + specifier: ^0.4.14 + version: 0.4.14 eslint: specifier: ^9.12.0 version: 9.39.1(jiti@1.21.7) @@ -5033,6 +5045,9 @@ packages: '@types/acorn@4.0.6': resolution: {integrity: sha512-veQTnWP+1D/xbxVrPC3zHnCZRjSrKfhbMUlEA43iMZLu7EsnTtkJklIuwrCPbOi8YkvDQAiW05VQQFvvz9oieQ==} + '@types/adm-zip@0.5.7': + resolution: {integrity: sha512-DNEs/QvmyRLurdQPChqq0Md4zGvPwHerAJYWk9l2jCbD1VPpnzRJorOdiq4zsw09NFbYnhfsoEhWtxIzXpn2yw==} + '@types/aria-query@5.0.4': resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} @@ -5293,6 +5308,9 @@ packages: '@types/whatwg-url@8.2.2': resolution: {integrity: sha512-FtQu10RWgn3D9U4aazdwIE2yzphmTJREDqNdODHrbrZmmMqI0vMheC/6NE/J1Yveaj8H+ela+YwWTjq5PGmuhA==} + '@types/xml2js@0.4.14': + resolution: {integrity: sha512-4YnrRemBShWRO2QjvUin8ESA41rH+9nQGLUGZV/1IDhi3SL9OhdpNC/MrulTWuptXKwhx/aDxE7toV0f/ypIXQ==} + '@types/yargs-parser@21.0.3': resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} @@ -5560,6 +5578,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + adm-zip@0.5.16: + resolution: {integrity: sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==} + engines: {node: '>=12.0'} + agent-base@6.0.2: resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} engines: {node: '>= 6.0.0'} @@ -9890,6 +9912,10 @@ packages: sass-formatter@0.7.9: resolution: {integrity: sha512-CWZ8XiSim+fJVG0cFLStwDvft1VI7uvXdCNJYXhDvowiv+DsbD1nXLiQ4zrE5UBvj5DWZJ93cwN0NX5PMsr1Pw==} + sax@1.4.4: + resolution: {integrity: sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==} + engines: {node: '>=11.0.0'} + saxes@6.0.0: resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} engines: {node: '>=v12.22.7'} @@ -11019,9 +11045,17 @@ packages: resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} engines: {node: '>=18'} + xml2js@0.6.2: + resolution: {integrity: sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==} + engines: {node: '>=4.0.0'} + xml@1.0.1: resolution: {integrity: sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==} + xmlbuilder@11.0.1: + resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==} + engines: {node: '>=4.0'} + xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} @@ -16678,6 +16712,10 @@ snapshots: dependencies: '@types/estree': 1.0.7 + '@types/adm-zip@0.5.7': + dependencies: + '@types/node': 20.19.0 + '@types/aria-query@5.0.4': {} '@types/babel__core@7.20.5': @@ -16959,6 +16997,10 @@ snapshots: '@types/node': 20.19.0 '@types/webidl-conversions': 7.0.3 + '@types/xml2js@0.4.14': + dependencies: + '@types/node': 20.19.0 + '@types/yargs-parser@21.0.3': {} '@types/yargs@17.0.33': @@ -17503,6 +17545,8 @@ snapshots: acorn@8.15.0: {} + adm-zip@0.5.16: {} + agent-base@6.0.2: dependencies: debug: 4.4.1 @@ -23239,6 +23283,8 @@ snapshots: dependencies: suf-log: 2.5.3 + sax@1.4.4: {} + saxes@6.0.0: dependencies: xmlchars: 2.2.0 @@ -24895,8 +24941,15 @@ snapshots: xml-name-validator@5.0.0: {} + xml2js@0.6.2: + dependencies: + sax: 1.4.4 + xmlbuilder: 11.0.1 + xml@1.0.1: {} + xmlbuilder@11.0.1: {} + xmlchars@2.2.0: {} xpath@0.0.32: {}