@devultur/self-host 1.0.3 → 1.2.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/README.md +10 -1
- package/dist/{chunk-VF4KBW3I.js → chunk-PQO3X57I.js} +11 -3
- package/dist/index.d.ts +232 -7
- package/dist/index.js +1605 -257
- package/dist/transcode/container.js +1 -1
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -38,9 +38,10 @@ import {
|
|
|
38
38
|
storageKey,
|
|
39
39
|
storagePrefix,
|
|
40
40
|
updateRequestState,
|
|
41
|
+
verifyJobLocation,
|
|
41
42
|
verifyJobTicket,
|
|
42
43
|
verifyViewerToken
|
|
43
|
-
} from "./chunk-
|
|
44
|
+
} from "./chunk-PQO3X57I.js";
|
|
44
45
|
import {
|
|
45
46
|
ImageTransformsMode,
|
|
46
47
|
WORKER_DECODABLE_TYPES,
|
|
@@ -67,39 +68,18 @@ import {
|
|
|
67
68
|
SHA256_HEADER as SHA256_HEADER3,
|
|
68
69
|
SHA256_VERIFIED_HEADER as SHA256_VERIFIED_HEADER2
|
|
69
70
|
} from "@devultur/core";
|
|
70
|
-
import { Hono as
|
|
71
|
+
import { Hono as Hono11 } from "hono";
|
|
71
72
|
import { cors } from "hono/cors";
|
|
72
73
|
import { logger } from "hono/logger";
|
|
73
74
|
|
|
74
|
-
// src/
|
|
75
|
-
|
|
76
|
-
function assertComposable(plugins) {
|
|
77
|
-
const names = /* @__PURE__ */ new Set();
|
|
78
|
-
for (const plugin of plugins) {
|
|
79
|
-
if (names.has(plugin.name)) throw new Error(`createWorker: two plugins are named "${plugin.name}"`);
|
|
80
|
-
names.add(plugin.name);
|
|
81
|
-
}
|
|
82
|
-
if (!plugins.some((plugin) => (plugin.middleware?.auth?.length ?? 0) > 0)) {
|
|
83
|
-
throw new Error("createWorker: no plugin authenticates requests. Add workerSecretAuth() or your own auth plugin.");
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
function composePlugins(plugins) {
|
|
87
|
-
assertComposable(plugins);
|
|
88
|
-
return {
|
|
89
|
-
middleware: PHASES.flatMap((phase) => plugins.flatMap((plugin) => plugin.middleware?.[phase] ?? [])),
|
|
90
|
-
routes: plugins.flatMap((plugin) => plugin.routes ?? []),
|
|
91
|
-
hooks: {
|
|
92
|
-
issueCredential: plugins.flatMap((plugin) => plugin.issueCredential ? [plugin.issueCredential] : []),
|
|
93
|
-
beforeLinkRedeem: plugins.flatMap((plugin) => plugin.beforeLinkRedeem ? [plugin.beforeLinkRedeem] : [])
|
|
94
|
-
}
|
|
95
|
-
};
|
|
96
|
-
}
|
|
75
|
+
// src/object-lock/guard.ts
|
|
76
|
+
import { isVideoRawKey } from "@devultur/core";
|
|
97
77
|
|
|
98
78
|
// src/plugin-hooks.ts
|
|
99
79
|
var PLUGIN_HOOKS_VARIABLE = "pluginHooks";
|
|
100
|
-
var NO_HOOKS = { issueCredential: [], beforeLinkRedeem: [] };
|
|
80
|
+
var NO_HOOKS = { issueCredential: [], beforeLinkRedeem: [], authorizeLockChange: [], onLockAudit: [] };
|
|
101
81
|
function getPluginHooks(context) {
|
|
102
|
-
return context.get(PLUGIN_HOOKS_VARIABLE)
|
|
82
|
+
return { ...NO_HOOKS, ...context.get(PLUGIN_HOOKS_VARIABLE) };
|
|
103
83
|
}
|
|
104
84
|
var earliest = (current, next) => current === void 0 ? next : next === void 0 ? current : Math.min(current, next);
|
|
105
85
|
async function decideCredential(context, request) {
|
|
@@ -115,11 +95,19 @@ async function decideCredential(context, request) {
|
|
|
115
95
|
}
|
|
116
96
|
async function gateLinkRedemption(context, link) {
|
|
117
97
|
for (const hook of getPluginHooks(context).beforeLinkRedeem) {
|
|
118
|
-
const
|
|
119
|
-
if (
|
|
98
|
+
const refusal2 = await hook(context, link);
|
|
99
|
+
if (refusal2) return refusal2;
|
|
120
100
|
}
|
|
121
101
|
return null;
|
|
122
102
|
}
|
|
103
|
+
async function decideLockChange(context, request) {
|
|
104
|
+
const hooks = getPluginHooks(context).authorizeLockChange;
|
|
105
|
+
for (const hook of hooks) {
|
|
106
|
+
const decision = await hook(context, request);
|
|
107
|
+
if (!decision.allow) return decision;
|
|
108
|
+
}
|
|
109
|
+
return { allow: true, isApproved: hooks.length > 0 };
|
|
110
|
+
}
|
|
123
111
|
function cappedExpiry(requested, cap, toUnit) {
|
|
124
112
|
return cap === void 0 ? requested : Math.min(requested, toUnit(cap));
|
|
125
113
|
}
|
|
@@ -127,6 +115,599 @@ function withPluginClaims(claims, decision) {
|
|
|
127
115
|
return { ...decision.claims, ...claims };
|
|
128
116
|
}
|
|
129
117
|
|
|
118
|
+
// src/object-lock/audit.ts
|
|
119
|
+
var LOCK_AUDIT_PREFIX = "__lock-audit/";
|
|
120
|
+
function auditObjectKey(root, at, id = crypto.randomUUID()) {
|
|
121
|
+
return `${root}${LOCK_AUDIT_PREFIX}${at.toISOString().slice(0, 10)}/${at.getTime()}-${id}.json`;
|
|
122
|
+
}
|
|
123
|
+
async function deliver(context, root, record) {
|
|
124
|
+
const body = JSON.stringify(record);
|
|
125
|
+
const stored = context.env.MEDIA_BUCKET.put(auditObjectKey(root, new Date(record.at)), body, {
|
|
126
|
+
onlyIf: new Headers({ "If-None-Match": "*" }),
|
|
127
|
+
httpMetadata: { contentType: "application/json" }
|
|
128
|
+
});
|
|
129
|
+
const hooks = getPluginHooks(context).onLockAudit.map((hook) => hook(context, record));
|
|
130
|
+
const results = await Promise.allSettled([stored, ...hooks]);
|
|
131
|
+
for (const result of results) {
|
|
132
|
+
if (result.status === "rejected") console.error("lock audit delivery failed:", result.reason);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
async function inBackground(context, task) {
|
|
136
|
+
let executionCtx;
|
|
137
|
+
try {
|
|
138
|
+
executionCtx = context.executionCtx;
|
|
139
|
+
} catch {
|
|
140
|
+
}
|
|
141
|
+
if (!executionCtx) return task;
|
|
142
|
+
executionCtx.waitUntil(task);
|
|
143
|
+
}
|
|
144
|
+
async function recordLockAudit(context, input) {
|
|
145
|
+
const { root = getRequestState(context).storagePrefix, actor, ...event } = input;
|
|
146
|
+
const record = {
|
|
147
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
148
|
+
...event,
|
|
149
|
+
actor: actor === void 0 ? getRequestState(context).context ?? null : actor
|
|
150
|
+
};
|
|
151
|
+
console.log(JSON.stringify({ event: "lock.audit", ...record }));
|
|
152
|
+
await inBackground(context, deliver(context, root, record));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// src/object-lock/object-keys.ts
|
|
156
|
+
import { decodeEncryptionMeta } from "@devultur/core";
|
|
157
|
+
var DELETE_BATCH_SIZE = 1e3;
|
|
158
|
+
async function listAll(bucket, options) {
|
|
159
|
+
const objects = [];
|
|
160
|
+
let cursor;
|
|
161
|
+
do {
|
|
162
|
+
const listing = await bucket.list({ ...options, ...cursor ? { cursor } : {} });
|
|
163
|
+
objects.push(...listing.objects);
|
|
164
|
+
cursor = listing.truncated ? listing.cursor : void 0;
|
|
165
|
+
} while (cursor);
|
|
166
|
+
return objects;
|
|
167
|
+
}
|
|
168
|
+
var isWrittenBy = (key, writtenBy) => key.uploaded.getTime() <= writtenBy.getTime();
|
|
169
|
+
async function keysWrittenBy(keysBucket, prefix, writtenBy) {
|
|
170
|
+
return (await listAll(keysBucket, { prefix })).filter((key) => isWrittenBy(key, writtenBy)).map((key) => key.key);
|
|
171
|
+
}
|
|
172
|
+
async function objectKeysToDelete(keysBucket, fullKey, deleted) {
|
|
173
|
+
const own = await listAll(keysBucket, { prefix: `${fullKey}/`, delimiter: "/" });
|
|
174
|
+
const keys = new Set(own.filter((key) => isWrittenBy(key, deleted.uploaded)).map((key) => key.key));
|
|
175
|
+
const meta = decodeEncryptionMeta(deleted.customMetadata?.enc);
|
|
176
|
+
if (meta) keys.add(`${fullKey}/${meta.keyId}`);
|
|
177
|
+
for (const variantKey of await listAll(keysBucket, { prefix: variantPrefix(fullKey) })) keys.add(variantKey.key);
|
|
178
|
+
return [...keys];
|
|
179
|
+
}
|
|
180
|
+
var deleteCallsFor = (keys) => Math.ceil(keys.length / DELETE_BATCH_SIZE);
|
|
181
|
+
async function deleteKeys(keysBucket, keys) {
|
|
182
|
+
for (let start = 0; start < keys.length; start += DELETE_BATCH_SIZE) {
|
|
183
|
+
await keysBucket.delete(keys.slice(start, start + DELETE_BATCH_SIZE));
|
|
184
|
+
}
|
|
185
|
+
return keys.length;
|
|
186
|
+
}
|
|
187
|
+
async function sweepObjectKeys(keysBucket, fullKey, deleted) {
|
|
188
|
+
return deleteKeys(keysBucket, await objectKeysToDelete(keysBucket, fullKey, deleted));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// src/object-lock/sidecar.ts
|
|
192
|
+
import { isIsoInstant, validateLock as validateLock2 } from "@devultur/core";
|
|
193
|
+
|
|
194
|
+
// src/object-lock/state.ts
|
|
195
|
+
import { effectiveUntil, isLocked, validateLock } from "@devultur/core";
|
|
196
|
+
var LOCK_METADATA_KEY = "lock";
|
|
197
|
+
function encodeLockMetadata(lock) {
|
|
198
|
+
if (!lock || !lock.retention && !lock.hold) return void 0;
|
|
199
|
+
return JSON.stringify(lock);
|
|
200
|
+
}
|
|
201
|
+
function readLockMetadata(raw) {
|
|
202
|
+
if (raw === void 0) return void 0;
|
|
203
|
+
try {
|
|
204
|
+
const validation = validateLock(JSON.parse(raw));
|
|
205
|
+
if (validation.ok) return { lock: validation.lock, isUnreadable: false };
|
|
206
|
+
} catch {
|
|
207
|
+
}
|
|
208
|
+
return { lock: { hold: true }, isUnreadable: true };
|
|
209
|
+
}
|
|
210
|
+
function lockStateOf(object) {
|
|
211
|
+
const stored = readLockMetadata(object.customMetadata?.[LOCK_METADATA_KEY]);
|
|
212
|
+
if (!stored) return null;
|
|
213
|
+
return {
|
|
214
|
+
lock: stored.lock,
|
|
215
|
+
...object.uploaded ? { uploadedAt: object.uploaded } : {},
|
|
216
|
+
...stored.isUnreadable ? { isUnreadable: true } : {}
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
function isStateLocked(state, now) {
|
|
220
|
+
try {
|
|
221
|
+
return isLocked(state.lock, now, { uploadedAt: state.uploadedAt, holdReleasedAt: state.holdReleasedAt });
|
|
222
|
+
} catch {
|
|
223
|
+
return true;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
function untilOf(state) {
|
|
227
|
+
try {
|
|
228
|
+
return effectiveUntil(state.lock, { uploadedAt: state.uploadedAt, holdReleasedAt: state.holdReleasedAt });
|
|
229
|
+
} catch {
|
|
230
|
+
return void 0;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// src/object-lock/sidecar.ts
|
|
235
|
+
var LOCK_SIDECAR_PREFIX = "__lock/";
|
|
236
|
+
function objectVersionOf(object) {
|
|
237
|
+
return object.version ?? `${object.etag}@${object.uploaded?.toISOString() ?? ""}`;
|
|
238
|
+
}
|
|
239
|
+
var sidecarKey = (root, key) => `${root}${LOCK_SIDECAR_PREFIX}${key}`;
|
|
240
|
+
function parseSidecar(raw) {
|
|
241
|
+
try {
|
|
242
|
+
const parsed = JSON.parse(raw);
|
|
243
|
+
const lock = validateLock2(parsed.lock);
|
|
244
|
+
if (!lock.ok || typeof parsed.revision !== "number" || typeof parsed.objectVersion !== "string") return null;
|
|
245
|
+
if (parsed.holdReleasedAt !== void 0 && !isIsoInstant(parsed.holdReleasedAt)) return null;
|
|
246
|
+
return { ...parsed, lock: lock.lock };
|
|
247
|
+
} catch {
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
async function readSidecar(bucket, root, key) {
|
|
252
|
+
const object = await bucket.get(sidecarKey(root, key));
|
|
253
|
+
if (!object) return null;
|
|
254
|
+
const sidecar = parseSidecar(await object.text());
|
|
255
|
+
if (sidecar) return { sidecar, etag: object.etag };
|
|
256
|
+
const unreadable = { revision: 0, objectVersion: "*", lock: { hold: true }, updatedAt: "" };
|
|
257
|
+
return { sidecar: unreadable, etag: object.etag, isUnreadable: true };
|
|
258
|
+
}
|
|
259
|
+
async function writeSidecar(bucket, root, key, sidecar, previous) {
|
|
260
|
+
const onlyIf = previous ? { etagMatches: previous.etag } : new Headers({ "If-None-Match": "*" });
|
|
261
|
+
const stored = await bucket.put(sidecarKey(root, key), JSON.stringify(sidecar), {
|
|
262
|
+
onlyIf,
|
|
263
|
+
httpMetadata: { contentType: "application/json" }
|
|
264
|
+
});
|
|
265
|
+
return stored !== null;
|
|
266
|
+
}
|
|
267
|
+
async function effectiveLock(bucket, root, key, object) {
|
|
268
|
+
const stored = await readSidecar(bucket, root, key);
|
|
269
|
+
const bound = stored?.sidecar.objectVersion;
|
|
270
|
+
const applies = stored && (bound === "*" || bound === objectVersionOf(object));
|
|
271
|
+
if (stored && applies) {
|
|
272
|
+
const { lock, holdReleasedAt } = stored.sidecar;
|
|
273
|
+
const state2 = {
|
|
274
|
+
lock,
|
|
275
|
+
...object.uploaded ? { uploadedAt: object.uploaded } : {},
|
|
276
|
+
...holdReleasedAt ? { holdReleasedAt: new Date(holdReleasedAt) } : {},
|
|
277
|
+
...stored.isUnreadable ? { isUnreadable: true } : {}
|
|
278
|
+
};
|
|
279
|
+
return { state: state2, source: "sidecar", stored };
|
|
280
|
+
}
|
|
281
|
+
const state = lockStateOf(object);
|
|
282
|
+
return { state, source: state ? "upload" : "none", stored };
|
|
283
|
+
}
|
|
284
|
+
function unreadableLockRefusal(state, key) {
|
|
285
|
+
if (!state?.isUnreadable) return null;
|
|
286
|
+
return Response.json(
|
|
287
|
+
{
|
|
288
|
+
error: `The lock record of ${key} can't be read; it stays held until an operator repairs it`,
|
|
289
|
+
code: "lock_record_unreadable",
|
|
290
|
+
key
|
|
291
|
+
},
|
|
292
|
+
{ status: 409 }
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
var VIDEO_STORAGE_KEY = /^(.*?)((?:public|private)\/video\/[^/]+\/.+)$/;
|
|
296
|
+
function splitVideoStorageKey(storageKey2) {
|
|
297
|
+
const match = VIDEO_STORAGE_KEY.exec(storageKey2);
|
|
298
|
+
return match ? { root: match[1] ?? "", key: match[2] ?? "" } : null;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// src/object-lock/guard.ts
|
|
302
|
+
var RESERVED_LOCK_PREFIXES = ["__lock/", "__lock-audit/"];
|
|
303
|
+
function isReservedLockKey(key) {
|
|
304
|
+
const normalized = `${key.replace(/^\/+/, "")}/`;
|
|
305
|
+
return RESERVED_LOCK_PREFIXES.some((prefix) => normalized.startsWith(prefix));
|
|
306
|
+
}
|
|
307
|
+
function reservedKeyResponse(context, key) {
|
|
308
|
+
return context.json({ error: `${key} is under a reserved prefix`, code: "reserved_key", key }, 400);
|
|
309
|
+
}
|
|
310
|
+
function routeKeyOf(path) {
|
|
311
|
+
const key = path.split("/").slice(3).join("/");
|
|
312
|
+
try {
|
|
313
|
+
return decodeURIComponent(key);
|
|
314
|
+
} catch {
|
|
315
|
+
return key;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
var rejectReservedLockPaths = async (context, next) => {
|
|
319
|
+
const key = routeKeyOf(context.req.path);
|
|
320
|
+
if (isReservedLockKey(key)) return reservedKeyResponse(context, key);
|
|
321
|
+
await next();
|
|
322
|
+
};
|
|
323
|
+
var VIDEO_KEY = /^((?:public|private)\/video\/[^/]+)(?:\/|$)/;
|
|
324
|
+
function lockSubjectOf(key) {
|
|
325
|
+
if (isVariantKey(key)) return null;
|
|
326
|
+
if (isVideoRawKey(key)) return { kind: "original", key };
|
|
327
|
+
const video = VIDEO_KEY.exec(key);
|
|
328
|
+
if (video) return { kind: "video", rawPrefix: `${video[1]}/raw/` };
|
|
329
|
+
return { kind: "original", key };
|
|
330
|
+
}
|
|
331
|
+
function protectsDerived(state) {
|
|
332
|
+
const derivatives = state.lock.retention?.derivatives ?? "inherit";
|
|
333
|
+
return derivatives === "inherit";
|
|
334
|
+
}
|
|
335
|
+
async function lockedObject(bucket, root, key, object, now) {
|
|
336
|
+
const { state } = await effectiveLock(bucket, root, key, object);
|
|
337
|
+
return state && isStateLocked(state, now) ? { key, state } : null;
|
|
338
|
+
}
|
|
339
|
+
async function lockedOriginal(bucket, root, key, now) {
|
|
340
|
+
const object = await bucket.head(`${root}${key}`);
|
|
341
|
+
return object ? lockedObject(bucket, root, key, object, now) : null;
|
|
342
|
+
}
|
|
343
|
+
async function lockedRaw(bucket, root, rawPrefix, now) {
|
|
344
|
+
let cursor;
|
|
345
|
+
do {
|
|
346
|
+
const listing = await bucket.list({ prefix: `${root}${rawPrefix}`, cursor });
|
|
347
|
+
for (const listed of listing.objects) {
|
|
348
|
+
const key = listed.key.slice(root.length);
|
|
349
|
+
const object = await bucket.head(listed.key);
|
|
350
|
+
const state = object ? (await effectiveLock(bucket, root, key, object)).state : null;
|
|
351
|
+
if (state && protectsDerived(state) && isStateLocked(state, now)) return { key, state };
|
|
352
|
+
}
|
|
353
|
+
cursor = listing.truncated ? listing.cursor : void 0;
|
|
354
|
+
} while (cursor);
|
|
355
|
+
return null;
|
|
356
|
+
}
|
|
357
|
+
function findLockedOriginal(bucket, root, subject, now) {
|
|
358
|
+
return subject.kind === "original" ? lockedOriginal(bucket, root, subject.key, now) : lockedRaw(bucket, root, subject.rawPrefix, now);
|
|
359
|
+
}
|
|
360
|
+
function retentionActive(context, locked, op) {
|
|
361
|
+
const { lock } = locked.state;
|
|
362
|
+
const until = untilOf(locked.state);
|
|
363
|
+
const verb = op === "delete" ? "deleted" : "overwritten";
|
|
364
|
+
return context.json(
|
|
365
|
+
{
|
|
366
|
+
error: `${locked.key} is retained and can't be ${verb}`,
|
|
367
|
+
code: "retention_active",
|
|
368
|
+
key: locked.key,
|
|
369
|
+
...lock.retention ? { mode: lock.retention.mode } : {},
|
|
370
|
+
...until ? { until } : {},
|
|
371
|
+
hold: lock.hold === true
|
|
372
|
+
},
|
|
373
|
+
409
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
async function assertWritable(context, keys, op, root = getRequestState(context).storagePrefix) {
|
|
377
|
+
const reserved = keys.find(isReservedLockKey);
|
|
378
|
+
if (reserved !== void 0) return reservedKeyResponse(context, reserved);
|
|
379
|
+
const now = /* @__PURE__ */ new Date();
|
|
380
|
+
const checked = /* @__PURE__ */ new Set();
|
|
381
|
+
for (const key of keys) {
|
|
382
|
+
const subject = lockSubjectOf(key);
|
|
383
|
+
if (!subject) continue;
|
|
384
|
+
const subjectId = subject.kind === "original" ? subject.key : subject.rawPrefix;
|
|
385
|
+
if (checked.has(subjectId)) continue;
|
|
386
|
+
checked.add(subjectId);
|
|
387
|
+
const locked = await findLockedOriginal(context.env.MEDIA_BUCKET, root, subject, now);
|
|
388
|
+
if (!locked) continue;
|
|
389
|
+
const requested = key === locked.key ? [key] : [key, locked.key];
|
|
390
|
+
await recordLockAudit(context, {
|
|
391
|
+
action: op === "delete" ? "delete_rejected" : "overwrite_rejected",
|
|
392
|
+
keys: requested,
|
|
393
|
+
before: locked.state.lock,
|
|
394
|
+
after: null,
|
|
395
|
+
outcome: "rejected",
|
|
396
|
+
reason: "retention_active",
|
|
397
|
+
root
|
|
398
|
+
});
|
|
399
|
+
return retentionActive(context, locked, op);
|
|
400
|
+
}
|
|
401
|
+
return null;
|
|
402
|
+
}
|
|
403
|
+
var DELETE_BATCH_SIZE2 = 1e3;
|
|
404
|
+
async function guardedDeletePrefix(context, prefix) {
|
|
405
|
+
const root = getRequestState(context).storagePrefix;
|
|
406
|
+
const storageKeys = [];
|
|
407
|
+
let cursor;
|
|
408
|
+
do {
|
|
409
|
+
const listing = await context.env.MEDIA_BUCKET.list({ prefix: storageKey(context, prefix), cursor });
|
|
410
|
+
storageKeys.push(...listing.objects.map((object) => object.key));
|
|
411
|
+
cursor = listing.truncated ? listing.cursor : void 0;
|
|
412
|
+
} while (cursor);
|
|
413
|
+
const keys = storageKeys.map((key) => key.slice(root.length));
|
|
414
|
+
const blocked = await assertWritable(context, keys, "delete");
|
|
415
|
+
if (blocked) return { blocked };
|
|
416
|
+
for (let start = 0; start < storageKeys.length; start += DELETE_BATCH_SIZE2) {
|
|
417
|
+
await context.env.MEDIA_BUCKET.delete(storageKeys.slice(start, start + DELETE_BATCH_SIZE2));
|
|
418
|
+
}
|
|
419
|
+
return { deletedCount: storageKeys.length };
|
|
420
|
+
}
|
|
421
|
+
async function deleteKeysFor(context, objectKey, deleted) {
|
|
422
|
+
const blocked = await assertWritable(context, [objectKey], "delete");
|
|
423
|
+
if (blocked) return { blocked };
|
|
424
|
+
const keysBucket = context.env.KEYS_BUCKET;
|
|
425
|
+
if (!keysBucket) return { deletedCount: 0 };
|
|
426
|
+
return { deletedCount: await sweepObjectKeys(keysBucket, storageKey(context, objectKey), deleted) };
|
|
427
|
+
}
|
|
428
|
+
async function deleteFolderKeys(context, videoFolder) {
|
|
429
|
+
const blocked = await assertWritable(context, [videoFolder], "delete");
|
|
430
|
+
if (blocked) return { blocked };
|
|
431
|
+
if (!context.env.KEYS_BUCKET) return { deletedCount: 0 };
|
|
432
|
+
return { deletedCount: await deletePrefix(context.env.KEYS_BUCKET, `${storageKey(context, videoFolder)}/`) };
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// src/object-lock/lock-route.ts
|
|
436
|
+
import { canChange as canChange2, effectiveUntil as effectiveUntil2 } from "@devultur/core";
|
|
437
|
+
import { Hono } from "hono";
|
|
438
|
+
|
|
439
|
+
// src/object-lock/change.ts
|
|
440
|
+
import { canChange, validateLock as validateLock3 } from "@devultur/core";
|
|
441
|
+
var BODY_FIELDS = /* @__PURE__ */ new Set(["mode", "until", "period", "derivatives", "remove", "bypassGovernance"]);
|
|
442
|
+
function nextLock(before, body) {
|
|
443
|
+
const unknown = Object.keys(body).filter((field) => !BODY_FIELDS.has(field));
|
|
444
|
+
if (unknown.length > 0) return { ok: false, errors: unknown.map((field) => `${field}: unknown field`) };
|
|
445
|
+
if (body.remove !== void 0 && body.remove !== true) return { ok: false, errors: ["remove: must be true"] };
|
|
446
|
+
const hold = before?.hold === true;
|
|
447
|
+
if (body.remove === true) return { ok: true, lock: { hold } };
|
|
448
|
+
const current = before?.retention;
|
|
449
|
+
const isExpiryChanged = body.until !== void 0 || body.period !== void 0;
|
|
450
|
+
const retention = {
|
|
451
|
+
mode: body.mode ?? current?.mode,
|
|
452
|
+
...isExpiryChanged ? {
|
|
453
|
+
...body.until !== void 0 ? { until: body.until } : {},
|
|
454
|
+
...body.period !== void 0 ? { period: body.period } : {}
|
|
455
|
+
} : {
|
|
456
|
+
...current?.until ? { until: current.until } : {},
|
|
457
|
+
...current?.period ? { period: current.period } : {}
|
|
458
|
+
},
|
|
459
|
+
derivatives: body.derivatives ?? current?.derivatives ?? "inherit"
|
|
460
|
+
};
|
|
461
|
+
const validation = validateLock3({ retention, hold });
|
|
462
|
+
if (!validation.ok) return validation;
|
|
463
|
+
return { ok: true, lock: validation.lock };
|
|
464
|
+
}
|
|
465
|
+
var timingOf = (state) => ({
|
|
466
|
+
uploadedAt: state?.uploadedAt,
|
|
467
|
+
holdReleasedAt: state?.holdReleasedAt
|
|
468
|
+
});
|
|
469
|
+
function classifyChange(state, after, now) {
|
|
470
|
+
const before = state?.lock ?? {};
|
|
471
|
+
const isActive = canChange(before, { hold: before.hold }, { privileged: false, now, timing: timingOf(state) });
|
|
472
|
+
if (isActive.ok) return after.retention ? "set" : "remove";
|
|
473
|
+
if (!after.retention) return "remove";
|
|
474
|
+
const change = canChange(before, after, { privileged: false, now, timing: timingOf(state) });
|
|
475
|
+
return change.ok ? "extend" : "shorten";
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// src/object-lock/hold.ts
|
|
479
|
+
import { validateLock as validateLock4 } from "@devultur/core";
|
|
480
|
+
var HOLD_PAGE_SIZE = 50;
|
|
481
|
+
var isKeyList = (value) => Array.isArray(value) && value.every((key) => typeof key === "string" && key.length > 0);
|
|
482
|
+
async function targetsOf(context, body) {
|
|
483
|
+
if (body.keys === void 0 === (body.prefix === void 0)) return { error: "pass either keys or prefix" };
|
|
484
|
+
if (body.keys !== void 0) {
|
|
485
|
+
if (!isKeyList(body.keys) || body.keys.length === 0) return { error: "keys must be a non-empty list of keys" };
|
|
486
|
+
if (body.keys.length > HOLD_PAGE_SIZE) return { error: `keys may list at most ${HOLD_PAGE_SIZE} keys` };
|
|
487
|
+
if (body.keys.some(isReservedLockKey)) return { error: "keys may not be under a reserved prefix" };
|
|
488
|
+
return { keys: [...new Set(body.keys)] };
|
|
489
|
+
}
|
|
490
|
+
if (typeof body.prefix !== "string" || body.prefix === "" || isReservedLockKey(body.prefix)) {
|
|
491
|
+
return { error: "prefix must be a non-empty key prefix outside the reserved ones" };
|
|
492
|
+
}
|
|
493
|
+
const cursor = typeof body.cursor === "string" ? body.cursor : void 0;
|
|
494
|
+
const listing = await context.env.MEDIA_BUCKET.list({
|
|
495
|
+
prefix: storageKey(context, body.prefix),
|
|
496
|
+
limit: HOLD_PAGE_SIZE,
|
|
497
|
+
...cursor ? { cursor } : {}
|
|
498
|
+
});
|
|
499
|
+
const root = getRequestState(context).storagePrefix;
|
|
500
|
+
const keys = listing.objects.map((object) => object.key.slice(root.length)).filter((key) => !isVariantKey(key) && !isReservedLockKey(key));
|
|
501
|
+
return { keys, prefix: body.prefix, ...listing.truncated ? { cursor: listing.cursor } : {} };
|
|
502
|
+
}
|
|
503
|
+
async function applyHold(context, key, hold, now) {
|
|
504
|
+
const root = getRequestState(context).storagePrefix;
|
|
505
|
+
const object = await context.env.MEDIA_BUCKET.head(storageKey(context, key));
|
|
506
|
+
if (!object) return { updated: false, reason: "not_found" };
|
|
507
|
+
const effective = await effectiveLock(context.env.MEDIA_BUCKET, root, key, object);
|
|
508
|
+
const before = effective.state?.lock ?? { hold: false };
|
|
509
|
+
if (before.hold === true === hold) return { updated: false, reason: "unchanged" };
|
|
510
|
+
if (effective.state?.isUnreadable) {
|
|
511
|
+
await recordLockAudit(context, {
|
|
512
|
+
action: "release",
|
|
513
|
+
keys: [key],
|
|
514
|
+
before,
|
|
515
|
+
after: { ...before, hold },
|
|
516
|
+
outcome: "rejected",
|
|
517
|
+
reason: "lock_record_unreadable"
|
|
518
|
+
});
|
|
519
|
+
return { updated: false, reason: "lock_record_unreadable" };
|
|
520
|
+
}
|
|
521
|
+
const validated = validateLock4({ ...before, hold });
|
|
522
|
+
const after = validated.ok ? validated.lock : { ...before, hold };
|
|
523
|
+
const previous = effective.source === "sidecar" ? effective.stored?.sidecar : void 0;
|
|
524
|
+
const holdReleasedAt = hold ? previous?.holdReleasedAt : now.toISOString();
|
|
525
|
+
const sidecar = {
|
|
526
|
+
revision: (effective.stored?.sidecar.revision ?? 0) + 1,
|
|
527
|
+
objectVersion: objectVersionOf(object),
|
|
528
|
+
lock: after,
|
|
529
|
+
...holdReleasedAt ? { holdReleasedAt } : {},
|
|
530
|
+
updatedAt: now.toISOString()
|
|
531
|
+
};
|
|
532
|
+
const action = hold ? "hold" : "release";
|
|
533
|
+
if (!await writeSidecar(context.env.MEDIA_BUCKET, root, key, sidecar, effective.stored)) {
|
|
534
|
+
await recordLockAudit(context, {
|
|
535
|
+
action,
|
|
536
|
+
keys: [key],
|
|
537
|
+
before,
|
|
538
|
+
after,
|
|
539
|
+
outcome: "rejected",
|
|
540
|
+
reason: "lock_conflict"
|
|
541
|
+
});
|
|
542
|
+
return { updated: false, reason: "lock_conflict" };
|
|
543
|
+
}
|
|
544
|
+
await recordLockAudit(context, { action, keys: [key], before, after, outcome: "applied" });
|
|
545
|
+
return { updated: true };
|
|
546
|
+
}
|
|
547
|
+
async function handleHold(context) {
|
|
548
|
+
const body = await context.req.json().catch(() => null) ?? {};
|
|
549
|
+
if (typeof body.hold !== "boolean")
|
|
550
|
+
return context.json({ error: "hold must be a boolean", code: "invalid_hold" }, 400);
|
|
551
|
+
const targets = await targetsOf(context, body);
|
|
552
|
+
if ("error" in targets) return context.json({ error: targets.error, code: "invalid_hold" }, 400);
|
|
553
|
+
const hold = body.hold;
|
|
554
|
+
const decision = await decideLockChange(context, {
|
|
555
|
+
action: hold ? "hold" : "release",
|
|
556
|
+
keys: targets.keys,
|
|
557
|
+
...targets.prefix ? { prefix: targets.prefix } : {},
|
|
558
|
+
before: null,
|
|
559
|
+
after: { hold }
|
|
560
|
+
});
|
|
561
|
+
if (!decision.allow) {
|
|
562
|
+
await recordLockAudit(context, {
|
|
563
|
+
action: hold ? "hold" : "release",
|
|
564
|
+
keys: targets.keys,
|
|
565
|
+
before: null,
|
|
566
|
+
after: { hold },
|
|
567
|
+
outcome: "rejected",
|
|
568
|
+
reason: "hook_refused"
|
|
569
|
+
});
|
|
570
|
+
return decision.response;
|
|
571
|
+
}
|
|
572
|
+
const now = /* @__PURE__ */ new Date();
|
|
573
|
+
const updated = [];
|
|
574
|
+
const skipped = [];
|
|
575
|
+
for (const key of targets.keys) {
|
|
576
|
+
const outcome = await applyHold(context, key, hold, now);
|
|
577
|
+
if (outcome.updated) updated.push(key);
|
|
578
|
+
else skipped.push({ key, reason: outcome.reason });
|
|
579
|
+
}
|
|
580
|
+
return context.json({ updated, skipped, ...targets.cursor ? { cursor: targets.cursor } : {} });
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// src/object-lock/lock-route.ts
|
|
584
|
+
function lockView(key, effective) {
|
|
585
|
+
const lock = effective.state?.lock;
|
|
586
|
+
let until;
|
|
587
|
+
try {
|
|
588
|
+
until = lock ? effectiveUntil2(lock, timingOf(effective.state)) : void 0;
|
|
589
|
+
} catch {
|
|
590
|
+
}
|
|
591
|
+
return {
|
|
592
|
+
key,
|
|
593
|
+
mode: lock?.retention?.mode ?? null,
|
|
594
|
+
until: until ?? null,
|
|
595
|
+
period: lock?.retention?.period ?? null,
|
|
596
|
+
hold: lock?.hold === true,
|
|
597
|
+
derivatives: lock?.retention ? lock.retention.derivatives ?? "inherit" : null,
|
|
598
|
+
source: effective.source,
|
|
599
|
+
revision: effective.source === "sidecar" ? effective.stored?.sidecar.revision ?? null : null
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
async function loadTarget(context) {
|
|
603
|
+
const key = context.req.param("key") ?? "";
|
|
604
|
+
const root = getRequestState(context).storagePrefix;
|
|
605
|
+
const object = await context.env.MEDIA_BUCKET.head(storageKey(context, key));
|
|
606
|
+
if (!object) return { key, root, object: null, effective: null };
|
|
607
|
+
return { key, root, object, effective: await effectiveLock(context.env.MEDIA_BUCKET, root, key, object) };
|
|
608
|
+
}
|
|
609
|
+
var lockRoute = new Hono();
|
|
610
|
+
lockRoute.use("*", async (context, next) => {
|
|
611
|
+
if (!isAuthenticated(context)) return context.json({ error: "Unauthorized" }, 401);
|
|
612
|
+
await next();
|
|
613
|
+
});
|
|
614
|
+
lockRoute.post("/hold", handleHold);
|
|
615
|
+
lockRoute.get("/:key{.+}", async (context) => {
|
|
616
|
+
const { key, effective } = await loadTarget(context);
|
|
617
|
+
if (!effective) return context.json({ error: "Not found" }, 404);
|
|
618
|
+
return context.json(lockView(key, effective));
|
|
619
|
+
});
|
|
620
|
+
lockRoute.put("/:key{.+}", async (context) => {
|
|
621
|
+
const body = await context.req.json().catch(() => null) ?? {};
|
|
622
|
+
const { key, root, object, effective } = await loadTarget(context);
|
|
623
|
+
if (!object || !effective) return context.json({ error: "Not found" }, 404);
|
|
624
|
+
const before = effective.state?.lock ?? null;
|
|
625
|
+
const next = nextLock(before, body);
|
|
626
|
+
if (!next.ok) return context.json({ error: next.errors.join("; "), code: "invalid_lock" }, 400);
|
|
627
|
+
const now = /* @__PURE__ */ new Date();
|
|
628
|
+
const action = classifyChange(effective.state, next.lock, now);
|
|
629
|
+
const audit = { action, keys: [key], before, after: next.lock, root };
|
|
630
|
+
const unreadable = unreadableLockRefusal(effective.state, key);
|
|
631
|
+
if (unreadable) {
|
|
632
|
+
await recordLockAudit(context, { ...audit, outcome: "rejected", reason: "lock_record_unreadable" });
|
|
633
|
+
return unreadable;
|
|
634
|
+
}
|
|
635
|
+
const decision = await decideLockChange(context, { action, keys: [key], before, after: next.lock });
|
|
636
|
+
if (!decision.allow) {
|
|
637
|
+
await recordLockAudit(context, { ...audit, outcome: "rejected", reason: "hook_refused" });
|
|
638
|
+
return decision.response;
|
|
639
|
+
}
|
|
640
|
+
const isPrivileged = body.bypassGovernance === true && decision.isApproved;
|
|
641
|
+
const change = canChange2(before ?? {}, next.lock, {
|
|
642
|
+
privileged: isPrivileged,
|
|
643
|
+
now,
|
|
644
|
+
timing: timingOf(effective.state)
|
|
645
|
+
});
|
|
646
|
+
if (!change.ok) {
|
|
647
|
+
await recordLockAudit(context, { ...audit, outcome: "rejected", reason: change.reason });
|
|
648
|
+
return refusal(context, key, change.reason);
|
|
649
|
+
}
|
|
650
|
+
const sidecar = nextSidecar(effective, objectVersionOf(object), next.lock, now);
|
|
651
|
+
if (!await writeSidecar(context.env.MEDIA_BUCKET, root, key, sidecar, effective.stored)) {
|
|
652
|
+
await recordLockAudit(context, { ...audit, outcome: "rejected", reason: "lock_conflict" });
|
|
653
|
+
return context.json({ error: `The lock of ${key} changed meanwhile; retry`, code: "lock_conflict", key }, 409);
|
|
654
|
+
}
|
|
655
|
+
await recordLockAudit(context, { ...audit, outcome: "applied", ...change.bypass ? { bypass: true } : {} });
|
|
656
|
+
const state = { ...effective.state ?? {}, lock: next.lock };
|
|
657
|
+
return context.json(lockView(key, { state, source: "sidecar", stored: { sidecar, etag: "" } }));
|
|
658
|
+
});
|
|
659
|
+
function nextSidecar(effective, objectVersion, lock, now) {
|
|
660
|
+
const previous = effective.source === "sidecar" ? effective.stored?.sidecar : void 0;
|
|
661
|
+
return {
|
|
662
|
+
revision: (effective.stored?.sidecar.revision ?? 0) + 1,
|
|
663
|
+
objectVersion,
|
|
664
|
+
lock,
|
|
665
|
+
...previous?.holdReleasedAt ? { holdReleasedAt: previous.holdReleasedAt } : {},
|
|
666
|
+
updatedAt: now.toISOString()
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
function refusal(context, key, reason) {
|
|
670
|
+
if (reason === "lock_privilege_required") {
|
|
671
|
+
return context.json(
|
|
672
|
+
{
|
|
673
|
+
error: "Shortening or removing a governance retention needs bypassGovernance and an authorizeLockChange approval",
|
|
674
|
+
code: reason,
|
|
675
|
+
key
|
|
676
|
+
},
|
|
677
|
+
403
|
|
678
|
+
);
|
|
679
|
+
}
|
|
680
|
+
return context.json({ error: `The compliance retention of ${key} can only be extended`, code: reason, key }, 409);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// src/plugin.ts
|
|
684
|
+
var PHASES = ["beforeAuth", "auth", "afterAuth"];
|
|
685
|
+
function assertComposable(plugins) {
|
|
686
|
+
const names = /* @__PURE__ */ new Set();
|
|
687
|
+
for (const plugin of plugins) {
|
|
688
|
+
if (names.has(plugin.name)) throw new Error(`createWorker: two plugins are named "${plugin.name}"`);
|
|
689
|
+
names.add(plugin.name);
|
|
690
|
+
}
|
|
691
|
+
if (!plugins.some((plugin) => (plugin.middleware?.auth?.length ?? 0) > 0)) {
|
|
692
|
+
throw new Error("createWorker: no plugin authenticates requests. Add workerSecretAuth() or your own auth plugin.");
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
function composePlugins(plugins) {
|
|
696
|
+
assertComposable(plugins);
|
|
697
|
+
return {
|
|
698
|
+
middleware: PHASES.flatMap((phase) => plugins.flatMap((plugin) => plugin.middleware?.[phase] ?? [])),
|
|
699
|
+
routes: plugins.flatMap((plugin) => plugin.routes ?? []),
|
|
700
|
+
hooks: {
|
|
701
|
+
issueCredential: plugins.flatMap((plugin) => plugin.issueCredential ? [plugin.issueCredential] : []),
|
|
702
|
+
beforeLinkRedeem: plugins.flatMap((plugin) => plugin.beforeLinkRedeem ? [plugin.beforeLinkRedeem] : []),
|
|
703
|
+
authorizeLockChange: plugins.flatMap(
|
|
704
|
+
(plugin) => plugin.authorizeLockChange ? [plugin.authorizeLockChange] : []
|
|
705
|
+
),
|
|
706
|
+
onLockAudit: plugins.flatMap((plugin) => plugin.onLockAudit ? [plugin.onLockAudit] : [])
|
|
707
|
+
}
|
|
708
|
+
};
|
|
709
|
+
}
|
|
710
|
+
|
|
130
711
|
// src/plugins/credential-policy.ts
|
|
131
712
|
function credentialPolicy(options = {}) {
|
|
132
713
|
return {
|
|
@@ -140,11 +721,11 @@ function credentialPolicy(options = {}) {
|
|
|
140
721
|
}
|
|
141
722
|
|
|
142
723
|
// src/plugins/download-links.ts
|
|
143
|
-
import { Hono as
|
|
724
|
+
import { Hono as Hono3 } from "hono";
|
|
144
725
|
|
|
145
726
|
// src/routes/download.ts
|
|
146
727
|
import { contentDispositionAttachment as contentDispositionAttachment2, relativeMediaPath, TokenScope } from "@devultur/core";
|
|
147
|
-
import { Hono } from "hono";
|
|
728
|
+
import { Hono as Hono2 } from "hono";
|
|
148
729
|
|
|
149
730
|
// src/download-link.ts
|
|
150
731
|
var isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
@@ -224,7 +805,7 @@ function addRouteResult(context, result) {
|
|
|
224
805
|
import {
|
|
225
806
|
AES_BLOCK_BYTES,
|
|
226
807
|
createDecryptStream,
|
|
227
|
-
decodeEncryptionMeta,
|
|
808
|
+
decodeEncryptionMeta as decodeEncryptionMeta2,
|
|
228
809
|
decryptChunk,
|
|
229
810
|
EXPIRES_AT_HEADER,
|
|
230
811
|
REPR_DIGEST_HEADER,
|
|
@@ -242,7 +823,13 @@ var ObjectMeta = {
|
|
|
242
823
|
/** Epoch ms. From then on the gate responds 410 (and an external sweeper may delete it). */
|
|
243
824
|
EXPIRES_AT: "expiresAt"
|
|
244
825
|
};
|
|
245
|
-
var WORKER_METADATA_KEYS = /* @__PURE__ */ new Set([
|
|
826
|
+
var WORKER_METADATA_KEYS = /* @__PURE__ */ new Set([
|
|
827
|
+
"security",
|
|
828
|
+
"enc",
|
|
829
|
+
ObjectMeta.EXPIRES_AT.toLowerCase(),
|
|
830
|
+
"derivedfrom",
|
|
831
|
+
LOCK_METADATA_KEY
|
|
832
|
+
]);
|
|
246
833
|
function isWorkerMetadataKey(key) {
|
|
247
834
|
const lowerKey = key.toLowerCase();
|
|
248
835
|
return lowerKey.startsWith(ObjectMeta.SHA256) || WORKER_METADATA_KEYS.has(lowerKey);
|
|
@@ -251,8 +838,8 @@ function allowedUploadMetadata(metadata) {
|
|
|
251
838
|
if (!metadata) return {};
|
|
252
839
|
return Object.fromEntries(Object.entries(metadata).filter(([key]) => !isWorkerMetadataKey(key)));
|
|
253
840
|
}
|
|
254
|
-
function targetFailure(context,
|
|
255
|
-
return context.json({ error:
|
|
841
|
+
function targetFailure(context, failure4) {
|
|
842
|
+
return context.json({ error: failure4.error, code: failure4.code }, failure4.status);
|
|
256
843
|
}
|
|
257
844
|
function alreadyExists(context, key) {
|
|
258
845
|
return context.json(
|
|
@@ -278,14 +865,19 @@ async function digestBody(body, declared, header) {
|
|
|
278
865
|
if (expected && expected !== actual) return { mismatch: { expected, actual } };
|
|
279
866
|
return { sha256: actual };
|
|
280
867
|
}
|
|
868
|
+
function lockMetadata(lock) {
|
|
869
|
+
const encoded = encodeLockMetadata(lock);
|
|
870
|
+
return encoded ? { [LOCK_METADATA_KEY]: encoded } : {};
|
|
871
|
+
}
|
|
281
872
|
function integrityMetadata(sha256, isVerified, expiresAt) {
|
|
282
873
|
return {
|
|
283
874
|
...sha256 ? { [ObjectMeta.SHA256]: sha256, [ObjectMeta.SHA256_VERIFIED]: String(isVerified) } : {},
|
|
284
875
|
...expiresAt ? { [ObjectMeta.EXPIRES_AT]: String(expiresAt) } : {}
|
|
285
876
|
};
|
|
286
877
|
}
|
|
287
|
-
function
|
|
288
|
-
|
|
878
|
+
function expiryMetadataOf(customMetadata) {
|
|
879
|
+
const expiresAt = customMetadata?.[ObjectMeta.EXPIRES_AT];
|
|
880
|
+
return expiresAt ? { [ObjectMeta.EXPIRES_AT]: expiresAt } : {};
|
|
289
881
|
}
|
|
290
882
|
async function discardStaleVariants(bucket, pending) {
|
|
291
883
|
if (!pending?.overwrite) return;
|
|
@@ -293,9 +885,13 @@ async function discardStaleVariants(bucket, pending) {
|
|
|
293
885
|
}
|
|
294
886
|
|
|
295
887
|
// src/routes/object-response.ts
|
|
296
|
-
function
|
|
888
|
+
function objectExpiresAt(customMetadata) {
|
|
297
889
|
const expiresAt = Number(customMetadata?.[ObjectMeta.EXPIRES_AT]);
|
|
298
|
-
return Number.isFinite(expiresAt) && expiresAt > 0
|
|
890
|
+
return Number.isFinite(expiresAt) && expiresAt > 0 ? expiresAt : void 0;
|
|
891
|
+
}
|
|
892
|
+
function isObjectExpired(customMetadata, now = Date.now()) {
|
|
893
|
+
const expiresAt = objectExpiresAt(customMetadata);
|
|
894
|
+
return expiresAt !== void 0 && expiresAt <= now;
|
|
299
895
|
}
|
|
300
896
|
function expiredResponse() {
|
|
301
897
|
return Response.json({ error: "This file has expired", code: "expired" }, { status: 410 });
|
|
@@ -352,7 +948,7 @@ async function streamObject(input) {
|
|
|
352
948
|
headers.set("ETag", object.httpEtag);
|
|
353
949
|
headers.set("Accept-Ranges", "bytes");
|
|
354
950
|
applyIntegrityHeaders(headers, object.customMetadata);
|
|
355
|
-
const enc =
|
|
951
|
+
const enc = decodeEncryptionMeta2(object.customMetadata?.enc);
|
|
356
952
|
const loaded = enc ? await loadStorageKey(input.keysBucket, input.fullKey, enc.keyId) : null;
|
|
357
953
|
if (enc && !loaded) {
|
|
358
954
|
return { response: Response.json({ error: "Decryption key unavailable" }, { status: 500 }), kind: "other" };
|
|
@@ -410,8 +1006,8 @@ async function handleDownloadLink(context) {
|
|
|
410
1006
|
const object = await context.env.MEDIA_BUCKET.head(fullKey);
|
|
411
1007
|
if (!object) return context.json({ error: "Not found" }, 404);
|
|
412
1008
|
if (isObjectExpired(object.customMetadata)) return expiredResponse();
|
|
413
|
-
const
|
|
414
|
-
const requestedExpiresAt = Math.min(Date.now() + parsed.expiresIn * 1e3,
|
|
1009
|
+
const objectExpiresAt2 = Number(object.customMetadata?.expiresAt) || Infinity;
|
|
1010
|
+
const requestedExpiresAt = Math.min(Date.now() + parsed.expiresIn * 1e3, objectExpiresAt2);
|
|
415
1011
|
const decision = await decideCredential(context, {
|
|
416
1012
|
kind: "download-link",
|
|
417
1013
|
scopes: [TokenScope.MEDIA_READ],
|
|
@@ -456,7 +1052,7 @@ async function gateRedemption(context, stub) {
|
|
|
456
1052
|
if (!peeked.ok) return relayLinkFailure(peeked);
|
|
457
1053
|
return gateLinkRedemption(context, linkInfoOf(await peeked.json()));
|
|
458
1054
|
}
|
|
459
|
-
var download = new
|
|
1055
|
+
var download = new Hono2();
|
|
460
1056
|
download.get("/:linkId", async (context) => {
|
|
461
1057
|
if (context.req.method === "HEAD") return new Response(null, { status: 405, headers: { Allow: "GET" } });
|
|
462
1058
|
const linkId = context.req.param("linkId");
|
|
@@ -490,7 +1086,7 @@ download.get("/:linkId", async (context) => {
|
|
|
490
1086
|
|
|
491
1087
|
// src/plugins/download-links.ts
|
|
492
1088
|
function downloadLinks() {
|
|
493
|
-
const issue = new
|
|
1089
|
+
const issue = new Hono3().post("/", handleDownloadLink);
|
|
494
1090
|
return {
|
|
495
1091
|
name: "download-links",
|
|
496
1092
|
routes: [
|
|
@@ -510,7 +1106,11 @@ function staticConfig(config) {
|
|
|
510
1106
|
}
|
|
511
1107
|
|
|
512
1108
|
// src/routes/token-upload.ts
|
|
513
|
-
import {
|
|
1109
|
+
import {
|
|
1110
|
+
assetRelativePath as assetRelativePath2,
|
|
1111
|
+
normalizeAssetPrefix,
|
|
1112
|
+
validateLock as validateLock5
|
|
1113
|
+
} from "@devultur/core";
|
|
514
1114
|
|
|
515
1115
|
// src/token-claims.ts
|
|
516
1116
|
function audienceClaim(storagePrefix2) {
|
|
@@ -671,18 +1271,26 @@ function parseVisibility(value) {
|
|
|
671
1271
|
if (value === void 0 || value === null) return "private";
|
|
672
1272
|
return value === "public" || value === "private" ? value : null;
|
|
673
1273
|
}
|
|
1274
|
+
function lockClaim(value) {
|
|
1275
|
+
if (value === void 0) return void 0;
|
|
1276
|
+
const validation = validateLock5(value);
|
|
1277
|
+
return validation.ok ? validation.lock : null;
|
|
1278
|
+
}
|
|
674
1279
|
function uploadGrantFromClaims(payload) {
|
|
675
1280
|
if (payload.typ !== UPLOAD_TOKEN_TYPE || typeof payload.res !== "string" || typeof payload.jti !== "string") {
|
|
676
1281
|
return null;
|
|
677
1282
|
}
|
|
678
1283
|
if (payload.visibility !== "public" && payload.visibility !== "private") return null;
|
|
1284
|
+
const lock = lockClaim(payload.lock);
|
|
1285
|
+
if (lock === null) return null;
|
|
679
1286
|
return {
|
|
680
1287
|
tokenId: payload.jti,
|
|
681
1288
|
pathPrefix: payload.res,
|
|
682
1289
|
visibility: payload.visibility,
|
|
683
1290
|
...isPositiveInteger(payload.max_bytes) ? { maxBytes: payload.max_bytes } : {},
|
|
684
1291
|
...isPositiveInteger(payload.max_files) ? { maxFiles: payload.max_files } : {},
|
|
685
|
-
...isPositiveInteger(payload.file_ttl) ? { fileExpiresIn: payload.file_ttl } : {}
|
|
1292
|
+
...isPositiveInteger(payload.file_ttl) ? { fileExpiresIn: payload.file_ttl } : {},
|
|
1293
|
+
...lock ? { lock } : {}
|
|
686
1294
|
};
|
|
687
1295
|
}
|
|
688
1296
|
var badRequest = (context, code, error) => context.json({ error, code }, 400);
|
|
@@ -703,6 +1311,8 @@ async function handleUploadToken(context) {
|
|
|
703
1311
|
if (fileExpiresIn === null) {
|
|
704
1312
|
return badRequest(context, "invalid_file_expires_in", "fileExpiresIn must be a positive number of seconds");
|
|
705
1313
|
}
|
|
1314
|
+
const lock = body.lock === void 0 ? { ok: true, lock: void 0 } : validateLock5(body.lock);
|
|
1315
|
+
if (!lock.ok) return badRequest(context, "invalid_lock", lock.errors.join("; "));
|
|
706
1316
|
const pathPrefix = assetRelativePath2(prefix);
|
|
707
1317
|
const now = Math.floor(Date.now() / 1e3);
|
|
708
1318
|
const requestedExp = now + (expiresIn ?? DEFAULT_UPLOAD_TOKEN_TTL_SECONDS);
|
|
@@ -726,7 +1336,8 @@ async function handleUploadToken(context) {
|
|
|
726
1336
|
exp: expiresAt,
|
|
727
1337
|
...maxBytes ? { max_bytes: maxBytes } : {},
|
|
728
1338
|
...maxFiles ? { max_files: maxFiles } : {},
|
|
729
|
-
...fileExpiresIn ? { file_ttl: fileExpiresIn } : {}
|
|
1339
|
+
...fileExpiresIn ? { file_ttl: fileExpiresIn } : {},
|
|
1340
|
+
...lock.lock ? { lock: lock.lock } : {}
|
|
730
1341
|
},
|
|
731
1342
|
decision
|
|
732
1343
|
);
|
|
@@ -738,7 +1349,8 @@ async function handleUploadToken(context) {
|
|
|
738
1349
|
expiresAt,
|
|
739
1350
|
...maxBytes ? { maxBytes } : {},
|
|
740
1351
|
...maxFiles ? { maxFiles } : {},
|
|
741
|
-
...fileExpiresIn ? { fileExpiresIn } : {}
|
|
1352
|
+
...fileExpiresIn ? { fileExpiresIn } : {},
|
|
1353
|
+
...lock.lock ? { lock: lock.lock } : {}
|
|
742
1354
|
});
|
|
743
1355
|
}
|
|
744
1356
|
|
|
@@ -821,9 +1433,46 @@ function defaultPlugins(config = {}) {
|
|
|
821
1433
|
|
|
822
1434
|
// src/routes/assets.ts
|
|
823
1435
|
import { ASSET_NAMESPACE, normalizeAssetPrefix as normalizeAssetPrefix2 } from "@devultur/core";
|
|
824
|
-
import { Hono as
|
|
1436
|
+
import { Hono as Hono4 } from "hono";
|
|
1437
|
+
|
|
1438
|
+
// src/object-lock/delivery.ts
|
|
1439
|
+
import { effectiveUntil as effectiveUntil3 } from "@devultur/core";
|
|
1440
|
+
var LOCK_UNTIL_HEADER = "x-devultur-lock-until";
|
|
1441
|
+
var LOCK_HOLD_HEADER = "x-devultur-lock-hold";
|
|
1442
|
+
async function lockHeaders(context, key, object) {
|
|
1443
|
+
const root = getRequestState(context).storagePrefix;
|
|
1444
|
+
const { state } = await effectiveLock(context.env.MEDIA_BUCKET, root, key, object);
|
|
1445
|
+
if (!state || !isStateLocked(state, /* @__PURE__ */ new Date())) return [];
|
|
1446
|
+
const headers = [];
|
|
1447
|
+
try {
|
|
1448
|
+
const until = effectiveUntil3(state.lock, timingOf(state));
|
|
1449
|
+
if (until) headers.push([LOCK_UNTIL_HEADER, until]);
|
|
1450
|
+
} catch {
|
|
1451
|
+
}
|
|
1452
|
+
if (state.lock.hold) headers.push([LOCK_HOLD_HEADER, "true"]);
|
|
1453
|
+
return headers;
|
|
1454
|
+
}
|
|
1455
|
+
async function retainedOf(bucket, location, object) {
|
|
1456
|
+
const { state } = await effectiveLock(bucket, location.root, location.key, object);
|
|
1457
|
+
if (!state || !isStateLocked(state, /* @__PURE__ */ new Date())) return null;
|
|
1458
|
+
let until;
|
|
1459
|
+
try {
|
|
1460
|
+
until = effectiveUntil3(state.lock, timingOf(state));
|
|
1461
|
+
} catch {
|
|
1462
|
+
}
|
|
1463
|
+
return { ...until ? { until } : {}, hold: state.lock.hold === true };
|
|
1464
|
+
}
|
|
1465
|
+
async function expiredRefusal(bucket, location, object, isOwnerRead) {
|
|
1466
|
+
if (!isObjectExpired(object.customMetadata)) return null;
|
|
1467
|
+
if (isOwnerRead && await retainedOf(bucket, location, object) !== null) return null;
|
|
1468
|
+
return expiredResponse();
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
// src/routes/assets.ts
|
|
825
1472
|
var MAX_ASSET_LIST_LIMIT = 1e3;
|
|
1473
|
+
var MAX_EXPIRED_LOOKUPS = 32;
|
|
826
1474
|
var VISIBILITY_ORDER = ["private", "public"];
|
|
1475
|
+
var RESUME_AFTER_MARK = "~";
|
|
827
1476
|
function parseListPrefix(raw) {
|
|
828
1477
|
if (!raw?.trim()) return "";
|
|
829
1478
|
return normalizeAssetPrefix2(raw);
|
|
@@ -834,7 +1483,12 @@ function parseListCursor(raw, visibilities) {
|
|
|
834
1483
|
const visibility = raw.slice(0, separator);
|
|
835
1484
|
if (separator < 0 || !visibilities.includes(visibility)) return null;
|
|
836
1485
|
const cursor = raw.slice(separator + 1);
|
|
837
|
-
return { visibility, ...cursor ? { cursor } : {} };
|
|
1486
|
+
if (!cursor.startsWith(RESUME_AFTER_MARK)) return { visibility, ...cursor ? { cursor } : {} };
|
|
1487
|
+
try {
|
|
1488
|
+
return { visibility, startAfter: decodeURIComponent(cursor.slice(RESUME_AFTER_MARK.length)) };
|
|
1489
|
+
} catch {
|
|
1490
|
+
return null;
|
|
1491
|
+
}
|
|
838
1492
|
}
|
|
839
1493
|
function parseLimit(raw) {
|
|
840
1494
|
const limit = Number(raw);
|
|
@@ -861,7 +1515,32 @@ function nextCursor(listing, position, visibilities) {
|
|
|
861
1515
|
const next = visibilities[visibilities.indexOf(position.visibility) + 1];
|
|
862
1516
|
return next ? `${next}:` : null;
|
|
863
1517
|
}
|
|
864
|
-
var
|
|
1518
|
+
var resumeCursor = (visibility, afterKey) => `${visibility}:${RESUME_AFTER_MARK}${encodeURIComponent(afterKey)}`;
|
|
1519
|
+
var expiredEntry = (entry, retained) => ({
|
|
1520
|
+
...entry,
|
|
1521
|
+
expired: true,
|
|
1522
|
+
...retained.until ? { retainedUntil: retained.until } : {},
|
|
1523
|
+
...retained.hold ? { hold: true } : {}
|
|
1524
|
+
});
|
|
1525
|
+
async function listedEntries(bucket, listing, storageRoot, visibility) {
|
|
1526
|
+
const page = { objects: [] };
|
|
1527
|
+
let lookups = 0;
|
|
1528
|
+
let lastKey;
|
|
1529
|
+
for (const object of listing.objects) {
|
|
1530
|
+
const entry = toEntry(object, storageRoot, visibility);
|
|
1531
|
+
if (isObjectExpired(object.customMetadata)) {
|
|
1532
|
+
if (lookups === MAX_EXPIRED_LOOKUPS) return { ...page, cutAfter: lastKey };
|
|
1533
|
+
lookups += 1;
|
|
1534
|
+
const retained = await retainedOf(bucket, { root: storageRoot, key: entry.key }, object);
|
|
1535
|
+
if (retained) page.objects.push(expiredEntry(entry, retained));
|
|
1536
|
+
} else {
|
|
1537
|
+
page.objects.push(entry);
|
|
1538
|
+
}
|
|
1539
|
+
lastKey = entry.key;
|
|
1540
|
+
}
|
|
1541
|
+
return page;
|
|
1542
|
+
}
|
|
1543
|
+
var assets = new Hono4();
|
|
865
1544
|
assets.get("/", async (context) => {
|
|
866
1545
|
const prefix = parseListPrefix(context.req.query("prefix"));
|
|
867
1546
|
if (prefix === null) {
|
|
@@ -879,23 +1558,251 @@ assets.get("/", async (context) => {
|
|
|
879
1558
|
prefix: `${storageRoot}${position.visibility}/${ASSET_NAMESPACE}/${prefix}`,
|
|
880
1559
|
limit: parseLimit(context.req.query("limit")),
|
|
881
1560
|
...position.cursor ? { cursor: position.cursor } : {},
|
|
1561
|
+
...position.startAfter ? { startAfter: `${storageRoot}${position.startAfter}` } : {},
|
|
882
1562
|
include: ["httpMetadata", "customMetadata"]
|
|
883
1563
|
};
|
|
884
1564
|
const listing = await context.env.MEDIA_BUCKET.list(listOptions);
|
|
885
|
-
const
|
|
886
|
-
|
|
1565
|
+
const page = await listedEntries(context.env.MEDIA_BUCKET, listing, storageRoot, position.visibility);
|
|
1566
|
+
const cursor = page.cutAfter ? resumeCursor(position.visibility, page.cutAfter) : nextCursor(listing, position, visibilities);
|
|
1567
|
+
return context.json({ objects: page.objects, cursor });
|
|
887
1568
|
});
|
|
888
1569
|
|
|
889
1570
|
// src/routes/captions.ts
|
|
890
1571
|
import {
|
|
891
1572
|
captionPath,
|
|
892
1573
|
createDecryptStream as createDecryptStream2,
|
|
893
|
-
decodeEncryptionMeta as
|
|
1574
|
+
decodeEncryptionMeta as decodeEncryptionMeta5,
|
|
894
1575
|
extractId,
|
|
895
1576
|
resolveSecurity,
|
|
896
1577
|
visibilityFromKey
|
|
897
1578
|
} from "@devultur/core";
|
|
898
|
-
import { Hono as
|
|
1579
|
+
import { Hono as Hono6 } from "hono";
|
|
1580
|
+
|
|
1581
|
+
// src/keys-bucket.ts
|
|
1582
|
+
import { decodeEncryptionMeta as decodeEncryptionMeta3 } from "@devultur/core";
|
|
1583
|
+
var KEYS_BUCKET_MISSING_CODE = "keys_bucket_missing";
|
|
1584
|
+
function isKeysBucketMissing(shouldEncrypt, env) {
|
|
1585
|
+
return shouldEncrypt && !env.KEYS_BUCKET;
|
|
1586
|
+
}
|
|
1587
|
+
function keysBucketMissing(context) {
|
|
1588
|
+
console.error("[encrypt] security.encrypt is on but KEYS_BUCKET is not bound: refusing to store a private file");
|
|
1589
|
+
return context.json(
|
|
1590
|
+
{ error: "Encryption at rest is on but KEYS_BUCKET is not bound", code: KEYS_BUCKET_MISSING_CODE },
|
|
1591
|
+
503
|
|
1592
|
+
);
|
|
1593
|
+
}
|
|
1594
|
+
var ENCRYPTION_KEY_MISSING_CODE = "encryption_key_missing";
|
|
1595
|
+
async function hasEncryptionKey(keysBucket, objectStorageKey, enc) {
|
|
1596
|
+
const meta = decodeEncryptionMeta3(enc);
|
|
1597
|
+
if (!meta) return true;
|
|
1598
|
+
if (!keysBucket) return false;
|
|
1599
|
+
return await keysBucket.head(`${objectStorageKey}/${meta.keyId}`) !== null;
|
|
1600
|
+
}
|
|
1601
|
+
function encryptionKeyMissing(context, key) {
|
|
1602
|
+
console.error(`[encrypt] the encryption key of ${key} is gone: refusing to store an object nobody could decrypt`);
|
|
1603
|
+
return context.json(
|
|
1604
|
+
{ error: `The encryption key of ${key} was deleted; upload it again`, code: ENCRYPTION_KEY_MISSING_CODE, key },
|
|
1605
|
+
500
|
|
1606
|
+
);
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
// src/object-lock/overwrite.ts
|
|
1610
|
+
import { decodeEncryptionMeta as decodeEncryptionMeta4, TranscodeStatus as TranscodeStatus2 } from "@devultur/core";
|
|
1611
|
+
|
|
1612
|
+
// src/transcode/status.ts
|
|
1613
|
+
import { TranscodeStatus } from "@devultur/core";
|
|
1614
|
+
var statusKey = (videoId) => `${KvPrefix.TRANSCODE_STATUS}:${videoId}`;
|
|
1615
|
+
function statusChangeFor(event) {
|
|
1616
|
+
switch (event.event) {
|
|
1617
|
+
case "transcode.started":
|
|
1618
|
+
return { status: TranscodeStatus.RUNNING };
|
|
1619
|
+
case "transcode.progress":
|
|
1620
|
+
return event.phase === "downloading" ? { status: TranscodeStatus.RUNNING } : null;
|
|
1621
|
+
case "transcode.complete":
|
|
1622
|
+
return { status: TranscodeStatus.COMPLETED };
|
|
1623
|
+
case "transcode.failed":
|
|
1624
|
+
return {
|
|
1625
|
+
status: TranscodeStatus.FAILED,
|
|
1626
|
+
...typeof event.error === "string" ? { error: event.error } : {},
|
|
1627
|
+
...typeof event.reason === "string" ? { reason: event.reason } : {},
|
|
1628
|
+
...typeof event.retriable === "boolean" ? { retriable: event.retriable } : {}
|
|
1629
|
+
};
|
|
1630
|
+
default:
|
|
1631
|
+
return null;
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
async function readStatus(kv, videoId) {
|
|
1635
|
+
return kv.get(statusKey(videoId), "json");
|
|
1636
|
+
}
|
|
1637
|
+
async function writeQueuedStatus(kv, videoId, job) {
|
|
1638
|
+
const record = { status: TranscodeStatus.QUEUED, ...job, updatedAt: Date.now() };
|
|
1639
|
+
await kv.put(statusKey(videoId), JSON.stringify(record), { expirationTtl: KV_JOB_TTL_SECONDS });
|
|
1640
|
+
}
|
|
1641
|
+
async function applyStatusEvent(kv, event) {
|
|
1642
|
+
const change = statusChangeFor(event);
|
|
1643
|
+
const videoId = typeof event.videoId === "string" ? event.videoId : "";
|
|
1644
|
+
if (!change || !videoId) return;
|
|
1645
|
+
const current = await readStatus(kv, videoId);
|
|
1646
|
+
if (!current) return;
|
|
1647
|
+
if (current.status === TranscodeStatus.COMPLETED) return;
|
|
1648
|
+
const record = {
|
|
1649
|
+
jobRef: current.jobRef,
|
|
1650
|
+
...current.runner ? { runner: current.runner } : {},
|
|
1651
|
+
...change,
|
|
1652
|
+
updatedAt: Date.now()
|
|
1653
|
+
};
|
|
1654
|
+
await kv.put(statusKey(videoId), JSON.stringify(record), { expirationTtl: KV_JOB_TTL_SECONDS });
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
// src/transcode/raw-retention.ts
|
|
1658
|
+
import { isVideoRawKey as isVideoRawKey2 } from "@devultur/core";
|
|
1659
|
+
var disposalKey = (videoId) => `${KvPrefix.TRANSCODE_RAW}:${videoId}`;
|
|
1660
|
+
async function readRawDisposal(kv, videoId) {
|
|
1661
|
+
return kv.get(disposalKey(videoId), "json");
|
|
1662
|
+
}
|
|
1663
|
+
async function rememberRawDisposal(kv, videoId, disposal) {
|
|
1664
|
+
await kv.put(disposalKey(videoId), JSON.stringify(disposal), { expirationTtl: KV_JOB_TTL_SECONDS });
|
|
1665
|
+
}
|
|
1666
|
+
async function disposeRawAfterTranscode(context, videoId) {
|
|
1667
|
+
const { env } = context;
|
|
1668
|
+
const disposal = await readRawDisposal(env.MEDIA_KV, videoId);
|
|
1669
|
+
if (!disposal?.deleteOnComplete || !disposal.storageKey) return "kept";
|
|
1670
|
+
const raw = await env.MEDIA_BUCKET.head(disposal.storageKey);
|
|
1671
|
+
const location = splitVideoStorageKey(disposal.storageKey);
|
|
1672
|
+
const lock = raw && location ? await effectiveLock(env.MEDIA_BUCKET, location.root, location.key, raw) : null;
|
|
1673
|
+
if (lock?.state && isStateLocked(lock.state, /* @__PURE__ */ new Date())) {
|
|
1674
|
+
await recordLockAudit(context, {
|
|
1675
|
+
action: "original_retained",
|
|
1676
|
+
keys: [location?.key ?? disposal.storageKey],
|
|
1677
|
+
before: lock.state.lock,
|
|
1678
|
+
after: null,
|
|
1679
|
+
outcome: "skipped",
|
|
1680
|
+
root: location?.root ?? ""
|
|
1681
|
+
});
|
|
1682
|
+
return "retained";
|
|
1683
|
+
}
|
|
1684
|
+
await env.MEDIA_BUCKET.delete(disposal.storageKey);
|
|
1685
|
+
if (env.KEYS_BUCKET) await deletePrefix(env.KEYS_BUCKET, `${disposal.storageKey}/`);
|
|
1686
|
+
return "deleted";
|
|
1687
|
+
}
|
|
1688
|
+
var ORIGINAL_NOT_RETAINED = {
|
|
1689
|
+
error: "The original of this video is no longer retained, so it can't be re-encoded. Upload it again, or set `config.transcode.deleteOriginalOnSuccess` to false for future videos.",
|
|
1690
|
+
code: "original_not_retained"
|
|
1691
|
+
};
|
|
1692
|
+
var PRIOR_OUTPUTS_LIST_LIMIT = 100;
|
|
1693
|
+
async function hasPriorTranscodeOutputs(bucket, videoFolder) {
|
|
1694
|
+
const listing = await bucket.list({ prefix: videoFolder, limit: PRIOR_OUTPUTS_LIST_LIMIT });
|
|
1695
|
+
return listing.objects.some((object) => !isVideoRawKey2(object.key));
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
// src/object-lock/transcode-marker.ts
|
|
1699
|
+
var MARKER_NAME = "transcode-completed";
|
|
1700
|
+
var markerKey = (root, videoFolder) => sidecarKey(root, `${videoFolder}/${MARKER_NAME}`);
|
|
1701
|
+
async function markTranscodeCompleted(env, videoId, ticketLocation) {
|
|
1702
|
+
const location = ticketLocation ?? await rememberedLocation(env, videoId);
|
|
1703
|
+
if (!location) return;
|
|
1704
|
+
await env.MEDIA_BUCKET.put(markerKey(location.root, location.folder), (/* @__PURE__ */ new Date()).toISOString(), {
|
|
1705
|
+
httpMetadata: { contentType: "text/plain" }
|
|
1706
|
+
});
|
|
1707
|
+
}
|
|
1708
|
+
async function rememberedLocation(env, videoId) {
|
|
1709
|
+
const disposal = await readRawDisposal(env.MEDIA_KV, videoId);
|
|
1710
|
+
const raw = disposal?.storageKey ? splitVideoStorageKey(disposal.storageKey) : null;
|
|
1711
|
+
const folder = raw?.key.split("/").slice(0, 3).join("/");
|
|
1712
|
+
return raw && folder ? { root: raw.root, folder } : null;
|
|
1713
|
+
}
|
|
1714
|
+
async function hasCompletedTranscode(bucket, root, videoFolder) {
|
|
1715
|
+
return await bucket.head(markerKey(root, videoFolder)) !== null;
|
|
1716
|
+
}
|
|
1717
|
+
async function forgetTranscodeCompleted(bucket, root, videoFolder) {
|
|
1718
|
+
await bucket.delete(markerKey(root, videoFolder));
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
// src/object-lock/overwrite.ts
|
|
1722
|
+
function sameObject(object) {
|
|
1723
|
+
return {
|
|
1724
|
+
etagMatches: object.etag,
|
|
1725
|
+
// R2 keeps `uploaded` to the millisecond, so "before uploaded + 1 ms" matches exactly this write and refuses
|
|
1726
|
+
// any later one. Real R2 only: the in-memory bucket of the tests can't prove the precision.
|
|
1727
|
+
...object.uploaded ? { uploadedBefore: new Date(object.uploaded.getTime() + 1) } : {}
|
|
1728
|
+
};
|
|
1729
|
+
}
|
|
1730
|
+
function overwriteOf(object) {
|
|
1731
|
+
const replacedEnc = object.customMetadata?.enc;
|
|
1732
|
+
return { onlyIf: sameObject(object), exists: true, ...replacedEnc ? { replacedEnc } : {} };
|
|
1733
|
+
}
|
|
1734
|
+
function createOnlyCondition() {
|
|
1735
|
+
return new Headers({ "If-None-Match": "*" });
|
|
1736
|
+
}
|
|
1737
|
+
async function checkWrite(context, key, fullKey = storageKey(context, key), options = {}) {
|
|
1738
|
+
if (isReservedLockKey(key)) return { blocked: reservedKeyResponse(context, key) };
|
|
1739
|
+
const object = await context.env.MEDIA_BUCKET.head(fullKey);
|
|
1740
|
+
if (!object) return { onlyIf: createOnlyCondition(), exists: false };
|
|
1741
|
+
if (options.isLockExempt) return overwriteOf(object);
|
|
1742
|
+
const root = fullKey.endsWith(key) ? fullKey.slice(0, fullKey.length - key.length) : void 0;
|
|
1743
|
+
const blocked = await assertWritable(context, [key], "overwrite", root);
|
|
1744
|
+
if (blocked) return { blocked };
|
|
1745
|
+
return overwriteOf(object);
|
|
1746
|
+
}
|
|
1747
|
+
async function deleteReplacedKey(context, fullKey, replacedEnc, writtenEnc) {
|
|
1748
|
+
const meta = decodeEncryptionMeta4(replacedEnc);
|
|
1749
|
+
if (!meta || !context.env.KEYS_BUCKET) return;
|
|
1750
|
+
if (decodeEncryptionMeta4(writtenEnc)?.keyId === meta.keyId) return;
|
|
1751
|
+
await context.env.KEYS_BUCKET.delete(`${fullKey}/${meta.keyId}`);
|
|
1752
|
+
}
|
|
1753
|
+
function writeConflict(context, key) {
|
|
1754
|
+
return context.json({ error: `${key} changed while it was being written; retry`, code: "write_conflict", key }, 409);
|
|
1755
|
+
}
|
|
1756
|
+
var WriteConflictError = class extends Error {
|
|
1757
|
+
constructor(key) {
|
|
1758
|
+
super(`${key} changed while it was being written`);
|
|
1759
|
+
this.key = key;
|
|
1760
|
+
this.name = "WriteConflictError";
|
|
1761
|
+
}
|
|
1762
|
+
key;
|
|
1763
|
+
};
|
|
1764
|
+
async function assertRetranscodable(context, video, raw) {
|
|
1765
|
+
const root = getRequestState(context).storagePrefix;
|
|
1766
|
+
const { state } = await effectiveLock(context.env.MEDIA_BUCKET, root, video.rawKey, raw);
|
|
1767
|
+
if (!state) return null;
|
|
1768
|
+
const isCompleted = await hasCompletedTranscode(context.env.MEDIA_BUCKET, root, video.folder) || (await readStatus(context.env.MEDIA_KV, video.id))?.status === TranscodeStatus2.COMPLETED;
|
|
1769
|
+
if (!isCompleted) return null;
|
|
1770
|
+
return assertWritable(context, [video.folder], "overwrite");
|
|
1771
|
+
}
|
|
1772
|
+
async function checkWrites(context, keys) {
|
|
1773
|
+
const checks = /* @__PURE__ */ new Map();
|
|
1774
|
+
for (const key of keys) {
|
|
1775
|
+
const check = await checkWrite(context, key);
|
|
1776
|
+
if ("blocked" in check) return check;
|
|
1777
|
+
checks.set(key, check);
|
|
1778
|
+
}
|
|
1779
|
+
return { checks };
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1782
|
+
// src/object-lock/caption-writes.ts
|
|
1783
|
+
async function prepareCaptionWrites(context, locales, virtualKeyOf, source) {
|
|
1784
|
+
const writes = await checkWrites(context, locales.map(virtualKeyOf));
|
|
1785
|
+
if ("blocked" in writes) return writes;
|
|
1786
|
+
const storageKeyOf = (locale) => storageKey(context, virtualKeyOf(locale));
|
|
1787
|
+
const expiry = expiryMetadataOf(source.customMetadata);
|
|
1788
|
+
return {
|
|
1789
|
+
storageKeyOf,
|
|
1790
|
+
put: async (locale, body, options) => {
|
|
1791
|
+
const check = writes.checks.get(virtualKeyOf(locale));
|
|
1792
|
+
const customMetadata = { ...options.customMetadata, ...expiry };
|
|
1793
|
+
const stored = await context.env.MEDIA_BUCKET.put(storageKeyOf(locale), body, {
|
|
1794
|
+
...options,
|
|
1795
|
+
...Object.keys(customMetadata).length > 0 ? { customMetadata } : {},
|
|
1796
|
+
onlyIf: check?.onlyIf
|
|
1797
|
+
});
|
|
1798
|
+
if (stored === null) throw new WriteConflictError(virtualKeyOf(locale));
|
|
1799
|
+
await deleteReplacedKey(context, storageKeyOf(locale), check?.replacedEnc, customMetadata.enc);
|
|
1800
|
+
}
|
|
1801
|
+
};
|
|
1802
|
+
}
|
|
1803
|
+
function captionConflictResponse(context, error) {
|
|
1804
|
+
return error instanceof WriteConflictError ? writeConflict(context, error.key) : null;
|
|
1805
|
+
}
|
|
899
1806
|
|
|
900
1807
|
// src/routes/captions-errors.ts
|
|
901
1808
|
var CaptionsErrorCode = {
|
|
@@ -954,7 +1861,7 @@ function logCaptionsError(context, error) {
|
|
|
954
1861
|
}
|
|
955
1862
|
|
|
956
1863
|
// src/routes/webhooks.ts
|
|
957
|
-
import { Hono as
|
|
1864
|
+
import { Hono as Hono5 } from "hono";
|
|
958
1865
|
|
|
959
1866
|
// src/transcode/metrics.ts
|
|
960
1867
|
var OPERATOR_EVENT_FIELDS = ["encode", "posterKey", "posterFormats"];
|
|
@@ -1072,76 +1979,8 @@ async function loadOutputBaseUrl(kv, videoId, fallbackUrl) {
|
|
|
1072
1979
|
return await kv.get(outputBaseKey(videoId)) ?? fallbackUrl;
|
|
1073
1980
|
}
|
|
1074
1981
|
|
|
1075
|
-
// src/transcode/raw-retention.ts
|
|
1076
|
-
import { isVideoRawKey } from "@devultur/core";
|
|
1077
|
-
var disposalKey = (videoId) => `${KvPrefix.TRANSCODE_RAW}:${videoId}`;
|
|
1078
|
-
async function rememberRawDisposal(kv, videoId, disposal) {
|
|
1079
|
-
await kv.put(disposalKey(videoId), JSON.stringify(disposal), { expirationTtl: KV_JOB_TTL_SECONDS });
|
|
1080
|
-
}
|
|
1081
|
-
async function disposeRawAfterTranscode(env, videoId) {
|
|
1082
|
-
const disposal = await env.MEDIA_KV.get(disposalKey(videoId), "json");
|
|
1083
|
-
if (!disposal?.deleteOnComplete || !disposal.storageKey) return false;
|
|
1084
|
-
await env.MEDIA_BUCKET.delete(disposal.storageKey);
|
|
1085
|
-
if (env.KEYS_BUCKET) await deletePrefix(env.KEYS_BUCKET, `${disposal.storageKey}/`);
|
|
1086
|
-
return true;
|
|
1087
|
-
}
|
|
1088
|
-
var ORIGINAL_NOT_RETAINED = {
|
|
1089
|
-
error: "The original of this video is no longer retained, so it can't be re-encoded. Upload it again, or set `config.transcode.deleteOriginalOnSuccess` to false for future videos.",
|
|
1090
|
-
code: "original_not_retained"
|
|
1091
|
-
};
|
|
1092
|
-
var PRIOR_OUTPUTS_LIST_LIMIT = 100;
|
|
1093
|
-
async function hasPriorTranscodeOutputs(bucket, videoFolder) {
|
|
1094
|
-
const listing = await bucket.list({ prefix: videoFolder, limit: PRIOR_OUTPUTS_LIST_LIMIT });
|
|
1095
|
-
return listing.objects.some((object) => !isVideoRawKey(object.key));
|
|
1096
|
-
}
|
|
1097
|
-
|
|
1098
|
-
// src/transcode/status.ts
|
|
1099
|
-
import { TranscodeStatus } from "@devultur/core";
|
|
1100
|
-
var statusKey = (videoId) => `${KvPrefix.TRANSCODE_STATUS}:${videoId}`;
|
|
1101
|
-
function statusChangeFor(event) {
|
|
1102
|
-
switch (event.event) {
|
|
1103
|
-
case "transcode.started":
|
|
1104
|
-
return { status: TranscodeStatus.RUNNING };
|
|
1105
|
-
case "transcode.progress":
|
|
1106
|
-
return event.phase === "downloading" ? { status: TranscodeStatus.RUNNING } : null;
|
|
1107
|
-
case "transcode.complete":
|
|
1108
|
-
return { status: TranscodeStatus.COMPLETED };
|
|
1109
|
-
case "transcode.failed":
|
|
1110
|
-
return {
|
|
1111
|
-
status: TranscodeStatus.FAILED,
|
|
1112
|
-
...typeof event.error === "string" ? { error: event.error } : {},
|
|
1113
|
-
...typeof event.reason === "string" ? { reason: event.reason } : {},
|
|
1114
|
-
...typeof event.retriable === "boolean" ? { retriable: event.retriable } : {}
|
|
1115
|
-
};
|
|
1116
|
-
default:
|
|
1117
|
-
return null;
|
|
1118
|
-
}
|
|
1119
|
-
}
|
|
1120
|
-
async function readStatus(kv, videoId) {
|
|
1121
|
-
return kv.get(statusKey(videoId), "json");
|
|
1122
|
-
}
|
|
1123
|
-
async function writeQueuedStatus(kv, videoId, job) {
|
|
1124
|
-
const record = { status: TranscodeStatus.QUEUED, ...job, updatedAt: Date.now() };
|
|
1125
|
-
await kv.put(statusKey(videoId), JSON.stringify(record), { expirationTtl: KV_JOB_TTL_SECONDS });
|
|
1126
|
-
}
|
|
1127
|
-
async function applyStatusEvent(kv, event) {
|
|
1128
|
-
const change = statusChangeFor(event);
|
|
1129
|
-
const videoId = typeof event.videoId === "string" ? event.videoId : "";
|
|
1130
|
-
if (!change || !videoId) return;
|
|
1131
|
-
const current = await readStatus(kv, videoId);
|
|
1132
|
-
if (!current) return;
|
|
1133
|
-
if (current.status === TranscodeStatus.COMPLETED) return;
|
|
1134
|
-
const record = {
|
|
1135
|
-
jobRef: current.jobRef,
|
|
1136
|
-
...current.runner ? { runner: current.runner } : {},
|
|
1137
|
-
...change,
|
|
1138
|
-
updatedAt: Date.now()
|
|
1139
|
-
};
|
|
1140
|
-
await kv.put(statusKey(videoId), JSON.stringify(record), { expirationTtl: KV_JOB_TTL_SECONDS });
|
|
1141
|
-
}
|
|
1142
|
-
|
|
1143
1982
|
// src/routes/webhooks.ts
|
|
1144
|
-
var webhooks = new
|
|
1983
|
+
var webhooks = new Hono5();
|
|
1145
1984
|
webhooks.post("/transcode", async (context) => {
|
|
1146
1985
|
const webhookSecret = context.env.TRANSCODE_WEBHOOK_SECRET;
|
|
1147
1986
|
if (!webhookSecret) {
|
|
@@ -1156,7 +1995,12 @@ webhooks.post("/transcode", async (context) => {
|
|
|
1156
1995
|
const body = await context.req.json();
|
|
1157
1996
|
await publishTranscodeJob(context, body);
|
|
1158
1997
|
if (body.event === "transcode.complete" && body.videoId) {
|
|
1159
|
-
|
|
1998
|
+
const disposal = await disposeRawAfterTranscode(context, body.videoId);
|
|
1999
|
+
if (disposal === "deleted") addRouteResult(context, { originalDeleted: true });
|
|
2000
|
+
if (disposal === "retained") addRouteResult(context, { originalRetained: true });
|
|
2001
|
+
const ticket = context.req.header(JOB_TICKET_HEADER);
|
|
2002
|
+
const location = await verifyJobLocation(context.env.DELIVERY_SIGN_SECRET, ticket, body.videoId);
|
|
2003
|
+
await markTranscodeCompleted(context.env, body.videoId, location);
|
|
1160
2004
|
await context.env.MEDIA_KV.put(
|
|
1161
2005
|
`${KvPrefix.TRANSCODE_META}:${body.videoId}`,
|
|
1162
2006
|
JSON.stringify({ duration: body.duration }),
|
|
@@ -1358,7 +2202,7 @@ async function transcodedDuration(kv, videoId) {
|
|
|
1358
2202
|
const meta = await kv.get(`${KvPrefix.TRANSCODE_META}:${videoId}`, "json");
|
|
1359
2203
|
return meta?.duration ?? void 0;
|
|
1360
2204
|
}
|
|
1361
|
-
var captions = new
|
|
2205
|
+
var captions = new Hono6();
|
|
1362
2206
|
captions.post("/", async (context) => {
|
|
1363
2207
|
const body = await context.req.json();
|
|
1364
2208
|
if (!body.key || !body.locales?.length) {
|
|
@@ -1382,7 +2226,7 @@ captions.post("/", async (context) => {
|
|
|
1382
2226
|
}
|
|
1383
2227
|
const filename = body.key.split("/").pop() ?? "audio.mp4";
|
|
1384
2228
|
const webhookData = await resolveCaptionsWebhook(context, videoId, body.callbackUrl);
|
|
1385
|
-
const rawEnc =
|
|
2229
|
+
const rawEnc = decodeEncryptionMeta5(file.customMetadata?.enc);
|
|
1386
2230
|
let audioBody = file.body;
|
|
1387
2231
|
if (rawEnc) {
|
|
1388
2232
|
const loaded = await loadStorageKey(context.env.KEYS_BUCKET, fullKey, rawEnc.keyId);
|
|
@@ -1392,12 +2236,15 @@ captions.post("/", async (context) => {
|
|
|
1392
2236
|
audioBody = file.body.pipeThrough(createDecryptStream2(loaded.key, rawEnc.iv));
|
|
1393
2237
|
}
|
|
1394
2238
|
const visibility = visibilityFromKey(body.key) ?? "private";
|
|
1395
|
-
const
|
|
2239
|
+
const captionVirtualKey = (locale) => `${visibility}/${captionPath(videoId, locale)}`;
|
|
1396
2240
|
const resourceOverride = parseSecurityHeader(file.customMetadata?.security);
|
|
1397
2241
|
const captionPolicy = resolveSecurity(resourceOverride, scope.config?.security);
|
|
1398
|
-
const encryptCaptions = captionPolicy.encrypt && visibility === "private"
|
|
2242
|
+
const encryptCaptions = Boolean(captionPolicy.encrypt) && visibility === "private";
|
|
2243
|
+
if (isKeysBucketMissing(encryptCaptions, context.env)) return keysBucketMissing(context);
|
|
2244
|
+
const writes = await prepareCaptionWrites(context, body.locales, captionVirtualKey, file);
|
|
2245
|
+
if ("blocked" in writes) return writes.blocked;
|
|
1399
2246
|
const putCaption = async (locale, vtt) => {
|
|
1400
|
-
const objectKey =
|
|
2247
|
+
const objectKey = writes.storageKeyOf(locale);
|
|
1401
2248
|
const metadata = {};
|
|
1402
2249
|
if (resourceOverride) metadata.security = JSON.stringify(resourceOverride);
|
|
1403
2250
|
let bodyToStore = vtt;
|
|
@@ -1411,7 +2258,7 @@ captions.post("/", async (context) => {
|
|
|
1411
2258
|
bodyToStore = result.body;
|
|
1412
2259
|
metadata.enc = result.enc;
|
|
1413
2260
|
}
|
|
1414
|
-
await
|
|
2261
|
+
await writes.put(locale, bodyToStore, {
|
|
1415
2262
|
httpMetadata: { contentType: "text/vtt" },
|
|
1416
2263
|
...Object.keys(metadata).length > 0 ? { customMetadata: metadata } : {}
|
|
1417
2264
|
});
|
|
@@ -1466,18 +2313,19 @@ captions.post("/", async (context) => {
|
|
|
1466
2313
|
});
|
|
1467
2314
|
} catch (err) {
|
|
1468
2315
|
logCaptionsError(`job failed for ${videoId}`, err);
|
|
1469
|
-
const
|
|
2316
|
+
const failure4 = toCaptionsFailure(err);
|
|
1470
2317
|
await forwardToClientWebhook(
|
|
1471
2318
|
{
|
|
1472
2319
|
event: "captions.failed",
|
|
1473
2320
|
videoId,
|
|
1474
|
-
error:
|
|
1475
|
-
reason:
|
|
1476
|
-
retriable:
|
|
2321
|
+
error: failure4.error,
|
|
2322
|
+
reason: failure4.reason,
|
|
2323
|
+
retriable: failure4.retriable
|
|
1477
2324
|
},
|
|
1478
2325
|
webhookData
|
|
1479
2326
|
);
|
|
1480
|
-
|
|
2327
|
+
const conflict = captionConflictResponse(context, err);
|
|
2328
|
+
return conflict ?? context.json({ error: failure4.error, code: failure4.code }, failure4.status);
|
|
1481
2329
|
}
|
|
1482
2330
|
});
|
|
1483
2331
|
captions.get("/:transcriptId", async (context) => {
|
|
@@ -1498,16 +2346,15 @@ captions.get("/:transcriptId", async (context) => {
|
|
|
1498
2346
|
// src/routes/media.ts
|
|
1499
2347
|
import {
|
|
1500
2348
|
computeFingerprint,
|
|
1501
|
-
decodeEncryptionMeta as
|
|
2349
|
+
decodeEncryptionMeta as decodeEncryptionMeta7,
|
|
1502
2350
|
GENERATED_PRIVATE_CACHE_CONTROL,
|
|
1503
2351
|
GENERATED_PUBLIC_CACHE_CONTROL,
|
|
1504
2352
|
resolveSecurity as resolveSecurity2,
|
|
1505
2353
|
SESSION_HEADER,
|
|
1506
2354
|
toPrivateCacheControl,
|
|
1507
|
-
videoBasePath,
|
|
1508
2355
|
visibilityFromKey as visibilityFromKey2
|
|
1509
2356
|
} from "@devultur/core";
|
|
1510
|
-
import { Hono as
|
|
2357
|
+
import { Hono as Hono7 } from "hono";
|
|
1511
2358
|
|
|
1512
2359
|
// src/image/cloudflare.ts
|
|
1513
2360
|
var IMAGE_RESIZING_VIA = "image-resizing";
|
|
@@ -1714,7 +2561,7 @@ function hasAvifSequenceBrand(bytes) {
|
|
|
1714
2561
|
}
|
|
1715
2562
|
|
|
1716
2563
|
// src/image/storage.ts
|
|
1717
|
-
import { decodeEncryptionMeta as
|
|
2564
|
+
import { decodeEncryptionMeta as decodeEncryptionMeta6, decryptChunk as decryptChunk2 } from "@devultur/core";
|
|
1718
2565
|
var HEADER_PROBE_BYTES = 256 * 1024;
|
|
1719
2566
|
var passThrough = async (bytes) => bytes;
|
|
1720
2567
|
var DerivedKind = {
|
|
@@ -1722,7 +2569,7 @@ var DerivedKind = {
|
|
|
1722
2569
|
MEZZANINE: "mezzanine"
|
|
1723
2570
|
};
|
|
1724
2571
|
async function loadDecryptor(env, objectKey, customMetadata) {
|
|
1725
|
-
const enc =
|
|
2572
|
+
const enc = decodeEncryptionMeta6(customMetadata?.enc);
|
|
1726
2573
|
if (!enc) return passThrough;
|
|
1727
2574
|
const loaded = await loadStorageKey(env.KEYS_BUCKET, objectKey, enc.keyId);
|
|
1728
2575
|
if (!loaded) return null;
|
|
@@ -1743,7 +2590,7 @@ async function storeDerived(env, storageKey2, body, contentType, original, kind)
|
|
|
1743
2590
|
const customMetadata = { derivedFrom: kind };
|
|
1744
2591
|
if (security) customMetadata.security = security;
|
|
1745
2592
|
let storedBody = body;
|
|
1746
|
-
if (
|
|
2593
|
+
if (decodeEncryptionMeta6(original.customMetadata?.enc)) {
|
|
1747
2594
|
if (!env.KEYS_BUCKET) throw new Error("Encrypted original without KEYS_BUCKET");
|
|
1748
2595
|
const encrypted = await encryptForStorage(env.KEYS_BUCKET, storageKey2, body, security ? { security } : void 0);
|
|
1749
2596
|
storedBody = encrypted.body;
|
|
@@ -1912,6 +2759,8 @@ import { edgeTtlSeconds, isAssetKey, sha256Hex as sha256Hex2 } from "@devultur/c
|
|
|
1912
2759
|
var MAX_EDGE_TTL_SECONDS = 7 * 24 * 60 * 60;
|
|
1913
2760
|
var MAX_EDGE_CACHE_FILL_BYTES = 100 * 1024 * 1024;
|
|
1914
2761
|
var ORIGINAL_CACHE_CONTROL_HEADER = "x-devultur-cache-control";
|
|
2762
|
+
var EDGE_EXPIRES_HEADER = "x-devultur-edge-expires";
|
|
2763
|
+
var OWNER_HEADER_PREFIX = "x-devultur-lock";
|
|
1915
2764
|
var CACHE_TAG_HEADER = "Cache-Tag";
|
|
1916
2765
|
var TAG_HASH_LENGTH = 40;
|
|
1917
2766
|
function publicEdgeCache() {
|
|
@@ -1935,35 +2784,112 @@ async function edgeCacheTags(storagePrefix2, virtualKey) {
|
|
|
1935
2784
|
const objectTag = await edgeCacheTag(storagePrefix2, virtualKey);
|
|
1936
2785
|
return prefixTag ? `${objectTag},${prefixTag}` : objectTag;
|
|
1937
2786
|
}
|
|
2787
|
+
function isEdgeCopyExpired(hit, now = Date.now()) {
|
|
2788
|
+
const expiresAt = Number(hit.headers.get(EDGE_EXPIRES_HEADER));
|
|
2789
|
+
return Number.isFinite(expiresAt) && expiresAt > 0 && expiresAt <= now;
|
|
2790
|
+
}
|
|
2791
|
+
async function matchEdgeCopy(cache, request, options) {
|
|
2792
|
+
const hit = await cache.match(request);
|
|
2793
|
+
if (!hit || isEdgeCopyExpired(hit)) return null;
|
|
2794
|
+
return responseFromEdgeHit(hit, options);
|
|
2795
|
+
}
|
|
1938
2796
|
function responseFromEdgeHit(hit, options) {
|
|
1939
2797
|
const headers = new Headers(hit.headers);
|
|
1940
2798
|
if (options.isFormatNegotiated) headers.set("Vary", "Accept");
|
|
1941
2799
|
const original = headers.get(ORIGINAL_CACHE_CONTROL_HEADER);
|
|
1942
2800
|
if (original) headers.set("Cache-Control", original);
|
|
1943
2801
|
headers.delete(ORIGINAL_CACHE_CONTROL_HEADER);
|
|
2802
|
+
headers.delete(EDGE_EXPIRES_HEADER);
|
|
1944
2803
|
headers.delete(CACHE_TAG_HEADER);
|
|
1945
2804
|
return new Response(hit.body, { status: hit.status, headers });
|
|
1946
2805
|
}
|
|
1947
2806
|
function edgeTtlFor(cacheControl) {
|
|
1948
2807
|
return edgeTtlSeconds(cacheControl, MAX_EDGE_TTL_SECONDS);
|
|
1949
2808
|
}
|
|
1950
|
-
|
|
2809
|
+
function edgeCopyTtl(target, now = Date.now()) {
|
|
1951
2810
|
const ttl = edgeTtlFor(target.cacheControl);
|
|
2811
|
+
if (target.expiresAt === void 0) return ttl;
|
|
2812
|
+
return Math.max(0, Math.min(ttl, Math.floor((target.expiresAt - now) / 1e3)));
|
|
2813
|
+
}
|
|
2814
|
+
var carriesOwnerHeaders = (headers) => [...headers.keys()].some((name) => name.toLowerCase().startsWith(OWNER_HEADER_PREFIX));
|
|
2815
|
+
async function putEdgeCopy(target, body, headers) {
|
|
2816
|
+
const ttl = edgeCopyTtl(target);
|
|
2817
|
+
if (ttl <= 0 || carriesOwnerHeaders(headers)) {
|
|
2818
|
+
await body?.cancel();
|
|
2819
|
+
return;
|
|
2820
|
+
}
|
|
1952
2821
|
const copyHeaders = new Headers(headers);
|
|
1953
2822
|
copyHeaders.delete("Content-Range");
|
|
1954
2823
|
copyHeaders.delete("Vary");
|
|
1955
2824
|
copyHeaders.set(ORIGINAL_CACHE_CONTROL_HEADER, target.cacheControl);
|
|
1956
2825
|
copyHeaders.set("Cache-Control", `public, max-age=${ttl}`);
|
|
2826
|
+
if (target.expiresAt !== void 0) copyHeaders.set(EDGE_EXPIRES_HEADER, String(target.expiresAt));
|
|
1957
2827
|
copyHeaders.set(CACHE_TAG_HEADER, await edgeCacheTags(target.storagePrefix, target.virtualKey));
|
|
1958
2828
|
await target.cache.put(target.cacheKey, new Response(body, { status: 200, headers: copyHeaders }));
|
|
1959
2829
|
}
|
|
1960
2830
|
async function fillEdgeCache(target, bucket, fullKey, headers) {
|
|
2831
|
+
if (edgeCopyTtl(target) <= 0 || carriesOwnerHeaders(headers)) return;
|
|
1961
2832
|
const full = await bucket.get(fullKey);
|
|
1962
2833
|
if (!full) return;
|
|
1963
2834
|
const fullHeaders = new Headers(headers);
|
|
1964
2835
|
fullHeaders.set("Content-Length", String(full.size));
|
|
1965
2836
|
await putEdgeCopy(target, full.body, fullHeaders);
|
|
1966
2837
|
}
|
|
2838
|
+
function cacheDelivered(waitUntil, target, delivered) {
|
|
2839
|
+
if (edgeCopyTtl(target) <= 0) return;
|
|
2840
|
+
if (delivered.kind === "plain-range" && delivered.size <= MAX_EDGE_CACHE_FILL_BYTES) {
|
|
2841
|
+
waitUntil(fillEdgeCache(target, delivered.bucket, delivered.servedKey, delivered.response.headers));
|
|
2842
|
+
}
|
|
2843
|
+
if (delivered.kind === "plain-full") {
|
|
2844
|
+
waitUntil(putEdgeCopy(target, delivered.response.clone().body, delivered.headers));
|
|
2845
|
+
}
|
|
2846
|
+
}
|
|
2847
|
+
|
|
2848
|
+
// src/routes/media-delete.ts
|
|
2849
|
+
import { videoBasePath } from "@devultur/core";
|
|
2850
|
+
async function handleVideoDelete(context) {
|
|
2851
|
+
const videoId = context.req.param("videoId") ?? "";
|
|
2852
|
+
const visibilityParam = context.req.query("visibility");
|
|
2853
|
+
const visibilities = visibilityParam === "public" || visibilityParam === "private" ? [visibilityParam] : ["public", "private"];
|
|
2854
|
+
const folders = visibilities.map((visibility) => `${visibility}/${videoBasePath(videoId)}`);
|
|
2855
|
+
const blocked = await assertWritable(context, folders, "delete");
|
|
2856
|
+
if (blocked) return blocked;
|
|
2857
|
+
let deletedCount = 0;
|
|
2858
|
+
let deletedKeyCount = 0;
|
|
2859
|
+
for (const folder of folders) {
|
|
2860
|
+
const deleted = await guardedDeletePrefix(context, `${folder}/`);
|
|
2861
|
+
if ("blocked" in deleted) return deleted.blocked;
|
|
2862
|
+
deletedCount += deleted.deletedCount;
|
|
2863
|
+
if (deleted.deletedCount > 0) {
|
|
2864
|
+
await forgetTranscodeCompleted(context.env.MEDIA_BUCKET, getRequestState(context).storagePrefix, folder);
|
|
2865
|
+
}
|
|
2866
|
+
if (!folder.startsWith("private/")) continue;
|
|
2867
|
+
const deletedKeys = await deleteFolderKeys(context, folder);
|
|
2868
|
+
if ("blocked" in deletedKeys) return deletedKeys.blocked;
|
|
2869
|
+
deletedKeyCount += deletedKeys.deletedCount;
|
|
2870
|
+
}
|
|
2871
|
+
return context.json({ videoId, deletedCount, deletedKeyCount });
|
|
2872
|
+
}
|
|
2873
|
+
async function handleKeyDelete(context) {
|
|
2874
|
+
const key = context.req.param("key") ?? "";
|
|
2875
|
+
const fullKey = storageKey(context, key);
|
|
2876
|
+
if (isVariantKey(key)) {
|
|
2877
|
+
return context.json({ error: "Not found" }, 404);
|
|
2878
|
+
}
|
|
2879
|
+
if (isReservedLockKey(key)) return reservedKeyResponse(context, key);
|
|
2880
|
+
const exists = await context.env.MEDIA_BUCKET.head(fullKey);
|
|
2881
|
+
if (!exists) {
|
|
2882
|
+
return context.json({ error: "Not found" }, 404);
|
|
2883
|
+
}
|
|
2884
|
+
const blocked = await assertWritable(context, [key], "delete");
|
|
2885
|
+
if (blocked) return blocked;
|
|
2886
|
+
await context.env.MEDIA_BUCKET.delete(fullKey);
|
|
2887
|
+
const variants = await guardedDeletePrefix(context, variantPrefix(key));
|
|
2888
|
+
if ("blocked" in variants) return variants.blocked;
|
|
2889
|
+
const keys = await deleteKeysFor(context, key, exists);
|
|
2890
|
+
if ("blocked" in keys) return keys.blocked;
|
|
2891
|
+
return new Response(null, { status: 204 });
|
|
2892
|
+
}
|
|
1967
2893
|
|
|
1968
2894
|
// src/routes/storyboard-vtt.ts
|
|
1969
2895
|
import { createDecryptStream as createDecryptStream3 } from "@devultur/core";
|
|
@@ -1993,7 +2919,7 @@ function downloadDisposition(storedDisposition, key, filename) {
|
|
|
1993
2919
|
if (storedDisposition?.startsWith("inline")) return `attachment${storedDisposition.slice("inline".length)}`;
|
|
1994
2920
|
return contentDispositionAttachment(key.split("/").pop() || "download");
|
|
1995
2921
|
}
|
|
1996
|
-
var media = new
|
|
2922
|
+
var media = new Hono7();
|
|
1997
2923
|
media.get("/:key{.+}", async (context) => {
|
|
1998
2924
|
const key = context.req.param("key");
|
|
1999
2925
|
const fullKey = storageKey(context, key);
|
|
@@ -2033,24 +2959,28 @@ media.get("/:key{.+}", async (context) => {
|
|
|
2033
2959
|
const accept = context.req.header("Accept");
|
|
2034
2960
|
const edgeCacheKey = workerParams ? `${requestUrl.origin}/__variant/${workerVariantKey(fullKey, workerParams, accept)}` : requestUrl.toString();
|
|
2035
2961
|
const isUncachedTransform = hasTransformQuery(requestUrl) && !workerParams;
|
|
2036
|
-
const
|
|
2962
|
+
const isEdgeCached = !isPrivate && !isOriginRequest && !isUncachedTransform && !isOwnerAccess;
|
|
2963
|
+
const edgeCache = isEdgeCached ? publicEdgeCache() : null;
|
|
2037
2964
|
if (edgeCache) {
|
|
2038
2965
|
const cacheRequest = workerParams ? new Request(edgeCacheKey, context.req.raw) : context.req.raw;
|
|
2039
|
-
const hit = await edgeCache
|
|
2040
|
-
if (hit) return
|
|
2966
|
+
const hit = await matchEdgeCopy(edgeCache, cacheRequest, { isFormatNegotiated });
|
|
2967
|
+
if (hit) return hit;
|
|
2041
2968
|
}
|
|
2042
2969
|
let object = await context.env.MEDIA_BUCKET.get(fullKey);
|
|
2043
2970
|
let servedKey = fullKey;
|
|
2044
2971
|
if (!object) {
|
|
2045
2972
|
return context.json({ error: "Not found" }, 404);
|
|
2046
2973
|
}
|
|
2047
|
-
|
|
2974
|
+
const location = { root: getRequestState(context).storagePrefix, key };
|
|
2975
|
+
const expired = await expiredRefusal(context.env.MEDIA_BUCKET, location, object, isOwnerAccess);
|
|
2976
|
+
if (expired) return expired;
|
|
2977
|
+
const expiresAt = objectExpiresAt(object.customMetadata);
|
|
2048
2978
|
if (tokenPayload && await bindingMismatch(context, tokenPayload, object.customMetadata)) {
|
|
2049
2979
|
return context.json({ error: "Session mismatch", code: "session_mismatch" }, 401);
|
|
2050
2980
|
}
|
|
2051
2981
|
let contentType = object.httpMetadata?.contentType ?? "application/octet-stream";
|
|
2052
2982
|
const isPlaylist = key.endsWith(".m3u8");
|
|
2053
|
-
const cacheControl = servedCacheControl(object.httpMetadata?.cacheControl, isPrivate);
|
|
2983
|
+
const cacheControl = servedCacheControl(object.httpMetadata?.cacheControl, isPrivate || isOwnerAccess);
|
|
2054
2984
|
const downloadParam = requestUrl.searchParams.get("download");
|
|
2055
2985
|
const isDownload = downloadParam !== null;
|
|
2056
2986
|
if (isPlaylist && token2) {
|
|
@@ -2096,7 +3026,7 @@ media.get("/:key{.+}", async (context) => {
|
|
|
2096
3026
|
});
|
|
2097
3027
|
}
|
|
2098
3028
|
if (token2 && isStoryboardVttKey(key) && object.body) {
|
|
2099
|
-
const enc =
|
|
3029
|
+
const enc = decodeEncryptionMeta7(object.customMetadata?.enc);
|
|
2100
3030
|
const loaded = enc ? await loadStorageKey(context.env.KEYS_BUCKET, fullKey, enc.keyId) : null;
|
|
2101
3031
|
if (enc && !loaded) return context.json({ error: "Decryption key unavailable" }, 500);
|
|
2102
3032
|
const decryption = enc && loaded ? { key: loaded.key, iv: enc.iv } : null;
|
|
@@ -2111,6 +3041,7 @@ media.get("/:key{.+}", async (context) => {
|
|
|
2111
3041
|
} else if (object.httpMetadata?.contentDisposition) {
|
|
2112
3042
|
headers.set("Content-Disposition", object.httpMetadata.contentDisposition);
|
|
2113
3043
|
}
|
|
3044
|
+
if (isOwnerAccess) for (const [name, value] of await lockHeaders(context, key, object)) headers.set(name, value);
|
|
2114
3045
|
const { response, kind } = await streamObject({
|
|
2115
3046
|
bucket: context.env.MEDIA_BUCKET,
|
|
2116
3047
|
keysBucket: context.env.KEYS_BUCKET,
|
|
@@ -2119,56 +3050,25 @@ media.get("/:key{.+}", async (context) => {
|
|
|
2119
3050
|
headers,
|
|
2120
3051
|
range: context.req.header("Range")
|
|
2121
3052
|
});
|
|
2122
|
-
if (!edgeCache
|
|
3053
|
+
if (!edgeCache) return response;
|
|
2123
3054
|
const target = {
|
|
2124
3055
|
cache: edgeCache,
|
|
2125
3056
|
cacheKey: edgeCacheKey,
|
|
2126
3057
|
storagePrefix: getRequestState(context).storagePrefix,
|
|
2127
3058
|
virtualKey: key,
|
|
2128
|
-
cacheControl
|
|
3059
|
+
cacheControl,
|
|
3060
|
+
expiresAt
|
|
2129
3061
|
};
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
}
|
|
2133
|
-
if (kind === "plain-full") {
|
|
2134
|
-
context.executionCtx.waitUntil(putEdgeCopy(target, response.clone().body, headers));
|
|
2135
|
-
}
|
|
3062
|
+
const delivered = { bucket: context.env.MEDIA_BUCKET, servedKey, size: object.size, kind, response, headers };
|
|
3063
|
+
cacheDelivered((task) => context.executionCtx.waitUntil(task), target, delivered);
|
|
2136
3064
|
return response;
|
|
2137
3065
|
});
|
|
2138
3066
|
function servedCacheControl(stored, isPrivate) {
|
|
2139
3067
|
if (!stored) return isPrivate ? GENERATED_PRIVATE_CACHE_CONTROL : GENERATED_PUBLIC_CACHE_CONTROL;
|
|
2140
3068
|
return isPrivate ? toPrivateCacheControl(stored) : stored;
|
|
2141
3069
|
}
|
|
2142
|
-
media.delete("/videos/:videoId",
|
|
2143
|
-
|
|
2144
|
-
const visibilityParam = context.req.query("visibility");
|
|
2145
|
-
const visibilities = visibilityParam === "public" || visibilityParam === "private" ? [visibilityParam] : ["public", "private"];
|
|
2146
|
-
let deletedCount = 0;
|
|
2147
|
-
let deletedKeyCount = 0;
|
|
2148
|
-
for (const visibility of visibilities) {
|
|
2149
|
-
const prefix = storagePrefix(context, `${visibility}/${videoBasePath(videoId)}/`);
|
|
2150
|
-
deletedCount += await deletePrefix(context.env.MEDIA_BUCKET, prefix);
|
|
2151
|
-
if (visibility === "private" && context.env.KEYS_BUCKET) {
|
|
2152
|
-
deletedKeyCount += await deletePrefix(context.env.KEYS_BUCKET, prefix);
|
|
2153
|
-
}
|
|
2154
|
-
}
|
|
2155
|
-
return context.json({ videoId, deletedCount, deletedKeyCount });
|
|
2156
|
-
});
|
|
2157
|
-
media.delete("/:key{.+}", async (context) => {
|
|
2158
|
-
const key = context.req.param("key");
|
|
2159
|
-
const fullKey = storageKey(context, key);
|
|
2160
|
-
if (isVariantKey(key)) {
|
|
2161
|
-
return context.json({ error: "Not found" }, 404);
|
|
2162
|
-
}
|
|
2163
|
-
const exists = await context.env.MEDIA_BUCKET.head(fullKey);
|
|
2164
|
-
if (!exists) {
|
|
2165
|
-
return context.json({ error: "Not found" }, 404);
|
|
2166
|
-
}
|
|
2167
|
-
await context.env.MEDIA_BUCKET.delete(fullKey);
|
|
2168
|
-
await deletePrefix(context.env.MEDIA_BUCKET, variantPrefix(fullKey));
|
|
2169
|
-
if (context.env.KEYS_BUCKET) await deletePrefix(context.env.KEYS_BUCKET, `${fullKey}/`);
|
|
2170
|
-
return new Response(null, { status: 204 });
|
|
2171
|
-
});
|
|
3070
|
+
media.delete("/videos/:videoId", handleVideoDelete);
|
|
3071
|
+
media.delete("/:key{.+}", handleKeyDelete);
|
|
2172
3072
|
function parseSecurityMeta(raw) {
|
|
2173
3073
|
if (!raw) return void 0;
|
|
2174
3074
|
try {
|
|
@@ -2217,9 +3117,9 @@ async function serveEncryptionKey(context, key, tokenPayload) {
|
|
|
2217
3117
|
|
|
2218
3118
|
// src/routes/token.ts
|
|
2219
3119
|
import { computeFingerprint as computeFingerprint2, DEFAULT_SECURITY, TokenScope as TokenScope3 } from "@devultur/core";
|
|
2220
|
-
import { Hono as
|
|
3120
|
+
import { Hono as Hono8 } from "hono";
|
|
2221
3121
|
var DEFAULT_VIEWER_TOKEN_TTL_SECONDS = 7200;
|
|
2222
|
-
var token = new
|
|
3122
|
+
var token = new Hono8();
|
|
2223
3123
|
token.post("/", async (context) => {
|
|
2224
3124
|
const body = await context.req.json();
|
|
2225
3125
|
if (!body.userId) {
|
|
@@ -2281,7 +3181,7 @@ import {
|
|
|
2281
3181
|
resolveSecurity as resolveSecurity3,
|
|
2282
3182
|
resolveTranscodePreset,
|
|
2283
3183
|
TokenScope as TokenScope4,
|
|
2284
|
-
TranscodeStatus as
|
|
3184
|
+
TranscodeStatus as TranscodeStatus3,
|
|
2285
3185
|
videoBasePath as videoBasePath2,
|
|
2286
3186
|
videoHlsPath,
|
|
2287
3187
|
videoMp4Dir,
|
|
@@ -2290,7 +3190,7 @@ import {
|
|
|
2290
3190
|
videoThumbnailPath,
|
|
2291
3191
|
visibilityFromKey as visibilityFromKey3
|
|
2292
3192
|
} from "@devultur/core";
|
|
2293
|
-
import { Hono as
|
|
3193
|
+
import { Hono as Hono9 } from "hono";
|
|
2294
3194
|
|
|
2295
3195
|
// src/transcode/plan.ts
|
|
2296
3196
|
import { MAIN_VARIANT } from "@devultur/core";
|
|
@@ -3229,7 +4129,7 @@ function parseSecurityMeta2(raw) {
|
|
|
3229
4129
|
}
|
|
3230
4130
|
var SERVICE_TOKEN_TTL_SECONDS = 3600;
|
|
3231
4131
|
var isVideoId = (videoId, key) => Boolean(videoId) && videoId !== key;
|
|
3232
|
-
var transcode = new
|
|
4132
|
+
var transcode = new Hono9();
|
|
3233
4133
|
transcode.post("/", async (context) => {
|
|
3234
4134
|
const body = await context.req.json();
|
|
3235
4135
|
if (!body.key) {
|
|
@@ -3250,6 +4150,9 @@ transcode.post("/", async (context) => {
|
|
|
3250
4150
|
if (!isVideoId(videoId, body.key)) {
|
|
3251
4151
|
return context.json({ error: "Invalid key format" }, 400);
|
|
3252
4152
|
}
|
|
4153
|
+
const video = { id: videoId, folder: `${visibility}/${videoBasePath2(videoId)}`, rawKey: body.key };
|
|
4154
|
+
const retained = await assertRetranscodable(context, video, object);
|
|
4155
|
+
if (retained) return retained;
|
|
3253
4156
|
const scope = getRequestState(context);
|
|
3254
4157
|
const transcodeConfig = scope.config?.transcode;
|
|
3255
4158
|
const resolved = resolveTranscodePreset(body.preset, transcodeConfig?.presets);
|
|
@@ -3269,7 +4172,8 @@ transcode.post("/", async (context) => {
|
|
|
3269
4172
|
const resourceSecurityHeader = rawOverride ? JSON.stringify(rawOverride) : "";
|
|
3270
4173
|
const keyMetadata = rawOverride ? { security: resourceSecurityHeader } : void 0;
|
|
3271
4174
|
const policy = resolveSecurity3(rawOverride, scope.config?.security);
|
|
3272
|
-
const shouldEncrypt = policy.encrypt && visibility === "private"
|
|
4175
|
+
const shouldEncrypt = Boolean(policy.encrypt) && visibility === "private";
|
|
4176
|
+
if (isKeysBucketMissing(shouldEncrypt, context.env)) return keysBucketMissing(context);
|
|
3273
4177
|
let encryptKeyB64 = "";
|
|
3274
4178
|
let encryptKeyId = "";
|
|
3275
4179
|
if (shouldEncrypt && context.env.KEYS_BUCKET) {
|
|
@@ -3325,7 +4229,8 @@ transcode.post("/", async (context) => {
|
|
|
3325
4229
|
});
|
|
3326
4230
|
const { ticket: jobTicket } = await signJobTicket(context.env.DELIVERY_SIGN_SECRET, {
|
|
3327
4231
|
videoId,
|
|
3328
|
-
...scope.context ? { context: scope.context } : {}
|
|
4232
|
+
...scope.context ? { context: scope.context } : {},
|
|
4233
|
+
location: { root: scope.storagePrefix, folder: video.folder }
|
|
3329
4234
|
});
|
|
3330
4235
|
const runner = createRunner(context.env);
|
|
3331
4236
|
if (!runner) {
|
|
@@ -3386,7 +4291,7 @@ transcode.post("/", async (context) => {
|
|
|
3386
4291
|
outputPrefix,
|
|
3387
4292
|
thumbnailKey,
|
|
3388
4293
|
preset,
|
|
3389
|
-
status:
|
|
4294
|
+
status: TranscodeStatus3.QUEUED
|
|
3390
4295
|
});
|
|
3391
4296
|
});
|
|
3392
4297
|
transcode.get("/:jobId", async (context) => {
|
|
@@ -3399,12 +4304,12 @@ transcode.get("/:jobId", async (context) => {
|
|
|
3399
4304
|
kv.get(`${KvPrefix.TRANSCODE_META}:${jobMeta.videoId}`, "json")
|
|
3400
4305
|
]);
|
|
3401
4306
|
const ownRecord = record?.jobRef === jobId ? record : null;
|
|
3402
|
-
const status = ownRecord?.status ?? (durationMeta ?
|
|
3403
|
-
if (status ===
|
|
4307
|
+
const status = ownRecord?.status ?? (durationMeta ? TranscodeStatus3.COMPLETED : TranscodeStatus3.QUEUED);
|
|
4308
|
+
if (status === TranscodeStatus3.FAILED) {
|
|
3404
4309
|
const { error, reason, retriable } = ownRecord ?? {};
|
|
3405
4310
|
return context.json({ jobId, status, error, reason, retriable });
|
|
3406
4311
|
}
|
|
3407
|
-
if (status !==
|
|
4312
|
+
if (status !== TranscodeStatus3.COMPLETED) return context.json({ jobId, status });
|
|
3408
4313
|
return context.json({
|
|
3409
4314
|
jobId,
|
|
3410
4315
|
status,
|
|
@@ -3415,29 +4320,42 @@ transcode.get("/:jobId", async (context) => {
|
|
|
3415
4320
|
});
|
|
3416
4321
|
|
|
3417
4322
|
// src/routes/upload.ts
|
|
3418
|
-
import {
|
|
3419
|
-
|
|
3420
|
-
MediaType as MediaType2,
|
|
3421
|
-
resolveSecurity as resolveSecurity5,
|
|
3422
|
-
resolveVisibility,
|
|
3423
|
-
UploadRequestKind as UploadRequestKind2
|
|
3424
|
-
} from "@devultur/core";
|
|
3425
|
-
import { Hono as Hono9 } from "hono";
|
|
4323
|
+
import { MediaType as MediaType2, resolveSecurity as resolveSecurity5, resolveVisibility, UploadRequestKind as UploadRequestKind2 } from "@devultur/core";
|
|
4324
|
+
import { Hono as Hono10 } from "hono";
|
|
3426
4325
|
|
|
3427
|
-
// src/
|
|
3428
|
-
import {
|
|
3429
|
-
|
|
3430
|
-
|
|
3431
|
-
|
|
3432
|
-
|
|
3433
|
-
|
|
3434
|
-
|
|
3435
|
-
|
|
3436
|
-
|
|
3437
|
-
|
|
3438
|
-
|
|
3439
|
-
|
|
3440
|
-
|
|
4326
|
+
// src/object-lock/upload-lock.ts
|
|
4327
|
+
import { canChange as canChange3, resolveLock, validateLock as validateLock6 } from "@devultur/core";
|
|
4328
|
+
var failure3 = (status, code, error) => ({ status, code, error });
|
|
4329
|
+
function requestedLock(lock, hold) {
|
|
4330
|
+
if (hold !== void 0 && typeof hold !== "boolean") return failure3(400, "invalid_lock", "hold must be a boolean");
|
|
4331
|
+
if (lock === void 0 && hold === void 0) return void 0;
|
|
4332
|
+
const validation = validateLock6(lock ?? {});
|
|
4333
|
+
if (!validation.ok) return failure3(400, "invalid_lock", validation.errors.join("; "));
|
|
4334
|
+
return { ...validation.lock, ...hold !== void 0 ? { hold } : {} };
|
|
4335
|
+
}
|
|
4336
|
+
function satisfiesMinimum(minimum, lock, now) {
|
|
4337
|
+
if (minimum.hold && !lock.hold) return false;
|
|
4338
|
+
const change = canChange3(minimum, lock, { privileged: false, now, timing: { uploadedAt: now } });
|
|
4339
|
+
return change.ok && !change.bypass;
|
|
4340
|
+
}
|
|
4341
|
+
function resolveUploadLock(input) {
|
|
4342
|
+
const requested = requestedLock(input.lock, input.hold);
|
|
4343
|
+
if (requested && "code" in requested) return requested;
|
|
4344
|
+
const minimum = input.grant?.lock;
|
|
4345
|
+
const resolution = resolveLock(requested ?? minimum, input.appDefault);
|
|
4346
|
+
if (!resolution.ok) return failure3(400, "invalid_lock", resolution.errors.join("; "));
|
|
4347
|
+
const lock = resolution.lock;
|
|
4348
|
+
if (minimum && !satisfiesMinimum(minimum, lock, input.now)) {
|
|
4349
|
+
return failure3(403, "lock_below_minimum", "The upload token requires a stricter lock than the request asks for");
|
|
4350
|
+
}
|
|
4351
|
+
return lock.retention || lock.hold ? lock : void 0;
|
|
4352
|
+
}
|
|
4353
|
+
|
|
4354
|
+
// src/object-lock/upload-write.ts
|
|
4355
|
+
import { decodeEncryptionMeta as decodeEncryptionMeta8, resolveLock as resolveLock2 } from "@devultur/core";
|
|
4356
|
+
|
|
4357
|
+
// src/object-lock/job-outputs.ts
|
|
4358
|
+
import { isBearerOf, TranscodeStatus as TranscodeStatus4 } from "@devultur/core";
|
|
3441
4359
|
|
|
3442
4360
|
// src/routes/derived-upload.ts
|
|
3443
4361
|
var DERIVED_ASSET_KEY = /^(public|private)\/video\/[A-Za-z0-9-]+\/(hls|mp4|thumbnails|storyboard)\/[^/]+$/;
|
|
@@ -3451,18 +4369,109 @@ var DERIVED_CONTENT_TYPES = /* @__PURE__ */ new Set([
|
|
|
3451
4369
|
function isDerivedAssetKey(key) {
|
|
3452
4370
|
return DERIVED_ASSET_KEY.test(key);
|
|
3453
4371
|
}
|
|
4372
|
+
var RAW_LIST_LIMIT = 100;
|
|
4373
|
+
async function inheritedExpiresAt(context, key) {
|
|
4374
|
+
if (!isDerivedAssetKey(key)) return void 0;
|
|
4375
|
+
const videoFolder = key.split("/").slice(0, 3).join("/");
|
|
4376
|
+
const options = {
|
|
4377
|
+
prefix: `${getRequestState(context).storagePrefix}${videoFolder}/raw/`,
|
|
4378
|
+
limit: RAW_LIST_LIMIT,
|
|
4379
|
+
include: ["customMetadata"]
|
|
4380
|
+
};
|
|
4381
|
+
const listing = await context.env.MEDIA_BUCKET.list(options);
|
|
4382
|
+
const expiries = listing.objects.map((raw) => objectExpiresAt(raw.customMetadata)).filter((expiresAt) => expiresAt !== void 0);
|
|
4383
|
+
return expiries.length > 0 ? Math.min(...expiries) : void 0;
|
|
4384
|
+
}
|
|
3454
4385
|
async function derivedAssetPending(context, key) {
|
|
3455
4386
|
if (!isDerivedAssetKey(key) || !isAuthenticated(context)) return null;
|
|
3456
4387
|
const body = await context.req.json().catch(() => null);
|
|
3457
4388
|
const declared = typeof body?.contentType === "string" ? body.contentType : "";
|
|
3458
4389
|
const security = parseSecurityHeader(context.req.header(RESOURCE_SECURITY_HEADER));
|
|
4390
|
+
const expiresAt = await inheritedExpiresAt(context, key);
|
|
3459
4391
|
return {
|
|
3460
4392
|
contentType: DERIVED_CONTENT_TYPES.has(declared) ? declared : "application/octet-stream",
|
|
3461
4393
|
storageKey: storageKey(context, key),
|
|
3462
|
-
...security ? { security } : {}
|
|
4394
|
+
...security ? { security } : {},
|
|
4395
|
+
...expiresAt ? { expiresAt } : {}
|
|
3463
4396
|
};
|
|
3464
4397
|
}
|
|
3465
4398
|
|
|
4399
|
+
// src/object-lock/job-outputs.ts
|
|
4400
|
+
var VIDEO_ID = /^(?:public|private)\/video\/([^/]+)\//;
|
|
4401
|
+
var IN_PROGRESS = /* @__PURE__ */ new Set([TranscodeStatus4.QUEUED, TranscodeStatus4.RUNNING]);
|
|
4402
|
+
async function isInProgressJobOutput(context, key) {
|
|
4403
|
+
if (!isDerivedAssetKey(key)) return false;
|
|
4404
|
+
if (!isBearerOf(context.req.header("Authorization"), getRequestState(context).runnerCredential)) return false;
|
|
4405
|
+
const videoId = VIDEO_ID.exec(key)?.[1];
|
|
4406
|
+
if (!videoId) return false;
|
|
4407
|
+
const status = await readStatus(context.env.MEDIA_KV, videoId);
|
|
4408
|
+
return status !== null && IN_PROGRESS.has(status.status);
|
|
4409
|
+
}
|
|
4410
|
+
|
|
4411
|
+
// src/object-lock/upload-write.ts
|
|
4412
|
+
async function prepareUploadWrite(context, key, pending, fullKey) {
|
|
4413
|
+
const isLockExempt = await isInProgressJobOutput(context, key);
|
|
4414
|
+
const check = await checkWrite(context, key, fullKey, { isLockExempt });
|
|
4415
|
+
if ("blocked" in check) return check;
|
|
4416
|
+
const isCreateOnly = Boolean(pending?.isAsset && !pending.overwrite);
|
|
4417
|
+
if (isCreateOnly && check.exists) return { blocked: alreadyExists(context, key) };
|
|
4418
|
+
return check;
|
|
4419
|
+
}
|
|
4420
|
+
async function assetUploadConflict(context, key, fullKey, shouldOverwrite) {
|
|
4421
|
+
const check = await checkWrite(context, key, fullKey);
|
|
4422
|
+
if ("blocked" in check) return check.blocked;
|
|
4423
|
+
return check.exists && !shouldOverwrite ? alreadyExists(context, key) : null;
|
|
4424
|
+
}
|
|
4425
|
+
async function failedWrite(context, key, pending, enc) {
|
|
4426
|
+
const meta = decodeEncryptionMeta8(enc);
|
|
4427
|
+
const fullKey = pending?.storageKey ?? storageKey(context, key);
|
|
4428
|
+
if (context.env.KEYS_BUCKET && meta) await context.env.KEYS_BUCKET.delete(`${fullKey}/${meta.keyId}`);
|
|
4429
|
+
return pending?.isAsset && !pending.overwrite ? alreadyExists(context, key) : writeConflict(context, key);
|
|
4430
|
+
}
|
|
4431
|
+
function uploadLockOf(context, key, pending) {
|
|
4432
|
+
if (pending) return pending.lock;
|
|
4433
|
+
if (lockSubjectOf(key)?.kind !== "original") return void 0;
|
|
4434
|
+
const resolution = resolveLock2(void 0, getRequestState(context).config?.lock);
|
|
4435
|
+
if (!resolution.ok) {
|
|
4436
|
+
console.error("config.lock is invalid:", resolution.errors.join("; "));
|
|
4437
|
+
return { hold: true };
|
|
4438
|
+
}
|
|
4439
|
+
const { lock } = resolution;
|
|
4440
|
+
return lock.retention || lock.hold ? lock : void 0;
|
|
4441
|
+
}
|
|
4442
|
+
async function afterUploadWrite(context, key, pending, write, writtenEnc) {
|
|
4443
|
+
const fullKey = pending?.storageKey ?? storageKey(context, key);
|
|
4444
|
+
const root = fullKey.endsWith(key) ? fullKey.slice(0, -key.length) : getRequestState(context).storagePrefix;
|
|
4445
|
+
if (pending) await discardStaleVariants(context.env.MEDIA_BUCKET, pending);
|
|
4446
|
+
if (write.exists) await deleteReplacedKey(context, fullKey, write.replacedEnc, writtenEnc);
|
|
4447
|
+
const lock = uploadLockOf(context, key, pending);
|
|
4448
|
+
if (!lock) return;
|
|
4449
|
+
await recordLockAudit(context, {
|
|
4450
|
+
action: "set",
|
|
4451
|
+
keys: [key],
|
|
4452
|
+
before: null,
|
|
4453
|
+
after: lock,
|
|
4454
|
+
outcome: "applied",
|
|
4455
|
+
actor: pending ? pending.context ?? null : getRequestState(context).context ?? null,
|
|
4456
|
+
root
|
|
4457
|
+
});
|
|
4458
|
+
}
|
|
4459
|
+
|
|
4460
|
+
// src/routes/upload-multipart.ts
|
|
4461
|
+
import {
|
|
4462
|
+
AES_BLOCK_BYTES as AES_BLOCK_BYTES2,
|
|
4463
|
+
classifyUploadRequest,
|
|
4464
|
+
counterForBlock,
|
|
4465
|
+
decodeEncryptionMeta as decodeEncryptionMeta9,
|
|
4466
|
+
encryptBytes,
|
|
4467
|
+
MULTIPART_PART_SIZE_BYTES,
|
|
4468
|
+
MULTIPART_QUERY,
|
|
4469
|
+
multipartPartCount,
|
|
4470
|
+
multipartPartRange,
|
|
4471
|
+
resolveSecurity as resolveSecurity4,
|
|
4472
|
+
UploadRequestKind
|
|
4473
|
+
} from "@devultur/core";
|
|
4474
|
+
|
|
3466
4475
|
// src/routes/upload-pending.ts
|
|
3467
4476
|
var UPLOAD_PENDING_QUERY = "u";
|
|
3468
4477
|
var PENDING_ID_BYTES = 16;
|
|
@@ -3620,15 +4629,19 @@ async function handleMultipartCreate(context, key, target) {
|
|
|
3620
4629
|
if (pending.multipart) {
|
|
3621
4630
|
return context.json({ uploadId: pending.multipart.uploadId, partSize: pending.multipart.partSize, partCount });
|
|
3622
4631
|
}
|
|
3623
|
-
|
|
4632
|
+
const policy = resolveSecurity4(pending.security, getRequestState(context).config?.security);
|
|
4633
|
+
const shouldEncrypt = shouldEncryptAtRest(policy, key);
|
|
4634
|
+
if (isKeysBucketMissing(shouldEncrypt, context.env)) return keysBucketMissing(context);
|
|
4635
|
+
const write = await prepareUploadWrite(context, pending.key ?? key, pending, pending.storageKey);
|
|
4636
|
+
if ("blocked" in write) return write.blocked;
|
|
3624
4637
|
const customMetadata = {
|
|
3625
4638
|
...allowedUploadMetadata(pending.uploadMetadata),
|
|
3626
|
-
...integrityMetadata(declaredSha256, false, pending.expiresAt)
|
|
4639
|
+
...integrityMetadata(declaredSha256, false, pending.expiresAt),
|
|
4640
|
+
...lockMetadata(pending.lock)
|
|
3627
4641
|
};
|
|
3628
4642
|
if (pending.security) customMetadata.security = JSON.stringify(pending.security);
|
|
3629
|
-
const policy = resolveSecurity4(pending.security, getRequestState(context).config?.security);
|
|
3630
4643
|
let enc;
|
|
3631
|
-
if (context.env.KEYS_BUCKET
|
|
4644
|
+
if (shouldEncrypt && context.env.KEYS_BUCKET) {
|
|
3632
4645
|
({ enc } = await createObjectEncryption(context.env.KEYS_BUCKET, pending.storageKey, { ...customMetadata }));
|
|
3633
4646
|
customMetadata.enc = enc;
|
|
3634
4647
|
}
|
|
@@ -3661,7 +4674,7 @@ async function handleMultipartCreate(context, key, target) {
|
|
|
3661
4674
|
}
|
|
3662
4675
|
async function encryptPart(context, pending, multipart, partNumber, body) {
|
|
3663
4676
|
if (!multipart.enc) return body;
|
|
3664
|
-
const meta =
|
|
4677
|
+
const meta = decodeEncryptionMeta9(multipart.enc);
|
|
3665
4678
|
const material = meta ? await loadStorageKey(context.env.KEYS_BUCKET, pending.storageKey, meta.keyId) : null;
|
|
3666
4679
|
if (!meta || !material) return null;
|
|
3667
4680
|
const startBlock = (partNumber - 1) * multipart.partSize / AES_BLOCK_BYTES2;
|
|
@@ -3698,18 +4711,20 @@ async function handleMultipartComplete(context, key) {
|
|
|
3698
4711
|
const { pending, multipart, kvKey } = active;
|
|
3699
4712
|
const parts = normalizeCompletedParts(multipart, body?.parts);
|
|
3700
4713
|
if (!parts) return context.json({ error: "parts must list every part exactly once", code: "invalid_parts" }, 400);
|
|
3701
|
-
|
|
4714
|
+
const write = await prepareUploadWrite(context, pending.key ?? key, pending, pending.storageKey);
|
|
4715
|
+
if ("blocked" in write) return write.blocked;
|
|
3702
4716
|
const multipartUpload = context.env.MEDIA_BUCKET.resumeMultipartUpload(pending.storageKey, multipart.uploadId);
|
|
4717
|
+
if (!await hasEncryptionKey(context.env.KEYS_BUCKET, pending.storageKey, multipart.enc)) {
|
|
4718
|
+
await multipartUpload.abort();
|
|
4719
|
+
await context.env.MEDIA_KV.delete(kvKey);
|
|
4720
|
+
return encryptionKeyMissing(context, key);
|
|
4721
|
+
}
|
|
3703
4722
|
const object = await multipartUpload.complete(parts);
|
|
3704
4723
|
await context.env.MEDIA_KV.delete(kvKey);
|
|
3705
|
-
await
|
|
4724
|
+
await afterUploadWrite(context, key, pending, write, multipart.enc);
|
|
3706
4725
|
const integrity = multipart.sha256 ? { sha256: multipart.sha256, sha256Verified: false } : {};
|
|
3707
4726
|
return context.json({ key, size: object.size, ...integrity });
|
|
3708
4727
|
}
|
|
3709
|
-
async function isTakenAsset(context, pending) {
|
|
3710
|
-
if (!pending.isAsset || pending.overwrite) return false;
|
|
3711
|
-
return Boolean(await context.env.MEDIA_BUCKET.head(pending.storageKey));
|
|
3712
|
-
}
|
|
3713
4728
|
async function checkPartSha256(context, multipart, body) {
|
|
3714
4729
|
const partSha256 = headerSha256(context);
|
|
3715
4730
|
if (partSha256 === null) {
|
|
@@ -3754,7 +4769,7 @@ async function handleMultipartAbort(context) {
|
|
|
3754
4769
|
}
|
|
3755
4770
|
|
|
3756
4771
|
// src/routes/upload.ts
|
|
3757
|
-
var upload = new
|
|
4772
|
+
var upload = new Hono10();
|
|
3758
4773
|
upload.post("/", async (context) => {
|
|
3759
4774
|
const body = await context.req.json();
|
|
3760
4775
|
const isAssetRequest = body.path !== void 0;
|
|
@@ -3798,6 +4813,14 @@ upload.post("/", async (context) => {
|
|
|
3798
4813
|
if (isUploadTargetError(sha256)) return targetFailure(context, sha256);
|
|
3799
4814
|
const cacheControl = resolveObjectCacheControl(body.cacheControl, target, scope.config?.cacheRules);
|
|
3800
4815
|
if (isUploadTargetError(cacheControl)) return targetFailure(context, cacheControl);
|
|
4816
|
+
const lock = resolveUploadLock({
|
|
4817
|
+
lock: body.lock,
|
|
4818
|
+
hold: body.hold,
|
|
4819
|
+
grant,
|
|
4820
|
+
appDefault: scope.config?.lock,
|
|
4821
|
+
now: Date.now()
|
|
4822
|
+
});
|
|
4823
|
+
if (isUploadTargetError(lock)) return targetFailure(context, lock);
|
|
3801
4824
|
const maxSizeBytes = effectiveMaxBytes(uploadRule?.maxSizeBytes, grant);
|
|
3802
4825
|
const declaredSize = Number(body.size);
|
|
3803
4826
|
if (maxSizeBytes != null && Number.isFinite(declaredSize) && declaredSize > maxSizeBytes) {
|
|
@@ -3805,15 +4828,17 @@ upload.post("/", async (context) => {
|
|
|
3805
4828
|
}
|
|
3806
4829
|
const virtualKey = target.key;
|
|
3807
4830
|
const fullKey = storageKey(context, virtualKey);
|
|
3808
|
-
const
|
|
3809
|
-
if (
|
|
3810
|
-
return
|
|
4831
|
+
const resolvedSecurity = resolveSecurity5(body.security, scope.config?.security);
|
|
4832
|
+
if (isKeysBucketMissing(shouldEncryptAtRest(resolvedSecurity, virtualKey), context.env)) {
|
|
4833
|
+
return keysBucketMissing(context);
|
|
3811
4834
|
}
|
|
4835
|
+
const shouldOverwrite = body.overwrite === true;
|
|
4836
|
+
const conflict = target.isAsset ? await assetUploadConflict(context, virtualKey, fullKey, shouldOverwrite) : null;
|
|
4837
|
+
if (conflict) return conflict;
|
|
3812
4838
|
if (grant) {
|
|
3813
4839
|
const exhausted = await claimTokenFileSlot(context.env.MEDIA_KV, grant);
|
|
3814
4840
|
if (exhausted) return targetFailure(context, exhausted);
|
|
3815
4841
|
}
|
|
3816
|
-
const resolvedSecurity = resolveSecurity5(body.security, scope.config?.security);
|
|
3817
4842
|
const pending = {
|
|
3818
4843
|
contentType: body.contentType,
|
|
3819
4844
|
storageKey: fullKey,
|
|
@@ -3828,7 +4853,8 @@ upload.post("/", async (context) => {
|
|
|
3828
4853
|
...expiresAt ? { expiresAt } : {},
|
|
3829
4854
|
...target.isAsset ? { isAsset: true, overwrite: shouldOverwrite } : {},
|
|
3830
4855
|
...cacheControl ? { cacheControl } : {},
|
|
3831
|
-
...scope.config?.uploadMetadata ? { uploadMetadata: scope.config.uploadMetadata } : {}
|
|
4856
|
+
...scope.config?.uploadMetadata ? { uploadMetadata: scope.config.uploadMetadata } : {},
|
|
4857
|
+
...lock ? { lock } : {}
|
|
3832
4858
|
};
|
|
3833
4859
|
const pendingId = newUploadPendingId();
|
|
3834
4860
|
await context.env.MEDIA_KV.put(uploadPendingKvKey(pendingId), JSON.stringify(pending), {
|
|
@@ -3883,25 +4909,30 @@ upload.put("/:key{.+}", async (context) => {
|
|
|
3883
4909
|
}
|
|
3884
4910
|
if ("mismatch" in digest) return sha256Mismatch(context, digest.mismatch.expected, digest.mismatch.actual);
|
|
3885
4911
|
const policy = resolveSecurity5(resourceSecurity, getRequestState(context).config?.security);
|
|
3886
|
-
const shouldEncrypt = shouldEncryptAtRest(policy, key
|
|
4912
|
+
const shouldEncrypt = shouldEncryptAtRest(policy, key);
|
|
4913
|
+
if (isKeysBucketMissing(shouldEncrypt, context.env)) return keysBucketMissing(context);
|
|
3887
4914
|
const customMetadata = {
|
|
3888
4915
|
...allowedUploadMetadata(pending?.uploadMetadata ?? getRequestState(context).config?.uploadMetadata),
|
|
3889
|
-
|
|
4916
|
+
// A transcode output (no pending) expires with its video's raw.
|
|
4917
|
+
...integrityMetadata(digest.sha256, true, pending?.expiresAt ?? await inheritedExpiresAt(context, key)),
|
|
4918
|
+
...lockMetadata(uploadLockOf(context, key, pending))
|
|
3890
4919
|
};
|
|
3891
4920
|
if (resourceSecurity) customMetadata.security = JSON.stringify(resourceSecurity);
|
|
4921
|
+
const write = await prepareUploadWrite(context, pending?.key ?? key, pending, finalStorageKey);
|
|
4922
|
+
if ("blocked" in write) return write.blocked;
|
|
3892
4923
|
let storedBody = body;
|
|
3893
4924
|
if (shouldEncrypt && context.env.KEYS_BUCKET) {
|
|
3894
4925
|
const result = await encryptForStorage(context.env.KEYS_BUCKET, finalStorageKey, body, customMetadata);
|
|
3895
4926
|
storedBody = result.body;
|
|
3896
4927
|
customMetadata.enc = result.enc;
|
|
3897
4928
|
}
|
|
3898
|
-
|
|
4929
|
+
if (!await hasEncryptionKey(context.env.KEYS_BUCKET, finalStorageKey, customMetadata.enc)) {
|
|
4930
|
+
return encryptionKeyMissing(context, key);
|
|
4931
|
+
}
|
|
3899
4932
|
const stored = await context.env.MEDIA_BUCKET.put(finalStorageKey, storedBody, {
|
|
3900
|
-
|
|
3901
|
-
// Standard except
|
|
3902
|
-
//
|
|
3903
|
-
// transcode's read, and the direct MP4 delivery if it isn't transcoded. With retention `off` the raw lives
|
|
3904
|
-
// for minutes.
|
|
4933
|
+
onlyIf: write.onlyIf,
|
|
4934
|
+
// Standard, except a video raw the config keeps ≥30 days: Infrequent Access (30-day minimum, and a retrieval
|
|
4935
|
+
// charge on every read, the transcode's included; see `RAW_STORAGE_CLASS`).
|
|
3905
4936
|
storageClass,
|
|
3906
4937
|
httpMetadata: {
|
|
3907
4938
|
contentType,
|
|
@@ -3910,23 +4941,325 @@ upload.put("/:key{.+}", async (context) => {
|
|
|
3910
4941
|
},
|
|
3911
4942
|
...Object.keys(customMetadata).length > 0 ? { customMetadata } : {}
|
|
3912
4943
|
});
|
|
3913
|
-
if (
|
|
3914
|
-
await discardObjectKey(context.env.KEYS_BUCKET, finalStorageKey, customMetadata.enc);
|
|
3915
|
-
return alreadyExists(context, key);
|
|
3916
|
-
}
|
|
4944
|
+
if (stored === null) return failedWrite(context, key, pending, customMetadata.enc);
|
|
3917
4945
|
if (resolved) await context.env.MEDIA_KV.delete(resolved.kvKey);
|
|
3918
|
-
await
|
|
4946
|
+
await afterUploadWrite(context, key, pending, write, customMetadata.enc);
|
|
3919
4947
|
return context.json({ key, size: body.byteLength, sha256: digest.sha256, sha256Verified: true });
|
|
3920
4948
|
});
|
|
3921
|
-
|
|
3922
|
-
|
|
3923
|
-
|
|
4949
|
+
|
|
4950
|
+
// src/object-lock/lock-status.ts
|
|
4951
|
+
var VISIBILITY_SEGMENTS = /* @__PURE__ */ new Set(["public", "private"]);
|
|
4952
|
+
var RESERVED_SEGMENT = /__lock(?:-audit)?$/;
|
|
4953
|
+
function placeOfStorageKey(storageKey2, root) {
|
|
4954
|
+
const segments = storageKey2.split("/");
|
|
4955
|
+
if (segments.some((segment) => RESERVED_SEGMENT.test(segment))) return { kind: "reserved" };
|
|
4956
|
+
if (root !== void 0) {
|
|
4957
|
+
const key = storageKey2.slice(root.length);
|
|
4958
|
+
const isWorkerKey = storageKey2.startsWith(root) && VISIBILITY_SEGMENTS.has(key.split("/")[0] ?? "");
|
|
4959
|
+
return isWorkerKey ? { kind: "object", root, key } : { kind: "unrecognized" };
|
|
4960
|
+
}
|
|
4961
|
+
const first = segments.findIndex((segment) => VISIBILITY_SEGMENTS.has(segment));
|
|
4962
|
+
if (first < 0) return { kind: "unrecognized" };
|
|
4963
|
+
const inferredRoot = first === 0 ? "" : `${segments.slice(0, first).join("/")}/`;
|
|
4964
|
+
return { kind: "object", root: inferredRoot, key: storageKey2.slice(inferredRoot.length) };
|
|
4965
|
+
}
|
|
4966
|
+
var UNRECOGNIZED_LOCK = { locked: true, hold: false, unrecognized: true };
|
|
4967
|
+
var VARIANTS_SEGMENT = "/__variants/";
|
|
4968
|
+
function retentionSubjectOf(key) {
|
|
4969
|
+
const isVariant = isVariantKey(key);
|
|
4970
|
+
const original = isVariant ? key.slice(0, key.indexOf(VARIANTS_SEGMENT)) : key;
|
|
4971
|
+
return { subject: lockSubjectOf(original) ?? { kind: "original", key: original }, isVariant };
|
|
4972
|
+
}
|
|
4973
|
+
function toStoredObjectLock(locked, place) {
|
|
4974
|
+
if (!locked) return { locked: false, hold: false };
|
|
4975
|
+
const { lock, isUnreadable } = locked.state;
|
|
4976
|
+
const until = untilOf(locked.state);
|
|
4977
|
+
return {
|
|
4978
|
+
locked: true,
|
|
4979
|
+
hold: lock.hold === true,
|
|
4980
|
+
...until ? { until } : {},
|
|
4981
|
+
...lock.retention ? { mode: lock.retention.mode } : {},
|
|
4982
|
+
...isUnreadable ? { unreadable: true } : {},
|
|
4983
|
+
...locked.key === place.key ? {} : { lockedBy: `${place.root}${locked.key}` }
|
|
4984
|
+
};
|
|
4985
|
+
}
|
|
4986
|
+
var LOCK_CACHE_MAX_USES = 50;
|
|
4987
|
+
var LOCK_CACHE_MAX_AGE_MS = 5e3;
|
|
4988
|
+
function lockResolver(mediaBucket, now) {
|
|
4989
|
+
const cache = /* @__PURE__ */ new Map();
|
|
4990
|
+
const resolveSubject = (root, subject) => {
|
|
4991
|
+
const id = `${root}\0${subject.kind === "original" ? subject.key : subject.rawPrefix}`;
|
|
4992
|
+
const cached = cache.get(id);
|
|
4993
|
+
const isFresh = cached && cached.uses < LOCK_CACHE_MAX_USES && Date.now() - cached.readAt < LOCK_CACHE_MAX_AGE_MS;
|
|
4994
|
+
if (cached && isFresh) {
|
|
4995
|
+
cached.uses += 1;
|
|
4996
|
+
return cached.pending;
|
|
4997
|
+
}
|
|
4998
|
+
const pending = findLockedOriginal(mediaBucket, root, subject, now);
|
|
4999
|
+
pending.catch(() => cache.delete(id));
|
|
5000
|
+
cache.set(id, { pending, readAt: Date.now(), uses: 1 });
|
|
5001
|
+
return pending;
|
|
5002
|
+
};
|
|
5003
|
+
return async (place, object) => {
|
|
5004
|
+
const { subject, isVariant } = retentionSubjectOf(place.key);
|
|
5005
|
+
if (subject.kind === "original" && subject.key === place.key) {
|
|
5006
|
+
return lockedObject(mediaBucket, place.root, place.key, object, now);
|
|
5007
|
+
}
|
|
5008
|
+
const locked = await resolveSubject(place.root, subject);
|
|
5009
|
+
if (isVariant && subject.kind === "original" && locked && !protectsDerived(locked.state)) return null;
|
|
5010
|
+
return locked;
|
|
5011
|
+
};
|
|
5012
|
+
}
|
|
5013
|
+
async function readObjectLock(options, storageKey2) {
|
|
5014
|
+
const place = placeOfStorageKey(storageKey2, options.root);
|
|
5015
|
+
if (place.kind === "reserved") return null;
|
|
5016
|
+
const object = await options.mediaBucket.head(storageKey2);
|
|
5017
|
+
if (!object) return null;
|
|
5018
|
+
if (place.kind === "unrecognized") return UNRECOGNIZED_LOCK;
|
|
5019
|
+
const locked = await lockResolver(options.mediaBucket, options.now ?? /* @__PURE__ */ new Date())(place, object);
|
|
5020
|
+
return toStoredObjectLock(locked, place);
|
|
5021
|
+
}
|
|
5022
|
+
|
|
5023
|
+
// src/object-lock/prefix-walk.ts
|
|
5024
|
+
var DEFAULT_MAX_SUBREQUESTS = 40;
|
|
5025
|
+
var DEFAULT_BATCH_SIZE = 100;
|
|
5026
|
+
var MAX_BATCH_SIZE = 1e3;
|
|
5027
|
+
var BudgetExhausted = class extends Error {
|
|
5028
|
+
constructor() {
|
|
5029
|
+
super("subrequest budget exhausted");
|
|
5030
|
+
}
|
|
5031
|
+
};
|
|
5032
|
+
function subrequestBudget(max) {
|
|
5033
|
+
if (!Number.isInteger(max) || max < 1) throw new RangeError("maxSubrequests must be a positive integer");
|
|
5034
|
+
let used = 0;
|
|
5035
|
+
return {
|
|
5036
|
+
get used() {
|
|
5037
|
+
return used;
|
|
5038
|
+
},
|
|
5039
|
+
canAfford: (count) => used + count <= max,
|
|
5040
|
+
track: (bucket) => new Proxy(bucket, {
|
|
5041
|
+
get(target, property) {
|
|
5042
|
+
const value = Reflect.get(target, property);
|
|
5043
|
+
if (typeof value !== "function") return value;
|
|
5044
|
+
return (...args) => {
|
|
5045
|
+
if (used >= max) throw new BudgetExhausted();
|
|
5046
|
+
used += 1;
|
|
5047
|
+
return value.apply(target, args);
|
|
5048
|
+
};
|
|
5049
|
+
}
|
|
5050
|
+
})
|
|
5051
|
+
};
|
|
5052
|
+
}
|
|
5053
|
+
function encodeCursor(prefix, cursor) {
|
|
5054
|
+
return btoa(JSON.stringify({ prefix, ...cursor }));
|
|
5055
|
+
}
|
|
5056
|
+
var VIDEO_FOLDER_SHAPE = /^(?:public|private)\/video\/[^/]+$/;
|
|
5057
|
+
function invalidCursor(reason) {
|
|
5058
|
+
throw new RangeError(`cursor isn't one this function returned for this prefix: ${reason}`);
|
|
5059
|
+
}
|
|
5060
|
+
function parseCursor(cursor) {
|
|
5061
|
+
try {
|
|
5062
|
+
const parsed = JSON.parse(atob(cursor));
|
|
5063
|
+
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed))
|
|
5064
|
+
return parsed;
|
|
5065
|
+
} catch {
|
|
5066
|
+
}
|
|
5067
|
+
return invalidCursor("not a cursor");
|
|
5068
|
+
}
|
|
5069
|
+
var isOptionalString = (value) => value === void 0 || typeof value === "string";
|
|
5070
|
+
function validFolder(value, options) {
|
|
5071
|
+
if (value === void 0) return void 0;
|
|
5072
|
+
const { root, folder } = typeof value === "object" && value !== null ? value : {};
|
|
5073
|
+
if (typeof root !== "string" || typeof folder !== "string" || !VIDEO_FOLDER_SHAPE.test(folder)) {
|
|
5074
|
+
return invalidCursor("folder");
|
|
5075
|
+
}
|
|
5076
|
+
const folderPrefix = `${root}${folder}/`;
|
|
5077
|
+
const place = placeOfStorageKey(`${folderPrefix}x`, options.root);
|
|
5078
|
+
const isPlaced = place.kind === "object" && place.root === root;
|
|
5079
|
+
if (!isPlaced || !folderPrefix.startsWith(options.prefix)) return invalidCursor("folder outside the prefix");
|
|
5080
|
+
return { root, folder };
|
|
5081
|
+
}
|
|
5082
|
+
function decodeCursor(cursor, options) {
|
|
5083
|
+
if (cursor === void 0) return {};
|
|
5084
|
+
const { prefix, after, listCursor, folder } = parseCursor(cursor);
|
|
5085
|
+
if (prefix !== options.prefix) return invalidCursor("another prefix");
|
|
5086
|
+
if (!isOptionalString(after) || !isOptionalString(listCursor)) return invalidCursor("position");
|
|
5087
|
+
if (typeof after === "string" && !after.startsWith(options.prefix))
|
|
5088
|
+
return invalidCursor("position outside the prefix");
|
|
5089
|
+
const pendingFolder = validFolder(folder, options);
|
|
5090
|
+
return {
|
|
5091
|
+
...typeof after === "string" ? { after } : {},
|
|
5092
|
+
...typeof listCursor === "string" ? { listCursor } : {},
|
|
5093
|
+
...pendingFolder ? { folder: pendingFolder } : {}
|
|
5094
|
+
};
|
|
5095
|
+
}
|
|
5096
|
+
async function walkPrefix(options, mediaBucket, start, visit) {
|
|
5097
|
+
const limit = Math.min(Math.max(1, Math.floor(options.batchSize ?? DEFAULT_BATCH_SIZE)), MAX_BATCH_SIZE);
|
|
5098
|
+
let position = { ...start };
|
|
5099
|
+
let hasProgressed = false;
|
|
5100
|
+
const stopped = () => ({ position, isDone: false, hasProgressed });
|
|
5101
|
+
for (; ; ) {
|
|
5102
|
+
let listing;
|
|
5103
|
+
try {
|
|
5104
|
+
listing = await mediaBucket.list({ prefix: options.prefix, limit, ...listFrom(position) });
|
|
5105
|
+
} catch (error) {
|
|
5106
|
+
if (error instanceof BudgetExhausted) return stopped();
|
|
5107
|
+
throw error;
|
|
5108
|
+
}
|
|
5109
|
+
const stray = listing.objects.find((listed) => !listed.key.startsWith(options.prefix));
|
|
5110
|
+
if (stray) throw new RangeError(`listing returned ${stray.key}, outside the prefix ${options.prefix}`);
|
|
5111
|
+
for (const listed of listing.objects) {
|
|
5112
|
+
try {
|
|
5113
|
+
await visit({ listed, place: placeOfStorageKey(listed.key, options.root) });
|
|
5114
|
+
} catch (error) {
|
|
5115
|
+
if (error instanceof BudgetExhausted) return stopped();
|
|
5116
|
+
throw error;
|
|
5117
|
+
}
|
|
5118
|
+
position = { after: listed.key };
|
|
5119
|
+
hasProgressed = true;
|
|
5120
|
+
}
|
|
5121
|
+
if (!listing.truncated) return { position, isDone: true, hasProgressed };
|
|
5122
|
+
if (listing.objects.length > 0) continue;
|
|
5123
|
+
position = { ...position, listCursor: listing.cursor };
|
|
5124
|
+
hasProgressed = true;
|
|
5125
|
+
}
|
|
5126
|
+
}
|
|
5127
|
+
function listFrom(position) {
|
|
5128
|
+
if (position.listCursor) return { cursor: position.listCursor };
|
|
5129
|
+
return position.after ? { startAfter: position.after } : {};
|
|
5130
|
+
}
|
|
5131
|
+
function assertProgress(hasProgressed) {
|
|
5132
|
+
if (hasProgressed) return;
|
|
5133
|
+
throw new RangeError("maxSubrequests is too small to finish the next object: raise it");
|
|
5134
|
+
}
|
|
5135
|
+
|
|
5136
|
+
// src/object-lock/prefix-delete.ts
|
|
5137
|
+
var VIDEO_FOLDER = /^((?:public|private)\/video\/[^/]+)\//;
|
|
5138
|
+
var isPending = (folder) => folder?.hasDeleted === true && !folder.hasRetained;
|
|
5139
|
+
async function deletePrefixRespectingLocks(options) {
|
|
5140
|
+
const startedAt = /* @__PURE__ */ new Date();
|
|
5141
|
+
const budget = subrequestBudget(options.maxSubrequests ?? DEFAULT_MAX_SUBREQUESTS);
|
|
5142
|
+
const mediaBucket = budget.track(options.mediaBucket);
|
|
5143
|
+
const keysBucket = options.keysBucket ? budget.track(options.keysBucket) : void 0;
|
|
5144
|
+
const resolveLock3 = lockResolver(mediaBucket, startedAt);
|
|
5145
|
+
const start = decodeCursor(options.cursor, options);
|
|
5146
|
+
const result = {
|
|
5147
|
+
deletedCount: 0,
|
|
5148
|
+
deletedBytes: 0,
|
|
5149
|
+
deletedKeyCount: 0,
|
|
5150
|
+
retainedCount: 0,
|
|
5151
|
+
retainedBytes: 0,
|
|
5152
|
+
retained: [],
|
|
5153
|
+
skippedCount: 0,
|
|
5154
|
+
subrequests: 0
|
|
5155
|
+
};
|
|
5156
|
+
let openFolder = start.folder ? { ...start.folder, hasDeleted: true, hasRetained: false } : null;
|
|
5157
|
+
let hasClosed = false;
|
|
5158
|
+
const closeFolder = async () => {
|
|
5159
|
+
if (isPending(openFolder)) {
|
|
5160
|
+
const { root, folder } = openFolder;
|
|
5161
|
+
const folderPrefix = `${root}${folder}/`;
|
|
5162
|
+
const remaining = await mediaBucket.list({ prefix: folderPrefix, limit: 1 });
|
|
5163
|
+
if (remaining.objects.length === 0) {
|
|
5164
|
+
if (keysBucket) {
|
|
5165
|
+
const keys = await keysWrittenBy(keysBucket, folderPrefix, startedAt);
|
|
5166
|
+
result.deletedKeyCount += await deleteKeys(keysBucket, keys);
|
|
5167
|
+
}
|
|
5168
|
+
await forgetTranscodeCompleted(mediaBucket, root, folder);
|
|
5169
|
+
}
|
|
5170
|
+
hasClosed = true;
|
|
5171
|
+
}
|
|
5172
|
+
openFolder = null;
|
|
5173
|
+
};
|
|
5174
|
+
const enterFolder = async (place) => {
|
|
5175
|
+
const videoFolder = VIDEO_FOLDER.exec(place.key)?.[1];
|
|
5176
|
+
const folder = videoFolder && `${place.root}${videoFolder}/`.startsWith(options.prefix) ? videoFolder : void 0;
|
|
5177
|
+
if (openFolder && openFolder.root === place.root && openFolder.folder === folder) return openFolder;
|
|
5178
|
+
await closeFolder();
|
|
5179
|
+
openFolder = folder ? { root: place.root, folder, hasDeleted: false, hasRetained: false } : null;
|
|
5180
|
+
return openFolder;
|
|
5181
|
+
};
|
|
5182
|
+
const retain = (key, size, lock) => {
|
|
5183
|
+
result.retained.push({ key, size, ...lock });
|
|
5184
|
+
result.retainedCount += 1;
|
|
5185
|
+
result.retainedBytes += size;
|
|
5186
|
+
};
|
|
5187
|
+
const visit = async ({ listed, place }) => {
|
|
5188
|
+
if (place.kind === "reserved") {
|
|
5189
|
+
result.skippedCount += 1;
|
|
5190
|
+
return;
|
|
5191
|
+
}
|
|
5192
|
+
if (place.kind === "unrecognized") return retain(listed.key, listed.size, UNRECOGNIZED_LOCK);
|
|
5193
|
+
const folder = await enterFolder(place);
|
|
5194
|
+
const object = await mediaBucket.head(listed.key);
|
|
5195
|
+
if (!object) return;
|
|
5196
|
+
const locked = await resolveLock3(place, object);
|
|
5197
|
+
if (locked) {
|
|
5198
|
+
if (folder) folder.hasRetained = true;
|
|
5199
|
+
return retain(listed.key, object.size, toStoredObjectLock(locked, place));
|
|
5200
|
+
}
|
|
5201
|
+
const keys = keysBucket ? await objectKeysToDelete(keysBucket, listed.key, object) : [];
|
|
5202
|
+
if (!budget.canAfford(1 + deleteCallsFor(keys))) throw new BudgetExhausted();
|
|
5203
|
+
await mediaBucket.delete(listed.key);
|
|
5204
|
+
result.deletedCount += 1;
|
|
5205
|
+
result.deletedBytes += object.size;
|
|
5206
|
+
if (folder) folder.hasDeleted = true;
|
|
5207
|
+
if (keysBucket) result.deletedKeyCount += await deleteKeys(keysBucket, keys);
|
|
5208
|
+
};
|
|
5209
|
+
const walk = await walkPrefix(options, mediaBucket, start, visit);
|
|
5210
|
+
if (walk.isDone) {
|
|
5211
|
+
try {
|
|
5212
|
+
await closeFolder();
|
|
5213
|
+
} catch (error) {
|
|
5214
|
+
if (!(error instanceof BudgetExhausted)) throw error;
|
|
5215
|
+
}
|
|
5216
|
+
}
|
|
5217
|
+
const pendingFolder = isPending(openFolder) ? { root: openFolder.root, folder: openFolder.folder } : void 0;
|
|
5218
|
+
const isFinished = walk.isDone && !pendingFolder;
|
|
5219
|
+
assertProgress(walk.hasProgressed || hasClosed || isFinished);
|
|
5220
|
+
const cursor = isFinished ? void 0 : encodeCursor(options.prefix, { ...walk.position, ...pendingFolder ? { folder: pendingFolder } : {} });
|
|
5221
|
+
return { ...result, ...cursor ? { cursor } : {}, subrequests: budget.used };
|
|
5222
|
+
}
|
|
5223
|
+
|
|
5224
|
+
// src/object-lock/retained-summary.ts
|
|
5225
|
+
async function retainedSummary(options) {
|
|
5226
|
+
const budget = subrequestBudget(options.maxSubrequests ?? DEFAULT_MAX_SUBREQUESTS);
|
|
5227
|
+
const mediaBucket = budget.track(options.mediaBucket);
|
|
5228
|
+
const resolveLock3 = lockResolver(mediaBucket, options.now ?? /* @__PURE__ */ new Date());
|
|
5229
|
+
const summary = {
|
|
5230
|
+
scannedCount: 0,
|
|
5231
|
+
retainedCount: 0,
|
|
5232
|
+
retainedBytes: 0,
|
|
5233
|
+
hasHold: false,
|
|
5234
|
+
subrequests: 0
|
|
5235
|
+
};
|
|
5236
|
+
const visit = async ({ listed, place }) => {
|
|
5237
|
+
if (place.kind === "reserved") return;
|
|
5238
|
+
let lock = UNRECOGNIZED_LOCK;
|
|
5239
|
+
let size = listed.size;
|
|
5240
|
+
if (place.kind === "object") {
|
|
5241
|
+
const object = await mediaBucket.head(listed.key);
|
|
5242
|
+
if (!object) return;
|
|
5243
|
+
lock = toStoredObjectLock(await resolveLock3(place, object), place);
|
|
5244
|
+
size = object.size;
|
|
5245
|
+
}
|
|
5246
|
+
summary.scannedCount += 1;
|
|
5247
|
+
if (!lock.locked) return;
|
|
5248
|
+
summary.retainedCount += 1;
|
|
5249
|
+
summary.retainedBytes += size;
|
|
5250
|
+
if (lock.hold) summary.hasHold = true;
|
|
5251
|
+
if (lock.until && (!summary.latestUntil || lock.until > summary.latestUntil)) summary.latestUntil = lock.until;
|
|
5252
|
+
};
|
|
5253
|
+
const walk = await walkPrefix(options, mediaBucket, decodeCursor(options.cursor, options), visit);
|
|
5254
|
+
assertProgress(walk.hasProgressed || walk.isDone);
|
|
5255
|
+
const cursor = walk.isDone ? void 0 : encodeCursor(options.prefix, walk.position);
|
|
5256
|
+
return { ...summary, ...cursor ? { cursor } : {}, subrequests: budget.used };
|
|
3924
5257
|
}
|
|
3925
5258
|
|
|
3926
5259
|
// src/index.ts
|
|
3927
5260
|
function createWorker(options) {
|
|
3928
5261
|
const { middleware, routes, hooks } = composePlugins(options?.plugins ?? defaultPlugins());
|
|
3929
|
-
const app = new
|
|
5262
|
+
const app = new Hono11();
|
|
3930
5263
|
app.use("*", logger());
|
|
3931
5264
|
app.use(
|
|
3932
5265
|
"*",
|
|
@@ -3941,6 +5274,7 @@ function createWorker(options) {
|
|
|
3941
5274
|
context.set(PLUGIN_HOOKS_VARIABLE, hooks);
|
|
3942
5275
|
await next();
|
|
3943
5276
|
});
|
|
5277
|
+
app.use("/v1/*", rejectReservedLockPaths);
|
|
3944
5278
|
for (const handler of middleware) app.use("*", handler);
|
|
3945
5279
|
app.get("/health", (c) => c.json({ status: "ok" }));
|
|
3946
5280
|
app.post("/v1/metrics/playback", (c) => c.body(null, 204));
|
|
@@ -3951,6 +5285,7 @@ function createWorker(options) {
|
|
|
3951
5285
|
app.route("/v1/transcode", transcode);
|
|
3952
5286
|
app.route("/v1/captions", captions);
|
|
3953
5287
|
app.route("/v1/token", token);
|
|
5288
|
+
app.route("/v1/lock", lockRoute);
|
|
3954
5289
|
for (const route of routes) app.route(route.path, route.app);
|
|
3955
5290
|
app.notFound((c) => c.json({ error: "Not found" }, 404));
|
|
3956
5291
|
app.onError((err, c) => {
|
|
@@ -3961,10 +5296,20 @@ function createWorker(options) {
|
|
|
3961
5296
|
}
|
|
3962
5297
|
export {
|
|
3963
5298
|
DownloadLink,
|
|
5299
|
+
ENCRYPTION_KEY_MISSING_CODE,
|
|
5300
|
+
HOLD_PAGE_SIZE,
|
|
5301
|
+
KEYS_BUCKET_MISSING_CODE,
|
|
5302
|
+
LOCK_AUDIT_PREFIX,
|
|
5303
|
+
LOCK_HOLD_HEADER,
|
|
5304
|
+
LOCK_METADATA_KEY,
|
|
5305
|
+
LOCK_SIDECAR_PREFIX,
|
|
5306
|
+
LOCK_UNTIL_HEADER,
|
|
3964
5307
|
MAX_ASSET_LIST_LIMIT,
|
|
5308
|
+
MAX_EXPIRED_LOOKUPS,
|
|
3965
5309
|
MAX_UPLOAD_TOKEN_TTL_SECONDS,
|
|
3966
5310
|
MaxResolution,
|
|
3967
5311
|
ObjectMeta,
|
|
5312
|
+
RESERVED_LOCK_PREFIXES,
|
|
3968
5313
|
TOKEN_REJECTED_CODE,
|
|
3969
5314
|
UPLOAD_TOKEN_TYPE,
|
|
3970
5315
|
WorkerAuthErrorCode,
|
|
@@ -3973,6 +5318,7 @@ export {
|
|
|
3973
5318
|
credentialPolicy,
|
|
3974
5319
|
defaultPlugins,
|
|
3975
5320
|
deletePrefix,
|
|
5321
|
+
deletePrefixRespectingLocks,
|
|
3976
5322
|
downloadLinks,
|
|
3977
5323
|
edgeCacheTag,
|
|
3978
5324
|
getPendingUpload,
|
|
@@ -3991,10 +5337,12 @@ export {
|
|
|
3991
5337
|
parseEncodeMetrics,
|
|
3992
5338
|
parseTransformParams,
|
|
3993
5339
|
readImageOriginScope,
|
|
5340
|
+
readObjectLock,
|
|
3994
5341
|
readStoryboardKey,
|
|
3995
5342
|
readTokenScopes,
|
|
3996
5343
|
readTranscodeOutputKeys,
|
|
3997
5344
|
readTranscodeOutputs,
|
|
5345
|
+
retainedSummary,
|
|
3998
5346
|
servedCacheControl,
|
|
3999
5347
|
staticConfig,
|
|
4000
5348
|
storageKey,
|