@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/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 (!isImageSource(source.mimeType, source.extension)) {
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
- let pipeline = sharp(await toBuffer(source.contents), {
106
+ const image = sharp(await toBuffer(source.contents), {
141
107
  animated: true,
142
- failOn: "warning"
143
- }).rotate();
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: inferMimeType(fileName)
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 { Storage as Storage4 } from "@holo-js/storage/runtime";
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
- written = [];
384
+ created = [];
427
385
  async put(disk, path, contents) {
428
- const previous = await Storage.disk(disk).getBytes(path);
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.written.splice(0).reverse()) {
391
+ for (const file of this.created.splice(0).reverse()) {
435
392
  try {
436
- if (file.previous) {
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
- return await DB.writeTransaction(async (transaction) => {
452
- transaction.afterRollback(async () => {
453
- const failures = [];
454
- try {
455
- await mutation.compensate();
456
- } catch (error) {
457
- failures.push(error);
458
- }
459
- try {
460
- await options.afterRollback?.();
461
- } catch (error) {
462
- failures.push(error);
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 effect(result);
425
+ await mutation.compensate();
473
426
  } catch (error) {
474
427
  failures.push(error);
475
428
  }
476
- }
477
- options.afterEffects?.();
478
- if (failures.length === 0) return;
479
- const cause = failures.length === 1 ? failures[0] : new AggregateError(failures, "[Holo Media] Post-commit effects failed.");
480
- throw new Error(options.committedMessage, { cause });
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
- return result;
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
- async function generateStoredConversions(options) {
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 conversionPath = getMediaPathGenerator().conversionPath({
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 targetDisk = generated.disk ?? options.conversionsDisk;
623
- const conversionMimeType = inferMimeType(generatedFileName, generated.mimeType);
624
- const conversionContents = await toBinaryContent(generated.contents);
625
- await options.mutation.put(targetDisk, conversionPath, conversionContents);
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: conversionPath,
628
- disk: targetDisk,
605
+ path,
606
+ disk,
629
607
  fileName: generatedFileName,
630
- mimeType: conversionMimeType,
631
- size: getContentSize(conversionContents)
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
- async function deleteObsoleteConversions(current, next, fallbackDisk, requested) {
637
- const obsolete = Object.entries(current).filter(([name, conversion2]) => {
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 nextBase = requested?.length ? Object.fromEntries(
663
- Object.entries(current).filter(([name]) => !requested.includes(name))
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
- operation: async (mutation) => {
672
- const regenerated = await generateStoredConversions({
673
- definition,
674
- collection: collection2,
675
- conversionsDisk,
676
- requestedConversions: requested,
677
- includeQueued: options.includeQueued === true,
678
- mutation,
679
- source: {
680
- uuid: String(media.get("uuid")),
681
- fileName: String(media.get("file_name")),
682
- extension: media.get("extension") ?? void 0,
683
- mimeType: media.get("mime_type") ?? void 0,
684
- size: sourceContents.byteLength,
685
- contents: sourceContents
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 generatedConversions;
671
+ return {
672
+ generatedConversions,
673
+ obsolete: obsoleteConversionFiles(current, generatedConversions, conversionsDisk)
674
+ };
693
675
  },
694
676
  afterCommit: [
695
- async (generatedConversions) => {
696
- await deleteObsoleteConversions(current, generatedConversions, conversionsDisk, requested);
677
+ async ({ obsolete }) => {
678
+ await removeStoredFiles(obsolete);
697
679
  },
698
680
  async () => {
699
681
  if (options.includeQueued) return;
700
682
  await dispatchQueuedMediaConversionsForModel({
701
- mediaId: media.get("id"),
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 resolveImplicitDiskName() {
954
- const defaultDisk = Storage4.disk();
955
- if (defaultDisk.visibility === "public") {
956
- return defaultDisk.name;
957
- }
964
+ function resolveMediaDefaultDisk() {
965
+ let configuredDisk;
958
966
  try {
959
- const publicDisk = Storage4.disk("public");
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 defaultDisk.name;
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 contents2 = new Uint8Array(await response.arrayBuffer());
1011
- if (contents2.byteLength > resolvedMaxSize) {
1012
- throw createMaxSizeError(fileName, collectionName, resolvedMaxSize, contents2.byteLength);
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 contents2;
1020
+ return contents;
1015
1021
  }
1016
- const reader = response.body.getReader();
1017
- const chunks = [];
1018
- let totalSize = 0;
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
- break;
1024
- }
1025
- if (!value) {
1026
- continue;
1035
+ return;
1027
1036
  }
1028
- totalSize += value.byteLength;
1029
- if (totalSize > resolvedMaxSize) {
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 chunk of chunks) {
1041
- contents.set(chunk, offset);
1042
- offset += chunk.byteLength;
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
- if (typeof input === "string" && /^https?:\/\//i.test(input)) {
1048
- return resolveRemoteMediaSource(
1049
- { url: input },
1050
- overrideFileName,
1051
- overrideName,
1052
- options.maxSize,
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 isUrlInput(input) {
1113
- return typeof input === "object" && input !== null && "url" in input;
1114
- }
1115
- async function resolveRemoteMediaSource(input, overrideFileName, overrideName, maxSize, collectionName) {
1116
- const remoteUrl = resolveRemoteMediaUrl(input.url);
1117
- const response = await remoteMediaDownloader(remoteUrl);
1118
- if (response.status >= 300 && response.status < 400) {
1119
- throw new Error("[@holo-js/media] Remote media downloads do not follow redirects.");
1120
- }
1121
- if (!response.ok) {
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
- `[Holo Media] Failed to download media from "${remoteUrl.toString()}" (${response.status} ${response.statusText}).`
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 ?? input.name)
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 (isBlockedRemoteMediaHost(url.hostname)) {
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
- return BLOCKED_REMOTE_MEDIA_ADDRESSES.check(address.address, address.family === 4 ? "ipv4" : "ipv6");
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 resolver(url.hostname);
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 requestRemoteMedia(url, address) {
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 isBlockedRemoteMediaHost(hostname) {
1482
+ function isLocalRemoteMediaName(hostname) {
1256
1483
  const lowerCaseHostname = hostname.toLowerCase();
1257
- const normalized = lowerCaseHostname.startsWith("[") && lowerCaseHostname.endsWith("]") ? lowerCaseHostname.slice(1, -1) : lowerCaseHostname;
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 ?? resolveImplicitDiskName()
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
- operation: async (mutation) => {
1377
- const existing = collection2.singleFile ? await owner.getMedia(options.collectionName) : [];
1601
+ write: async (mutation) => {
1378
1602
  await mutation.put(disk, path, source.contents);
1379
- const generatedConversions = await generateStoredConversions({
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, { url });
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,