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`.
+
+
+
+ > **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**.
+
+
+
+6. Wait for the upload to complete. CourseLit will automatically validate the package and extract the course structure.
+
+
+
+## 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
+
+
+
+2. Clicking the button opens the SCORM content in a popup window
+
+
+
+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 }) => (
-
- {
+ const isScormDisabled =
+ value ===
+ Constants.LessonType
+ .SCORM &&
+ !config.cacheEnabled;
+ const isTypeDisabled =
+ isEditing &&
+ value !==
+ loadedLessonRef.current?.type?.toLowerCase();
+ const isDisabled =
+ isScormDisabled ||
+ isTypeDisabled;
+
+ const cardContent = (
+
-
- {label}
-
- ),
+ className={`flex flex-col items-center justify-center rounded-md border-2 border-muted bg-popover p-4 ${!isDisabled ? "hover:bg-accent hover:text-accent-foreground cursor-pointer" : "opacity-50 cursor-not-allowed"} [&:has([data-state=checked])]:border-primary ${
+ lesson.type === value
+ ? "border-primary"
+ : ""
+ }`}
+ >
+
+
+
+ {label}
+ {value ===
+ Constants.LessonType
+ .SCORM && (
+
+ {ALPHA_LABEL}
+
+ )}
+
+
+ );
+
+ 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 (
+
+
SCORM Package
+
+ {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 && (
+
+ )}
+
+ ) : (
+
+
+
+
+
+
+ {hasPackage
+ ? "Replace Package"
+ : "Upload SCORM Package"}
+
+
+
+
+ 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 && (
-
-
-
-
-
-
- All
- {[
- Constants.CourseType.COURSE,
- Constants.CourseType.DOWNLOAD,
- ].map((status) => (
-
- {capitalize(status)}
-
- ))}
-
-
-
- )}
+
+
+
+
+
+
+ All
+ {[
+ Constants.CourseType.COURSE,
+ Constants.CourseType.DOWNLOAD,
+ ].map((status) => (
+
+ {capitalize(status)}
+
+ ))}
+
+
+
+ {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 (
+
+
+ Enter
+
+
+ );
+}
+
+// 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: {}