coursecode 0.1.53 → 0.1.54
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -2
- package/bin/cli.js +1 -0
- package/framework/docs/COURSE_AUTHORING_GUIDE.md +8 -6
- package/framework/docs/FRAMEWORK_GUIDE.md +22 -21
- package/framework/docs/USER_GUIDE.md +9 -3
- package/framework/index.html +1 -1
- package/framework/js/core/event-bus.js +21 -6
- package/framework/js/drivers/lti-driver.js +50 -94
- package/framework/js/drivers/proxy-driver.js +8 -5
- package/framework/js/main.js +3 -15
- package/framework/js/utilities/access-control.js +11 -45
- package/framework/js/utilities/data-reporter.js +16 -3
- package/framework/version.json +26 -50
- package/lib/build-packaging.d.ts +4 -0
- package/lib/build-packaging.js +39 -2
- package/lib/cloud.js +1 -1
- package/lib/course-writer.js +97 -32
- package/lib/manifest/cmi5-manifest.js +19 -10
- package/lib/manifest/lti-tool-config.js +18 -5
- package/lib/manifest/scorm-12-manifest.js +11 -5
- package/lib/manifest/scorm-2004-manifest.js +16 -9
- package/lib/manifest/xml-utils.js +14 -0
- package/lib/preview-routes-api.js +16 -7
- package/lib/preview-server.js +49 -28
- package/lib/project-utils.js +34 -0
- package/lib/proxy-templates/proxy.html +7 -3
- package/lib/proxy-templates/scorm-bridge.js +15 -9
- package/lib/stub-player.js +19 -2
- package/lib/token.js +26 -29
- package/lib/upgrade.js +76 -2
- package/package.json +32 -25
- package/template/course/course-config.js +9 -7
- package/template/package.json +13 -4
- package/template/vite.config.js +80 -27
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
* @description Generates imsmanifest.xml for SCORM 2004 4th Edition packages.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import { escapeXmlAttribute, escapeXmlText } from './xml-utils.js';
|
|
7
|
+
|
|
6
8
|
/**
|
|
7
9
|
* Generates the SCORM 2004 4th Edition manifest.
|
|
8
10
|
* @param {Object} config - Course configuration
|
|
@@ -17,12 +19,17 @@ export function generateScorm2004Manifest(config, files) {
|
|
|
17
19
|
!f.startsWith('common/')
|
|
18
20
|
);
|
|
19
21
|
|
|
20
|
-
const fileEntries = resourceFiles.map(f => ` <file href="${f}"/>`).join('\n');
|
|
22
|
+
const fileEntries = resourceFiles.map(f => ` <file href="${escapeXmlAttribute(f)}"/>`).join('\n');
|
|
23
|
+
const version = escapeXmlAttribute(config.version);
|
|
24
|
+
const language = escapeXmlAttribute(config.language);
|
|
25
|
+
const title = escapeXmlText(config.title);
|
|
26
|
+
const description = escapeXmlText(config.description);
|
|
27
|
+
const author = escapeXmlText(config.author);
|
|
21
28
|
|
|
22
29
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
23
30
|
<!-- SCORM 2004 4th Edition manifest - GENERATED FILE - DO NOT EDIT MANUALLY -->
|
|
24
31
|
<manifest identifier="${config.title.replace(/[^a-zA-Z0-9]/g, '-')}"
|
|
25
|
-
version="${
|
|
32
|
+
version="${version}"
|
|
26
33
|
xmlns="http://www.imsglobal.org/xsd/imscp_v1p1"
|
|
27
34
|
xmlns:imscp="http://www.imsglobal.org/xsd/imscp_v1p1"
|
|
28
35
|
xmlns:imsss="http://www.imsglobal.org/xsd/imsss"
|
|
@@ -45,18 +52,18 @@ export function generateScorm2004Manifest(config, files) {
|
|
|
45
52
|
<schemaversion>2004 4th Edition</schemaversion>
|
|
46
53
|
<lom:lom>
|
|
47
54
|
<lom:general>
|
|
48
|
-
<lom:title><lom:string language="${
|
|
49
|
-
<lom:description><lom:string language="${
|
|
50
|
-
<lom:language>${config.language}</lom:language>
|
|
55
|
+
<lom:title><lom:string language="${language}">${title}</lom:string></lom:title>
|
|
56
|
+
<lom:description><lom:string language="${language}">${description}</lom:string></lom:description>
|
|
57
|
+
<lom:language>${escapeXmlText(config.language)}</lom:language>
|
|
51
58
|
</lom:general>
|
|
52
59
|
<lom:lifecycle>
|
|
53
|
-
<lom:version><lom:string>${config.version}</lom:string></lom:version>
|
|
60
|
+
<lom:version><lom:string>${escapeXmlText(config.version)}</lom:string></lom:version>
|
|
54
61
|
<lom:contribute>
|
|
55
62
|
<lom:role>
|
|
56
63
|
<lom:source><lom:string>LOMv1.0</lom:string></lom:source>
|
|
57
64
|
<lom:value><lom:string>author</lom:string></lom:value>
|
|
58
65
|
</lom:role>
|
|
59
|
-
<lom:entity>${
|
|
66
|
+
<lom:entity>${author}</lom:entity>
|
|
60
67
|
</lom:contribute>
|
|
61
68
|
</lom:lifecycle>
|
|
62
69
|
<lom:technical>
|
|
@@ -67,9 +74,9 @@ export function generateScorm2004Manifest(config, files) {
|
|
|
67
74
|
|
|
68
75
|
<organizations default="org-1">
|
|
69
76
|
<organization identifier="org-1" adlseq:objectivesGlobalToSystem="false">
|
|
70
|
-
<title>${
|
|
77
|
+
<title>${title}</title>
|
|
71
78
|
<item identifier="item-1" identifierref="res-1" isvisible="true">
|
|
72
|
-
<title>${
|
|
79
|
+
<title>${title}</title>
|
|
73
80
|
<imsss:sequencing>
|
|
74
81
|
<imsss:controlMode choiceExit="true" forwardOnly="false"/>
|
|
75
82
|
<imsss:deliveryControls tracked="true" completionSetByContent="true" objectiveSetByContent="true"/>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** Escape text inserted into XML element content. */
|
|
2
|
+
export function escapeXmlText(value) {
|
|
3
|
+
return String(value ?? '')
|
|
4
|
+
.replace(/&/g, '&')
|
|
5
|
+
.replace(/</g, '<')
|
|
6
|
+
.replace(/>/g, '>');
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Escape text inserted into a double-quoted XML attribute. */
|
|
10
|
+
export function escapeXmlAttribute(value) {
|
|
11
|
+
return escapeXmlText(value)
|
|
12
|
+
.replace(/"/g, '"')
|
|
13
|
+
.replace(/'/g, ''');
|
|
14
|
+
}
|
|
@@ -13,6 +13,7 @@ import { generateContentHtml } from './stub-player/content-generator.js';
|
|
|
13
13
|
import { parseCourse } from './course-parser.js';
|
|
14
14
|
import { getComponentCatalog, getInteractionCatalog, getIconCatalog, getWorkflowStatus, getRefsStatus, buildCourse } from './authoring-api.js';
|
|
15
15
|
import { getAllIcons, getAllSchemas } from './schema-extractor.js';
|
|
16
|
+
import { escapeHtml, resolveWithinRoot } from './project-utils.js';
|
|
16
17
|
|
|
17
18
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
18
19
|
|
|
@@ -657,12 +658,19 @@ export async function handleApiRoutes(ctx, req, res, url) {
|
|
|
657
658
|
if (url === '/__stub-player/ref-preview') {
|
|
658
659
|
const params = new URL(req.url, 'http://localhost').searchParams;
|
|
659
660
|
const fileName = params.get('file');
|
|
660
|
-
if (!fileName
|
|
661
|
+
if (!fileName) {
|
|
661
662
|
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
|
662
663
|
res.end('Invalid file parameter');
|
|
663
664
|
return true;
|
|
664
665
|
}
|
|
665
|
-
|
|
666
|
+
let filePath;
|
|
667
|
+
try {
|
|
668
|
+
filePath = resolveWithinRoot(path.join(paths.coursePath, 'references', 'converted'), fileName);
|
|
669
|
+
} catch {
|
|
670
|
+
res.writeHead(403, { 'Content-Type': 'text/plain' });
|
|
671
|
+
res.end('Forbidden');
|
|
672
|
+
return true;
|
|
673
|
+
}
|
|
666
674
|
if (!fs.existsSync(filePath)) {
|
|
667
675
|
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
668
676
|
res.end('File not found');
|
|
@@ -676,7 +684,7 @@ export async function handleApiRoutes(ctx, req, res, url) {
|
|
|
676
684
|
<head>
|
|
677
685
|
<meta charset="UTF-8">
|
|
678
686
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
679
|
-
<title>${fileName} – Reference Preview</title>
|
|
687
|
+
<title>${escapeHtml(fileName)} – Reference Preview</title>
|
|
680
688
|
<link rel="stylesheet" href="/__stub-player/styles.css">
|
|
681
689
|
<style>
|
|
682
690
|
body { padding: 40px; max-width: 900px; margin: 0 auto; font-family: system-ui, -apple-system, sans-serif; line-height: 1.6; background: var(--color-primary-deep, #0b1628); color: var(--color-gray-200, #d1d5db); }
|
|
@@ -686,7 +694,7 @@ export async function handleApiRoutes(ctx, req, res, url) {
|
|
|
686
694
|
</style>
|
|
687
695
|
</head>
|
|
688
696
|
<body>
|
|
689
|
-
<h1>${fileName}</h1>
|
|
697
|
+
<h1>${escapeHtml(fileName)}</h1>
|
|
690
698
|
${html}
|
|
691
699
|
</body>
|
|
692
700
|
</html>`);
|
|
@@ -696,13 +704,14 @@ export async function handleApiRoutes(ctx, req, res, url) {
|
|
|
696
704
|
// Stub player static files
|
|
697
705
|
if (url.startsWith('/__stub-player/')) {
|
|
698
706
|
const relativePath = url.slice('/__stub-player/'.length);
|
|
699
|
-
|
|
707
|
+
let filePath;
|
|
708
|
+
try {
|
|
709
|
+
filePath = resolveWithinRoot(path.join(__dirname, 'stub-player'), relativePath);
|
|
710
|
+
} catch {
|
|
700
711
|
res.writeHead(403);
|
|
701
712
|
res.end('Forbidden');
|
|
702
713
|
return true;
|
|
703
714
|
}
|
|
704
|
-
|
|
705
|
-
const filePath = path.join(__dirname, 'stub-player', relativePath);
|
|
706
715
|
fs.stat(filePath, (err, stats) => {
|
|
707
716
|
if (err || !stats.isFile()) {
|
|
708
717
|
res.writeHead(404);
|
package/lib/preview-server.js
CHANGED
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
import fs from 'fs';
|
|
13
13
|
import path from 'path';
|
|
14
14
|
import http from 'http';
|
|
15
|
-
import
|
|
15
|
+
import crypto from 'crypto';
|
|
16
|
+
import { spawn } from 'child_process';
|
|
16
17
|
import { fileURLToPath } from 'url';
|
|
17
18
|
|
|
18
19
|
import { generateStubPlayer } from './stub-player.js';
|
|
@@ -24,7 +25,7 @@ import { handleEditingRoutes } from './preview-routes-editing.js';
|
|
|
24
25
|
import { handleLmsRoutes, createLmsStore } from './preview-routes-lms.js';
|
|
25
26
|
import {
|
|
26
27
|
validateProject, escapeHtml, getMimeType, serveFile,
|
|
27
|
-
countSlides, findSlideById, collectSlideIds
|
|
28
|
+
countSlides, findSlideById, collectSlideIds, resolveWithinRoot
|
|
28
29
|
} from './project-utils.js';
|
|
29
30
|
|
|
30
31
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -196,6 +197,15 @@ function getCourseTitle(coursePath) {
|
|
|
196
197
|
}
|
|
197
198
|
}
|
|
198
199
|
|
|
200
|
+
export function requiresPreviewMutationToken(method, url) {
|
|
201
|
+
return !['GET', 'HEAD', 'OPTIONS'].includes(String(method || 'GET').toUpperCase()) &&
|
|
202
|
+
url.startsWith('/__') && !url.startsWith('/__lms/');
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function hasValidPreviewMutationToken(headers, expectedToken) {
|
|
206
|
+
return Boolean(expectedToken) && headers?.['x-coursecode-preview-token'] === expectedToken;
|
|
207
|
+
}
|
|
208
|
+
|
|
199
209
|
|
|
200
210
|
|
|
201
211
|
// ============================================================================
|
|
@@ -207,6 +217,8 @@ export async function previewServer(options = {}) {
|
|
|
207
217
|
const paths = validateProject({ frameworkDev });
|
|
208
218
|
const title = options.title || getCourseTitle(paths.coursePath);
|
|
209
219
|
const previewPort = parseInt(options.port || '4173', 10);
|
|
220
|
+
const previewHost = options.host || '127.0.0.1';
|
|
221
|
+
const previewToken = crypto.randomBytes(32).toString('base64url');
|
|
210
222
|
const distDir = path.join(process.cwd(), 'dist');
|
|
211
223
|
|
|
212
224
|
console.log('\n🚀 Starting preview server...');
|
|
@@ -347,6 +359,7 @@ export async function previewServer(options = {}) {
|
|
|
347
359
|
password: null,
|
|
348
360
|
isLive: true,
|
|
349
361
|
liveReload: true,
|
|
362
|
+
previewToken,
|
|
350
363
|
courseContent,
|
|
351
364
|
isDesktop: options.desktop || false
|
|
352
365
|
});
|
|
@@ -374,6 +387,16 @@ export async function previewServer(options = {}) {
|
|
|
374
387
|
const server = http.createServer(async (req, res) => {
|
|
375
388
|
const url = req.url.split('?')[0];
|
|
376
389
|
|
|
390
|
+
// All source/file mutations require a per-process capability token.
|
|
391
|
+
// LMS state synchronization is intentionally exempt: it only touches
|
|
392
|
+
// the in-memory test store and is used directly by E2E helpers.
|
|
393
|
+
const isMutation = requiresPreviewMutationToken(req.method, url);
|
|
394
|
+
if (isMutation && !hasValidPreviewMutationToken(req.headers, previewToken)) {
|
|
395
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
396
|
+
res.end(JSON.stringify({ error: 'Invalid or missing preview mutation token' }));
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
|
|
377
400
|
// LMS routes (state sync + testing API)
|
|
378
401
|
if (handleLmsRoutes(ctx, req, res, url)) return;
|
|
379
402
|
|
|
@@ -393,7 +416,14 @@ export async function previewServer(options = {}) {
|
|
|
393
416
|
// Serve files from dist/ for /course/* requests
|
|
394
417
|
if (url.startsWith('/course/')) {
|
|
395
418
|
const relativePath = url.slice('/course/'.length) || 'index.html';
|
|
396
|
-
|
|
419
|
+
let filePath;
|
|
420
|
+
try {
|
|
421
|
+
filePath = resolveWithinRoot(distDir, relativePath);
|
|
422
|
+
} catch {
|
|
423
|
+
res.writeHead(403);
|
|
424
|
+
res.end('Forbidden');
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
397
427
|
serveFile(filePath, res);
|
|
398
428
|
return;
|
|
399
429
|
}
|
|
@@ -490,12 +520,24 @@ export async function previewServer(options = {}) {
|
|
|
490
520
|
|
|
491
521
|
const contentNote = options.content !== false ? ' • Content viewer (📄 button in toolbar)\n' : '';
|
|
492
522
|
|
|
493
|
-
|
|
494
|
-
|
|
523
|
+
server.on('error', (err) => {
|
|
524
|
+
if (err.code === 'EADDRINUSE') {
|
|
525
|
+
console.error(`\n❌ Port ${previewPort} is already in use. Stop that process or choose another port with --port.`);
|
|
526
|
+
viteProcess.kill();
|
|
527
|
+
process.exitCode = 1;
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
throw err;
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
const startListening = () => {
|
|
534
|
+
server.listen(previewPort, previewHost, () => {
|
|
535
|
+
const displayHost = previewHost === '127.0.0.1' ? 'localhost' : previewHost;
|
|
495
536
|
console.log(`
|
|
496
537
|
✅ Preview server running!
|
|
497
538
|
|
|
498
|
-
🎯 Open: http
|
|
539
|
+
🎯 Open: http://${displayHost}:${previewPort}
|
|
540
|
+
🔒 Bound to: ${previewHost}
|
|
499
541
|
|
|
500
542
|
Features:
|
|
501
543
|
• Live reload - browser updates automatically on rebuild
|
|
@@ -511,28 +553,6 @@ ${contentNote}
|
|
|
511
553
|
Press Ctrl+C to stop
|
|
512
554
|
`);
|
|
513
555
|
});
|
|
514
|
-
|
|
515
|
-
server.on('error', (err) => {
|
|
516
|
-
if (err.code === 'EADDRINUSE' && !retried) {
|
|
517
|
-
console.warn(`\n⚠️ STALE PROCESS DETECTED — port ${previewPort} is already in use.`);
|
|
518
|
-
console.warn(' Killing stale process and retrying...');
|
|
519
|
-
exec(`lsof -ti :${previewPort}`, (_, stdout) => {
|
|
520
|
-
const pids = (stdout || '').trim();
|
|
521
|
-
if (pids) console.warn(` Killed PID(s): ${pids.split('\n').join(', ')}`);
|
|
522
|
-
exec(`lsof -ti :${previewPort} | xargs kill -9 2>/dev/null`, () => {
|
|
523
|
-
setTimeout(() => {
|
|
524
|
-
server.close();
|
|
525
|
-
startListening(true);
|
|
526
|
-
}, 500);
|
|
527
|
-
});
|
|
528
|
-
});
|
|
529
|
-
} else if (err.code === 'EADDRINUSE') {
|
|
530
|
-
console.error(`\n❌ Port ${previewPort} is still in use after retry. Kill it manually:\n lsof -ti :${previewPort} | xargs kill -9`);
|
|
531
|
-
process.exit(1);
|
|
532
|
-
} else {
|
|
533
|
-
throw err;
|
|
534
|
-
}
|
|
535
|
-
});
|
|
536
556
|
};
|
|
537
557
|
|
|
538
558
|
startListening();
|
|
@@ -563,6 +583,7 @@ if (process.argv[1] && process.argv[1].endsWith('preview-server.js')) {
|
|
|
563
583
|
const options = {
|
|
564
584
|
frameworkDev: args.includes('--framework-dev'),
|
|
565
585
|
port: args.find(a => a.startsWith('--port='))?.split('=')[1] || '4173',
|
|
586
|
+
host: args.find(a => a.startsWith('--host='))?.split('=')[1] || '127.0.0.1',
|
|
566
587
|
format: process.env.LMS_FORMAT || null
|
|
567
588
|
};
|
|
568
589
|
previewServer(options);
|
package/lib/project-utils.js
CHANGED
|
@@ -178,6 +178,40 @@ export function getMimeType(filePath) {
|
|
|
178
178
|
return mimeTypes[path.extname(filePath).toLowerCase()] || 'application/octet-stream';
|
|
179
179
|
}
|
|
180
180
|
|
|
181
|
+
/**
|
|
182
|
+
* Resolve a user-controlled relative path while guaranteeing that the result
|
|
183
|
+
* remains inside the supplied root directory.
|
|
184
|
+
*
|
|
185
|
+
* @param {string} rootDir - Absolute directory that contains all allowed files
|
|
186
|
+
* @param {string} relativePath - URL/path fragment supplied by a caller
|
|
187
|
+
* @returns {string} Safe absolute path
|
|
188
|
+
* @throws {Error} If the path is malformed or escapes rootDir
|
|
189
|
+
*/
|
|
190
|
+
export function resolveWithinRoot(rootDir, relativePath) {
|
|
191
|
+
if (typeof rootDir !== 'string' || typeof relativePath !== 'string') {
|
|
192
|
+
throw new Error('Root directory and relative path must be strings');
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
let decodedPath;
|
|
196
|
+
try {
|
|
197
|
+
decodedPath = decodeURIComponent(relativePath);
|
|
198
|
+
} catch {
|
|
199
|
+
throw new Error('Invalid URL encoding in path');
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (decodedPath.includes('\0')) {
|
|
203
|
+
throw new Error('Path contains a null byte');
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const root = path.resolve(rootDir);
|
|
207
|
+
const resolved = path.resolve(root, decodedPath.replace(/^[/\\]+/, ''));
|
|
208
|
+
if (resolved !== root && !resolved.startsWith(root + path.sep)) {
|
|
209
|
+
throw new Error('Path escapes the allowed root directory');
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return resolved;
|
|
213
|
+
}
|
|
214
|
+
|
|
181
215
|
// =============================================================================
|
|
182
216
|
// FILE SERVING
|
|
183
217
|
// =============================================================================
|
|
@@ -44,11 +44,15 @@
|
|
|
44
44
|
</div>
|
|
45
45
|
|
|
46
46
|
<iframe id="course" style="display:none"></iframe>
|
|
47
|
-
|
|
47
|
+
|
|
48
|
+
<script src="pipwerks.js"></script>
|
|
49
|
+
<script>
|
|
50
|
+
// Injected as a JSON string by the build system before the bridge loads.
|
|
51
|
+
window.COURSECODE_PROXY_CONFIG = { courseUrl: {{EXTERNAL_URL_JSON}} };
|
|
52
|
+
</script>
|
|
48
53
|
<script src="scorm-bridge.js"></script>
|
|
49
54
|
<script>
|
|
50
|
-
|
|
51
|
-
const COURSE_URL = '{{EXTERNAL_URL}}';
|
|
55
|
+
const COURSE_URL = window.COURSECODE_PROXY_CONFIG.courseUrl;
|
|
52
56
|
|
|
53
57
|
const iframe = document.getElementById('course');
|
|
54
58
|
const loading = document.getElementById('loading');
|
|
@@ -22,16 +22,17 @@
|
|
|
22
22
|
const iframe = document.getElementById('course');
|
|
23
23
|
let initialized = false;
|
|
24
24
|
|
|
25
|
-
// Derive expected course origin from build-time
|
|
26
|
-
//
|
|
27
|
-
let courseOrigin
|
|
25
|
+
// Derive the exact expected course origin from build-time configuration.
|
|
26
|
+
// Fail closed: wildcard origins would allow unrelated frames to operate the LMS API.
|
|
27
|
+
let courseOrigin;
|
|
28
28
|
try {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
29
|
+
const courseUrl = window.COURSECODE_PROXY_CONFIG?.courseUrl;
|
|
30
|
+
if (!courseUrl) throw new Error('Missing proxy course URL');
|
|
31
|
+
courseOrigin = new URL(courseUrl).origin;
|
|
32
|
+
console.log('SCORM Bridge: Expected course origin:', courseOrigin);
|
|
33
33
|
} catch (e) {
|
|
34
|
-
console.
|
|
34
|
+
console.error('SCORM Bridge: Invalid course URL; bridge disabled:', e.message);
|
|
35
|
+
return;
|
|
35
36
|
}
|
|
36
37
|
|
|
37
38
|
// Disable pipwerks auto-handling - we manage status ourselves
|
|
@@ -49,8 +50,13 @@
|
|
|
49
50
|
return;
|
|
50
51
|
}
|
|
51
52
|
|
|
53
|
+
if (!iframe || event.source !== iframe.contentWindow) {
|
|
54
|
+
console.warn('SCORM Bridge: Rejected message not sent by the course iframe');
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
52
58
|
// Validate origin when we have a known course origin
|
|
53
|
-
if (
|
|
59
|
+
if (event.origin !== courseOrigin) {
|
|
54
60
|
console.warn('SCORM Bridge: Rejected message from unexpected origin:', event.origin);
|
|
55
61
|
return;
|
|
56
62
|
}
|
package/lib/stub-player.js
CHANGED
|
@@ -58,7 +58,7 @@ export { escapeHtml };
|
|
|
58
58
|
* @returns {string} - Complete HTML for the player page
|
|
59
59
|
*/
|
|
60
60
|
export function generateStubPlayer(config) {
|
|
61
|
-
const { title, launchUrl, storageKey, passwordHash, isLive, liveReload, courseContent, startSlide, isDesktop, showHeader = true, skipGating = null, moduleBasePath = '/__stub-player' } = config;
|
|
61
|
+
const { title, launchUrl, storageKey, passwordHash, isLive, liveReload, courseContent, startSlide, isDesktop, showHeader = true, skipGating = null, moduleBasePath = '/__stub-player', previewToken = null } = config;
|
|
62
62
|
const hasPassword = !!passwordHash;
|
|
63
63
|
const hasContent = !!courseContent;
|
|
64
64
|
const headerVisible = showHeader !== false;
|
|
@@ -115,8 +115,25 @@ ${stubPlayerStyles}
|
|
|
115
115
|
showHeader: ${headerVisible},
|
|
116
116
|
skipGating: ${typeof skipGating === 'boolean' ? skipGating : 'null'},
|
|
117
117
|
isDesktop: ${isDesktop || false},
|
|
118
|
-
isCI: ${!!process.env.CI}
|
|
118
|
+
isCI: ${!!process.env.CI},
|
|
119
|
+
previewToken: ${previewToken ? JSON.stringify(previewToken) : 'null'}
|
|
119
120
|
};
|
|
121
|
+
|
|
122
|
+
// Protect source-mutating preview endpoints from cross-site requests. The
|
|
123
|
+
// token is random for each preview process and is never used by exports.
|
|
124
|
+
if (window.STUB_CONFIG.previewToken) {
|
|
125
|
+
const nativeFetch = window.fetch.bind(window);
|
|
126
|
+
window.fetch = function(input, init = {}) {
|
|
127
|
+
const requestUrl = typeof input === 'string' ? input : input?.url;
|
|
128
|
+
const requestMethod = String(init.method || input?.method || 'GET').toUpperCase();
|
|
129
|
+
if (requestUrl?.startsWith('/__') && !['GET', 'HEAD', 'OPTIONS'].includes(requestMethod)) {
|
|
130
|
+
const headers = new Headers(init.headers || input?.headers || {});
|
|
131
|
+
headers.set('X-CourseCode-Preview-Token', window.STUB_CONFIG.previewToken);
|
|
132
|
+
init = { ...init, headers };
|
|
133
|
+
}
|
|
134
|
+
return nativeFetch(input, init);
|
|
135
|
+
};
|
|
136
|
+
}
|
|
120
137
|
</script>
|
|
121
138
|
<script type="module" src="${moduleBasePath}/${isLive ? 'app' : 'app-viewer'}.js"></script>
|
|
122
139
|
|
package/lib/token.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Usage:
|
|
5
5
|
* coursecode token # Generate random token
|
|
6
|
-
* coursecode token --add client-name # Add client to
|
|
6
|
+
* coursecode token --add client-name # Add client to gitignored access file
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import crypto from 'crypto';
|
|
@@ -25,51 +25,48 @@ export function generateToken(length = 24) {
|
|
|
25
25
|
export async function token(options = {}) {
|
|
26
26
|
const newToken = generateToken();
|
|
27
27
|
|
|
28
|
-
// If --add specified, add to
|
|
28
|
+
// If --add specified, add to the non-published access file. Credentials
|
|
29
|
+
// must never live in course-config.js because that module is bundled into
|
|
30
|
+
// learner-facing JavaScript.
|
|
29
31
|
if (options.add) {
|
|
30
32
|
const clientId = options.add;
|
|
31
|
-
|
|
32
|
-
|
|
33
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/.test(clientId)) {
|
|
34
|
+
throw new Error('Client ID must use letters, numbers, dots, underscores, or hyphens');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const configPath = path.join(process.cwd(), 'course', 'course-config.js');
|
|
33
38
|
|
|
34
39
|
if (!fs.existsSync(configPath)) {
|
|
35
40
|
console.error('\n❌ No course-config.js found. Run from a CourseCode project directory.\n');
|
|
36
41
|
process.exit(1);
|
|
37
42
|
}
|
|
38
43
|
|
|
39
|
-
|
|
44
|
+
const accessDir = path.join(process.cwd(), '.coursecode');
|
|
45
|
+
const accessPath = path.join(accessDir, 'access-control.json');
|
|
46
|
+
fs.mkdirSync(accessDir, { recursive: true });
|
|
40
47
|
|
|
41
|
-
|
|
42
|
-
if (
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
const newClient = `\n '${clientId}': { token: '${newToken}' },`;
|
|
48
|
-
content = content.slice(0, insertPos) + newClient + content.slice(insertPos);
|
|
48
|
+
let accessConfig = { clients: {} };
|
|
49
|
+
if (fs.existsSync(accessPath)) {
|
|
50
|
+
try {
|
|
51
|
+
accessConfig = JSON.parse(fs.readFileSync(accessPath, 'utf-8'));
|
|
52
|
+
} catch (error) {
|
|
53
|
+
throw new Error(`Cannot parse ${accessPath}: ${error.message}`);
|
|
49
54
|
}
|
|
50
|
-
} else {
|
|
51
|
-
// Add accessControl section before closing brace
|
|
52
|
-
const accessControlBlock = `
|
|
53
|
-
accessControl: {
|
|
54
|
-
enabled: true,
|
|
55
|
-
clients: {
|
|
56
|
-
'${clientId}': { token: '${newToken}' }
|
|
57
55
|
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
const lastBrace = content.lastIndexOf('};');
|
|
61
|
-
if (lastBrace !== -1) {
|
|
62
|
-
content = content.slice(0, lastBrace) + accessControlBlock + '\n' + content.slice(lastBrace);
|
|
63
|
-
}
|
|
56
|
+
if (!accessConfig.clients || typeof accessConfig.clients !== 'object') {
|
|
57
|
+
accessConfig.clients = {};
|
|
64
58
|
}
|
|
65
|
-
|
|
66
|
-
fs.writeFileSync(
|
|
59
|
+
accessConfig.clients[clientId] = { token: newToken };
|
|
60
|
+
fs.writeFileSync(accessPath, JSON.stringify(accessConfig, null, 2) + '\n', { mode: 0o600 });
|
|
61
|
+
try { fs.chmodSync(accessPath, 0o600); } catch { /* Windows/filesystem may not support chmod */ }
|
|
67
62
|
|
|
68
63
|
console.log(`
|
|
69
|
-
✅ Added client '${clientId}' to
|
|
64
|
+
✅ Added client '${clientId}' to .coursecode/access-control.json
|
|
70
65
|
|
|
71
66
|
Token: ${newToken}
|
|
72
67
|
|
|
68
|
+
Keep accessControl.enforcement = 'server' in course-config.js.
|
|
69
|
+
Your CDN/backend must validate this token before serving course files.
|
|
73
70
|
Build will generate: ${clientId}_proxy.zip
|
|
74
71
|
`);
|
|
75
72
|
} else {
|
package/lib/upgrade.js
CHANGED
|
@@ -113,6 +113,46 @@ export function addMissingRuntimeDependencies(projectPkg, templatePkg) {
|
|
|
113
113
|
return added;
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
+
const OBSOLETE_MANAGED_DEV_DEPENDENCIES = ['@vitejs/plugin-legacy'];
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Synchronize the build tooling that accompanies a replaced template config.
|
|
120
|
+
* This only runs with `coursecode upgrade --configs`, keeping the default
|
|
121
|
+
* framework-only upgrade from changing a project's custom build stack.
|
|
122
|
+
*/
|
|
123
|
+
export function syncManagedBuildTooling(projectPkg, templatePkg) {
|
|
124
|
+
const projectDevDeps = { ...(projectPkg.devDependencies || {}) };
|
|
125
|
+
const templateDevDeps = templatePkg.devDependencies || {};
|
|
126
|
+
const updated = [];
|
|
127
|
+
const removed = [];
|
|
128
|
+
|
|
129
|
+
for (const [name, version] of Object.entries(templateDevDeps)) {
|
|
130
|
+
if (projectDevDeps[name] !== version) {
|
|
131
|
+
projectDevDeps[name] = version;
|
|
132
|
+
updated.push(`${name}@${version}`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
for (const name of OBSOLETE_MANAGED_DEV_DEPENDENCIES) {
|
|
137
|
+
if (projectDevDeps[name]) {
|
|
138
|
+
delete projectDevDeps[name];
|
|
139
|
+
removed.push(name);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
projectPkg.devDependencies = Object.fromEntries(
|
|
144
|
+
Object.entries(projectDevDeps).sort(([a], [b]) => a.localeCompare(b))
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
const nodeEngine = templatePkg.engines?.node;
|
|
148
|
+
const engineUpdated = Boolean(nodeEngine && projectPkg.engines?.node !== nodeEngine);
|
|
149
|
+
if (engineUpdated) {
|
|
150
|
+
projectPkg.engines = { ...(projectPkg.engines || {}), node: nodeEngine };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return { updated, removed, engineUpdated };
|
|
154
|
+
}
|
|
155
|
+
|
|
116
156
|
export async function upgrade(options = {}) {
|
|
117
157
|
const cwd = process.cwd();
|
|
118
158
|
const rcPath = path.join(cwd, '.coursecoderc.json');
|
|
@@ -175,7 +215,8 @@ export async function upgrade(options = {}) {
|
|
|
175
215
|
if (options.configs) {
|
|
176
216
|
dryRunMsg += `
|
|
177
217
|
- vite.config.js (replace, backup original)
|
|
178
|
-
- eslint.config.js (replace, backup original)
|
|
218
|
+
- eslint.config.js (replace, backup original)
|
|
219
|
+
- package.json (sync managed Vite/ESLint tooling and remove plugin-legacy)`;
|
|
179
220
|
}
|
|
180
221
|
|
|
181
222
|
dryRunMsg += `
|
|
@@ -266,10 +307,18 @@ export async function upgrade(options = {}) {
|
|
|
266
307
|
// the project's existing version ranges.
|
|
267
308
|
const pkgPath = path.join(cwd, 'package.json');
|
|
268
309
|
let addedRuntimeDeps = [];
|
|
310
|
+
let toolingChanges = null;
|
|
269
311
|
if (fs.existsSync(pkgPath)) {
|
|
270
312
|
const projectPkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
|
271
313
|
addedRuntimeDeps = addMissingRuntimeDependencies(projectPkg, templatePkg);
|
|
272
|
-
if (
|
|
314
|
+
if (options.configs) {
|
|
315
|
+
toolingChanges = syncManagedBuildTooling(projectPkg, templatePkg);
|
|
316
|
+
}
|
|
317
|
+
const packageChanged = addedRuntimeDeps.length > 0 ||
|
|
318
|
+
toolingChanges?.updated.length > 0 ||
|
|
319
|
+
toolingChanges?.removed.length > 0 ||
|
|
320
|
+
toolingChanges?.engineUpdated;
|
|
321
|
+
if (packageChanged) {
|
|
273
322
|
fs.writeFileSync(pkgPath, JSON.stringify(projectPkg, null, 2) + '\n');
|
|
274
323
|
}
|
|
275
324
|
}
|
|
@@ -290,6 +339,31 @@ export async function upgrade(options = {}) {
|
|
|
290
339
|
Run npm install to update node_modules and your lockfile.`;
|
|
291
340
|
}
|
|
292
341
|
|
|
342
|
+
if (toolingChanges && (
|
|
343
|
+
toolingChanges.updated.length > 0 ||
|
|
344
|
+
toolingChanges.removed.length > 0 ||
|
|
345
|
+
toolingChanges.engineUpdated
|
|
346
|
+
)) {
|
|
347
|
+
successMsg += `
|
|
348
|
+
|
|
349
|
+
Managed build tooling synchronized for the new config:`;
|
|
350
|
+
if (toolingChanges.updated.length > 0) {
|
|
351
|
+
successMsg += `
|
|
352
|
+
- Updated: ${toolingChanges.updated.join(', ')}`;
|
|
353
|
+
}
|
|
354
|
+
if (toolingChanges.removed.length > 0) {
|
|
355
|
+
successMsg += `
|
|
356
|
+
- Removed: ${toolingChanges.removed.join(', ')}`;
|
|
357
|
+
}
|
|
358
|
+
if (toolingChanges.engineUpdated) {
|
|
359
|
+
successMsg += `
|
|
360
|
+
- Updated Node engine requirement to ${templatePkg.engines.node}`;
|
|
361
|
+
}
|
|
362
|
+
successMsg += `
|
|
363
|
+
|
|
364
|
+
Run npm install to update node_modules and your lockfile.`;
|
|
365
|
+
}
|
|
366
|
+
|
|
293
367
|
if (configUpdates.length > 0) {
|
|
294
368
|
successMsg += `
|
|
295
369
|
|