@vue-skuilder/express 0.1.24 → 0.1.26
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/dist/app-factory.d.ts.map +1 -1
- package/dist/app-factory.js +26 -11
- package/dist/app-factory.js.map +1 -1
- package/dist/attachment-preprocessing/normalize-local.js +31 -4
- package/dist/attachment-preprocessing/normalize-local.js.map +1 -1
- package/dist/client-requests/pack-requests.d.ts.map +1 -1
- package/dist/client-requests/pack-requests.js +14 -10
- package/dist/client-requests/pack-requests.js.map +1 -1
- package/dist/couchdb/authentication.d.ts +35 -1
- package/dist/couchdb/authentication.d.ts.map +1 -1
- package/dist/couchdb/authentication.js +87 -52
- package/dist/couchdb/authentication.js.map +1 -1
- package/dist/peruser.d.ts +14 -0
- package/dist/peruser.d.ts.map +1 -0
- package/dist/peruser.js +75 -0
- package/dist/peruser.js.map +1 -0
- package/package.json +4 -4
- package/src/app-factory.ts +61 -29
- package/src/attachment-preprocessing/normalize-local.ts +38 -4
- package/src/client-requests/pack-requests.ts +74 -42
- package/src/couchdb/authentication.ts +107 -54
- package/src/peruser.ts +80 -0
package/dist/peruser.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-user database provisioning daemon.
|
|
3
|
+
*
|
|
4
|
+
* Replaces CouchDB's built-in couch_peruser Erlang module, which has a
|
|
5
|
+
* process leak bug that causes unbounded memory growth:
|
|
6
|
+
* https://github.com/apache/couchdb/issues/5871
|
|
7
|
+
*
|
|
8
|
+
* Watches the _users database changes feed and ensures that every user
|
|
9
|
+
* has a corresponding userdb-{hex(username)} database with appropriate
|
|
10
|
+
* security settings. Replays from since=0 on every startup — this is
|
|
11
|
+
* cheap and removes the need to persist checkpoint state.
|
|
12
|
+
*/
|
|
13
|
+
import { getCouchDB } from './couchdb/index.js';
|
|
14
|
+
import logger from './logger.js';
|
|
15
|
+
function hexEncode(str) {
|
|
16
|
+
let returnStr = '';
|
|
17
|
+
for (let i = 0; i < str.length; i++) {
|
|
18
|
+
returnStr += ('000' + str.charCodeAt(i).toString(16)).slice(3);
|
|
19
|
+
}
|
|
20
|
+
return returnStr;
|
|
21
|
+
}
|
|
22
|
+
async function ensureUserDB(username) {
|
|
23
|
+
const dbName = `userdb-${hexEncode(username)}`;
|
|
24
|
+
const server = getCouchDB();
|
|
25
|
+
try {
|
|
26
|
+
await server.db.create(dbName);
|
|
27
|
+
logger.info(`peruser: created ${dbName}`);
|
|
28
|
+
}
|
|
29
|
+
catch (e) {
|
|
30
|
+
if (e &&
|
|
31
|
+
typeof e === 'object' &&
|
|
32
|
+
'statusCode' in e &&
|
|
33
|
+
e.statusCode === 412) {
|
|
34
|
+
// already exists — expected on replay
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
logger.error(`peruser: error creating ${dbName}:`, e);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const db = server.use(dbName);
|
|
42
|
+
try {
|
|
43
|
+
const security = {
|
|
44
|
+
admins: { names: [username], roles: [] },
|
|
45
|
+
members: { names: [username], roles: [] },
|
|
46
|
+
};
|
|
47
|
+
await db.insert(security, '_security');
|
|
48
|
+
}
|
|
49
|
+
catch (e) {
|
|
50
|
+
logger.error(`peruser: error setting security on ${dbName}:`, e);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
export function startPerUserProvisioning() {
|
|
54
|
+
const usersDB = getCouchDB().use('_users');
|
|
55
|
+
usersDB.changesReader
|
|
56
|
+
.start({
|
|
57
|
+
includeDocs: false,
|
|
58
|
+
since: '0',
|
|
59
|
+
})
|
|
60
|
+
.on('change', (change) => {
|
|
61
|
+
if (!change.id.startsWith('org.couchdb.user:'))
|
|
62
|
+
return;
|
|
63
|
+
if (change.deleted)
|
|
64
|
+
return;
|
|
65
|
+
const username = change.id.replace('org.couchdb.user:', '');
|
|
66
|
+
ensureUserDB(username).catch((e) => {
|
|
67
|
+
logger.error(`peruser: unhandled error for ${username}:`, e);
|
|
68
|
+
});
|
|
69
|
+
})
|
|
70
|
+
.on('error', (err) => {
|
|
71
|
+
logger.error('peruser: _users changes feed error:', err);
|
|
72
|
+
});
|
|
73
|
+
logger.info('peruser: watching _users for new accounts');
|
|
74
|
+
}
|
|
75
|
+
//# sourceMappingURL=peruser.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"peruser.js","sourceRoot":"","sources":["../src/peruser.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"publishConfig": {
|
|
4
4
|
"access": "public"
|
|
5
5
|
},
|
|
6
|
-
"version": "0.1.
|
|
6
|
+
"version": "0.1.26",
|
|
7
7
|
"description": "an API",
|
|
8
8
|
"main": "dist/index.js",
|
|
9
9
|
"type": "module",
|
|
@@ -35,8 +35,8 @@
|
|
|
35
35
|
"author": "Colin Kennedy",
|
|
36
36
|
"license": "GPL-3.0-or-later",
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@vue-skuilder/common": "^0.1.
|
|
39
|
-
"@vue-skuilder/db": "^0.1.
|
|
38
|
+
"@vue-skuilder/common": "^0.1.26",
|
|
39
|
+
"@vue-skuilder/db": "^0.1.26",
|
|
40
40
|
"axios": "^1.12.0",
|
|
41
41
|
"cookie-parser": "^1.4.7",
|
|
42
42
|
"cors": "^2.8.5",
|
|
@@ -77,5 +77,5 @@
|
|
|
77
77
|
"typescript-eslint": "^8.48.1",
|
|
78
78
|
"vitest": "^4.0.15"
|
|
79
79
|
},
|
|
80
|
-
"stableVersion": "0.1.
|
|
80
|
+
"stableVersion": "0.1.26"
|
|
81
81
|
}
|
package/src/app-factory.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { initializeDataLayer } from '@vue-skuilder/db';
|
|
2
|
-
import {
|
|
3
|
-
ServerRequestType as RequestEnum,
|
|
4
|
-
ServerRequest,
|
|
5
|
-
prepareNote55,
|
|
2
|
+
import {
|
|
3
|
+
ServerRequestType as RequestEnum,
|
|
4
|
+
ServerRequest,
|
|
5
|
+
prepareNote55,
|
|
6
6
|
} from '@vue-skuilder/common';
|
|
7
7
|
import { CourseLookup } from '@vue-skuilder/db';
|
|
8
8
|
import cookieParser from 'cookie-parser';
|
|
@@ -12,6 +12,7 @@ import express from 'express';
|
|
|
12
12
|
import morgan from 'morgan';
|
|
13
13
|
import Nano from 'nano';
|
|
14
14
|
import PostProcess from './attachment-preprocessing/index.js';
|
|
15
|
+
import { startPerUserProvisioning } from './peruser.js';
|
|
15
16
|
import {
|
|
16
17
|
ClassroomCreationQueue,
|
|
17
18
|
ClassroomJoinQueue,
|
|
@@ -22,8 +23,16 @@ import {
|
|
|
22
23
|
initCourseDBDesignDocInsert,
|
|
23
24
|
} from './client-requests/course-requests.js';
|
|
24
25
|
import { packCourse } from './client-requests/pack-requests.js';
|
|
25
|
-
import {
|
|
26
|
-
|
|
26
|
+
import {
|
|
27
|
+
getAuthenticatedUser,
|
|
28
|
+
requestIsAuthenticated,
|
|
29
|
+
} from './couchdb/authentication.js';
|
|
30
|
+
import {
|
|
31
|
+
getCouchDB,
|
|
32
|
+
initializeCouchDB,
|
|
33
|
+
useOrCreateCourseDB,
|
|
34
|
+
useOrCreateDB,
|
|
35
|
+
} from './couchdb/index.js';
|
|
27
36
|
import { classroomDbDesignDoc } from './design-docs.js';
|
|
28
37
|
import logger from './logger.js';
|
|
29
38
|
import logsRouter from './routes/logs.js';
|
|
@@ -45,7 +54,9 @@ export type AppConfig = ExpressServerConfig | EnvironmentConfig;
|
|
|
45
54
|
/**
|
|
46
55
|
* Type guard to determine if config is ExpressServerConfig (programmatic usage)
|
|
47
56
|
*/
|
|
48
|
-
function isExpressServerConfig(
|
|
57
|
+
function isExpressServerConfig(
|
|
58
|
+
config: AppConfig
|
|
59
|
+
): config is ExpressServerConfig {
|
|
49
60
|
return 'couchdb' in config && typeof config.couchdb === 'object';
|
|
50
61
|
}
|
|
51
62
|
|
|
@@ -70,19 +81,20 @@ function convertToEnvConfig(config: ExpressServerConfig): EnvironmentConfig {
|
|
|
70
81
|
*/
|
|
71
82
|
export function createExpressApp(config: AppConfig): express.Application {
|
|
72
83
|
const app = express();
|
|
73
|
-
|
|
84
|
+
|
|
74
85
|
// Normalize config to environment format for internal usage
|
|
75
|
-
const envConfig = isExpressServerConfig(config)
|
|
76
|
-
? convertToEnvConfig(config)
|
|
86
|
+
const envConfig = isExpressServerConfig(config)
|
|
87
|
+
? convertToEnvConfig(config)
|
|
77
88
|
: config;
|
|
78
89
|
|
|
79
90
|
// Initialize CouchDB connection
|
|
80
91
|
initializeCouchDB(envConfig);
|
|
81
92
|
|
|
82
93
|
// Configure CORS - use config if available, otherwise defaults
|
|
83
|
-
const corsOptions =
|
|
84
|
-
|
|
85
|
-
|
|
94
|
+
const corsOptions =
|
|
95
|
+
isExpressServerConfig(config) && config.cors
|
|
96
|
+
? config.cors
|
|
97
|
+
: { credentials: true, origin: true };
|
|
86
98
|
|
|
87
99
|
// Middleware setup
|
|
88
100
|
app.use(cookieParser());
|
|
@@ -130,7 +142,10 @@ export function createExpressApp(config: AppConfig): express.Application {
|
|
|
130
142
|
logger.info(`Delete request made on course ${req.params.courseID}...`);
|
|
131
143
|
const auth = await requestIsAuthenticated(req);
|
|
132
144
|
if (auth) {
|
|
133
|
-
logger.info(` Authenticated delete request made...`);
|
|
145
|
+
logger.info(` Authenticated delete request made...`);
|
|
146
|
+
const dbResp = await getCouchDB().db.destroy(
|
|
147
|
+
`coursedb-${req.params.courseID}`
|
|
148
|
+
);
|
|
134
149
|
if (!dbResp.ok) {
|
|
135
150
|
res.json({ success: false, error: dbResp });
|
|
136
151
|
return;
|
|
@@ -147,7 +162,9 @@ export function createExpressApp(config: AppConfig): express.Application {
|
|
|
147
162
|
}
|
|
148
163
|
} catch (error) {
|
|
149
164
|
logger.error('Error deleting course:', error);
|
|
150
|
-
res
|
|
165
|
+
res
|
|
166
|
+
.status(500)
|
|
167
|
+
.json({ success: false, error: 'Failed to delete course' });
|
|
151
168
|
}
|
|
152
169
|
})();
|
|
153
170
|
});
|
|
@@ -156,13 +173,15 @@ export function createExpressApp(config: AppConfig): express.Application {
|
|
|
156
173
|
req: VueClientRequest,
|
|
157
174
|
res: express.Response
|
|
158
175
|
): Promise<void> {
|
|
159
|
-
|
|
160
|
-
|
|
176
|
+
// Use secure authentication that returns the actual session username
|
|
177
|
+
const authResult = await getAuthenticatedUser(req);
|
|
178
|
+
if (authResult.authenticated) {
|
|
179
|
+
const authenticatedUsername = authResult.username;
|
|
161
180
|
const body = req.body;
|
|
162
181
|
logger.info(
|
|
163
182
|
`Authorized ${
|
|
164
183
|
body.type ? body.type : '[unspecified request type]'
|
|
165
|
-
} request
|
|
184
|
+
} request from ${authenticatedUsername}...`
|
|
166
185
|
);
|
|
167
186
|
|
|
168
187
|
if (body.type === RequestEnum.CREATE_CLASSROOM) {
|
|
@@ -176,8 +195,9 @@ export function createExpressApp(config: AppConfig): express.Application {
|
|
|
176
195
|
body.response = await ClassroomJoinQueue.getResult(id);
|
|
177
196
|
res.json(body.response);
|
|
178
197
|
} else if (body.type === RequestEnum.LEAVE_CLASSROOM) {
|
|
198
|
+
// SECURITY FIX: Use authenticated username from session, not user-supplied req.body.user
|
|
179
199
|
const id: number = ClassroomLeaveQueue.addRequest({
|
|
180
|
-
username:
|
|
200
|
+
username: authenticatedUsername,
|
|
181
201
|
...body.data,
|
|
182
202
|
});
|
|
183
203
|
body.response = await ClassroomLeaveQueue.getResult(id);
|
|
@@ -196,7 +216,8 @@ export function createExpressApp(config: AppConfig): express.Application {
|
|
|
196
216
|
body.data.tags,
|
|
197
217
|
body.data.uploads
|
|
198
218
|
);
|
|
199
|
-
getCouchDB()
|
|
219
|
+
getCouchDB()
|
|
220
|
+
.use(`coursedb-${body.data.courseID}`)
|
|
200
221
|
.insert(payload as Nano.MaybeDocument)
|
|
201
222
|
.then((r) => {
|
|
202
223
|
logger.info(`\t\t\tCouchDB insert result: ${JSON.stringify(r)}`);
|
|
@@ -212,15 +233,16 @@ export function createExpressApp(config: AppConfig): express.Application {
|
|
|
212
233
|
`\tPACK_COURSE request received in production mode, but this is not supported!`
|
|
213
234
|
);
|
|
214
235
|
res.status(400);
|
|
215
|
-
res.statusMessage =
|
|
236
|
+
res.statusMessage =
|
|
237
|
+
'Packing courses is not supported in production mode.';
|
|
216
238
|
res.send();
|
|
217
239
|
return;
|
|
218
240
|
}
|
|
219
|
-
|
|
241
|
+
|
|
220
242
|
body.response = await packCourse({
|
|
221
243
|
courseId: body.courseId,
|
|
222
244
|
outputPath: body.outputPath,
|
|
223
|
-
couchdbUrl: body.couchdbUrl
|
|
245
|
+
couchdbUrl: body.couchdbUrl,
|
|
224
246
|
});
|
|
225
247
|
res.json(body.response);
|
|
226
248
|
}
|
|
@@ -243,7 +265,8 @@ export function createExpressApp(config: AppConfig): express.Application {
|
|
|
243
265
|
app.get('/', (_req: Request, res: Response) => {
|
|
244
266
|
let status = `Express service is running.\nVersion: ${envConfig.VERSION}\n`;
|
|
245
267
|
|
|
246
|
-
getCouchDB()
|
|
268
|
+
getCouchDB()
|
|
269
|
+
.session()
|
|
247
270
|
.then((s) => {
|
|
248
271
|
if (s.ok) {
|
|
249
272
|
status += 'Couchdb is running.\n';
|
|
@@ -272,8 +295,6 @@ export async function initializeServices(config: AppConfig): Promise<void> {
|
|
|
272
295
|
? convertToEnvConfig(config)
|
|
273
296
|
: config;
|
|
274
297
|
|
|
275
|
-
|
|
276
|
-
|
|
277
298
|
await initializeDataLayer({
|
|
278
299
|
type: 'couch',
|
|
279
300
|
options: {
|
|
@@ -301,10 +322,21 @@ export async function initializeServices(config: AppConfig): Promise<void> {
|
|
|
301
322
|
// media uploads
|
|
302
323
|
void PostProcess();
|
|
303
324
|
|
|
325
|
+
// start the per-user database provisioning listener
|
|
326
|
+
// (replaces CouchDB's couch_peruser, which has a process leak)
|
|
327
|
+
// skip in studio mode — studio is a local single-user environment
|
|
328
|
+
if (envConfig.NODE_ENV !== 'studio') {
|
|
329
|
+
startPerUserProvisioning();
|
|
330
|
+
}
|
|
331
|
+
|
|
304
332
|
initCourseDBDesignDocInsert().catch((error) => {
|
|
305
|
-
logger.error(
|
|
333
|
+
logger.error(
|
|
334
|
+
`Error in initCourseDBDesignDocInsert background task: ${error}`
|
|
335
|
+
);
|
|
306
336
|
if (error && typeof error === 'object') {
|
|
307
|
-
logger.error(
|
|
337
|
+
logger.error(
|
|
338
|
+
`Full error details in initCourseDBDesignDocInsert: ${JSON.stringify(error)}`
|
|
339
|
+
);
|
|
308
340
|
}
|
|
309
341
|
});
|
|
310
342
|
|
|
@@ -325,4 +357,4 @@ export async function initializeServices(config: AppConfig): Promise<void> {
|
|
|
325
357
|
} catch (e) {
|
|
326
358
|
logger.info(`Error: ${JSON.stringify(e)}`);
|
|
327
359
|
}
|
|
328
|
-
}
|
|
360
|
+
}
|
|
@@ -58,8 +58,28 @@ interface LoudnessData {
|
|
|
58
58
|
target_offset: string;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
// Fade durations in seconds — short enough to preserve consonant onsets,
|
|
62
|
+
// long enough to smooth out the noise-floor cut-in/cut-out
|
|
63
|
+
const FADE_IN_DURATION = 0.05; // 50ms
|
|
64
|
+
const FADE_OUT_DURATION = 0.1; // 100ms
|
|
65
|
+
|
|
66
|
+
function parseDuration(output: string): number {
|
|
67
|
+
const match = output.match(/Duration:\s*(\d+):(\d+):(\d+)\.(\d+)/);
|
|
68
|
+
if (!match) throw new Error('Could not parse duration from ffmpeg output');
|
|
69
|
+
const hours = parseInt(match[1]);
|
|
70
|
+
const minutes = parseInt(match[2]);
|
|
71
|
+
const seconds = parseInt(match[3]);
|
|
72
|
+
const frac = parseInt(match[4]) / Math.pow(10, match[4].length);
|
|
73
|
+
return hours * 3600 + minutes * 60 + seconds + frac;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function getAudioDuration(filePath: string): Promise<number> {
|
|
77
|
+
const result = await exec(`"${FFMPEG}" -i "${filePath}" -f null -`);
|
|
78
|
+
return parseDuration(result.stderr);
|
|
79
|
+
}
|
|
80
|
+
|
|
61
81
|
/**
|
|
62
|
-
* Normalizes a single wav file to mp3 with loudnorm
|
|
82
|
+
* Normalizes a single wav file to mp3 with loudnorm and fade in/out
|
|
63
83
|
* Same spec as the attachment preprocessing: I=-16:TP=-1.5:LRA=11
|
|
64
84
|
*/
|
|
65
85
|
async function normalizeFile(
|
|
@@ -73,6 +93,7 @@ async function normalizeFile(
|
|
|
73
93
|
|
|
74
94
|
const PADDED = path.join(tmpDir, 'padded.wav');
|
|
75
95
|
const PADDED_NORMALIZED = path.join(tmpDir, 'paddedNormalized.wav');
|
|
96
|
+
const TRIMMED = path.join(tmpDir, 'trimmed.wav');
|
|
76
97
|
const NORMALIZED = path.join(tmpDir, 'normalized.mp3');
|
|
77
98
|
|
|
78
99
|
try {
|
|
@@ -108,10 +129,23 @@ async function normalizeFile(
|
|
|
108
129
|
`print_format=summary -ar 48k "${PADDED_NORMALIZED}"`
|
|
109
130
|
);
|
|
110
131
|
|
|
111
|
-
// Cut off the padded
|
|
112
|
-
log(`[${baseName}] Cutting padding
|
|
132
|
+
// Cut off the padded silence (keep as wav to avoid double lossy encoding)
|
|
133
|
+
log(`[${baseName}] Cutting padding...`);
|
|
134
|
+
await exec(
|
|
135
|
+
`"${FFMPEG}" -i "${PADDED_NORMALIZED}" -ss 00:00:10.000 "${TRIMMED}"`
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
// Apply fade in/out and encode to mp3
|
|
139
|
+
const duration = await getAudioDuration(TRIMMED);
|
|
140
|
+
const fadeOutStart = Math.max(0, duration - FADE_OUT_DURATION);
|
|
141
|
+
log(
|
|
142
|
+
`[${baseName}] Applying fades (in=${FADE_IN_DURATION}s, out=${FADE_OUT_DURATION}s @ ${fadeOutStart.toFixed(3)}s) and encoding to mp3...`
|
|
143
|
+
);
|
|
113
144
|
await exec(
|
|
114
|
-
`"${FFMPEG}" -i "${
|
|
145
|
+
`"${FFMPEG}" -i "${TRIMMED}" -af ` +
|
|
146
|
+
`"afade=t=in:st=0:d=${FADE_IN_DURATION}:curve=qsin,` +
|
|
147
|
+
`afade=t=out:st=${fadeOutStart}:d=${FADE_OUT_DURATION}:curve=qsin" ` +
|
|
148
|
+
`-acodec libmp3lame -b:a 192k "${NORMALIZED}"`
|
|
115
149
|
);
|
|
116
150
|
|
|
117
151
|
// Copy to output location
|
|
@@ -28,23 +28,25 @@ interface PackCourseResponse {
|
|
|
28
28
|
function extractOriginalCourseId(decoratedId: string): string {
|
|
29
29
|
// Remove unpacked_ prefix if present
|
|
30
30
|
let courseId = decoratedId.replace(/^unpacked_/, '');
|
|
31
|
-
|
|
31
|
+
|
|
32
32
|
// Remove timestamp_random suffix pattern: _YYYYMMDD_abcdef
|
|
33
33
|
courseId = courseId.replace(/_\d{8}_[a-z0-9]{6}$/, '');
|
|
34
|
-
|
|
34
|
+
|
|
35
35
|
// If no changes were made, return original (handles non-decorated IDs)
|
|
36
36
|
return courseId === decoratedId ? decoratedId : courseId;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
export async function packCourse(
|
|
39
|
+
export async function packCourse(
|
|
40
|
+
data: PackCourseData
|
|
41
|
+
): Promise<PackCourseResponse> {
|
|
40
42
|
logger.info(`Starting PACK_COURSE for ${data.courseId}...`);
|
|
41
|
-
|
|
43
|
+
|
|
42
44
|
try {
|
|
43
45
|
const startTime = Date.now();
|
|
44
|
-
|
|
46
|
+
|
|
45
47
|
// Use CouchDBToStaticPacker directly from db package
|
|
46
48
|
const { CouchDBToStaticPacker } = await import('@vue-skuilder/db');
|
|
47
|
-
|
|
49
|
+
|
|
48
50
|
// Create database connection URL - use provided couchdbUrl if available (studio mode)
|
|
49
51
|
logger.info(`Pack request data: ${JSON.stringify(data, null, 2)}`);
|
|
50
52
|
let courseDbUrl: string;
|
|
@@ -53,21 +55,23 @@ export async function packCourse(data: PackCourseData): Promise<PackCourseRespon
|
|
|
53
55
|
logger.info(`Using provided CouchDB URL: "${courseDbUrl}"`);
|
|
54
56
|
} else {
|
|
55
57
|
// Fallback to ENV configuration for production mode
|
|
56
|
-
logger.info(
|
|
58
|
+
logger.info(
|
|
59
|
+
`ENV values - Protocol: "${ENV.COUCHDB_PROTOCOL}", Admin: "${ENV.COUCHDB_ADMIN}", Password: PROBABLY_NOT_THE_PASSORD, Server: "${ENV.COUCHDB_SERVER}"`
|
|
60
|
+
);
|
|
57
61
|
const dbUrl = `${ENV.COUCHDB_PROTOCOL}://${ENV.COUCHDB_ADMIN}:${ENV.COUCHDB_PASSWORD}@${ENV.COUCHDB_SERVER}`;
|
|
58
62
|
const dbName = `coursedb-${data.courseId}`;
|
|
59
63
|
courseDbUrl = `${dbUrl}/${dbName}`;
|
|
60
64
|
logger.info(`Constructed dbUrl from ENV: "${courseDbUrl}"`);
|
|
61
65
|
}
|
|
62
|
-
|
|
66
|
+
|
|
63
67
|
// Determine output path based on environment and provided path
|
|
64
68
|
let outputPath: string;
|
|
65
|
-
|
|
69
|
+
|
|
66
70
|
if (data.outputPath) {
|
|
67
71
|
// If output path is provided, check if it's absolute or relative
|
|
68
72
|
const pathModule = await import('path');
|
|
69
73
|
const path = pathModule.default || pathModule;
|
|
70
|
-
|
|
74
|
+
|
|
71
75
|
if (path.isAbsolute(data.outputPath)) {
|
|
72
76
|
// Use absolute path as-is
|
|
73
77
|
outputPath = data.outputPath;
|
|
@@ -78,39 +82,45 @@ export async function packCourse(data: PackCourseData): Promise<PackCourseRespon
|
|
|
78
82
|
}
|
|
79
83
|
} else {
|
|
80
84
|
// No output path provided - use default
|
|
81
|
-
outputPath =
|
|
82
|
-
'
|
|
83
|
-
|
|
85
|
+
outputPath =
|
|
86
|
+
ENV.NODE_ENV === 'studio'
|
|
87
|
+
? '/tmp/skuilder-studio-output'
|
|
88
|
+
: process.cwd();
|
|
84
89
|
}
|
|
85
|
-
|
|
86
|
-
logger.info(
|
|
87
|
-
|
|
90
|
+
|
|
91
|
+
logger.info(
|
|
92
|
+
`Packing course ${data.courseId} from ${courseDbUrl} to ${outputPath}`
|
|
93
|
+
);
|
|
94
|
+
|
|
88
95
|
// Clean up existing output directory for replace-in-place functionality
|
|
89
96
|
const fsExtra = await import('fs-extra');
|
|
90
97
|
const fs = fsExtra.default || fsExtra;
|
|
91
|
-
|
|
98
|
+
|
|
92
99
|
try {
|
|
93
100
|
if (await fs.pathExists(outputPath)) {
|
|
94
101
|
logger.info(`Removing existing directory: ${outputPath}`);
|
|
95
102
|
await fs.remove(outputPath);
|
|
96
103
|
}
|
|
97
104
|
} catch (cleanupError) {
|
|
98
|
-
logger.warn(
|
|
105
|
+
logger.warn(
|
|
106
|
+
`Warning: Could not clean up existing directory ${outputPath}:`,
|
|
107
|
+
cleanupError
|
|
108
|
+
);
|
|
99
109
|
// Continue anyway - the write operation might still succeed
|
|
100
110
|
}
|
|
101
|
-
|
|
111
|
+
|
|
102
112
|
// Initialize packer and perform pack operation with file writing
|
|
103
113
|
const packer = new CouchDBToStaticPacker();
|
|
104
|
-
|
|
114
|
+
|
|
105
115
|
// For Express, we create a simple FileSystemAdapter using dynamic imports
|
|
106
116
|
const createFsAdapter = async () => {
|
|
107
117
|
const fsExtra = await import('fs-extra');
|
|
108
118
|
const pathModule = await import('path');
|
|
109
|
-
|
|
119
|
+
|
|
110
120
|
// Access the default export for fs-extra
|
|
111
121
|
const fs = fsExtra.default || fsExtra;
|
|
112
122
|
const path = pathModule.default || pathModule;
|
|
113
|
-
|
|
123
|
+
|
|
114
124
|
return {
|
|
115
125
|
async readFile(filePath: string): Promise<string> {
|
|
116
126
|
return await fs.readFile(filePath, 'utf8');
|
|
@@ -131,13 +141,20 @@ export async function packCourse(data: PackCourseData): Promise<PackCourseRespon
|
|
|
131
141
|
return {
|
|
132
142
|
isDirectory: () => stats.isDirectory(),
|
|
133
143
|
isFile: () => stats.isFile(),
|
|
134
|
-
size: stats.size
|
|
144
|
+
size: stats.size,
|
|
135
145
|
};
|
|
136
146
|
},
|
|
137
|
-
async writeFile(
|
|
147
|
+
async writeFile(
|
|
148
|
+
filePath: string,
|
|
149
|
+
data: string | Buffer
|
|
150
|
+
): Promise<void> {
|
|
138
151
|
await fs.writeFile(filePath, data as string | Uint8Array);
|
|
139
152
|
},
|
|
140
|
-
async writeJson(
|
|
153
|
+
async writeJson(
|
|
154
|
+
filePath: string,
|
|
155
|
+
data: unknown,
|
|
156
|
+
options?: { spaces?: number }
|
|
157
|
+
): Promise<void> {
|
|
141
158
|
await fs.writeJson(filePath, data, options);
|
|
142
159
|
},
|
|
143
160
|
async ensureDir(dirPath: string): Promise<void> {
|
|
@@ -151,25 +168,32 @@ export async function packCourse(data: PackCourseData): Promise<PackCourseRespon
|
|
|
151
168
|
},
|
|
152
169
|
isAbsolute(filePath: string): boolean {
|
|
153
170
|
return path.isAbsolute(filePath);
|
|
154
|
-
}
|
|
171
|
+
},
|
|
155
172
|
};
|
|
156
173
|
};
|
|
157
|
-
|
|
174
|
+
|
|
158
175
|
const fsAdapter = await createFsAdapter();
|
|
159
|
-
|
|
176
|
+
|
|
160
177
|
// Use regular PouchDB for simple data reading
|
|
161
178
|
logger.info(`Creating PouchDB instance with URL: ${courseDbUrl}`);
|
|
162
179
|
// logger.info(`PouchDB constructor available: ${typeof PouchDB}`);
|
|
163
180
|
// logger.info(`PouchDB adapters: ${JSON.stringify(Object.keys((PouchDB as any).adapters || {}))}`);
|
|
164
|
-
|
|
181
|
+
|
|
165
182
|
const courseDb = new PouchDB(courseDbUrl);
|
|
166
183
|
// logger.info(`PouchDB instance created, adapter: ${(courseDb as any).adapter}`);
|
|
167
|
-
|
|
184
|
+
|
|
168
185
|
// Extract original courseId from decorated database name for manifest generation
|
|
169
186
|
const originalCourseId = extractOriginalCourseId(data.courseId);
|
|
170
|
-
logger.info(
|
|
171
|
-
|
|
172
|
-
|
|
187
|
+
logger.info(
|
|
188
|
+
`Using originalCourseId "${originalCourseId}" for manifest (extracted from "${data.courseId}")`
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
const packResult = await packer.packCourseToFiles(
|
|
192
|
+
courseDb,
|
|
193
|
+
originalCourseId,
|
|
194
|
+
outputPath,
|
|
195
|
+
fsAdapter
|
|
196
|
+
);
|
|
173
197
|
|
|
174
198
|
// Create course-level skuilder.json for the packed course
|
|
175
199
|
const pathModule = await import('path');
|
|
@@ -181,9 +205,12 @@ export async function packCourse(data: PackCourseData): Promise<PackCourseRespon
|
|
|
181
205
|
try {
|
|
182
206
|
const manifestContent = await fsAdapter.readFile(manifestPath);
|
|
183
207
|
const manifest = JSON.parse(manifestContent);
|
|
184
|
-
courseTitle =
|
|
208
|
+
courseTitle =
|
|
209
|
+
manifest.courseName || manifest.courseConfig?.name || originalCourseId;
|
|
185
210
|
} catch (error) {
|
|
186
|
-
logger.warn(
|
|
211
|
+
logger.warn(
|
|
212
|
+
`Could not read manifest for course title, using courseId: ${error}`
|
|
213
|
+
);
|
|
187
214
|
}
|
|
188
215
|
|
|
189
216
|
// Create course-level skuilder.json
|
|
@@ -198,7 +225,9 @@ export async function packCourse(data: PackCourseData): Promise<PackCourseRespon
|
|
|
198
225
|
};
|
|
199
226
|
|
|
200
227
|
const skuilderJsonPath = path.join(outputPath, 'skuilder.json');
|
|
201
|
-
await fsAdapter.writeJson(skuilderJsonPath, courseSkuilderJson, {
|
|
228
|
+
await fsAdapter.writeJson(skuilderJsonPath, courseSkuilderJson, {
|
|
229
|
+
spaces: 2,
|
|
230
|
+
});
|
|
202
231
|
logger.info(`Created skuilder.json for course: ${originalCourseId}`);
|
|
203
232
|
|
|
204
233
|
const duration = Date.now() - startTime;
|
|
@@ -210,24 +239,27 @@ export async function packCourse(data: PackCourseData): Promise<PackCourseRespon
|
|
|
210
239
|
attachmentsFound: packResult.attachmentsFound,
|
|
211
240
|
filesWritten: packResult.filesWritten + 1, // +1 for skuilder.json
|
|
212
241
|
totalFiles: packResult.filesWritten + 1, // Updated to reflect actual files written including skuilder.json
|
|
213
|
-
duration: duration
|
|
242
|
+
duration: duration,
|
|
214
243
|
};
|
|
215
244
|
|
|
216
|
-
logger.info(
|
|
245
|
+
logger.info(
|
|
246
|
+
`Pack completed in ${duration}ms. Attachments: ${response.attachmentsFound}, Files written: ${response.filesWritten}`
|
|
247
|
+
);
|
|
217
248
|
|
|
218
249
|
return response;
|
|
219
250
|
} catch (error) {
|
|
220
251
|
logger.error('Pack operation failed:', error);
|
|
221
|
-
|
|
252
|
+
|
|
222
253
|
const response: PackCourseResponse = {
|
|
223
254
|
status: Status.error,
|
|
224
255
|
ok: false,
|
|
225
|
-
errorText:
|
|
256
|
+
errorText:
|
|
257
|
+
error instanceof Error ? error.message : 'Pack operation failed',
|
|
226
258
|
};
|
|
227
|
-
|
|
259
|
+
|
|
228
260
|
return response;
|
|
229
261
|
}
|
|
230
262
|
}
|
|
231
263
|
|
|
232
264
|
// Export types for use in app.ts
|
|
233
|
-
export type { PackCourseData, PackCourseResponse };
|
|
265
|
+
export type { PackCourseData, PackCourseResponse };
|