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.
- package/html/index.html +2 -0
- package/package.json +20 -14
- package/source/Pict-Application-RetoldRemote.js +46 -5
- package/source/cli/RetoldRemote-CLI-Run.js +0 -0
- package/source/cli/RetoldRemote-Server-Setup.js +790 -8
- package/source/cli/commands/RetoldRemote-Command-Serve.js +34 -1
- package/source/providers/Pict-Provider-GalleryFilterSort.js +61 -9
- package/source/providers/Pict-Provider-GalleryNavigation.js +517 -18
- package/source/providers/Pict-Provider-RetoldRemote.js +11 -2
- package/source/providers/Pict-Provider-RetoldRemoteIcons.js +1 -0
- package/source/server/RetoldRemote-ArchiveService.js +830 -0
- package/source/server/RetoldRemote-AudioWaveformService.js +673 -0
- package/source/server/RetoldRemote-EbookService.js +242 -0
- package/source/server/RetoldRemote-MediaService.js +1 -1
- package/source/server/RetoldRemote-ToolDetector.js +31 -1
- package/source/server/RetoldRemote-VideoFrameService.js +486 -0
- package/source/views/PictView-Remote-AudioExplorer.js +1213 -0
- package/source/views/PictView-Remote-Gallery.js +141 -2
- package/source/views/PictView-Remote-Layout.js +18 -27
- package/source/views/PictView-Remote-MediaViewer.js +638 -39
- package/source/views/PictView-Remote-SettingsPanel.js +23 -0
- package/source/views/PictView-Remote-TopBar.js +121 -0
- package/source/views/PictView-Remote-VideoExplorer.js +1229 -0
- package/web-application/index.html +2 -0
- package/web-application/js/epub.min.js +1 -0
- package/web-application/retold-remote.js +7030 -1244
- package/web-application/retold-remote.js.map +1 -1
- package/web-application/retold-remote.min.js +13 -44
- package/web-application/retold-remote.min.js.map +1 -1
- package/web-application/retold-remote.compatible.js +0 -5764
- package/web-application/retold-remote.compatible.js.map +0 -1
- package/web-application/retold-remote.compatible.min.js +0 -120
- 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;
|