• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

Lumieducation / H5P-Nodejs-library / 02cc80d0-c897-4372-9b17-f7f98ba2398c

01 Feb 2025 06:42PM UTC coverage: 69.655% (+0.2%) from 69.415%
02cc80d0-c897-4372-9b17-f7f98ba2398c

Pull #3882

circleci

sr258
Merge remote-tracking branch 'origin/master' into refactor/remove-fs-extra
Pull Request #3882: refactor: removed fs-extra and replaced it with native Node functiona…

2458 of 4085 branches covered (60.17%)

Branch coverage included in aggregate %.

166 of 175 new or added lines in 16 files covered. (94.86%)

246 existing lines in 13 files now uncovered.

5872 of 7874 relevant lines covered (74.57%)

5828.4 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

78.57
/packages/h5p-server/src/implementation/fs/FileContentUserDataStorage.ts
1
import path from 'path';
14✔
2
import { getAllFiles } from 'get-all-files';
14✔
3
import { readFile, rm, writeFile } from 'fs/promises';
14✔
4
import { existsSync, mkdirSync } from 'fs';
14✔
5

6
import {
7
    ContentId,
8
    IContentUserData,
9
    IContentUserDataStorage,
10
    IFinishedUserData,
11
    IUser
12
} from '../../types';
13
import Logger from '../../helpers/Logger';
14✔
14
import { checkFilename, sanitizeFilename } from './filenameUtils';
14✔
15

16
const log = new Logger('FileContentUserDataStorage');
14✔
17

18
/**
19
 * Saves user data in JSON files on the disk. It creates one file per content
20
 * object. There's a separate file for user states and one for the finished data.
21
 * Each file contains a list of all states or finished data objects.
22
 */
23
export default class FileContentUserDataStorage
14✔
24
    implements IContentUserDataStorage
25
{
26
    constructor(protected directory: string) {
40✔
27
        if (!existsSync(directory)) {
40!
28
            log.debug('Creating directory', directory);
×
NEW
29
            mkdirSync(directory, { recursive: true });
×
30
        }
31
    }
32

33
    public async getContentUserData(
34
        contentId: ContentId,
35
        dataType: string,
36
        subContentId: string,
37
        userId: string,
38
        contextId?: string
39
    ): Promise<IContentUserData> {
40
        const file = this.getSafeUserDataFilePath(contentId);
22✔
41
        let dataList: IContentUserData[];
42
        try {
22✔
43
            dataList = JSON.parse(await readFile(file, 'utf-8'));
22✔
44
        } catch (error) {
45
            log.error(
2✔
46
                'getContentUserData',
47
                'Error reading file',
48
                file,
49
                'Error:',
50
                error
51
            );
52
            return null;
2✔
53
        }
54
        try {
20✔
55
            return (
20✔
56
                dataList.find(
11✔
57
                    (data) =>
58
                        data.dataType === dataType &&
28✔
59
                        data.subContentId === subContentId &&
60
                        data.userId === userId &&
61
                        data.contextId === contextId
62
                ) || null
63
            );
64
        } catch (error) {
65
            log.error(
×
66
                'getContentUserData',
67
                'Corrupt file',
68
                file,
69
                'Error:',
70
                error
71
            );
72
            return null;
×
73
        }
74
    }
75

76
    public async getContentUserDataByUser(
77
        user: IUser
78
    ): Promise<IContentUserData[]> {
79
        const files = await getAllFiles(this.directory).toArray();
14✔
80
        const result: IContentUserData[] = [];
14✔
81
        for (const file of files) {
14✔
82
            if (!file.endsWith('-userdata.json')) {
14!
83
                continue;
×
84
            }
85
            let data: IContentUserData[];
86
            try {
14✔
87
                data = JSON.parse(await readFile(file, 'utf-8'));
14✔
88
            } catch (error) {
89
                log.error(
×
90
                    'getContentUserDataByUser',
91
                    'Error reading file',
92
                    file,
93
                    'Error:',
94
                    error,
95
                    'Data in the corrupt file is not part of the list'
96
                );
97
            }
98
            try {
14✔
99
                for (const entry of data) {
14✔
100
                    if (entry.userId === user.id) {
14✔
101
                        result.push(entry);
12✔
102
                    }
103
                }
104
            } catch (error) {
105
                log.error(
×
106
                    'getContentUserDataByUser',
107
                    'Error going through data in file',
108
                    file,
109
                    'Error:',
110
                    error
111
                );
112
            }
113
        }
114
        return result;
14✔
115
    }
116

117
    public async createOrUpdateContentUserData(
118
        userData: IContentUserData
119
    ): Promise<void> {
120
        const filename = this.getSafeUserDataFilePath(userData.contentId);
56✔
121
        let oldData: IContentUserData[];
122
        try {
56✔
123
            oldData = JSON.parse(await readFile(filename, 'utf-8'));
56✔
124
        } catch (error) {
125
            log.debug(
32✔
126
                'createOrUpdateContentUserData',
127
                'Error while reading user data file for contentId',
128
                userData.contentId,
129
                '(error:',
130
                error,
131
                '). Seeding with empty list.'
132
            );
133
            oldData = [];
32✔
134
        }
135

136
        // make sure we have only one entry for contentId, dataType,
137
        // subContentId, user and contextId
138
        const newUserData = oldData.filter(
56✔
139
            (data) =>
140
                data.contentId !== userData.contentId ||
32✔
141
                data.dataType !== userData.dataType ||
142
                data.subContentId !== userData.subContentId ||
143
                data.userId !== userData.userId ||
144
                data.contextId !== userData.contextId
145
        );
146

147
        newUserData.push(userData);
56✔
148
        try {
56✔
149
            await writeFile(filename, JSON.stringify(newUserData));
56✔
150
        } catch (error) {
151
            log.error(
×
152
                'createOrUpdateContentUserData',
153
                'Error while writing user data to file for contentId',
154
                userData.contentId,
155
                'Error:',
156
                error
157
            );
158
        }
159
    }
160

161
    public async deleteInvalidatedContentUserData(
162
        contentId: string
163
    ): Promise<void> {
164
        const filename = this.getSafeUserDataFilePath(contentId);
4✔
165
        let oldData: IContentUserData[];
166
        try {
4✔
167
            oldData = JSON.parse(await readFile(filename, 'utf-8'));
4✔
168
        } catch (error) {
169
            log.debug(
2✔
170
                'deleteInvalidatedContentUserData',
171
                'Error while reading user data file for contentId',
172
                contentId,
173
                '(error:',
174
                error,
175
                '). Seeding with empty list.'
176
            );
177
            oldData = [];
2✔
178
        }
179

180
        // make sure we have only one entry for contentId, dataType, subContentId and user
181
        const newUserData = oldData.filter(
4✔
182
            (data) => data.contentId !== contentId || !data.invalidate
6✔
183
        );
184

185
        try {
4✔
186
            await writeFile(filename, JSON.stringify(newUserData));
4✔
187
        } catch (error) {
188
            log.error(
×
189
                'deleteInvalidatedContentUserData',
190
                'Error while writing user data to file for contentId',
191
                contentId,
192
                'Error:',
193
                error
194
            );
195
        }
196
    }
197

198
    public async deleteAllContentUserDataByUser(user: IUser): Promise<void> {
199
        const files = await getAllFiles(this.directory).toArray();
4✔
200
        for (const file of files) {
4✔
201
            if (!file.endsWith('-userdata.json')) {
4!
202
                continue;
×
203
            }
204
            let data: IContentUserData[];
205
            try {
4✔
206
                data = JSON.parse(await readFile(file, 'utf-8'));
4✔
207
            } catch (error) {
208
                log.error(
×
209
                    'deleteAllContentUserDataByUser',
210
                    'Error reading file',
211
                    file,
212
                    'Error:',
213
                    error,
214
                    'Data in the corrupt file is not part of the list'
215
                );
216
            }
217
            let newData: IContentUserData[];
218
            try {
4✔
219
                newData = data?.filter((d) => d.userId !== user.id);
6✔
220
            } catch (error) {
221
                log.error(
×
222
                    'deleteAllContentUserDataByUser',
223
                    'Error going through data in file',
224
                    file,
225
                    'Error:',
226
                    error
227
                );
228
            }
229
            if (newData) {
4✔
230
                try {
4✔
231
                    await writeFile(file, JSON.stringify(newData));
4✔
232
                } catch (error) {
233
                    log.error(
×
234
                        'deleteAllContentUserDataByUser',
235
                        'Error writing data to file',
236
                        file,
237
                        'Error:',
238
                        error
239
                    );
240
                }
241
            }
242
        }
243
    }
244

245
    public async deleteAllContentUserDataByContentId(
246
        contentId: ContentId
247
    ): Promise<void> {
248
        const file = this.getSafeUserDataFilePath(contentId);
4✔
249
        try {
4✔
250
            await rm(file, { recursive: true, force: true });
4✔
251
        } catch (error) {
UNCOV
252
            log.error(
×
253
                'deleteAllContentUserDataByContentId',
254
                'Could not delete file',
255
                file,
256
                'Error:',
257
                error
258
            );
259
        }
260
    }
261

262
    public async getContentUserDataByContentIdAndUser(
263
        contentId: ContentId,
264
        userId: string,
265
        contextId?: string
266
    ): Promise<IContentUserData[]> {
267
        const file = this.getSafeUserDataFilePath(contentId);
16✔
268
        let dataList: IContentUserData[];
269
        try {
16✔
270
            dataList = JSON.parse(await readFile(file, 'utf-8'));
16✔
271
        } catch (error) {
272
            log.error(
2✔
273
                'getContentUserDataByContentIdAndUser',
274
                'Error reading file',
275
                file,
276
                'Error:',
277
                error
278
            );
279
            return [];
2✔
280
        }
281

282
        try {
14✔
283
            return dataList.filter(
14✔
284
                (data) => data.userId === userId && data.contextId == contextId
12✔
285
            );
286
        } catch (error) {
287
            log.error(
×
288
                'getContentUserDataByContentIdAndUser',
289
                'Corrupt file',
290
                file
291
            );
292
            return [];
×
293
        }
294
    }
295

296
    public async createOrUpdateFinishedData(
297
        finishedData: IFinishedUserData
298
    ): Promise<void> {
299
        const filename = this.getSafeFinishedFilePath(finishedData.contentId);
22✔
300
        let oldData: IFinishedUserData[];
301
        try {
22✔
302
            oldData = JSON.parse(await readFile(filename, 'utf-8'));
22✔
303
        } catch (error) {
304
            log.debug(
12✔
305
                'createOrUpdateFinishedData',
306
                'Error while reading finished file for contentId',
307
                finishedData.contentId,
308
                '(error:',
309
                error,
310
                '). Seeding with empty list.'
311
            );
312
            oldData = [];
12✔
313
        }
314

315
        // make sure we have only one entry for user
316
        const newData = oldData.filter(
22✔
317
            (data) => data.userId !== finishedData.userId
12✔
318
        );
319

320
        newData.push(finishedData);
22✔
321

322
        try {
22✔
323
            await writeFile(filename, JSON.stringify(newData));
22✔
324
        } catch (error) {
325
            log.error(
×
326
                'createOrUpdateFinishedData',
327
                'Error while writing finished data to file for contentId',
328
                finishedData.contentId,
329
                'Error:',
330
                error
331
            );
332
        }
333
    }
334

335
    public async getFinishedDataByContentId(
336
        contentId: string
337
    ): Promise<IFinishedUserData[]> {
338
        const file = this.getSafeFinishedFilePath(contentId);
4✔
339
        let finishedList: IFinishedUserData[];
340
        try {
4✔
341
            finishedList = JSON.parse(await readFile(file, 'utf-8'));
4✔
342
        } catch (error) {
343
            log.error(
×
344
                'getFinishedDataByContentId',
345
                'Error reading file',
346
                file,
347
                'Error:',
348
                error
349
            );
350
            return undefined;
×
351
        }
352

353
        if (Array.isArray(finishedList)) {
4!
354
            return finishedList;
4✔
355
        } else {
356
            log.error('getFinishedDataByContentId', 'Corrupt file', file);
×
357
            return [];
×
358
        }
359
    }
360

361
    public async getFinishedDataByUser(
362
        user: IUser
363
    ): Promise<IFinishedUserData[]> {
364
        const files = await getAllFiles(this.directory).toArray();
8✔
365
        const result: IFinishedUserData[] = [];
8✔
366
        for (const file of files) {
8✔
367
            if (!file.endsWith('-finished.json')) {
12!
368
                continue;
×
369
            }
370
            let data: IFinishedUserData[];
371
            try {
12✔
372
                data = JSON.parse(await readFile(file, 'utf-8'));
12✔
373
            } catch (error) {
374
                log.error(
×
375
                    'getFinishedDataByUser',
376
                    'Error reading file',
377
                    file,
378
                    'Error:',
379
                    error,
380
                    'Data in the corrupt file is not part of the list'
381
                );
382
            }
383
            try {
12✔
384
                for (const entry of data) {
12✔
385
                    if (entry.userId === user.id) {
12✔
386
                        result.push(entry);
6✔
387
                    }
388
                }
389
            } catch (error) {
390
                log.error(
×
391
                    'getFinishedDataByUser',
392
                    'Error going through data in file',
393
                    file,
394
                    'Error:',
395
                    error
396
                );
397
            }
398
        }
399
        return result;
8✔
400
    }
401

402
    public async deleteFinishedDataByContentId(
403
        contentId: string
404
    ): Promise<void> {
405
        const file = this.getSafeFinishedFilePath(contentId);
4✔
406
        try {
4✔
407
            await rm(file, { recursive: true, force: true });
4✔
408
        } catch (error) {
UNCOV
409
            log.error(
×
410
                'deleteFinishedDataByContentId',
411
                'Could not delete file',
412
                file,
413
                'Error:',
414
                error
415
            );
416
        }
417
    }
418

419
    public async deleteFinishedDataByUser(user: IUser): Promise<void> {
420
        const files = await getAllFiles(this.directory).toArray();
4✔
421
        for (const file of files) {
4✔
422
            if (!file.endsWith('-finished.json')) {
4!
423
                continue;
×
424
            }
425
            let data: IFinishedUserData[];
426
            try {
4✔
427
                data = JSON.parse(await readFile(file, 'utf-8'));
4✔
428
            } catch (error) {
429
                log.error(
×
430
                    'deleteFinishedDataByUser',
431
                    'Error reading file',
432
                    file,
433
                    'Error:',
434
                    error,
435
                    'Data in the corrupt file is not part of the list'
436
                );
437
            }
438
            let newData: IFinishedUserData[];
439
            try {
4✔
440
                newData = data?.filter((d) => d.userId !== user.id);
6✔
441
            } catch (error) {
442
                log.error(
×
443
                    'deleteFinishedDataByUser',
444
                    'Error going through data in file',
445
                    file,
446
                    'Error:',
447
                    error
448
                );
449
            }
450
            if (newData) {
4✔
451
                try {
4✔
452
                    await writeFile(file, JSON.stringify(newData));
4✔
453
                } catch (error) {
454
                    log.error(
×
455
                        'deleteFinishedDataByUser',
456
                        'Error writing data to file',
457
                        file,
458
                        'Error:',
459
                        error
460
                    );
461
                }
462
            }
463
        }
464
    }
465

466
    private getSafeUserDataFilePath(contentId: string): string {
467
        checkFilename(contentId);
102✔
468
        return path.join(
102✔
469
            this.directory,
470
            sanitizeFilename(
471
                `${contentId}-userdata.json`,
472
                80,
473
                /[^A-Za-z0-9\-._]/g
474
            )
475
        );
476
    }
477

478
    private getSafeFinishedFilePath(contentId: string): string {
479
        checkFilename(contentId);
30✔
480
        return path.join(
30✔
481
            this.directory,
482
            sanitizeFilename(
483
                `${contentId}-finished.json`,
484
                80,
485
                /[^A-Za-z0-9\-._]/g
486
            )
487
        );
488
    }
489
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc