Reference server implementation
The entire source code to this [live] server is available on my GitHub, which has the S3 storage stuff. However, i've also created a cut-down PHP implementation of a server that uses local file storage and easier to follow code. Throw this into ChatGPT if you want it for another language.
It has been tested on PHP 7.3 and Apache 2.4 and needs the Authorization header to be passed through to the script. You can do this in an Apache vhost using:
SetEnvIf Authorization .+ HTTP_AUTHORIZATION=$0
<?php
/*
* index.php
*
* Copyright (c) 2025 frumbert
* Licensed under the MIT license.
*
* The MIT License (MIT)
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
// == CONFIG ==
define("TOKEN_DIR", __DIR__ . "/tokens");
define("DATA_DIR", __DIR__ . "/data");
define("BASE_URL", "https://your.server.com/data/"); // Must match hosting domain
// === Always send CORS headers (required) ===
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: HEAD, POST, GET, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization, Pragma');
// === Handle OPTIONS (CORS preflight) ===
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
exit;
}
// === check writable locations ===
if (!file_exists(TOKEN_DIR)) mkdir(TOKEN_DIR, 0775, true);
if (!file_exists(DATA_DIR)) mkdir(DATA_DIR, 0775, true);
// === Handle POST /generate (saves incoming text )===
if ($_SERVER['REQUEST_METHOD'] === 'POST' && strpos($_SERVER['REQUEST_URI'], 'generate') !== false) {
handleGenerate();
exit;
}
// === Handle POST /view (looks up text) ===
if ($_SERVER['REQUEST_METHOD'] === 'POST' && strpos($_SERVER['REQUEST_URI'], 'view') !== false) {
handleView();
exit;
}
// don't show anything else to anyone
/* ----------------------------------------- core routines ------------------------------------------- */
// === Main POST handler ===
// authorises and validates the incoming data, then saves it to disk, and returns the URL to save back to Scorm
function handleGenerate() {
$auth = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
if (!preg_match('/Bearer (\w+)/', $auth, $matches)) {
http_response_code(401); echo "Missing or invalid token (auth = $auth)"; exit;
}
$token = $matches[1];
if (!validateToken($token)) {
http_response_code(403); echo "Unauthorized"; exit;
}
$data = json_decode(file_get_contents("php://input"), true);
$course = $data['course'] ?? '';
$learner = $data['learner'] ?? '';
$interaction = $data['interaction'] ?? '';
$content = $data['content'] ?? '';
$question = $data['question'] ?? '';
$key = $data['key'] ?? '';
if (!$course || !$learner || !$interaction || !$content) {
http_response_code(400); echo "Missing fields"; exit;
}
$hashInput = $course . "|" . $learner . "|" . $interaction . "|" . $key;
$hash = substr(hash('sha256', $hashInput), 0, 32); // safe enough from clashes
$filename = DATA_DIR . "/{$hash}.html";
$html = "<!DOCTYPE html><html><head><meta charset='UTF-8'>";
$html .= "<style>body{font-family:sans-serif;}header{font-style:italic}</style>";
$html .= "</head><body><header>" . wrap($question) . "</header>";
$html .= "<main>" . wrap($content) . "</main>"; // the Javascript expects to read the response from within <main> - don't change this
file_put_contents($filename, $html . "</body></html>");
header('Content-Type: application/json');
echo json_encode(["url" => BASE_URL . "{$hash}.html"]);
}
// === Lookup entry ===
// called from javascript, this looks up the stored data.
// you could also use the cmi.interactions.n.student_response and read the URL directly, this way authenticates the request
function handleView() {
$auth = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
if (!preg_match('/Bearer (\w+)/', $auth, $matches)) {
http_response_code(401); echo "Missing or invalid token (auth = $auth)"; exit;
}
$token = $matches[1];
if (!validateToken($token)) {
http_response_code(403); echo "Unauthorized"; exit;
}
$data = json_decode(file_get_contents("php://input"), true);
$course = $data['course'] ?? '';
$learner = $data['learner'] ?? '';
$interaction = $data['interaction'] ?? '';
$key = $data['key'] ?? '';
if (!$course || !$learner || !$interaction) {
http_response_code(400); echo "Missing fields"; exit;
}
$hashInput = $course . "|" . $learner . "|" . $interaction . "|" . $key;
$hash = substr(hash('sha256', $hashInput), 0, 32); // safe hash
$filename = DATA_DIR . "/{$hash}.html";
$content = '';
http_response_code(200);
header('Content-Type: text/html');
if (file_exists($filename)) {
$content = file_get_contents($filename);
}
echo $content;
}
// === wrap and sanitise line breaks into html ===
// This routine simply wraps line breaks in <p> tags so the HTML page reads well enough.
function wrap($lines, $tag = "p") {
$html = "";
foreach(explode("\n", $lines) as $paragraph) {
$html .= "<" . $tag . ">" . htmlentities(trim($paragraph)) . "</" . $tag . ">";
}
return $html;
}
// === Validate token + origin ===
// this routine looks at the ORIGIN or REFERER of the incoming request and checks the DOMAIN is on the list
function validateToken($token) {
$file = TOKEN_DIR . "/$token.json";
if (!file_exists($file)) return false;
$info = json_decode(file_get_contents($file), true);
$allowed = $info['allowed_domains'] ?? [];
$origin = $_SERVER['HTTP_ORIGIN'] ?? $_SERVER['HTTP_REFERER'] ?? '';
$host = parse_url($origin, PHP_URL_HOST);
return in_array($host, $allowed, true);
}