@holo-js/media 0.1.4 → 0.1.6
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/dist/index.d.ts +906 -6001
- package/dist/index.mjs +336 -83
- package/package.json +7 -7
package/dist/index.mjs
CHANGED
|
@@ -76,6 +76,9 @@ function getContentSize(value) {
|
|
|
76
76
|
if (Buffer.isBuffer(value) || value instanceof Uint8Array) {
|
|
77
77
|
return value.byteLength;
|
|
78
78
|
}
|
|
79
|
+
if (value instanceof ArrayBuffer) {
|
|
80
|
+
return value.byteLength;
|
|
81
|
+
}
|
|
79
82
|
return value.size;
|
|
80
83
|
}
|
|
81
84
|
function isImageSource(mimeType, extension) {
|
|
@@ -269,7 +272,17 @@ function resolveMediaCollection(target, collectionName = "default") {
|
|
|
269
272
|
acceptedExtensions: Object.freeze([])
|
|
270
273
|
});
|
|
271
274
|
}
|
|
272
|
-
|
|
275
|
+
const collection2 = definition.collectionsByName[collectionName];
|
|
276
|
+
if (collection2) {
|
|
277
|
+
return collection2;
|
|
278
|
+
}
|
|
279
|
+
if (collectionName !== "default") {
|
|
280
|
+
const model = resolveDefinition(target);
|
|
281
|
+
throw new Error(
|
|
282
|
+
`[Holo Media] Unknown media collection "${collectionName}" for model "${model.name}".`
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
return Object.freeze({
|
|
273
286
|
kind: "collection",
|
|
274
287
|
name: collectionName,
|
|
275
288
|
singleFile: false,
|
|
@@ -352,10 +365,13 @@ var Media = defineModel(mediaTable, {
|
|
|
352
365
|
});
|
|
353
366
|
|
|
354
367
|
// src/model/adder.ts
|
|
355
|
-
import {
|
|
356
|
-
import {
|
|
368
|
+
import { isIP } from "net";
|
|
369
|
+
import { tmpdir } from "os";
|
|
370
|
+
import { basename as basename2, resolve, sep } from "path";
|
|
371
|
+
import { readFile, realpath } from "fs/promises";
|
|
357
372
|
import { randomUUID } from "crypto";
|
|
358
373
|
import { Storage as Storage3 } from "@holo-js/storage/runtime";
|
|
374
|
+
import { connectionAsyncContext as connectionAsyncContext4 } from "@holo-js/db";
|
|
359
375
|
|
|
360
376
|
// src/queue.ts
|
|
361
377
|
import {
|
|
@@ -364,10 +380,59 @@ import {
|
|
|
364
380
|
getRegisteredQueueJob,
|
|
365
381
|
registerQueueJob
|
|
366
382
|
} from "@holo-js/queue";
|
|
367
|
-
import { connectionAsyncContext } from "@holo-js/db";
|
|
383
|
+
import { connectionAsyncContext as connectionAsyncContext2 } from "@holo-js/db";
|
|
368
384
|
|
|
369
385
|
// src/model/conversions.ts
|
|
370
386
|
import { Storage } from "@holo-js/storage/runtime";
|
|
387
|
+
import { connectionAsyncContext } from "@holo-js/db";
|
|
388
|
+
function getActiveTransaction() {
|
|
389
|
+
const active = connectionAsyncContext.getActive()?.connection;
|
|
390
|
+
if (!active || active.getScope().kind === "root") {
|
|
391
|
+
return void 0;
|
|
392
|
+
}
|
|
393
|
+
return active;
|
|
394
|
+
}
|
|
395
|
+
function registerStorageWriteRollback(snapshot) {
|
|
396
|
+
const active = getActiveTransaction();
|
|
397
|
+
if (!active) {
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
active.afterRollback(async () => {
|
|
401
|
+
await restoreStoredFileSnapshot(snapshot);
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
async function restoreStoredFileSnapshot(snapshot) {
|
|
405
|
+
if (snapshot.contents) {
|
|
406
|
+
await Storage.disk(snapshot.disk).put(snapshot.path, snapshot.contents);
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
await Storage.disk(snapshot.disk).delete(snapshot.path);
|
|
410
|
+
}
|
|
411
|
+
async function restoreStoredFileSnapshots(snapshots) {
|
|
412
|
+
for (const snapshot of [...snapshots].reverse()) {
|
|
413
|
+
await restoreStoredFileSnapshot(snapshot).catch(() => void 0);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
async function putFileWithRollbackRestore(disk, path, contents) {
|
|
417
|
+
const previous = await Storage.disk(disk).getBytes(path);
|
|
418
|
+
await Storage.disk(disk).put(path, contents);
|
|
419
|
+
const snapshot = {
|
|
420
|
+
disk,
|
|
421
|
+
path,
|
|
422
|
+
contents: previous
|
|
423
|
+
};
|
|
424
|
+
registerStorageWriteRollback(snapshot);
|
|
425
|
+
return snapshot;
|
|
426
|
+
}
|
|
427
|
+
async function deleteFileWithRollbackRestore(disk, path) {
|
|
428
|
+
const previous = await Storage.disk(disk).getBytes(path);
|
|
429
|
+
await Storage.disk(disk).delete(path);
|
|
430
|
+
registerStorageWriteRollback({
|
|
431
|
+
disk,
|
|
432
|
+
path,
|
|
433
|
+
contents: previous
|
|
434
|
+
});
|
|
435
|
+
}
|
|
371
436
|
function fallbackCollectionDefinition(collectionName) {
|
|
372
437
|
return Object.freeze({
|
|
373
438
|
kind: "collection",
|
|
@@ -447,6 +512,7 @@ async function generateStoredConversions(options) {
|
|
|
447
512
|
}
|
|
448
513
|
const generatedConversions = /* @__PURE__ */ Object.create(null);
|
|
449
514
|
const executor = getMediaConversionExecutor();
|
|
515
|
+
const writtenSnapshots = [];
|
|
450
516
|
try {
|
|
451
517
|
for (const conversion2 of matchingConversions) {
|
|
452
518
|
const generated = await executor.generate({
|
|
@@ -469,7 +535,7 @@ async function generateStoredConversions(options) {
|
|
|
469
535
|
const targetDisk = generated.disk ?? options.conversionsDisk;
|
|
470
536
|
const conversionMimeType = inferMimeType(generatedFileName, generated.mimeType);
|
|
471
537
|
const conversionContents = await toBinaryContent(generated.contents);
|
|
472
|
-
await
|
|
538
|
+
writtenSnapshots.push(await putFileWithRollbackRestore(targetDisk, conversionPath, conversionContents));
|
|
473
539
|
generatedConversions[conversion2.name] = Object.freeze({
|
|
474
540
|
path: conversionPath,
|
|
475
541
|
disk: targetDisk,
|
|
@@ -479,19 +545,11 @@ async function generateStoredConversions(options) {
|
|
|
479
545
|
});
|
|
480
546
|
}
|
|
481
547
|
} catch (error) {
|
|
482
|
-
await
|
|
483
|
-
generatedConversions,
|
|
484
|
-
options.conversionsDisk
|
|
485
|
-
);
|
|
548
|
+
await restoreStoredFileSnapshots(writtenSnapshots);
|
|
486
549
|
throw error;
|
|
487
550
|
}
|
|
488
551
|
return Object.freeze(generatedConversions);
|
|
489
552
|
}
|
|
490
|
-
async function deleteStoredConversions(conversions, _fallbackDisk) {
|
|
491
|
-
for (const conversion2 of Object.values(conversions)) {
|
|
492
|
-
await Storage.disk(conversion2.disk).delete(conversion2.path);
|
|
493
|
-
}
|
|
494
|
-
}
|
|
495
553
|
async function deleteObsoleteConversions(current, next, fallbackDisk, requested) {
|
|
496
554
|
for (const [name, conversion2] of Object.entries(current)) {
|
|
497
555
|
if (requested?.length && !requested.includes(name)) {
|
|
@@ -504,7 +562,7 @@ async function deleteObsoleteConversions(current, next, fallbackDisk, requested)
|
|
|
504
562
|
if (nextConversion?.path === conversion2.path && nextConversion.disk === (conversion2.disk ?? fallbackDisk)) {
|
|
505
563
|
continue;
|
|
506
564
|
}
|
|
507
|
-
await
|
|
565
|
+
await deleteFileWithRollbackRestore(conversion2.disk ?? fallbackDisk, conversion2.path);
|
|
508
566
|
}
|
|
509
567
|
}
|
|
510
568
|
async function regenerateMediaEntityConversions(options) {
|
|
@@ -588,22 +646,30 @@ function normalizePayload(payload) {
|
|
|
588
646
|
}
|
|
589
647
|
async function runMediaGenerateConversionsJob(payload) {
|
|
590
648
|
const normalized = normalizePayload(payload);
|
|
591
|
-
|
|
592
|
-
if (!media) {
|
|
649
|
+
if (normalized.conversionNames.length === 0) {
|
|
593
650
|
return Object.freeze({
|
|
594
|
-
status: "
|
|
595
|
-
conversionNames:
|
|
651
|
+
status: "processed",
|
|
652
|
+
conversionNames: normalized.conversionNames
|
|
596
653
|
});
|
|
597
654
|
}
|
|
598
|
-
await
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
655
|
+
const media = typeof normalized.mediaId === "number" ? await Media.find(normalized.mediaId) : await Media.where("id", normalized.mediaId).first();
|
|
656
|
+
switch (media) {
|
|
657
|
+
case void 0:
|
|
658
|
+
return Object.freeze({
|
|
659
|
+
status: "missing-media",
|
|
660
|
+
conversionNames: Object.freeze([])
|
|
661
|
+
});
|
|
662
|
+
default:
|
|
663
|
+
await regenerateMediaEntityConversions({
|
|
664
|
+
media,
|
|
665
|
+
conversions: normalized.conversionNames,
|
|
666
|
+
includeQueued: true
|
|
667
|
+
});
|
|
668
|
+
return Object.freeze({
|
|
669
|
+
status: "processed",
|
|
670
|
+
conversionNames: normalized.conversionNames
|
|
671
|
+
});
|
|
672
|
+
}
|
|
607
673
|
}
|
|
608
674
|
function ensureMediaQueueJobRegistered() {
|
|
609
675
|
if (getRegisteredQueueJob(MEDIA_GENERATE_CONVERSIONS_JOB)) {
|
|
@@ -618,8 +684,8 @@ function ensureMediaQueueJobRegistered() {
|
|
|
618
684
|
name: MEDIA_GENERATE_CONVERSIONS_JOB
|
|
619
685
|
});
|
|
620
686
|
}
|
|
621
|
-
function
|
|
622
|
-
const active =
|
|
687
|
+
function getActiveTransaction2() {
|
|
688
|
+
const active = connectionAsyncContext2.getActive()?.connection;
|
|
623
689
|
if (!active || active.getScope().kind === "root") {
|
|
624
690
|
return void 0;
|
|
625
691
|
}
|
|
@@ -637,7 +703,7 @@ async function dispatchQueuedMediaConversions(payload) {
|
|
|
637
703
|
return;
|
|
638
704
|
}
|
|
639
705
|
ensureMediaQueueJobRegistered();
|
|
640
|
-
const activeTransaction =
|
|
706
|
+
const activeTransaction = getActiveTransaction2();
|
|
641
707
|
if (activeTransaction) {
|
|
642
708
|
activeTransaction.afterCommit(() => executeQueuedMediaDispatch(normalized));
|
|
643
709
|
return;
|
|
@@ -650,7 +716,7 @@ async function dispatchQueuedMediaConversionsForModel(payload, onSynchronousDisp
|
|
|
650
716
|
return;
|
|
651
717
|
}
|
|
652
718
|
ensureMediaQueueJobRegistered();
|
|
653
|
-
const activeTransaction =
|
|
719
|
+
const activeTransaction = getActiveTransaction2();
|
|
654
720
|
if (activeTransaction) {
|
|
655
721
|
activeTransaction.afterCommit(() => executeQueuedMediaDispatch(normalized, onSynchronousDispatch));
|
|
656
722
|
return;
|
|
@@ -660,6 +726,29 @@ async function dispatchQueuedMediaConversionsForModel(payload, onSynchronousDisp
|
|
|
660
726
|
|
|
661
727
|
// src/model/item.ts
|
|
662
728
|
import { Storage as Storage2 } from "@holo-js/storage/runtime";
|
|
729
|
+
import { connectionAsyncContext as connectionAsyncContext3 } from "@holo-js/db";
|
|
730
|
+
function toUrlPath(url) {
|
|
731
|
+
if (url.startsWith("/")) {
|
|
732
|
+
return url;
|
|
733
|
+
}
|
|
734
|
+
try {
|
|
735
|
+
const parsed = new URL(url);
|
|
736
|
+
return `${parsed.pathname}${parsed.search}`;
|
|
737
|
+
} catch {
|
|
738
|
+
return `/${url.replace(/^\/+/, "")}`;
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
function registerDeletedVariantRollbackRestore(snapshots) {
|
|
742
|
+
const active = connectionAsyncContext3.getActive()?.connection;
|
|
743
|
+
if (!active || active.getScope().kind === "root") {
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
746
|
+
active.afterRollback(async () => {
|
|
747
|
+
for (const snapshot of [...snapshots].reverse()) {
|
|
748
|
+
await Storage2.disk(snapshot.disk).put(snapshot.path, snapshot.contents).catch(() => void 0);
|
|
749
|
+
}
|
|
750
|
+
});
|
|
751
|
+
}
|
|
663
752
|
var MediaItem = class {
|
|
664
753
|
constructor(entity, owner) {
|
|
665
754
|
this.entity = entity;
|
|
@@ -690,15 +779,8 @@ var MediaItem = class {
|
|
|
690
779
|
);
|
|
691
780
|
}
|
|
692
781
|
getPath(conversion2) {
|
|
693
|
-
const
|
|
694
|
-
|
|
695
|
-
return null;
|
|
696
|
-
}
|
|
697
|
-
try {
|
|
698
|
-
return Storage2.disk(variant.disk).path(variant.path);
|
|
699
|
-
} catch {
|
|
700
|
-
return null;
|
|
701
|
-
}
|
|
782
|
+
const url = this.getUrl(conversion2);
|
|
783
|
+
return url ? toUrlPath(url) : null;
|
|
702
784
|
}
|
|
703
785
|
getUrl(conversion2) {
|
|
704
786
|
const variant = this.resolveVariant(conversion2);
|
|
@@ -737,6 +819,7 @@ var MediaItem = class {
|
|
|
737
819
|
}
|
|
738
820
|
}
|
|
739
821
|
await this.entity.delete();
|
|
822
|
+
registerDeletedVariantRollbackRestore(deletedSnapshots);
|
|
740
823
|
this.owner?.forgetRelation("media");
|
|
741
824
|
} catch (error) {
|
|
742
825
|
for (const snapshot of deletedSnapshots.reverse()) {
|
|
@@ -806,6 +889,20 @@ var MediaItem = class {
|
|
|
806
889
|
};
|
|
807
890
|
|
|
808
891
|
// src/model/adder.ts
|
|
892
|
+
var DEFAULT_REMOTE_MEDIA_MAX_SIZE = 10 * 1024 * 1024;
|
|
893
|
+
var LOCAL_MEDIA_SOURCE_ROOTS = Object.freeze([
|
|
894
|
+
tmpdir(),
|
|
895
|
+
resolve(process.cwd(), "storage")
|
|
896
|
+
]);
|
|
897
|
+
var MediaAddValidationException = class extends Error {
|
|
898
|
+
error;
|
|
899
|
+
constructor(details) {
|
|
900
|
+
const error = createMediaAddError(details);
|
|
901
|
+
super(error.message);
|
|
902
|
+
this.name = "MediaAddValidationException";
|
|
903
|
+
this.error = error;
|
|
904
|
+
}
|
|
905
|
+
};
|
|
809
906
|
function resolveImplicitDiskName() {
|
|
810
907
|
const defaultDisk = Storage3.disk();
|
|
811
908
|
if (defaultDisk.visibility === "public") {
|
|
@@ -828,14 +925,53 @@ function parseRemoteFileName(url) {
|
|
|
828
925
|
return "media.bin";
|
|
829
926
|
}
|
|
830
927
|
}
|
|
831
|
-
function
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
928
|
+
function formatBytes(bytes) {
|
|
929
|
+
if (!Number.isFinite(bytes) || bytes < 0) {
|
|
930
|
+
return "0 bytes";
|
|
931
|
+
}
|
|
932
|
+
const units = ["bytes", "KB", "MB", "GB"];
|
|
933
|
+
let value = bytes;
|
|
934
|
+
let unitIndex = 0;
|
|
935
|
+
while (value >= 1024 && unitIndex < units.length - 1) {
|
|
936
|
+
value /= 1024;
|
|
937
|
+
unitIndex += 1;
|
|
938
|
+
}
|
|
939
|
+
const formatted = unitIndex === 0 ? String(Math.round(value)) : value.toFixed(value >= 10 ? 0 : 1).replace(/\.0$/, "");
|
|
940
|
+
return `${formatted} ${units[unitIndex]}`;
|
|
941
|
+
}
|
|
942
|
+
function createMediaValidationMessage(details) {
|
|
943
|
+
if (details.code === "max_size_exceeded") {
|
|
944
|
+
return `The selected file must be ${formatBytes(details.maxSize)} or smaller.`;
|
|
945
|
+
}
|
|
946
|
+
if (details.code === "invalid_mime_type") {
|
|
947
|
+
return `The selected file must be one of these types: ${details.acceptedMimeTypes.join(", ")}.`;
|
|
948
|
+
}
|
|
949
|
+
return `The selected file must use one of these extensions: ${details.acceptedExtensions.join(", ")}.`;
|
|
950
|
+
}
|
|
951
|
+
function createMediaAddError(details) {
|
|
952
|
+
return Object.freeze({
|
|
953
|
+
...details,
|
|
954
|
+
status: 422,
|
|
955
|
+
message: createMediaValidationMessage(details)
|
|
956
|
+
});
|
|
957
|
+
}
|
|
958
|
+
function createMaxSizeError(fileName, collectionName, maxSize, actualSize) {
|
|
959
|
+
return new MediaAddValidationException({
|
|
960
|
+
code: "max_size_exceeded",
|
|
961
|
+
collection: collectionName,
|
|
962
|
+
fileName,
|
|
963
|
+
maxSize,
|
|
964
|
+
actualSize
|
|
965
|
+
});
|
|
835
966
|
}
|
|
836
967
|
async function readRemoteMediaContents(response, fileName, maxSize, collectionName) {
|
|
837
|
-
|
|
838
|
-
|
|
968
|
+
const resolvedMaxSize = typeof maxSize === "number" ? maxSize : DEFAULT_REMOTE_MEDIA_MAX_SIZE;
|
|
969
|
+
if (!response.body) {
|
|
970
|
+
const contents2 = new Uint8Array(await response.arrayBuffer());
|
|
971
|
+
if (contents2.byteLength > resolvedMaxSize) {
|
|
972
|
+
throw createMaxSizeError(fileName, collectionName ?? "default", resolvedMaxSize, contents2.byteLength);
|
|
973
|
+
}
|
|
974
|
+
return contents2;
|
|
839
975
|
}
|
|
840
976
|
const reader = response.body.getReader();
|
|
841
977
|
const chunks = [];
|
|
@@ -850,9 +986,9 @@ async function readRemoteMediaContents(response, fileName, maxSize, collectionNa
|
|
|
850
986
|
continue;
|
|
851
987
|
}
|
|
852
988
|
totalSize += value.byteLength;
|
|
853
|
-
if (totalSize >
|
|
989
|
+
if (totalSize > resolvedMaxSize) {
|
|
854
990
|
await reader.cancel().catch(() => void 0);
|
|
855
|
-
throw createMaxSizeError(fileName, collectionName);
|
|
991
|
+
throw createMaxSizeError(fileName, collectionName ?? "default", resolvedMaxSize, totalSize);
|
|
856
992
|
}
|
|
857
993
|
chunks.push(value);
|
|
858
994
|
}
|
|
@@ -887,7 +1023,7 @@ async function resolveMediaSource(input, overrideFileName, overrideName, options
|
|
|
887
1023
|
);
|
|
888
1024
|
}
|
|
889
1025
|
if (typeof input === "string" || isPathInput(input)) {
|
|
890
|
-
const path = typeof input === "string" ? input : input.path;
|
|
1026
|
+
const path = await resolveLocalMediaSourcePath(typeof input === "string" ? input : input.path);
|
|
891
1027
|
const contents2 = await readFile(path);
|
|
892
1028
|
const fileName2 = sanitizeFileName(overrideFileName ?? basename2(path));
|
|
893
1029
|
return {
|
|
@@ -912,16 +1048,24 @@ async function resolveMediaSource(input, overrideFileName, overrideName, options
|
|
|
912
1048
|
};
|
|
913
1049
|
}
|
|
914
1050
|
const contents = await toBinaryContent(input);
|
|
915
|
-
const fileName = sanitizeFileName(overrideFileName ??
|
|
1051
|
+
const fileName = sanitizeFileName(overrideFileName ?? getBinaryFileName(input));
|
|
916
1052
|
return {
|
|
917
1053
|
contents,
|
|
918
1054
|
fileName,
|
|
919
|
-
mimeType: inferMimeType(fileName),
|
|
1055
|
+
mimeType: inferMimeType(fileName, getBinaryMimeType(input)),
|
|
920
1056
|
extension: getExtension(fileName),
|
|
921
1057
|
size: getContentSize(contents),
|
|
922
1058
|
name: getDisplayName(fileName, overrideName)
|
|
923
1059
|
};
|
|
924
1060
|
}
|
|
1061
|
+
function getBinaryFileName(input) {
|
|
1062
|
+
const name = input.name;
|
|
1063
|
+
return typeof name === "string" && name.trim() ? name : "media.bin";
|
|
1064
|
+
}
|
|
1065
|
+
function getBinaryMimeType(input) {
|
|
1066
|
+
const type = input.type;
|
|
1067
|
+
return typeof type === "string" && type.trim() ? type : void 0;
|
|
1068
|
+
}
|
|
925
1069
|
function isPathInput(input) {
|
|
926
1070
|
return typeof input === "object" && input !== null && "path" in input;
|
|
927
1071
|
}
|
|
@@ -929,20 +1073,27 @@ function isUrlInput(input) {
|
|
|
929
1073
|
return typeof input === "object" && input !== null && "url" in input;
|
|
930
1074
|
}
|
|
931
1075
|
async function resolveRemoteMediaSource(input, overrideFileName, overrideName, maxSize, collectionName) {
|
|
932
|
-
const
|
|
1076
|
+
const remoteUrl = resolveRemoteMediaUrl(input.url);
|
|
1077
|
+
const response = await fetch(remoteUrl, {
|
|
1078
|
+
redirect: "manual"
|
|
1079
|
+
});
|
|
1080
|
+
if (response.status >= 300 && response.status < 400) {
|
|
1081
|
+
throw new Error("[@holo-js/media] Remote media downloads do not follow redirects.");
|
|
1082
|
+
}
|
|
933
1083
|
if (!response.ok) {
|
|
934
1084
|
throw new Error(
|
|
935
|
-
`[Holo Media] Failed to download media from "${
|
|
1085
|
+
`[Holo Media] Failed to download media from "${remoteUrl}" (${response.status} ${response.statusText}).`
|
|
936
1086
|
);
|
|
937
1087
|
}
|
|
938
1088
|
const fileName = sanitizeFileName(
|
|
939
|
-
overrideFileName ?? input.fileName ?? parseRemoteFileName(
|
|
1089
|
+
overrideFileName ?? input.fileName ?? parseRemoteFileName(remoteUrl)
|
|
940
1090
|
);
|
|
941
1091
|
const responseMimeType = response.headers.get("content-type")?.split(";")[0];
|
|
942
1092
|
const contentLengthHeader = response.headers.get("content-length");
|
|
943
1093
|
const contentLength = contentLengthHeader?.trim() ? Number(contentLengthHeader) : Number.NaN;
|
|
944
|
-
|
|
945
|
-
|
|
1094
|
+
const resolvedMaxSize = typeof maxSize === "number" ? maxSize : DEFAULT_REMOTE_MEDIA_MAX_SIZE;
|
|
1095
|
+
if (Number.isFinite(contentLength) && contentLength > resolvedMaxSize) {
|
|
1096
|
+
throw createMaxSizeError(fileName, collectionName ?? "default", resolvedMaxSize, contentLength);
|
|
946
1097
|
}
|
|
947
1098
|
const contents = await readRemoteMediaContents(
|
|
948
1099
|
response,
|
|
@@ -959,25 +1110,75 @@ async function resolveRemoteMediaSource(input, overrideFileName, overrideName, m
|
|
|
959
1110
|
name: getDisplayName(fileName, overrideName ?? input.name)
|
|
960
1111
|
};
|
|
961
1112
|
}
|
|
1113
|
+
async function resolveLocalMediaSourcePath(path) {
|
|
1114
|
+
const resolvedPath = await realpath(path);
|
|
1115
|
+
const allowedRoots = await Promise.all(LOCAL_MEDIA_SOURCE_ROOTS.map(async (root) => await realpath(root).catch(() => null)));
|
|
1116
|
+
if (allowedRoots.some((root) => root && isPathWithinRoot(resolvedPath, root))) {
|
|
1117
|
+
return resolvedPath;
|
|
1118
|
+
}
|
|
1119
|
+
throw new Error("[@holo-js/media] Media path sources must resolve inside an allowed upload directory.");
|
|
1120
|
+
}
|
|
1121
|
+
function isPathWithinRoot(path, root) {
|
|
1122
|
+
return path === root || path.startsWith(`${root}${sep}`);
|
|
1123
|
+
}
|
|
1124
|
+
function resolveRemoteMediaUrl(value) {
|
|
1125
|
+
let url;
|
|
1126
|
+
try {
|
|
1127
|
+
url = new URL(value);
|
|
1128
|
+
} catch {
|
|
1129
|
+
throw new Error("[@holo-js/media] Remote media URLs must be valid absolute URLs.");
|
|
1130
|
+
}
|
|
1131
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
1132
|
+
throw new Error("[@holo-js/media] Remote media URLs must use http or https.");
|
|
1133
|
+
}
|
|
1134
|
+
if (url.username || url.password) {
|
|
1135
|
+
throw new Error("[@holo-js/media] Remote media URLs must not include credentials.");
|
|
1136
|
+
}
|
|
1137
|
+
if (isBlockedRemoteMediaHost(url.hostname)) {
|
|
1138
|
+
throw new Error("[@holo-js/media] Remote media URLs must not target local or private hosts.");
|
|
1139
|
+
}
|
|
1140
|
+
return url.toString();
|
|
1141
|
+
}
|
|
1142
|
+
function isBlockedRemoteMediaHost(hostname) {
|
|
1143
|
+
const normalized = hostname.toLowerCase();
|
|
1144
|
+
if (normalized === "localhost" || normalized.endsWith(".localhost")) {
|
|
1145
|
+
return true;
|
|
1146
|
+
}
|
|
1147
|
+
const ipVersion = isIP(normalized);
|
|
1148
|
+
if (ipVersion === 4) {
|
|
1149
|
+
const [first = 0, second = 0] = normalized.split(".").map(Number);
|
|
1150
|
+
return first === 0 || first === 10 || first === 127 || first === 169 && second === 254 || first === 172 && second >= 16 && second <= 31 || first === 192 && second === 168 || first >= 224;
|
|
1151
|
+
}
|
|
1152
|
+
if (ipVersion === 6) {
|
|
1153
|
+
return normalized === "::1" || normalized === "::" || normalized.startsWith("fc") || normalized.startsWith("fd") || normalized.startsWith("fe80:");
|
|
1154
|
+
}
|
|
1155
|
+
return false;
|
|
1156
|
+
}
|
|
962
1157
|
function validateSource(collection2, source) {
|
|
963
1158
|
if (typeof collection2.maxSize === "number" && source.size > collection2.maxSize) {
|
|
964
|
-
throw
|
|
965
|
-
`[Holo Media] File "${source.fileName}" exceeds the max size for collection "${collection2.name}".`
|
|
966
|
-
);
|
|
1159
|
+
throw createMaxSizeError(source.fileName, collection2.name, collection2.maxSize, source.size);
|
|
967
1160
|
}
|
|
968
1161
|
if (collection2.acceptedMimeTypes.length > 0) {
|
|
969
1162
|
const mimeType = source.mimeType?.trim().toLowerCase();
|
|
970
1163
|
if (!mimeType || !collection2.acceptedMimeTypes.includes(mimeType)) {
|
|
971
|
-
throw new
|
|
972
|
-
|
|
973
|
-
|
|
1164
|
+
throw new MediaAddValidationException({
|
|
1165
|
+
code: "invalid_mime_type",
|
|
1166
|
+
collection: collection2.name,
|
|
1167
|
+
fileName: source.fileName,
|
|
1168
|
+
mimeType,
|
|
1169
|
+
acceptedMimeTypes: collection2.acceptedMimeTypes
|
|
1170
|
+
});
|
|
974
1171
|
}
|
|
975
1172
|
}
|
|
976
1173
|
if (collection2.acceptedExtensions.length > 0) {
|
|
977
1174
|
if (!source.extension || !collection2.acceptedExtensions.includes(source.extension)) {
|
|
978
|
-
throw new
|
|
979
|
-
|
|
980
|
-
|
|
1175
|
+
throw new MediaAddValidationException({
|
|
1176
|
+
code: "invalid_extension",
|
|
1177
|
+
collection: collection2.name,
|
|
1178
|
+
fileName: source.fileName,
|
|
1179
|
+
extension: source.extension,
|
|
1180
|
+
acceptedExtensions: collection2.acceptedExtensions
|
|
1181
|
+
});
|
|
981
1182
|
}
|
|
982
1183
|
}
|
|
983
1184
|
}
|
|
@@ -994,23 +1195,29 @@ async function deleteMediaItemsWithRollback(items) {
|
|
|
994
1195
|
deletedSnapshots.push(snapshot);
|
|
995
1196
|
}
|
|
996
1197
|
} catch (error) {
|
|
997
|
-
|
|
998
|
-
await restoreDeletedMediaSnapshot(snapshot).catch(() => void 0);
|
|
999
|
-
}
|
|
1198
|
+
await restoreDeletedMediaSnapshots(deletedSnapshots);
|
|
1000
1199
|
throw error;
|
|
1001
1200
|
}
|
|
1201
|
+
return deletedSnapshots;
|
|
1002
1202
|
}
|
|
1003
1203
|
async function deleteOverflowItems(items, limit) {
|
|
1004
1204
|
if (items.length <= limit) {
|
|
1005
|
-
return;
|
|
1205
|
+
return [];
|
|
1006
1206
|
}
|
|
1007
|
-
await deleteMediaItemsWithRollback(items.slice(0, items.length - limit));
|
|
1207
|
+
return await deleteMediaItemsWithRollback(items.slice(0, items.length - limit));
|
|
1008
1208
|
}
|
|
1009
1209
|
async function cleanupGeneratedConversions(conversions, _fallbackDisk) {
|
|
1010
1210
|
for (const conversion2 of Object.values(conversions)) {
|
|
1011
1211
|
await Storage3.disk(conversion2.disk).delete(conversion2.path).catch(() => void 0);
|
|
1012
1212
|
}
|
|
1013
1213
|
}
|
|
1214
|
+
function registerCreatedMediaRollbackCleanup(cleanup) {
|
|
1215
|
+
const active = connectionAsyncContext4.getActive()?.connection;
|
|
1216
|
+
if (!active || active.getScope().kind === "root") {
|
|
1217
|
+
return;
|
|
1218
|
+
}
|
|
1219
|
+
active.afterRollback(cleanup);
|
|
1220
|
+
}
|
|
1014
1221
|
async function snapshotDeletedMediaItem(item) {
|
|
1015
1222
|
const record = item.record;
|
|
1016
1223
|
const fileTargets = [{
|
|
@@ -1060,6 +1267,11 @@ async function restoreDeletedMediaSnapshot(snapshot) {
|
|
|
1060
1267
|
}
|
|
1061
1268
|
await Media.create(snapshot.record);
|
|
1062
1269
|
}
|
|
1270
|
+
async function restoreDeletedMediaSnapshots(snapshots) {
|
|
1271
|
+
for (const snapshot of snapshots.reverse()) {
|
|
1272
|
+
await restoreDeletedMediaSnapshot(snapshot).catch(() => void 0);
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1063
1275
|
var MediaAdder = class _MediaAdder {
|
|
1064
1276
|
constructor(entity, source, state = {}) {
|
|
1065
1277
|
this.entity = entity;
|
|
@@ -1085,6 +1297,23 @@ var MediaAdder = class _MediaAdder {
|
|
|
1085
1297
|
});
|
|
1086
1298
|
}
|
|
1087
1299
|
async toMediaCollection(collectionName = "default") {
|
|
1300
|
+
try {
|
|
1301
|
+
const media = await this.storeInMediaCollection(collectionName);
|
|
1302
|
+
return Object.assign(media, {
|
|
1303
|
+
data: media,
|
|
1304
|
+
error: null
|
|
1305
|
+
});
|
|
1306
|
+
} catch (error) {
|
|
1307
|
+
if (error instanceof MediaAddValidationException) {
|
|
1308
|
+
return {
|
|
1309
|
+
data: null,
|
|
1310
|
+
error: error.error
|
|
1311
|
+
};
|
|
1312
|
+
}
|
|
1313
|
+
throw error;
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
async storeInMediaCollection(collectionName = "default") {
|
|
1088
1317
|
const mediaDefinition = requireMediaDefinition(this.entity);
|
|
1089
1318
|
const ownerDefinition = this.entity.getRepository().definition;
|
|
1090
1319
|
const ownerId = this.entity.get(ownerDefinition.primaryKey);
|
|
@@ -1117,9 +1346,14 @@ var MediaAdder = class _MediaAdder {
|
|
|
1117
1346
|
let originalStored = false;
|
|
1118
1347
|
let createdMedia;
|
|
1119
1348
|
let generatedConversions = Object.freeze({});
|
|
1349
|
+
const deletedMediaSnapshots = [];
|
|
1120
1350
|
try {
|
|
1121
1351
|
await Storage3.disk(diskName).put(originalPath, source.contents);
|
|
1122
1352
|
originalStored = true;
|
|
1353
|
+
registerCreatedMediaRollbackCleanup(async () => {
|
|
1354
|
+
await cleanupGeneratedConversions(generatedConversions, conversionsDisk).catch(() => void 0);
|
|
1355
|
+
await Storage3.disk(diskName).delete(originalPath).catch(() => void 0);
|
|
1356
|
+
});
|
|
1123
1357
|
generatedConversions = await generateStoredConversions({
|
|
1124
1358
|
definition: mediaDefinition,
|
|
1125
1359
|
collection: collection2,
|
|
@@ -1155,10 +1389,10 @@ var MediaAdder = class _MediaAdder {
|
|
|
1155
1389
|
});
|
|
1156
1390
|
createdMedia = media;
|
|
1157
1391
|
this.entity.forgetRelation("media");
|
|
1158
|
-
await deleteMediaItemsWithRollback(existing);
|
|
1392
|
+
deletedMediaSnapshots.push(...await deleteMediaItemsWithRollback(existing));
|
|
1159
1393
|
if (typeof collection2.onlyKeepLatest === "number") {
|
|
1160
1394
|
const items = await this.entity.getMedia(collectionName);
|
|
1161
|
-
await deleteOverflowItems(items, collection2.onlyKeepLatest);
|
|
1395
|
+
deletedMediaSnapshots.push(...await deleteOverflowItems(items, collection2.onlyKeepLatest));
|
|
1162
1396
|
}
|
|
1163
1397
|
const queuedConversions = resolveQueuedConversionNames({
|
|
1164
1398
|
definition: mediaDefinition,
|
|
@@ -1180,6 +1414,7 @@ var MediaAdder = class _MediaAdder {
|
|
|
1180
1414
|
await createdMedia.refresh().catch(() => void 0);
|
|
1181
1415
|
await createdMedia.delete().catch(() => void 0);
|
|
1182
1416
|
}
|
|
1417
|
+
await restoreDeletedMediaSnapshots(deletedMediaSnapshots);
|
|
1183
1418
|
this.entity.forgetRelation("media");
|
|
1184
1419
|
throw error;
|
|
1185
1420
|
}
|
|
@@ -1286,6 +1521,12 @@ function installEntityMediaMethods() {
|
|
|
1286
1521
|
import { morphMany } from "@holo-js/db";
|
|
1287
1522
|
|
|
1288
1523
|
// src/definitions/collections.ts
|
|
1524
|
+
function normalizePositiveInteger(value, fieldName) {
|
|
1525
|
+
if (!Number.isFinite(value)) {
|
|
1526
|
+
throw new Error(`[Holo Media] Media collection ${fieldName} must be a finite number.`);
|
|
1527
|
+
}
|
|
1528
|
+
return Math.max(1, Math.floor(value));
|
|
1529
|
+
}
|
|
1289
1530
|
function decorateCollection(definition) {
|
|
1290
1531
|
const clone = (overrides) => decorateCollection(Object.freeze({
|
|
1291
1532
|
...definition,
|
|
@@ -1306,7 +1547,7 @@ function decorateCollection(definition) {
|
|
|
1306
1547
|
},
|
|
1307
1548
|
onlyKeepLatest(limit) {
|
|
1308
1549
|
return clone({
|
|
1309
|
-
onlyKeepLatest:
|
|
1550
|
+
onlyKeepLatest: normalizePositiveInteger(limit, "onlyKeepLatest")
|
|
1310
1551
|
});
|
|
1311
1552
|
},
|
|
1312
1553
|
acceptsMimeTypes(mimeTypes) {
|
|
@@ -1325,7 +1566,7 @@ function decorateCollection(definition) {
|
|
|
1325
1566
|
},
|
|
1326
1567
|
maxSize(bytes) {
|
|
1327
1568
|
return clone({
|
|
1328
|
-
maxFileSize:
|
|
1569
|
+
maxFileSize: normalizePositiveInteger(bytes, "maxFileSize")
|
|
1329
1570
|
});
|
|
1330
1571
|
}
|
|
1331
1572
|
});
|
|
@@ -1346,19 +1587,31 @@ function normalizeCollectionDefinitions(definitions) {
|
|
|
1346
1587
|
disk: definition.diskName?.trim() || void 0,
|
|
1347
1588
|
conversionsDisk: definition.conversionsDiskName?.trim() || void 0,
|
|
1348
1589
|
singleFile: Boolean(definition.singleFile),
|
|
1349
|
-
onlyKeepLatest: typeof definition.onlyKeepLatest === "number" ?
|
|
1590
|
+
onlyKeepLatest: typeof definition.onlyKeepLatest === "number" ? normalizePositiveInteger(definition.onlyKeepLatest, "onlyKeepLatest") : void 0,
|
|
1350
1591
|
acceptedMimeTypes: Object.freeze(
|
|
1351
1592
|
definition.acceptedMimeTypes.map((mimeType) => mimeType.trim()).map((mimeType) => mimeType.toLowerCase()).filter(Boolean)
|
|
1352
1593
|
),
|
|
1353
1594
|
acceptedExtensions: Object.freeze(
|
|
1354
1595
|
definition.acceptedExtensions.map((extension) => extension.trim().replace(/^\./, "").toLowerCase()).filter(Boolean)
|
|
1355
1596
|
),
|
|
1356
|
-
maxSize: typeof definition.maxFileSize === "number" ?
|
|
1597
|
+
maxSize: typeof definition.maxFileSize === "number" ? normalizePositiveInteger(definition.maxFileSize, "maxFileSize") : void 0
|
|
1357
1598
|
});
|
|
1358
1599
|
}));
|
|
1359
1600
|
}
|
|
1360
1601
|
|
|
1361
1602
|
// src/definitions/conversions.ts
|
|
1603
|
+
function normalizePositiveInteger2(value, fieldName) {
|
|
1604
|
+
if (!Number.isFinite(value)) {
|
|
1605
|
+
throw new Error(`[Holo Media] Media conversion ${fieldName} must be a finite number.`);
|
|
1606
|
+
}
|
|
1607
|
+
return Math.max(1, Math.floor(value));
|
|
1608
|
+
}
|
|
1609
|
+
function normalizeQuality(value) {
|
|
1610
|
+
if (!Number.isFinite(value)) {
|
|
1611
|
+
throw new Error("[Holo Media] Media conversion quality must be a finite number.");
|
|
1612
|
+
}
|
|
1613
|
+
return Math.max(1, Math.min(100, Math.floor(value)));
|
|
1614
|
+
}
|
|
1362
1615
|
function decorateConversion(definition) {
|
|
1363
1616
|
const clone = (overrides) => decorateConversion(Object.freeze({
|
|
1364
1617
|
...definition,
|
|
@@ -1377,12 +1630,12 @@ function decorateConversion(definition) {
|
|
|
1377
1630
|
},
|
|
1378
1631
|
width(pixels) {
|
|
1379
1632
|
return clone({
|
|
1380
|
-
width:
|
|
1633
|
+
width: normalizePositiveInteger2(pixels, "width")
|
|
1381
1634
|
});
|
|
1382
1635
|
},
|
|
1383
1636
|
height(pixels) {
|
|
1384
1637
|
return clone({
|
|
1385
|
-
height:
|
|
1638
|
+
height: normalizePositiveInteger2(pixels, "height")
|
|
1386
1639
|
});
|
|
1387
1640
|
},
|
|
1388
1641
|
fit(mode) {
|
|
@@ -1393,7 +1646,7 @@ function decorateConversion(definition) {
|
|
|
1393
1646
|
},
|
|
1394
1647
|
quality(value) {
|
|
1395
1648
|
return clone({
|
|
1396
|
-
quality:
|
|
1649
|
+
quality: normalizeQuality(value)
|
|
1397
1650
|
});
|
|
1398
1651
|
},
|
|
1399
1652
|
queued() {
|
|
@@ -1419,9 +1672,9 @@ function normalizeConversionDefinitions(definitions) {
|
|
|
1419
1672
|
collections: Object.freeze(
|
|
1420
1673
|
definition.collections.filter(Boolean)
|
|
1421
1674
|
),
|
|
1422
|
-
width: typeof definition.width === "number" ?
|
|
1423
|
-
height: typeof definition.height === "number" ?
|
|
1424
|
-
quality: typeof definition.quality === "number" ?
|
|
1675
|
+
width: typeof definition.width === "number" ? normalizePositiveInteger2(definition.width, "width") : void 0,
|
|
1676
|
+
height: typeof definition.height === "number" ? normalizePositiveInteger2(definition.height, "height") : void 0,
|
|
1677
|
+
quality: typeof definition.quality === "number" ? normalizeQuality(definition.quality) : void 0,
|
|
1425
1678
|
queued: Boolean(definition.queued)
|
|
1426
1679
|
});
|
|
1427
1680
|
}));
|