retold-remote 0.0.6 → 0.0.7

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.
@@ -0,0 +1,560 @@
1
+ /**
2
+ * Retold Remote -- File Operation Service
3
+ *
4
+ * Provides safe file move, directory creation, and undo capabilities.
5
+ * All paths are sanitized and resolved within the content root to
6
+ * prevent directory traversal attacks.
7
+ *
8
+ * Batch moves are recorded in Bibliograph for undo support.
9
+ *
10
+ * API:
11
+ * POST /api/files/mkdir - Create directory recursively
12
+ * POST /api/files/move - Move a single file
13
+ * POST /api/files/move-batch - Move multiple files atomically
14
+ * POST /api/files/undo-batch - Reverse a completed batch
15
+ *
16
+ * @license MIT
17
+ */
18
+ const libFableServiceProviderBase = require('fable-serviceproviderbase');
19
+ const libFs = require('fs');
20
+ const libPath = require('path');
21
+
22
+ const SOURCE_NAME = 'retold-remote-file-ops';
23
+
24
+ const _DefaultServiceConfiguration =
25
+ {
26
+ "ContentPath": ".",
27
+ "MaxBatchSize": 1000
28
+ };
29
+
30
+ class RetoldRemoteFileOperationService extends libFableServiceProviderBase
31
+ {
32
+ constructor(pFable, pOptions, pServiceHash)
33
+ {
34
+ super(pFable, pOptions, pServiceHash);
35
+
36
+ this.serviceType = 'RetoldRemoteFileOperationService';
37
+
38
+ // Merge with defaults
39
+ for (let tmpKey in _DefaultServiceConfiguration)
40
+ {
41
+ if (!(tmpKey in this.options))
42
+ {
43
+ this.options[tmpKey] = _DefaultServiceConfiguration[tmpKey];
44
+ }
45
+ }
46
+
47
+ this.contentPath = libPath.resolve(this.options.ContentPath);
48
+
49
+ this.fable.log.info('File Operation Service: initialized');
50
+ this.fable.log.info(` Content root: ${this.contentPath}`);
51
+ this.fable.log.info(` Max batch size: ${this.options.MaxBatchSize}`);
52
+ }
53
+
54
+ /**
55
+ * Sanitize a path to prevent directory traversal.
56
+ * Returns null if invalid.
57
+ *
58
+ * @param {string} pPath
59
+ * @returns {string|null}
60
+ */
61
+ _sanitizePath(pPath)
62
+ {
63
+ if (!pPath || typeof (pPath) !== 'string')
64
+ {
65
+ return null;
66
+ }
67
+
68
+ let tmpPath = decodeURIComponent(pPath).replace(/^\/+/, '');
69
+
70
+ if (tmpPath.includes('..') || libPath.isAbsolute(tmpPath))
71
+ {
72
+ return null;
73
+ }
74
+
75
+ // Resolve and verify it stays within content root
76
+ let tmpAbsPath = libPath.join(this.contentPath, tmpPath);
77
+ if (!tmpAbsPath.startsWith(this.contentPath))
78
+ {
79
+ return null;
80
+ }
81
+
82
+ return tmpPath;
83
+ }
84
+
85
+ /**
86
+ * Move a single file from source to destination within the content root.
87
+ * Creates parent directories as needed.
88
+ *
89
+ * @param {string} pSource - Relative source path
90
+ * @param {string} pDestination - Relative destination path
91
+ * @param {function} fCallback - Callback(pError, { Source, Destination })
92
+ */
93
+ moveFile(pSource, pDestination, fCallback)
94
+ {
95
+ try
96
+ {
97
+ let tmpSourceRel = this._sanitizePath(pSource);
98
+ let tmpDestRel = this._sanitizePath(pDestination);
99
+
100
+ if (!tmpSourceRel)
101
+ {
102
+ return fCallback(new Error('Invalid source path.'));
103
+ }
104
+ if (!tmpDestRel)
105
+ {
106
+ return fCallback(new Error('Invalid destination path.'));
107
+ }
108
+
109
+ let tmpSourceAbs = libPath.join(this.contentPath, tmpSourceRel);
110
+ let tmpDestAbs = libPath.join(this.contentPath, tmpDestRel);
111
+
112
+ if (!libFs.existsSync(tmpSourceAbs))
113
+ {
114
+ return fCallback(new Error('Source file not found: ' + tmpSourceRel));
115
+ }
116
+
117
+ // Don't overwrite existing files
118
+ if (libFs.existsSync(tmpDestAbs))
119
+ {
120
+ return fCallback(new Error('Destination already exists: ' + tmpDestRel));
121
+ }
122
+
123
+ // Create parent directory if needed
124
+ let tmpDestDir = libPath.dirname(tmpDestAbs);
125
+ if (!libFs.existsSync(tmpDestDir))
126
+ {
127
+ libFs.mkdirSync(tmpDestDir, { recursive: true });
128
+ }
129
+
130
+ // Try rename first (fast, same filesystem)
131
+ try
132
+ {
133
+ libFs.renameSync(tmpSourceAbs, tmpDestAbs);
134
+ }
135
+ catch (pRenameError)
136
+ {
137
+ // EXDEV = cross-device link; fall back to copy+unlink
138
+ if (pRenameError.code === 'EXDEV')
139
+ {
140
+ libFs.copyFileSync(tmpSourceAbs, tmpDestAbs);
141
+ libFs.unlinkSync(tmpSourceAbs);
142
+ }
143
+ else
144
+ {
145
+ throw pRenameError;
146
+ }
147
+ }
148
+
149
+ return fCallback(null, { Source: tmpSourceRel, Destination: tmpDestRel });
150
+ }
151
+ catch (pError)
152
+ {
153
+ return fCallback(pError);
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Move multiple files. Records the batch in Bibliograph for undo.
159
+ *
160
+ * @param {Array<{Source:string, Destination:string}>} pMoves
161
+ * @param {function} fCallback - Callback(pError, { BatchGUID, Completed, Failed })
162
+ */
163
+ moveBatch(pMoves, fCallback)
164
+ {
165
+ let tmpSelf = this;
166
+
167
+ if (!Array.isArray(pMoves) || pMoves.length === 0)
168
+ {
169
+ return fCallback(new Error('No moves provided.'));
170
+ }
171
+
172
+ if (pMoves.length > this.options.MaxBatchSize)
173
+ {
174
+ return fCallback(new Error(`Batch exceeds maximum size of ${this.options.MaxBatchSize}.`));
175
+ }
176
+
177
+ // Pre-validate all paths before moving anything
178
+ let tmpValidatedMoves = [];
179
+ for (let i = 0; i < pMoves.length; i++)
180
+ {
181
+ let tmpMove = pMoves[i];
182
+ let tmpSourceRel = this._sanitizePath(tmpMove.Source);
183
+ let tmpDestRel = this._sanitizePath(tmpMove.Destination);
184
+
185
+ if (!tmpSourceRel || !tmpDestRel)
186
+ {
187
+ return fCallback(new Error(`Invalid path at index ${i}.`));
188
+ }
189
+
190
+ let tmpSourceAbs = libPath.join(this.contentPath, tmpSourceRel);
191
+ if (!libFs.existsSync(tmpSourceAbs))
192
+ {
193
+ return fCallback(new Error(`Source not found at index ${i}: ${tmpSourceRel}`));
194
+ }
195
+
196
+ let tmpDestAbs = libPath.join(this.contentPath, tmpDestRel);
197
+ if (libFs.existsSync(tmpDestAbs))
198
+ {
199
+ return fCallback(new Error(`Destination already exists at index ${i}: ${tmpDestRel}`));
200
+ }
201
+
202
+ tmpValidatedMoves.push(
203
+ {
204
+ Source: tmpSourceRel,
205
+ Destination: tmpDestRel,
206
+ SourceAbs: tmpSourceAbs,
207
+ DestAbs: tmpDestAbs
208
+ });
209
+ }
210
+
211
+ // Execute moves sequentially
212
+ let tmpCompleted = [];
213
+ let tmpFailed = [];
214
+ let tmpIndex = 0;
215
+
216
+ function _moveNext()
217
+ {
218
+ if (tmpIndex >= tmpValidatedMoves.length)
219
+ {
220
+ // Record batch in Bibliograph for undo
221
+ let tmpBatchGUID = tmpSelf.fable.getUUID();
222
+ let tmpBatchRecord =
223
+ {
224
+ GUID: tmpBatchGUID,
225
+ Timestamp: new Date().toISOString(),
226
+ Moves: tmpCompleted,
227
+ FailedCount: tmpFailed.length,
228
+ Status: (tmpFailed.length === 0) ? 'completed' : 'partial'
229
+ };
230
+
231
+ tmpSelf.fable.Bibliograph.write(SOURCE_NAME, tmpBatchGUID, tmpBatchRecord,
232
+ (pWriteError) =>
233
+ {
234
+ if (pWriteError)
235
+ {
236
+ tmpSelf.fable.log.warn(`Failed to record batch: ${pWriteError.message}`);
237
+ }
238
+
239
+ return fCallback(null,
240
+ {
241
+ BatchGUID: tmpBatchGUID,
242
+ Completed: tmpCompleted,
243
+ Failed: tmpFailed,
244
+ TotalMoved: tmpCompleted.length,
245
+ TotalFailed: tmpFailed.length
246
+ });
247
+ });
248
+ return;
249
+ }
250
+
251
+ let tmpMove = tmpValidatedMoves[tmpIndex];
252
+ tmpIndex++;
253
+
254
+ try
255
+ {
256
+ // Create parent directory if needed
257
+ let tmpDestDir = libPath.dirname(tmpMove.DestAbs);
258
+ if (!libFs.existsSync(tmpDestDir))
259
+ {
260
+ libFs.mkdirSync(tmpDestDir, { recursive: true });
261
+ }
262
+
263
+ // Try rename first
264
+ try
265
+ {
266
+ libFs.renameSync(tmpMove.SourceAbs, tmpMove.DestAbs);
267
+ }
268
+ catch (pRenameError)
269
+ {
270
+ if (pRenameError.code === 'EXDEV')
271
+ {
272
+ libFs.copyFileSync(tmpMove.SourceAbs, tmpMove.DestAbs);
273
+ libFs.unlinkSync(tmpMove.SourceAbs);
274
+ }
275
+ else
276
+ {
277
+ throw pRenameError;
278
+ }
279
+ }
280
+
281
+ tmpCompleted.push({ Source: tmpMove.Source, Destination: tmpMove.Destination });
282
+ }
283
+ catch (pMoveError)
284
+ {
285
+ tmpFailed.push(
286
+ {
287
+ Source: tmpMove.Source,
288
+ Destination: tmpMove.Destination,
289
+ Error: pMoveError.message
290
+ });
291
+ }
292
+
293
+ _moveNext();
294
+ }
295
+
296
+ // Initialize Bibliograph source, then proceed
297
+ this.fable.Bibliograph.createSource(SOURCE_NAME,
298
+ (pCreateError) =>
299
+ {
300
+ // createSource may error if source exists; that's fine
301
+ _moveNext();
302
+ });
303
+ }
304
+
305
+ /**
306
+ * Undo a previously executed batch by reversing all moves.
307
+ *
308
+ * @param {string} pBatchGUID
309
+ * @param {function} fCallback - Callback(pError, { Reversed, Failed })
310
+ */
311
+ undoBatch(pBatchGUID, fCallback)
312
+ {
313
+ let tmpSelf = this;
314
+
315
+ this.fable.Bibliograph.createSource(SOURCE_NAME,
316
+ (pCreateError) =>
317
+ {
318
+ tmpSelf.fable.Bibliograph.read(SOURCE_NAME, pBatchGUID,
319
+ (pReadError, pRecord) =>
320
+ {
321
+ if (pReadError || !pRecord)
322
+ {
323
+ return fCallback(new Error('Batch not found: ' + pBatchGUID));
324
+ }
325
+
326
+ if (pRecord.Status === 'undone')
327
+ {
328
+ return fCallback(new Error('Batch already undone.'));
329
+ }
330
+
331
+ let tmpMoves = pRecord.Moves || [];
332
+ let tmpReversed = [];
333
+ let tmpFailed = [];
334
+
335
+ // Reverse in reverse order
336
+ for (let i = tmpMoves.length - 1; i >= 0; i--)
337
+ {
338
+ let tmpMove = tmpMoves[i];
339
+
340
+ try
341
+ {
342
+ let tmpCurrentAbs = libPath.join(tmpSelf.contentPath, tmpMove.Destination);
343
+ let tmpOriginalAbs = libPath.join(tmpSelf.contentPath, tmpMove.Source);
344
+
345
+ if (!libFs.existsSync(tmpCurrentAbs))
346
+ {
347
+ tmpFailed.push(
348
+ {
349
+ Source: tmpMove.Destination,
350
+ Destination: tmpMove.Source,
351
+ Error: 'File not found at current location'
352
+ });
353
+ continue;
354
+ }
355
+
356
+ // Re-create original directory if needed
357
+ let tmpOrigDir = libPath.dirname(tmpOriginalAbs);
358
+ if (!libFs.existsSync(tmpOrigDir))
359
+ {
360
+ libFs.mkdirSync(tmpOrigDir, { recursive: true });
361
+ }
362
+
363
+ try
364
+ {
365
+ libFs.renameSync(tmpCurrentAbs, tmpOriginalAbs);
366
+ }
367
+ catch (pRenameError)
368
+ {
369
+ if (pRenameError.code === 'EXDEV')
370
+ {
371
+ libFs.copyFileSync(tmpCurrentAbs, tmpOriginalAbs);
372
+ libFs.unlinkSync(tmpCurrentAbs);
373
+ }
374
+ else
375
+ {
376
+ throw pRenameError;
377
+ }
378
+ }
379
+
380
+ tmpReversed.push({ Source: tmpMove.Destination, Destination: tmpMove.Source });
381
+ }
382
+ catch (pUndoError)
383
+ {
384
+ tmpFailed.push(
385
+ {
386
+ Source: tmpMove.Destination,
387
+ Destination: tmpMove.Source,
388
+ Error: pUndoError.message
389
+ });
390
+ }
391
+ }
392
+
393
+ // Update batch record
394
+ pRecord.Status = 'undone';
395
+ pRecord.UndoneAt = new Date().toISOString();
396
+
397
+ tmpSelf.fable.Bibliograph.write(SOURCE_NAME, pBatchGUID, pRecord,
398
+ (pWriteError) =>
399
+ {
400
+ return fCallback(null,
401
+ {
402
+ Reversed: tmpReversed,
403
+ Failed: tmpFailed,
404
+ TotalReversed: tmpReversed.length,
405
+ TotalFailed: tmpFailed.length
406
+ });
407
+ });
408
+ });
409
+ });
410
+ }
411
+
412
+ /**
413
+ * Wire REST endpoints.
414
+ *
415
+ * @param {object} pServiceServer - The Orator service server
416
+ */
417
+ connectRoutes(pServiceServer)
418
+ {
419
+ let tmpSelf = this;
420
+ let tmpServer = pServiceServer.server;
421
+
422
+ // --- POST /api/files/mkdir ---
423
+ tmpServer.post('/api/files/mkdir',
424
+ (pRequest, pResponse, fNext) =>
425
+ {
426
+ try
427
+ {
428
+ let tmpPath = tmpSelf._sanitizePath(pRequest.body && pRequest.body.Path);
429
+
430
+ if (!tmpPath)
431
+ {
432
+ pResponse.send(400, { Success: false, Error: 'Invalid path.' });
433
+ return fNext();
434
+ }
435
+
436
+ let tmpAbsPath = libPath.join(tmpSelf.contentPath, tmpPath);
437
+
438
+ if (libFs.existsSync(tmpAbsPath))
439
+ {
440
+ pResponse.send(200, { Success: true, Path: tmpPath, AlreadyExists: true });
441
+ return fNext();
442
+ }
443
+
444
+ libFs.mkdirSync(tmpAbsPath, { recursive: true });
445
+ pResponse.send(200, { Success: true, Path: tmpPath });
446
+ return fNext();
447
+ }
448
+ catch (pError)
449
+ {
450
+ pResponse.send(500, { Success: false, Error: pError.message });
451
+ return fNext();
452
+ }
453
+ });
454
+
455
+ // --- POST /api/files/move ---
456
+ tmpServer.post('/api/files/move',
457
+ (pRequest, pResponse, fNext) =>
458
+ {
459
+ try
460
+ {
461
+ let tmpSource = pRequest.body && pRequest.body.Source;
462
+ let tmpDest = pRequest.body && pRequest.body.Destination;
463
+
464
+ tmpSelf.moveFile(tmpSource, tmpDest,
465
+ (pError, pResult) =>
466
+ {
467
+ if (pError)
468
+ {
469
+ pResponse.send(400, { Success: false, Error: pError.message });
470
+ return fNext();
471
+ }
472
+ pResponse.send(200, { Success: true, Source: pResult.Source, Destination: pResult.Destination });
473
+ return fNext();
474
+ });
475
+ }
476
+ catch (pError)
477
+ {
478
+ pResponse.send(500, { Success: false, Error: pError.message });
479
+ return fNext();
480
+ }
481
+ });
482
+
483
+ // --- POST /api/files/move-batch ---
484
+ tmpServer.post('/api/files/move-batch',
485
+ (pRequest, pResponse, fNext) =>
486
+ {
487
+ try
488
+ {
489
+ let tmpMoves = pRequest.body && pRequest.body.Moves;
490
+
491
+ tmpSelf.moveBatch(tmpMoves,
492
+ (pError, pResult) =>
493
+ {
494
+ if (pError)
495
+ {
496
+ pResponse.send(400, { Success: false, Error: pError.message });
497
+ return fNext();
498
+ }
499
+ pResponse.send(200,
500
+ {
501
+ Success: true,
502
+ BatchGUID: pResult.BatchGUID,
503
+ TotalMoved: pResult.TotalMoved,
504
+ TotalFailed: pResult.TotalFailed,
505
+ Completed: pResult.Completed,
506
+ Failed: pResult.Failed
507
+ });
508
+ return fNext();
509
+ });
510
+ }
511
+ catch (pError)
512
+ {
513
+ pResponse.send(500, { Success: false, Error: pError.message });
514
+ return fNext();
515
+ }
516
+ });
517
+
518
+ // --- POST /api/files/undo-batch ---
519
+ tmpServer.post('/api/files/undo-batch',
520
+ (pRequest, pResponse, fNext) =>
521
+ {
522
+ try
523
+ {
524
+ let tmpBatchGUID = pRequest.body && pRequest.body.BatchGUID;
525
+
526
+ if (!tmpBatchGUID)
527
+ {
528
+ pResponse.send(400, { Success: false, Error: 'Missing BatchGUID.' });
529
+ return fNext();
530
+ }
531
+
532
+ tmpSelf.undoBatch(tmpBatchGUID,
533
+ (pError, pResult) =>
534
+ {
535
+ if (pError)
536
+ {
537
+ pResponse.send(400, { Success: false, Error: pError.message });
538
+ return fNext();
539
+ }
540
+ pResponse.send(200,
541
+ {
542
+ Success: true,
543
+ TotalReversed: pResult.TotalReversed,
544
+ TotalFailed: pResult.TotalFailed,
545
+ Reversed: pResult.Reversed,
546
+ Failed: pResult.Failed
547
+ });
548
+ return fNext();
549
+ });
550
+ }
551
+ catch (pError)
552
+ {
553
+ pResponse.send(500, { Success: false, Error: pError.message });
554
+ return fNext();
555
+ }
556
+ });
557
+ }
558
+ }
559
+
560
+ module.exports = RetoldRemoteFileOperationService;
@@ -254,6 +254,7 @@ class RetoldRemoteMediaService extends libFableServiceProviderBase
254
254
  tmpProbe.Height = pMetadata.height;
255
255
  tmpProbe.Codec = pMetadata.codec;
256
256
  tmpProbe.Bitrate = pMetadata.bitrate;
257
+ tmpProbe.Tags = pMetadata.tags || {};
257
258
  }
258
259
  pResponse.send(tmpProbe);
259
260
  return fNext();
@@ -491,6 +492,17 @@ class RetoldRemoteMediaService extends libFableServiceProviderBase
491
492
  {
492
493
  tmpResult.duration = parseFloat(tmpData.format.duration) || null;
493
494
  tmpResult.bitrate = parseInt(tmpData.format.bit_rate, 10) || null;
495
+
496
+ // Extract format-level tags (ID3, Vorbis comments, etc.)
497
+ if (tmpData.format.tags)
498
+ {
499
+ tmpResult.tags = {};
500
+ let tmpTagKeys = Object.keys(tmpData.format.tags);
501
+ for (let t = 0; t < tmpTagKeys.length; t++)
502
+ {
503
+ tmpResult.tags[tmpTagKeys[t].toLowerCase()] = tmpData.format.tags[tmpTagKeys[t]];
504
+ }
505
+ }
494
506
  }
495
507
 
496
508
  // Find video stream for dimensions