• 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

67.24
/packages/h5p-server/src/implementation/fs/FileLibraryStorage.ts
1
import { Readable } from 'stream';
2
import { getAllFiles } from 'get-all-files';
38✔
3
import path from 'path';
38✔
4
import promisepipe from 'promisepipe';
38✔
5
import upath from 'upath';
38✔
6
import { createReadStream, createWriteStream, mkdirSync } from 'fs';
38✔
7
import { access, mkdir, readdir, rm, stat, writeFile } from 'fs/promises';
38✔
8

9
import { checkFilename } from './filenameUtils';
38✔
10
import {
11
    IFileStats,
12
    IAdditionalLibraryMetadata,
13
    IInstalledLibrary,
14
    ILibraryMetadata,
15
    ILibraryName,
16
    ILibraryStorage
17
} from '../../types';
18
import { streamToString } from '../../helpers/StreamHelpers';
38✔
19
import H5pError from '../../helpers/H5pError';
38✔
20
import InstalledLibrary from '../../InstalledLibrary';
38✔
21
import LibraryName from '../../LibraryName';
38✔
22
/**
23
 * Stores libraries in a directory.
24
 */
25

26
export default class FileLibraryStorage implements ILibraryStorage {
38✔
27
    /**
28
     * Gets the directory path of the specified library.
29
     * @param library
30
     * @returns the absolute path to the directory
31
     */
32
    protected getDirectoryPath(library: ILibraryName): string {
33
        return path.join(
3,958✔
34
            this.getLibrariesDirectory(),
35
            LibraryName.toUberName(library)
36
        );
37
    }
38

39
    /**
40
     * Gets the path of any file of the specified library.
41
     * @param library
42
     * @param filename
43
     * @returns the absolute path to the file
44
     */
45
    protected getFilePath(library: ILibraryName, filename: string): string {
46
        return path.join(
344,644✔
47
            this.getLibrariesDirectory(),
48
            LibraryName.toUberName(library),
49
            filename
50
        );
51
    }
52

53
    /**
54
     * Get the base path of the libraries
55
     * @returns the base library path
56
     */
57
    protected getLibrariesDirectory(): string {
58
        return this.librariesDirectory;
354,218✔
59
    }
60

61
    /**
62
     * Files with this pattern are not returned when listing the directory contents. Can be used by classes
63
     * extending FileLibraryStorage to hide internals.
64
     */
65
    protected ignoredFilePatterns: RegExp[] = [];
290✔
66
    /**
67
     * @param librariesDirectory The path of the directory in the file system at which libraries are stored.
68
     */
69
    constructor(protected librariesDirectory: string) {
290✔
70
        mkdirSync(librariesDirectory, { recursive: true });
290✔
71
    }
72

73
    /**
74
     * Adds a library file to a library. The library metadata must have been installed with installLibrary(...) first.
75
     * Throws an error if something unexpected happens.
76
     * @param library The library that is being installed
77
     * @param filename Filename of the file to add, relative to the library root
78
     * @param stream The stream containing the file content
79
     * @returns true if successful
80
     */
81
    public async addFile(
82
        library: ILibraryName,
83
        filename: string,
84
        stream: Readable
85
    ): Promise<boolean> {
86
        checkFilename(filename);
61,874✔
87
        if (!(await this.isInstalled(library))) {
61,874!
88
            throw new H5pError(
×
89
                'storage-file-implementations:add-library-file-not-installed',
90
                { filename, libraryName: LibraryName.toUberName(library) },
91
                500
92
            );
93
        }
94

95
        const fullPath = this.getFilePath(library, filename);
61,874✔
96
        await mkdir(path.dirname(fullPath), { recursive: true });
61,874✔
97
        const writeStream = createWriteStream(fullPath);
61,874✔
98
        await promisepipe(stream, writeStream);
61,874✔
99

100
        return true;
61,874✔
101
    }
102

103
    /**
104
     * Adds the metadata of the library to the repository.
105
     * Throws errors if something goes wrong.
106
     * @param libraryMetadata The library metadata object (= content of library.json)
107
     * @param restricted True if the library can only be used be users allowed to install restricted libraries.
108
     * @returns The newly created library object to use when adding library files with addFile(...)
109
     */
110
    public async addLibrary(
111
        libraryMetadata: ILibraryMetadata,
112
        restricted: boolean = false
×
113
    ): Promise<IInstalledLibrary> {
114
        const library = new InstalledLibrary(
2,388✔
115
            libraryMetadata.machineName,
116
            libraryMetadata.majorVersion,
117
            libraryMetadata.minorVersion,
118
            libraryMetadata.patchVersion,
119
            restricted
120
        );
121

122
        const libPath = this.getDirectoryPath(library);
2,388✔
123
        try {
2,388✔
124
            await access(libPath);
2,388✔
UNCOV
125
            throw new H5pError(
×
126
                'storage-file-implementations:install-library-already-installed',
127
                {
128
                    libraryName: LibraryName.toUberName(library)
129
                }
130
            );
131
        } catch {
132
            // Do nothing
133
        }
134

135
        try {
2,388✔
136
            await mkdir(libPath, { recursive: true });
2,388✔
137
            await writeFile(
2,388✔
138
                this.getFilePath(library, 'library.json'),
139
                JSON.stringify(libraryMetadata)
140
            );
141
            return library;
2,388✔
142
        } catch (error) {
NEW
143
            await rm(libPath, { recursive: true, force: true });
×
144
            throw error;
×
145
        }
146
    }
147

148
    /**
149
     * Removes all files of a library. Doesn't delete the library metadata. (Used when updating libraries.)
150
     * @param library the library whose files should be deleted
151
     * @returns
152
     */
153
    public async clearFiles(library: ILibraryName): Promise<void> {
154
        if (!(await this.isInstalled(library))) {
6!
155
            throw new H5pError(
×
156
                'storage-file-implementations:clear-library-not-found',
157
                {
158
                    libraryName: LibraryName.toUberName(library)
159
                }
160
            );
161
        }
162
        const fullLibraryPath = this.getDirectoryPath(library);
6✔
163
        const directoryEntries = (await readdir(fullLibraryPath)).filter(
6✔
164
            (entry) => entry !== 'library.json'
30✔
165
        );
166
        await Promise.all(
6✔
167
            directoryEntries.map((entry) =>
168
                rm(this.getFilePath(library, entry), {
24✔
169
                    recursive: true,
170
                    force: true
171
                })
172
            )
173
        );
174
    }
175

176
    /**
177
     * Removes the library and all its files from the repository.
178
     * Throws errors if something went wrong.
179
     * @param library The library to remove.
180
     * @returns
181
     */
182
    public async deleteLibrary(library: ILibraryName): Promise<void> {
183
        const libPath = this.getDirectoryPath(library);
6✔
184
        try {
6✔
185
            await access(libPath);
6✔
186
        } catch {
UNCOV
187
            throw new H5pError(
×
188
                'storage-file-implementations:remove-library-library-missing',
189
                { libraryName: LibraryName.toUberName(library) },
190
                404
191
            );
192
        }
193
        await rm(libPath, { recursive: true, force: true });
6✔
194
    }
195

196
    /**
197
     * Check if the library contains a file
198
     * @param library The library to check
199
     * @param filename
200
     * @returns true if file exists in library, false otherwise
201
     */
202
    public async fileExists(
203
        library: ILibraryName,
204
        filename: string
205
    ): Promise<boolean> {
206
        checkFilename(filename);
89,702✔
207
        if (this.isIgnored(filename)) {
89,702!
208
            return false;
×
209
        }
210

211
        try {
89,702✔
212
            await access(this.getFilePath(library, filename));
89,702✔
213
            return true;
89,660✔
214
        } catch {
215
            return false;
40✔
216
        }
217
    }
218

219
    /**
220
     * Counts how often libraries are listed in the dependencies of other
221
     * libraries and returns a list of the number.
222
     * @returns an object with ubernames as key.
223
     * Example:
224
     * {
225
     *   'H5P.Example': 10
226
     * }
227
     * This means that H5P.Example is used by 10 other libraries.
228
     */
229
    public async getAllDependentsCount(): Promise<{
230
        [ubername: string]: number;
231
    }> {
232
        const librariesNames = await this.getInstalledLibraryNames();
×
233
        const librariesMetadata = await Promise.all(
×
234
            librariesNames.map((lib) => this.getLibrary(lib))
×
235
        );
236

237
        // the metadata map allows faster access to libraries by ubername
238
        const librariesMetadataMap: {
239
            [ubername: string]: IInstalledLibrary;
240
        } = librariesMetadata.reduce((prev, curr) => {
×
241
            prev[LibraryName.toUberName(curr)] = curr;
×
242
            return prev;
×
243
        }, {});
244

245
        // Remove circular dependencies caused by editor dependencies in
246
        // content types like H5P.InteractiveVideo.
247
        for (const libraryMetadata of librariesMetadata) {
×
248
            for (const dependency of libraryMetadata.editorDependencies ?? []) {
×
249
                const ubername = LibraryName.toUberName(dependency);
×
250
                const index = librariesMetadataMap[
×
251
                    ubername
252
                ]?.preloadedDependencies?.findIndex((ln) =>
253
                    LibraryName.equal(ln, libraryMetadata)
×
254
                );
255
                if (index >= 0) {
×
256
                    librariesMetadataMap[ubername].preloadedDependencies.splice(
×
257
                        index,
258
                        1
259
                    );
260
                }
261
            }
262
        }
263

264
        // Count dependencies
265
        const dependencies = {};
×
266
        for (const libraryMetadata of librariesMetadata) {
×
267
            for (const dependency of (
×
268
                libraryMetadata.preloadedDependencies ?? []
×
269
            )
270
                .concat(libraryMetadata.editorDependencies ?? [])
×
271
                .concat(libraryMetadata.dynamicDependencies ?? [])) {
×
272
                const ubername = LibraryName.toUberName(dependency);
×
273
                dependencies[ubername] = (dependencies[ubername] ?? 0) + 1;
×
274
            }
275
        }
276

277
        return dependencies;
×
278
    }
279

280
    /**
281
     * Returns the number of libraries that depend on this (single) library.
282
     * @param library the library to check
283
     * @returns the number of libraries that depend on this library.
284
     */
285
    public async getDependentsCount(library: ILibraryName): Promise<number> {
286
        const allDependencies = await this.getAllDependentsCount();
×
287
        return allDependencies[LibraryName.toUberName(library)] ?? 0;
×
288
    }
289

290
    public async getFileAsJson(
291
        library: ILibraryName,
292
        file: string
293
    ): Promise<any> {
294
        const str = await this.getFileAsString(library, file);
6,736✔
295
        return JSON.parse(str);
6,734✔
296
    }
297

298
    public async getFileAsString(
299
        library: ILibraryName,
300
        file: string
301
    ): Promise<string> {
302
        const stream: Readable = await this.getFileStream(library, file);
6,748✔
303
        return streamToString(stream);
6,740✔
304
    }
305

306
    /**
307
     * Returns a information about a library file.
308
     * Throws an exception if the file does not exist.
309
     * @param library library
310
     * @param filename the relative path inside the library
311
     * @returns the file stats
312
     */
313
    public async getFileStats(
314
        library: ILibraryName,
315
        filename: string
316
    ): Promise<IFileStats> {
317
        checkFilename(filename);
2✔
318
        if (
2!
319
            !(await this.fileExists(library, filename)) ||
2✔
320
            this.isIgnored(filename)
321
        ) {
322
            throw new H5pError(
×
323
                'library-file-missing',
324
                {
325
                    filename,
326
                    library: LibraryName.toUberName(library)
327
                },
328
                404
329
            );
330
        }
331
        return stat(this.getFilePath(library, filename));
2✔
332
    }
333

334
    /**
335
     * Returns a readable stream of a library file's contents.
336
     * Throws an exception if the file does not exist.
337
     * @param library library
338
     * @param filename the relative path inside the library
339
     * @returns a readable stream of the file's contents
340
     */
341
    public async getFileStream(
342
        library: ILibraryName,
343
        filename: string
344
    ): Promise<Readable> {
345
        checkFilename(filename);
82,094✔
346
        if (
82,094✔
347
            !(await this.fileExists(library, filename)) ||
82,090✔
348
            this.isIgnored(filename)
349
        ) {
350
            throw new H5pError(
6✔
351
                'library-file-missing',
352
                {
353
                    filename,
354
                    library: LibraryName.toUberName(library)
355
                },
356
                404
357
            );
358
        }
359
        return createReadStream(this.getFilePath(library, filename));
82,086✔
360
    }
361

362
    /**
363
     * Returns all installed libraries or the installed libraries that have the
364
     * machine names.
365
     * @param machineName (optional) only return libraries that have this
366
     * machine name
367
     * @returns the libraries installed
368
     */
369
    public async getInstalledLibraryNames(
370
        machineName?: string
371
    ): Promise<ILibraryName[]> {
372
        const nameRegex = /^([\w.]+)-(\d+)\.(\d+)$/i;
5,616✔
373
        const libraryDirectories = await readdir(this.getLibrariesDirectory());
5,616✔
374
        return libraryDirectories
5,616✔
375
            .filter((name) => nameRegex.test(name))
558,710✔
376
            .map((name) => LibraryName.fromUberName(name))
558,598✔
377
            .filter((lib) => !machineName || lib.machineName === machineName);
558,598✔
378
    }
379

380
    /**
381
     * Gets a list of installed language files for the library.
382
     * @param library The library to get the languages for
383
     * @returns The list of JSON files in the language folder (without the extension .json)
384
     */
385
    public async getLanguages(library: ILibraryName): Promise<string[]> {
386
        const files = await readdir(this.getFilePath(library, 'language'));
4✔
387
        return files
2✔
388
            .filter((file) => path.extname(file) === '.json')
4✔
389
            .map((file) => path.basename(file, '.json'));
4✔
390
    }
391

392
    /**
393
     * Gets the library metadata (= content of library.json) of the library.
394
     * @param library the library
395
     * @returns the metadata
396
     */
397
    public async getLibrary(library: ILibraryName): Promise<IInstalledLibrary> {
398
        if (!(await this.isInstalled(library))) {
34,390✔
399
            throw new H5pError(
2✔
400
                'storage-file-implementations:get-library-metadata-not-installed',
401
                { libraryName: LibraryName.toUberName(library) },
402
                404
403
            );
404
        }
405
        return InstalledLibrary.fromMetadata(
34,388✔
406
            JSON.parse(
407
                await streamToString(
408
                    await this.getFileStream(library, 'library.json')
409
                )
410
            )
411
        );
412
    }
413

414
    /**
415
     * Checks if a library is installed in the system.
416
     * @param library the library to check
417
     * @returns true if installed, false if not
418
     */
419
    public async isInstalled(library: ILibraryName): Promise<boolean> {
420
        try {
108,554✔
421
            await access(this.getFilePath(library, 'library.json'));
108,554✔
422
            return true;
103,826✔
423
        } catch {
424
            return false;
4,728✔
425
        }
426
    }
427

428
    /**
429
     * Returns a list of library addons that are installed in the system.
430
     * Addons are libraries that have the property 'addTo' in their metadata.
431
     */
432
    public async listAddons(): Promise<ILibraryMetadata[]> {
433
        const installedLibraries = await this.getInstalledLibraryNames();
6✔
434
        return (
6✔
435
            await Promise.all(
436
                installedLibraries.map((addonName) =>
437
                    this.getLibrary(addonName)
10✔
438
                )
439
            )
440
        ).filter((library) => library.addTo !== undefined);
10✔
441
    }
442

443
    /**
444
     * Gets a list of all library files that exist for this library.
445
     * @param library
446
     * @returns all files that exist for the library
447
     */
448
    public async listFiles(library: ILibraryName): Promise<string[]> {
449
        const libPath = this.getDirectoryPath(library);
1,550✔
450
        const libPathLength = libPath.length + 1;
1,550✔
451
        return (await getAllFiles(libPath).toArray())
1,550✔
452
            .map((p) => p.substr(libPathLength))
40,994✔
453
            .filter((p) => !this.isIgnored(p))
40,994✔
454
            .map((p) => upath.toUnix(p))
40,994✔
455
            .sort();
456
    }
457

458
    /**
459
     * Updates the additional metadata properties that is added to the
460
     * stored libraries. This metadata can be used to customize behavior like
461
     * restricting libraries to specific users.
462
     * @param library the library for which the metadata should be updated
463
     * @param additionalMetadata the metadata to update
464
     * @returns true if the additionalMetadata object contained real changes
465
     * and if they were successfully saved; false if there were not changes.
466
     * Throws an error if saving was not possible.
467
     */
468
    public async updateAdditionalMetadata(
469
        library: ILibraryName,
470
        additionalMetadata: Partial<IAdditionalLibraryMetadata>
471
    ): Promise<boolean> {
472
        const metadata = await this.getLibrary(library);
2✔
473

474
        // We set dirty to true if there is an actual update in the
475
        // additional metadata.
476
        let dirty = false;
2✔
477
        for (const property of Object.keys(additionalMetadata)) {
2✔
478
            if (additionalMetadata[property] !== metadata[property]) {
2✔
479
                metadata[property] = additionalMetadata[property];
2✔
480
                dirty = true;
2✔
481
            }
482
        }
483
        if (!dirty) {
2!
484
            return false;
×
485
        }
486

487
        try {
2✔
488
            await writeFile(
2✔
489
                this.getFilePath(library, 'library.json'),
490
                JSON.stringify(metadata)
491
            );
492
            return true;
2✔
493
        } catch (error) {
494
            throw new H5pError(
×
495
                'storage-file-implementations:error-updating-metadata',
496
                {
497
                    library: LibraryName.toUberName(library),
498
                    error: error.message
499
                },
500
                500
501
            );
502
        }
503
    }
504

505
    /**
506
     * Updates the library metadata.
507
     * This is necessary when updating to a new patch version.
508
     * You also need to call clearFiles(...) to remove all old files
509
     * during the update process and addFile(...) to add the files of
510
     * the patch.
511
     * @param libraryMetadata the new library metadata
512
     * @returns The updated library object
513
     */
514
    public async updateLibrary(
515
        libraryMetadata: ILibraryMetadata
516
    ): Promise<IInstalledLibrary> {
517
        const libPath = this.getDirectoryPath(libraryMetadata);
8✔
518
        try {
8✔
519
            await access(libPath);
8✔
520
        } catch {
UNCOV
521
            throw new H5pError(
×
522
                'storage-file-implementations:update-library-library-missing',
523
                { libraryName: LibraryName.toUberName(libraryMetadata) },
524
                404
525
            );
526
        }
527
        await writeFile(
8✔
528
            this.getFilePath(libraryMetadata, 'library.json'),
529
            JSON.stringify(libraryMetadata)
530
        );
531
        const newLibrary = InstalledLibrary.fromMetadata(libraryMetadata);
8✔
532
        return newLibrary;
8✔
533
    }
534

535
    /**
536
     * Checks if a filename is in the ignore list.
537
     * @param filename the filename to check
538
     */
539
    private isIgnored(filename: string): boolean {
540
        return this.ignoredFilePatterns.some((pattern) =>
212,784✔
541
            pattern.test(filename)
×
542
        );
543
    }
544
}
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