diff --git a/frontend/src/pages/SCORMChapter.vue b/frontend/src/pages/SCORMChapter.vue index ca53ade5..fed85dbe 100644 --- a/frontend/src/pages/SCORMChapter.vue +++ b/frontend/src/pages/SCORMChapter.vue @@ -61,41 +61,28 @@ const props = defineProps({ onBeforeMount(() => { sidebarStore.isSidebarCollapsed = true - window.API_1484_11 = { - Initialize: () => 'true', - Terminate: () => 'true', - GetValue: (key) => { - console.log(`GET: ${key}`) - return getDataFromLMS(key) - }, - SetValue: (key, value) => { - console.log(`SET: ${key} to value: ${value}`) + setupSCORMAPI() +}) - saveDataToLMS(key, value) - return 'true' - }, - Commit: () => 'true', - GetLastError: () => '0', - GetErrorString: () => '', - GetDiagnostic: () => '', - } - window.API = { - LMSInitialize: () => 'true', - LMSFinish: () => 'true', - LMSGetValue: (key) => { - console.log(`GET: ${key}`) - return getDataFromLMS(key) - }, - LMSSetValue: (key, value) => { - console.log(`SET: ${key} to value: ${value}`) - saveDataToLMS(key, value) - return 'true' - }, - LMSCommit: () => 'true', - LMSGetLastError: () => '0', - LMSGetErrorString: () => '', - LMSGetDiagnostic: () => '', - } +const chapter = createDocumentResource({ + doctype: 'Course Chapter', + name: props.chapterName, + auto: true, + cache: ['chapter', props.chapterName], + onSuccess(data) { + progress.submit() + }, +}) + +const enrollment = createListResource({ + doctype: 'LMS Enrollment', + fields: ['member', 'course'], + filters: { + course: props.courseName, + member: user.data?.name, + }, + auto: true, + cache: ['enrollments', props.courseName, user.data?.name], }) const getDataFromLMS = (key) => { @@ -114,27 +101,6 @@ const saveDataToLMS = (key, value) => { } } -const enrollment = createListResource({ - doctype: 'LMS Enrollment', - fields: ['member', 'course'], - filters: { - course: props.courseName, - member: user.data?.name, - }, - auto: true, - cache: ['enrollments', props.courseName, user.data?.name], -}) - -const chapter = createDocumentResource({ - doctype: 'Course Chapter', - name: props.chapterName, - auto: true, - cache: ['chapter', props.chapterName], - onSuccess(data) { - progress.submit() - }, -}) - const saveProgress = () => { call('lms.lms.doctype.course_lesson.course_lesson.save_progress', { lesson: chapter.doc.lessons[0].lesson, @@ -175,6 +141,44 @@ const enrollStudent = () => { ) } +const setupSCORMAPI = () => { + window.API_1484_11 = { + Initialize: () => 'true', + Terminate: () => 'true', + GetValue: (key) => { + console.log(`GET: ${key}`) + return getDataFromLMS(key) + }, + SetValue: (key, value) => { + console.log(`SET: ${key} to value: ${value}`) + + saveDataToLMS(key, value) + return 'true' + }, + Commit: () => 'true', + GetLastError: () => '0', + GetErrorString: () => '', + GetDiagnostic: () => '', + } + window.API = { + LMSInitialize: () => 'true', + LMSFinish: () => 'true', + LMSGetValue: (key) => { + console.log(`GET: ${key}`) + return getDataFromLMS(key) + }, + LMSSetValue: (key, value) => { + console.log(`SET: ${key} to value: ${value}`) + saveDataToLMS(key, value) + return 'true' + }, + LMSCommit: () => 'true', + LMSGetLastError: () => '0', + LMSGetErrorString: () => '', + LMSGetDiagnostic: () => '', + } +} + const breadcrumbs = computed(() => { return [ { diff --git a/lms/hooks.py b/lms/hooks.py index b58bd840..b77b8d0f 100644 --- a/lms/hooks.py +++ b/lms/hooks.py @@ -220,11 +220,14 @@ lms_markdown_macro_renderers = { "PDF": "lms.plugins.pdf_renderer", } +website_path_resolver = "lms.lms.api.resolve_scorm_path" + # page_renderer to manage profile pages page_renderer = [ "lms.page_renderers.ProfileRedirectPage", "lms.page_renderers.ProfilePage", "lms.page_renderers.CoursePage", + "lms.page_renderers.SCORMRenderer", ] # set this to "/" to have profiles on the top-level diff --git a/lms/lms/api.py b/lms/lms/api.py index e4a84404..37f7d452 100644 --- a/lms/lms/api.py +++ b/lms/lms/api.py @@ -5,7 +5,9 @@ import json import frappe import zipfile import os +import re import shutil +import requests import xml.etree.ElementTree as ET from frappe.translate import get_all_translations from frappe import _ @@ -15,6 +17,7 @@ from frappe.utils import time_diff, now_datetime, get_datetime, flt from typing import Optional from lms.lms.utils import get_average_rating, get_lesson_count from xml.dom.minidom import parseString +from frappe.website.path_resolver import resolve_path as original_resolve_path @frappe.whitelist() @@ -590,7 +593,7 @@ def get_categories(doctype, filters): def get_members(start=0, search=""): """Get members for the given search term and start index. Args: start (int): Start index for the query. - search (str): Search term to filter the results. + search (str): Search term to filter the results. Returns: List of members. """ @@ -919,12 +922,37 @@ def upsert_chapter(title, course, is_scorm_package, scorm_package, name=None): def extract_package(course, title, scorm_package): package = frappe.get_doc("File", scorm_package.name) zip_path = package.get_full_path() - - extract_path = frappe.get_site_path("public", "files", "scorm", course, title) + # check_for_malicious_code(zip_path) + extract_path = frappe.get_site_path("public", "scorm", course, title) zipfile.ZipFile(zip_path).extractall(extract_path) return extract_path +def check_for_malicious_code(zip_path): + suspicious_patterns = [ + # Unsafe inline JavaScript + r'on(click|load|mouseover|error|submit|focus|blur|change|keyup|keydown|keypress|resize)=".*?"', # Inline event handlers (e.g., onerror, onclick) + r'", # External stylesheets in XML + ] + + with zipfile.ZipFile(zip_path, "r") as zf: + for file_name in zf.namelist(): + if file_name.endswith((".html", ".js", ".xml")): + with zf.open(file_name) as file: + content = file.read().decode("utf-8", errors="ignore") + for pattern in suspicious_patterns: + if re.search(pattern, content): + frappe.throw( + _("Suspicious pattern found in {0}: {1}").format(file_name, pattern) + ) + + def get_manifest_file(extract_path): manifest_file = None for root, dirs, files in os.walk(extract_path): @@ -999,6 +1027,16 @@ def delete_chapter(chapter): def delete_scorm_package(scorm_package_path): - scorm_package_path = frappe.get_site_path("public", scorm_package_path) + scorm_package_path = frappe.get_site_path("public", scorm_package_path[1:]) if os.path.exists(scorm_package_path): shutil.rmtree(scorm_package_path) + + +def resolve_scorm_path(path): + try: + if "scorm/" in path and path.endswith(".html"): + return path + except Exception: + pass + + return original_resolve_path(path) diff --git a/lms/page_renderers.py b/lms/page_renderers.py index e6f687e2..32168e3a 100644 --- a/lms/page_renderers.py +++ b/lms/page_renderers.py @@ -3,7 +3,8 @@ Handles rendering of profile pages. """ import re - +import os +import mimetypes import frappe from frappe.website.page_renderers.base_renderer import BaseRenderer from frappe.website.page_renderers.document_page import DocumentPage @@ -14,6 +15,8 @@ from frappe.website.page_renderers.redirect_page import RedirectPage from frappe.website.page_renderers.static_page import StaticPage from frappe.website.page_renderers.template_page import TemplatePage from frappe.website.page_renderers.web_form import WebFormPage +from werkzeug.wrappers import Response +from werkzeug.wsgi import wrap_file def get_profile_url(username): @@ -138,3 +141,17 @@ class CoursePage(BaseRenderer): else: frappe.flags.redirect_location = "/lms/courses" return RedirectPage(self.path).render() + + +class SCORMRenderer(BaseRenderer): + def can_render(self): + return "scorm/" in self.path + + def render(self): + path = os.path.join(frappe.local.site_path, "public", self.path.lstrip("/")) + f = open(path, "rb") + response = Response( + wrap_file(frappe.local.request.environ, f), direct_passthrough=True + ) + response.mimetype = mimetypes.guess_type(path)[0] + return response