@devultur/self-host 1.3.2 → 1.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.d.ts CHANGED
@@ -681,6 +681,9 @@ declare const ObjectMeta: {
681
681
  readonly SHA256_VERIFIED: "sha256Verified";
682
682
  /** Epoch ms. From then on the gate responds 410 (and an external sweeper may delete it). */
683
683
  readonly EXPIRES_AT: "expiresAt";
684
+ /** An image's intrinsic size (px), read from its header bytes on a single PUT. */
685
+ readonly IMAGE_WIDTH: "imageWidth";
686
+ readonly IMAGE_HEIGHT: "imageHeight";
684
687
  };
685
688
 
686
689
  /** The upload an upload URL (`/v1/upload/<key>?u=<id>`) points to, as plugins see it. */
package/dist/index.js CHANGED
@@ -870,6 +870,95 @@ import {
870
870
  SHA256_VERIFIED_HEADER
871
871
  } from "@devultur/core";
872
872
 
873
+ // src/routes/upload-image-size.ts
874
+ import { IMAGE_HEIGHT_HEADER, IMAGE_WIDTH_HEADER } from "@devultur/core";
875
+
876
+ // src/image/dimensions.ts
877
+ function readImageDimensions(bytes, contentType) {
878
+ if (contentType === "image/jpeg") return readJpeg(bytes);
879
+ if (contentType === "image/png") return readPng(bytes);
880
+ if (contentType === "image/webp") return readWebp(bytes);
881
+ if (contentType === "image/avif") return readAvif(bytes);
882
+ if (contentType === "image/gif") return readGif(bytes);
883
+ return null;
884
+ }
885
+ var uint16BE = (bytes, offset) => bytes[offset] << 8 | bytes[offset + 1];
886
+ var uint32BE = (bytes, offset) => (bytes[offset] << 24 | bytes[offset + 1] << 16 | bytes[offset + 2] << 8 | bytes[offset + 3]) >>> 0;
887
+ var uint16LE = (bytes, offset) => bytes[offset] | bytes[offset + 1] << 8;
888
+ var uint24LE = (bytes, offset) => bytes[offset] | bytes[offset + 1] << 8 | bytes[offset + 2] << 16;
889
+ var isStartOfFrame = (marker) => marker >= 192 && marker <= 207 && marker !== 196 && marker !== 200 && marker !== 204;
890
+ function readJpeg(bytes) {
891
+ if (bytes[0] !== 255 || bytes[1] !== 216) return null;
892
+ let offset = 2;
893
+ while (offset + 9 < bytes.length) {
894
+ if (bytes[offset] !== 255) return null;
895
+ const marker = bytes[offset + 1];
896
+ if (isStartOfFrame(marker)) {
897
+ return { height: uint16BE(bytes, offset + 5), width: uint16BE(bytes, offset + 7) };
898
+ }
899
+ offset += 2 + uint16BE(bytes, offset + 2);
900
+ }
901
+ return null;
902
+ }
903
+ function readPng(bytes) {
904
+ const isPng2 = bytes[0] === 137 && bytes[1] === 80 && bytes[2] === 78 && bytes[3] === 71;
905
+ if (!isPng2 || bytes.length < 24) return null;
906
+ return { width: uint32BE(bytes, 16), height: uint32BE(bytes, 20) };
907
+ }
908
+ function readWebp(bytes) {
909
+ if (bytes.length < 30) return null;
910
+ const chunk = String.fromCharCode(bytes[12], bytes[13], bytes[14], bytes[15]);
911
+ if (chunk === "VP8X") return { width: uint24LE(bytes, 24) + 1, height: uint24LE(bytes, 27) + 1 };
912
+ if (chunk === "VP8 ") return { width: uint16LE(bytes, 26) & 16383, height: uint16LE(bytes, 28) & 16383 };
913
+ if (chunk === "VP8L") {
914
+ const bits = bytes[21] | bytes[22] << 8 | bytes[23] << 16 | bytes[24] << 24;
915
+ return { width: (bits & 16383) + 1, height: (bits >> 14 & 16383) + 1 };
916
+ }
917
+ return null;
918
+ }
919
+ function readAvif(bytes) {
920
+ const limit = Math.min(bytes.length - 20, 64 * 1024);
921
+ for (let offset = 4; offset < limit; offset += 1) {
922
+ const isIspe = bytes[offset] === 105 && bytes[offset + 1] === 115 && bytes[offset + 2] === 112 && bytes[offset + 3] === 101;
923
+ if (isIspe) return { width: uint32BE(bytes, offset + 8), height: uint32BE(bytes, offset + 12) };
924
+ }
925
+ return null;
926
+ }
927
+ function readGif(bytes) {
928
+ if (bytes.length < 10) return null;
929
+ const signature = String.fromCharCode(...bytes.subarray(0, 6));
930
+ if (signature !== "GIF87a" && signature !== "GIF89a") return null;
931
+ return { width: uint16LE(bytes, 6), height: uint16LE(bytes, 8) };
932
+ }
933
+ var fourCC = (bytes, offset) => String.fromCharCode(bytes[offset], bytes[offset + 1], bytes[offset + 2], bytes[offset + 3]);
934
+ function isAnimatedImage(bytes, contentType) {
935
+ if (contentType === "image/gif") return true;
936
+ if (contentType === "image/webp")
937
+ return bytes.length > 20 && fourCC(bytes, 12) === "VP8X" && (bytes[20] & 2) !== 0;
938
+ if (contentType === "image/png") return hasPngAnimationControl(bytes);
939
+ if (contentType === "image/avif") return hasAvifSequenceBrand(bytes);
940
+ return false;
941
+ }
942
+ function hasPngAnimationControl(bytes) {
943
+ let offset = 8;
944
+ while (offset + 8 <= bytes.length) {
945
+ const chunk = fourCC(bytes, offset + 4);
946
+ if (chunk === "acTL") return true;
947
+ if (chunk === "IDAT") return false;
948
+ offset += 12 + uint32BE(bytes, offset);
949
+ }
950
+ return false;
951
+ }
952
+ function hasAvifSequenceBrand(bytes) {
953
+ if (bytes.length < 16 || fourCC(bytes, 4) !== "ftyp") return false;
954
+ const boxEnd = Math.min(uint32BE(bytes, 0), bytes.length);
955
+ for (let offset = 8; offset + 4 <= boxEnd; offset += 4) {
956
+ if (offset === 12) continue;
957
+ if (fourCC(bytes, offset) === "avis") return true;
958
+ }
959
+ return false;
960
+ }
961
+
873
962
  // src/routes/upload-integrity.ts
874
963
  import { normalizeSha256, SHA256_HEADER, sha256Hex } from "@devultur/core";
875
964
  var ObjectMeta = {
@@ -877,12 +966,17 @@ var ObjectMeta = {
877
966
  /** `"true"` = the worker hashed the bytes; `"false"` = sha256 declared by the client (multipart). */
878
967
  SHA256_VERIFIED: "sha256Verified",
879
968
  /** Epoch ms. From then on the gate responds 410 (and an external sweeper may delete it). */
880
- EXPIRES_AT: "expiresAt"
969
+ EXPIRES_AT: "expiresAt",
970
+ /** An image's intrinsic size (px), read from its header bytes on a single PUT. */
971
+ IMAGE_WIDTH: "imageWidth",
972
+ IMAGE_HEIGHT: "imageHeight"
881
973
  };
882
974
  var WORKER_METADATA_KEYS = /* @__PURE__ */ new Set([
883
975
  "security",
884
976
  "enc",
885
977
  ObjectMeta.EXPIRES_AT.toLowerCase(),
978
+ ObjectMeta.IMAGE_WIDTH.toLowerCase(),
979
+ ObjectMeta.IMAGE_HEIGHT.toLowerCase(),
886
980
  "derivedfrom",
887
981
  LOCK_METADATA_KEY
888
982
  ]);
@@ -959,6 +1053,25 @@ async function discardStaleVariants(bucket, pending) {
959
1053
  await deletePrefix(bucket, variantPrefix(pending.storageKey));
960
1054
  }
961
1055
 
1056
+ // src/routes/upload-image-size.ts
1057
+ var MAX_IMAGE_SIDE = 1e5;
1058
+ function isImageSide(value) {
1059
+ return Number.isInteger(value) && value > 0 && value <= MAX_IMAGE_SIDE;
1060
+ }
1061
+ function imageSizeMetadata(contentType, body) {
1062
+ if (!contentType.startsWith("image/")) return {};
1063
+ const dimensions = readImageDimensions(new Uint8Array(body), contentType);
1064
+ if (!dimensions || !isImageSide(dimensions.width) || !isImageSide(dimensions.height)) return {};
1065
+ return { [ObjectMeta.IMAGE_WIDTH]: String(dimensions.width), [ObjectMeta.IMAGE_HEIGHT]: String(dimensions.height) };
1066
+ }
1067
+ function applyImageSizeHeaders(headers, customMetadata) {
1068
+ const width = customMetadata?.[ObjectMeta.IMAGE_WIDTH];
1069
+ const height = customMetadata?.[ObjectMeta.IMAGE_HEIGHT];
1070
+ if (!width || !height) return;
1071
+ headers.set(IMAGE_WIDTH_HEADER, width);
1072
+ headers.set(IMAGE_HEIGHT_HEADER, height);
1073
+ }
1074
+
962
1075
  // src/routes/object-response.ts
963
1076
  function objectExpiresAt(customMetadata) {
964
1077
  const expiresAt = Number(customMetadata?.[ObjectMeta.EXPIRES_AT]);
@@ -1023,6 +1136,7 @@ async function streamObject(input) {
1023
1136
  headers.set("ETag", object.httpEtag);
1024
1137
  headers.set("Accept-Ranges", "bytes");
1025
1138
  applyIntegrityHeaders(headers, object.customMetadata);
1139
+ applyImageSizeHeaders(headers, object.customMetadata);
1026
1140
  const enc = decodeEncryptionMeta2(object.customMetadata?.enc);
1027
1141
  const loaded = enc ? await loadStorageKey(input.keysBucket, input.fullKey, enc.keyId) : null;
1028
1142
  if (enc && !loaded) {
@@ -2565,85 +2679,6 @@ function hasTransformQuery(url) {
2565
2679
  return TRANSFORM_QUERY_PARAMS.some((param) => url.searchParams.has(param));
2566
2680
  }
2567
2681
 
2568
- // src/image/dimensions.ts
2569
- function readImageDimensions(bytes, contentType) {
2570
- if (contentType === "image/jpeg") return readJpeg(bytes);
2571
- if (contentType === "image/png") return readPng(bytes);
2572
- if (contentType === "image/webp") return readWebp(bytes);
2573
- if (contentType === "image/avif") return readAvif(bytes);
2574
- return null;
2575
- }
2576
- var uint16BE = (bytes, offset) => bytes[offset] << 8 | bytes[offset + 1];
2577
- var uint32BE = (bytes, offset) => (bytes[offset] << 24 | bytes[offset + 1] << 16 | bytes[offset + 2] << 8 | bytes[offset + 3]) >>> 0;
2578
- var uint16LE = (bytes, offset) => bytes[offset] | bytes[offset + 1] << 8;
2579
- var uint24LE = (bytes, offset) => bytes[offset] | bytes[offset + 1] << 8 | bytes[offset + 2] << 16;
2580
- var isStartOfFrame = (marker) => marker >= 192 && marker <= 207 && marker !== 196 && marker !== 200 && marker !== 204;
2581
- function readJpeg(bytes) {
2582
- if (bytes[0] !== 255 || bytes[1] !== 216) return null;
2583
- let offset = 2;
2584
- while (offset + 9 < bytes.length) {
2585
- if (bytes[offset] !== 255) return null;
2586
- const marker = bytes[offset + 1];
2587
- if (isStartOfFrame(marker)) {
2588
- return { height: uint16BE(bytes, offset + 5), width: uint16BE(bytes, offset + 7) };
2589
- }
2590
- offset += 2 + uint16BE(bytes, offset + 2);
2591
- }
2592
- return null;
2593
- }
2594
- function readPng(bytes) {
2595
- const isPng2 = bytes[0] === 137 && bytes[1] === 80 && bytes[2] === 78 && bytes[3] === 71;
2596
- if (!isPng2 || bytes.length < 24) return null;
2597
- return { width: uint32BE(bytes, 16), height: uint32BE(bytes, 20) };
2598
- }
2599
- function readWebp(bytes) {
2600
- if (bytes.length < 30) return null;
2601
- const chunk = String.fromCharCode(bytes[12], bytes[13], bytes[14], bytes[15]);
2602
- if (chunk === "VP8X") return { width: uint24LE(bytes, 24) + 1, height: uint24LE(bytes, 27) + 1 };
2603
- if (chunk === "VP8 ") return { width: uint16LE(bytes, 26) & 16383, height: uint16LE(bytes, 28) & 16383 };
2604
- if (chunk === "VP8L") {
2605
- const bits = bytes[21] | bytes[22] << 8 | bytes[23] << 16 | bytes[24] << 24;
2606
- return { width: (bits & 16383) + 1, height: (bits >> 14 & 16383) + 1 };
2607
- }
2608
- return null;
2609
- }
2610
- function readAvif(bytes) {
2611
- const limit = Math.min(bytes.length - 20, 64 * 1024);
2612
- for (let offset = 4; offset < limit; offset += 1) {
2613
- const isIspe = bytes[offset] === 105 && bytes[offset + 1] === 115 && bytes[offset + 2] === 112 && bytes[offset + 3] === 101;
2614
- if (isIspe) return { width: uint32BE(bytes, offset + 8), height: uint32BE(bytes, offset + 12) };
2615
- }
2616
- return null;
2617
- }
2618
- var fourCC = (bytes, offset) => String.fromCharCode(bytes[offset], bytes[offset + 1], bytes[offset + 2], bytes[offset + 3]);
2619
- function isAnimatedImage(bytes, contentType) {
2620
- if (contentType === "image/gif") return true;
2621
- if (contentType === "image/webp")
2622
- return bytes.length > 20 && fourCC(bytes, 12) === "VP8X" && (bytes[20] & 2) !== 0;
2623
- if (contentType === "image/png") return hasPngAnimationControl(bytes);
2624
- if (contentType === "image/avif") return hasAvifSequenceBrand(bytes);
2625
- return false;
2626
- }
2627
- function hasPngAnimationControl(bytes) {
2628
- let offset = 8;
2629
- while (offset + 8 <= bytes.length) {
2630
- const chunk = fourCC(bytes, offset + 4);
2631
- if (chunk === "acTL") return true;
2632
- if (chunk === "IDAT") return false;
2633
- offset += 12 + uint32BE(bytes, offset);
2634
- }
2635
- return false;
2636
- }
2637
- function hasAvifSequenceBrand(bytes) {
2638
- if (bytes.length < 16 || fourCC(bytes, 4) !== "ftyp") return false;
2639
- const boxEnd = Math.min(uint32BE(bytes, 0), bytes.length);
2640
- for (let offset = 8; offset + 4 <= boxEnd; offset += 4) {
2641
- if (offset === 12) continue;
2642
- if (fourCC(bytes, offset) === "avis") return true;
2643
- }
2644
- return false;
2645
- }
2646
-
2647
2682
  // src/image/storage.ts
2648
2683
  import { decodeEncryptionMeta as decodeEncryptionMeta6, decryptChunk as decryptChunk2 } from "@devultur/core";
2649
2684
  var HEADER_PROBE_BYTES = 256 * 1024;
@@ -5278,7 +5313,8 @@ upload.put("/:key{.+}", async (context) => {
5278
5313
  ...allowedUploadMetadata(pending?.uploadMetadata ?? getRequestState(context).config?.uploadMetadata),
5279
5314
  // A transcode output (no pending) expires with its video's raw.
5280
5315
  ...integrityMetadata(digest.sha256, true, pending?.expiresAt ?? await inheritedExpiresAt(context, key)),
5281
- ...lockMetadata(uploadLockOf(context, key, pending))
5316
+ ...lockMetadata(uploadLockOf(context, key, pending)),
5317
+ ...imageSizeMetadata(contentType, body)
5282
5318
  };
5283
5319
  if (resourceSecurity) customMetadata.security = JSON.stringify(resourceSecurity);
5284
5320
  const write = await prepareUploadWrite(context, pending?.key ?? key, pending, finalStorageKey);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devultur/self-host",
3
- "version": "1.3.2",
3
+ "version": "1.4.0",
4
4
  "description": "Self-hosted media Worker for Cloudflare: R2 uploads, edge-cached delivery, image transforms, HLS transcode and captions",
5
5
  "keywords": [
6
6
  "cloudflare-workers",
@@ -46,7 +46,7 @@
46
46
  "@jsquash/resize": "2.1.1",
47
47
  "@jsquash/webp": "1.5.0",
48
48
  "hono": "^4.7.0",
49
- "@devultur/core": "1.3.2"
49
+ "@devultur/core": "1.4.0"
50
50
  },
51
51
  "devDependencies": {
52
52
  "@cf-wasm/photon": "0.4.0",