@holo-js/media 0.3.27 → 0.4.0
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/LICENSE +21 -0
- package/dist/.holo-build-fingerprint +1 -0
- package/dist/index.d.ts +41 -32
- package/dist/index.mjs +478 -249
- package/dist/projectConfig.d.ts +3 -1
- package/package.json +19 -12
package/dist/index.mjs
CHANGED
|
@@ -9,33 +9,12 @@ import { Entity as Entity2 } from "@holo-js/db";
|
|
|
9
9
|
// src/registry.ts
|
|
10
10
|
import { Entity } from "@holo-js/db";
|
|
11
11
|
|
|
12
|
+
// src/runtime/image.ts
|
|
13
|
+
import { mimeTypeFromFileName } from "@holo-js/kernel/file-content";
|
|
14
|
+
import { imageDecodingLimits } from "@holo-js/kernel/image-decoding";
|
|
15
|
+
|
|
12
16
|
// src/runtime/binary.ts
|
|
13
17
|
import { basename, extname } from "path";
|
|
14
|
-
var MIME_TYPES = Object.freeze({
|
|
15
|
-
avif: "image/avif",
|
|
16
|
-
gif: "image/gif",
|
|
17
|
-
jpeg: "image/jpeg",
|
|
18
|
-
jpg: "image/jpeg",
|
|
19
|
-
json: "application/json",
|
|
20
|
-
pdf: "application/pdf",
|
|
21
|
-
png: "image/png",
|
|
22
|
-
svg: "image/svg+xml",
|
|
23
|
-
txt: "text/plain",
|
|
24
|
-
webp: "image/webp"
|
|
25
|
-
});
|
|
26
|
-
var IMAGE_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
27
|
-
"avif",
|
|
28
|
-
"gif",
|
|
29
|
-
"heic",
|
|
30
|
-
"heif",
|
|
31
|
-
"jpeg",
|
|
32
|
-
"jpg",
|
|
33
|
-
"png",
|
|
34
|
-
"svg",
|
|
35
|
-
"tif",
|
|
36
|
-
"tiff",
|
|
37
|
-
"webp"
|
|
38
|
-
]);
|
|
39
18
|
var SHARP_OUTPUT_FORMATS = /* @__PURE__ */ new Set([
|
|
40
19
|
"avif",
|
|
41
20
|
"jpeg",
|
|
@@ -47,13 +26,6 @@ function sanitizeFileName(value) {
|
|
|
47
26
|
const sanitized = value.trim().replace(/\\/g, "/").split("/").filter(Boolean).join("-");
|
|
48
27
|
return sanitized || "media.bin";
|
|
49
28
|
}
|
|
50
|
-
function inferMimeType(fileName, explicit) {
|
|
51
|
-
if (explicit?.trim()) {
|
|
52
|
-
return explicit.trim();
|
|
53
|
-
}
|
|
54
|
-
const extension = extname(fileName).replace(/^\./, "").toLowerCase();
|
|
55
|
-
return MIME_TYPES[extension];
|
|
56
|
-
}
|
|
57
29
|
function getExtension(fileName) {
|
|
58
30
|
const extension = extname(fileName).replace(/^\./, "").toLowerCase();
|
|
59
31
|
return extension || void 0;
|
|
@@ -86,12 +58,6 @@ function getContentSize(value) {
|
|
|
86
58
|
}
|
|
87
59
|
return value.size;
|
|
88
60
|
}
|
|
89
|
-
function isImageSource(mimeType, extension) {
|
|
90
|
-
if (mimeType?.trim().toLowerCase().startsWith("image/")) {
|
|
91
|
-
return true;
|
|
92
|
-
}
|
|
93
|
-
return extension ? IMAGE_EXTENSIONS.has(extension.trim().toLowerCase()) : false;
|
|
94
|
-
}
|
|
95
61
|
function normalizeOutputFormat(extension, conversionFormat) {
|
|
96
62
|
if (conversionFormat) {
|
|
97
63
|
return conversionFormat;
|
|
@@ -131,16 +97,22 @@ function resolveFit(mode) {
|
|
|
131
97
|
function createDefaultMediaConversionExecutor() {
|
|
132
98
|
return {
|
|
133
99
|
async generate({ source, conversion: conversion2 }) {
|
|
134
|
-
if (!
|
|
100
|
+
if (!source.mimeType?.toLowerCase().startsWith("image/")) {
|
|
135
101
|
return null;
|
|
136
102
|
}
|
|
137
103
|
const outputFormat = normalizeOutputFormat(source.extension, conversion2.format);
|
|
138
104
|
try {
|
|
139
105
|
const sharp = await loadSharp();
|
|
140
|
-
|
|
106
|
+
const image = sharp(await toBuffer(source.contents), {
|
|
141
107
|
animated: true,
|
|
142
|
-
failOn: "warning"
|
|
143
|
-
|
|
108
|
+
failOn: "warning",
|
|
109
|
+
limitInputPixels: imageDecodingLimits.maximumDecodedPixels
|
|
110
|
+
});
|
|
111
|
+
const metadata = await image.metadata();
|
|
112
|
+
if ((metadata.pages ?? 1) > imageDecodingLimits.maximumFrames) {
|
|
113
|
+
throw new Error(`The image has more than ${imageDecodingLimits.maximumFrames} frames.`);
|
|
114
|
+
}
|
|
115
|
+
let pipeline = image.rotate();
|
|
144
116
|
if (conversion2.width || conversion2.height) {
|
|
145
117
|
pipeline = pipeline.resize({
|
|
146
118
|
width: conversion2.width,
|
|
@@ -181,41 +153,18 @@ function createDefaultMediaConversionExecutor() {
|
|
|
181
153
|
return {
|
|
182
154
|
contents,
|
|
183
155
|
fileName,
|
|
184
|
-
mimeType:
|
|
156
|
+
mimeType: mimeTypeFromFileName(fileName) ?? void 0
|
|
185
157
|
};
|
|
186
158
|
} catch (error) {
|
|
187
159
|
throw new Error(
|
|
188
|
-
`[Holo Media] Failed to generate conversion "${conversion2.name}" for "${source.fileName}": ${error.message}
|
|
160
|
+
`[Holo Media] Failed to generate conversion "${conversion2.name}" for "${source.fileName}": ${error.message}`,
|
|
161
|
+
{ cause: error }
|
|
189
162
|
);
|
|
190
163
|
}
|
|
191
164
|
}
|
|
192
165
|
};
|
|
193
166
|
}
|
|
194
167
|
var defaultMediaConversionExecutor = createDefaultMediaConversionExecutor();
|
|
195
|
-
var imageMimeTypes = {
|
|
196
|
-
gif: "image/gif",
|
|
197
|
-
jpeg: "image/jpeg",
|
|
198
|
-
png: "image/png",
|
|
199
|
-
webp: "image/webp"
|
|
200
|
-
};
|
|
201
|
-
var mediaRuntimeInternals = Object.freeze({
|
|
202
|
-
async inspectImage(contents, maximumPixels = 4e7) {
|
|
203
|
-
if (!Number.isSafeInteger(maximumPixels) || maximumPixels < 1) throw new TypeError("[Holo Media] Image pixel limits require a positive integer.");
|
|
204
|
-
const factory = await loadSharp();
|
|
205
|
-
try {
|
|
206
|
-
const image = factory(Buffer.from(contents), { animated: true, failOn: "warning", limitInputPixels: maximumPixels });
|
|
207
|
-
const metadata = await image.metadata();
|
|
208
|
-
const { width, height } = metadata;
|
|
209
|
-
const format = metadata.format;
|
|
210
|
-
if (format !== "gif" && format !== "jpeg" && format !== "png" && format !== "webp") throw new Error("[Holo Media] Unsupported raster image format.");
|
|
211
|
-
if (!width || !height || !Number.isSafeInteger(width) || !Number.isSafeInteger(height) || width * height > maximumPixels) throw new Error("[Holo Media] Image exceeds the pixel limit.");
|
|
212
|
-
await image.stats();
|
|
213
|
-
return { mimeType: imageMimeTypes[format], width, height: metadata.pageHeight ?? height, pages: metadata.pages ?? 1 };
|
|
214
|
-
} catch (error) {
|
|
215
|
-
throw new Error("[Holo Media] Image could not be decoded.", { cause: error });
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
});
|
|
219
168
|
|
|
220
169
|
// src/registry.ts
|
|
221
170
|
var mediaRegistrySymbol = /* @__PURE__ */ Symbol.for("@holo-js/media.registry");
|
|
@@ -405,7 +354,14 @@ import { BlockList, isIP } from "net";
|
|
|
405
354
|
import { tmpdir } from "os";
|
|
406
355
|
import { basename as basename2, resolve, sep } from "path";
|
|
407
356
|
import { Readable } from "stream";
|
|
408
|
-
import {
|
|
357
|
+
import { config } from "@holo-js/config";
|
|
358
|
+
import { localStoragePathInternals } from "@holo-js/kernel";
|
|
359
|
+
import { inspectFileContent } from "@holo-js/kernel/file-content";
|
|
360
|
+
import {
|
|
361
|
+
Storage as Storage4,
|
|
362
|
+
StorageMetadataUnsupportedError,
|
|
363
|
+
StorageStreamingUnsupportedError
|
|
364
|
+
} from "@holo-js/storage/runtime";
|
|
409
365
|
|
|
410
366
|
// src/queue.ts
|
|
411
367
|
import {
|
|
@@ -417,27 +373,24 @@ import {
|
|
|
417
373
|
import { connectionAsyncContext } from "@holo-js/db";
|
|
418
374
|
|
|
419
375
|
// src/model/conversions.ts
|
|
376
|
+
import { randomBytes } from "crypto";
|
|
377
|
+
import { mimeTypeFromFileName as mimeTypeFromFileName2 } from "@holo-js/kernel/file-content";
|
|
420
378
|
import { Storage as Storage2 } from "@holo-js/storage/runtime";
|
|
421
379
|
|
|
422
380
|
// src/model/mutation.ts
|
|
423
381
|
import { DB } from "@holo-js/db";
|
|
424
382
|
import { Storage } from "@holo-js/storage/runtime";
|
|
425
383
|
var MediaMutation = class {
|
|
426
|
-
|
|
384
|
+
created = [];
|
|
427
385
|
async put(disk, path, contents) {
|
|
428
|
-
|
|
429
|
-
this.written.push({ disk, path, previous });
|
|
386
|
+
this.created.push({ disk, path });
|
|
430
387
|
await Storage.disk(disk).put(path, contents);
|
|
431
388
|
}
|
|
432
389
|
async compensate() {
|
|
433
390
|
const failures = [];
|
|
434
|
-
for (const file of this.
|
|
391
|
+
for (const file of this.created.splice(0).reverse()) {
|
|
435
392
|
try {
|
|
436
|
-
|
|
437
|
-
await Storage.disk(file.disk).put(file.path, file.previous);
|
|
438
|
-
} else {
|
|
439
|
-
await Storage.disk(file.disk).delete(file.path);
|
|
440
|
-
}
|
|
393
|
+
await Storage.disk(file.disk).delete(file.path);
|
|
441
394
|
} catch (error) {
|
|
442
395
|
failures.push(error);
|
|
443
396
|
}
|
|
@@ -446,41 +399,62 @@ var MediaMutation = class {
|
|
|
446
399
|
if (failures.length > 1) throw new AggregateError(failures, "[Holo Media] File compensation failed.");
|
|
447
400
|
}
|
|
448
401
|
};
|
|
402
|
+
async function compensateAfterFailure(mutation, error) {
|
|
403
|
+
try {
|
|
404
|
+
await mutation.compensate();
|
|
405
|
+
} catch (cleanupError) {
|
|
406
|
+
return new AggregateError([error, cleanupError], "[Holo Media] File compensation failed.");
|
|
407
|
+
}
|
|
408
|
+
return error;
|
|
409
|
+
}
|
|
449
410
|
async function runMediaMutation(options) {
|
|
450
411
|
const mutation = new MediaMutation();
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
failures
|
|
463
|
-
}
|
|
464
|
-
if (failures.length === 1) throw failures[0];
|
|
465
|
-
if (failures.length > 1) throw new AggregateError(failures, "[Holo Media] Rollback restoration failed.");
|
|
466
|
-
});
|
|
467
|
-
const result = await options.operation(mutation);
|
|
468
|
-
transaction.afterCommit(async () => {
|
|
469
|
-
const failures = [];
|
|
470
|
-
for (const effect of options.afterCommit ?? []) {
|
|
412
|
+
let written;
|
|
413
|
+
try {
|
|
414
|
+
written = await options.write(mutation);
|
|
415
|
+
} catch (error) {
|
|
416
|
+
throw await compensateAfterFailure(mutation, error);
|
|
417
|
+
}
|
|
418
|
+
let transactionStarted = false;
|
|
419
|
+
try {
|
|
420
|
+
return await DB.writeTransaction(async (transaction) => {
|
|
421
|
+
transactionStarted = true;
|
|
422
|
+
transaction.afterRollback(async () => {
|
|
423
|
+
const failures = [];
|
|
471
424
|
try {
|
|
472
|
-
await
|
|
425
|
+
await mutation.compensate();
|
|
473
426
|
} catch (error) {
|
|
474
427
|
failures.push(error);
|
|
475
428
|
}
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
429
|
+
try {
|
|
430
|
+
await options.afterRollback?.();
|
|
431
|
+
} catch (error) {
|
|
432
|
+
failures.push(error);
|
|
433
|
+
}
|
|
434
|
+
if (failures.length === 1) throw failures[0];
|
|
435
|
+
if (failures.length > 1) throw new AggregateError(failures, "[Holo Media] Rollback restoration failed.");
|
|
436
|
+
});
|
|
437
|
+
const result = await options.operation(mutation, written);
|
|
438
|
+
transaction.afterCommit(async () => {
|
|
439
|
+
const failures = [];
|
|
440
|
+
for (const effect of options.afterCommit ?? []) {
|
|
441
|
+
try {
|
|
442
|
+
await effect(result);
|
|
443
|
+
} catch (error) {
|
|
444
|
+
failures.push(error);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
options.afterEffects?.();
|
|
448
|
+
if (failures.length === 0) return;
|
|
449
|
+
const cause = failures.length === 1 ? failures[0] : new AggregateError(failures, "[Holo Media] Post-commit effects failed.");
|
|
450
|
+
throw new Error(options.committedMessage, { cause });
|
|
451
|
+
});
|
|
452
|
+
return result;
|
|
481
453
|
});
|
|
482
|
-
|
|
483
|
-
|
|
454
|
+
} catch (error) {
|
|
455
|
+
if (transactionStarted) throw error;
|
|
456
|
+
throw await compensateAfterFailure(mutation, error);
|
|
457
|
+
}
|
|
484
458
|
}
|
|
485
459
|
async function removeStoredFiles(files, retained = []) {
|
|
486
460
|
const failures = [];
|
|
@@ -508,6 +482,7 @@ async function deleteMediaItems(items, owner) {
|
|
|
508
482
|
if (items.length === 0) return;
|
|
509
483
|
await runMediaMutation({
|
|
510
484
|
committedMessage: "[Holo Media] Deletion remains committed; post-commit file cleanup failed.",
|
|
485
|
+
write: async () => void 0,
|
|
511
486
|
operation: async () => {
|
|
512
487
|
for (const item of items) await item.getEntity().delete();
|
|
513
488
|
owner?.forgetRelation("media");
|
|
@@ -588,7 +563,13 @@ function resolveQueuedConversionNames(options) {
|
|
|
588
563
|
).filter((conversion2) => conversion2.queued).map((conversion2) => conversion2.name)
|
|
589
564
|
);
|
|
590
565
|
}
|
|
591
|
-
|
|
566
|
+
function withUniqueSuffix(path) {
|
|
567
|
+
const token = randomBytes(8).toString("hex");
|
|
568
|
+
const slash = path.lastIndexOf("/");
|
|
569
|
+
const dot = path.lastIndexOf(".");
|
|
570
|
+
return dot > slash + 1 ? `${path.slice(0, dot)}.${token}${path.slice(dot)}` : `${path}.${token}`;
|
|
571
|
+
}
|
|
572
|
+
async function writeStoredConversions(options) {
|
|
592
573
|
const requested = normalizeRequestedConversions(options.requestedConversions);
|
|
593
574
|
const matchingConversions = resolveMatchingConversions(
|
|
594
575
|
options.definition,
|
|
@@ -596,9 +577,6 @@ async function generateStoredConversions(options) {
|
|
|
596
577
|
requested,
|
|
597
578
|
{ includeQueued: options.includeQueued === true }
|
|
598
579
|
);
|
|
599
|
-
if (matchingConversions.length === 0) {
|
|
600
|
-
return Object.freeze({});
|
|
601
|
-
}
|
|
602
580
|
const generatedConversions = /* @__PURE__ */ Object.create(null);
|
|
603
581
|
const executor = getMediaConversionExecutor();
|
|
604
582
|
for (const conversion2 of matchingConversions) {
|
|
@@ -611,7 +589,7 @@ async function generateStoredConversions(options) {
|
|
|
611
589
|
continue;
|
|
612
590
|
}
|
|
613
591
|
const generatedFileName = sanitizeFileName(generated.fileName ?? options.source.fileName);
|
|
614
|
-
const
|
|
592
|
+
const plannedPath = getMediaPathGenerator().conversionPath({
|
|
615
593
|
uuid: options.source.uuid,
|
|
616
594
|
fileName: options.source.fileName,
|
|
617
595
|
extension: options.source.extension,
|
|
@@ -619,27 +597,22 @@ async function generateStoredConversions(options) {
|
|
|
619
597
|
conversion: conversion2,
|
|
620
598
|
generatedFileName
|
|
621
599
|
});
|
|
622
|
-
const
|
|
623
|
-
const
|
|
624
|
-
const
|
|
625
|
-
await options.mutation.put(
|
|
600
|
+
const path = options.unique ? withUniqueSuffix(plannedPath) : plannedPath;
|
|
601
|
+
const disk = generated.disk ?? options.conversionsDisk;
|
|
602
|
+
const contents = await toBinaryContent(generated.contents);
|
|
603
|
+
await options.mutation.put(disk, path, contents);
|
|
626
604
|
generatedConversions[conversion2.name] = Object.freeze({
|
|
627
|
-
path
|
|
628
|
-
disk
|
|
605
|
+
path,
|
|
606
|
+
disk,
|
|
629
607
|
fileName: generatedFileName,
|
|
630
|
-
mimeType:
|
|
631
|
-
size: getContentSize(
|
|
608
|
+
mimeType: generated.mimeType?.trim() || mimeTypeFromFileName2(generatedFileName) || void 0,
|
|
609
|
+
size: getContentSize(contents)
|
|
632
610
|
});
|
|
633
611
|
}
|
|
634
612
|
return Object.freeze(generatedConversions);
|
|
635
613
|
}
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
if (requested?.length && !requested.includes(name) || !conversion2?.path) return false;
|
|
639
|
-
const nextConversion = next[name];
|
|
640
|
-
return nextConversion?.path !== conversion2.path || (nextConversion.disk ?? fallbackDisk) !== (conversion2.disk ?? fallbackDisk);
|
|
641
|
-
}).map(([, conversion2]) => ({ disk: conversion2.disk ?? fallbackDisk, path: conversion2.path }));
|
|
642
|
-
await removeStoredFiles(obsolete);
|
|
614
|
+
function obsoleteConversionFiles(current, next, fallbackDisk) {
|
|
615
|
+
return Object.entries(current).filter(([name, conversion2]) => conversion2?.path && next[name]?.path !== conversion2.path).map(([, conversion2]) => ({ disk: conversion2.disk ?? fallbackDisk, path: conversion2.path }));
|
|
643
616
|
}
|
|
644
617
|
async function regenerateMediaEntityConversions(options) {
|
|
645
618
|
const media = options.media;
|
|
@@ -648,7 +621,6 @@ async function regenerateMediaEntityConversions(options) {
|
|
|
648
621
|
const collectionName = String(media.get("collection_name"));
|
|
649
622
|
const collection2 = resolveCollectionDefinition(definition, collectionName);
|
|
650
623
|
const requested = normalizeRequestedConversions(options.conversions);
|
|
651
|
-
const current = media.get("generated_conversions") ?? {};
|
|
652
624
|
const conversionsDisk = String(media.get("conversions_disk") ?? media.get("disk"));
|
|
653
625
|
resolveMatchingConversions(definition, collectionName, requested, {
|
|
654
626
|
includeQueued: options.includeQueued === true
|
|
@@ -659,46 +631,56 @@ async function regenerateMediaEntityConversions(options) {
|
|
|
659
631
|
`[Holo Media] Cannot regenerate conversions for media "${media.get("uuid")}" because the original file is missing.`
|
|
660
632
|
);
|
|
661
633
|
}
|
|
662
|
-
const
|
|
663
|
-
|
|
664
|
-
) : {};
|
|
665
|
-
return await runMediaMutation({
|
|
634
|
+
const mediaId = media.get("id");
|
|
635
|
+
const result = await runMediaMutation({
|
|
666
636
|
committedMessage: "[Holo Media] Regeneration remains committed; post-commit cleanup or conversion dispatch failed.",
|
|
667
637
|
afterRollback: async () => {
|
|
668
638
|
options.owner?.forgetRelation("media");
|
|
669
639
|
await media.refresh();
|
|
670
640
|
},
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
641
|
+
write: async (mutation) => await writeStoredConversions({
|
|
642
|
+
definition,
|
|
643
|
+
collection: collection2,
|
|
644
|
+
conversionsDisk,
|
|
645
|
+
requestedConversions: requested,
|
|
646
|
+
includeQueued: options.includeQueued === true,
|
|
647
|
+
unique: true,
|
|
648
|
+
mutation,
|
|
649
|
+
source: {
|
|
650
|
+
uuid: String(media.get("uuid")),
|
|
651
|
+
fileName: String(media.get("file_name")),
|
|
652
|
+
extension: media.get("extension") ?? void 0,
|
|
653
|
+
mimeType: media.get("mime_type") ?? void 0,
|
|
654
|
+
size: sourceContents.byteLength,
|
|
655
|
+
contents: sourceContents
|
|
656
|
+
}
|
|
657
|
+
}),
|
|
658
|
+
operation: async (_mutation, regenerated) => {
|
|
659
|
+
const locked = await Media.query().where("id", mediaId).lockForUpdate().first();
|
|
660
|
+
if (!locked) {
|
|
661
|
+
throw new Error(`[Holo Media] Cannot regenerate conversions for media "${media.get("uuid")}" because the media row no longer exists.`);
|
|
662
|
+
}
|
|
663
|
+
const current = locked.get("generated_conversions") ?? {};
|
|
664
|
+
const nextBase = requested?.length ? Object.fromEntries(
|
|
665
|
+
Object.entries(current).filter(([name]) => !requested.includes(name))
|
|
666
|
+
) : {};
|
|
688
667
|
const generatedConversions = Object.freeze({ ...nextBase, ...regenerated });
|
|
689
668
|
media.forceFill({ generated_conversions: generatedConversions });
|
|
690
669
|
await media.save();
|
|
691
670
|
options.owner?.forgetRelation("media");
|
|
692
|
-
return
|
|
671
|
+
return {
|
|
672
|
+
generatedConversions,
|
|
673
|
+
obsolete: obsoleteConversionFiles(current, generatedConversions, conversionsDisk)
|
|
674
|
+
};
|
|
693
675
|
},
|
|
694
676
|
afterCommit: [
|
|
695
|
-
async (
|
|
696
|
-
await
|
|
677
|
+
async ({ obsolete }) => {
|
|
678
|
+
await removeStoredFiles(obsolete);
|
|
697
679
|
},
|
|
698
680
|
async () => {
|
|
699
681
|
if (options.includeQueued) return;
|
|
700
682
|
await dispatchQueuedMediaConversionsForModel({
|
|
701
|
-
mediaId
|
|
683
|
+
mediaId,
|
|
702
684
|
conversionNames: resolveQueuedConversionNames({ definition, collectionName, requestedConversions: requested })
|
|
703
685
|
}, async () => {
|
|
704
686
|
await media.refresh();
|
|
@@ -709,6 +691,7 @@ async function regenerateMediaEntityConversions(options) {
|
|
|
709
691
|
options.owner?.forgetRelation("media");
|
|
710
692
|
}
|
|
711
693
|
});
|
|
694
|
+
return result.generatedConversions;
|
|
712
695
|
}
|
|
713
696
|
|
|
714
697
|
// src/queue.ts
|
|
@@ -839,6 +822,8 @@ var MediaItem = class {
|
|
|
839
822
|
this.entity = entity;
|
|
840
823
|
this.owner = owner;
|
|
841
824
|
}
|
|
825
|
+
entity;
|
|
826
|
+
owner;
|
|
842
827
|
get record() {
|
|
843
828
|
return this.entity.toAttributes();
|
|
844
829
|
}
|
|
@@ -923,8 +908,34 @@ var MediaItem = class {
|
|
|
923
908
|
};
|
|
924
909
|
|
|
925
910
|
// src/model/adder.ts
|
|
911
|
+
var RemoteMediaSource = class {
|
|
912
|
+
constructor(url) {
|
|
913
|
+
this.url = url;
|
|
914
|
+
}
|
|
915
|
+
url;
|
|
916
|
+
};
|
|
917
|
+
var StoredMediaSource = class {
|
|
918
|
+
constructor(disk, path) {
|
|
919
|
+
this.disk = disk;
|
|
920
|
+
this.path = path;
|
|
921
|
+
}
|
|
922
|
+
disk;
|
|
923
|
+
path;
|
|
924
|
+
};
|
|
926
925
|
var DEFAULT_REMOTE_MEDIA_MAX_SIZE = 10 * 1024 * 1024;
|
|
926
|
+
var REMOTE_MEDIA_TIMEOUT_MS = 3e4;
|
|
927
|
+
var DEFAULT_MEDIA_DISK = "public";
|
|
927
928
|
var BLOCKED_REMOTE_MEDIA_ADDRESSES = createBlockedRemoteMediaAddresses();
|
|
929
|
+
var GLOBALLY_REACHABLE_BLOCKED_IPV4_ADDRESSES = createGloballyReachableBlockedIPv4Addresses();
|
|
930
|
+
var GLOBAL_UNICAST_IPV6_ADDRESSES = createGlobalUnicastIPv6Addresses();
|
|
931
|
+
var IETF_PROTOCOL_ASSIGNMENTS = createIETFProtocolAssignmentsBlockList();
|
|
932
|
+
var GLOBALLY_REACHABLE_IETF_PROTOCOL_ASSIGNMENTS = createGloballyReachableIETFProtocolAssignments();
|
|
933
|
+
var NAT64_WELL_KNOWN_PREFIX = Buffer.concat([Buffer.from([0, 100, 255, 155]), Buffer.alloc(8)]);
|
|
934
|
+
var SIX_TO_FOUR_PREFIX = Buffer.from([32, 2]);
|
|
935
|
+
var IPV4_COMPATIBLE_PREFIX = Buffer.alloc(12);
|
|
936
|
+
var TRANSLATED_IPV4_PREFIX = Buffer.from([0, 0, 0, 0, 0, 0, 0, 0, 255, 255, 0, 0]);
|
|
937
|
+
var IPV4_MAPPED_PREFIX = Buffer.from([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 255]);
|
|
938
|
+
var TEREDO_PREFIX = Buffer.from([32, 1, 0, 0]);
|
|
928
939
|
var LOCAL_MEDIA_SOURCE_ROOTS = Object.freeze([
|
|
929
940
|
tmpdir(),
|
|
930
941
|
resolve(process.cwd(), "storage")
|
|
@@ -934,9 +945,9 @@ var defaultRemoteMediaAddressResolver = async (hostname) => await lookup(hostnam
|
|
|
934
945
|
verbatim: true
|
|
935
946
|
});
|
|
936
947
|
function createRemoteMediaDownloader(resolver, requester) {
|
|
937
|
-
return async (url) => {
|
|
948
|
+
return async (url, signal) => {
|
|
938
949
|
const address = await resolveRemoteMediaAddress(url, resolver);
|
|
939
|
-
return await requester(url, address);
|
|
950
|
+
return await requester(url, address, signal);
|
|
940
951
|
};
|
|
941
952
|
}
|
|
942
953
|
var defaultRemoteMediaDownloader = createRemoteMediaDownloader(defaultRemoteMediaAddressResolver, requestRemoteMedia);
|
|
@@ -950,19 +961,14 @@ var MediaAddValidationException = class extends Error {
|
|
|
950
961
|
this.error = error;
|
|
951
962
|
}
|
|
952
963
|
};
|
|
953
|
-
function
|
|
954
|
-
|
|
955
|
-
if (defaultDisk.visibility === "public") {
|
|
956
|
-
return defaultDisk.name;
|
|
957
|
-
}
|
|
964
|
+
function resolveMediaDefaultDisk() {
|
|
965
|
+
let configuredDisk;
|
|
958
966
|
try {
|
|
959
|
-
|
|
960
|
-
if (publicDisk.visibility === "public") {
|
|
961
|
-
return publicDisk.name;
|
|
962
|
-
}
|
|
967
|
+
configuredDisk = config("media.disk");
|
|
963
968
|
} catch {
|
|
969
|
+
return DEFAULT_MEDIA_DISK;
|
|
964
970
|
}
|
|
965
|
-
return
|
|
971
|
+
return typeof configuredDisk === "string" && configuredDisk.trim() ? configuredDisk.trim() : DEFAULT_MEDIA_DISK;
|
|
966
972
|
}
|
|
967
973
|
function parseRemoteFileName(url) {
|
|
968
974
|
const parsedUrl = new URL(url);
|
|
@@ -1007,53 +1013,61 @@ function createMaxSizeError(fileName, collectionName, maxSize, actualSize) {
|
|
|
1007
1013
|
async function readRemoteMediaContents(response, fileName, maxSize, collectionName) {
|
|
1008
1014
|
const resolvedMaxSize = typeof maxSize === "number" ? maxSize : DEFAULT_REMOTE_MEDIA_MAX_SIZE;
|
|
1009
1015
|
if (!response.body) {
|
|
1010
|
-
const
|
|
1011
|
-
if (
|
|
1012
|
-
throw createMaxSizeError(fileName, collectionName, resolvedMaxSize,
|
|
1016
|
+
const contents = new Uint8Array(await response.arrayBuffer());
|
|
1017
|
+
if (contents.byteLength > resolvedMaxSize) {
|
|
1018
|
+
throw createMaxSizeError(fileName, collectionName, resolvedMaxSize, contents.byteLength);
|
|
1013
1019
|
}
|
|
1014
|
-
return
|
|
1020
|
+
return contents;
|
|
1015
1021
|
}
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1022
|
+
return await readBoundedMediaContents(
|
|
1023
|
+
readWebStreamChunks(response.body),
|
|
1024
|
+
fileName,
|
|
1025
|
+
resolvedMaxSize,
|
|
1026
|
+
collectionName
|
|
1027
|
+
);
|
|
1028
|
+
}
|
|
1029
|
+
async function* readWebStreamChunks(stream) {
|
|
1030
|
+
const reader = stream.getReader();
|
|
1019
1031
|
try {
|
|
1020
1032
|
while (true) {
|
|
1021
1033
|
const { done, value } = await reader.read();
|
|
1022
1034
|
if (done) {
|
|
1023
|
-
|
|
1024
|
-
}
|
|
1025
|
-
if (!value) {
|
|
1026
|
-
continue;
|
|
1035
|
+
return;
|
|
1027
1036
|
}
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
await reader.cancel().catch(() => void 0);
|
|
1031
|
-
throw createMaxSizeError(fileName, collectionName, resolvedMaxSize, totalSize);
|
|
1037
|
+
if (value) {
|
|
1038
|
+
yield value;
|
|
1032
1039
|
}
|
|
1033
|
-
chunks.push(value);
|
|
1034
1040
|
}
|
|
1035
1041
|
} finally {
|
|
1042
|
+
await reader.cancel().catch(() => void 0);
|
|
1036
1043
|
reader.releaseLock();
|
|
1037
1044
|
}
|
|
1045
|
+
}
|
|
1046
|
+
async function readBoundedMediaContents(chunks, fileName, maxSize, collectionName) {
|
|
1047
|
+
const parts = [];
|
|
1048
|
+
let totalSize = 0;
|
|
1049
|
+
for await (const chunk of chunks) {
|
|
1050
|
+
totalSize += chunk.byteLength;
|
|
1051
|
+
if (totalSize > maxSize) {
|
|
1052
|
+
throw createMaxSizeError(fileName, collectionName, maxSize, totalSize);
|
|
1053
|
+
}
|
|
1054
|
+
parts.push(chunk);
|
|
1055
|
+
}
|
|
1038
1056
|
const contents = new Uint8Array(totalSize);
|
|
1039
1057
|
let offset = 0;
|
|
1040
|
-
for (const
|
|
1041
|
-
contents.set(
|
|
1042
|
-
offset +=
|
|
1058
|
+
for (const part of parts) {
|
|
1059
|
+
contents.set(part, offset);
|
|
1060
|
+
offset += part.byteLength;
|
|
1043
1061
|
}
|
|
1044
1062
|
return contents;
|
|
1045
1063
|
}
|
|
1046
1064
|
async function resolveMediaSource(input, overrideFileName, overrideName, options) {
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
options.collectionName
|
|
1054
|
-
);
|
|
1055
|
-
}
|
|
1056
|
-
if (typeof input !== "string" && isUrlInput(input)) {
|
|
1065
|
+
const source = await readMediaSource(input, overrideFileName, overrideName, options);
|
|
1066
|
+
const { mimeType } = await inspectFileContent(source.contents, source.fileName);
|
|
1067
|
+
return mimeType ? { ...source, mimeType } : source;
|
|
1068
|
+
}
|
|
1069
|
+
async function readMediaSource(input, overrideFileName, overrideName, options) {
|
|
1070
|
+
if (input instanceof RemoteMediaSource) {
|
|
1057
1071
|
return resolveRemoteMediaSource(
|
|
1058
1072
|
input,
|
|
1059
1073
|
overrideFileName,
|
|
@@ -1062,6 +1076,12 @@ async function resolveMediaSource(input, overrideFileName, overrideName, options
|
|
|
1062
1076
|
options.collectionName
|
|
1063
1077
|
);
|
|
1064
1078
|
}
|
|
1079
|
+
if (input instanceof StoredMediaSource) {
|
|
1080
|
+
return resolveStorageMediaSource(input, overrideFileName, overrideName, options);
|
|
1081
|
+
}
|
|
1082
|
+
if (typeof input === "string" && /^https?:\/\//i.test(input) || typeof input === "object" && input !== null && "url" in input) {
|
|
1083
|
+
throw new Error("[@holo-js/media] addMedia() reads file paths and binary contents. Use addMediaFromUrl() to download a URL.");
|
|
1084
|
+
}
|
|
1065
1085
|
if (typeof input === "string" || isPathInput(input)) {
|
|
1066
1086
|
const path = await resolveLocalMediaSourcePath(typeof input === "string" ? input : input.path);
|
|
1067
1087
|
const contents2 = await readFile(path);
|
|
@@ -1069,7 +1089,6 @@ async function resolveMediaSource(input, overrideFileName, overrideName, options
|
|
|
1069
1089
|
return {
|
|
1070
1090
|
contents: contents2,
|
|
1071
1091
|
fileName: fileName2,
|
|
1072
|
-
mimeType: inferMimeType(fileName2),
|
|
1073
1092
|
extension: getExtension(fileName2),
|
|
1074
1093
|
size: contents2.byteLength,
|
|
1075
1094
|
name: getDisplayName(fileName2, overrideName)
|
|
@@ -1081,7 +1100,6 @@ async function resolveMediaSource(input, overrideFileName, overrideName, options
|
|
|
1081
1100
|
return {
|
|
1082
1101
|
contents: contents2,
|
|
1083
1102
|
fileName: fileName2,
|
|
1084
|
-
mimeType: inferMimeType(fileName2, input.mimeType),
|
|
1085
1103
|
extension: getExtension(fileName2),
|
|
1086
1104
|
size: getContentSize(contents2),
|
|
1087
1105
|
name: getDisplayName(fileName2, overrideName ?? input.name)
|
|
@@ -1092,7 +1110,6 @@ async function resolveMediaSource(input, overrideFileName, overrideName, options
|
|
|
1092
1110
|
return {
|
|
1093
1111
|
contents,
|
|
1094
1112
|
fileName,
|
|
1095
|
-
mimeType: inferMimeType(fileName, getBinaryMimeType(input)),
|
|
1096
1113
|
extension: getExtension(fileName),
|
|
1097
1114
|
size: getContentSize(contents),
|
|
1098
1115
|
name: getDisplayName(fileName, overrideName)
|
|
@@ -1102,52 +1119,133 @@ function getBinaryFileName(input) {
|
|
|
1102
1119
|
const name = input.name;
|
|
1103
1120
|
return typeof name === "string" && name.trim() ? name : "media.bin";
|
|
1104
1121
|
}
|
|
1105
|
-
function getBinaryMimeType(input) {
|
|
1106
|
-
const type = input.type;
|
|
1107
|
-
return typeof type === "string" && type.trim() ? type : void 0;
|
|
1108
|
-
}
|
|
1109
1122
|
function isPathInput(input) {
|
|
1110
1123
|
return typeof input === "object" && input !== null && "path" in input;
|
|
1111
1124
|
}
|
|
1112
|
-
function
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
const
|
|
1117
|
-
const
|
|
1118
|
-
if (
|
|
1119
|
-
throw
|
|
1120
|
-
}
|
|
1121
|
-
|
|
1125
|
+
async function resolveStorageMediaSource(input, overrideFileName, overrideName, options) {
|
|
1126
|
+
const disk = Storage4.disk(input.disk);
|
|
1127
|
+
const fileName = sanitizeFileName(overrideFileName ?? localStoragePathInternals.splitLocalStoragePath(input.path).at(-1) ?? "");
|
|
1128
|
+
const maxSize = options.maxSize;
|
|
1129
|
+
const metadataAvailable = typeof maxSize === "number" && await checkStoredFileSize(disk, input, fileName, maxSize, options.collectionName);
|
|
1130
|
+
const chunks = await openStoredFileStream(disk, input);
|
|
1131
|
+
if (chunks === null) {
|
|
1132
|
+
throw missingStoredFileError(input);
|
|
1133
|
+
}
|
|
1134
|
+
let contents;
|
|
1135
|
+
if (chunks) {
|
|
1136
|
+
contents = await readBoundedMediaContents(chunks, fileName, maxSize ?? Number.POSITIVE_INFINITY, options.collectionName);
|
|
1137
|
+
} else if (typeof maxSize !== "number") {
|
|
1138
|
+
contents = await readWholeStoredFile(disk, input);
|
|
1139
|
+
} else if (!metadataAvailable) {
|
|
1122
1140
|
throw new Error(
|
|
1123
|
-
`[
|
|
1141
|
+
`[@holo-js/media] Collection "${options.collectionName}" has a maxSize, so "${input.path}" on disk "${input.disk}" needs a disk that supports file metadata or streaming.`
|
|
1124
1142
|
);
|
|
1143
|
+
} else {
|
|
1144
|
+
contents = await readWholeStoredFile(disk, input);
|
|
1145
|
+
if (contents.byteLength > maxSize) {
|
|
1146
|
+
throw createMaxSizeError(fileName, options.collectionName, maxSize, contents.byteLength);
|
|
1147
|
+
}
|
|
1125
1148
|
}
|
|
1126
|
-
const fileName = sanitizeFileName(
|
|
1127
|
-
overrideFileName ?? input.fileName ?? parseRemoteFileName(remoteUrl.toString())
|
|
1128
|
-
);
|
|
1129
|
-
const responseMimeType = response.headers.get("content-type")?.split(";")[0];
|
|
1130
|
-
const contentLengthHeader = response.headers.get("content-length");
|
|
1131
|
-
const contentLength = contentLengthHeader?.trim() ? Number(contentLengthHeader) : Number.NaN;
|
|
1132
|
-
const resolvedMaxSize = typeof maxSize === "number" ? maxSize : DEFAULT_REMOTE_MEDIA_MAX_SIZE;
|
|
1133
|
-
if (Number.isFinite(contentLength) && contentLength > resolvedMaxSize) {
|
|
1134
|
-
throw createMaxSizeError(fileName, collectionName, resolvedMaxSize, contentLength);
|
|
1135
|
-
}
|
|
1136
|
-
const contents = await readRemoteMediaContents(
|
|
1137
|
-
response,
|
|
1138
|
-
fileName,
|
|
1139
|
-
maxSize,
|
|
1140
|
-
collectionName
|
|
1141
|
-
);
|
|
1142
1149
|
return {
|
|
1143
1150
|
contents,
|
|
1144
1151
|
fileName,
|
|
1145
|
-
mimeType: inferMimeType(fileName, input.mimeType ?? responseMimeType ?? void 0),
|
|
1146
1152
|
extension: getExtension(fileName),
|
|
1147
1153
|
size: contents.byteLength,
|
|
1148
|
-
name: getDisplayName(fileName, overrideName
|
|
1154
|
+
name: getDisplayName(fileName, overrideName)
|
|
1149
1155
|
};
|
|
1150
1156
|
}
|
|
1157
|
+
async function checkStoredFileSize(disk, input, fileName, maxSize, collectionName) {
|
|
1158
|
+
let metadata;
|
|
1159
|
+
try {
|
|
1160
|
+
metadata = await disk.stat(input.path);
|
|
1161
|
+
} catch (error) {
|
|
1162
|
+
if (error instanceof StorageMetadataUnsupportedError) {
|
|
1163
|
+
return false;
|
|
1164
|
+
}
|
|
1165
|
+
throw error;
|
|
1166
|
+
}
|
|
1167
|
+
if (!metadata) {
|
|
1168
|
+
throw missingStoredFileError(input);
|
|
1169
|
+
}
|
|
1170
|
+
if (metadata.size > maxSize) {
|
|
1171
|
+
throw createMaxSizeError(fileName, collectionName, maxSize, metadata.size);
|
|
1172
|
+
}
|
|
1173
|
+
return true;
|
|
1174
|
+
}
|
|
1175
|
+
async function openStoredFileStream(disk, input) {
|
|
1176
|
+
try {
|
|
1177
|
+
return await disk.readStream(input.path);
|
|
1178
|
+
} catch (error) {
|
|
1179
|
+
if (error instanceof StorageStreamingUnsupportedError) {
|
|
1180
|
+
return void 0;
|
|
1181
|
+
}
|
|
1182
|
+
throw error;
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
async function readWholeStoredFile(disk, input) {
|
|
1186
|
+
const contents = await disk.getBytes(input.path);
|
|
1187
|
+
if (!contents) {
|
|
1188
|
+
throw missingStoredFileError(input);
|
|
1189
|
+
}
|
|
1190
|
+
return contents;
|
|
1191
|
+
}
|
|
1192
|
+
function missingStoredFileError(input) {
|
|
1193
|
+
return new Error(`[@holo-js/media] File "${input.path}" was not found on disk "${input.disk}".`);
|
|
1194
|
+
}
|
|
1195
|
+
async function withRemoteMediaDeadline(download) {
|
|
1196
|
+
const controller = new AbortController();
|
|
1197
|
+
let timer;
|
|
1198
|
+
const deadline = new Promise((_resolve, reject) => {
|
|
1199
|
+
timer = setTimeout(() => {
|
|
1200
|
+
const error = new Error(
|
|
1201
|
+
`[@holo-js/media] Remote media downloads must finish within ${REMOTE_MEDIA_TIMEOUT_MS / 1e3} seconds.`
|
|
1202
|
+
);
|
|
1203
|
+
controller.abort(error);
|
|
1204
|
+
reject(error);
|
|
1205
|
+
}, REMOTE_MEDIA_TIMEOUT_MS);
|
|
1206
|
+
});
|
|
1207
|
+
try {
|
|
1208
|
+
return await Promise.race([download(controller.signal), deadline]);
|
|
1209
|
+
} finally {
|
|
1210
|
+
clearTimeout(timer);
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
async function resolveRemoteMediaSource(input, overrideFileName, overrideName, maxSize, collectionName) {
|
|
1214
|
+
const remoteUrl = resolveRemoteMediaUrl(input.url);
|
|
1215
|
+
return await withRemoteMediaDeadline(async (signal) => {
|
|
1216
|
+
const response = await remoteMediaDownloader(remoteUrl, signal);
|
|
1217
|
+
if (response.status >= 300 && response.status < 400) {
|
|
1218
|
+
throw new Error("[@holo-js/media] Remote media downloads do not follow redirects.");
|
|
1219
|
+
}
|
|
1220
|
+
if (!response.ok) {
|
|
1221
|
+
throw new Error(
|
|
1222
|
+
`[Holo Media] Failed to download media from "${remoteUrl.toString()}" (${response.status} ${response.statusText}).`
|
|
1223
|
+
);
|
|
1224
|
+
}
|
|
1225
|
+
const fileName = sanitizeFileName(
|
|
1226
|
+
overrideFileName ?? parseRemoteFileName(remoteUrl.toString())
|
|
1227
|
+
);
|
|
1228
|
+
const contentLengthHeader = response.headers.get("content-length");
|
|
1229
|
+
const contentLength = contentLengthHeader?.trim() ? Number(contentLengthHeader) : Number.NaN;
|
|
1230
|
+
const resolvedMaxSize = typeof maxSize === "number" ? maxSize : DEFAULT_REMOTE_MEDIA_MAX_SIZE;
|
|
1231
|
+
if (Number.isFinite(contentLength) && contentLength > resolvedMaxSize) {
|
|
1232
|
+
throw createMaxSizeError(fileName, collectionName, resolvedMaxSize, contentLength);
|
|
1233
|
+
}
|
|
1234
|
+
const contents = await readRemoteMediaContents(
|
|
1235
|
+
response,
|
|
1236
|
+
fileName,
|
|
1237
|
+
maxSize,
|
|
1238
|
+
collectionName
|
|
1239
|
+
);
|
|
1240
|
+
return {
|
|
1241
|
+
contents,
|
|
1242
|
+
fileName,
|
|
1243
|
+
extension: getExtension(fileName),
|
|
1244
|
+
size: contents.byteLength,
|
|
1245
|
+
name: getDisplayName(fileName, overrideName)
|
|
1246
|
+
};
|
|
1247
|
+
});
|
|
1248
|
+
}
|
|
1151
1249
|
async function resolveLocalMediaSourcePath(path) {
|
|
1152
1250
|
const resolvedPath = await realpath(path);
|
|
1153
1251
|
const allowedRoots = await Promise.all(LOCAL_MEDIA_SOURCE_ROOTS.map(async (root) => await realpath(root).catch(() => null)));
|
|
@@ -1172,9 +1270,13 @@ function resolveRemoteMediaUrl(value) {
|
|
|
1172
1270
|
if (url.username || url.password) {
|
|
1173
1271
|
throw new Error("[@holo-js/media] Remote media URLs must not include credentials.");
|
|
1174
1272
|
}
|
|
1175
|
-
if (
|
|
1273
|
+
if (isLocalRemoteMediaName(url.hostname)) {
|
|
1176
1274
|
throw new Error("[@holo-js/media] Remote media URLs must not target local or private hosts.");
|
|
1177
1275
|
}
|
|
1276
|
+
const literal = parseRemoteMediaIPLiteral(url.hostname);
|
|
1277
|
+
if (literal) {
|
|
1278
|
+
assertRemoteMediaAddressesAllowed([literal]);
|
|
1279
|
+
}
|
|
1178
1280
|
return url;
|
|
1179
1281
|
}
|
|
1180
1282
|
function createBlockedRemoteMediaAddresses() {
|
|
@@ -1188,42 +1290,167 @@ function createBlockedRemoteMediaAddresses() {
|
|
|
1188
1290
|
["172.16.0.0", 12],
|
|
1189
1291
|
["192.0.0.0", 24],
|
|
1190
1292
|
["192.0.2.0", 24],
|
|
1293
|
+
["192.88.99.2", 32],
|
|
1191
1294
|
["192.168.0.0", 16],
|
|
1192
1295
|
["198.18.0.0", 15],
|
|
1193
1296
|
["198.51.100.0", 24],
|
|
1194
1297
|
["203.0.113.0", 24],
|
|
1195
|
-
["224.0.0.0", 4]
|
|
1298
|
+
["224.0.0.0", 4],
|
|
1299
|
+
["240.0.0.0", 4],
|
|
1300
|
+
["255.255.255.255", 32]
|
|
1196
1301
|
]) {
|
|
1197
1302
|
blockList.addSubnet(network, prefix, "ipv4");
|
|
1198
1303
|
}
|
|
1199
1304
|
for (const [network, prefix] of [
|
|
1200
1305
|
["::", 128],
|
|
1201
1306
|
["::1", 128],
|
|
1307
|
+
["100::", 64],
|
|
1308
|
+
["100:0:0:1::", 64],
|
|
1309
|
+
["2001:db8::", 32],
|
|
1310
|
+
["3fff::", 20],
|
|
1311
|
+
["5f00::", 16],
|
|
1202
1312
|
["fc00::", 7],
|
|
1203
1313
|
["fe80::", 10],
|
|
1314
|
+
["fec0::", 10],
|
|
1204
1315
|
["ff00::", 8]
|
|
1205
1316
|
]) {
|
|
1206
1317
|
blockList.addSubnet(network, prefix, "ipv6");
|
|
1207
1318
|
}
|
|
1208
1319
|
return blockList;
|
|
1209
1320
|
}
|
|
1321
|
+
function createGloballyReachableBlockedIPv4Addresses() {
|
|
1322
|
+
const blockList = new BlockList();
|
|
1323
|
+
for (const [network, prefix] of [
|
|
1324
|
+
["192.0.0.9", 32],
|
|
1325
|
+
["192.0.0.10", 32]
|
|
1326
|
+
]) {
|
|
1327
|
+
blockList.addSubnet(network, prefix, "ipv4");
|
|
1328
|
+
}
|
|
1329
|
+
return blockList;
|
|
1330
|
+
}
|
|
1331
|
+
function createGlobalUnicastIPv6Addresses() {
|
|
1332
|
+
const blockList = new BlockList();
|
|
1333
|
+
blockList.addSubnet("2000::", 3, "ipv6");
|
|
1334
|
+
return blockList;
|
|
1335
|
+
}
|
|
1336
|
+
function createIETFProtocolAssignmentsBlockList() {
|
|
1337
|
+
const blockList = new BlockList();
|
|
1338
|
+
blockList.addSubnet("2001::", 23, "ipv6");
|
|
1339
|
+
return blockList;
|
|
1340
|
+
}
|
|
1341
|
+
function createGloballyReachableIETFProtocolAssignments() {
|
|
1342
|
+
const blockList = new BlockList();
|
|
1343
|
+
for (const [network, prefix] of [
|
|
1344
|
+
["2001::", 32],
|
|
1345
|
+
["2001:1::1", 128],
|
|
1346
|
+
["2001:1::2", 128],
|
|
1347
|
+
["2001:1::3", 128],
|
|
1348
|
+
["2001:3::", 32],
|
|
1349
|
+
["2001:4:112::", 48],
|
|
1350
|
+
["2001:20::", 28],
|
|
1351
|
+
["2001:30::", 28]
|
|
1352
|
+
]) {
|
|
1353
|
+
blockList.addSubnet(network, prefix, "ipv6");
|
|
1354
|
+
}
|
|
1355
|
+
return blockList;
|
|
1356
|
+
}
|
|
1357
|
+
function isBlockedRemoteMediaIPv4Address(address) {
|
|
1358
|
+
return BLOCKED_REMOTE_MEDIA_ADDRESSES.check(address, "ipv4") && !GLOBALLY_REACHABLE_BLOCKED_IPV4_ADDRESSES.check(address, "ipv4");
|
|
1359
|
+
}
|
|
1210
1360
|
function isBlockedRemoteMediaAddress(address) {
|
|
1211
|
-
|
|
1361
|
+
if (address.family === 4) {
|
|
1362
|
+
return isBlockedRemoteMediaIPv4Address(address.address);
|
|
1363
|
+
}
|
|
1364
|
+
return isBlockedRemoteMediaIPv6Address(address.address);
|
|
1365
|
+
}
|
|
1366
|
+
function isBlockedRemoteMediaIPv6Address(address) {
|
|
1367
|
+
const bytes = findIPv6Bytes(address);
|
|
1368
|
+
if (!bytes) {
|
|
1369
|
+
return true;
|
|
1370
|
+
}
|
|
1371
|
+
if (!GLOBAL_UNICAST_IPV6_ADDRESSES.check(address, "ipv6")) {
|
|
1372
|
+
const mappedAddress = findMappedIPv4Address(bytes);
|
|
1373
|
+
return mappedAddress === null || isBlockedRemoteMediaIPv4Address(mappedAddress);
|
|
1374
|
+
}
|
|
1375
|
+
if (BLOCKED_REMOTE_MEDIA_ADDRESSES.check(address, "ipv6")) {
|
|
1376
|
+
return true;
|
|
1377
|
+
}
|
|
1378
|
+
if (IETF_PROTOCOL_ASSIGNMENTS.check(address, "ipv6") && !GLOBALLY_REACHABLE_IETF_PROTOCOL_ASSIGNMENTS.check(address, "ipv6")) {
|
|
1379
|
+
return true;
|
|
1380
|
+
}
|
|
1381
|
+
return findEmbeddedIPv4Addresses(bytes).some(isBlockedRemoteMediaIPv4Address);
|
|
1382
|
+
}
|
|
1383
|
+
function findMappedIPv4Address(bytes) {
|
|
1384
|
+
const mappedPrefixes = [NAT64_WELL_KNOWN_PREFIX, IPV4_COMPATIBLE_PREFIX, IPV4_MAPPED_PREFIX, TRANSLATED_IPV4_PREFIX];
|
|
1385
|
+
if (!mappedPrefixes.some((prefix) => bytes.subarray(0, 12).equals(prefix))) {
|
|
1386
|
+
return null;
|
|
1387
|
+
}
|
|
1388
|
+
return formatIPv4Octets(bytes.subarray(12, 16));
|
|
1389
|
+
}
|
|
1390
|
+
function findEmbeddedIPv4Addresses(bytes) {
|
|
1391
|
+
const embeddedAddresses = [];
|
|
1392
|
+
if (bytes.subarray(0, 2).equals(SIX_TO_FOUR_PREFIX)) {
|
|
1393
|
+
embeddedAddresses.push(formatIPv4Octets(bytes.subarray(2, 6)));
|
|
1394
|
+
}
|
|
1395
|
+
if (bytes.subarray(0, 4).equals(TEREDO_PREFIX)) {
|
|
1396
|
+
embeddedAddresses.push(formatIPv4Octets(bytes.subarray(12, 16).map((octet) => octet ^ 255)));
|
|
1397
|
+
}
|
|
1398
|
+
const interfaceIdentifierPrefix = bytes.readUInt16BE(8);
|
|
1399
|
+
if ((interfaceIdentifierPrefix === 0 || interfaceIdentifierPrefix === 512) && bytes.readUInt16BE(10) === 24318) {
|
|
1400
|
+
embeddedAddresses.push(formatIPv4Octets(bytes.subarray(12, 16)));
|
|
1401
|
+
}
|
|
1402
|
+
return embeddedAddresses;
|
|
1403
|
+
}
|
|
1404
|
+
function findIPv6Bytes(address) {
|
|
1405
|
+
let canonical;
|
|
1406
|
+
try {
|
|
1407
|
+
canonical = new URL(`http://[${address}]`).hostname.slice(1, -1);
|
|
1408
|
+
} catch {
|
|
1409
|
+
return null;
|
|
1410
|
+
}
|
|
1411
|
+
const halves = canonical.split("::");
|
|
1412
|
+
const head = halves[0] ? halves[0].split(":") : [];
|
|
1413
|
+
const tail = halves[1] ? halves[1].split(":") : [];
|
|
1414
|
+
const zeroGroups = halves.length === 2 ? 8 - head.length - tail.length : 0;
|
|
1415
|
+
const groups = [...head, ...Array(zeroGroups).fill("0"), ...tail];
|
|
1416
|
+
if (groups.length !== 8) {
|
|
1417
|
+
return null;
|
|
1418
|
+
}
|
|
1419
|
+
return Buffer.from(groups.flatMap((group) => {
|
|
1420
|
+
const value = Number.parseInt(group, 16);
|
|
1421
|
+
return [value >> 8, value & 255];
|
|
1422
|
+
}));
|
|
1423
|
+
}
|
|
1424
|
+
function formatIPv4Octets(octets) {
|
|
1425
|
+
return Array.from(octets).join(".");
|
|
1212
1426
|
}
|
|
1213
1427
|
async function resolveRemoteMediaAddress(url, resolver) {
|
|
1214
|
-
const addresses = await
|
|
1428
|
+
const addresses = await resolveRemoteMediaHostAddresses(url.hostname, resolver);
|
|
1429
|
+
assertRemoteMediaAddressesAllowed(addresses);
|
|
1430
|
+
return addresses[0];
|
|
1431
|
+
}
|
|
1432
|
+
function assertRemoteMediaAddressesAllowed(addresses) {
|
|
1215
1433
|
if (addresses.length === 0 || addresses.some(isBlockedRemoteMediaAddress)) {
|
|
1216
1434
|
throw new Error("[@holo-js/media] Remote media URLs must not resolve to local or private hosts.");
|
|
1217
1435
|
}
|
|
1218
|
-
return addresses[0];
|
|
1219
1436
|
}
|
|
1220
|
-
function
|
|
1437
|
+
async function resolveRemoteMediaHostAddresses(hostname, resolver) {
|
|
1438
|
+
const literal = parseRemoteMediaIPLiteral(hostname);
|
|
1439
|
+
return literal ? [literal] : await resolver(hostname);
|
|
1440
|
+
}
|
|
1441
|
+
function parseRemoteMediaIPLiteral(hostname) {
|
|
1442
|
+
const address = hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
|
|
1443
|
+
const family = isIP(address);
|
|
1444
|
+
return family === 0 ? null : { address, family };
|
|
1445
|
+
}
|
|
1446
|
+
function requestRemoteMedia(url, address, signal) {
|
|
1221
1447
|
return new Promise((resolveResponse, reject) => {
|
|
1222
1448
|
const request = resolveRemoteMediaRequest(url.protocol)(url, {
|
|
1223
1449
|
headers: {
|
|
1224
1450
|
host: url.host
|
|
1225
1451
|
},
|
|
1226
|
-
lookup: createPinnedRemoteMediaLookup(address)
|
|
1452
|
+
lookup: createPinnedRemoteMediaLookup(address),
|
|
1453
|
+
signal
|
|
1227
1454
|
}, (response) => {
|
|
1228
1455
|
const headers = new Headers();
|
|
1229
1456
|
appendRemoteMediaHeaders(headers, response.headers);
|
|
@@ -1252,14 +1479,9 @@ function appendRemoteMediaHeaders(headers, source) {
|
|
|
1252
1479
|
function resolveRemoteMediaRequest(protocol) {
|
|
1253
1480
|
return protocol === "https:" ? requestHttps : requestHttp;
|
|
1254
1481
|
}
|
|
1255
|
-
function
|
|
1482
|
+
function isLocalRemoteMediaName(hostname) {
|
|
1256
1483
|
const lowerCaseHostname = hostname.toLowerCase();
|
|
1257
|
-
|
|
1258
|
-
if (normalized === "localhost" || normalized.endsWith(".localhost")) {
|
|
1259
|
-
return true;
|
|
1260
|
-
}
|
|
1261
|
-
const family = isIP(normalized);
|
|
1262
|
-
return family !== 0 && isBlockedRemoteMediaAddress({ address: normalized, family });
|
|
1484
|
+
return lowerCaseHostname === "localhost" || lowerCaseHostname.endsWith(".localhost");
|
|
1263
1485
|
}
|
|
1264
1486
|
function validateSource(collection2, source) {
|
|
1265
1487
|
if (typeof collection2.maxSize === "number" && source.size > collection2.maxSize) {
|
|
@@ -1295,6 +1517,9 @@ var MediaAdder = class _MediaAdder {
|
|
|
1295
1517
|
this.source = source;
|
|
1296
1518
|
this.state = state;
|
|
1297
1519
|
}
|
|
1520
|
+
entity;
|
|
1521
|
+
source;
|
|
1522
|
+
state;
|
|
1298
1523
|
usingName(name) {
|
|
1299
1524
|
return new _MediaAdder(this.entity, this.source, {
|
|
1300
1525
|
...this.state,
|
|
@@ -1356,7 +1581,7 @@ var MediaAdder = class _MediaAdder {
|
|
|
1356
1581
|
collection: collection2,
|
|
1357
1582
|
definition: mediaDefinition,
|
|
1358
1583
|
source,
|
|
1359
|
-
disk: this.state.disk ?? collection2.disk ??
|
|
1584
|
+
disk: this.state.disk ?? collection2.disk ?? resolveMediaDefaultDisk()
|
|
1360
1585
|
});
|
|
1361
1586
|
}
|
|
1362
1587
|
};
|
|
@@ -1373,16 +1598,18 @@ async function attachMedia(options) {
|
|
|
1373
1598
|
afterRollback: () => {
|
|
1374
1599
|
owner.forgetRelation("media");
|
|
1375
1600
|
},
|
|
1376
|
-
|
|
1377
|
-
const existing = collection2.singleFile ? await owner.getMedia(options.collectionName) : [];
|
|
1601
|
+
write: async (mutation) => {
|
|
1378
1602
|
await mutation.put(disk, path, source.contents);
|
|
1379
|
-
|
|
1603
|
+
return await writeStoredConversions({
|
|
1380
1604
|
definition: options.definition,
|
|
1381
1605
|
collection: collection2,
|
|
1382
1606
|
conversionsDisk,
|
|
1383
1607
|
source: { ...source, uuid },
|
|
1384
1608
|
mutation
|
|
1385
1609
|
});
|
|
1610
|
+
},
|
|
1611
|
+
operation: async (_mutation, generatedConversions) => {
|
|
1612
|
+
const existing = collection2.singleFile ? await owner.getMedia(options.collectionName) : [];
|
|
1386
1613
|
const max = await Media.query().where("model_type", ownerDefinition.morphClass).where("model_id", ownerId).where("collection_name", options.collectionName).max("order_column");
|
|
1387
1614
|
const media = await Media.create({
|
|
1388
1615
|
uuid,
|
|
@@ -1481,7 +1708,10 @@ function installEntityMediaMethods() {
|
|
|
1481
1708
|
return new MediaAdder(this, source);
|
|
1482
1709
|
};
|
|
1483
1710
|
prototype.addMediaFromUrl = function addMediaFromUrl(url) {
|
|
1484
|
-
return new MediaAdder(this,
|
|
1711
|
+
return new MediaAdder(this, new RemoteMediaSource(url));
|
|
1712
|
+
};
|
|
1713
|
+
prototype.addMediaFromDisk = function addMediaFromDisk(path, disk) {
|
|
1714
|
+
return new MediaAdder(this, new StoredMediaSource(disk, path));
|
|
1485
1715
|
};
|
|
1486
1716
|
prototype.getMedia = async function getMedia(collectionName) {
|
|
1487
1717
|
const query = createQuery(this, collectionName);
|
|
@@ -1800,7 +2030,6 @@ export {
|
|
|
1800
2030
|
getMediaDefinition,
|
|
1801
2031
|
getMediaDefinitionForMorphClass,
|
|
1802
2032
|
getMediaPathGenerator,
|
|
1803
|
-
mediaRuntimeInternals,
|
|
1804
2033
|
normalizeCollectionDefinitions,
|
|
1805
2034
|
normalizeConversionDefinitions,
|
|
1806
2035
|
normalizeMediaConfig,
|