retold-remote 0.0.1 → 0.0.2

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.
Files changed (33) hide show
  1. package/html/index.html +2 -0
  2. package/package.json +20 -14
  3. package/source/Pict-Application-RetoldRemote.js +46 -5
  4. package/source/cli/RetoldRemote-CLI-Run.js +0 -0
  5. package/source/cli/RetoldRemote-Server-Setup.js +790 -8
  6. package/source/cli/commands/RetoldRemote-Command-Serve.js +34 -1
  7. package/source/providers/Pict-Provider-GalleryFilterSort.js +61 -9
  8. package/source/providers/Pict-Provider-GalleryNavigation.js +517 -18
  9. package/source/providers/Pict-Provider-RetoldRemote.js +11 -2
  10. package/source/providers/Pict-Provider-RetoldRemoteIcons.js +1 -0
  11. package/source/server/RetoldRemote-ArchiveService.js +830 -0
  12. package/source/server/RetoldRemote-AudioWaveformService.js +673 -0
  13. package/source/server/RetoldRemote-EbookService.js +242 -0
  14. package/source/server/RetoldRemote-MediaService.js +1 -1
  15. package/source/server/RetoldRemote-ToolDetector.js +31 -1
  16. package/source/server/RetoldRemote-VideoFrameService.js +486 -0
  17. package/source/views/PictView-Remote-AudioExplorer.js +1213 -0
  18. package/source/views/PictView-Remote-Gallery.js +141 -2
  19. package/source/views/PictView-Remote-Layout.js +18 -27
  20. package/source/views/PictView-Remote-MediaViewer.js +638 -39
  21. package/source/views/PictView-Remote-SettingsPanel.js +23 -0
  22. package/source/views/PictView-Remote-TopBar.js +121 -0
  23. package/source/views/PictView-Remote-VideoExplorer.js +1229 -0
  24. package/web-application/index.html +2 -0
  25. package/web-application/js/epub.min.js +1 -0
  26. package/web-application/retold-remote.js +7030 -1244
  27. package/web-application/retold-remote.js.map +1 -1
  28. package/web-application/retold-remote.min.js +13 -44
  29. package/web-application/retold-remote.min.js.map +1 -1
  30. package/web-application/retold-remote.compatible.js +0 -5764
  31. package/web-application/retold-remote.compatible.js.map +0 -1
  32. package/web-application/retold-remote.compatible.min.js +0 -120
  33. package/web-application/retold-remote.compatible.min.js.map +0 -1
@@ -0,0 +1,830 @@
1
+ /**
2
+ * Retold Remote -- Archive Service
3
+ *
4
+ * Provides transparent browsing of archive files (zip, 7z, rar, tar.*).
5
+ * When 7z (p7zip) is available, it is used for listing and extraction.
6
+ * Otherwise, falls back to yauzl for .zip files only.
7
+ *
8
+ * Archives are treated as navigable containers — their contents appear
9
+ * as standard file entries that the gallery and viewer can consume.
10
+ *
11
+ * Extracted files are cached under dist/retold-cache/archives/<hash>/
12
+ * so repeated access is fast. The cache key includes the archive's mtime
13
+ * so modifications automatically invalidate the cache.
14
+ *
15
+ * @license MIT
16
+ */
17
+ const libFableServiceProviderBase = require('fable-serviceproviderbase');
18
+ const libFs = require('fs');
19
+ const libPath = require('path');
20
+ const libCrypto = require('crypto');
21
+ const libChildProcess = require('child_process');
22
+
23
+ const libToolDetector = require('./RetoldRemote-ToolDetector.js');
24
+
25
+ // Multi-segment extensions must come first so they match before single-segment ones
26
+ const _ArchiveExtensions = ['.tar.gz', '.tar.bz2', '.tar.xz', '.tgz', '.zip', '.7z', '.rar', '.tar', '.cbz', '.cbr'];
27
+
28
+ // Extensions that the native yauzl fallback can handle (cbz is zip-based)
29
+ const _NativeZipExtensions = { '.zip': true, '.cbz': true };
30
+
31
+ // Quick lookup set for isArchiveFile()
32
+ const _ArchiveExtensionSet = {};
33
+ for (let i = 0; i < _ArchiveExtensions.length; i++)
34
+ {
35
+ _ArchiveExtensionSet[_ArchiveExtensions[i]] = true;
36
+ }
37
+
38
+ // Common MIME types for serving extracted files
39
+ const _MimeTypes =
40
+ {
41
+ '.html': 'text/html', '.htm': 'text/html',
42
+ '.css': 'text/css', '.js': 'application/javascript',
43
+ '.json': 'application/json', '.xml': 'application/xml',
44
+ '.txt': 'text/plain', '.md': 'text/plain',
45
+ '.csv': 'text/csv',
46
+ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
47
+ '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml',
48
+ '.bmp': 'image/bmp', '.ico': 'image/x-icon',
49
+ '.avif': 'image/avif', '.tiff': 'image/tiff', '.tif': 'image/tiff',
50
+ '.heic': 'image/heic', '.heif': 'image/heif',
51
+ '.mp4': 'video/mp4', '.webm': 'video/webm', '.mov': 'video/quicktime',
52
+ '.mkv': 'video/x-matroska', '.avi': 'video/x-msvideo',
53
+ '.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.ogg': 'audio/ogg',
54
+ '.flac': 'audio/flac', '.aac': 'audio/aac', '.m4a': 'audio/mp4',
55
+ '.pdf': 'application/pdf', '.zip': 'application/zip',
56
+ '.7z': 'application/x-7z-compressed', '.rar': 'application/x-rar-compressed',
57
+ '.tar': 'application/x-tar', '.gz': 'application/gzip'
58
+ };
59
+
60
+ const _DefaultServiceConfiguration =
61
+ {
62
+ "ContentPath": ".",
63
+ "CachePath": null
64
+ };
65
+
66
+ class RetoldRemoteArchiveService extends libFableServiceProviderBase
67
+ {
68
+ constructor(pFable, pOptions, pServiceHash)
69
+ {
70
+ super(pFable, pOptions, pServiceHash);
71
+
72
+ this.serviceType = 'RetoldRemoteArchiveService';
73
+
74
+ // Merge with defaults
75
+ for (let tmpKey in _DefaultServiceConfiguration)
76
+ {
77
+ if (!(tmpKey in this.options))
78
+ {
79
+ this.options[tmpKey] = _DefaultServiceConfiguration[tmpKey];
80
+ }
81
+ }
82
+
83
+ this.contentPath = libPath.resolve(this.options.ContentPath);
84
+
85
+ this.archiveCachePath = this.options.CachePath
86
+ || libPath.join(process.cwd(), 'dist', 'retold-cache', 'archives');
87
+
88
+ // Ensure cache directory exists
89
+ if (!libFs.existsSync(this.archiveCachePath))
90
+ {
91
+ libFs.mkdirSync(this.archiveCachePath, { recursive: true });
92
+ }
93
+
94
+ // Detect 7z availability
95
+ let tmpDetector = new libToolDetector();
96
+ let tmpCapabilities = tmpDetector.detect();
97
+ this.has7z = !!tmpCapabilities.p7zip;
98
+
99
+ // Try to load yauzl
100
+ this.hasYauzl = false;
101
+ try
102
+ {
103
+ this._yauzl = require('yauzl');
104
+ this.hasYauzl = true;
105
+ }
106
+ catch (pError)
107
+ {
108
+ this._yauzl = null;
109
+ }
110
+
111
+ this.fable.log.info(`Archive Service: 7z=${this.has7z}, yauzl=${this.hasYauzl}`);
112
+ }
113
+
114
+ // ──────────────────────────────────────────────
115
+ // Path parsing
116
+ // ──────────────────────────────────────────────
117
+
118
+ /**
119
+ * Scan a relative path for an archive boundary.
120
+ *
121
+ * Walks segments of the path, checking if the accumulated path ends
122
+ * with a known archive extension. Multi-segment extensions like
123
+ * .tar.gz are tested first.
124
+ *
125
+ * @param {string} pRelativePath - The relative path to parse
126
+ * @returns {object|null} { archivePath, innerPath, extension } or null
127
+ */
128
+ parseArchivePath(pRelativePath)
129
+ {
130
+ if (!pRelativePath || typeof (pRelativePath) !== 'string')
131
+ {
132
+ return null;
133
+ }
134
+
135
+ let tmpSegments = pRelativePath.split('/');
136
+ let tmpAccumulated = '';
137
+
138
+ for (let i = 0; i < tmpSegments.length; i++)
139
+ {
140
+ tmpAccumulated = tmpAccumulated
141
+ ? (tmpAccumulated + '/' + tmpSegments[i])
142
+ : tmpSegments[i];
143
+
144
+ let tmpLower = tmpAccumulated.toLowerCase();
145
+
146
+ for (let j = 0; j < _ArchiveExtensions.length; j++)
147
+ {
148
+ if (tmpLower.endsWith(_ArchiveExtensions[j]))
149
+ {
150
+ let tmpInnerPath = tmpSegments.slice(i + 1).join('/');
151
+ return {
152
+ archivePath: tmpAccumulated,
153
+ innerPath: tmpInnerPath || '',
154
+ extension: _ArchiveExtensions[j]
155
+ };
156
+ }
157
+ }
158
+ }
159
+
160
+ return null;
161
+ }
162
+
163
+ /**
164
+ * Check if a file extension is a known archive type.
165
+ *
166
+ * @param {string} pExtension - Extension including dot (e.g. '.zip')
167
+ * @returns {boolean}
168
+ */
169
+ isArchiveFile(pExtension)
170
+ {
171
+ if (!pExtension)
172
+ {
173
+ return false;
174
+ }
175
+ let tmpExt = pExtension.toLowerCase();
176
+ // Also handle compound extensions
177
+ return !!_ArchiveExtensionSet[tmpExt];
178
+ }
179
+
180
+ /**
181
+ * Check if a given archive extension can be handled with the current tools.
182
+ *
183
+ * @param {string} pExtension - Archive extension (e.g. '.zip')
184
+ * @returns {boolean}
185
+ */
186
+ canHandle(pExtension)
187
+ {
188
+ if (this.has7z)
189
+ {
190
+ // 7z can handle all archive types
191
+ return this.isArchiveFile(pExtension);
192
+ }
193
+ // yauzl only handles .zip
194
+ return this.hasYauzl && !!_NativeZipExtensions[pExtension.toLowerCase()];
195
+ }
196
+
197
+ /**
198
+ * Get the list of supported extensions.
199
+ *
200
+ * @returns {Array} Array of extension strings
201
+ */
202
+ getSupportedExtensions()
203
+ {
204
+ if (this.has7z)
205
+ {
206
+ return _ArchiveExtensions.slice();
207
+ }
208
+ return Object.keys(_NativeZipExtensions);
209
+ }
210
+
211
+ /**
212
+ * Get MIME type for a file extension.
213
+ *
214
+ * @param {string} pExtension - Extension including dot (e.g. '.jpg')
215
+ * @returns {string}
216
+ */
217
+ getMimeType(pExtension)
218
+ {
219
+ return _MimeTypes[pExtension.toLowerCase()] || 'application/octet-stream';
220
+ }
221
+
222
+ // ──────────────────────────────────────────────
223
+ // Cache management
224
+ // ──────────────────────────────────────────────
225
+
226
+ /**
227
+ * Build a cache directory path for a given archive file.
228
+ * The key is derived from the archive path and its mtime
229
+ * so that modifications automatically invalidate the cache.
230
+ *
231
+ * @param {string} pArchiveAbsPath - Absolute path to the archive
232
+ * @returns {string} Absolute path to the cache subdirectory
233
+ */
234
+ _getArchiveCacheDir(pArchiveAbsPath)
235
+ {
236
+ let tmpMtime = 0;
237
+ try
238
+ {
239
+ let tmpStat = libFs.statSync(pArchiveAbsPath);
240
+ tmpMtime = tmpStat.mtimeMs;
241
+ }
242
+ catch (pError)
243
+ {
244
+ // Use 0 if stat fails
245
+ }
246
+
247
+ let tmpInput = `${pArchiveAbsPath}:${tmpMtime}`;
248
+ let tmpHash = libCrypto.createHash('sha256').update(tmpInput).digest('hex').substring(0, 16);
249
+ let tmpDir = libPath.join(this.archiveCachePath, tmpHash);
250
+
251
+ if (!libFs.existsSync(tmpDir))
252
+ {
253
+ libFs.mkdirSync(tmpDir, { recursive: true });
254
+ }
255
+
256
+ return tmpDir;
257
+ }
258
+
259
+ // ──────────────────────────────────────────────
260
+ // Listing
261
+ // ──────────────────────────────────────────────
262
+
263
+ /**
264
+ * List the contents of an archive, filtered to direct children
265
+ * of the given inner path.
266
+ *
267
+ * @param {string} pArchiveAbsPath - Absolute path to the archive file
268
+ * @param {string} pInnerPath - Path within the archive ('' for root)
269
+ * @param {string} pArchiveRelPath - Relative path of the archive (for building entry Paths)
270
+ * @param {Function} fCallback - Callback(pError, pFileList)
271
+ */
272
+ listContents(pArchiveAbsPath, pInnerPath, pArchiveRelPath, fCallback)
273
+ {
274
+ let tmpSelf = this;
275
+ let tmpExtension = '';
276
+
277
+ // Determine the extension of the archive
278
+ let tmpLower = pArchiveAbsPath.toLowerCase();
279
+ for (let i = 0; i < _ArchiveExtensions.length; i++)
280
+ {
281
+ if (tmpLower.endsWith(_ArchiveExtensions[i]))
282
+ {
283
+ tmpExtension = _ArchiveExtensions[i];
284
+ break;
285
+ }
286
+ }
287
+
288
+ if (!this.canHandle(tmpExtension))
289
+ {
290
+ return fCallback(new Error(`No tools available for ${tmpExtension} archives.`));
291
+ }
292
+
293
+ // Get the full listing, then filter to the requested directory
294
+ let tmpListFn = this.has7z
295
+ ? this._list7z.bind(this)
296
+ : this._listYauzl.bind(this);
297
+
298
+ tmpListFn(pArchiveAbsPath,
299
+ (pError, pAllEntries) =>
300
+ {
301
+ if (pError)
302
+ {
303
+ return fCallback(pError);
304
+ }
305
+
306
+ // Filter to direct children of pInnerPath
307
+ let tmpResult = tmpSelf._filterToDirectory(pAllEntries, pInnerPath, pArchiveRelPath);
308
+ return fCallback(null, tmpResult);
309
+ });
310
+ }
311
+
312
+ /**
313
+ * Filter a flat list of archive entries to the direct children of
314
+ * the given directory path. Synthesizes folder entries for
315
+ * intermediate directories that don't have explicit entries.
316
+ *
317
+ * @param {Array} pAllEntries - Flat list of all entries in the archive
318
+ * @param {string} pInnerPath - The directory within the archive to list
319
+ * @param {string} pArchiveRelPath - Relative archive path for building entry Paths
320
+ * @returns {Array} File entries for the requested directory
321
+ */
322
+ _filterToDirectory(pAllEntries, pInnerPath, pArchiveRelPath)
323
+ {
324
+ let tmpPrefix = pInnerPath ? (pInnerPath + '/') : '';
325
+ let tmpPrefixLen = tmpPrefix.length;
326
+
327
+ // Track which direct child names we've seen (to avoid duplicates and synthesize folders)
328
+ let tmpSeenNames = {};
329
+ let tmpResult = [];
330
+
331
+ for (let i = 0; i < pAllEntries.length; i++)
332
+ {
333
+ let tmpEntry = pAllEntries[i];
334
+ let tmpEntryPath = tmpEntry._innerPath || '';
335
+
336
+ // Skip entries that aren't under the requested directory
337
+ if (tmpPrefix && !tmpEntryPath.startsWith(tmpPrefix))
338
+ {
339
+ continue;
340
+ }
341
+
342
+ // If no prefix, skip the root directory itself (empty path)
343
+ if (!tmpPrefix && !tmpEntryPath)
344
+ {
345
+ continue;
346
+ }
347
+
348
+ // Get the portion after the prefix
349
+ let tmpRemainder = tmpEntryPath.substring(tmpPrefixLen);
350
+
351
+ // Remove trailing slash for directory entries
352
+ if (tmpRemainder.endsWith('/'))
353
+ {
354
+ tmpRemainder = tmpRemainder.substring(0, tmpRemainder.length - 1);
355
+ }
356
+
357
+ if (!tmpRemainder)
358
+ {
359
+ continue;
360
+ }
361
+
362
+ // Check if this is a direct child (no more slashes)
363
+ let tmpSlashIdx = tmpRemainder.indexOf('/');
364
+
365
+ if (tmpSlashIdx >= 0)
366
+ {
367
+ // This is a deeper entry — synthesize a folder for the first segment
368
+ let tmpFolderName = tmpRemainder.substring(0, tmpSlashIdx);
369
+ if (!tmpSeenNames[tmpFolderName])
370
+ {
371
+ tmpSeenNames[tmpFolderName] = true;
372
+ let tmpFolderInnerPath = tmpPrefix + tmpFolderName;
373
+ tmpResult.push(
374
+ {
375
+ Type: 'folder',
376
+ Name: tmpFolderName,
377
+ Path: pArchiveRelPath + '/' + tmpFolderInnerPath,
378
+ Size: 0,
379
+ Modified: tmpEntry.Modified || '',
380
+ Extension: ''
381
+ });
382
+ }
383
+ }
384
+ else
385
+ {
386
+ // Direct child
387
+ let tmpName = tmpRemainder;
388
+ if (tmpSeenNames[tmpName])
389
+ {
390
+ continue;
391
+ }
392
+ tmpSeenNames[tmpName] = true;
393
+
394
+ if (tmpEntry.Type === 'folder')
395
+ {
396
+ tmpResult.push(
397
+ {
398
+ Type: 'folder',
399
+ Name: tmpName,
400
+ Path: pArchiveRelPath + '/' + (tmpPrefix + tmpName),
401
+ Size: 0,
402
+ Modified: tmpEntry.Modified || '',
403
+ Extension: ''
404
+ });
405
+ }
406
+ else
407
+ {
408
+ let tmpExt = libPath.extname(tmpName).toLowerCase();
409
+ let tmpFileEntry =
410
+ {
411
+ Type: 'file',
412
+ Name: tmpName,
413
+ Path: pArchiveRelPath + '/' + (tmpPrefix + tmpName),
414
+ Size: tmpEntry.Size || 0,
415
+ Modified: tmpEntry.Modified || '',
416
+ Extension: tmpExt
417
+ };
418
+
419
+ // If this is itself an archive, mark it
420
+ if (this.isArchiveFile(tmpExt) && this.canHandle(tmpExt))
421
+ {
422
+ tmpFileEntry.Type = 'archive';
423
+ }
424
+
425
+ tmpResult.push(tmpFileEntry);
426
+ }
427
+ }
428
+ }
429
+
430
+ // Sort: folders first, then alphabetically
431
+ tmpResult.sort((pA, pB) =>
432
+ {
433
+ if (pA.Type === 'folder' && pB.Type !== 'folder') return -1;
434
+ if (pA.Type !== 'folder' && pB.Type === 'folder') return 1;
435
+ return pA.Name.localeCompare(pB.Name);
436
+ });
437
+
438
+ return tmpResult;
439
+ }
440
+
441
+ /**
442
+ * List archive contents using 7z.
443
+ *
444
+ * @param {string} pArchiveAbsPath - Absolute path to the archive
445
+ * @param {Function} fCallback - Callback(pError, pEntries)
446
+ */
447
+ _list7z(pArchiveAbsPath, fCallback)
448
+ {
449
+ try
450
+ {
451
+ let tmpOutput = libChildProcess.execSync(
452
+ `7z l -slt "${pArchiveAbsPath}"`,
453
+ {
454
+ maxBuffer: 50 * 1024 * 1024,
455
+ timeout: 60000,
456
+ encoding: 'utf8'
457
+ });
458
+
459
+ let tmpEntries = this._parse7zOutput(tmpOutput);
460
+ return fCallback(null, tmpEntries);
461
+ }
462
+ catch (pError)
463
+ {
464
+ return fCallback(new Error(`7z listing failed: ${pError.message}`));
465
+ }
466
+ }
467
+
468
+ /**
469
+ * Parse the structured output of `7z l -slt`.
470
+ *
471
+ * Output has blocks separated by blank lines. Each block contains
472
+ * key = value lines. We look for Path, Size, Attributes, and Modified.
473
+ *
474
+ * @param {string} pOutput - Raw stdout from 7z
475
+ * @returns {Array} Parsed entry objects with _innerPath, Type, Size, Modified
476
+ */
477
+ _parse7zOutput(pOutput)
478
+ {
479
+ let tmpEntries = [];
480
+ let tmpLines = pOutput.split('\n');
481
+ let tmpCurrent = null;
482
+ let tmpInHeader = true;
483
+
484
+ for (let i = 0; i < tmpLines.length; i++)
485
+ {
486
+ let tmpLine = tmpLines[i].trim();
487
+
488
+ if (tmpLine === '')
489
+ {
490
+ if (tmpCurrent && tmpCurrent._innerPath)
491
+ {
492
+ tmpEntries.push(tmpCurrent);
493
+ }
494
+ tmpCurrent = null;
495
+ continue;
496
+ }
497
+
498
+ // Lines before the first "----------" are header
499
+ if (tmpLine.startsWith('----------'))
500
+ {
501
+ tmpInHeader = false;
502
+ continue;
503
+ }
504
+
505
+ if (tmpInHeader)
506
+ {
507
+ continue;
508
+ }
509
+
510
+ let tmpEqIdx = tmpLine.indexOf(' = ');
511
+ if (tmpEqIdx < 0)
512
+ {
513
+ continue;
514
+ }
515
+
516
+ let tmpKey = tmpLine.substring(0, tmpEqIdx).trim();
517
+ let tmpValue = tmpLine.substring(tmpEqIdx + 3).trim();
518
+
519
+ if (!tmpCurrent)
520
+ {
521
+ tmpCurrent = { _innerPath: '', Type: 'file', Size: 0, Modified: '' };
522
+ }
523
+
524
+ switch (tmpKey)
525
+ {
526
+ case 'Path':
527
+ // Normalize separators to forward slashes
528
+ tmpCurrent._innerPath = tmpValue.replace(/\\/g, '/');
529
+ break;
530
+ case 'Size':
531
+ tmpCurrent.Size = parseInt(tmpValue, 10) || 0;
532
+ break;
533
+ case 'Attributes':
534
+ if (tmpValue.indexOf('D') >= 0)
535
+ {
536
+ tmpCurrent.Type = 'folder';
537
+ }
538
+ break;
539
+ case 'Modified':
540
+ tmpCurrent.Modified = tmpValue;
541
+ break;
542
+ }
543
+ }
544
+
545
+ // Flush the last entry
546
+ if (tmpCurrent && tmpCurrent._innerPath)
547
+ {
548
+ tmpEntries.push(tmpCurrent);
549
+ }
550
+
551
+ return tmpEntries;
552
+ }
553
+
554
+ /**
555
+ * List archive contents using yauzl (zip-only fallback).
556
+ *
557
+ * @param {string} pArchiveAbsPath - Absolute path to the zip file
558
+ * @param {Function} fCallback - Callback(pError, pEntries)
559
+ */
560
+ _listYauzl(pArchiveAbsPath, fCallback)
561
+ {
562
+ if (!this._yauzl)
563
+ {
564
+ return fCallback(new Error('yauzl is not available.'));
565
+ }
566
+
567
+ this._yauzl.open(pArchiveAbsPath, { lazyEntries: true },
568
+ (pError, pZipFile) =>
569
+ {
570
+ if (pError)
571
+ {
572
+ return fCallback(new Error(`Failed to open zip: ${pError.message}`));
573
+ }
574
+
575
+ let tmpEntries = [];
576
+
577
+ pZipFile.on('entry',
578
+ (pEntry) =>
579
+ {
580
+ let tmpPath = pEntry.fileName;
581
+ let tmpIsDir = tmpPath.endsWith('/');
582
+
583
+ tmpEntries.push(
584
+ {
585
+ _innerPath: tmpPath,
586
+ Type: tmpIsDir ? 'folder' : 'file',
587
+ Size: tmpIsDir ? 0 : (pEntry.uncompressedSize || 0),
588
+ Modified: pEntry.getLastModDate ? pEntry.getLastModDate().toISOString() : ''
589
+ });
590
+
591
+ pZipFile.readEntry();
592
+ });
593
+
594
+ pZipFile.on('end',
595
+ () =>
596
+ {
597
+ return fCallback(null, tmpEntries);
598
+ });
599
+
600
+ pZipFile.on('error',
601
+ (pZipError) =>
602
+ {
603
+ return fCallback(new Error(`Zip read error: ${pZipError.message}`));
604
+ });
605
+
606
+ pZipFile.readEntry();
607
+ });
608
+ }
609
+
610
+ // ──────────────────────────────────────────────
611
+ // Extraction
612
+ // ──────────────────────────────────────────────
613
+
614
+ /**
615
+ * Extract a single file from an archive to the cache directory.
616
+ * Returns the absolute path to the extracted file.
617
+ *
618
+ * Checks cache first — if the file is already extracted, returns
619
+ * immediately.
620
+ *
621
+ * @param {string} pArchiveAbsPath - Absolute path to the archive
622
+ * @param {string} pInnerFilePath - Path within the archive
623
+ * @param {Function} fCallback - Callback(pError, pExtractedPath)
624
+ */
625
+ extractFile(pArchiveAbsPath, pInnerFilePath, fCallback)
626
+ {
627
+ if (!pInnerFilePath)
628
+ {
629
+ return fCallback(new Error('No inner file path specified.'));
630
+ }
631
+
632
+ // Security: reject path traversal in archive entries
633
+ let tmpNormalized = libPath.normalize(pInnerFilePath);
634
+ if (tmpNormalized.startsWith('..') || libPath.isAbsolute(tmpNormalized))
635
+ {
636
+ return fCallback(new Error('Invalid inner path.'));
637
+ }
638
+
639
+ let tmpCacheDir = this._getArchiveCacheDir(pArchiveAbsPath);
640
+ let tmpOutputPath = libPath.join(tmpCacheDir, tmpNormalized);
641
+
642
+ // Security: verify the output path stays within the cache dir
643
+ let tmpResolvedOutput = libPath.resolve(tmpOutputPath);
644
+ let tmpResolvedCache = libPath.resolve(tmpCacheDir);
645
+ if (!tmpResolvedOutput.startsWith(tmpResolvedCache))
646
+ {
647
+ return fCallback(new Error('Path traversal detected in archive entry.'));
648
+ }
649
+
650
+ // Check cache
651
+ if (libFs.existsSync(tmpOutputPath))
652
+ {
653
+ return fCallback(null, tmpOutputPath);
654
+ }
655
+
656
+ // Ensure parent directory exists
657
+ let tmpParentDir = libPath.dirname(tmpOutputPath);
658
+ if (!libFs.existsSync(tmpParentDir))
659
+ {
660
+ libFs.mkdirSync(tmpParentDir, { recursive: true });
661
+ }
662
+
663
+ // Determine extension to choose extraction method
664
+ let tmpLower = pArchiveAbsPath.toLowerCase();
665
+ let tmpExtension = '';
666
+ for (let i = 0; i < _ArchiveExtensions.length; i++)
667
+ {
668
+ if (tmpLower.endsWith(_ArchiveExtensions[i]))
669
+ {
670
+ tmpExtension = _ArchiveExtensions[i];
671
+ break;
672
+ }
673
+ }
674
+
675
+ if (this.has7z)
676
+ {
677
+ return this._extract7z(pArchiveAbsPath, pInnerFilePath, tmpOutputPath, fCallback);
678
+ }
679
+ else if (this.hasYauzl && _NativeZipExtensions[tmpExtension])
680
+ {
681
+ return this._extractYauzl(pArchiveAbsPath, pInnerFilePath, tmpOutputPath, fCallback);
682
+ }
683
+ else
684
+ {
685
+ return fCallback(new Error(`No extraction tools available for ${tmpExtension}.`));
686
+ }
687
+ }
688
+
689
+ /**
690
+ * Extract a single file using 7z.
691
+ *
692
+ * @param {string} pArchiveAbsPath - Absolute path to the archive
693
+ * @param {string} pInnerFilePath - Path within the archive
694
+ * @param {string} pOutputPath - Absolute destination path
695
+ * @param {Function} fCallback - Callback(pError, pExtractedPath)
696
+ */
697
+ _extract7z(pArchiveAbsPath, pInnerFilePath, pOutputPath, fCallback)
698
+ {
699
+ try
700
+ {
701
+ // 7z x extracts with full paths; we extract to the cache dir
702
+ let tmpCacheDir = libPath.dirname(pOutputPath);
703
+
704
+ // Use 7z e (extract without paths) to a temp location, then move
705
+ // Or use 7z x to preserve directory structure
706
+ // Using x with -o to extract maintaining structure into cache
707
+ let tmpBaseDir = this._getArchiveCacheDir(pArchiveAbsPath);
708
+
709
+ libChildProcess.execSync(
710
+ `7z x "${pArchiveAbsPath}" -o"${tmpBaseDir}" "${pInnerFilePath}" -y`,
711
+ {
712
+ maxBuffer: 50 * 1024 * 1024,
713
+ timeout: 120000,
714
+ stdio: 'ignore'
715
+ });
716
+
717
+ // The file should now be at tmpBaseDir + pInnerFilePath
718
+ let tmpExtractedPath = libPath.join(tmpBaseDir, pInnerFilePath);
719
+
720
+ if (libFs.existsSync(tmpExtractedPath))
721
+ {
722
+ return fCallback(null, tmpExtractedPath);
723
+ }
724
+ else
725
+ {
726
+ return fCallback(new Error('7z extraction produced no output file.'));
727
+ }
728
+ }
729
+ catch (pError)
730
+ {
731
+ return fCallback(new Error(`7z extraction failed: ${pError.message}`));
732
+ }
733
+ }
734
+
735
+ /**
736
+ * Extract a single file using yauzl (zip-only).
737
+ *
738
+ * @param {string} pArchiveAbsPath - Absolute path to the zip file
739
+ * @param {string} pInnerFilePath - Path within the zip
740
+ * @param {string} pOutputPath - Absolute destination path
741
+ * @param {Function} fCallback - Callback(pError, pExtractedPath)
742
+ */
743
+ _extractYauzl(pArchiveAbsPath, pInnerFilePath, pOutputPath, fCallback)
744
+ {
745
+ if (!this._yauzl)
746
+ {
747
+ return fCallback(new Error('yauzl is not available.'));
748
+ }
749
+
750
+ let tmpCallbackFired = false;
751
+
752
+ function fireCallback(pError, pResult)
753
+ {
754
+ if (tmpCallbackFired) return;
755
+ tmpCallbackFired = true;
756
+ return fCallback(pError, pResult);
757
+ }
758
+
759
+ this._yauzl.open(pArchiveAbsPath, { lazyEntries: true },
760
+ (pError, pZipFile) =>
761
+ {
762
+ if (pError)
763
+ {
764
+ return fireCallback(new Error(`Failed to open zip: ${pError.message}`));
765
+ }
766
+
767
+ let tmpFound = false;
768
+
769
+ pZipFile.on('entry',
770
+ (pEntry) =>
771
+ {
772
+ // Match the requested file (normalize slashes)
773
+ let tmpEntryPath = pEntry.fileName.replace(/\\/g, '/');
774
+ let tmpTargetPath = pInnerFilePath.replace(/\\/g, '/');
775
+
776
+ if (tmpEntryPath === tmpTargetPath)
777
+ {
778
+ tmpFound = true;
779
+ pZipFile.openReadStream(pEntry,
780
+ (pStreamError, pReadStream) =>
781
+ {
782
+ if (pStreamError)
783
+ {
784
+ return fireCallback(new Error(`Zip stream error: ${pStreamError.message}`));
785
+ }
786
+
787
+ let tmpWriteStream = libFs.createWriteStream(pOutputPath);
788
+
789
+ tmpWriteStream.on('finish',
790
+ () =>
791
+ {
792
+ return fireCallback(null, pOutputPath);
793
+ });
794
+
795
+ tmpWriteStream.on('error',
796
+ (pWriteError) =>
797
+ {
798
+ return fireCallback(new Error(`Write error: ${pWriteError.message}`));
799
+ });
800
+
801
+ pReadStream.pipe(tmpWriteStream);
802
+ });
803
+ }
804
+ else
805
+ {
806
+ pZipFile.readEntry();
807
+ }
808
+ });
809
+
810
+ pZipFile.on('end',
811
+ () =>
812
+ {
813
+ if (!tmpFound)
814
+ {
815
+ return fireCallback(new Error(`File not found in archive: ${pInnerFilePath}`));
816
+ }
817
+ });
818
+
819
+ pZipFile.on('error',
820
+ (pZipError) =>
821
+ {
822
+ return fireCallback(new Error(`Zip error: ${pZipError.message}`));
823
+ });
824
+
825
+ pZipFile.readEntry();
826
+ });
827
+ }
828
+ }
829
+
830
+ module.exports = RetoldRemoteArchiveService;