Skip to content

LMS Authoring

Ed Mozley edited this page Jul 12, 2026 · 1 revision

LMS Authoring

How FreeITSM lets you write a course, rather than only upload a SCORM package authored elsewhere. Companion to the module hub LMS; for the AI helpers see LMS AI Authoring.

Before this, publishing a single page of text meant buying Articulate or Rise, exporting a zip and uploading it β€” a strange front door for a free, self-hosted ITSM. Now you press Create and write the course in the app.


Why native, not "compile to SCORM"

A tempting alternative is to author in a database but compile to a SCORM zip and play it in the existing iframe. FreeITSM deliberately does not do this, for two reasons that outweigh the portability a zip would give:

  1. You'd write the player twice. A SCORM package is static files; something inside it must still render the lessons, collect answers, grade, and call the SCORM API β€” the same player code, plus a compiler and a manifest generator. Native is strictly less code, not more.
  2. The answer key would ship to the browser. If grading happens inside the package, the correct answers sit in a JS file the learner can open in devtools β€” exactly why SCORM quizzes are trivially cheatable. Native keeps grading on the server (see below).

The database is the source of truth either way, so "Export as SCORM" remains a viable future feature β€” generated on demand for portability to another LMS β€” without being the runtime.


The content model

A course is a tree, three tables deep, all cascading on delete:

lms_courses  (content_type='native', pass_mark)
  └─ lms_lessons        (ordered; body = TinyMCE HTML)
       └─ lms_questions (question_type: single | multiple | truefalse)
            └─ lms_answers  (answer_text, is_correct  ← the key)
  • Lessons are written in TinyMCE, the same editor and same trust level as a Knowledge article β€” including inline base64 images, so a lesson carries its own media with nothing on disk to lose or leak. Reorder by dragging; display_order is the position.
  • Questions hang off a lesson and are asked at its end. Three types: choose-one, choose-several, true/false. Each has an optional explanation shown to the learner after they answer.
  • Answers are a question's options. is_correct is the answer key and never leaves the server (see Grading).

The editor (lms/editor.php)

Gated by lms.manage. Left: the lessons, draggable. Right: the selected lesson's body in TinyMCE, and the questions that follow it.

  • Course settings β€” title, description, and the pass mark (0–100, or blank for "no pass mark").
  • Lessons β€” add, rename, write, reorder, delete. A lesson save writes only the body; questions have their own endpoint.
  • Questions β€” a modal per question: the text, the type, the answers (radio for single/true-false, checkboxes for multiple β€” the control tells the author the rule), which are correct, and the explanation.

Question validation happens at the door, server-side in api/lms/questions.php, and refuses author mistakes rather than discovering them in the results:

  • at least two answers, each with text;
  • at least one correct answer (a question with none would silently mark everyone wrong);
  • a single / truefalse question may not have two correct answers (a contradiction) β€” the error nudges you to "Choose several".

The native player (lms/native-player.php)

An authored course has no package and no launch URL, so player.php detects content_type = 'native' and hands off to the native player instead of the SCORM iframe. It renders lesson-by-lesson from the database, so it inherits the app's theme and dark mode rather than being a foreign white box.

  • A contents list, a progress bar, and resume (the last lesson is the bookmark).
  • At the end, an answer review: what you got wrong, the correct answer, and the explanation.
  • Content is fetched from api/lms/course_content.php β€” the one endpoint that returns a course to a learner, and the one that never selects is_correct. There is no filtering step to forget: the answer key is simply never on the wire.

Grading is server-side

api/lms/native_progress.php grades a submission by re-reading the correct answers from lms_answers and comparing. The client posts only which answer ids were chosen; it has no key to check against and cannot be made to lie about the result.

  • Right = exactly the correct set, no more and no less (ticking every box is marked wrong).
  • Score = percentage of questions correct across the whole course.
  • With a pass mark, the learner is passed / failed against it and may retake; without one, finishing is completed; a course with no questions is completed on reaching the last lesson.

Reuse: authored results ride the SCORM rails

The clever bit that avoided a second admin UI: the grader writes each result as SCORM CMI rows into lms_cmi_data using the real element names β€” cmi.interactions.N.id, .learner_response, .result, .correct_responses.0.pattern, and cmi.score.raw. Because the admin Progress tab and the Learner Data drill-down already read those, an authored course lights them up with no new admin code at all. See LMS.


The learner/manager split

The LMS is the pilot module for Roles (RBAC "Layer 2"). The whole surface is divided by one capability, lms.manage:

Surface Gate Who
Admin dashboard, editor, all management APIs lms.manage Managers + admins
My Courses page + its feed the lms module Every learner
Playback, learner content / progress / SCORM-runtime APIs assigned-to-me or lms.manage Learners (their courses) + managers (any, = Preview)

Enforcement lives in one helper file, includes/lms_access.php, so the rule can't drift between the pages, the APIs and the player:

  • lmsCanManage($conn, $analystId) β€” thin wrapper over analystHasCapability('lms.manage') (so is_admin bypasses).
  • lmsCourseAssignedTo($conn, $analystId, $courseId) β€” is the course assigned to a group the analyst is in?
  • lmsCanAccessCourse(...) β€” manage or assigned. The playback gate.
  • requireLmsCourseAccessJson($conn, $courseId) β€” the 403 twin for learner APIs.
  • lmsMyCourses($conn, $analystId) β€” the assigned-to-me feed behind My Courses.

lms/ (the dashboard) routes a non-manager to my-courses.php, and the header nav adapts β€” learners see My Courses + Help, managers additionally get Dashboard + Settings.

Manager β‰  admin. A manager is a non-administrator who holds an lms.manage Role. This is exactly the point of the Roles system: delegate running the LMS without handing someone the whole System module.


SCORM upload safety

Uploaded packages extract into lms/content/<id>/ β€” a web-served directory Apache can execute from β€” so an unguarded ZipArchive::extractTo() would let anyone with the LMS ship a .php shell and run code as the web server. includes/lms_package.php vets every entry before anything is written (and before the course row is created, so a rejected upload leaves nothing behind):

  • an allowlist of course file types (HTML/CSS/JS/images/media/fonts/PDF) β€” never .php, never an extension-less file;
  • rejection of any path that escapes the folder (zip-slip: ../…, absolute, C:\…);
  • zip-bomb caps β€” total uncompressed size, entry count, and per-entry compression ratio.

Only the vetted entries are extracted, never the whole archive. As defence in depth, a committed .htaccess in lms/content/ disables code execution for the whole tree β€” and .htaccess is itself on the deny list, so a package can't ship one to undo it.


Key files

File Role
lms/editor.php Β· assets/js/lms-editor.js The authoring surface (gated by lms.manage)
lms/native-player.php Β· assets/js/lms-native-player.js The learner's player for authored courses
lms/my-courses.php Β· assets/js/lms-my-courses.js The learner landing
api/lms/lessons.php Β· questions.php Author-side content APIs (return the key)
api/lms/course_content.php Learner content feed (never returns the key)
api/lms/native_progress.php Server-side grading + progress
api/lms/my_courses.php The assigned-to-me feed
includes/lms_access.php The learner/manager access rules
includes/lms_package.php Safe SCORM extraction

Related pages

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally