@devultur/self-host 1.2.0 → 1.3.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 +16 -1
- package/dist/index.js +406 -43
- package/package.json +2 -2
package/dist/index.d.ts
CHANGED
|
@@ -167,7 +167,22 @@ interface WorkerConfig {
|
|
|
167
167
|
*/
|
|
168
168
|
lock?: ObjectLock;
|
|
169
169
|
transcode?: TranscodeConfig;
|
|
170
|
+
/**
|
|
171
|
+
* What an upload whose first bytes contradict its declared Content-Type gets (see `ContentSniffingMode`). Every
|
|
172
|
+
* mismatch is logged (`[content-sniff] {declared, detected, key, action}`). A transcode's outputs are never checked.
|
|
173
|
+
* Unset: `scriptable`.
|
|
174
|
+
*/
|
|
175
|
+
contentSniffing?: ContentSniffingMode;
|
|
170
176
|
}
|
|
177
|
+
/**
|
|
178
|
+
* - `scriptable` (default): `415 content_type_mismatch` only when the bytes are markup (HTML, SVG, XML) under an inert
|
|
179
|
+
* type; any other mismatch is stored with its declared type.
|
|
180
|
+
* - `reject`: `415` for every mismatch.
|
|
181
|
+
* - `octet-stream`: a single PUT that mismatches is stored as `application/octet-stream`; a multipart upload is
|
|
182
|
+
* rejected (R2 fixes its type when it starts).
|
|
183
|
+
* - `off`: no check.
|
|
184
|
+
*/
|
|
185
|
+
type ContentSniffingMode = "scriptable" | "reject" | "octet-stream" | "off";
|
|
171
186
|
/** What an auth plugin established about the request's credential. */
|
|
172
187
|
interface RequestAccess {
|
|
173
188
|
/**
|
|
@@ -752,4 +767,4 @@ interface CreateWorkerOptions {
|
|
|
752
767
|
}
|
|
753
768
|
declare function createWorker(options?: CreateWorkerOptions): Hono<WorkerApp, hono_types.BlankSchema, "/">;
|
|
754
769
|
|
|
755
|
-
export { type AllowedCredential, type AssetListEntry, type AuthorizeLockChangeHook, type BeforeLinkRedeemHook, type ComposedPlugins, type CreateWorkerOptions, type CredentialDecision, type CredentialKind, type CredentialPolicyOptions, type CredentialRequest, DownloadLink, type DownloadLinkInfo, type DownloadLinkRecord, type DownloadLinkStorage, ENCRYPTION_KEY_MISSING_CODE, type EncodeMetrics, HOLD_PAGE_SIZE, type HoldSkip, type ImageTransformParams, type IssueCredentialHook, type Json, KEYS_BUCKET_MISSING_CODE, LOCK_AUDIT_PREFIX, LOCK_HOLD_HEADER, LOCK_METADATA_KEY, LOCK_SIDECAR_PREFIX, LOCK_UNTIL_HEADER, type LockAuditAction, type LockAuditRecord, type LockChangeAction, type LockChangeDecision, type LockChangeRequest, type LockLookupOptions, type LockSidecar, type LockSource, MAX_ASSET_LIST_LIMIT, MAX_EXPIRED_LOOKUPS, MAX_UPLOAD_TOKEN_TTL_SECONDS, MaxResolution, ObjectMeta, type OnLockAuditHook, type PendingUploadInfo, type PluginContext, type PluginMiddleware, type PluginRoute, type PrefixDeleteOptions, type PrefixDeleteResult, type PrefixWalkOptions, RESERVED_LOCK_PREFIXES, type RawStorageClass, type RequestAccess, type RequestState, type RequestStatePatch, type RetainedObject, type RetainedSummary, type RouteResult, type StoredObjectLock, TOKEN_REJECTED_CODE, type TranscodeConfig, type TranscodeJob, UPLOAD_TOKEN_TYPE, type UploadGrant, type UploadRules, type UploadTypeRule, type WorkerApp, WorkerAuthErrorCode, type WorkerConfig, type WorkerEnv, type WorkerPlugin, type WorkerVariables, cancelPendingUpload, createWorker, credentialPolicy, defaultPlugins, deletePrefix, deletePrefixRespectingLocks, downloadLinks, edgeCacheTag, getPendingUpload, getRequestState, getRouteResult, hashKey, imageOriginAuthorization, isAuthenticated, isImageOriginRequest, isObjectExpired, isOpenRoute, isServiceToken, isUploadUrl, isVariantKey, logEncodeMetrics, parseEncodeMetrics, parseTransformParams, readImageOriginScope, readObjectLock, readStoryboardKey, readTokenScopes, readTranscodeOutputKeys, readTranscodeOutputs, retainedSummary, servedCacheControl, staticConfig, storageKey, updateRequestState, uploadGrantFromClaims, variantPrefix, vcpuSecondsPerVideoMinute, verifyViewerToken, withOutputUrls, workerSecretAuth };
|
|
770
|
+
export { type AllowedCredential, type AssetListEntry, type AuthorizeLockChangeHook, type BeforeLinkRedeemHook, type ComposedPlugins, type ContentSniffingMode, type CreateWorkerOptions, type CredentialDecision, type CredentialKind, type CredentialPolicyOptions, type CredentialRequest, DownloadLink, type DownloadLinkInfo, type DownloadLinkRecord, type DownloadLinkStorage, ENCRYPTION_KEY_MISSING_CODE, type EncodeMetrics, HOLD_PAGE_SIZE, type HoldSkip, type ImageTransformParams, type IssueCredentialHook, type Json, KEYS_BUCKET_MISSING_CODE, LOCK_AUDIT_PREFIX, LOCK_HOLD_HEADER, LOCK_METADATA_KEY, LOCK_SIDECAR_PREFIX, LOCK_UNTIL_HEADER, type LockAuditAction, type LockAuditRecord, type LockChangeAction, type LockChangeDecision, type LockChangeRequest, type LockLookupOptions, type LockSidecar, type LockSource, MAX_ASSET_LIST_LIMIT, MAX_EXPIRED_LOOKUPS, MAX_UPLOAD_TOKEN_TTL_SECONDS, MaxResolution, ObjectMeta, type OnLockAuditHook, type PendingUploadInfo, type PluginContext, type PluginMiddleware, type PluginRoute, type PrefixDeleteOptions, type PrefixDeleteResult, type PrefixWalkOptions, RESERVED_LOCK_PREFIXES, type RawStorageClass, type RequestAccess, type RequestState, type RequestStatePatch, type RetainedObject, type RetainedSummary, type RouteResult, type StoredObjectLock, TOKEN_REJECTED_CODE, type TranscodeConfig, type TranscodeJob, UPLOAD_TOKEN_TYPE, type UploadGrant, type UploadRules, type UploadTypeRule, type WorkerApp, WorkerAuthErrorCode, type WorkerConfig, type WorkerEnv, type WorkerPlugin, type WorkerVariables, cancelPendingUpload, createWorker, credentialPolicy, defaultPlugins, deletePrefix, deletePrefixRespectingLocks, downloadLinks, edgeCacheTag, getPendingUpload, getRequestState, getRouteResult, hashKey, imageOriginAuthorization, isAuthenticated, isImageOriginRequest, isObjectExpired, isOpenRoute, isServiceToken, isUploadUrl, isVariantKey, logEncodeMetrics, parseEncodeMetrics, parseTransformParams, readImageOriginScope, readObjectLock, readStoryboardKey, readTokenScopes, readTranscodeOutputKeys, readTranscodeOutputs, retainedSummary, servedCacheControl, staticConfig, storageKey, updateRequestState, uploadGrantFromClaims, variantPrefix, vcpuSecondsPerVideoMinute, verifyViewerToken, withOutputUrls, workerSecretAuth };
|
package/dist/index.js
CHANGED
|
@@ -801,6 +801,62 @@ function addRouteResult(context, result) {
|
|
|
801
801
|
context.set(ROUTE_RESULT_VARIABLE, { ...getRouteResult(context), ...result });
|
|
802
802
|
}
|
|
803
803
|
|
|
804
|
+
// src/routes/content-safety.ts
|
|
805
|
+
var SANDBOX_CSP = "sandbox; default-src 'none'";
|
|
806
|
+
var SAFE_INLINE_TYPES = /* @__PURE__ */ new Set([
|
|
807
|
+
"image/png",
|
|
808
|
+
"image/jpeg",
|
|
809
|
+
"image/webp",
|
|
810
|
+
"image/gif",
|
|
811
|
+
"image/avif",
|
|
812
|
+
"application/pdf",
|
|
813
|
+
"text/plain",
|
|
814
|
+
"text/csv",
|
|
815
|
+
"application/json",
|
|
816
|
+
"text/vtt",
|
|
817
|
+
"application/vnd.apple.mpegurl",
|
|
818
|
+
"application/x-mpegurl"
|
|
819
|
+
]);
|
|
820
|
+
var SAFE_INLINE_FAMILIES = ["video/", "audio/"];
|
|
821
|
+
function contentTypeEssence(contentType) {
|
|
822
|
+
return contentType?.split(";")[0]?.trim().toLowerCase() ?? "";
|
|
823
|
+
}
|
|
824
|
+
function isSafeInlineType(contentType) {
|
|
825
|
+
const essence = contentTypeEssence(contentType);
|
|
826
|
+
if (SAFE_INLINE_TYPES.has(essence)) return true;
|
|
827
|
+
return SAFE_INLINE_FAMILIES.some((family) => essence.startsWith(family));
|
|
828
|
+
}
|
|
829
|
+
function attachmentOf(disposition, filename) {
|
|
830
|
+
if (disposition?.startsWith("attachment")) return disposition;
|
|
831
|
+
if (disposition?.startsWith("inline")) return `attachment${disposition.slice("inline".length)}`;
|
|
832
|
+
return contentDispositionAttachment(filename || "download");
|
|
833
|
+
}
|
|
834
|
+
function applyContentSafety(headers, filename, isPublic) {
|
|
835
|
+
headers.set("X-Content-Type-Options", "nosniff");
|
|
836
|
+
if (isPublic) headers.set("Cross-Origin-Resource-Policy", "cross-origin");
|
|
837
|
+
if (isSafeInlineType(headers.get("Content-Type"))) return;
|
|
838
|
+
headers.set("Content-Security-Policy", SANDBOX_CSP);
|
|
839
|
+
headers.set("Content-Disposition", attachmentOf(headers.get("Content-Disposition"), filename));
|
|
840
|
+
}
|
|
841
|
+
function basenameOf(url) {
|
|
842
|
+
const last = new URL(url).pathname.split("/").pop() ?? "";
|
|
843
|
+
try {
|
|
844
|
+
return decodeURIComponent(last);
|
|
845
|
+
} catch {
|
|
846
|
+
return last;
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
function isPublicMediaUrl(url) {
|
|
850
|
+
return new URL(url).pathname.startsWith("/v1/media/public/");
|
|
851
|
+
}
|
|
852
|
+
var contentSafety = async (context, next) => {
|
|
853
|
+
await next();
|
|
854
|
+
const headers = new Headers(context.res.headers);
|
|
855
|
+
applyContentSafety(headers, basenameOf(context.req.url), isPublicMediaUrl(context.req.url));
|
|
856
|
+
const { body, status, statusText } = context.res;
|
|
857
|
+
context.res = new Response(body, { status, statusText, headers });
|
|
858
|
+
};
|
|
859
|
+
|
|
804
860
|
// src/routes/object-response.ts
|
|
805
861
|
import {
|
|
806
862
|
AES_BLOCK_BYTES,
|
|
@@ -865,6 +921,25 @@ async function digestBody(body, declared, header) {
|
|
|
865
921
|
if (expected && expected !== actual) return { mismatch: { expected, actual } };
|
|
866
922
|
return { sha256: actual };
|
|
867
923
|
}
|
|
924
|
+
async function checkPartSha256(context, multipart, body) {
|
|
925
|
+
const partSha256 = headerSha256(context);
|
|
926
|
+
if (partSha256 === null) {
|
|
927
|
+
return context.json({ error: "x-devultur-sha256 must be a 64-character hex string", code: "invalid_sha256" }, 400);
|
|
928
|
+
}
|
|
929
|
+
if (multipart.sha256 && !partSha256) {
|
|
930
|
+
return context.json(
|
|
931
|
+
{
|
|
932
|
+
error: "Each part must carry its x-devultur-sha256 when the upload declares one",
|
|
933
|
+
code: "part_sha256_required"
|
|
934
|
+
},
|
|
935
|
+
400
|
|
936
|
+
);
|
|
937
|
+
}
|
|
938
|
+
if (!partSha256) return null;
|
|
939
|
+
const digest = await digestBody(body, void 0, partSha256);
|
|
940
|
+
if ("mismatch" in digest) return sha256Mismatch(context, digest.mismatch.expected, digest.mismatch.actual);
|
|
941
|
+
return null;
|
|
942
|
+
}
|
|
868
943
|
function lockMetadata(lock) {
|
|
869
944
|
const encoded = encodeLockMetadata(lock);
|
|
870
945
|
return encoded ? { [LOCK_METADATA_KEY]: encoded } : {};
|
|
@@ -1053,6 +1128,7 @@ async function gateRedemption(context, stub) {
|
|
|
1053
1128
|
return gateLinkRedemption(context, linkInfoOf(await peeked.json()));
|
|
1054
1129
|
}
|
|
1055
1130
|
var download = new Hono2();
|
|
1131
|
+
download.use("*", contentSafety);
|
|
1056
1132
|
download.get("/:linkId", async (context) => {
|
|
1057
1133
|
if (context.req.method === "HEAD") return new Response(null, { status: 405, headers: { Allow: "GET" } });
|
|
1058
1134
|
const linkId = context.req.param("linkId");
|
|
@@ -1256,6 +1332,14 @@ function inferMediaType(contentType) {
|
|
|
1256
1332
|
if (contentType.startsWith("application/")) return MediaType.FILE;
|
|
1257
1333
|
return null;
|
|
1258
1334
|
}
|
|
1335
|
+
function resolveUploadMediaType(declared, contentType, isAssetRequest) {
|
|
1336
|
+
const inferredType = inferMediaType(contentType);
|
|
1337
|
+
if (declared && inferredType && declared !== inferredType) {
|
|
1338
|
+
return failure(400, "media_type_mismatch", `mediaType "${declared}" does not match content type ${contentType}`);
|
|
1339
|
+
}
|
|
1340
|
+
const mediaType = declared ?? inferredType ?? (isAssetRequest ? MediaType.FILE : null);
|
|
1341
|
+
return mediaType ?? failure(400, "unsupported_content_type", `Unsupported content type: ${contentType}`);
|
|
1342
|
+
}
|
|
1259
1343
|
|
|
1260
1344
|
// src/routes/token-upload.ts
|
|
1261
1345
|
var UPLOAD_TOKEN_TYPE = "upload";
|
|
@@ -2508,8 +2592,8 @@ function readJpeg(bytes) {
|
|
|
2508
2592
|
return null;
|
|
2509
2593
|
}
|
|
2510
2594
|
function readPng(bytes) {
|
|
2511
|
-
const
|
|
2512
|
-
if (!
|
|
2595
|
+
const isPng2 = bytes[0] === 137 && bytes[1] === 80 && bytes[2] === 78 && bytes[3] === 71;
|
|
2596
|
+
if (!isPng2 || bytes.length < 24) return null;
|
|
2513
2597
|
return { width: uint32BE(bytes, 16), height: uint32BE(bytes, 20) };
|
|
2514
2598
|
}
|
|
2515
2599
|
function readWebp(bytes) {
|
|
@@ -2920,6 +3004,7 @@ function downloadDisposition(storedDisposition, key, filename) {
|
|
|
2920
3004
|
return contentDispositionAttachment(key.split("/").pop() || "download");
|
|
2921
3005
|
}
|
|
2922
3006
|
var media = new Hono7();
|
|
3007
|
+
media.use("*", contentSafety);
|
|
2923
3008
|
media.get("/:key{.+}", async (context) => {
|
|
2924
3009
|
const key = context.req.param("key");
|
|
2925
3010
|
const fullKey = storageKey(context, key);
|
|
@@ -3042,6 +3127,7 @@ media.get("/:key{.+}", async (context) => {
|
|
|
3042
3127
|
headers.set("Content-Disposition", object.httpMetadata.contentDisposition);
|
|
3043
3128
|
}
|
|
3044
3129
|
if (isOwnerAccess) for (const [name, value] of await lockHeaders(context, key, object)) headers.set(name, value);
|
|
3130
|
+
applyContentSafety(headers, key.split("/").pop() ?? "", !isPrivate);
|
|
3045
3131
|
const { response, kind } = await streamObject({
|
|
3046
3132
|
bucket: context.env.MEDIA_BUCKET,
|
|
3047
3133
|
keysBucket: context.env.KEYS_BUCKET,
|
|
@@ -4369,6 +4455,17 @@ var DERIVED_CONTENT_TYPES = /* @__PURE__ */ new Set([
|
|
|
4369
4455
|
function isDerivedAssetKey(key) {
|
|
4370
4456
|
return DERIVED_ASSET_KEY.test(key);
|
|
4371
4457
|
}
|
|
4458
|
+
var DERIVED_TYPES_BY_FOLDER = {
|
|
4459
|
+
hls: /* @__PURE__ */ new Set(["application/vnd.apple.mpegurl", "video/mp2t"]),
|
|
4460
|
+
mp4: /* @__PURE__ */ new Set(["video/mp4"]),
|
|
4461
|
+
thumbnails: /* @__PURE__ */ new Set(["image/jpeg"]),
|
|
4462
|
+
storyboard: /* @__PURE__ */ new Set(["image/jpeg", "text/vtt"])
|
|
4463
|
+
};
|
|
4464
|
+
var DERIVED_OUTPUT_KEY = /^(?:public|private)\/video\/[A-Za-z0-9-]+\/(hls|mp4|thumbnails|storyboard)\/.+$/;
|
|
4465
|
+
function isTrustedDerivedOutput(key, contentType) {
|
|
4466
|
+
const folder = DERIVED_OUTPUT_KEY.exec(key)?.[1];
|
|
4467
|
+
return folder !== void 0 && (DERIVED_TYPES_BY_FOLDER[folder]?.has(contentType) ?? false);
|
|
4468
|
+
}
|
|
4372
4469
|
var RAW_LIST_LIMIT = 100;
|
|
4373
4470
|
async function inheritedExpiresAt(context, key) {
|
|
4374
4471
|
if (!isDerivedAssetKey(key)) return void 0;
|
|
@@ -4391,6 +4488,7 @@ async function derivedAssetPending(context, key) {
|
|
|
4391
4488
|
return {
|
|
4392
4489
|
contentType: DERIVED_CONTENT_TYPES.has(declared) ? declared : "application/octet-stream",
|
|
4393
4490
|
storageKey: storageKey(context, key),
|
|
4491
|
+
isDerived: true,
|
|
4394
4492
|
...security ? { security } : {},
|
|
4395
4493
|
...expiresAt ? { expiresAt } : {}
|
|
4396
4494
|
};
|
|
@@ -4457,12 +4555,298 @@ async function afterUploadWrite(context, key, pending, write, writtenEnc) {
|
|
|
4457
4555
|
});
|
|
4458
4556
|
}
|
|
4459
4557
|
|
|
4558
|
+
// src/routes/upload-content-check.ts
|
|
4559
|
+
import { decodeEncryptionMeta as decodeEncryptionMeta9 } from "@devultur/core";
|
|
4560
|
+
|
|
4561
|
+
// src/markup-sniff.ts
|
|
4562
|
+
var MARKUP_WINDOW_BYTES = 1024;
|
|
4563
|
+
function utf16Encoding(head) {
|
|
4564
|
+
if (head[0] === 255 && head[1] === 254) return "utf-16le";
|
|
4565
|
+
if (head[0] === 254 && head[1] === 255) return "utf-16be";
|
|
4566
|
+
return null;
|
|
4567
|
+
}
|
|
4568
|
+
function isBinaryDataByte(byte) {
|
|
4569
|
+
return byte <= 8 || byte === 11 || byte >= 14 && byte <= 26 || byte >= 28 && byte <= 31;
|
|
4570
|
+
}
|
|
4571
|
+
function decodesStrictly(head, encoding) {
|
|
4572
|
+
try {
|
|
4573
|
+
new TextDecoder(encoding, { fatal: true }).decode(head, { stream: true });
|
|
4574
|
+
return true;
|
|
4575
|
+
} catch {
|
|
4576
|
+
return false;
|
|
4577
|
+
}
|
|
4578
|
+
}
|
|
4579
|
+
function leadingText(head) {
|
|
4580
|
+
const window = head.subarray(0, MARKUP_WINDOW_BYTES);
|
|
4581
|
+
const decoded = new TextDecoder(utf16Encoding(head) ?? "utf-8").decode(window, { stream: true });
|
|
4582
|
+
return decoded.replace(/^/, "").trimStart();
|
|
4583
|
+
}
|
|
4584
|
+
var HTML_TAGS = ["html", "head", "script", "iframe", "h1", "div", "font", "table", "a", "style", "title", "b"];
|
|
4585
|
+
var MORE_HTML_TAGS = ["body", "br", "p"];
|
|
4586
|
+
var DISTINCT_TAGS = ["html", "head", "script", "iframe", "div", "font", "table", "style", "title", "body"];
|
|
4587
|
+
var tagSource = (tags) => `<(?:${tags.join("|")})[\\s>]`;
|
|
4588
|
+
var ANY_HTML_TAG = new RegExp(tagSource([...HTML_TAGS, ...MORE_HTML_TAGS]));
|
|
4589
|
+
var DISTINCT_HTML_TAG = new RegExp(tagSource(DISTINCT_TAGS));
|
|
4590
|
+
var SVG_TAG = /<svg[\s>/]/;
|
|
4591
|
+
var LEADING_HTML = new RegExp(`^(?:<!doctype html|<!--|${tagSource([...HTML_TAGS, ...MORE_HTML_TAGS])})`);
|
|
4592
|
+
function detectScriptable(head) {
|
|
4593
|
+
const start = leadingText(head).slice(0, 512).toLowerCase();
|
|
4594
|
+
if (start.startsWith("<?xml")) return SVG_TAG.test(start) ? "svg" : "xml";
|
|
4595
|
+
if (/^<svg[\s>/]/.test(start)) return "svg";
|
|
4596
|
+
if (LEADING_HTML.test(start)) return "html";
|
|
4597
|
+
return null;
|
|
4598
|
+
}
|
|
4599
|
+
function windowText(head, end) {
|
|
4600
|
+
const window = head.subarray(0, Math.min(end, MARKUP_WINDOW_BYTES));
|
|
4601
|
+
const utf16 = utf16Encoding(head);
|
|
4602
|
+
const text = utf16 ? new TextDecoder(utf16).decode(window, { stream: true }) : String.fromCharCode(...window);
|
|
4603
|
+
return text.toLowerCase();
|
|
4604
|
+
}
|
|
4605
|
+
function markupIn(text, htmlTag) {
|
|
4606
|
+
if (SVG_TAG.test(text)) return "svg";
|
|
4607
|
+
if (text.includes("<!doctype html") || htmlTag.test(text)) return "html";
|
|
4608
|
+
return null;
|
|
4609
|
+
}
|
|
4610
|
+
function detectEmbeddedMarkup(head) {
|
|
4611
|
+
return markupIn(windowText(head, MARKUP_WINDOW_BYTES), DISTINCT_HTML_TAG);
|
|
4612
|
+
}
|
|
4613
|
+
function detectMarkupBefore(head, end) {
|
|
4614
|
+
const text = windowText(head, end);
|
|
4615
|
+
if (text.includes("<!--")) return "html";
|
|
4616
|
+
return markupIn(text, ANY_HTML_TAG);
|
|
4617
|
+
}
|
|
4618
|
+
|
|
4619
|
+
// src/content-sniff.ts
|
|
4620
|
+
var SNIFF_HEAD_BYTES = 4096;
|
|
4621
|
+
var ascii = (text) => Array.from(text, (char) => char.charCodeAt(0));
|
|
4622
|
+
function hasBytesAt(head, offset, bytes) {
|
|
4623
|
+
if (head.length < offset + bytes.length) return false;
|
|
4624
|
+
return bytes.every((byte, index) => head[offset + index] === byte);
|
|
4625
|
+
}
|
|
4626
|
+
function readAscii(head, start, end) {
|
|
4627
|
+
return String.fromCharCode(...head.subarray(start, Math.min(end, head.length)));
|
|
4628
|
+
}
|
|
4629
|
+
var startsWith = (...bytes) => (head) => hasBytesAt(head, 0, bytes);
|
|
4630
|
+
var riff = (form) => (head) => hasBytesAt(head, 0, ascii("RIFF")) && hasBytesAt(head, 8, ascii(form));
|
|
4631
|
+
var PDF_HEADER = ascii("%PDF-");
|
|
4632
|
+
function pdfHeaderOffset(head) {
|
|
4633
|
+
const searchEnd = Math.min(head.length, MARKUP_WINDOW_BYTES) - PDF_HEADER.length;
|
|
4634
|
+
for (let offset = 0; offset <= searchEnd; offset++) {
|
|
4635
|
+
if (hasBytesAt(head, offset, PDF_HEADER)) return offset;
|
|
4636
|
+
}
|
|
4637
|
+
return -1;
|
|
4638
|
+
}
|
|
4639
|
+
var isPdf = (head) => pdfHeaderOffset(head) >= 0;
|
|
4640
|
+
function ftypBrands(head) {
|
|
4641
|
+
if (!hasBytesAt(head, 4, ascii("ftyp"))) return null;
|
|
4642
|
+
const boxSize = (head[0] ?? 0) << 24 | (head[1] ?? 0) << 16 | (head[2] ?? 0) << 8 | (head[3] ?? 0);
|
|
4643
|
+
const boxEnd = Math.min(boxSize >= 16 ? boxSize : 16, head.length);
|
|
4644
|
+
const brands = [readAscii(head, 8, 12)];
|
|
4645
|
+
for (let offset = 16; offset + 4 <= boxEnd; offset += 4) brands.push(readAscii(head, offset, offset + 4));
|
|
4646
|
+
return brands;
|
|
4647
|
+
}
|
|
4648
|
+
var AVIF_BRANDS = /* @__PURE__ */ new Set(["avif", "avis"]);
|
|
4649
|
+
var HEIF_BRANDS = /* @__PURE__ */ new Set(["heic", "heix", "heim", "heis", "hevc", "hevx", "mif1", "msf1"]);
|
|
4650
|
+
var LEGACY_MOV_ATOMS = ["moov", "mdat", "wide", "free", "skip", "pnot"];
|
|
4651
|
+
var hasBrand = (brands) => (head) => ftypBrands(head)?.some((brand) => brands.has(brand)) ?? false;
|
|
4652
|
+
var isIsoMedia = (head) => {
|
|
4653
|
+
const brands = ftypBrands(head);
|
|
4654
|
+
if (!brands) return LEGACY_MOV_ATOMS.some((atom) => hasBytesAt(head, 4, ascii(atom)));
|
|
4655
|
+
const majorBrand = brands[0] ?? "";
|
|
4656
|
+
return !AVIF_BRANDS.has(majorBrand) && !HEIF_BRANDS.has(majorBrand);
|
|
4657
|
+
};
|
|
4658
|
+
var EBML_MAGIC = [26, 69, 223, 163];
|
|
4659
|
+
var ebmlDocType = (...docTypes) => (head) => {
|
|
4660
|
+
if (!hasBytesAt(head, 0, EBML_MAGIC)) return false;
|
|
4661
|
+
const header = readAscii(head, 4, 64);
|
|
4662
|
+
return docTypes.some((docType) => header.includes(docType));
|
|
4663
|
+
};
|
|
4664
|
+
var isPng = startsWith(137, 80, 78, 71, 13, 10, 26, 10);
|
|
4665
|
+
var isJpeg = startsWith(255, 216, 255);
|
|
4666
|
+
var isGif = (head) => hasBytesAt(head, 0, ascii("GIF87a")) || hasBytesAt(head, 0, ascii("GIF89a"));
|
|
4667
|
+
var isWebp = riff("WEBP");
|
|
4668
|
+
var isWav = riff("WAVE");
|
|
4669
|
+
var isAvif = hasBrand(AVIF_BRANDS);
|
|
4670
|
+
var isHeif = hasBrand(HEIF_BRANDS);
|
|
4671
|
+
var isWebm = ebmlDocType("webm");
|
|
4672
|
+
var isMatroskaOrWebm = ebmlDocType("matroska", "webm");
|
|
4673
|
+
var isOgg = startsWith(...ascii("OggS"), 0);
|
|
4674
|
+
var isFlac = startsWith(...ascii("fLaC"));
|
|
4675
|
+
var isMp3 = (head) => hasBytesAt(head, 0, ascii("ID3")) || head[0] === 255 && ((head[1] ?? 0) & 224) === 224;
|
|
4676
|
+
var isZip = (head) => hasBytesAt(head, 0, [80, 75, 3, 4]) || hasBytesAt(head, 0, [80, 75, 5, 6]);
|
|
4677
|
+
var isText = (head) => {
|
|
4678
|
+
const utf16 = utf16Encoding(head);
|
|
4679
|
+
if (utf16) return decodesStrictly(head.subarray(2), utf16);
|
|
4680
|
+
return !head.some(isBinaryDataByte);
|
|
4681
|
+
};
|
|
4682
|
+
var isJson = (head) => !head.some(isBinaryDataByte) && decodesStrictly(head, "utf-8") && /^[{[]/.test(leadingText(head));
|
|
4683
|
+
var FAMILIES = [
|
|
4684
|
+
["pdf", startsWith(...PDF_HEADER)],
|
|
4685
|
+
["png", isPng],
|
|
4686
|
+
["jpeg", isJpeg],
|
|
4687
|
+
["gif", isGif],
|
|
4688
|
+
["webp", isWebp],
|
|
4689
|
+
["wav", isWav],
|
|
4690
|
+
["avif", isAvif],
|
|
4691
|
+
["heif", isHeif],
|
|
4692
|
+
["mp4", isIsoMedia],
|
|
4693
|
+
["webm", isWebm],
|
|
4694
|
+
["matroska", isMatroskaOrWebm],
|
|
4695
|
+
["ogg", isOgg],
|
|
4696
|
+
["flac", isFlac],
|
|
4697
|
+
["zip", isZip],
|
|
4698
|
+
["mp3", isMp3],
|
|
4699
|
+
["json", isJson],
|
|
4700
|
+
["text", isText],
|
|
4701
|
+
["pdf", isPdf]
|
|
4702
|
+
];
|
|
4703
|
+
function detectFamily(head) {
|
|
4704
|
+
if (head.length === 0) return "empty";
|
|
4705
|
+
const scriptable = detectScriptable(head);
|
|
4706
|
+
if (scriptable) return scriptable;
|
|
4707
|
+
return FAMILIES.find(([, signature]) => signature(head))?.[0] ?? "binary";
|
|
4708
|
+
}
|
|
4709
|
+
var OFFICE_ZIP_TYPES = ["wordprocessingml.document", "spreadsheetml.sheet", "presentationml.presentation"].map(
|
|
4710
|
+
(subtype) => `application/vnd.openxmlformats-officedocument.${subtype}`
|
|
4711
|
+
);
|
|
4712
|
+
var typesWith = (types, signature) => types.map((type) => [type, signature]);
|
|
4713
|
+
var SIGNATURES = new Map([
|
|
4714
|
+
["application/pdf", isPdf],
|
|
4715
|
+
["image/png", isPng],
|
|
4716
|
+
["image/jpeg", isJpeg],
|
|
4717
|
+
["image/gif", isGif],
|
|
4718
|
+
["image/webp", isWebp],
|
|
4719
|
+
["image/avif", isAvif],
|
|
4720
|
+
...typesWith(["image/heic", "image/heif"], isHeif),
|
|
4721
|
+
...typesWith(["video/mp4", "video/quicktime", "video/x-m4v", "video/3gpp", "audio/mp4", "audio/x-m4a"], isIsoMedia),
|
|
4722
|
+
...typesWith(["video/webm", "audio/webm"], isWebm),
|
|
4723
|
+
...typesWith(["video/x-matroska", "audio/x-matroska"], isMatroskaOrWebm),
|
|
4724
|
+
...typesWith(["audio/mpeg", "audio/mp3"], isMp3),
|
|
4725
|
+
...typesWith(["audio/ogg", "video/ogg", "application/ogg", "audio/opus"], isOgg),
|
|
4726
|
+
...typesWith(["audio/wav", "audio/x-wav", "audio/wave", "audio/vnd.wave"], isWav),
|
|
4727
|
+
...typesWith(["audio/flac", "audio/x-flac"], isFlac),
|
|
4728
|
+
...typesWith(["application/zip", "application/x-zip-compressed", ...OFFICE_ZIP_TYPES], isZip),
|
|
4729
|
+
...typesWith(["text/plain", "text/csv", "text/markdown", "text/tab-separated-values"], isText),
|
|
4730
|
+
["application/json", isJson]
|
|
4731
|
+
]);
|
|
4732
|
+
function essenceOf(contentType) {
|
|
4733
|
+
return contentType.split(";")[0]?.trim().toLowerCase() ?? "";
|
|
4734
|
+
}
|
|
4735
|
+
var TEXT_TYPES = /* @__PURE__ */ new Set([
|
|
4736
|
+
"text/plain",
|
|
4737
|
+
"text/csv",
|
|
4738
|
+
"text/markdown",
|
|
4739
|
+
"text/tab-separated-values",
|
|
4740
|
+
"application/json"
|
|
4741
|
+
]);
|
|
4742
|
+
function embeddedMarkup(essence, head) {
|
|
4743
|
+
if (essence === "application/pdf") {
|
|
4744
|
+
const headerOffset = pdfHeaderOffset(head);
|
|
4745
|
+
const before = detectMarkupBefore(head, headerOffset < 0 ? MARKUP_WINDOW_BYTES : headerOffset);
|
|
4746
|
+
return before ?? detectEmbeddedMarkup(head);
|
|
4747
|
+
}
|
|
4748
|
+
return TEXT_TYPES.has(essence) ? detectEmbeddedMarkup(head) : null;
|
|
4749
|
+
}
|
|
4750
|
+
var SCRIPTABLE_FAMILIES = /* @__PURE__ */ new Set(["html", "svg", "xml"]);
|
|
4751
|
+
var mismatch = (detected) => ({
|
|
4752
|
+
verdict: "mismatch",
|
|
4753
|
+
detected,
|
|
4754
|
+
isScriptable: SCRIPTABLE_FAMILIES.has(detected)
|
|
4755
|
+
});
|
|
4756
|
+
function sniffContent(declaredType, head) {
|
|
4757
|
+
const essence = essenceOf(declaredType);
|
|
4758
|
+
const signature = SIGNATURES.get(essence);
|
|
4759
|
+
if (!signature) return { verdict: "unverifiable" };
|
|
4760
|
+
const sample = head.subarray(0, SNIFF_HEAD_BYTES);
|
|
4761
|
+
const scriptable = detectScriptable(sample) ?? embeddedMarkup(essence, sample);
|
|
4762
|
+
if (scriptable) return mismatch(scriptable);
|
|
4763
|
+
if (signature(sample)) return { verdict: "match" };
|
|
4764
|
+
return mismatch(detectFamily(sample));
|
|
4765
|
+
}
|
|
4766
|
+
|
|
4767
|
+
// src/routes/upload-content-check.ts
|
|
4768
|
+
var OCTET_STREAM = "application/octet-stream";
|
|
4769
|
+
var MULTIPART_PENDING_TTL_SECONDS = 24 * 60 * 60;
|
|
4770
|
+
function sniffingModeOf(context, pending) {
|
|
4771
|
+
return pending?.contentSniffing ?? getRequestState(context).config?.contentSniffing ?? "scriptable";
|
|
4772
|
+
}
|
|
4773
|
+
function shouldSniffUpload(context, key, pending, contentType) {
|
|
4774
|
+
const isRunnerWrite = pending ? Boolean(pending.isDerived) : true;
|
|
4775
|
+
if (isRunnerWrite && isTrustedDerivedOutput(key, contentType)) return false;
|
|
4776
|
+
return sniffingModeOf(context, pending) !== "off";
|
|
4777
|
+
}
|
|
4778
|
+
function mismatchAction(mode, mismatch2, isMultipart) {
|
|
4779
|
+
if (mode === "scriptable") return mismatch2.isScriptable ? "rejected" : "accepted";
|
|
4780
|
+
if (mode === "octet-stream" && !isMultipart) return "stored_as_octet_stream";
|
|
4781
|
+
return "rejected";
|
|
4782
|
+
}
|
|
4783
|
+
function logMismatch(declared, mismatch2, key, action) {
|
|
4784
|
+
console.warn(`[content-sniff] ${JSON.stringify({ declared, detected: mismatch2.detected, key, action })}`);
|
|
4785
|
+
}
|
|
4786
|
+
function contentTypeMismatch(context, declared, detected) {
|
|
4787
|
+
return context.json(
|
|
4788
|
+
{
|
|
4789
|
+
error: `The file's content (${detected}) does not match its declared Content-Type ${declared}`,
|
|
4790
|
+
code: "content_type_mismatch",
|
|
4791
|
+
declared,
|
|
4792
|
+
detected
|
|
4793
|
+
},
|
|
4794
|
+
415
|
|
4795
|
+
);
|
|
4796
|
+
}
|
|
4797
|
+
function sniffHead(declared, body) {
|
|
4798
|
+
return sniffContent(declared, new Uint8Array(body, 0, Math.min(body.byteLength, SNIFF_HEAD_BYTES)));
|
|
4799
|
+
}
|
|
4800
|
+
function checkSinglePutContent(context, upload2) {
|
|
4801
|
+
const { contentType, key, pending } = upload2;
|
|
4802
|
+
if (!shouldSniffUpload(context, key, pending, contentType)) return { contentType };
|
|
4803
|
+
const result = sniffHead(contentType, upload2.body);
|
|
4804
|
+
if (result.verdict !== "mismatch") return { contentType };
|
|
4805
|
+
const action = mismatchAction(sniffingModeOf(context, pending), result, false);
|
|
4806
|
+
logMismatch(contentType, result, key, action);
|
|
4807
|
+
if (action === "accepted") return { contentType };
|
|
4808
|
+
if (action === "stored_as_octet_stream") return { contentType: OCTET_STREAM };
|
|
4809
|
+
return { rejected: contentTypeMismatch(context, contentType, result.detected) };
|
|
4810
|
+
}
|
|
4811
|
+
async function discardMultipart(context, active) {
|
|
4812
|
+
const { pending, multipart, kvKey } = active;
|
|
4813
|
+
await context.env.MEDIA_BUCKET.resumeMultipartUpload(pending.storageKey, multipart.uploadId).abort();
|
|
4814
|
+
await context.env.MEDIA_KV.delete(kvKey);
|
|
4815
|
+
const meta = decodeEncryptionMeta9(multipart.enc);
|
|
4816
|
+
if (meta && context.env.KEYS_BUCKET) await context.env.KEYS_BUCKET.delete(`${pending.storageKey}/${meta.keyId}`);
|
|
4817
|
+
}
|
|
4818
|
+
async function checkFirstPart(context, active, key, partNumber, body) {
|
|
4819
|
+
const { pending, multipart } = active;
|
|
4820
|
+
if (partNumber !== 1 || !multipart.checksFirstPart) return null;
|
|
4821
|
+
const result = sniffHead(pending.contentType, body);
|
|
4822
|
+
if (result.verdict !== "mismatch") return null;
|
|
4823
|
+
const action = mismatchAction(sniffingModeOf(context, pending), result, true);
|
|
4824
|
+
logMismatch(pending.contentType, result, key, action);
|
|
4825
|
+
if (action === "accepted") return null;
|
|
4826
|
+
await discardMultipart(context, active);
|
|
4827
|
+
return contentTypeMismatch(context, pending.contentType, result.detected);
|
|
4828
|
+
}
|
|
4829
|
+
async function recordFirstPart(context, active, partNumber, etag) {
|
|
4830
|
+
const { pending, multipart, kvKey } = active;
|
|
4831
|
+
if (partNumber !== 1 || !multipart.checksFirstPart) return;
|
|
4832
|
+
const recorded = { ...pending, multipart: { ...multipart, firstPartEtag: etag } };
|
|
4833
|
+
await context.env.MEDIA_KV.put(kvKey, JSON.stringify(recorded), { expirationTtl: MULTIPART_PENDING_TTL_SECONDS });
|
|
4834
|
+
}
|
|
4835
|
+
function unverifiedFirstPart(context, multipart, parts) {
|
|
4836
|
+
if (!multipart.checksFirstPart) return null;
|
|
4837
|
+
if (multipart.firstPartEtag && parts[0]?.etag === multipart.firstPartEtag) return null;
|
|
4838
|
+
return context.json(
|
|
4839
|
+
{ error: "Part 1 of this upload has not been checked yet, or was replaced", code: "content_unverified" },
|
|
4840
|
+
409
|
|
4841
|
+
);
|
|
4842
|
+
}
|
|
4843
|
+
|
|
4460
4844
|
// src/routes/upload-multipart.ts
|
|
4461
4845
|
import {
|
|
4462
4846
|
AES_BLOCK_BYTES as AES_BLOCK_BYTES2,
|
|
4463
4847
|
classifyUploadRequest,
|
|
4464
4848
|
counterForBlock,
|
|
4465
|
-
decodeEncryptionMeta as
|
|
4849
|
+
decodeEncryptionMeta as decodeEncryptionMeta10,
|
|
4466
4850
|
encryptBytes,
|
|
4467
4851
|
MULTIPART_PART_SIZE_BYTES,
|
|
4468
4852
|
MULTIPART_QUERY,
|
|
@@ -4568,7 +4952,6 @@ async function cancelPendingUpload(context, uploadUrl) {
|
|
|
4568
4952
|
}
|
|
4569
4953
|
|
|
4570
4954
|
// src/routes/upload-multipart.ts
|
|
4571
|
-
var MULTIPART_PENDING_TTL_SECONDS = 24 * 60 * 60;
|
|
4572
4955
|
var MAX_PART_COUNT = 1e4;
|
|
4573
4956
|
async function readMultipartAt(context, kvKey) {
|
|
4574
4957
|
const raw = await context.env.MEDIA_KV.get(kvKey);
|
|
@@ -4660,7 +5043,9 @@ async function handleMultipartCreate(context, key, target) {
|
|
|
4660
5043
|
size,
|
|
4661
5044
|
partSize,
|
|
4662
5045
|
...enc ? { enc } : {},
|
|
4663
|
-
...declaredSha256 ? { sha256: declaredSha256 } : {}
|
|
5046
|
+
...declaredSha256 ? { sha256: declaredSha256 } : {},
|
|
5047
|
+
// The type is fixed now, before any byte: part 1 is checked against it when it arrives.
|
|
5048
|
+
...shouldSniffUpload(context, key, pending, pending.contentType) ? { checksFirstPart: true } : {}
|
|
4664
5049
|
};
|
|
4665
5050
|
await context.env.MEDIA_KV.put(kvKey, JSON.stringify({ ...pending, multipart }), {
|
|
4666
5051
|
expirationTtl: MULTIPART_PENDING_TTL_SECONDS
|
|
@@ -4674,7 +5059,7 @@ async function handleMultipartCreate(context, key, target) {
|
|
|
4674
5059
|
}
|
|
4675
5060
|
async function encryptPart(context, pending, multipart, partNumber, body) {
|
|
4676
5061
|
if (!multipart.enc) return body;
|
|
4677
|
-
const meta =
|
|
5062
|
+
const meta = decodeEncryptionMeta10(multipart.enc);
|
|
4678
5063
|
const material = meta ? await loadStorageKey(context.env.KEYS_BUCKET, pending.storageKey, meta.keyId) : null;
|
|
4679
5064
|
if (!meta || !material) return null;
|
|
4680
5065
|
const startBlock = (partNumber - 1) * multipart.partSize / AES_BLOCK_BYTES2;
|
|
@@ -4698,10 +5083,13 @@ async function handleMultipartPart(context) {
|
|
|
4698
5083
|
}
|
|
4699
5084
|
const partChecksumFailure = await checkPartSha256(context, multipart, body);
|
|
4700
5085
|
if (partChecksumFailure) return partChecksumFailure;
|
|
5086
|
+
const contentFailure = await checkFirstPart(context, active, key, partNumber, body);
|
|
5087
|
+
if (contentFailure) return contentFailure;
|
|
4701
5088
|
const storedPart = await encryptPart(context, pending, multipart, partNumber, body);
|
|
4702
5089
|
if (!storedPart) return context.json({ error: "Encryption key not found for this upload" }, 500);
|
|
4703
5090
|
const multipartUpload = context.env.MEDIA_BUCKET.resumeMultipartUpload(pending.storageKey, multipart.uploadId);
|
|
4704
5091
|
const uploaded = await multipartUpload.uploadPart(partNumber, storedPart);
|
|
5092
|
+
await recordFirstPart(context, active, partNumber, uploaded.etag);
|
|
4705
5093
|
return context.json({ partNumber: uploaded.partNumber, etag: uploaded.etag });
|
|
4706
5094
|
}
|
|
4707
5095
|
async function handleMultipartComplete(context, key) {
|
|
@@ -4711,6 +5099,8 @@ async function handleMultipartComplete(context, key) {
|
|
|
4711
5099
|
const { pending, multipart, kvKey } = active;
|
|
4712
5100
|
const parts = normalizeCompletedParts(multipart, body?.parts);
|
|
4713
5101
|
if (!parts) return context.json({ error: "parts must list every part exactly once", code: "invalid_parts" }, 400);
|
|
5102
|
+
const unverified = unverifiedFirstPart(context, multipart, parts);
|
|
5103
|
+
if (unverified) return unverified;
|
|
4714
5104
|
const write = await prepareUploadWrite(context, pending.key ?? key, pending, pending.storageKey);
|
|
4715
5105
|
if ("blocked" in write) return write.blocked;
|
|
4716
5106
|
const multipartUpload = context.env.MEDIA_BUCKET.resumeMultipartUpload(pending.storageKey, multipart.uploadId);
|
|
@@ -4725,25 +5115,6 @@ async function handleMultipartComplete(context, key) {
|
|
|
4725
5115
|
const integrity = multipart.sha256 ? { sha256: multipart.sha256, sha256Verified: false } : {};
|
|
4726
5116
|
return context.json({ key, size: object.size, ...integrity });
|
|
4727
5117
|
}
|
|
4728
|
-
async function checkPartSha256(context, multipart, body) {
|
|
4729
|
-
const partSha256 = headerSha256(context);
|
|
4730
|
-
if (partSha256 === null) {
|
|
4731
|
-
return context.json({ error: "x-devultur-sha256 must be a 64-character hex string", code: "invalid_sha256" }, 400);
|
|
4732
|
-
}
|
|
4733
|
-
if (multipart.sha256 && !partSha256) {
|
|
4734
|
-
return context.json(
|
|
4735
|
-
{
|
|
4736
|
-
error: "Each part must carry its x-devultur-sha256 when the upload declares one",
|
|
4737
|
-
code: "part_sha256_required"
|
|
4738
|
-
},
|
|
4739
|
-
400
|
|
4740
|
-
);
|
|
4741
|
-
}
|
|
4742
|
-
if (!partSha256) return null;
|
|
4743
|
-
const digest = await digestBody(body, void 0, partSha256);
|
|
4744
|
-
if ("mismatch" in digest) return sha256Mismatch(context, digest.mismatch.expected, digest.mismatch.actual);
|
|
4745
|
-
return null;
|
|
4746
|
-
}
|
|
4747
5118
|
async function handleMultipartPost(context) {
|
|
4748
5119
|
const key = context.req.param("key") ?? "";
|
|
4749
5120
|
const kind = uploadRequestKindOf(context);
|
|
@@ -4777,20 +5148,8 @@ upload.post("/", async (context) => {
|
|
|
4777
5148
|
if (!filename || !body.contentType) {
|
|
4778
5149
|
return context.json({ error: "filename and contentType are required" }, 400);
|
|
4779
5150
|
}
|
|
4780
|
-
const
|
|
4781
|
-
if (
|
|
4782
|
-
return context.json(
|
|
4783
|
-
{
|
|
4784
|
-
error: `mediaType "${body.mediaType}" does not match content type ${body.contentType}`,
|
|
4785
|
-
code: "media_type_mismatch"
|
|
4786
|
-
},
|
|
4787
|
-
400
|
|
4788
|
-
);
|
|
4789
|
-
}
|
|
4790
|
-
const mediaType = body.mediaType ?? inferredType ?? (isAssetRequest ? MediaType2.FILE : null);
|
|
4791
|
-
if (!mediaType) {
|
|
4792
|
-
return context.json({ error: `Unsupported content type: ${body.contentType}` }, 400);
|
|
4793
|
-
}
|
|
5151
|
+
const mediaType = resolveUploadMediaType(body.mediaType, body.contentType, isAssetRequest);
|
|
5152
|
+
if (isUploadTargetError(mediaType)) return targetFailure(context, mediaType);
|
|
4794
5153
|
const scope = getRequestState(context);
|
|
4795
5154
|
const uploadRule = scope.config?.uploadRules?.[mediaType];
|
|
4796
5155
|
if (!isContentTypeAllowed(body.contentType, uploadRule?.accept)) {
|
|
@@ -4854,7 +5213,8 @@ upload.post("/", async (context) => {
|
|
|
4854
5213
|
...target.isAsset ? { isAsset: true, overwrite: shouldOverwrite } : {},
|
|
4855
5214
|
...cacheControl ? { cacheControl } : {},
|
|
4856
5215
|
...scope.config?.uploadMetadata ? { uploadMetadata: scope.config.uploadMetadata } : {},
|
|
4857
|
-
...lock ? { lock } : {}
|
|
5216
|
+
...lock ? { lock } : {},
|
|
5217
|
+
...scope.config?.contentSniffing ? { contentSniffing: scope.config.contentSniffing } : {}
|
|
4858
5218
|
};
|
|
4859
5219
|
const pendingId = newUploadPendingId();
|
|
4860
5220
|
await context.env.MEDIA_KV.put(uploadPendingKvKey(pendingId), JSON.stringify(pending), {
|
|
@@ -4885,9 +5245,9 @@ upload.put("/:key{.+}", async (context) => {
|
|
|
4885
5245
|
const maxSizeBytes = pending?.maxSizeBytes ?? null;
|
|
4886
5246
|
const storageClass = pending?.storageClass ?? "Standard";
|
|
4887
5247
|
const resourceSecurity = pending?.security ?? parseSecurityHeader(context.req.header(RESOURCE_SECURITY_HEADER));
|
|
4888
|
-
const
|
|
4889
|
-
if (expectedContentType &&
|
|
4890
|
-
return context.json({ error: `Expected Content-Type ${expectedContentType}, got ${
|
|
5248
|
+
const declaredContentType = context.req.header("Content-Type") ?? OCTET_STREAM;
|
|
5249
|
+
if (expectedContentType && declaredContentType !== expectedContentType) {
|
|
5250
|
+
return context.json({ error: `Expected Content-Type ${expectedContentType}, got ${declaredContentType}` }, 400);
|
|
4891
5251
|
}
|
|
4892
5252
|
const declaredSize = Number(context.req.header("content-length") ?? "0");
|
|
4893
5253
|
if (maxSizeBytes != null && Number.isFinite(declaredSize) && declaredSize > maxSizeBytes) {
|
|
@@ -4908,6 +5268,9 @@ upload.put("/:key{.+}", async (context) => {
|
|
|
4908
5268
|
return context.json({ error: "x-devultur-sha256 must be a 64-character hex string", code: "invalid_sha256" }, 400);
|
|
4909
5269
|
}
|
|
4910
5270
|
if ("mismatch" in digest) return sha256Mismatch(context, digest.mismatch.expected, digest.mismatch.actual);
|
|
5271
|
+
const content = checkSinglePutContent(context, { key, pending, contentType: declaredContentType, body });
|
|
5272
|
+
if ("rejected" in content) return content.rejected;
|
|
5273
|
+
const { contentType } = content;
|
|
4911
5274
|
const policy = resolveSecurity5(resourceSecurity, getRequestState(context).config?.security);
|
|
4912
5275
|
const shouldEncrypt = shouldEncryptAtRest(policy, key);
|
|
4913
5276
|
if (isKeysBucketMissing(shouldEncrypt, context.env)) return keysBucketMissing(context);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@devultur/self-host",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.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.
|
|
49
|
+
"@devultur/core": "1.3.0"
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
52
|
"@cf-wasm/photon": "0.4.0",
|