coursecode 0.1.53 ā 0.1.55
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 +6 -3
- package/bin/cli.js +14 -1
- package/framework/docs/COURSE_AUTHORING_GUIDE.md +13 -7
- package/framework/docs/FRAMEWORK_GUIDE.md +23 -22
- 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/create.js +71 -18
- 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/scaffold.js +33 -4
- package/lib/stub-player.js +19 -2
- package/lib/token.js +26 -29
- package/lib/upgrade.js +76 -2
- package/package.json +33 -26
- package/template/course/course-config.js +9 -7
- package/template/gitattributes +44 -0
- package/template/gitignore +38 -0
- package/template/package.json +13 -4
- package/template/vite.config.js +90 -27
package/lib/create.js
CHANGED
|
@@ -77,6 +77,42 @@ function copyDir(src, dest, options = {}) {
|
|
|
77
77
|
}
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
function mergeProjectControlFile(sourcePath, destinationPath) {
|
|
81
|
+
const source = fs.readFileSync(sourcePath, 'utf-8').trimEnd();
|
|
82
|
+
if (!fs.existsSync(destinationPath)) {
|
|
83
|
+
fs.writeFileSync(destinationPath, `${source}\n`, 'utf-8');
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const current = fs.readFileSync(destinationPath, 'utf-8');
|
|
88
|
+
const existingLines = new Set(current.split(/\r?\n/).map(line => line.trim()));
|
|
89
|
+
const missingRules = source
|
|
90
|
+
.split(/\r?\n/)
|
|
91
|
+
.filter(line => line.trim() && !line.trim().startsWith('#') && !existingLines.has(line.trim()));
|
|
92
|
+
|
|
93
|
+
if (missingRules.length === 0) return;
|
|
94
|
+
|
|
95
|
+
const separator = current.endsWith('\n') ? '\n' : '\n\n';
|
|
96
|
+
fs.appendFileSync(
|
|
97
|
+
destinationPath,
|
|
98
|
+
`${separator}# CourseCode project defaults\n${missingRules.join('\n')}\n`,
|
|
99
|
+
'utf-8'
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function assertCurrentDirectoryCanBeInitialized(targetDir, templateDir) {
|
|
104
|
+
const managedPaths = fs.readdirSync(templateDir)
|
|
105
|
+
.filter(name => !['.DS_Store', 'gitignore', 'gitattributes'].includes(name))
|
|
106
|
+
.concat(['framework', 'schemas', 'lib', '.coursecoderc.json']);
|
|
107
|
+
const conflicts = managedPaths.filter(name => fs.existsSync(path.join(targetDir, name)));
|
|
108
|
+
|
|
109
|
+
if (conflicts.length > 0) {
|
|
110
|
+
throw new Error(
|
|
111
|
+
`Cannot initialize the current directory because CourseCode-managed paths already exist: ${conflicts.join(', ')}`
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
80
116
|
/**
|
|
81
117
|
* Run npm install in directory
|
|
82
118
|
*/
|
|
@@ -123,7 +159,10 @@ function gitInit(cwd) {
|
|
|
123
159
|
}
|
|
124
160
|
|
|
125
161
|
export async function create(name, options = {}) {
|
|
126
|
-
const
|
|
162
|
+
const requestedName = String(name || '').trim();
|
|
163
|
+
const currentDirectory = options.currentDirectory === true || requestedName === '.';
|
|
164
|
+
const inferredName = path.basename(process.cwd()).replace(/[-_]+/g, ' ');
|
|
165
|
+
const displayName = currentDirectory && requestedName === '.' ? inferredName : requestedName;
|
|
127
166
|
const directoryName = toProjectDirectoryName(displayName);
|
|
128
167
|
|
|
129
168
|
if (!displayName) {
|
|
@@ -136,16 +175,18 @@ export async function create(name, options = {}) {
|
|
|
136
175
|
process.exit(1);
|
|
137
176
|
}
|
|
138
177
|
|
|
139
|
-
const targetDir = path.resolve(process.cwd(), directoryName);
|
|
178
|
+
const targetDir = currentDirectory ? path.resolve(process.cwd()) : path.resolve(process.cwd(), directoryName);
|
|
179
|
+
const templateDir = path.join(PACKAGE_ROOT, 'template');
|
|
140
180
|
|
|
141
|
-
|
|
142
|
-
|
|
181
|
+
if (currentDirectory) {
|
|
182
|
+
assertCurrentDirectoryCanBeInitialized(targetDir, templateDir);
|
|
183
|
+
} else if (fs.existsSync(targetDir)) {
|
|
143
184
|
console.error(`\nā Directory "${directoryName}" already exists.\n`);
|
|
144
185
|
process.exit(1);
|
|
145
186
|
}
|
|
146
187
|
|
|
147
188
|
console.log(`\nš Creating CourseCode project: ${displayName}\n`);
|
|
148
|
-
if (directoryName !== displayName) {
|
|
189
|
+
if (!currentDirectory && directoryName !== displayName) {
|
|
149
190
|
console.log(` Project folder: ${directoryName}`);
|
|
150
191
|
}
|
|
151
192
|
|
|
@@ -153,11 +194,12 @@ export async function create(name, options = {}) {
|
|
|
153
194
|
fs.mkdirSync(targetDir, { recursive: true });
|
|
154
195
|
|
|
155
196
|
// Copy template files (course directory and vite config)
|
|
156
|
-
const templateDir = path.join(PACKAGE_ROOT, 'template');
|
|
157
197
|
console.log(' Copying template files...');
|
|
158
198
|
copyDir(templateDir, targetDir, {
|
|
159
|
-
exclude: [/^\.DS_Store$/, /^node_modules$/]
|
|
199
|
+
exclude: [/^\.DS_Store$/, /^node_modules$/, /^gitignore$/, /^gitattributes$/]
|
|
160
200
|
});
|
|
201
|
+
mergeProjectControlFile(path.join(templateDir, 'gitignore'), path.join(targetDir, '.gitignore'));
|
|
202
|
+
mergeProjectControlFile(path.join(templateDir, 'gitattributes'), path.join(targetDir, '.gitattributes'));
|
|
161
203
|
|
|
162
204
|
// Copy framework
|
|
163
205
|
const frameworkSrc = path.join(PACKAGE_ROOT, 'framework');
|
|
@@ -189,14 +231,19 @@ export async function create(name, options = {}) {
|
|
|
189
231
|
console.log(' Copying proxy packaging templates...');
|
|
190
232
|
copyDir(proxyTemplatesSrc, proxyTemplatesDest);
|
|
191
233
|
|
|
192
|
-
// Read and customize package.json
|
|
234
|
+
// Read and customize package.json. Pin the authoring/build CLI to the
|
|
235
|
+
// version that created the project so an isolated checkout can build.
|
|
236
|
+
const frameworkPkg = JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT, 'package.json'), 'utf-8'));
|
|
193
237
|
const pkgPath = path.join(targetDir, 'package.json');
|
|
194
238
|
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
|
195
239
|
pkg.name = directoryName;
|
|
240
|
+
pkg.devDependencies = {
|
|
241
|
+
...pkg.devDependencies,
|
|
242
|
+
coursecode: `^${frameworkPkg.version}`
|
|
243
|
+
};
|
|
196
244
|
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2));
|
|
197
245
|
|
|
198
246
|
// Create .coursecoderc.json to track framework version
|
|
199
|
-
const frameworkPkg = JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT, 'package.json'), 'utf-8'));
|
|
200
247
|
const coursecoderc = {
|
|
201
248
|
frameworkVersion: frameworkPkg.version,
|
|
202
249
|
createdAt: new Date().toISOString(),
|
|
@@ -212,7 +259,7 @@ export async function create(name, options = {}) {
|
|
|
212
259
|
// If --blank, remove example files and reset config
|
|
213
260
|
if (options.blank) {
|
|
214
261
|
const { clean } = await import('./scaffold.js');
|
|
215
|
-
clean({ basePath: targetDir });
|
|
262
|
+
clean({ basePath: targetDir, blank: true });
|
|
216
263
|
}
|
|
217
264
|
|
|
218
265
|
writeCourseTitle(targetDir, displayName);
|
|
@@ -229,12 +276,16 @@ export async function create(name, options = {}) {
|
|
|
229
276
|
}
|
|
230
277
|
|
|
231
278
|
// Initialize git repository
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
console.log('
|
|
236
|
-
|
|
237
|
-
|
|
279
|
+
if (fs.existsSync(path.join(targetDir, '.git'))) {
|
|
280
|
+
console.log('\n ā
Existing Git repository preserved');
|
|
281
|
+
} else {
|
|
282
|
+
console.log('\n Initializing git repository...');
|
|
283
|
+
try {
|
|
284
|
+
await gitInit(targetDir);
|
|
285
|
+
console.log(' ā
Git repository initialized');
|
|
286
|
+
} catch (_error) {
|
|
287
|
+
console.warn(' ā ļø Git init failed. You can run "git init" manually.');
|
|
288
|
+
}
|
|
238
289
|
}
|
|
239
290
|
|
|
240
291
|
// Print success message
|
|
@@ -287,12 +338,14 @@ export async function create(name, options = {}) {
|
|
|
287
338
|
console.log(` cd ${directoryName} && coursecode dev\n`);
|
|
288
339
|
});
|
|
289
340
|
} else {
|
|
290
|
-
|
|
341
|
+
const changeDirectory = currentDirectory ? '' : `cd ${directoryName}\n `;
|
|
342
|
+
console.log(`\n To start developing:\n\n ${changeDirectory}coursecode dev\n`);
|
|
291
343
|
}
|
|
292
344
|
|
|
293
345
|
return {
|
|
294
346
|
displayName,
|
|
295
347
|
directoryName,
|
|
296
|
-
targetDir
|
|
348
|
+
targetDir,
|
|
349
|
+
currentDirectory
|
|
297
350
|
};
|
|
298
351
|
}
|
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
* For cmi5-remote format, the AU URL is absolute (pointing to CDN).
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
+
import { escapeXmlAttribute, escapeXmlText } from './xml-utils.js';
|
|
14
|
+
|
|
13
15
|
/**
|
|
14
16
|
* Generates the cmi5 course structure XML.
|
|
15
17
|
* @param {Object} config - Course configuration
|
|
@@ -23,39 +25,46 @@ export function generateCmi5Manifest(config, _files, options = {}) {
|
|
|
23
25
|
const courseId = config.identifier ||
|
|
24
26
|
`urn:coursecode:${config.title.replace(/[^a-zA-Z0-9]/g, '-').toLowerCase()}`;
|
|
25
27
|
|
|
26
|
-
//
|
|
27
|
-
|
|
28
|
+
// Package-level mastery is optional. Do not invent a value from one
|
|
29
|
+
// assessment because a course can contain multiple thresholds.
|
|
30
|
+
const masteryAttribute = Number.isFinite(config.masteryScore)
|
|
31
|
+
? ` masteryScore="${(config.masteryScore / 100).toFixed(2)}"`
|
|
32
|
+
: '';
|
|
28
33
|
|
|
29
34
|
// AU identifier - derive from course ID
|
|
30
35
|
const auId = `${courseId}/au/1`;
|
|
31
36
|
|
|
32
37
|
// URL: absolute for cmi5-remote (use as-is), relative for standard cmi5
|
|
33
38
|
const auUrl = options.externalUrl || 'index.html';
|
|
39
|
+
const escapedCourseId = escapeXmlAttribute(courseId);
|
|
40
|
+
const escapedAuId = escapeXmlAttribute(auId);
|
|
41
|
+
const escapedLanguage = escapeXmlAttribute(config.language);
|
|
42
|
+
const title = escapeXmlText(config.title);
|
|
43
|
+
const description = escapeXmlText(config.description);
|
|
34
44
|
|
|
35
45
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
36
46
|
<!-- cmi5 Course Structure - GENERATED FILE - DO NOT EDIT MANUALLY -->
|
|
37
47
|
<courseStructure xmlns="https://w3id.org/xapi/profiles/cmi5/v1/CourseStructure.xsd">
|
|
38
48
|
|
|
39
|
-
<course id="${
|
|
49
|
+
<course id="${escapedCourseId}">
|
|
40
50
|
<title>
|
|
41
|
-
<langstring lang="${
|
|
51
|
+
<langstring lang="${escapedLanguage}">${title}</langstring>
|
|
42
52
|
</title>
|
|
43
53
|
<description>
|
|
44
|
-
<langstring lang="${
|
|
54
|
+
<langstring lang="${escapedLanguage}">${description}</langstring>
|
|
45
55
|
</description>
|
|
46
56
|
</course>
|
|
47
57
|
|
|
48
|
-
<au id="${
|
|
58
|
+
<au id="${escapedAuId}" moveOn="Completed"${masteryAttribute} launchMethod="OwnWindow">
|
|
49
59
|
<title>
|
|
50
|
-
<langstring lang="${
|
|
60
|
+
<langstring lang="${escapedLanguage}">${title}</langstring>
|
|
51
61
|
</title>
|
|
52
62
|
<description>
|
|
53
|
-
<langstring lang="${
|
|
63
|
+
<langstring lang="${escapedLanguage}">${description}</langstring>
|
|
54
64
|
</description>
|
|
55
|
-
<url>${auUrl}</url>
|
|
65
|
+
<url>${escapeXmlText(auUrl)}</url>
|
|
56
66
|
</au>
|
|
57
67
|
|
|
58
68
|
</courseStructure>
|
|
59
69
|
`;
|
|
60
70
|
}
|
|
61
|
-
|
|
@@ -12,7 +12,21 @@
|
|
|
12
12
|
* @returns {string} JSON string of tool configuration
|
|
13
13
|
*/
|
|
14
14
|
export function generateLtiToolConfig(config, options = {}) {
|
|
15
|
-
const
|
|
15
|
+
const configuredUrl = options.externalUrl || config.externalUrl;
|
|
16
|
+
if (!configuredUrl) {
|
|
17
|
+
throw new Error('LTI builds require externalUrl for a trusted server-side OIDC/AGS backend');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
let parsedBaseUrl;
|
|
21
|
+
try {
|
|
22
|
+
parsedBaseUrl = new URL(configuredUrl);
|
|
23
|
+
} catch {
|
|
24
|
+
throw new Error(`Invalid LTI externalUrl: ${configuredUrl}`);
|
|
25
|
+
}
|
|
26
|
+
if (!['https:', 'http:'].includes(parsedBaseUrl.protocol)) {
|
|
27
|
+
throw new Error('LTI externalUrl must use https (or http for local development)');
|
|
28
|
+
}
|
|
29
|
+
const baseUrl = parsedBaseUrl.toString().replace(/\/$/, '');
|
|
16
30
|
const title = config.title || 'CourseCode Course';
|
|
17
31
|
const description = config.description || '';
|
|
18
32
|
|
|
@@ -24,7 +38,6 @@ export function generateLtiToolConfig(config, options = {}) {
|
|
|
24
38
|
'grant_types': ['implicit', 'client_credentials'],
|
|
25
39
|
'initiate_login_uri': `${baseUrl}/lti/login`,
|
|
26
40
|
'redirect_uris': [
|
|
27
|
-
`${baseUrl}/index.html`,
|
|
28
41
|
`${baseUrl}/lti/launch`
|
|
29
42
|
],
|
|
30
43
|
'client_name': title,
|
|
@@ -35,14 +48,14 @@ export function generateLtiToolConfig(config, options = {}) {
|
|
|
35
48
|
|
|
36
49
|
// LTI-specific claims
|
|
37
50
|
'https://purl.imsglobal.org/spec/lti-tool-configuration': {
|
|
38
|
-
'domain':
|
|
51
|
+
'domain': parsedBaseUrl.hostname,
|
|
39
52
|
'description': description,
|
|
40
|
-
'target_link_uri': `${baseUrl}/
|
|
53
|
+
'target_link_uri': `${baseUrl}/lti/launch`,
|
|
41
54
|
'claims': ['iss', 'sub', 'name', 'given_name', 'family_name', 'email'],
|
|
42
55
|
'messages': [
|
|
43
56
|
{
|
|
44
57
|
'type': 'LtiResourceLinkRequest',
|
|
45
|
-
'target_link_uri': `${baseUrl}/
|
|
58
|
+
'target_link_uri': `${baseUrl}/lti/launch`,
|
|
46
59
|
'label': title
|
|
47
60
|
}
|
|
48
61
|
]
|
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
* - 4th Edition schema references
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import { escapeXmlAttribute, escapeXmlText } from './xml-utils.js';
|
|
12
|
+
|
|
11
13
|
/**
|
|
12
14
|
* Generates the SCORM 1.2 manifest.
|
|
13
15
|
* @param {Object} config - Course configuration
|
|
@@ -22,13 +24,18 @@ export function generateScorm12Manifest(config, files) {
|
|
|
22
24
|
!f.startsWith('common/')
|
|
23
25
|
);
|
|
24
26
|
|
|
25
|
-
const fileEntries = resourceFiles.map(f => ` <file href="${f}"/>`).join('\n');
|
|
27
|
+
const fileEntries = resourceFiles.map(f => ` <file href="${escapeXmlAttribute(f)}"/>`).join('\n');
|
|
28
|
+
const version = escapeXmlAttribute(config.version);
|
|
29
|
+
const title = escapeXmlText(config.title);
|
|
30
|
+
const masteryScore = Number.isFinite(config.masteryScore)
|
|
31
|
+
? `\n <adlcp:masteryscore>${config.masteryScore}</adlcp:masteryscore>`
|
|
32
|
+
: '';
|
|
26
33
|
|
|
27
34
|
// SCORM 1.2 uses ADLCP 1.2 schema and doesn't include sequencing
|
|
28
35
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
29
36
|
<!-- SCORM 1.2 manifest - GENERATED FILE - DO NOT EDIT MANUALLY -->
|
|
30
37
|
<manifest identifier="${config.title.replace(/[^a-zA-Z0-9]/g, '-')}"
|
|
31
|
-
version="${
|
|
38
|
+
version="${version}"
|
|
32
39
|
xmlns="http://www.imsproject.org/xsd/imscp_rootv1p1p2"
|
|
33
40
|
xmlns:adlcp="http://www.adlnet.org/xsd/adlcp_rootv1p2"
|
|
34
41
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
|
@@ -43,10 +50,9 @@ export function generateScorm12Manifest(config, files) {
|
|
|
43
50
|
|
|
44
51
|
<organizations default="org-1">
|
|
45
52
|
<organization identifier="org-1">
|
|
46
|
-
<title>${
|
|
53
|
+
<title>${title}</title>
|
|
47
54
|
<item identifier="item-1" identifierref="res-1" isvisible="true">
|
|
48
|
-
<title>${
|
|
49
|
-
<adlcp:masteryscore>80</adlcp:masteryscore>
|
|
55
|
+
<title>${title}</title>${masteryScore}
|
|
50
56
|
</item>
|
|
51
57
|
</organization>
|
|
52
58
|
</organizations>
|
|
@@ -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
|
// =============================================================================
|