@devultur/self-host 1.0.3 → 1.1.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 +124 -7
- package/dist/index.js +1270 -258
- 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 { decodeEncryptionMeta, effectiveUntil, 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,575 @@ 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/sidecar.ts
|
|
156
|
+
import { isIsoInstant, validateLock as validateLock2 } from "@devultur/core";
|
|
157
|
+
|
|
158
|
+
// src/object-lock/state.ts
|
|
159
|
+
import { isLocked, validateLock } from "@devultur/core";
|
|
160
|
+
var LOCK_METADATA_KEY = "lock";
|
|
161
|
+
function encodeLockMetadata(lock) {
|
|
162
|
+
if (!lock || !lock.retention && !lock.hold) return void 0;
|
|
163
|
+
return JSON.stringify(lock);
|
|
164
|
+
}
|
|
165
|
+
function readLockMetadata(raw) {
|
|
166
|
+
if (raw === void 0) return void 0;
|
|
167
|
+
try {
|
|
168
|
+
const validation = validateLock(JSON.parse(raw));
|
|
169
|
+
if (validation.ok) return { lock: validation.lock, isUnreadable: false };
|
|
170
|
+
} catch {
|
|
171
|
+
}
|
|
172
|
+
return { lock: { hold: true }, isUnreadable: true };
|
|
173
|
+
}
|
|
174
|
+
function lockStateOf(object) {
|
|
175
|
+
const stored = readLockMetadata(object.customMetadata?.[LOCK_METADATA_KEY]);
|
|
176
|
+
if (!stored) return null;
|
|
177
|
+
return {
|
|
178
|
+
lock: stored.lock,
|
|
179
|
+
...object.uploaded ? { uploadedAt: object.uploaded } : {},
|
|
180
|
+
...stored.isUnreadable ? { isUnreadable: true } : {}
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
function isStateLocked(state, now) {
|
|
184
|
+
try {
|
|
185
|
+
return isLocked(state.lock, now, { uploadedAt: state.uploadedAt, holdReleasedAt: state.holdReleasedAt });
|
|
186
|
+
} catch {
|
|
187
|
+
return true;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// src/object-lock/sidecar.ts
|
|
192
|
+
var LOCK_SIDECAR_PREFIX = "__lock/";
|
|
193
|
+
function objectVersionOf(object) {
|
|
194
|
+
return object.version ?? `${object.etag}@${object.uploaded?.toISOString() ?? ""}`;
|
|
195
|
+
}
|
|
196
|
+
var sidecarKey = (root, key) => `${root}${LOCK_SIDECAR_PREFIX}${key}`;
|
|
197
|
+
function parseSidecar(raw) {
|
|
198
|
+
try {
|
|
199
|
+
const parsed = JSON.parse(raw);
|
|
200
|
+
const lock = validateLock2(parsed.lock);
|
|
201
|
+
if (!lock.ok || typeof parsed.revision !== "number" || typeof parsed.objectVersion !== "string") return null;
|
|
202
|
+
if (parsed.holdReleasedAt !== void 0 && !isIsoInstant(parsed.holdReleasedAt)) return null;
|
|
203
|
+
return { ...parsed, lock: lock.lock };
|
|
204
|
+
} catch {
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
async function readSidecar(bucket, root, key) {
|
|
209
|
+
const object = await bucket.get(sidecarKey(root, key));
|
|
210
|
+
if (!object) return null;
|
|
211
|
+
const sidecar = parseSidecar(await object.text());
|
|
212
|
+
if (sidecar) return { sidecar, etag: object.etag };
|
|
213
|
+
const unreadable = { revision: 0, objectVersion: "*", lock: { hold: true }, updatedAt: "" };
|
|
214
|
+
return { sidecar: unreadable, etag: object.etag, isUnreadable: true };
|
|
215
|
+
}
|
|
216
|
+
async function writeSidecar(bucket, root, key, sidecar, previous) {
|
|
217
|
+
const onlyIf = previous ? { etagMatches: previous.etag } : new Headers({ "If-None-Match": "*" });
|
|
218
|
+
const stored = await bucket.put(sidecarKey(root, key), JSON.stringify(sidecar), {
|
|
219
|
+
onlyIf,
|
|
220
|
+
httpMetadata: { contentType: "application/json" }
|
|
221
|
+
});
|
|
222
|
+
return stored !== null;
|
|
223
|
+
}
|
|
224
|
+
async function effectiveLock(bucket, root, key, object) {
|
|
225
|
+
const stored = await readSidecar(bucket, root, key);
|
|
226
|
+
const bound = stored?.sidecar.objectVersion;
|
|
227
|
+
const applies = stored && (bound === "*" || bound === objectVersionOf(object));
|
|
228
|
+
if (stored && applies) {
|
|
229
|
+
const { lock, holdReleasedAt } = stored.sidecar;
|
|
230
|
+
const state2 = {
|
|
231
|
+
lock,
|
|
232
|
+
...object.uploaded ? { uploadedAt: object.uploaded } : {},
|
|
233
|
+
...holdReleasedAt ? { holdReleasedAt: new Date(holdReleasedAt) } : {},
|
|
234
|
+
...stored.isUnreadable ? { isUnreadable: true } : {}
|
|
235
|
+
};
|
|
236
|
+
return { state: state2, source: "sidecar", stored };
|
|
237
|
+
}
|
|
238
|
+
const state = lockStateOf(object);
|
|
239
|
+
return { state, source: state ? "upload" : "none", stored };
|
|
240
|
+
}
|
|
241
|
+
function unreadableLockRefusal(state, key) {
|
|
242
|
+
if (!state?.isUnreadable) return null;
|
|
243
|
+
return Response.json(
|
|
244
|
+
{
|
|
245
|
+
error: `The lock record of ${key} can't be read; it stays held until an operator repairs it`,
|
|
246
|
+
code: "lock_record_unreadable",
|
|
247
|
+
key
|
|
248
|
+
},
|
|
249
|
+
{ status: 409 }
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
var VIDEO_STORAGE_KEY = /^(.*?)((?:public|private)\/video\/[^/]+\/.+)$/;
|
|
253
|
+
function splitVideoStorageKey(storageKey2) {
|
|
254
|
+
const match = VIDEO_STORAGE_KEY.exec(storageKey2);
|
|
255
|
+
return match ? { root: match[1] ?? "", key: match[2] ?? "" } : null;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// src/object-lock/guard.ts
|
|
259
|
+
var RESERVED_LOCK_PREFIXES = ["__lock/", "__lock-audit/"];
|
|
260
|
+
function isReservedLockKey(key) {
|
|
261
|
+
const normalized = `${key.replace(/^\/+/, "")}/`;
|
|
262
|
+
return RESERVED_LOCK_PREFIXES.some((prefix) => normalized.startsWith(prefix));
|
|
263
|
+
}
|
|
264
|
+
function reservedKeyResponse(context, key) {
|
|
265
|
+
return context.json({ error: `${key} is under a reserved prefix`, code: "reserved_key", key }, 400);
|
|
266
|
+
}
|
|
267
|
+
function routeKeyOf(path) {
|
|
268
|
+
const key = path.split("/").slice(3).join("/");
|
|
269
|
+
try {
|
|
270
|
+
return decodeURIComponent(key);
|
|
271
|
+
} catch {
|
|
272
|
+
return key;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
var rejectReservedLockPaths = async (context, next) => {
|
|
276
|
+
const key = routeKeyOf(context.req.path);
|
|
277
|
+
if (isReservedLockKey(key)) return reservedKeyResponse(context, key);
|
|
278
|
+
await next();
|
|
279
|
+
};
|
|
280
|
+
var VIDEO_KEY = /^((?:public|private)\/video\/[^/]+)(?:\/|$)/;
|
|
281
|
+
function lockSubjectOf(key) {
|
|
282
|
+
if (isVariantKey(key)) return null;
|
|
283
|
+
if (isVideoRawKey(key)) return { kind: "original", key };
|
|
284
|
+
const video = VIDEO_KEY.exec(key);
|
|
285
|
+
if (video) return { kind: "video", rawPrefix: `${video[1]}/raw/` };
|
|
286
|
+
return { kind: "original", key };
|
|
287
|
+
}
|
|
288
|
+
function protectsDerived(state) {
|
|
289
|
+
const derivatives = state.lock.retention?.derivatives ?? "inherit";
|
|
290
|
+
return derivatives === "inherit";
|
|
291
|
+
}
|
|
292
|
+
async function lockedOriginal(context, root, key, now) {
|
|
293
|
+
const object = await context.env.MEDIA_BUCKET.head(`${root}${key}`);
|
|
294
|
+
const state = object ? (await effectiveLock(context.env.MEDIA_BUCKET, root, key, object)).state : null;
|
|
295
|
+
return state && isStateLocked(state, now) ? { key, state } : null;
|
|
296
|
+
}
|
|
297
|
+
async function lockedRaw(context, root, rawPrefix, now) {
|
|
298
|
+
let cursor;
|
|
299
|
+
do {
|
|
300
|
+
const listing = await context.env.MEDIA_BUCKET.list({ prefix: `${root}${rawPrefix}`, cursor });
|
|
301
|
+
for (const listed of listing.objects) {
|
|
302
|
+
const key = listed.key.slice(root.length);
|
|
303
|
+
const object = await context.env.MEDIA_BUCKET.head(listed.key);
|
|
304
|
+
const state = object ? (await effectiveLock(context.env.MEDIA_BUCKET, root, key, object)).state : null;
|
|
305
|
+
if (state && protectsDerived(state) && isStateLocked(state, now)) return { key, state };
|
|
306
|
+
}
|
|
307
|
+
cursor = listing.truncated ? listing.cursor : void 0;
|
|
308
|
+
} while (cursor);
|
|
309
|
+
return null;
|
|
310
|
+
}
|
|
311
|
+
function retentionActive(context, locked, op) {
|
|
312
|
+
const { lock, uploadedAt, holdReleasedAt } = locked.state;
|
|
313
|
+
let until;
|
|
314
|
+
try {
|
|
315
|
+
until = effectiveUntil(lock, { uploadedAt, holdReleasedAt });
|
|
316
|
+
} catch {
|
|
317
|
+
}
|
|
318
|
+
const verb = op === "delete" ? "deleted" : "overwritten";
|
|
319
|
+
return context.json(
|
|
320
|
+
{
|
|
321
|
+
error: `${locked.key} is retained and can't be ${verb}`,
|
|
322
|
+
code: "retention_active",
|
|
323
|
+
key: locked.key,
|
|
324
|
+
...lock.retention ? { mode: lock.retention.mode } : {},
|
|
325
|
+
...until ? { until } : {},
|
|
326
|
+
hold: lock.hold === true
|
|
327
|
+
},
|
|
328
|
+
409
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
async function assertWritable(context, keys, op, root = getRequestState(context).storagePrefix) {
|
|
332
|
+
const reserved = keys.find(isReservedLockKey);
|
|
333
|
+
if (reserved !== void 0) return reservedKeyResponse(context, reserved);
|
|
334
|
+
const now = /* @__PURE__ */ new Date();
|
|
335
|
+
const checked = /* @__PURE__ */ new Set();
|
|
336
|
+
for (const key of keys) {
|
|
337
|
+
const subject = lockSubjectOf(key);
|
|
338
|
+
if (!subject) continue;
|
|
339
|
+
const subjectId = subject.kind === "original" ? subject.key : subject.rawPrefix;
|
|
340
|
+
if (checked.has(subjectId)) continue;
|
|
341
|
+
checked.add(subjectId);
|
|
342
|
+
const locked = subject.kind === "original" ? await lockedOriginal(context, root, subject.key, now) : await lockedRaw(context, root, subject.rawPrefix, now);
|
|
343
|
+
if (!locked) continue;
|
|
344
|
+
const requested = key === locked.key ? [key] : [key, locked.key];
|
|
345
|
+
await recordLockAudit(context, {
|
|
346
|
+
action: op === "delete" ? "delete_rejected" : "overwrite_rejected",
|
|
347
|
+
keys: requested,
|
|
348
|
+
before: locked.state.lock,
|
|
349
|
+
after: null,
|
|
350
|
+
outcome: "rejected",
|
|
351
|
+
reason: "retention_active",
|
|
352
|
+
root
|
|
353
|
+
});
|
|
354
|
+
return retentionActive(context, locked, op);
|
|
355
|
+
}
|
|
356
|
+
return null;
|
|
357
|
+
}
|
|
358
|
+
var DELETE_BATCH_SIZE = 1e3;
|
|
359
|
+
async function guardedDeletePrefix(context, prefix) {
|
|
360
|
+
const root = getRequestState(context).storagePrefix;
|
|
361
|
+
const storageKeys = [];
|
|
362
|
+
let cursor;
|
|
363
|
+
do {
|
|
364
|
+
const listing = await context.env.MEDIA_BUCKET.list({ prefix: storageKey(context, prefix), cursor });
|
|
365
|
+
storageKeys.push(...listing.objects.map((object) => object.key));
|
|
366
|
+
cursor = listing.truncated ? listing.cursor : void 0;
|
|
367
|
+
} while (cursor);
|
|
368
|
+
const keys = storageKeys.map((key) => key.slice(root.length));
|
|
369
|
+
const blocked = await assertWritable(context, keys, "delete");
|
|
370
|
+
if (blocked) return { blocked };
|
|
371
|
+
for (let start = 0; start < storageKeys.length; start += DELETE_BATCH_SIZE) {
|
|
372
|
+
await context.env.MEDIA_BUCKET.delete(storageKeys.slice(start, start + DELETE_BATCH_SIZE));
|
|
373
|
+
}
|
|
374
|
+
return { deletedCount: storageKeys.length };
|
|
375
|
+
}
|
|
376
|
+
async function ownKeysOf(keysBucket, fullKey) {
|
|
377
|
+
const keys = [];
|
|
378
|
+
let cursor;
|
|
379
|
+
do {
|
|
380
|
+
const listing = await keysBucket.list({ prefix: `${fullKey}/`, delimiter: "/", cursor });
|
|
381
|
+
keys.push(...listing.objects);
|
|
382
|
+
cursor = listing.truncated ? listing.cursor : void 0;
|
|
383
|
+
} while (cursor);
|
|
384
|
+
return keys;
|
|
385
|
+
}
|
|
386
|
+
function isKeyOfDeletedVersion(key, deleted) {
|
|
387
|
+
return key.uploaded.getTime() <= deleted.uploaded.getTime();
|
|
388
|
+
}
|
|
389
|
+
async function deleteKeysFor(context, objectKey, deleted) {
|
|
390
|
+
const blocked = await assertWritable(context, [objectKey], "delete");
|
|
391
|
+
if (blocked) return { blocked };
|
|
392
|
+
const keysBucket = context.env.KEYS_BUCKET;
|
|
393
|
+
if (!keysBucket) return { deletedCount: 0 };
|
|
394
|
+
const fullKey = storageKey(context, objectKey);
|
|
395
|
+
const ownKeys = new Set(
|
|
396
|
+
(await ownKeysOf(keysBucket, fullKey)).filter((key) => isKeyOfDeletedVersion(key, deleted)).map((key) => key.key)
|
|
397
|
+
);
|
|
398
|
+
const meta = decodeEncryptionMeta(deleted.customMetadata?.enc);
|
|
399
|
+
if (meta) ownKeys.add(`${fullKey}/${meta.keyId}`);
|
|
400
|
+
if (ownKeys.size > 0) await keysBucket.delete([...ownKeys]);
|
|
401
|
+
const variantKeyCount = await deletePrefix(keysBucket, variantPrefix(fullKey));
|
|
402
|
+
return { deletedCount: variantKeyCount + ownKeys.size };
|
|
403
|
+
}
|
|
404
|
+
async function deleteFolderKeys(context, videoFolder) {
|
|
405
|
+
const blocked = await assertWritable(context, [videoFolder], "delete");
|
|
406
|
+
if (blocked) return { blocked };
|
|
407
|
+
if (!context.env.KEYS_BUCKET) return { deletedCount: 0 };
|
|
408
|
+
return { deletedCount: await deletePrefix(context.env.KEYS_BUCKET, `${storageKey(context, videoFolder)}/`) };
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// src/object-lock/lock-route.ts
|
|
412
|
+
import { canChange as canChange2, effectiveUntil as effectiveUntil2 } from "@devultur/core";
|
|
413
|
+
import { Hono } from "hono";
|
|
414
|
+
|
|
415
|
+
// src/object-lock/change.ts
|
|
416
|
+
import { canChange, validateLock as validateLock3 } from "@devultur/core";
|
|
417
|
+
var BODY_FIELDS = /* @__PURE__ */ new Set(["mode", "until", "period", "derivatives", "remove", "bypassGovernance"]);
|
|
418
|
+
function nextLock(before, body) {
|
|
419
|
+
const unknown = Object.keys(body).filter((field) => !BODY_FIELDS.has(field));
|
|
420
|
+
if (unknown.length > 0) return { ok: false, errors: unknown.map((field) => `${field}: unknown field`) };
|
|
421
|
+
if (body.remove !== void 0 && body.remove !== true) return { ok: false, errors: ["remove: must be true"] };
|
|
422
|
+
const hold = before?.hold === true;
|
|
423
|
+
if (body.remove === true) return { ok: true, lock: { hold } };
|
|
424
|
+
const current = before?.retention;
|
|
425
|
+
const isExpiryChanged = body.until !== void 0 || body.period !== void 0;
|
|
426
|
+
const retention = {
|
|
427
|
+
mode: body.mode ?? current?.mode,
|
|
428
|
+
...isExpiryChanged ? {
|
|
429
|
+
...body.until !== void 0 ? { until: body.until } : {},
|
|
430
|
+
...body.period !== void 0 ? { period: body.period } : {}
|
|
431
|
+
} : {
|
|
432
|
+
...current?.until ? { until: current.until } : {},
|
|
433
|
+
...current?.period ? { period: current.period } : {}
|
|
434
|
+
},
|
|
435
|
+
derivatives: body.derivatives ?? current?.derivatives ?? "inherit"
|
|
436
|
+
};
|
|
437
|
+
const validation = validateLock3({ retention, hold });
|
|
438
|
+
if (!validation.ok) return validation;
|
|
439
|
+
return { ok: true, lock: validation.lock };
|
|
440
|
+
}
|
|
441
|
+
var timingOf = (state) => ({
|
|
442
|
+
uploadedAt: state?.uploadedAt,
|
|
443
|
+
holdReleasedAt: state?.holdReleasedAt
|
|
444
|
+
});
|
|
445
|
+
function classifyChange(state, after, now) {
|
|
446
|
+
const before = state?.lock ?? {};
|
|
447
|
+
const isActive = canChange(before, { hold: before.hold }, { privileged: false, now, timing: timingOf(state) });
|
|
448
|
+
if (isActive.ok) return after.retention ? "set" : "remove";
|
|
449
|
+
if (!after.retention) return "remove";
|
|
450
|
+
const change = canChange(before, after, { privileged: false, now, timing: timingOf(state) });
|
|
451
|
+
return change.ok ? "extend" : "shorten";
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// src/object-lock/hold.ts
|
|
455
|
+
import { validateLock as validateLock4 } from "@devultur/core";
|
|
456
|
+
var HOLD_PAGE_SIZE = 50;
|
|
457
|
+
var isKeyList = (value) => Array.isArray(value) && value.every((key) => typeof key === "string" && key.length > 0);
|
|
458
|
+
async function targetsOf(context, body) {
|
|
459
|
+
if (body.keys === void 0 === (body.prefix === void 0)) return { error: "pass either keys or prefix" };
|
|
460
|
+
if (body.keys !== void 0) {
|
|
461
|
+
if (!isKeyList(body.keys) || body.keys.length === 0) return { error: "keys must be a non-empty list of keys" };
|
|
462
|
+
if (body.keys.length > HOLD_PAGE_SIZE) return { error: `keys may list at most ${HOLD_PAGE_SIZE} keys` };
|
|
463
|
+
if (body.keys.some(isReservedLockKey)) return { error: "keys may not be under a reserved prefix" };
|
|
464
|
+
return { keys: [...new Set(body.keys)] };
|
|
465
|
+
}
|
|
466
|
+
if (typeof body.prefix !== "string" || body.prefix === "" || isReservedLockKey(body.prefix)) {
|
|
467
|
+
return { error: "prefix must be a non-empty key prefix outside the reserved ones" };
|
|
468
|
+
}
|
|
469
|
+
const cursor = typeof body.cursor === "string" ? body.cursor : void 0;
|
|
470
|
+
const listing = await context.env.MEDIA_BUCKET.list({
|
|
471
|
+
prefix: storageKey(context, body.prefix),
|
|
472
|
+
limit: HOLD_PAGE_SIZE,
|
|
473
|
+
...cursor ? { cursor } : {}
|
|
474
|
+
});
|
|
475
|
+
const root = getRequestState(context).storagePrefix;
|
|
476
|
+
const keys = listing.objects.map((object) => object.key.slice(root.length)).filter((key) => !isVariantKey(key) && !isReservedLockKey(key));
|
|
477
|
+
return { keys, prefix: body.prefix, ...listing.truncated ? { cursor: listing.cursor } : {} };
|
|
478
|
+
}
|
|
479
|
+
async function applyHold(context, key, hold, now) {
|
|
480
|
+
const root = getRequestState(context).storagePrefix;
|
|
481
|
+
const object = await context.env.MEDIA_BUCKET.head(storageKey(context, key));
|
|
482
|
+
if (!object) return { updated: false, reason: "not_found" };
|
|
483
|
+
const effective = await effectiveLock(context.env.MEDIA_BUCKET, root, key, object);
|
|
484
|
+
const before = effective.state?.lock ?? { hold: false };
|
|
485
|
+
if (before.hold === true === hold) return { updated: false, reason: "unchanged" };
|
|
486
|
+
if (effective.state?.isUnreadable) {
|
|
487
|
+
await recordLockAudit(context, {
|
|
488
|
+
action: "release",
|
|
489
|
+
keys: [key],
|
|
490
|
+
before,
|
|
491
|
+
after: { ...before, hold },
|
|
492
|
+
outcome: "rejected",
|
|
493
|
+
reason: "lock_record_unreadable"
|
|
494
|
+
});
|
|
495
|
+
return { updated: false, reason: "lock_record_unreadable" };
|
|
496
|
+
}
|
|
497
|
+
const validated = validateLock4({ ...before, hold });
|
|
498
|
+
const after = validated.ok ? validated.lock : { ...before, hold };
|
|
499
|
+
const previous = effective.source === "sidecar" ? effective.stored?.sidecar : void 0;
|
|
500
|
+
const holdReleasedAt = hold ? previous?.holdReleasedAt : now.toISOString();
|
|
501
|
+
const sidecar = {
|
|
502
|
+
revision: (effective.stored?.sidecar.revision ?? 0) + 1,
|
|
503
|
+
objectVersion: objectVersionOf(object),
|
|
504
|
+
lock: after,
|
|
505
|
+
...holdReleasedAt ? { holdReleasedAt } : {},
|
|
506
|
+
updatedAt: now.toISOString()
|
|
507
|
+
};
|
|
508
|
+
const action = hold ? "hold" : "release";
|
|
509
|
+
if (!await writeSidecar(context.env.MEDIA_BUCKET, root, key, sidecar, effective.stored)) {
|
|
510
|
+
await recordLockAudit(context, {
|
|
511
|
+
action,
|
|
512
|
+
keys: [key],
|
|
513
|
+
before,
|
|
514
|
+
after,
|
|
515
|
+
outcome: "rejected",
|
|
516
|
+
reason: "lock_conflict"
|
|
517
|
+
});
|
|
518
|
+
return { updated: false, reason: "lock_conflict" };
|
|
519
|
+
}
|
|
520
|
+
await recordLockAudit(context, { action, keys: [key], before, after, outcome: "applied" });
|
|
521
|
+
return { updated: true };
|
|
522
|
+
}
|
|
523
|
+
async function handleHold(context) {
|
|
524
|
+
const body = await context.req.json().catch(() => null) ?? {};
|
|
525
|
+
if (typeof body.hold !== "boolean")
|
|
526
|
+
return context.json({ error: "hold must be a boolean", code: "invalid_hold" }, 400);
|
|
527
|
+
const targets = await targetsOf(context, body);
|
|
528
|
+
if ("error" in targets) return context.json({ error: targets.error, code: "invalid_hold" }, 400);
|
|
529
|
+
const hold = body.hold;
|
|
530
|
+
const decision = await decideLockChange(context, {
|
|
531
|
+
action: hold ? "hold" : "release",
|
|
532
|
+
keys: targets.keys,
|
|
533
|
+
...targets.prefix ? { prefix: targets.prefix } : {},
|
|
534
|
+
before: null,
|
|
535
|
+
after: { hold }
|
|
536
|
+
});
|
|
537
|
+
if (!decision.allow) {
|
|
538
|
+
await recordLockAudit(context, {
|
|
539
|
+
action: hold ? "hold" : "release",
|
|
540
|
+
keys: targets.keys,
|
|
541
|
+
before: null,
|
|
542
|
+
after: { hold },
|
|
543
|
+
outcome: "rejected",
|
|
544
|
+
reason: "hook_refused"
|
|
545
|
+
});
|
|
546
|
+
return decision.response;
|
|
547
|
+
}
|
|
548
|
+
const now = /* @__PURE__ */ new Date();
|
|
549
|
+
const updated = [];
|
|
550
|
+
const skipped = [];
|
|
551
|
+
for (const key of targets.keys) {
|
|
552
|
+
const outcome = await applyHold(context, key, hold, now);
|
|
553
|
+
if (outcome.updated) updated.push(key);
|
|
554
|
+
else skipped.push({ key, reason: outcome.reason });
|
|
555
|
+
}
|
|
556
|
+
return context.json({ updated, skipped, ...targets.cursor ? { cursor: targets.cursor } : {} });
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// src/object-lock/lock-route.ts
|
|
560
|
+
function lockView(key, effective) {
|
|
561
|
+
const lock = effective.state?.lock;
|
|
562
|
+
let until;
|
|
563
|
+
try {
|
|
564
|
+
until = lock ? effectiveUntil2(lock, timingOf(effective.state)) : void 0;
|
|
565
|
+
} catch {
|
|
566
|
+
}
|
|
567
|
+
return {
|
|
568
|
+
key,
|
|
569
|
+
mode: lock?.retention?.mode ?? null,
|
|
570
|
+
until: until ?? null,
|
|
571
|
+
period: lock?.retention?.period ?? null,
|
|
572
|
+
hold: lock?.hold === true,
|
|
573
|
+
derivatives: lock?.retention ? lock.retention.derivatives ?? "inherit" : null,
|
|
574
|
+
source: effective.source,
|
|
575
|
+
revision: effective.source === "sidecar" ? effective.stored?.sidecar.revision ?? null : null
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
async function loadTarget(context) {
|
|
579
|
+
const key = context.req.param("key") ?? "";
|
|
580
|
+
const root = getRequestState(context).storagePrefix;
|
|
581
|
+
const object = await context.env.MEDIA_BUCKET.head(storageKey(context, key));
|
|
582
|
+
if (!object) return { key, root, object: null, effective: null };
|
|
583
|
+
return { key, root, object, effective: await effectiveLock(context.env.MEDIA_BUCKET, root, key, object) };
|
|
584
|
+
}
|
|
585
|
+
var lockRoute = new Hono();
|
|
586
|
+
lockRoute.use("*", async (context, next) => {
|
|
587
|
+
if (!isAuthenticated(context)) return context.json({ error: "Unauthorized" }, 401);
|
|
588
|
+
await next();
|
|
589
|
+
});
|
|
590
|
+
lockRoute.post("/hold", handleHold);
|
|
591
|
+
lockRoute.get("/:key{.+}", async (context) => {
|
|
592
|
+
const { key, effective } = await loadTarget(context);
|
|
593
|
+
if (!effective) return context.json({ error: "Not found" }, 404);
|
|
594
|
+
return context.json(lockView(key, effective));
|
|
595
|
+
});
|
|
596
|
+
lockRoute.put("/:key{.+}", async (context) => {
|
|
597
|
+
const body = await context.req.json().catch(() => null) ?? {};
|
|
598
|
+
const { key, root, object, effective } = await loadTarget(context);
|
|
599
|
+
if (!object || !effective) return context.json({ error: "Not found" }, 404);
|
|
600
|
+
const before = effective.state?.lock ?? null;
|
|
601
|
+
const next = nextLock(before, body);
|
|
602
|
+
if (!next.ok) return context.json({ error: next.errors.join("; "), code: "invalid_lock" }, 400);
|
|
603
|
+
const now = /* @__PURE__ */ new Date();
|
|
604
|
+
const action = classifyChange(effective.state, next.lock, now);
|
|
605
|
+
const audit = { action, keys: [key], before, after: next.lock, root };
|
|
606
|
+
const unreadable = unreadableLockRefusal(effective.state, key);
|
|
607
|
+
if (unreadable) {
|
|
608
|
+
await recordLockAudit(context, { ...audit, outcome: "rejected", reason: "lock_record_unreadable" });
|
|
609
|
+
return unreadable;
|
|
610
|
+
}
|
|
611
|
+
const decision = await decideLockChange(context, { action, keys: [key], before, after: next.lock });
|
|
612
|
+
if (!decision.allow) {
|
|
613
|
+
await recordLockAudit(context, { ...audit, outcome: "rejected", reason: "hook_refused" });
|
|
614
|
+
return decision.response;
|
|
615
|
+
}
|
|
616
|
+
const isPrivileged = body.bypassGovernance === true && decision.isApproved;
|
|
617
|
+
const change = canChange2(before ?? {}, next.lock, {
|
|
618
|
+
privileged: isPrivileged,
|
|
619
|
+
now,
|
|
620
|
+
timing: timingOf(effective.state)
|
|
621
|
+
});
|
|
622
|
+
if (!change.ok) {
|
|
623
|
+
await recordLockAudit(context, { ...audit, outcome: "rejected", reason: change.reason });
|
|
624
|
+
return refusal(context, key, change.reason);
|
|
625
|
+
}
|
|
626
|
+
const sidecar = nextSidecar(effective, objectVersionOf(object), next.lock, now);
|
|
627
|
+
if (!await writeSidecar(context.env.MEDIA_BUCKET, root, key, sidecar, effective.stored)) {
|
|
628
|
+
await recordLockAudit(context, { ...audit, outcome: "rejected", reason: "lock_conflict" });
|
|
629
|
+
return context.json({ error: `The lock of ${key} changed meanwhile; retry`, code: "lock_conflict", key }, 409);
|
|
630
|
+
}
|
|
631
|
+
await recordLockAudit(context, { ...audit, outcome: "applied", ...change.bypass ? { bypass: true } : {} });
|
|
632
|
+
const state = { ...effective.state ?? {}, lock: next.lock };
|
|
633
|
+
return context.json(lockView(key, { state, source: "sidecar", stored: { sidecar, etag: "" } }));
|
|
634
|
+
});
|
|
635
|
+
function nextSidecar(effective, objectVersion, lock, now) {
|
|
636
|
+
const previous = effective.source === "sidecar" ? effective.stored?.sidecar : void 0;
|
|
637
|
+
return {
|
|
638
|
+
revision: (effective.stored?.sidecar.revision ?? 0) + 1,
|
|
639
|
+
objectVersion,
|
|
640
|
+
lock,
|
|
641
|
+
...previous?.holdReleasedAt ? { holdReleasedAt: previous.holdReleasedAt } : {},
|
|
642
|
+
updatedAt: now.toISOString()
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
function refusal(context, key, reason) {
|
|
646
|
+
if (reason === "lock_privilege_required") {
|
|
647
|
+
return context.json(
|
|
648
|
+
{
|
|
649
|
+
error: "Shortening or removing a governance retention needs bypassGovernance and an authorizeLockChange approval",
|
|
650
|
+
code: reason,
|
|
651
|
+
key
|
|
652
|
+
},
|
|
653
|
+
403
|
|
654
|
+
);
|
|
655
|
+
}
|
|
656
|
+
return context.json({ error: `The compliance retention of ${key} can only be extended`, code: reason, key }, 409);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// src/plugin.ts
|
|
660
|
+
var PHASES = ["beforeAuth", "auth", "afterAuth"];
|
|
661
|
+
function assertComposable(plugins) {
|
|
662
|
+
const names = /* @__PURE__ */ new Set();
|
|
663
|
+
for (const plugin of plugins) {
|
|
664
|
+
if (names.has(plugin.name)) throw new Error(`createWorker: two plugins are named "${plugin.name}"`);
|
|
665
|
+
names.add(plugin.name);
|
|
666
|
+
}
|
|
667
|
+
if (!plugins.some((plugin) => (plugin.middleware?.auth?.length ?? 0) > 0)) {
|
|
668
|
+
throw new Error("createWorker: no plugin authenticates requests. Add workerSecretAuth() or your own auth plugin.");
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
function composePlugins(plugins) {
|
|
672
|
+
assertComposable(plugins);
|
|
673
|
+
return {
|
|
674
|
+
middleware: PHASES.flatMap((phase) => plugins.flatMap((plugin) => plugin.middleware?.[phase] ?? [])),
|
|
675
|
+
routes: plugins.flatMap((plugin) => plugin.routes ?? []),
|
|
676
|
+
hooks: {
|
|
677
|
+
issueCredential: plugins.flatMap((plugin) => plugin.issueCredential ? [plugin.issueCredential] : []),
|
|
678
|
+
beforeLinkRedeem: plugins.flatMap((plugin) => plugin.beforeLinkRedeem ? [plugin.beforeLinkRedeem] : []),
|
|
679
|
+
authorizeLockChange: plugins.flatMap(
|
|
680
|
+
(plugin) => plugin.authorizeLockChange ? [plugin.authorizeLockChange] : []
|
|
681
|
+
),
|
|
682
|
+
onLockAudit: plugins.flatMap((plugin) => plugin.onLockAudit ? [plugin.onLockAudit] : [])
|
|
683
|
+
}
|
|
684
|
+
};
|
|
685
|
+
}
|
|
686
|
+
|
|
130
687
|
// src/plugins/credential-policy.ts
|
|
131
688
|
function credentialPolicy(options = {}) {
|
|
132
689
|
return {
|
|
@@ -140,11 +697,11 @@ function credentialPolicy(options = {}) {
|
|
|
140
697
|
}
|
|
141
698
|
|
|
142
699
|
// src/plugins/download-links.ts
|
|
143
|
-
import { Hono as
|
|
700
|
+
import { Hono as Hono3 } from "hono";
|
|
144
701
|
|
|
145
702
|
// src/routes/download.ts
|
|
146
703
|
import { contentDispositionAttachment as contentDispositionAttachment2, relativeMediaPath, TokenScope } from "@devultur/core";
|
|
147
|
-
import { Hono } from "hono";
|
|
704
|
+
import { Hono as Hono2 } from "hono";
|
|
148
705
|
|
|
149
706
|
// src/download-link.ts
|
|
150
707
|
var isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
@@ -224,7 +781,7 @@ function addRouteResult(context, result) {
|
|
|
224
781
|
import {
|
|
225
782
|
AES_BLOCK_BYTES,
|
|
226
783
|
createDecryptStream,
|
|
227
|
-
decodeEncryptionMeta,
|
|
784
|
+
decodeEncryptionMeta as decodeEncryptionMeta2,
|
|
228
785
|
decryptChunk,
|
|
229
786
|
EXPIRES_AT_HEADER,
|
|
230
787
|
REPR_DIGEST_HEADER,
|
|
@@ -242,7 +799,13 @@ var ObjectMeta = {
|
|
|
242
799
|
/** Epoch ms. From then on the gate responds 410 (and an external sweeper may delete it). */
|
|
243
800
|
EXPIRES_AT: "expiresAt"
|
|
244
801
|
};
|
|
245
|
-
var WORKER_METADATA_KEYS = /* @__PURE__ */ new Set([
|
|
802
|
+
var WORKER_METADATA_KEYS = /* @__PURE__ */ new Set([
|
|
803
|
+
"security",
|
|
804
|
+
"enc",
|
|
805
|
+
ObjectMeta.EXPIRES_AT.toLowerCase(),
|
|
806
|
+
"derivedfrom",
|
|
807
|
+
LOCK_METADATA_KEY
|
|
808
|
+
]);
|
|
246
809
|
function isWorkerMetadataKey(key) {
|
|
247
810
|
const lowerKey = key.toLowerCase();
|
|
248
811
|
return lowerKey.startsWith(ObjectMeta.SHA256) || WORKER_METADATA_KEYS.has(lowerKey);
|
|
@@ -251,8 +814,8 @@ function allowedUploadMetadata(metadata) {
|
|
|
251
814
|
if (!metadata) return {};
|
|
252
815
|
return Object.fromEntries(Object.entries(metadata).filter(([key]) => !isWorkerMetadataKey(key)));
|
|
253
816
|
}
|
|
254
|
-
function targetFailure(context,
|
|
255
|
-
return context.json({ error:
|
|
817
|
+
function targetFailure(context, failure4) {
|
|
818
|
+
return context.json({ error: failure4.error, code: failure4.code }, failure4.status);
|
|
256
819
|
}
|
|
257
820
|
function alreadyExists(context, key) {
|
|
258
821
|
return context.json(
|
|
@@ -278,14 +841,19 @@ async function digestBody(body, declared, header) {
|
|
|
278
841
|
if (expected && expected !== actual) return { mismatch: { expected, actual } };
|
|
279
842
|
return { sha256: actual };
|
|
280
843
|
}
|
|
844
|
+
function lockMetadata(lock) {
|
|
845
|
+
const encoded = encodeLockMetadata(lock);
|
|
846
|
+
return encoded ? { [LOCK_METADATA_KEY]: encoded } : {};
|
|
847
|
+
}
|
|
281
848
|
function integrityMetadata(sha256, isVerified, expiresAt) {
|
|
282
849
|
return {
|
|
283
850
|
...sha256 ? { [ObjectMeta.SHA256]: sha256, [ObjectMeta.SHA256_VERIFIED]: String(isVerified) } : {},
|
|
284
851
|
...expiresAt ? { [ObjectMeta.EXPIRES_AT]: String(expiresAt) } : {}
|
|
285
852
|
};
|
|
286
853
|
}
|
|
287
|
-
function
|
|
288
|
-
|
|
854
|
+
function expiryMetadataOf(customMetadata) {
|
|
855
|
+
const expiresAt = customMetadata?.[ObjectMeta.EXPIRES_AT];
|
|
856
|
+
return expiresAt ? { [ObjectMeta.EXPIRES_AT]: expiresAt } : {};
|
|
289
857
|
}
|
|
290
858
|
async function discardStaleVariants(bucket, pending) {
|
|
291
859
|
if (!pending?.overwrite) return;
|
|
@@ -293,9 +861,13 @@ async function discardStaleVariants(bucket, pending) {
|
|
|
293
861
|
}
|
|
294
862
|
|
|
295
863
|
// src/routes/object-response.ts
|
|
296
|
-
function
|
|
864
|
+
function objectExpiresAt(customMetadata) {
|
|
297
865
|
const expiresAt = Number(customMetadata?.[ObjectMeta.EXPIRES_AT]);
|
|
298
|
-
return Number.isFinite(expiresAt) && expiresAt > 0
|
|
866
|
+
return Number.isFinite(expiresAt) && expiresAt > 0 ? expiresAt : void 0;
|
|
867
|
+
}
|
|
868
|
+
function isObjectExpired(customMetadata, now = Date.now()) {
|
|
869
|
+
const expiresAt = objectExpiresAt(customMetadata);
|
|
870
|
+
return expiresAt !== void 0 && expiresAt <= now;
|
|
299
871
|
}
|
|
300
872
|
function expiredResponse() {
|
|
301
873
|
return Response.json({ error: "This file has expired", code: "expired" }, { status: 410 });
|
|
@@ -352,7 +924,7 @@ async function streamObject(input) {
|
|
|
352
924
|
headers.set("ETag", object.httpEtag);
|
|
353
925
|
headers.set("Accept-Ranges", "bytes");
|
|
354
926
|
applyIntegrityHeaders(headers, object.customMetadata);
|
|
355
|
-
const enc =
|
|
927
|
+
const enc = decodeEncryptionMeta2(object.customMetadata?.enc);
|
|
356
928
|
const loaded = enc ? await loadStorageKey(input.keysBucket, input.fullKey, enc.keyId) : null;
|
|
357
929
|
if (enc && !loaded) {
|
|
358
930
|
return { response: Response.json({ error: "Decryption key unavailable" }, { status: 500 }), kind: "other" };
|
|
@@ -410,8 +982,8 @@ async function handleDownloadLink(context) {
|
|
|
410
982
|
const object = await context.env.MEDIA_BUCKET.head(fullKey);
|
|
411
983
|
if (!object) return context.json({ error: "Not found" }, 404);
|
|
412
984
|
if (isObjectExpired(object.customMetadata)) return expiredResponse();
|
|
413
|
-
const
|
|
414
|
-
const requestedExpiresAt = Math.min(Date.now() + parsed.expiresIn * 1e3,
|
|
985
|
+
const objectExpiresAt2 = Number(object.customMetadata?.expiresAt) || Infinity;
|
|
986
|
+
const requestedExpiresAt = Math.min(Date.now() + parsed.expiresIn * 1e3, objectExpiresAt2);
|
|
415
987
|
const decision = await decideCredential(context, {
|
|
416
988
|
kind: "download-link",
|
|
417
989
|
scopes: [TokenScope.MEDIA_READ],
|
|
@@ -456,7 +1028,7 @@ async function gateRedemption(context, stub) {
|
|
|
456
1028
|
if (!peeked.ok) return relayLinkFailure(peeked);
|
|
457
1029
|
return gateLinkRedemption(context, linkInfoOf(await peeked.json()));
|
|
458
1030
|
}
|
|
459
|
-
var download = new
|
|
1031
|
+
var download = new Hono2();
|
|
460
1032
|
download.get("/:linkId", async (context) => {
|
|
461
1033
|
if (context.req.method === "HEAD") return new Response(null, { status: 405, headers: { Allow: "GET" } });
|
|
462
1034
|
const linkId = context.req.param("linkId");
|
|
@@ -490,7 +1062,7 @@ download.get("/:linkId", async (context) => {
|
|
|
490
1062
|
|
|
491
1063
|
// src/plugins/download-links.ts
|
|
492
1064
|
function downloadLinks() {
|
|
493
|
-
const issue = new
|
|
1065
|
+
const issue = new Hono3().post("/", handleDownloadLink);
|
|
494
1066
|
return {
|
|
495
1067
|
name: "download-links",
|
|
496
1068
|
routes: [
|
|
@@ -510,7 +1082,11 @@ function staticConfig(config) {
|
|
|
510
1082
|
}
|
|
511
1083
|
|
|
512
1084
|
// src/routes/token-upload.ts
|
|
513
|
-
import {
|
|
1085
|
+
import {
|
|
1086
|
+
assetRelativePath as assetRelativePath2,
|
|
1087
|
+
normalizeAssetPrefix,
|
|
1088
|
+
validateLock as validateLock5
|
|
1089
|
+
} from "@devultur/core";
|
|
514
1090
|
|
|
515
1091
|
// src/token-claims.ts
|
|
516
1092
|
function audienceClaim(storagePrefix2) {
|
|
@@ -671,18 +1247,26 @@ function parseVisibility(value) {
|
|
|
671
1247
|
if (value === void 0 || value === null) return "private";
|
|
672
1248
|
return value === "public" || value === "private" ? value : null;
|
|
673
1249
|
}
|
|
1250
|
+
function lockClaim(value) {
|
|
1251
|
+
if (value === void 0) return void 0;
|
|
1252
|
+
const validation = validateLock5(value);
|
|
1253
|
+
return validation.ok ? validation.lock : null;
|
|
1254
|
+
}
|
|
674
1255
|
function uploadGrantFromClaims(payload) {
|
|
675
1256
|
if (payload.typ !== UPLOAD_TOKEN_TYPE || typeof payload.res !== "string" || typeof payload.jti !== "string") {
|
|
676
1257
|
return null;
|
|
677
1258
|
}
|
|
678
1259
|
if (payload.visibility !== "public" && payload.visibility !== "private") return null;
|
|
1260
|
+
const lock = lockClaim(payload.lock);
|
|
1261
|
+
if (lock === null) return null;
|
|
679
1262
|
return {
|
|
680
1263
|
tokenId: payload.jti,
|
|
681
1264
|
pathPrefix: payload.res,
|
|
682
1265
|
visibility: payload.visibility,
|
|
683
1266
|
...isPositiveInteger(payload.max_bytes) ? { maxBytes: payload.max_bytes } : {},
|
|
684
1267
|
...isPositiveInteger(payload.max_files) ? { maxFiles: payload.max_files } : {},
|
|
685
|
-
...isPositiveInteger(payload.file_ttl) ? { fileExpiresIn: payload.file_ttl } : {}
|
|
1268
|
+
...isPositiveInteger(payload.file_ttl) ? { fileExpiresIn: payload.file_ttl } : {},
|
|
1269
|
+
...lock ? { lock } : {}
|
|
686
1270
|
};
|
|
687
1271
|
}
|
|
688
1272
|
var badRequest = (context, code, error) => context.json({ error, code }, 400);
|
|
@@ -703,6 +1287,8 @@ async function handleUploadToken(context) {
|
|
|
703
1287
|
if (fileExpiresIn === null) {
|
|
704
1288
|
return badRequest(context, "invalid_file_expires_in", "fileExpiresIn must be a positive number of seconds");
|
|
705
1289
|
}
|
|
1290
|
+
const lock = body.lock === void 0 ? { ok: true, lock: void 0 } : validateLock5(body.lock);
|
|
1291
|
+
if (!lock.ok) return badRequest(context, "invalid_lock", lock.errors.join("; "));
|
|
706
1292
|
const pathPrefix = assetRelativePath2(prefix);
|
|
707
1293
|
const now = Math.floor(Date.now() / 1e3);
|
|
708
1294
|
const requestedExp = now + (expiresIn ?? DEFAULT_UPLOAD_TOKEN_TTL_SECONDS);
|
|
@@ -726,7 +1312,8 @@ async function handleUploadToken(context) {
|
|
|
726
1312
|
exp: expiresAt,
|
|
727
1313
|
...maxBytes ? { max_bytes: maxBytes } : {},
|
|
728
1314
|
...maxFiles ? { max_files: maxFiles } : {},
|
|
729
|
-
...fileExpiresIn ? { file_ttl: fileExpiresIn } : {}
|
|
1315
|
+
...fileExpiresIn ? { file_ttl: fileExpiresIn } : {},
|
|
1316
|
+
...lock.lock ? { lock: lock.lock } : {}
|
|
730
1317
|
},
|
|
731
1318
|
decision
|
|
732
1319
|
);
|
|
@@ -738,7 +1325,8 @@ async function handleUploadToken(context) {
|
|
|
738
1325
|
expiresAt,
|
|
739
1326
|
...maxBytes ? { maxBytes } : {},
|
|
740
1327
|
...maxFiles ? { maxFiles } : {},
|
|
741
|
-
...fileExpiresIn ? { fileExpiresIn } : {}
|
|
1328
|
+
...fileExpiresIn ? { fileExpiresIn } : {},
|
|
1329
|
+
...lock.lock ? { lock: lock.lock } : {}
|
|
742
1330
|
});
|
|
743
1331
|
}
|
|
744
1332
|
|
|
@@ -821,9 +1409,46 @@ function defaultPlugins(config = {}) {
|
|
|
821
1409
|
|
|
822
1410
|
// src/routes/assets.ts
|
|
823
1411
|
import { ASSET_NAMESPACE, normalizeAssetPrefix as normalizeAssetPrefix2 } from "@devultur/core";
|
|
824
|
-
import { Hono as
|
|
1412
|
+
import { Hono as Hono4 } from "hono";
|
|
1413
|
+
|
|
1414
|
+
// src/object-lock/delivery.ts
|
|
1415
|
+
import { effectiveUntil as effectiveUntil3 } from "@devultur/core";
|
|
1416
|
+
var LOCK_UNTIL_HEADER = "x-devultur-lock-until";
|
|
1417
|
+
var LOCK_HOLD_HEADER = "x-devultur-lock-hold";
|
|
1418
|
+
async function lockHeaders(context, key, object) {
|
|
1419
|
+
const root = getRequestState(context).storagePrefix;
|
|
1420
|
+
const { state } = await effectiveLock(context.env.MEDIA_BUCKET, root, key, object);
|
|
1421
|
+
if (!state || !isStateLocked(state, /* @__PURE__ */ new Date())) return [];
|
|
1422
|
+
const headers = [];
|
|
1423
|
+
try {
|
|
1424
|
+
const until = effectiveUntil3(state.lock, timingOf(state));
|
|
1425
|
+
if (until) headers.push([LOCK_UNTIL_HEADER, until]);
|
|
1426
|
+
} catch {
|
|
1427
|
+
}
|
|
1428
|
+
if (state.lock.hold) headers.push([LOCK_HOLD_HEADER, "true"]);
|
|
1429
|
+
return headers;
|
|
1430
|
+
}
|
|
1431
|
+
async function retainedOf(bucket, location, object) {
|
|
1432
|
+
const { state } = await effectiveLock(bucket, location.root, location.key, object);
|
|
1433
|
+
if (!state || !isStateLocked(state, /* @__PURE__ */ new Date())) return null;
|
|
1434
|
+
let until;
|
|
1435
|
+
try {
|
|
1436
|
+
until = effectiveUntil3(state.lock, timingOf(state));
|
|
1437
|
+
} catch {
|
|
1438
|
+
}
|
|
1439
|
+
return { ...until ? { until } : {}, hold: state.lock.hold === true };
|
|
1440
|
+
}
|
|
1441
|
+
async function expiredRefusal(bucket, location, object, isOwnerRead) {
|
|
1442
|
+
if (!isObjectExpired(object.customMetadata)) return null;
|
|
1443
|
+
if (isOwnerRead && await retainedOf(bucket, location, object) !== null) return null;
|
|
1444
|
+
return expiredResponse();
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
// src/routes/assets.ts
|
|
825
1448
|
var MAX_ASSET_LIST_LIMIT = 1e3;
|
|
1449
|
+
var MAX_EXPIRED_LOOKUPS = 32;
|
|
826
1450
|
var VISIBILITY_ORDER = ["private", "public"];
|
|
1451
|
+
var RESUME_AFTER_MARK = "~";
|
|
827
1452
|
function parseListPrefix(raw) {
|
|
828
1453
|
if (!raw?.trim()) return "";
|
|
829
1454
|
return normalizeAssetPrefix2(raw);
|
|
@@ -834,7 +1459,12 @@ function parseListCursor(raw, visibilities) {
|
|
|
834
1459
|
const visibility = raw.slice(0, separator);
|
|
835
1460
|
if (separator < 0 || !visibilities.includes(visibility)) return null;
|
|
836
1461
|
const cursor = raw.slice(separator + 1);
|
|
837
|
-
return { visibility, ...cursor ? { cursor } : {} };
|
|
1462
|
+
if (!cursor.startsWith(RESUME_AFTER_MARK)) return { visibility, ...cursor ? { cursor } : {} };
|
|
1463
|
+
try {
|
|
1464
|
+
return { visibility, startAfter: decodeURIComponent(cursor.slice(RESUME_AFTER_MARK.length)) };
|
|
1465
|
+
} catch {
|
|
1466
|
+
return null;
|
|
1467
|
+
}
|
|
838
1468
|
}
|
|
839
1469
|
function parseLimit(raw) {
|
|
840
1470
|
const limit = Number(raw);
|
|
@@ -861,7 +1491,32 @@ function nextCursor(listing, position, visibilities) {
|
|
|
861
1491
|
const next = visibilities[visibilities.indexOf(position.visibility) + 1];
|
|
862
1492
|
return next ? `${next}:` : null;
|
|
863
1493
|
}
|
|
864
|
-
var
|
|
1494
|
+
var resumeCursor = (visibility, afterKey) => `${visibility}:${RESUME_AFTER_MARK}${encodeURIComponent(afterKey)}`;
|
|
1495
|
+
var expiredEntry = (entry, retained) => ({
|
|
1496
|
+
...entry,
|
|
1497
|
+
expired: true,
|
|
1498
|
+
...retained.until ? { retainedUntil: retained.until } : {},
|
|
1499
|
+
...retained.hold ? { hold: true } : {}
|
|
1500
|
+
});
|
|
1501
|
+
async function listedEntries(bucket, listing, storageRoot, visibility) {
|
|
1502
|
+
const page = { objects: [] };
|
|
1503
|
+
let lookups = 0;
|
|
1504
|
+
let lastKey;
|
|
1505
|
+
for (const object of listing.objects) {
|
|
1506
|
+
const entry = toEntry(object, storageRoot, visibility);
|
|
1507
|
+
if (isObjectExpired(object.customMetadata)) {
|
|
1508
|
+
if (lookups === MAX_EXPIRED_LOOKUPS) return { ...page, cutAfter: lastKey };
|
|
1509
|
+
lookups += 1;
|
|
1510
|
+
const retained = await retainedOf(bucket, { root: storageRoot, key: entry.key }, object);
|
|
1511
|
+
if (retained) page.objects.push(expiredEntry(entry, retained));
|
|
1512
|
+
} else {
|
|
1513
|
+
page.objects.push(entry);
|
|
1514
|
+
}
|
|
1515
|
+
lastKey = entry.key;
|
|
1516
|
+
}
|
|
1517
|
+
return page;
|
|
1518
|
+
}
|
|
1519
|
+
var assets = new Hono4();
|
|
865
1520
|
assets.get("/", async (context) => {
|
|
866
1521
|
const prefix = parseListPrefix(context.req.query("prefix"));
|
|
867
1522
|
if (prefix === null) {
|
|
@@ -879,23 +1534,251 @@ assets.get("/", async (context) => {
|
|
|
879
1534
|
prefix: `${storageRoot}${position.visibility}/${ASSET_NAMESPACE}/${prefix}`,
|
|
880
1535
|
limit: parseLimit(context.req.query("limit")),
|
|
881
1536
|
...position.cursor ? { cursor: position.cursor } : {},
|
|
1537
|
+
...position.startAfter ? { startAfter: `${storageRoot}${position.startAfter}` } : {},
|
|
882
1538
|
include: ["httpMetadata", "customMetadata"]
|
|
883
1539
|
};
|
|
884
1540
|
const listing = await context.env.MEDIA_BUCKET.list(listOptions);
|
|
885
|
-
const
|
|
886
|
-
|
|
1541
|
+
const page = await listedEntries(context.env.MEDIA_BUCKET, listing, storageRoot, position.visibility);
|
|
1542
|
+
const cursor = page.cutAfter ? resumeCursor(position.visibility, page.cutAfter) : nextCursor(listing, position, visibilities);
|
|
1543
|
+
return context.json({ objects: page.objects, cursor });
|
|
887
1544
|
});
|
|
888
1545
|
|
|
889
1546
|
// src/routes/captions.ts
|
|
890
1547
|
import {
|
|
891
1548
|
captionPath,
|
|
892
1549
|
createDecryptStream as createDecryptStream2,
|
|
893
|
-
decodeEncryptionMeta as
|
|
1550
|
+
decodeEncryptionMeta as decodeEncryptionMeta5,
|
|
894
1551
|
extractId,
|
|
895
1552
|
resolveSecurity,
|
|
896
1553
|
visibilityFromKey
|
|
897
1554
|
} from "@devultur/core";
|
|
898
|
-
import { Hono as
|
|
1555
|
+
import { Hono as Hono6 } from "hono";
|
|
1556
|
+
|
|
1557
|
+
// src/keys-bucket.ts
|
|
1558
|
+
import { decodeEncryptionMeta as decodeEncryptionMeta3 } from "@devultur/core";
|
|
1559
|
+
var KEYS_BUCKET_MISSING_CODE = "keys_bucket_missing";
|
|
1560
|
+
function isKeysBucketMissing(shouldEncrypt, env) {
|
|
1561
|
+
return shouldEncrypt && !env.KEYS_BUCKET;
|
|
1562
|
+
}
|
|
1563
|
+
function keysBucketMissing(context) {
|
|
1564
|
+
console.error("[encrypt] security.encrypt is on but KEYS_BUCKET is not bound: refusing to store a private file");
|
|
1565
|
+
return context.json(
|
|
1566
|
+
{ error: "Encryption at rest is on but KEYS_BUCKET is not bound", code: KEYS_BUCKET_MISSING_CODE },
|
|
1567
|
+
503
|
|
1568
|
+
);
|
|
1569
|
+
}
|
|
1570
|
+
var ENCRYPTION_KEY_MISSING_CODE = "encryption_key_missing";
|
|
1571
|
+
async function hasEncryptionKey(keysBucket, objectStorageKey, enc) {
|
|
1572
|
+
const meta = decodeEncryptionMeta3(enc);
|
|
1573
|
+
if (!meta) return true;
|
|
1574
|
+
if (!keysBucket) return false;
|
|
1575
|
+
return await keysBucket.head(`${objectStorageKey}/${meta.keyId}`) !== null;
|
|
1576
|
+
}
|
|
1577
|
+
function encryptionKeyMissing(context, key) {
|
|
1578
|
+
console.error(`[encrypt] the encryption key of ${key} is gone: refusing to store an object nobody could decrypt`);
|
|
1579
|
+
return context.json(
|
|
1580
|
+
{ error: `The encryption key of ${key} was deleted; upload it again`, code: ENCRYPTION_KEY_MISSING_CODE, key },
|
|
1581
|
+
500
|
|
1582
|
+
);
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
// src/object-lock/overwrite.ts
|
|
1586
|
+
import { decodeEncryptionMeta as decodeEncryptionMeta4, TranscodeStatus as TranscodeStatus2 } from "@devultur/core";
|
|
1587
|
+
|
|
1588
|
+
// src/transcode/status.ts
|
|
1589
|
+
import { TranscodeStatus } from "@devultur/core";
|
|
1590
|
+
var statusKey = (videoId) => `${KvPrefix.TRANSCODE_STATUS}:${videoId}`;
|
|
1591
|
+
function statusChangeFor(event) {
|
|
1592
|
+
switch (event.event) {
|
|
1593
|
+
case "transcode.started":
|
|
1594
|
+
return { status: TranscodeStatus.RUNNING };
|
|
1595
|
+
case "transcode.progress":
|
|
1596
|
+
return event.phase === "downloading" ? { status: TranscodeStatus.RUNNING } : null;
|
|
1597
|
+
case "transcode.complete":
|
|
1598
|
+
return { status: TranscodeStatus.COMPLETED };
|
|
1599
|
+
case "transcode.failed":
|
|
1600
|
+
return {
|
|
1601
|
+
status: TranscodeStatus.FAILED,
|
|
1602
|
+
...typeof event.error === "string" ? { error: event.error } : {},
|
|
1603
|
+
...typeof event.reason === "string" ? { reason: event.reason } : {},
|
|
1604
|
+
...typeof event.retriable === "boolean" ? { retriable: event.retriable } : {}
|
|
1605
|
+
};
|
|
1606
|
+
default:
|
|
1607
|
+
return null;
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1610
|
+
async function readStatus(kv, videoId) {
|
|
1611
|
+
return kv.get(statusKey(videoId), "json");
|
|
1612
|
+
}
|
|
1613
|
+
async function writeQueuedStatus(kv, videoId, job) {
|
|
1614
|
+
const record = { status: TranscodeStatus.QUEUED, ...job, updatedAt: Date.now() };
|
|
1615
|
+
await kv.put(statusKey(videoId), JSON.stringify(record), { expirationTtl: KV_JOB_TTL_SECONDS });
|
|
1616
|
+
}
|
|
1617
|
+
async function applyStatusEvent(kv, event) {
|
|
1618
|
+
const change = statusChangeFor(event);
|
|
1619
|
+
const videoId = typeof event.videoId === "string" ? event.videoId : "";
|
|
1620
|
+
if (!change || !videoId) return;
|
|
1621
|
+
const current = await readStatus(kv, videoId);
|
|
1622
|
+
if (!current) return;
|
|
1623
|
+
if (current.status === TranscodeStatus.COMPLETED) return;
|
|
1624
|
+
const record = {
|
|
1625
|
+
jobRef: current.jobRef,
|
|
1626
|
+
...current.runner ? { runner: current.runner } : {},
|
|
1627
|
+
...change,
|
|
1628
|
+
updatedAt: Date.now()
|
|
1629
|
+
};
|
|
1630
|
+
await kv.put(statusKey(videoId), JSON.stringify(record), { expirationTtl: KV_JOB_TTL_SECONDS });
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
// src/transcode/raw-retention.ts
|
|
1634
|
+
import { isVideoRawKey as isVideoRawKey2 } from "@devultur/core";
|
|
1635
|
+
var disposalKey = (videoId) => `${KvPrefix.TRANSCODE_RAW}:${videoId}`;
|
|
1636
|
+
async function readRawDisposal(kv, videoId) {
|
|
1637
|
+
return kv.get(disposalKey(videoId), "json");
|
|
1638
|
+
}
|
|
1639
|
+
async function rememberRawDisposal(kv, videoId, disposal) {
|
|
1640
|
+
await kv.put(disposalKey(videoId), JSON.stringify(disposal), { expirationTtl: KV_JOB_TTL_SECONDS });
|
|
1641
|
+
}
|
|
1642
|
+
async function disposeRawAfterTranscode(context, videoId) {
|
|
1643
|
+
const { env } = context;
|
|
1644
|
+
const disposal = await readRawDisposal(env.MEDIA_KV, videoId);
|
|
1645
|
+
if (!disposal?.deleteOnComplete || !disposal.storageKey) return "kept";
|
|
1646
|
+
const raw = await env.MEDIA_BUCKET.head(disposal.storageKey);
|
|
1647
|
+
const location = splitVideoStorageKey(disposal.storageKey);
|
|
1648
|
+
const lock = raw && location ? await effectiveLock(env.MEDIA_BUCKET, location.root, location.key, raw) : null;
|
|
1649
|
+
if (lock?.state && isStateLocked(lock.state, /* @__PURE__ */ new Date())) {
|
|
1650
|
+
await recordLockAudit(context, {
|
|
1651
|
+
action: "original_retained",
|
|
1652
|
+
keys: [location?.key ?? disposal.storageKey],
|
|
1653
|
+
before: lock.state.lock,
|
|
1654
|
+
after: null,
|
|
1655
|
+
outcome: "skipped",
|
|
1656
|
+
root: location?.root ?? ""
|
|
1657
|
+
});
|
|
1658
|
+
return "retained";
|
|
1659
|
+
}
|
|
1660
|
+
await env.MEDIA_BUCKET.delete(disposal.storageKey);
|
|
1661
|
+
if (env.KEYS_BUCKET) await deletePrefix(env.KEYS_BUCKET, `${disposal.storageKey}/`);
|
|
1662
|
+
return "deleted";
|
|
1663
|
+
}
|
|
1664
|
+
var ORIGINAL_NOT_RETAINED = {
|
|
1665
|
+
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.",
|
|
1666
|
+
code: "original_not_retained"
|
|
1667
|
+
};
|
|
1668
|
+
var PRIOR_OUTPUTS_LIST_LIMIT = 100;
|
|
1669
|
+
async function hasPriorTranscodeOutputs(bucket, videoFolder) {
|
|
1670
|
+
const listing = await bucket.list({ prefix: videoFolder, limit: PRIOR_OUTPUTS_LIST_LIMIT });
|
|
1671
|
+
return listing.objects.some((object) => !isVideoRawKey2(object.key));
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
// src/object-lock/transcode-marker.ts
|
|
1675
|
+
var MARKER_NAME = "transcode-completed";
|
|
1676
|
+
var markerKey = (root, videoFolder) => sidecarKey(root, `${videoFolder}/${MARKER_NAME}`);
|
|
1677
|
+
async function markTranscodeCompleted(env, videoId, ticketLocation) {
|
|
1678
|
+
const location = ticketLocation ?? await rememberedLocation(env, videoId);
|
|
1679
|
+
if (!location) return;
|
|
1680
|
+
await env.MEDIA_BUCKET.put(markerKey(location.root, location.folder), (/* @__PURE__ */ new Date()).toISOString(), {
|
|
1681
|
+
httpMetadata: { contentType: "text/plain" }
|
|
1682
|
+
});
|
|
1683
|
+
}
|
|
1684
|
+
async function rememberedLocation(env, videoId) {
|
|
1685
|
+
const disposal = await readRawDisposal(env.MEDIA_KV, videoId);
|
|
1686
|
+
const raw = disposal?.storageKey ? splitVideoStorageKey(disposal.storageKey) : null;
|
|
1687
|
+
const folder = raw?.key.split("/").slice(0, 3).join("/");
|
|
1688
|
+
return raw && folder ? { root: raw.root, folder } : null;
|
|
1689
|
+
}
|
|
1690
|
+
async function hasCompletedTranscode(bucket, root, videoFolder) {
|
|
1691
|
+
return await bucket.head(markerKey(root, videoFolder)) !== null;
|
|
1692
|
+
}
|
|
1693
|
+
async function forgetTranscodeCompleted(bucket, root, videoFolder) {
|
|
1694
|
+
await bucket.delete(markerKey(root, videoFolder));
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1697
|
+
// src/object-lock/overwrite.ts
|
|
1698
|
+
function sameObject(object) {
|
|
1699
|
+
return {
|
|
1700
|
+
etagMatches: object.etag,
|
|
1701
|
+
// R2 keeps `uploaded` to the millisecond, so "before uploaded + 1 ms" matches exactly this write and refuses
|
|
1702
|
+
// any later one. Real R2 only: the in-memory bucket of the tests can't prove the precision.
|
|
1703
|
+
...object.uploaded ? { uploadedBefore: new Date(object.uploaded.getTime() + 1) } : {}
|
|
1704
|
+
};
|
|
1705
|
+
}
|
|
1706
|
+
function overwriteOf(object) {
|
|
1707
|
+
const replacedEnc = object.customMetadata?.enc;
|
|
1708
|
+
return { onlyIf: sameObject(object), exists: true, ...replacedEnc ? { replacedEnc } : {} };
|
|
1709
|
+
}
|
|
1710
|
+
function createOnlyCondition() {
|
|
1711
|
+
return new Headers({ "If-None-Match": "*" });
|
|
1712
|
+
}
|
|
1713
|
+
async function checkWrite(context, key, fullKey = storageKey(context, key), options = {}) {
|
|
1714
|
+
if (isReservedLockKey(key)) return { blocked: reservedKeyResponse(context, key) };
|
|
1715
|
+
const object = await context.env.MEDIA_BUCKET.head(fullKey);
|
|
1716
|
+
if (!object) return { onlyIf: createOnlyCondition(), exists: false };
|
|
1717
|
+
if (options.isLockExempt) return overwriteOf(object);
|
|
1718
|
+
const root = fullKey.endsWith(key) ? fullKey.slice(0, fullKey.length - key.length) : void 0;
|
|
1719
|
+
const blocked = await assertWritable(context, [key], "overwrite", root);
|
|
1720
|
+
if (blocked) return { blocked };
|
|
1721
|
+
return overwriteOf(object);
|
|
1722
|
+
}
|
|
1723
|
+
async function deleteReplacedKey(context, fullKey, replacedEnc, writtenEnc) {
|
|
1724
|
+
const meta = decodeEncryptionMeta4(replacedEnc);
|
|
1725
|
+
if (!meta || !context.env.KEYS_BUCKET) return;
|
|
1726
|
+
if (decodeEncryptionMeta4(writtenEnc)?.keyId === meta.keyId) return;
|
|
1727
|
+
await context.env.KEYS_BUCKET.delete(`${fullKey}/${meta.keyId}`);
|
|
1728
|
+
}
|
|
1729
|
+
function writeConflict(context, key) {
|
|
1730
|
+
return context.json({ error: `${key} changed while it was being written; retry`, code: "write_conflict", key }, 409);
|
|
1731
|
+
}
|
|
1732
|
+
var WriteConflictError = class extends Error {
|
|
1733
|
+
constructor(key) {
|
|
1734
|
+
super(`${key} changed while it was being written`);
|
|
1735
|
+
this.key = key;
|
|
1736
|
+
this.name = "WriteConflictError";
|
|
1737
|
+
}
|
|
1738
|
+
key;
|
|
1739
|
+
};
|
|
1740
|
+
async function assertRetranscodable(context, video, raw) {
|
|
1741
|
+
const root = getRequestState(context).storagePrefix;
|
|
1742
|
+
const { state } = await effectiveLock(context.env.MEDIA_BUCKET, root, video.rawKey, raw);
|
|
1743
|
+
if (!state) return null;
|
|
1744
|
+
const isCompleted = await hasCompletedTranscode(context.env.MEDIA_BUCKET, root, video.folder) || (await readStatus(context.env.MEDIA_KV, video.id))?.status === TranscodeStatus2.COMPLETED;
|
|
1745
|
+
if (!isCompleted) return null;
|
|
1746
|
+
return assertWritable(context, [video.folder], "overwrite");
|
|
1747
|
+
}
|
|
1748
|
+
async function checkWrites(context, keys) {
|
|
1749
|
+
const checks = /* @__PURE__ */ new Map();
|
|
1750
|
+
for (const key of keys) {
|
|
1751
|
+
const check = await checkWrite(context, key);
|
|
1752
|
+
if ("blocked" in check) return check;
|
|
1753
|
+
checks.set(key, check);
|
|
1754
|
+
}
|
|
1755
|
+
return { checks };
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
// src/object-lock/caption-writes.ts
|
|
1759
|
+
async function prepareCaptionWrites(context, locales, virtualKeyOf, source) {
|
|
1760
|
+
const writes = await checkWrites(context, locales.map(virtualKeyOf));
|
|
1761
|
+
if ("blocked" in writes) return writes;
|
|
1762
|
+
const storageKeyOf = (locale) => storageKey(context, virtualKeyOf(locale));
|
|
1763
|
+
const expiry = expiryMetadataOf(source.customMetadata);
|
|
1764
|
+
return {
|
|
1765
|
+
storageKeyOf,
|
|
1766
|
+
put: async (locale, body, options) => {
|
|
1767
|
+
const check = writes.checks.get(virtualKeyOf(locale));
|
|
1768
|
+
const customMetadata = { ...options.customMetadata, ...expiry };
|
|
1769
|
+
const stored = await context.env.MEDIA_BUCKET.put(storageKeyOf(locale), body, {
|
|
1770
|
+
...options,
|
|
1771
|
+
...Object.keys(customMetadata).length > 0 ? { customMetadata } : {},
|
|
1772
|
+
onlyIf: check?.onlyIf
|
|
1773
|
+
});
|
|
1774
|
+
if (stored === null) throw new WriteConflictError(virtualKeyOf(locale));
|
|
1775
|
+
await deleteReplacedKey(context, storageKeyOf(locale), check?.replacedEnc, customMetadata.enc);
|
|
1776
|
+
}
|
|
1777
|
+
};
|
|
1778
|
+
}
|
|
1779
|
+
function captionConflictResponse(context, error) {
|
|
1780
|
+
return error instanceof WriteConflictError ? writeConflict(context, error.key) : null;
|
|
1781
|
+
}
|
|
899
1782
|
|
|
900
1783
|
// src/routes/captions-errors.ts
|
|
901
1784
|
var CaptionsErrorCode = {
|
|
@@ -954,7 +1837,7 @@ function logCaptionsError(context, error) {
|
|
|
954
1837
|
}
|
|
955
1838
|
|
|
956
1839
|
// src/routes/webhooks.ts
|
|
957
|
-
import { Hono as
|
|
1840
|
+
import { Hono as Hono5 } from "hono";
|
|
958
1841
|
|
|
959
1842
|
// src/transcode/metrics.ts
|
|
960
1843
|
var OPERATOR_EVENT_FIELDS = ["encode", "posterKey", "posterFormats"];
|
|
@@ -1072,76 +1955,8 @@ async function loadOutputBaseUrl(kv, videoId, fallbackUrl) {
|
|
|
1072
1955
|
return await kv.get(outputBaseKey(videoId)) ?? fallbackUrl;
|
|
1073
1956
|
}
|
|
1074
1957
|
|
|
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
1958
|
// src/routes/webhooks.ts
|
|
1144
|
-
var webhooks = new
|
|
1959
|
+
var webhooks = new Hono5();
|
|
1145
1960
|
webhooks.post("/transcode", async (context) => {
|
|
1146
1961
|
const webhookSecret = context.env.TRANSCODE_WEBHOOK_SECRET;
|
|
1147
1962
|
if (!webhookSecret) {
|
|
@@ -1156,7 +1971,12 @@ webhooks.post("/transcode", async (context) => {
|
|
|
1156
1971
|
const body = await context.req.json();
|
|
1157
1972
|
await publishTranscodeJob(context, body);
|
|
1158
1973
|
if (body.event === "transcode.complete" && body.videoId) {
|
|
1159
|
-
|
|
1974
|
+
const disposal = await disposeRawAfterTranscode(context, body.videoId);
|
|
1975
|
+
if (disposal === "deleted") addRouteResult(context, { originalDeleted: true });
|
|
1976
|
+
if (disposal === "retained") addRouteResult(context, { originalRetained: true });
|
|
1977
|
+
const ticket = context.req.header(JOB_TICKET_HEADER);
|
|
1978
|
+
const location = await verifyJobLocation(context.env.DELIVERY_SIGN_SECRET, ticket, body.videoId);
|
|
1979
|
+
await markTranscodeCompleted(context.env, body.videoId, location);
|
|
1160
1980
|
await context.env.MEDIA_KV.put(
|
|
1161
1981
|
`${KvPrefix.TRANSCODE_META}:${body.videoId}`,
|
|
1162
1982
|
JSON.stringify({ duration: body.duration }),
|
|
@@ -1358,7 +2178,7 @@ async function transcodedDuration(kv, videoId) {
|
|
|
1358
2178
|
const meta = await kv.get(`${KvPrefix.TRANSCODE_META}:${videoId}`, "json");
|
|
1359
2179
|
return meta?.duration ?? void 0;
|
|
1360
2180
|
}
|
|
1361
|
-
var captions = new
|
|
2181
|
+
var captions = new Hono6();
|
|
1362
2182
|
captions.post("/", async (context) => {
|
|
1363
2183
|
const body = await context.req.json();
|
|
1364
2184
|
if (!body.key || !body.locales?.length) {
|
|
@@ -1382,7 +2202,7 @@ captions.post("/", async (context) => {
|
|
|
1382
2202
|
}
|
|
1383
2203
|
const filename = body.key.split("/").pop() ?? "audio.mp4";
|
|
1384
2204
|
const webhookData = await resolveCaptionsWebhook(context, videoId, body.callbackUrl);
|
|
1385
|
-
const rawEnc =
|
|
2205
|
+
const rawEnc = decodeEncryptionMeta5(file.customMetadata?.enc);
|
|
1386
2206
|
let audioBody = file.body;
|
|
1387
2207
|
if (rawEnc) {
|
|
1388
2208
|
const loaded = await loadStorageKey(context.env.KEYS_BUCKET, fullKey, rawEnc.keyId);
|
|
@@ -1392,12 +2212,15 @@ captions.post("/", async (context) => {
|
|
|
1392
2212
|
audioBody = file.body.pipeThrough(createDecryptStream2(loaded.key, rawEnc.iv));
|
|
1393
2213
|
}
|
|
1394
2214
|
const visibility = visibilityFromKey(body.key) ?? "private";
|
|
1395
|
-
const
|
|
2215
|
+
const captionVirtualKey = (locale) => `${visibility}/${captionPath(videoId, locale)}`;
|
|
1396
2216
|
const resourceOverride = parseSecurityHeader(file.customMetadata?.security);
|
|
1397
2217
|
const captionPolicy = resolveSecurity(resourceOverride, scope.config?.security);
|
|
1398
|
-
const encryptCaptions = captionPolicy.encrypt && visibility === "private"
|
|
2218
|
+
const encryptCaptions = Boolean(captionPolicy.encrypt) && visibility === "private";
|
|
2219
|
+
if (isKeysBucketMissing(encryptCaptions, context.env)) return keysBucketMissing(context);
|
|
2220
|
+
const writes = await prepareCaptionWrites(context, body.locales, captionVirtualKey, file);
|
|
2221
|
+
if ("blocked" in writes) return writes.blocked;
|
|
1399
2222
|
const putCaption = async (locale, vtt) => {
|
|
1400
|
-
const objectKey =
|
|
2223
|
+
const objectKey = writes.storageKeyOf(locale);
|
|
1401
2224
|
const metadata = {};
|
|
1402
2225
|
if (resourceOverride) metadata.security = JSON.stringify(resourceOverride);
|
|
1403
2226
|
let bodyToStore = vtt;
|
|
@@ -1411,7 +2234,7 @@ captions.post("/", async (context) => {
|
|
|
1411
2234
|
bodyToStore = result.body;
|
|
1412
2235
|
metadata.enc = result.enc;
|
|
1413
2236
|
}
|
|
1414
|
-
await
|
|
2237
|
+
await writes.put(locale, bodyToStore, {
|
|
1415
2238
|
httpMetadata: { contentType: "text/vtt" },
|
|
1416
2239
|
...Object.keys(metadata).length > 0 ? { customMetadata: metadata } : {}
|
|
1417
2240
|
});
|
|
@@ -1466,18 +2289,19 @@ captions.post("/", async (context) => {
|
|
|
1466
2289
|
});
|
|
1467
2290
|
} catch (err) {
|
|
1468
2291
|
logCaptionsError(`job failed for ${videoId}`, err);
|
|
1469
|
-
const
|
|
2292
|
+
const failure4 = toCaptionsFailure(err);
|
|
1470
2293
|
await forwardToClientWebhook(
|
|
1471
2294
|
{
|
|
1472
2295
|
event: "captions.failed",
|
|
1473
2296
|
videoId,
|
|
1474
|
-
error:
|
|
1475
|
-
reason:
|
|
1476
|
-
retriable:
|
|
2297
|
+
error: failure4.error,
|
|
2298
|
+
reason: failure4.reason,
|
|
2299
|
+
retriable: failure4.retriable
|
|
1477
2300
|
},
|
|
1478
2301
|
webhookData
|
|
1479
2302
|
);
|
|
1480
|
-
|
|
2303
|
+
const conflict = captionConflictResponse(context, err);
|
|
2304
|
+
return conflict ?? context.json({ error: failure4.error, code: failure4.code }, failure4.status);
|
|
1481
2305
|
}
|
|
1482
2306
|
});
|
|
1483
2307
|
captions.get("/:transcriptId", async (context) => {
|
|
@@ -1498,16 +2322,15 @@ captions.get("/:transcriptId", async (context) => {
|
|
|
1498
2322
|
// src/routes/media.ts
|
|
1499
2323
|
import {
|
|
1500
2324
|
computeFingerprint,
|
|
1501
|
-
decodeEncryptionMeta as
|
|
2325
|
+
decodeEncryptionMeta as decodeEncryptionMeta7,
|
|
1502
2326
|
GENERATED_PRIVATE_CACHE_CONTROL,
|
|
1503
2327
|
GENERATED_PUBLIC_CACHE_CONTROL,
|
|
1504
2328
|
resolveSecurity as resolveSecurity2,
|
|
1505
2329
|
SESSION_HEADER,
|
|
1506
2330
|
toPrivateCacheControl,
|
|
1507
|
-
videoBasePath,
|
|
1508
2331
|
visibilityFromKey as visibilityFromKey2
|
|
1509
2332
|
} from "@devultur/core";
|
|
1510
|
-
import { Hono as
|
|
2333
|
+
import { Hono as Hono7 } from "hono";
|
|
1511
2334
|
|
|
1512
2335
|
// src/image/cloudflare.ts
|
|
1513
2336
|
var IMAGE_RESIZING_VIA = "image-resizing";
|
|
@@ -1714,7 +2537,7 @@ function hasAvifSequenceBrand(bytes) {
|
|
|
1714
2537
|
}
|
|
1715
2538
|
|
|
1716
2539
|
// src/image/storage.ts
|
|
1717
|
-
import { decodeEncryptionMeta as
|
|
2540
|
+
import { decodeEncryptionMeta as decodeEncryptionMeta6, decryptChunk as decryptChunk2 } from "@devultur/core";
|
|
1718
2541
|
var HEADER_PROBE_BYTES = 256 * 1024;
|
|
1719
2542
|
var passThrough = async (bytes) => bytes;
|
|
1720
2543
|
var DerivedKind = {
|
|
@@ -1722,7 +2545,7 @@ var DerivedKind = {
|
|
|
1722
2545
|
MEZZANINE: "mezzanine"
|
|
1723
2546
|
};
|
|
1724
2547
|
async function loadDecryptor(env, objectKey, customMetadata) {
|
|
1725
|
-
const enc =
|
|
2548
|
+
const enc = decodeEncryptionMeta6(customMetadata?.enc);
|
|
1726
2549
|
if (!enc) return passThrough;
|
|
1727
2550
|
const loaded = await loadStorageKey(env.KEYS_BUCKET, objectKey, enc.keyId);
|
|
1728
2551
|
if (!loaded) return null;
|
|
@@ -1743,7 +2566,7 @@ async function storeDerived(env, storageKey2, body, contentType, original, kind)
|
|
|
1743
2566
|
const customMetadata = { derivedFrom: kind };
|
|
1744
2567
|
if (security) customMetadata.security = security;
|
|
1745
2568
|
let storedBody = body;
|
|
1746
|
-
if (
|
|
2569
|
+
if (decodeEncryptionMeta6(original.customMetadata?.enc)) {
|
|
1747
2570
|
if (!env.KEYS_BUCKET) throw new Error("Encrypted original without KEYS_BUCKET");
|
|
1748
2571
|
const encrypted = await encryptForStorage(env.KEYS_BUCKET, storageKey2, body, security ? { security } : void 0);
|
|
1749
2572
|
storedBody = encrypted.body;
|
|
@@ -1912,6 +2735,8 @@ import { edgeTtlSeconds, isAssetKey, sha256Hex as sha256Hex2 } from "@devultur/c
|
|
|
1912
2735
|
var MAX_EDGE_TTL_SECONDS = 7 * 24 * 60 * 60;
|
|
1913
2736
|
var MAX_EDGE_CACHE_FILL_BYTES = 100 * 1024 * 1024;
|
|
1914
2737
|
var ORIGINAL_CACHE_CONTROL_HEADER = "x-devultur-cache-control";
|
|
2738
|
+
var EDGE_EXPIRES_HEADER = "x-devultur-edge-expires";
|
|
2739
|
+
var OWNER_HEADER_PREFIX = "x-devultur-lock";
|
|
1915
2740
|
var CACHE_TAG_HEADER = "Cache-Tag";
|
|
1916
2741
|
var TAG_HASH_LENGTH = 40;
|
|
1917
2742
|
function publicEdgeCache() {
|
|
@@ -1935,35 +2760,112 @@ async function edgeCacheTags(storagePrefix2, virtualKey) {
|
|
|
1935
2760
|
const objectTag = await edgeCacheTag(storagePrefix2, virtualKey);
|
|
1936
2761
|
return prefixTag ? `${objectTag},${prefixTag}` : objectTag;
|
|
1937
2762
|
}
|
|
2763
|
+
function isEdgeCopyExpired(hit, now = Date.now()) {
|
|
2764
|
+
const expiresAt = Number(hit.headers.get(EDGE_EXPIRES_HEADER));
|
|
2765
|
+
return Number.isFinite(expiresAt) && expiresAt > 0 && expiresAt <= now;
|
|
2766
|
+
}
|
|
2767
|
+
async function matchEdgeCopy(cache, request, options) {
|
|
2768
|
+
const hit = await cache.match(request);
|
|
2769
|
+
if (!hit || isEdgeCopyExpired(hit)) return null;
|
|
2770
|
+
return responseFromEdgeHit(hit, options);
|
|
2771
|
+
}
|
|
1938
2772
|
function responseFromEdgeHit(hit, options) {
|
|
1939
2773
|
const headers = new Headers(hit.headers);
|
|
1940
2774
|
if (options.isFormatNegotiated) headers.set("Vary", "Accept");
|
|
1941
2775
|
const original = headers.get(ORIGINAL_CACHE_CONTROL_HEADER);
|
|
1942
2776
|
if (original) headers.set("Cache-Control", original);
|
|
1943
2777
|
headers.delete(ORIGINAL_CACHE_CONTROL_HEADER);
|
|
2778
|
+
headers.delete(EDGE_EXPIRES_HEADER);
|
|
1944
2779
|
headers.delete(CACHE_TAG_HEADER);
|
|
1945
2780
|
return new Response(hit.body, { status: hit.status, headers });
|
|
1946
2781
|
}
|
|
1947
2782
|
function edgeTtlFor(cacheControl) {
|
|
1948
2783
|
return edgeTtlSeconds(cacheControl, MAX_EDGE_TTL_SECONDS);
|
|
1949
2784
|
}
|
|
1950
|
-
|
|
2785
|
+
function edgeCopyTtl(target, now = Date.now()) {
|
|
1951
2786
|
const ttl = edgeTtlFor(target.cacheControl);
|
|
2787
|
+
if (target.expiresAt === void 0) return ttl;
|
|
2788
|
+
return Math.max(0, Math.min(ttl, Math.floor((target.expiresAt - now) / 1e3)));
|
|
2789
|
+
}
|
|
2790
|
+
var carriesOwnerHeaders = (headers) => [...headers.keys()].some((name) => name.toLowerCase().startsWith(OWNER_HEADER_PREFIX));
|
|
2791
|
+
async function putEdgeCopy(target, body, headers) {
|
|
2792
|
+
const ttl = edgeCopyTtl(target);
|
|
2793
|
+
if (ttl <= 0 || carriesOwnerHeaders(headers)) {
|
|
2794
|
+
await body?.cancel();
|
|
2795
|
+
return;
|
|
2796
|
+
}
|
|
1952
2797
|
const copyHeaders = new Headers(headers);
|
|
1953
2798
|
copyHeaders.delete("Content-Range");
|
|
1954
2799
|
copyHeaders.delete("Vary");
|
|
1955
2800
|
copyHeaders.set(ORIGINAL_CACHE_CONTROL_HEADER, target.cacheControl);
|
|
1956
2801
|
copyHeaders.set("Cache-Control", `public, max-age=${ttl}`);
|
|
2802
|
+
if (target.expiresAt !== void 0) copyHeaders.set(EDGE_EXPIRES_HEADER, String(target.expiresAt));
|
|
1957
2803
|
copyHeaders.set(CACHE_TAG_HEADER, await edgeCacheTags(target.storagePrefix, target.virtualKey));
|
|
1958
2804
|
await target.cache.put(target.cacheKey, new Response(body, { status: 200, headers: copyHeaders }));
|
|
1959
2805
|
}
|
|
1960
2806
|
async function fillEdgeCache(target, bucket, fullKey, headers) {
|
|
2807
|
+
if (edgeCopyTtl(target) <= 0 || carriesOwnerHeaders(headers)) return;
|
|
1961
2808
|
const full = await bucket.get(fullKey);
|
|
1962
2809
|
if (!full) return;
|
|
1963
2810
|
const fullHeaders = new Headers(headers);
|
|
1964
2811
|
fullHeaders.set("Content-Length", String(full.size));
|
|
1965
2812
|
await putEdgeCopy(target, full.body, fullHeaders);
|
|
1966
2813
|
}
|
|
2814
|
+
function cacheDelivered(waitUntil, target, delivered) {
|
|
2815
|
+
if (edgeCopyTtl(target) <= 0) return;
|
|
2816
|
+
if (delivered.kind === "plain-range" && delivered.size <= MAX_EDGE_CACHE_FILL_BYTES) {
|
|
2817
|
+
waitUntil(fillEdgeCache(target, delivered.bucket, delivered.servedKey, delivered.response.headers));
|
|
2818
|
+
}
|
|
2819
|
+
if (delivered.kind === "plain-full") {
|
|
2820
|
+
waitUntil(putEdgeCopy(target, delivered.response.clone().body, delivered.headers));
|
|
2821
|
+
}
|
|
2822
|
+
}
|
|
2823
|
+
|
|
2824
|
+
// src/routes/media-delete.ts
|
|
2825
|
+
import { videoBasePath } from "@devultur/core";
|
|
2826
|
+
async function handleVideoDelete(context) {
|
|
2827
|
+
const videoId = context.req.param("videoId") ?? "";
|
|
2828
|
+
const visibilityParam = context.req.query("visibility");
|
|
2829
|
+
const visibilities = visibilityParam === "public" || visibilityParam === "private" ? [visibilityParam] : ["public", "private"];
|
|
2830
|
+
const folders = visibilities.map((visibility) => `${visibility}/${videoBasePath(videoId)}`);
|
|
2831
|
+
const blocked = await assertWritable(context, folders, "delete");
|
|
2832
|
+
if (blocked) return blocked;
|
|
2833
|
+
let deletedCount = 0;
|
|
2834
|
+
let deletedKeyCount = 0;
|
|
2835
|
+
for (const folder of folders) {
|
|
2836
|
+
const deleted = await guardedDeletePrefix(context, `${folder}/`);
|
|
2837
|
+
if ("blocked" in deleted) return deleted.blocked;
|
|
2838
|
+
deletedCount += deleted.deletedCount;
|
|
2839
|
+
if (deleted.deletedCount > 0) {
|
|
2840
|
+
await forgetTranscodeCompleted(context.env.MEDIA_BUCKET, getRequestState(context).storagePrefix, folder);
|
|
2841
|
+
}
|
|
2842
|
+
if (!folder.startsWith("private/")) continue;
|
|
2843
|
+
const deletedKeys = await deleteFolderKeys(context, folder);
|
|
2844
|
+
if ("blocked" in deletedKeys) return deletedKeys.blocked;
|
|
2845
|
+
deletedKeyCount += deletedKeys.deletedCount;
|
|
2846
|
+
}
|
|
2847
|
+
return context.json({ videoId, deletedCount, deletedKeyCount });
|
|
2848
|
+
}
|
|
2849
|
+
async function handleKeyDelete(context) {
|
|
2850
|
+
const key = context.req.param("key") ?? "";
|
|
2851
|
+
const fullKey = storageKey(context, key);
|
|
2852
|
+
if (isVariantKey(key)) {
|
|
2853
|
+
return context.json({ error: "Not found" }, 404);
|
|
2854
|
+
}
|
|
2855
|
+
if (isReservedLockKey(key)) return reservedKeyResponse(context, key);
|
|
2856
|
+
const exists = await context.env.MEDIA_BUCKET.head(fullKey);
|
|
2857
|
+
if (!exists) {
|
|
2858
|
+
return context.json({ error: "Not found" }, 404);
|
|
2859
|
+
}
|
|
2860
|
+
const blocked = await assertWritable(context, [key], "delete");
|
|
2861
|
+
if (blocked) return blocked;
|
|
2862
|
+
await context.env.MEDIA_BUCKET.delete(fullKey);
|
|
2863
|
+
const variants = await guardedDeletePrefix(context, variantPrefix(key));
|
|
2864
|
+
if ("blocked" in variants) return variants.blocked;
|
|
2865
|
+
const keys = await deleteKeysFor(context, key, exists);
|
|
2866
|
+
if ("blocked" in keys) return keys.blocked;
|
|
2867
|
+
return new Response(null, { status: 204 });
|
|
2868
|
+
}
|
|
1967
2869
|
|
|
1968
2870
|
// src/routes/storyboard-vtt.ts
|
|
1969
2871
|
import { createDecryptStream as createDecryptStream3 } from "@devultur/core";
|
|
@@ -1993,7 +2895,7 @@ function downloadDisposition(storedDisposition, key, filename) {
|
|
|
1993
2895
|
if (storedDisposition?.startsWith("inline")) return `attachment${storedDisposition.slice("inline".length)}`;
|
|
1994
2896
|
return contentDispositionAttachment(key.split("/").pop() || "download");
|
|
1995
2897
|
}
|
|
1996
|
-
var media = new
|
|
2898
|
+
var media = new Hono7();
|
|
1997
2899
|
media.get("/:key{.+}", async (context) => {
|
|
1998
2900
|
const key = context.req.param("key");
|
|
1999
2901
|
const fullKey = storageKey(context, key);
|
|
@@ -2033,24 +2935,28 @@ media.get("/:key{.+}", async (context) => {
|
|
|
2033
2935
|
const accept = context.req.header("Accept");
|
|
2034
2936
|
const edgeCacheKey = workerParams ? `${requestUrl.origin}/__variant/${workerVariantKey(fullKey, workerParams, accept)}` : requestUrl.toString();
|
|
2035
2937
|
const isUncachedTransform = hasTransformQuery(requestUrl) && !workerParams;
|
|
2036
|
-
const
|
|
2938
|
+
const isEdgeCached = !isPrivate && !isOriginRequest && !isUncachedTransform && !isOwnerAccess;
|
|
2939
|
+
const edgeCache = isEdgeCached ? publicEdgeCache() : null;
|
|
2037
2940
|
if (edgeCache) {
|
|
2038
2941
|
const cacheRequest = workerParams ? new Request(edgeCacheKey, context.req.raw) : context.req.raw;
|
|
2039
|
-
const hit = await edgeCache
|
|
2040
|
-
if (hit) return
|
|
2942
|
+
const hit = await matchEdgeCopy(edgeCache, cacheRequest, { isFormatNegotiated });
|
|
2943
|
+
if (hit) return hit;
|
|
2041
2944
|
}
|
|
2042
2945
|
let object = await context.env.MEDIA_BUCKET.get(fullKey);
|
|
2043
2946
|
let servedKey = fullKey;
|
|
2044
2947
|
if (!object) {
|
|
2045
2948
|
return context.json({ error: "Not found" }, 404);
|
|
2046
2949
|
}
|
|
2047
|
-
|
|
2950
|
+
const location = { root: getRequestState(context).storagePrefix, key };
|
|
2951
|
+
const expired = await expiredRefusal(context.env.MEDIA_BUCKET, location, object, isOwnerAccess);
|
|
2952
|
+
if (expired) return expired;
|
|
2953
|
+
const expiresAt = objectExpiresAt(object.customMetadata);
|
|
2048
2954
|
if (tokenPayload && await bindingMismatch(context, tokenPayload, object.customMetadata)) {
|
|
2049
2955
|
return context.json({ error: "Session mismatch", code: "session_mismatch" }, 401);
|
|
2050
2956
|
}
|
|
2051
2957
|
let contentType = object.httpMetadata?.contentType ?? "application/octet-stream";
|
|
2052
2958
|
const isPlaylist = key.endsWith(".m3u8");
|
|
2053
|
-
const cacheControl = servedCacheControl(object.httpMetadata?.cacheControl, isPrivate);
|
|
2959
|
+
const cacheControl = servedCacheControl(object.httpMetadata?.cacheControl, isPrivate || isOwnerAccess);
|
|
2054
2960
|
const downloadParam = requestUrl.searchParams.get("download");
|
|
2055
2961
|
const isDownload = downloadParam !== null;
|
|
2056
2962
|
if (isPlaylist && token2) {
|
|
@@ -2096,7 +3002,7 @@ media.get("/:key{.+}", async (context) => {
|
|
|
2096
3002
|
});
|
|
2097
3003
|
}
|
|
2098
3004
|
if (token2 && isStoryboardVttKey(key) && object.body) {
|
|
2099
|
-
const enc =
|
|
3005
|
+
const enc = decodeEncryptionMeta7(object.customMetadata?.enc);
|
|
2100
3006
|
const loaded = enc ? await loadStorageKey(context.env.KEYS_BUCKET, fullKey, enc.keyId) : null;
|
|
2101
3007
|
if (enc && !loaded) return context.json({ error: "Decryption key unavailable" }, 500);
|
|
2102
3008
|
const decryption = enc && loaded ? { key: loaded.key, iv: enc.iv } : null;
|
|
@@ -2111,6 +3017,7 @@ media.get("/:key{.+}", async (context) => {
|
|
|
2111
3017
|
} else if (object.httpMetadata?.contentDisposition) {
|
|
2112
3018
|
headers.set("Content-Disposition", object.httpMetadata.contentDisposition);
|
|
2113
3019
|
}
|
|
3020
|
+
if (isOwnerAccess) for (const [name, value] of await lockHeaders(context, key, object)) headers.set(name, value);
|
|
2114
3021
|
const { response, kind } = await streamObject({
|
|
2115
3022
|
bucket: context.env.MEDIA_BUCKET,
|
|
2116
3023
|
keysBucket: context.env.KEYS_BUCKET,
|
|
@@ -2119,56 +3026,25 @@ media.get("/:key{.+}", async (context) => {
|
|
|
2119
3026
|
headers,
|
|
2120
3027
|
range: context.req.header("Range")
|
|
2121
3028
|
});
|
|
2122
|
-
if (!edgeCache
|
|
3029
|
+
if (!edgeCache) return response;
|
|
2123
3030
|
const target = {
|
|
2124
3031
|
cache: edgeCache,
|
|
2125
3032
|
cacheKey: edgeCacheKey,
|
|
2126
3033
|
storagePrefix: getRequestState(context).storagePrefix,
|
|
2127
3034
|
virtualKey: key,
|
|
2128
|
-
cacheControl
|
|
3035
|
+
cacheControl,
|
|
3036
|
+
expiresAt
|
|
2129
3037
|
};
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
}
|
|
2133
|
-
if (kind === "plain-full") {
|
|
2134
|
-
context.executionCtx.waitUntil(putEdgeCopy(target, response.clone().body, headers));
|
|
2135
|
-
}
|
|
3038
|
+
const delivered = { bucket: context.env.MEDIA_BUCKET, servedKey, size: object.size, kind, response, headers };
|
|
3039
|
+
cacheDelivered((task) => context.executionCtx.waitUntil(task), target, delivered);
|
|
2136
3040
|
return response;
|
|
2137
3041
|
});
|
|
2138
3042
|
function servedCacheControl(stored, isPrivate) {
|
|
2139
3043
|
if (!stored) return isPrivate ? GENERATED_PRIVATE_CACHE_CONTROL : GENERATED_PUBLIC_CACHE_CONTROL;
|
|
2140
3044
|
return isPrivate ? toPrivateCacheControl(stored) : stored;
|
|
2141
3045
|
}
|
|
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
|
-
});
|
|
3046
|
+
media.delete("/videos/:videoId", handleVideoDelete);
|
|
3047
|
+
media.delete("/:key{.+}", handleKeyDelete);
|
|
2172
3048
|
function parseSecurityMeta(raw) {
|
|
2173
3049
|
if (!raw) return void 0;
|
|
2174
3050
|
try {
|
|
@@ -2217,9 +3093,9 @@ async function serveEncryptionKey(context, key, tokenPayload) {
|
|
|
2217
3093
|
|
|
2218
3094
|
// src/routes/token.ts
|
|
2219
3095
|
import { computeFingerprint as computeFingerprint2, DEFAULT_SECURITY, TokenScope as TokenScope3 } from "@devultur/core";
|
|
2220
|
-
import { Hono as
|
|
3096
|
+
import { Hono as Hono8 } from "hono";
|
|
2221
3097
|
var DEFAULT_VIEWER_TOKEN_TTL_SECONDS = 7200;
|
|
2222
|
-
var token = new
|
|
3098
|
+
var token = new Hono8();
|
|
2223
3099
|
token.post("/", async (context) => {
|
|
2224
3100
|
const body = await context.req.json();
|
|
2225
3101
|
if (!body.userId) {
|
|
@@ -2281,7 +3157,7 @@ import {
|
|
|
2281
3157
|
resolveSecurity as resolveSecurity3,
|
|
2282
3158
|
resolveTranscodePreset,
|
|
2283
3159
|
TokenScope as TokenScope4,
|
|
2284
|
-
TranscodeStatus as
|
|
3160
|
+
TranscodeStatus as TranscodeStatus3,
|
|
2285
3161
|
videoBasePath as videoBasePath2,
|
|
2286
3162
|
videoHlsPath,
|
|
2287
3163
|
videoMp4Dir,
|
|
@@ -2290,7 +3166,7 @@ import {
|
|
|
2290
3166
|
videoThumbnailPath,
|
|
2291
3167
|
visibilityFromKey as visibilityFromKey3
|
|
2292
3168
|
} from "@devultur/core";
|
|
2293
|
-
import { Hono as
|
|
3169
|
+
import { Hono as Hono9 } from "hono";
|
|
2294
3170
|
|
|
2295
3171
|
// src/transcode/plan.ts
|
|
2296
3172
|
import { MAIN_VARIANT } from "@devultur/core";
|
|
@@ -3229,7 +4105,7 @@ function parseSecurityMeta2(raw) {
|
|
|
3229
4105
|
}
|
|
3230
4106
|
var SERVICE_TOKEN_TTL_SECONDS = 3600;
|
|
3231
4107
|
var isVideoId = (videoId, key) => Boolean(videoId) && videoId !== key;
|
|
3232
|
-
var transcode = new
|
|
4108
|
+
var transcode = new Hono9();
|
|
3233
4109
|
transcode.post("/", async (context) => {
|
|
3234
4110
|
const body = await context.req.json();
|
|
3235
4111
|
if (!body.key) {
|
|
@@ -3250,6 +4126,9 @@ transcode.post("/", async (context) => {
|
|
|
3250
4126
|
if (!isVideoId(videoId, body.key)) {
|
|
3251
4127
|
return context.json({ error: "Invalid key format" }, 400);
|
|
3252
4128
|
}
|
|
4129
|
+
const video = { id: videoId, folder: `${visibility}/${videoBasePath2(videoId)}`, rawKey: body.key };
|
|
4130
|
+
const retained = await assertRetranscodable(context, video, object);
|
|
4131
|
+
if (retained) return retained;
|
|
3253
4132
|
const scope = getRequestState(context);
|
|
3254
4133
|
const transcodeConfig = scope.config?.transcode;
|
|
3255
4134
|
const resolved = resolveTranscodePreset(body.preset, transcodeConfig?.presets);
|
|
@@ -3269,7 +4148,8 @@ transcode.post("/", async (context) => {
|
|
|
3269
4148
|
const resourceSecurityHeader = rawOverride ? JSON.stringify(rawOverride) : "";
|
|
3270
4149
|
const keyMetadata = rawOverride ? { security: resourceSecurityHeader } : void 0;
|
|
3271
4150
|
const policy = resolveSecurity3(rawOverride, scope.config?.security);
|
|
3272
|
-
const shouldEncrypt = policy.encrypt && visibility === "private"
|
|
4151
|
+
const shouldEncrypt = Boolean(policy.encrypt) && visibility === "private";
|
|
4152
|
+
if (isKeysBucketMissing(shouldEncrypt, context.env)) return keysBucketMissing(context);
|
|
3273
4153
|
let encryptKeyB64 = "";
|
|
3274
4154
|
let encryptKeyId = "";
|
|
3275
4155
|
if (shouldEncrypt && context.env.KEYS_BUCKET) {
|
|
@@ -3325,7 +4205,8 @@ transcode.post("/", async (context) => {
|
|
|
3325
4205
|
});
|
|
3326
4206
|
const { ticket: jobTicket } = await signJobTicket(context.env.DELIVERY_SIGN_SECRET, {
|
|
3327
4207
|
videoId,
|
|
3328
|
-
...scope.context ? { context: scope.context } : {}
|
|
4208
|
+
...scope.context ? { context: scope.context } : {},
|
|
4209
|
+
location: { root: scope.storagePrefix, folder: video.folder }
|
|
3329
4210
|
});
|
|
3330
4211
|
const runner = createRunner(context.env);
|
|
3331
4212
|
if (!runner) {
|
|
@@ -3386,7 +4267,7 @@ transcode.post("/", async (context) => {
|
|
|
3386
4267
|
outputPrefix,
|
|
3387
4268
|
thumbnailKey,
|
|
3388
4269
|
preset,
|
|
3389
|
-
status:
|
|
4270
|
+
status: TranscodeStatus3.QUEUED
|
|
3390
4271
|
});
|
|
3391
4272
|
});
|
|
3392
4273
|
transcode.get("/:jobId", async (context) => {
|
|
@@ -3399,12 +4280,12 @@ transcode.get("/:jobId", async (context) => {
|
|
|
3399
4280
|
kv.get(`${KvPrefix.TRANSCODE_META}:${jobMeta.videoId}`, "json")
|
|
3400
4281
|
]);
|
|
3401
4282
|
const ownRecord = record?.jobRef === jobId ? record : null;
|
|
3402
|
-
const status = ownRecord?.status ?? (durationMeta ?
|
|
3403
|
-
if (status ===
|
|
4283
|
+
const status = ownRecord?.status ?? (durationMeta ? TranscodeStatus3.COMPLETED : TranscodeStatus3.QUEUED);
|
|
4284
|
+
if (status === TranscodeStatus3.FAILED) {
|
|
3404
4285
|
const { error, reason, retriable } = ownRecord ?? {};
|
|
3405
4286
|
return context.json({ jobId, status, error, reason, retriable });
|
|
3406
4287
|
}
|
|
3407
|
-
if (status !==
|
|
4288
|
+
if (status !== TranscodeStatus3.COMPLETED) return context.json({ jobId, status });
|
|
3408
4289
|
return context.json({
|
|
3409
4290
|
jobId,
|
|
3410
4291
|
status,
|
|
@@ -3415,29 +4296,42 @@ transcode.get("/:jobId", async (context) => {
|
|
|
3415
4296
|
});
|
|
3416
4297
|
|
|
3417
4298
|
// 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";
|
|
4299
|
+
import { MediaType as MediaType2, resolveSecurity as resolveSecurity5, resolveVisibility, UploadRequestKind as UploadRequestKind2 } from "@devultur/core";
|
|
4300
|
+
import { Hono as Hono10 } from "hono";
|
|
3426
4301
|
|
|
3427
|
-
// src/
|
|
3428
|
-
import {
|
|
3429
|
-
|
|
3430
|
-
|
|
3431
|
-
|
|
3432
|
-
|
|
3433
|
-
|
|
3434
|
-
|
|
3435
|
-
|
|
3436
|
-
|
|
3437
|
-
|
|
3438
|
-
|
|
3439
|
-
|
|
3440
|
-
|
|
4302
|
+
// src/object-lock/upload-lock.ts
|
|
4303
|
+
import { canChange as canChange3, resolveLock, validateLock as validateLock6 } from "@devultur/core";
|
|
4304
|
+
var failure3 = (status, code, error) => ({ status, code, error });
|
|
4305
|
+
function requestedLock(lock, hold) {
|
|
4306
|
+
if (hold !== void 0 && typeof hold !== "boolean") return failure3(400, "invalid_lock", "hold must be a boolean");
|
|
4307
|
+
if (lock === void 0 && hold === void 0) return void 0;
|
|
4308
|
+
const validation = validateLock6(lock ?? {});
|
|
4309
|
+
if (!validation.ok) return failure3(400, "invalid_lock", validation.errors.join("; "));
|
|
4310
|
+
return { ...validation.lock, ...hold !== void 0 ? { hold } : {} };
|
|
4311
|
+
}
|
|
4312
|
+
function satisfiesMinimum(minimum, lock, now) {
|
|
4313
|
+
if (minimum.hold && !lock.hold) return false;
|
|
4314
|
+
const change = canChange3(minimum, lock, { privileged: false, now, timing: { uploadedAt: now } });
|
|
4315
|
+
return change.ok && !change.bypass;
|
|
4316
|
+
}
|
|
4317
|
+
function resolveUploadLock(input) {
|
|
4318
|
+
const requested = requestedLock(input.lock, input.hold);
|
|
4319
|
+
if (requested && "code" in requested) return requested;
|
|
4320
|
+
const minimum = input.grant?.lock;
|
|
4321
|
+
const resolution = resolveLock(requested ?? minimum, input.appDefault);
|
|
4322
|
+
if (!resolution.ok) return failure3(400, "invalid_lock", resolution.errors.join("; "));
|
|
4323
|
+
const lock = resolution.lock;
|
|
4324
|
+
if (minimum && !satisfiesMinimum(minimum, lock, input.now)) {
|
|
4325
|
+
return failure3(403, "lock_below_minimum", "The upload token requires a stricter lock than the request asks for");
|
|
4326
|
+
}
|
|
4327
|
+
return lock.retention || lock.hold ? lock : void 0;
|
|
4328
|
+
}
|
|
4329
|
+
|
|
4330
|
+
// src/object-lock/upload-write.ts
|
|
4331
|
+
import { decodeEncryptionMeta as decodeEncryptionMeta8, resolveLock as resolveLock2 } from "@devultur/core";
|
|
4332
|
+
|
|
4333
|
+
// src/object-lock/job-outputs.ts
|
|
4334
|
+
import { isBearerOf, TranscodeStatus as TranscodeStatus4 } from "@devultur/core";
|
|
3441
4335
|
|
|
3442
4336
|
// src/routes/derived-upload.ts
|
|
3443
4337
|
var DERIVED_ASSET_KEY = /^(public|private)\/video\/[A-Za-z0-9-]+\/(hls|mp4|thumbnails|storyboard)\/[^/]+$/;
|
|
@@ -3451,18 +4345,109 @@ var DERIVED_CONTENT_TYPES = /* @__PURE__ */ new Set([
|
|
|
3451
4345
|
function isDerivedAssetKey(key) {
|
|
3452
4346
|
return DERIVED_ASSET_KEY.test(key);
|
|
3453
4347
|
}
|
|
4348
|
+
var RAW_LIST_LIMIT = 100;
|
|
4349
|
+
async function inheritedExpiresAt(context, key) {
|
|
4350
|
+
if (!isDerivedAssetKey(key)) return void 0;
|
|
4351
|
+
const videoFolder = key.split("/").slice(0, 3).join("/");
|
|
4352
|
+
const options = {
|
|
4353
|
+
prefix: `${getRequestState(context).storagePrefix}${videoFolder}/raw/`,
|
|
4354
|
+
limit: RAW_LIST_LIMIT,
|
|
4355
|
+
include: ["customMetadata"]
|
|
4356
|
+
};
|
|
4357
|
+
const listing = await context.env.MEDIA_BUCKET.list(options);
|
|
4358
|
+
const expiries = listing.objects.map((raw) => objectExpiresAt(raw.customMetadata)).filter((expiresAt) => expiresAt !== void 0);
|
|
4359
|
+
return expiries.length > 0 ? Math.min(...expiries) : void 0;
|
|
4360
|
+
}
|
|
3454
4361
|
async function derivedAssetPending(context, key) {
|
|
3455
4362
|
if (!isDerivedAssetKey(key) || !isAuthenticated(context)) return null;
|
|
3456
4363
|
const body = await context.req.json().catch(() => null);
|
|
3457
4364
|
const declared = typeof body?.contentType === "string" ? body.contentType : "";
|
|
3458
4365
|
const security = parseSecurityHeader(context.req.header(RESOURCE_SECURITY_HEADER));
|
|
4366
|
+
const expiresAt = await inheritedExpiresAt(context, key);
|
|
3459
4367
|
return {
|
|
3460
4368
|
contentType: DERIVED_CONTENT_TYPES.has(declared) ? declared : "application/octet-stream",
|
|
3461
4369
|
storageKey: storageKey(context, key),
|
|
3462
|
-
...security ? { security } : {}
|
|
4370
|
+
...security ? { security } : {},
|
|
4371
|
+
...expiresAt ? { expiresAt } : {}
|
|
3463
4372
|
};
|
|
3464
4373
|
}
|
|
3465
4374
|
|
|
4375
|
+
// src/object-lock/job-outputs.ts
|
|
4376
|
+
var VIDEO_ID = /^(?:public|private)\/video\/([^/]+)\//;
|
|
4377
|
+
var IN_PROGRESS = /* @__PURE__ */ new Set([TranscodeStatus4.QUEUED, TranscodeStatus4.RUNNING]);
|
|
4378
|
+
async function isInProgressJobOutput(context, key) {
|
|
4379
|
+
if (!isDerivedAssetKey(key)) return false;
|
|
4380
|
+
if (!isBearerOf(context.req.header("Authorization"), getRequestState(context).runnerCredential)) return false;
|
|
4381
|
+
const videoId = VIDEO_ID.exec(key)?.[1];
|
|
4382
|
+
if (!videoId) return false;
|
|
4383
|
+
const status = await readStatus(context.env.MEDIA_KV, videoId);
|
|
4384
|
+
return status !== null && IN_PROGRESS.has(status.status);
|
|
4385
|
+
}
|
|
4386
|
+
|
|
4387
|
+
// src/object-lock/upload-write.ts
|
|
4388
|
+
async function prepareUploadWrite(context, key, pending, fullKey) {
|
|
4389
|
+
const isLockExempt = await isInProgressJobOutput(context, key);
|
|
4390
|
+
const check = await checkWrite(context, key, fullKey, { isLockExempt });
|
|
4391
|
+
if ("blocked" in check) return check;
|
|
4392
|
+
const isCreateOnly = Boolean(pending?.isAsset && !pending.overwrite);
|
|
4393
|
+
if (isCreateOnly && check.exists) return { blocked: alreadyExists(context, key) };
|
|
4394
|
+
return check;
|
|
4395
|
+
}
|
|
4396
|
+
async function assetUploadConflict(context, key, fullKey, shouldOverwrite) {
|
|
4397
|
+
const check = await checkWrite(context, key, fullKey);
|
|
4398
|
+
if ("blocked" in check) return check.blocked;
|
|
4399
|
+
return check.exists && !shouldOverwrite ? alreadyExists(context, key) : null;
|
|
4400
|
+
}
|
|
4401
|
+
async function failedWrite(context, key, pending, enc) {
|
|
4402
|
+
const meta = decodeEncryptionMeta8(enc);
|
|
4403
|
+
const fullKey = pending?.storageKey ?? storageKey(context, key);
|
|
4404
|
+
if (context.env.KEYS_BUCKET && meta) await context.env.KEYS_BUCKET.delete(`${fullKey}/${meta.keyId}`);
|
|
4405
|
+
return pending?.isAsset && !pending.overwrite ? alreadyExists(context, key) : writeConflict(context, key);
|
|
4406
|
+
}
|
|
4407
|
+
function uploadLockOf(context, key, pending) {
|
|
4408
|
+
if (pending) return pending.lock;
|
|
4409
|
+
if (lockSubjectOf(key)?.kind !== "original") return void 0;
|
|
4410
|
+
const resolution = resolveLock2(void 0, getRequestState(context).config?.lock);
|
|
4411
|
+
if (!resolution.ok) {
|
|
4412
|
+
console.error("config.lock is invalid:", resolution.errors.join("; "));
|
|
4413
|
+
return { hold: true };
|
|
4414
|
+
}
|
|
4415
|
+
const { lock } = resolution;
|
|
4416
|
+
return lock.retention || lock.hold ? lock : void 0;
|
|
4417
|
+
}
|
|
4418
|
+
async function afterUploadWrite(context, key, pending, write, writtenEnc) {
|
|
4419
|
+
const fullKey = pending?.storageKey ?? storageKey(context, key);
|
|
4420
|
+
const root = fullKey.endsWith(key) ? fullKey.slice(0, -key.length) : getRequestState(context).storagePrefix;
|
|
4421
|
+
if (pending) await discardStaleVariants(context.env.MEDIA_BUCKET, pending);
|
|
4422
|
+
if (write.exists) await deleteReplacedKey(context, fullKey, write.replacedEnc, writtenEnc);
|
|
4423
|
+
const lock = uploadLockOf(context, key, pending);
|
|
4424
|
+
if (!lock) return;
|
|
4425
|
+
await recordLockAudit(context, {
|
|
4426
|
+
action: "set",
|
|
4427
|
+
keys: [key],
|
|
4428
|
+
before: null,
|
|
4429
|
+
after: lock,
|
|
4430
|
+
outcome: "applied",
|
|
4431
|
+
actor: pending ? pending.context ?? null : getRequestState(context).context ?? null,
|
|
4432
|
+
root
|
|
4433
|
+
});
|
|
4434
|
+
}
|
|
4435
|
+
|
|
4436
|
+
// src/routes/upload-multipart.ts
|
|
4437
|
+
import {
|
|
4438
|
+
AES_BLOCK_BYTES as AES_BLOCK_BYTES2,
|
|
4439
|
+
classifyUploadRequest,
|
|
4440
|
+
counterForBlock,
|
|
4441
|
+
decodeEncryptionMeta as decodeEncryptionMeta9,
|
|
4442
|
+
encryptBytes,
|
|
4443
|
+
MULTIPART_PART_SIZE_BYTES,
|
|
4444
|
+
MULTIPART_QUERY,
|
|
4445
|
+
multipartPartCount,
|
|
4446
|
+
multipartPartRange,
|
|
4447
|
+
resolveSecurity as resolveSecurity4,
|
|
4448
|
+
UploadRequestKind
|
|
4449
|
+
} from "@devultur/core";
|
|
4450
|
+
|
|
3466
4451
|
// src/routes/upload-pending.ts
|
|
3467
4452
|
var UPLOAD_PENDING_QUERY = "u";
|
|
3468
4453
|
var PENDING_ID_BYTES = 16;
|
|
@@ -3620,15 +4605,19 @@ async function handleMultipartCreate(context, key, target) {
|
|
|
3620
4605
|
if (pending.multipart) {
|
|
3621
4606
|
return context.json({ uploadId: pending.multipart.uploadId, partSize: pending.multipart.partSize, partCount });
|
|
3622
4607
|
}
|
|
3623
|
-
|
|
4608
|
+
const policy = resolveSecurity4(pending.security, getRequestState(context).config?.security);
|
|
4609
|
+
const shouldEncrypt = shouldEncryptAtRest(policy, key);
|
|
4610
|
+
if (isKeysBucketMissing(shouldEncrypt, context.env)) return keysBucketMissing(context);
|
|
4611
|
+
const write = await prepareUploadWrite(context, pending.key ?? key, pending, pending.storageKey);
|
|
4612
|
+
if ("blocked" in write) return write.blocked;
|
|
3624
4613
|
const customMetadata = {
|
|
3625
4614
|
...allowedUploadMetadata(pending.uploadMetadata),
|
|
3626
|
-
...integrityMetadata(declaredSha256, false, pending.expiresAt)
|
|
4615
|
+
...integrityMetadata(declaredSha256, false, pending.expiresAt),
|
|
4616
|
+
...lockMetadata(pending.lock)
|
|
3627
4617
|
};
|
|
3628
4618
|
if (pending.security) customMetadata.security = JSON.stringify(pending.security);
|
|
3629
|
-
const policy = resolveSecurity4(pending.security, getRequestState(context).config?.security);
|
|
3630
4619
|
let enc;
|
|
3631
|
-
if (context.env.KEYS_BUCKET
|
|
4620
|
+
if (shouldEncrypt && context.env.KEYS_BUCKET) {
|
|
3632
4621
|
({ enc } = await createObjectEncryption(context.env.KEYS_BUCKET, pending.storageKey, { ...customMetadata }));
|
|
3633
4622
|
customMetadata.enc = enc;
|
|
3634
4623
|
}
|
|
@@ -3661,7 +4650,7 @@ async function handleMultipartCreate(context, key, target) {
|
|
|
3661
4650
|
}
|
|
3662
4651
|
async function encryptPart(context, pending, multipart, partNumber, body) {
|
|
3663
4652
|
if (!multipart.enc) return body;
|
|
3664
|
-
const meta =
|
|
4653
|
+
const meta = decodeEncryptionMeta9(multipart.enc);
|
|
3665
4654
|
const material = meta ? await loadStorageKey(context.env.KEYS_BUCKET, pending.storageKey, meta.keyId) : null;
|
|
3666
4655
|
if (!meta || !material) return null;
|
|
3667
4656
|
const startBlock = (partNumber - 1) * multipart.partSize / AES_BLOCK_BYTES2;
|
|
@@ -3698,18 +4687,20 @@ async function handleMultipartComplete(context, key) {
|
|
|
3698
4687
|
const { pending, multipart, kvKey } = active;
|
|
3699
4688
|
const parts = normalizeCompletedParts(multipart, body?.parts);
|
|
3700
4689
|
if (!parts) return context.json({ error: "parts must list every part exactly once", code: "invalid_parts" }, 400);
|
|
3701
|
-
|
|
4690
|
+
const write = await prepareUploadWrite(context, pending.key ?? key, pending, pending.storageKey);
|
|
4691
|
+
if ("blocked" in write) return write.blocked;
|
|
3702
4692
|
const multipartUpload = context.env.MEDIA_BUCKET.resumeMultipartUpload(pending.storageKey, multipart.uploadId);
|
|
4693
|
+
if (!await hasEncryptionKey(context.env.KEYS_BUCKET, pending.storageKey, multipart.enc)) {
|
|
4694
|
+
await multipartUpload.abort();
|
|
4695
|
+
await context.env.MEDIA_KV.delete(kvKey);
|
|
4696
|
+
return encryptionKeyMissing(context, key);
|
|
4697
|
+
}
|
|
3703
4698
|
const object = await multipartUpload.complete(parts);
|
|
3704
4699
|
await context.env.MEDIA_KV.delete(kvKey);
|
|
3705
|
-
await
|
|
4700
|
+
await afterUploadWrite(context, key, pending, write, multipart.enc);
|
|
3706
4701
|
const integrity = multipart.sha256 ? { sha256: multipart.sha256, sha256Verified: false } : {};
|
|
3707
4702
|
return context.json({ key, size: object.size, ...integrity });
|
|
3708
4703
|
}
|
|
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
4704
|
async function checkPartSha256(context, multipart, body) {
|
|
3714
4705
|
const partSha256 = headerSha256(context);
|
|
3715
4706
|
if (partSha256 === null) {
|
|
@@ -3754,7 +4745,7 @@ async function handleMultipartAbort(context) {
|
|
|
3754
4745
|
}
|
|
3755
4746
|
|
|
3756
4747
|
// src/routes/upload.ts
|
|
3757
|
-
var upload = new
|
|
4748
|
+
var upload = new Hono10();
|
|
3758
4749
|
upload.post("/", async (context) => {
|
|
3759
4750
|
const body = await context.req.json();
|
|
3760
4751
|
const isAssetRequest = body.path !== void 0;
|
|
@@ -3798,6 +4789,14 @@ upload.post("/", async (context) => {
|
|
|
3798
4789
|
if (isUploadTargetError(sha256)) return targetFailure(context, sha256);
|
|
3799
4790
|
const cacheControl = resolveObjectCacheControl(body.cacheControl, target, scope.config?.cacheRules);
|
|
3800
4791
|
if (isUploadTargetError(cacheControl)) return targetFailure(context, cacheControl);
|
|
4792
|
+
const lock = resolveUploadLock({
|
|
4793
|
+
lock: body.lock,
|
|
4794
|
+
hold: body.hold,
|
|
4795
|
+
grant,
|
|
4796
|
+
appDefault: scope.config?.lock,
|
|
4797
|
+
now: Date.now()
|
|
4798
|
+
});
|
|
4799
|
+
if (isUploadTargetError(lock)) return targetFailure(context, lock);
|
|
3801
4800
|
const maxSizeBytes = effectiveMaxBytes(uploadRule?.maxSizeBytes, grant);
|
|
3802
4801
|
const declaredSize = Number(body.size);
|
|
3803
4802
|
if (maxSizeBytes != null && Number.isFinite(declaredSize) && declaredSize > maxSizeBytes) {
|
|
@@ -3805,15 +4804,17 @@ upload.post("/", async (context) => {
|
|
|
3805
4804
|
}
|
|
3806
4805
|
const virtualKey = target.key;
|
|
3807
4806
|
const fullKey = storageKey(context, virtualKey);
|
|
3808
|
-
const
|
|
3809
|
-
if (
|
|
3810
|
-
return
|
|
4807
|
+
const resolvedSecurity = resolveSecurity5(body.security, scope.config?.security);
|
|
4808
|
+
if (isKeysBucketMissing(shouldEncryptAtRest(resolvedSecurity, virtualKey), context.env)) {
|
|
4809
|
+
return keysBucketMissing(context);
|
|
3811
4810
|
}
|
|
4811
|
+
const shouldOverwrite = body.overwrite === true;
|
|
4812
|
+
const conflict = target.isAsset ? await assetUploadConflict(context, virtualKey, fullKey, shouldOverwrite) : null;
|
|
4813
|
+
if (conflict) return conflict;
|
|
3812
4814
|
if (grant) {
|
|
3813
4815
|
const exhausted = await claimTokenFileSlot(context.env.MEDIA_KV, grant);
|
|
3814
4816
|
if (exhausted) return targetFailure(context, exhausted);
|
|
3815
4817
|
}
|
|
3816
|
-
const resolvedSecurity = resolveSecurity5(body.security, scope.config?.security);
|
|
3817
4818
|
const pending = {
|
|
3818
4819
|
contentType: body.contentType,
|
|
3819
4820
|
storageKey: fullKey,
|
|
@@ -3828,7 +4829,8 @@ upload.post("/", async (context) => {
|
|
|
3828
4829
|
...expiresAt ? { expiresAt } : {},
|
|
3829
4830
|
...target.isAsset ? { isAsset: true, overwrite: shouldOverwrite } : {},
|
|
3830
4831
|
...cacheControl ? { cacheControl } : {},
|
|
3831
|
-
...scope.config?.uploadMetadata ? { uploadMetadata: scope.config.uploadMetadata } : {}
|
|
4832
|
+
...scope.config?.uploadMetadata ? { uploadMetadata: scope.config.uploadMetadata } : {},
|
|
4833
|
+
...lock ? { lock } : {}
|
|
3832
4834
|
};
|
|
3833
4835
|
const pendingId = newUploadPendingId();
|
|
3834
4836
|
await context.env.MEDIA_KV.put(uploadPendingKvKey(pendingId), JSON.stringify(pending), {
|
|
@@ -3883,25 +4885,30 @@ upload.put("/:key{.+}", async (context) => {
|
|
|
3883
4885
|
}
|
|
3884
4886
|
if ("mismatch" in digest) return sha256Mismatch(context, digest.mismatch.expected, digest.mismatch.actual);
|
|
3885
4887
|
const policy = resolveSecurity5(resourceSecurity, getRequestState(context).config?.security);
|
|
3886
|
-
const shouldEncrypt = shouldEncryptAtRest(policy, key
|
|
4888
|
+
const shouldEncrypt = shouldEncryptAtRest(policy, key);
|
|
4889
|
+
if (isKeysBucketMissing(shouldEncrypt, context.env)) return keysBucketMissing(context);
|
|
3887
4890
|
const customMetadata = {
|
|
3888
4891
|
...allowedUploadMetadata(pending?.uploadMetadata ?? getRequestState(context).config?.uploadMetadata),
|
|
3889
|
-
|
|
4892
|
+
// A transcode output (no pending) expires with its video's raw.
|
|
4893
|
+
...integrityMetadata(digest.sha256, true, pending?.expiresAt ?? await inheritedExpiresAt(context, key)),
|
|
4894
|
+
...lockMetadata(uploadLockOf(context, key, pending))
|
|
3890
4895
|
};
|
|
3891
4896
|
if (resourceSecurity) customMetadata.security = JSON.stringify(resourceSecurity);
|
|
4897
|
+
const write = await prepareUploadWrite(context, pending?.key ?? key, pending, finalStorageKey);
|
|
4898
|
+
if ("blocked" in write) return write.blocked;
|
|
3892
4899
|
let storedBody = body;
|
|
3893
4900
|
if (shouldEncrypt && context.env.KEYS_BUCKET) {
|
|
3894
4901
|
const result = await encryptForStorage(context.env.KEYS_BUCKET, finalStorageKey, body, customMetadata);
|
|
3895
4902
|
storedBody = result.body;
|
|
3896
4903
|
customMetadata.enc = result.enc;
|
|
3897
4904
|
}
|
|
3898
|
-
|
|
4905
|
+
if (!await hasEncryptionKey(context.env.KEYS_BUCKET, finalStorageKey, customMetadata.enc)) {
|
|
4906
|
+
return encryptionKeyMissing(context, key);
|
|
4907
|
+
}
|
|
3899
4908
|
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.
|
|
4909
|
+
onlyIf: write.onlyIf,
|
|
4910
|
+
// Standard, except a video raw the config keeps ≥30 days: Infrequent Access (30-day minimum, and a retrieval
|
|
4911
|
+
// charge on every read, the transcode's included; see `RAW_STORAGE_CLASS`).
|
|
3905
4912
|
storageClass,
|
|
3906
4913
|
httpMetadata: {
|
|
3907
4914
|
contentType,
|
|
@@ -3910,23 +4917,16 @@ upload.put("/:key{.+}", async (context) => {
|
|
|
3910
4917
|
},
|
|
3911
4918
|
...Object.keys(customMetadata).length > 0 ? { customMetadata } : {}
|
|
3912
4919
|
});
|
|
3913
|
-
if (
|
|
3914
|
-
await discardObjectKey(context.env.KEYS_BUCKET, finalStorageKey, customMetadata.enc);
|
|
3915
|
-
return alreadyExists(context, key);
|
|
3916
|
-
}
|
|
4920
|
+
if (stored === null) return failedWrite(context, key, pending, customMetadata.enc);
|
|
3917
4921
|
if (resolved) await context.env.MEDIA_KV.delete(resolved.kvKey);
|
|
3918
|
-
await
|
|
4922
|
+
await afterUploadWrite(context, key, pending, write, customMetadata.enc);
|
|
3919
4923
|
return context.json({ key, size: body.byteLength, sha256: digest.sha256, sha256Verified: true });
|
|
3920
4924
|
});
|
|
3921
|
-
async function discardObjectKey(keysBucket, objectStorageKey, enc) {
|
|
3922
|
-
const meta = decodeEncryptionMeta6(enc);
|
|
3923
|
-
if (keysBucket && meta) await keysBucket.delete(`${objectStorageKey}/${meta.keyId}`);
|
|
3924
|
-
}
|
|
3925
4925
|
|
|
3926
4926
|
// src/index.ts
|
|
3927
4927
|
function createWorker(options) {
|
|
3928
4928
|
const { middleware, routes, hooks } = composePlugins(options?.plugins ?? defaultPlugins());
|
|
3929
|
-
const app = new
|
|
4929
|
+
const app = new Hono11();
|
|
3930
4930
|
app.use("*", logger());
|
|
3931
4931
|
app.use(
|
|
3932
4932
|
"*",
|
|
@@ -3941,6 +4941,7 @@ function createWorker(options) {
|
|
|
3941
4941
|
context.set(PLUGIN_HOOKS_VARIABLE, hooks);
|
|
3942
4942
|
await next();
|
|
3943
4943
|
});
|
|
4944
|
+
app.use("/v1/*", rejectReservedLockPaths);
|
|
3944
4945
|
for (const handler of middleware) app.use("*", handler);
|
|
3945
4946
|
app.get("/health", (c) => c.json({ status: "ok" }));
|
|
3946
4947
|
app.post("/v1/metrics/playback", (c) => c.body(null, 204));
|
|
@@ -3951,6 +4952,7 @@ function createWorker(options) {
|
|
|
3951
4952
|
app.route("/v1/transcode", transcode);
|
|
3952
4953
|
app.route("/v1/captions", captions);
|
|
3953
4954
|
app.route("/v1/token", token);
|
|
4955
|
+
app.route("/v1/lock", lockRoute);
|
|
3954
4956
|
for (const route of routes) app.route(route.path, route.app);
|
|
3955
4957
|
app.notFound((c) => c.json({ error: "Not found" }, 404));
|
|
3956
4958
|
app.onError((err, c) => {
|
|
@@ -3961,10 +4963,20 @@ function createWorker(options) {
|
|
|
3961
4963
|
}
|
|
3962
4964
|
export {
|
|
3963
4965
|
DownloadLink,
|
|
4966
|
+
ENCRYPTION_KEY_MISSING_CODE,
|
|
4967
|
+
HOLD_PAGE_SIZE,
|
|
4968
|
+
KEYS_BUCKET_MISSING_CODE,
|
|
4969
|
+
LOCK_AUDIT_PREFIX,
|
|
4970
|
+
LOCK_HOLD_HEADER,
|
|
4971
|
+
LOCK_METADATA_KEY,
|
|
4972
|
+
LOCK_SIDECAR_PREFIX,
|
|
4973
|
+
LOCK_UNTIL_HEADER,
|
|
3964
4974
|
MAX_ASSET_LIST_LIMIT,
|
|
4975
|
+
MAX_EXPIRED_LOOKUPS,
|
|
3965
4976
|
MAX_UPLOAD_TOKEN_TTL_SECONDS,
|
|
3966
4977
|
MaxResolution,
|
|
3967
4978
|
ObjectMeta,
|
|
4979
|
+
RESERVED_LOCK_PREFIXES,
|
|
3968
4980
|
TOKEN_REJECTED_CODE,
|
|
3969
4981
|
UPLOAD_TOKEN_TYPE,
|
|
3970
4982
|
WorkerAuthErrorCode,
|