@devultur/self-host 1.5.2 → 1.6.1
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 +9 -1
- package/dist/{chunk-PQO3X57I.js → chunk-BPHMKSQ7.js} +1 -0
- package/dist/index.d.ts +132 -5
- package/dist/index.js +822 -196
- package/dist/transcode/container.js +1 -1
- package/docs/guides/plugins.md +13 -5
- package/docs/guides/self-host.md +25 -4
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
hashKey,
|
|
24
24
|
isAuthenticated,
|
|
25
25
|
isBearerAuthorized,
|
|
26
|
+
isConstantTimeEqual,
|
|
26
27
|
isContentTypeAllowed,
|
|
27
28
|
isServiceToken,
|
|
28
29
|
isTranscodeWebhookAuthorized,
|
|
@@ -41,7 +42,7 @@ import {
|
|
|
41
42
|
verifyJobLocation,
|
|
42
43
|
verifyJobTicket,
|
|
43
44
|
verifyViewerToken
|
|
44
|
-
} from "./chunk-
|
|
45
|
+
} from "./chunk-BPHMKSQ7.js";
|
|
45
46
|
import {
|
|
46
47
|
ImageTransformsMode,
|
|
47
48
|
WORKER_DECODABLE_TYPES,
|
|
@@ -62,13 +63,17 @@ import {
|
|
|
62
63
|
|
|
63
64
|
// src/index.ts
|
|
64
65
|
import {
|
|
66
|
+
ENCRYPTED_HEADER as ENCRYPTED_HEADER2,
|
|
65
67
|
EXPIRES_AT_HEADER as EXPIRES_AT_HEADER2,
|
|
68
|
+
GRANT_EXPIRES_AT_HEADER as GRANT_EXPIRES_AT_HEADER2,
|
|
69
|
+
GRANT_KEY_HEADER as GRANT_KEY_HEADER2,
|
|
70
|
+
GRANT_METADATA_HEADER as GRANT_METADATA_HEADER2,
|
|
66
71
|
REPR_DIGEST_HEADER as REPR_DIGEST_HEADER2,
|
|
67
72
|
SESSION_HEADER as SESSION_HEADER2,
|
|
68
73
|
SHA256_HEADER as SHA256_HEADER3,
|
|
69
74
|
SHA256_VERIFIED_HEADER as SHA256_VERIFIED_HEADER2
|
|
70
75
|
} from "@devultur/core";
|
|
71
|
-
import { Hono as
|
|
76
|
+
import { Hono as Hono13 } from "hono";
|
|
72
77
|
import { cors } from "hono/cors";
|
|
73
78
|
import { logger } from "hono/logger";
|
|
74
79
|
|
|
@@ -77,7 +82,13 @@ import { isVideoRawKey } from "@devultur/core";
|
|
|
77
82
|
|
|
78
83
|
// src/plugin-hooks.ts
|
|
79
84
|
var PLUGIN_HOOKS_VARIABLE = "pluginHooks";
|
|
80
|
-
var NO_HOOKS = {
|
|
85
|
+
var NO_HOOKS = {
|
|
86
|
+
issueCredential: [],
|
|
87
|
+
beforeLinkRedeem: [],
|
|
88
|
+
authorizeGrant: [],
|
|
89
|
+
authorizeLockChange: [],
|
|
90
|
+
onLockAudit: []
|
|
91
|
+
};
|
|
81
92
|
function getPluginHooks(context) {
|
|
82
93
|
return { ...NO_HOOKS, ...context.get(PLUGIN_HOOKS_VARIABLE) };
|
|
83
94
|
}
|
|
@@ -100,6 +111,13 @@ async function gateLinkRedemption(context, link) {
|
|
|
100
111
|
}
|
|
101
112
|
return null;
|
|
102
113
|
}
|
|
114
|
+
async function gateGrantAccess(context, request) {
|
|
115
|
+
for (const hook of getPluginHooks(context).authorizeGrant) {
|
|
116
|
+
const refusal2 = await hook(context, request);
|
|
117
|
+
if (refusal2) return refusal2;
|
|
118
|
+
}
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
103
121
|
async function decideLockChange(context, request) {
|
|
104
122
|
const hooks = getPluginHooks(context).authorizeLockChange;
|
|
105
123
|
for (const hook of hooks) {
|
|
@@ -700,6 +718,7 @@ function composePlugins(plugins) {
|
|
|
700
718
|
hooks: {
|
|
701
719
|
issueCredential: plugins.flatMap((plugin) => plugin.issueCredential ? [plugin.issueCredential] : []),
|
|
702
720
|
beforeLinkRedeem: plugins.flatMap((plugin) => plugin.beforeLinkRedeem ? [plugin.beforeLinkRedeem] : []),
|
|
721
|
+
authorizeGrant: plugins.flatMap((plugin) => plugin.authorizeGrant ? [plugin.authorizeGrant] : []),
|
|
703
722
|
authorizeLockChange: plugins.flatMap(
|
|
704
723
|
(plugin) => plugin.authorizeLockChange ? [plugin.authorizeLockChange] : []
|
|
705
724
|
),
|
|
@@ -721,19 +740,13 @@ function credentialPolicy(options = {}) {
|
|
|
721
740
|
}
|
|
722
741
|
|
|
723
742
|
// src/plugins/download-links.ts
|
|
724
|
-
import { Hono as
|
|
743
|
+
import { Hono as Hono5 } from "hono";
|
|
725
744
|
|
|
726
745
|
// src/routes/download.ts
|
|
727
746
|
import { contentDispositionAttachment as contentDispositionAttachment2, relativeMediaPath, TokenScope } from "@devultur/core";
|
|
728
747
|
import { Hono as Hono2 } from "hono";
|
|
729
748
|
|
|
730
749
|
// src/download-link.ts
|
|
731
|
-
var isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
732
|
-
function recordContext(record) {
|
|
733
|
-
if (record.context) return record.context;
|
|
734
|
-
const legacy = record.callerContext;
|
|
735
|
-
return isRecord(legacy) ? legacy : void 0;
|
|
736
|
-
}
|
|
737
750
|
var DownloadLinkPath = {
|
|
738
751
|
CREATE: "/create",
|
|
739
752
|
REDEEM: "/redeem",
|
|
@@ -792,6 +805,35 @@ var DownloadLink = class {
|
|
|
792
805
|
}
|
|
793
806
|
};
|
|
794
807
|
|
|
808
|
+
// src/grant-credential.ts
|
|
809
|
+
import { DOWNLOAD_GRANT_ID_PREFIX, DOWNLOAD_GRANT_PREFIX } from "@devultur/core";
|
|
810
|
+
var OBJECT_ID = "[0-9a-f]{64}";
|
|
811
|
+
var GRANT_ID = "[A-Za-z0-9_-]{22}";
|
|
812
|
+
var SECRET = "[A-Za-z0-9_-]{43}";
|
|
813
|
+
var SECRET_PATTERN = new RegExp(`^${DOWNLOAD_GRANT_PREFIX}(${OBJECT_ID})\\.(${GRANT_ID})\\.(${SECRET})$`);
|
|
814
|
+
var PUBLIC_ID_PATTERN = new RegExp(`^${DOWNLOAD_GRANT_ID_PREFIX}(${OBJECT_ID})\\.(${GRANT_ID})$`);
|
|
815
|
+
function randomBase64Url(bytes) {
|
|
816
|
+
const random = crypto.getRandomValues(new Uint8Array(bytes));
|
|
817
|
+
return btoa(String.fromCharCode(...random)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
818
|
+
}
|
|
819
|
+
var newGrantId = () => randomBase64Url(16);
|
|
820
|
+
var newGrantSecret = () => randomBase64Url(32);
|
|
821
|
+
function formatGrantSecret({ objectId, grantId, secret }) {
|
|
822
|
+
return `${DOWNLOAD_GRANT_PREFIX}${objectId}.${grantId}.${secret}`;
|
|
823
|
+
}
|
|
824
|
+
function formatGrantPublicId({ objectId, grantId }) {
|
|
825
|
+
return `${DOWNLOAD_GRANT_ID_PREFIX}${objectId}.${grantId}`;
|
|
826
|
+
}
|
|
827
|
+
function parseGrantSecret(value) {
|
|
828
|
+
const match = SECRET_PATTERN.exec(value);
|
|
829
|
+
return match ? { objectId: match[1], grantId: match[2], secret: match[3] } : null;
|
|
830
|
+
}
|
|
831
|
+
function parseGrantPublicId(value) {
|
|
832
|
+
const match = PUBLIC_ID_PATTERN.exec(value);
|
|
833
|
+
return match ? { objectId: match[1], grantId: match[2] } : null;
|
|
834
|
+
}
|
|
835
|
+
var hashGrantSecret = (secret) => hashKey(secret);
|
|
836
|
+
|
|
795
837
|
// src/route-result.ts
|
|
796
838
|
var ROUTE_RESULT_VARIABLE = "routeResult";
|
|
797
839
|
function getRouteResult(context) {
|
|
@@ -988,8 +1030,8 @@ function allowedUploadMetadata(metadata) {
|
|
|
988
1030
|
if (!metadata) return {};
|
|
989
1031
|
return Object.fromEntries(Object.entries(metadata).filter(([key]) => !isWorkerMetadataKey(key)));
|
|
990
1032
|
}
|
|
991
|
-
function targetFailure(context,
|
|
992
|
-
return context.json({ error:
|
|
1033
|
+
function targetFailure(context, failure5) {
|
|
1034
|
+
return context.json({ error: failure5.error, code: failure5.code }, failure5.status);
|
|
993
1035
|
}
|
|
994
1036
|
function alreadyExists(context, key) {
|
|
995
1037
|
return context.json(
|
|
@@ -1162,10 +1204,7 @@ var DEFAULT_LINK_TTL_SECONDS = 15 * 60;
|
|
|
1162
1204
|
var MAX_LINK_TTL_SECONDS = 7 * 24 * 60 * 60;
|
|
1163
1205
|
var MAX_LINK_USES = 100;
|
|
1164
1206
|
var LINK_ID_PATTERN = /^[A-Za-z0-9_-]{22}$/;
|
|
1165
|
-
|
|
1166
|
-
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
|
1167
|
-
return btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
1168
|
-
}
|
|
1207
|
+
var newLinkId = () => randomBase64Url(16);
|
|
1169
1208
|
function linkStub(namespace, linkId) {
|
|
1170
1209
|
return namespace.get(namespace.idFromName(linkId));
|
|
1171
1210
|
}
|
|
@@ -1205,9 +1244,8 @@ async function handleDownloadLink(context) {
|
|
|
1205
1244
|
});
|
|
1206
1245
|
if (!decision.allow) return decision.response;
|
|
1207
1246
|
const expiresAt = cappedExpiry(requestedExpiresAt, decision.expiresAt, (epochMs) => epochMs);
|
|
1208
|
-
const linkId = newLinkId();
|
|
1209
1247
|
const scope = getRequestState(context);
|
|
1210
|
-
const
|
|
1248
|
+
const created = await storeLink(context, namespace, {
|
|
1211
1249
|
storageKey: fullKey,
|
|
1212
1250
|
key: parsed.key,
|
|
1213
1251
|
expiresAt,
|
|
@@ -1215,14 +1253,18 @@ async function handleDownloadLink(context) {
|
|
|
1215
1253
|
...parsed.filename ? { filename: parsed.filename } : {},
|
|
1216
1254
|
storagePrefix: scope.storagePrefix,
|
|
1217
1255
|
...scope.context ? { context: scope.context } : {}
|
|
1218
|
-
};
|
|
1256
|
+
});
|
|
1257
|
+
if (!created) return context.json({ error: "Could not create the download link" }, 500);
|
|
1258
|
+
return context.json({ ...created, expiresAt, maxUses: parsed.maxUses });
|
|
1259
|
+
}
|
|
1260
|
+
async function storeLink(context, namespace, record) {
|
|
1261
|
+
const linkId = newLinkId();
|
|
1219
1262
|
const created = await linkStub(namespace, linkId).fetch(`https://download-link${DownloadLinkPath.CREATE}`, {
|
|
1220
1263
|
method: "POST",
|
|
1221
1264
|
body: JSON.stringify(record)
|
|
1222
1265
|
});
|
|
1223
|
-
if (!created.ok) return
|
|
1224
|
-
|
|
1225
|
-
return context.json({ url: `${origin}/v1/download/${linkId}`, expiresAt, maxUses: parsed.maxUses });
|
|
1266
|
+
if (!created.ok) return null;
|
|
1267
|
+
return { id: linkId, url: `${new URL(context.req.url).origin}/v1/download/${linkId}` };
|
|
1226
1268
|
}
|
|
1227
1269
|
function attachmentDisposition(record, storedDisposition) {
|
|
1228
1270
|
if (record.filename) return contentDispositionAttachment2(record.filename);
|
|
@@ -1232,8 +1274,11 @@ function attachmentDisposition(record, storedDisposition) {
|
|
|
1232
1274
|
var callLink = (stub, path) => stub.fetch(`https://download-link${path}`, { method: "POST" });
|
|
1233
1275
|
var relayLinkFailure = (response) => new Response(response.body, { status: response.status, headers: response.headers });
|
|
1234
1276
|
function linkInfoOf(record) {
|
|
1235
|
-
|
|
1236
|
-
|
|
1277
|
+
return {
|
|
1278
|
+
storagePrefix: record.storagePrefix,
|
|
1279
|
+
key: record.key,
|
|
1280
|
+
...record.context ? { context: record.context } : {}
|
|
1281
|
+
};
|
|
1237
1282
|
}
|
|
1238
1283
|
async function gateRedemption(context, stub) {
|
|
1239
1284
|
if (getPluginHooks(context).beforeLinkRedeem.length === 0) return null;
|
|
@@ -1255,7 +1300,7 @@ download.get("/:linkId", async (context) => {
|
|
|
1255
1300
|
if (!redeemed.ok) return relayLinkFailure(redeemed);
|
|
1256
1301
|
const record = await redeemed.json();
|
|
1257
1302
|
const object = await context.env.MEDIA_BUCKET.get(record.storageKey);
|
|
1258
|
-
if (!object) return context.json({ error: "
|
|
1303
|
+
if (!object) return context.json({ error: "This link's file was deleted", code: "file_gone" }, 410);
|
|
1259
1304
|
if (isObjectExpired(object.customMetadata)) return expiredResponse();
|
|
1260
1305
|
addRouteResult(context, { redeemedLink: linkInfoOf(record) });
|
|
1261
1306
|
const headers = new Headers({
|
|
@@ -1274,14 +1319,700 @@ download.get("/:linkId", async (context) => {
|
|
|
1274
1319
|
return response;
|
|
1275
1320
|
});
|
|
1276
1321
|
|
|
1322
|
+
// src/routes/grant-bearer.ts
|
|
1323
|
+
import {
|
|
1324
|
+
DownloadGrantErrorCode as DownloadGrantErrorCode2,
|
|
1325
|
+
DownloadGrantLimits,
|
|
1326
|
+
ENCRYPTED_HEADER,
|
|
1327
|
+
GRANT_EXPIRES_AT_HEADER,
|
|
1328
|
+
GRANT_KEY_HEADER,
|
|
1329
|
+
GRANT_METADATA_HEADER,
|
|
1330
|
+
TokenScope as TokenScope2
|
|
1331
|
+
} from "@devultur/core";
|
|
1332
|
+
import { Hono as Hono3 } from "hono";
|
|
1333
|
+
|
|
1334
|
+
// src/download-grants.ts
|
|
1335
|
+
import { DownloadGrantErrorCode } from "@devultur/core";
|
|
1336
|
+
var DownloadGrantPath = {
|
|
1337
|
+
/** Stores a new grant. Body: the record without `linksIssued`/`revokedAt`. */
|
|
1338
|
+
CREATE: "/create",
|
|
1339
|
+
/** The live grant for `{ id, secretHash }`. */
|
|
1340
|
+
AUTHORIZE: "/authorize",
|
|
1341
|
+
/** The grant for `{ id, secretHash }`, live or not: who it belongs to, before deciding anything. */
|
|
1342
|
+
IDENTIFY: "/identify",
|
|
1343
|
+
/** Spends one link of a live grant. Body: `{ id, secretHash, expiresIn, maxUses }`. */
|
|
1344
|
+
ISSUE: "/issue",
|
|
1345
|
+
/** Gives back a link spent by `ISSUE` whose creation failed. Body: `{ id }`. */
|
|
1346
|
+
REFUND: "/refund",
|
|
1347
|
+
/** Revokes `{ id }` (the owner) or `{ id, secretHash }` (the bearer). Idempotent. */
|
|
1348
|
+
REVOKE: "/revoke",
|
|
1349
|
+
/** One grant by `{ id }`, live or not, for the owner. */
|
|
1350
|
+
GET: "/get",
|
|
1351
|
+
/** Every grant of the file, for the owner. */
|
|
1352
|
+
LIST: "/list",
|
|
1353
|
+
/** Revokes every grant of the file: it's gone. They stay listed until they expire. */
|
|
1354
|
+
REVOKE_ALL: "/revoke-all"
|
|
1355
|
+
};
|
|
1356
|
+
var GRANT_KEY_PREFIX = "grant:";
|
|
1357
|
+
var grantKey = (id) => `${GRANT_KEY_PREFIX}${id}`;
|
|
1358
|
+
var failure = (status, code, error) => Response.json({ error, code }, { status });
|
|
1359
|
+
var invalidGrant = () => failure(401, DownloadGrantErrorCode.INVALID, "Invalid download grant");
|
|
1360
|
+
var grantGone = () => failure(410, DownloadGrantErrorCode.GONE, "This download grant is no longer valid");
|
|
1361
|
+
var notFound2 = () => failure(404, DownloadGrantErrorCode.NOT_FOUND, "Download grant not found");
|
|
1362
|
+
function grantInfoOf(record) {
|
|
1363
|
+
const { secretHash: _secretHash, ...info } = record;
|
|
1364
|
+
return info;
|
|
1365
|
+
}
|
|
1366
|
+
function isGrantDead(record, now = Date.now()) {
|
|
1367
|
+
return record.revokedAt !== null || record.expiresAt <= now;
|
|
1368
|
+
}
|
|
1369
|
+
function fitsLinkCaps(caps, expiresIn, maxUses) {
|
|
1370
|
+
return expiresIn <= caps.maxExpiresIn && maxUses <= caps.maxUses;
|
|
1371
|
+
}
|
|
1372
|
+
var DownloadGrants = class {
|
|
1373
|
+
state;
|
|
1374
|
+
storage;
|
|
1375
|
+
constructor(state) {
|
|
1376
|
+
this.state = state;
|
|
1377
|
+
this.storage = state.storage;
|
|
1378
|
+
}
|
|
1379
|
+
async fetch(request) {
|
|
1380
|
+
if (request.method !== "POST") return new Response(null, { status: 405 });
|
|
1381
|
+
const handler = this.handlerFor(new URL(request.url).pathname);
|
|
1382
|
+
if (!handler) return new Response(null, { status: 404 });
|
|
1383
|
+
const body = await request.json().catch(() => ({}));
|
|
1384
|
+
return this.state.blockConcurrencyWhile(() => handler(body));
|
|
1385
|
+
}
|
|
1386
|
+
handlerFor(pathname) {
|
|
1387
|
+
switch (pathname) {
|
|
1388
|
+
case DownloadGrantPath.CREATE:
|
|
1389
|
+
return (body) => this.create(body);
|
|
1390
|
+
case DownloadGrantPath.AUTHORIZE:
|
|
1391
|
+
return (body) => this.authorize(body);
|
|
1392
|
+
case DownloadGrantPath.IDENTIFY:
|
|
1393
|
+
return (body) => this.identify(body);
|
|
1394
|
+
case DownloadGrantPath.ISSUE:
|
|
1395
|
+
return (body) => this.issue(body);
|
|
1396
|
+
case DownloadGrantPath.REFUND:
|
|
1397
|
+
return (body) => this.refund(body);
|
|
1398
|
+
case DownloadGrantPath.REVOKE:
|
|
1399
|
+
return (body) => this.revoke(body);
|
|
1400
|
+
case DownloadGrantPath.GET:
|
|
1401
|
+
return (body) => this.getOne(body);
|
|
1402
|
+
case DownloadGrantPath.LIST:
|
|
1403
|
+
return () => this.list();
|
|
1404
|
+
case DownloadGrantPath.REVOKE_ALL:
|
|
1405
|
+
return () => this.revokeAll();
|
|
1406
|
+
default:
|
|
1407
|
+
return null;
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
async create(record) {
|
|
1411
|
+
if (await this.storage.get(grantKey(record.id))) {
|
|
1412
|
+
return failure(409, "grant_exists", "Grant id already in use");
|
|
1413
|
+
}
|
|
1414
|
+
await this.storage.put(grantKey(record.id), { ...record, linksIssued: 0, revokedAt: null });
|
|
1415
|
+
await this.scheduleAlarm();
|
|
1416
|
+
return Response.json({ ok: true }, { status: 201 });
|
|
1417
|
+
}
|
|
1418
|
+
async recordOf(id) {
|
|
1419
|
+
return typeof id === "string" ? this.storage.get(grantKey(id)) : void 0;
|
|
1420
|
+
}
|
|
1421
|
+
/** The record whose secret hash matches, live or not, or 401. */
|
|
1422
|
+
async matchingRecord(body) {
|
|
1423
|
+
const record = await this.recordOf(body.id);
|
|
1424
|
+
const secretHash = typeof body.secretHash === "string" ? body.secretHash : "";
|
|
1425
|
+
if (!record || !isConstantTimeEqual(secretHash, record.secretHash)) return invalidGrant();
|
|
1426
|
+
return record;
|
|
1427
|
+
}
|
|
1428
|
+
/** The live record whose secret hash matches, or the response explaining why not. */
|
|
1429
|
+
async liveRecord(body) {
|
|
1430
|
+
const record = await this.matchingRecord(body);
|
|
1431
|
+
if (record instanceof Response) return record;
|
|
1432
|
+
if (isGrantDead(record)) return grantGone();
|
|
1433
|
+
return record;
|
|
1434
|
+
}
|
|
1435
|
+
async identify(body) {
|
|
1436
|
+
const record = await this.matchingRecord(body);
|
|
1437
|
+
return record instanceof Response ? record : Response.json(grantInfoOf(record));
|
|
1438
|
+
}
|
|
1439
|
+
async authorize(body) {
|
|
1440
|
+
const record = await this.liveRecord(body);
|
|
1441
|
+
return record instanceof Response ? record : Response.json(grantInfoOf(record));
|
|
1442
|
+
}
|
|
1443
|
+
/** Spends one link: caps first (a refused link spends nothing), then the total. */
|
|
1444
|
+
async issue(body) {
|
|
1445
|
+
const record = await this.liveRecord(body);
|
|
1446
|
+
if (record instanceof Response) return record;
|
|
1447
|
+
if (!fitsLinkCaps(record.link, Number(body.expiresIn), Number(body.maxUses))) {
|
|
1448
|
+
return Response.json(
|
|
1449
|
+
{
|
|
1450
|
+
error: "The link exceeds the grant's caps",
|
|
1451
|
+
code: DownloadGrantErrorCode.LINK_EXCEEDS_GRANT,
|
|
1452
|
+
link: record.link
|
|
1453
|
+
},
|
|
1454
|
+
{ status: 400 }
|
|
1455
|
+
);
|
|
1456
|
+
}
|
|
1457
|
+
if (record.linksIssued >= record.maxLinks) {
|
|
1458
|
+
return failure(410, DownloadGrantErrorCode.EXHAUSTED, "This download grant has issued all its links");
|
|
1459
|
+
}
|
|
1460
|
+
const spent = { ...record, linksIssued: record.linksIssued + 1 };
|
|
1461
|
+
await this.storage.put(grantKey(record.id), spent);
|
|
1462
|
+
return Response.json(grantInfoOf(spent));
|
|
1463
|
+
}
|
|
1464
|
+
async refund(body) {
|
|
1465
|
+
const record = await this.recordOf(body.id);
|
|
1466
|
+
if (!record) return notFound2();
|
|
1467
|
+
await this.storage.put(grantKey(record.id), { ...record, linksIssued: Math.max(0, record.linksIssued - 1) });
|
|
1468
|
+
return new Response(null, { status: 204 });
|
|
1469
|
+
}
|
|
1470
|
+
/** Without `secretHash` the route already authorized the owner; with it, only the grant's own secret revokes it. */
|
|
1471
|
+
async revoke(body) {
|
|
1472
|
+
const record = await this.recordOf(body.id);
|
|
1473
|
+
if (!record) return body.secretHash === void 0 ? notFound2() : invalidGrant();
|
|
1474
|
+
const isBearer = body.secretHash !== void 0;
|
|
1475
|
+
if (isBearer && !isConstantTimeEqual(String(body.secretHash), record.secretHash)) return invalidGrant();
|
|
1476
|
+
if (record.revokedAt !== null) return Response.json(grantInfoOf(record));
|
|
1477
|
+
const revoked = { ...record, revokedAt: Date.now() };
|
|
1478
|
+
await this.storage.put(grantKey(record.id), revoked);
|
|
1479
|
+
return Response.json(grantInfoOf(revoked));
|
|
1480
|
+
}
|
|
1481
|
+
async getOne(body) {
|
|
1482
|
+
const record = await this.recordOf(body.id);
|
|
1483
|
+
return record ? Response.json(grantInfoOf(record)) : notFound2();
|
|
1484
|
+
}
|
|
1485
|
+
async records() {
|
|
1486
|
+
const stored = await this.storage.list({ prefix: GRANT_KEY_PREFIX });
|
|
1487
|
+
return [...stored.values()];
|
|
1488
|
+
}
|
|
1489
|
+
async list() {
|
|
1490
|
+
const records = await this.records();
|
|
1491
|
+
return Response.json(records.sort((first, second) => first.createdAt - second.createdAt).map(grantInfoOf));
|
|
1492
|
+
}
|
|
1493
|
+
async revokeAll() {
|
|
1494
|
+
const now = Date.now();
|
|
1495
|
+
for (const record of await this.records()) {
|
|
1496
|
+
if (record.revokedAt === null) await this.storage.put(grantKey(record.id), { ...record, revokedAt: now });
|
|
1497
|
+
}
|
|
1498
|
+
return new Response(null, { status: 204 });
|
|
1499
|
+
}
|
|
1500
|
+
/** Alarm at the earliest expiry among the stored grants; none left: no alarm. */
|
|
1501
|
+
async scheduleAlarm() {
|
|
1502
|
+
const expiries = (await this.records()).map((record) => record.expiresAt);
|
|
1503
|
+
if (expiries.length === 0) {
|
|
1504
|
+
await this.storage.deleteAlarm();
|
|
1505
|
+
return;
|
|
1506
|
+
}
|
|
1507
|
+
await this.storage.setAlarm(Math.min(...expiries));
|
|
1508
|
+
}
|
|
1509
|
+
/** Drops the grants past their expiry (revoked ones stay listed until then) and schedules the next alarm. */
|
|
1510
|
+
async alarm() {
|
|
1511
|
+
const now = Date.now();
|
|
1512
|
+
for (const record of await this.records()) {
|
|
1513
|
+
if (record.expiresAt <= now) await this.storage.delete(grantKey(record.id));
|
|
1514
|
+
}
|
|
1515
|
+
await this.scheduleAlarm();
|
|
1516
|
+
}
|
|
1517
|
+
};
|
|
1518
|
+
|
|
1519
|
+
// src/routes/grant-shared.ts
|
|
1520
|
+
function grantNamespaces(context) {
|
|
1521
|
+
const { DOWNLOAD_GRANTS: grants, DOWNLOAD_LINKS: links } = context.env;
|
|
1522
|
+
return grants && links ? { grants, links } : null;
|
|
1523
|
+
}
|
|
1524
|
+
function notConfigured2(context) {
|
|
1525
|
+
return context.json({ error: "Download grants are not configured", code: "not_configured" }, 501);
|
|
1526
|
+
}
|
|
1527
|
+
function fileGrantsObject(namespace, storageKey2) {
|
|
1528
|
+
const id = namespace.idFromName(storageKey2);
|
|
1529
|
+
return { objectId: id.toString(), stub: namespace.get(id) };
|
|
1530
|
+
}
|
|
1531
|
+
function grantsObjectById(namespace, objectId) {
|
|
1532
|
+
try {
|
|
1533
|
+
return namespace.get(namespace.idFromString(objectId));
|
|
1534
|
+
} catch {
|
|
1535
|
+
return null;
|
|
1536
|
+
}
|
|
1537
|
+
}
|
|
1538
|
+
function callGrants(stub, path, body = {}) {
|
|
1539
|
+
return stub.fetch(`https://download-grants${path}`, { method: "POST", body: JSON.stringify(body) });
|
|
1540
|
+
}
|
|
1541
|
+
var relayGrantFailure = (response) => new Response(response.body, { status: response.status, headers: response.headers });
|
|
1542
|
+
function grantListEntry(objectId, grant) {
|
|
1543
|
+
return {
|
|
1544
|
+
id: formatGrantPublicId({ objectId, grantId: grant.id }),
|
|
1545
|
+
key: grant.key,
|
|
1546
|
+
expiresAt: grant.expiresAt,
|
|
1547
|
+
link: grant.link,
|
|
1548
|
+
maxLinks: grant.maxLinks,
|
|
1549
|
+
canDelete: grant.canDelete,
|
|
1550
|
+
...grant.metadata ? { metadata: grant.metadata } : {},
|
|
1551
|
+
linksIssued: grant.linksIssued,
|
|
1552
|
+
createdAt: grant.createdAt,
|
|
1553
|
+
revokedAt: grant.revokedAt
|
|
1554
|
+
};
|
|
1555
|
+
}
|
|
1556
|
+
var isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1557
|
+
function parseMetadata(value, maxBytes) {
|
|
1558
|
+
if (value === void 0) return void 0;
|
|
1559
|
+
if (!isPlainObject(value)) return null;
|
|
1560
|
+
if (!Object.values(value).every((entry) => typeof entry === "string")) return null;
|
|
1561
|
+
const size = new TextEncoder().encode(JSON.stringify(value)).byteLength;
|
|
1562
|
+
return size <= maxBytes ? value : null;
|
|
1563
|
+
}
|
|
1564
|
+
function parseCount(value, fallback2, max) {
|
|
1565
|
+
const count = value ?? fallback2;
|
|
1566
|
+
return typeof count === "number" && Number.isSafeInteger(count) && count > 0 && count <= max ? count : null;
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
// src/routes/media-delete.ts
|
|
1570
|
+
import { videoBasePath } from "@devultur/core";
|
|
1571
|
+
|
|
1572
|
+
// src/transcode/raw-retention.ts
|
|
1573
|
+
import { isVideoRawKey as isVideoRawKey2 } from "@devultur/core";
|
|
1574
|
+
var disposalKey = (videoId) => `${KvPrefix.TRANSCODE_RAW}:${videoId}`;
|
|
1575
|
+
async function readRawDisposal(kv, videoId) {
|
|
1576
|
+
return kv.get(disposalKey(videoId), "json");
|
|
1577
|
+
}
|
|
1578
|
+
async function rememberRawDisposal(kv, videoId, disposal) {
|
|
1579
|
+
await kv.put(disposalKey(videoId), JSON.stringify(disposal), { expirationTtl: KV_JOB_TTL_SECONDS });
|
|
1580
|
+
}
|
|
1581
|
+
async function disposeRawAfterTranscode(context, videoId) {
|
|
1582
|
+
const { env } = context;
|
|
1583
|
+
const disposal = await readRawDisposal(env.MEDIA_KV, videoId);
|
|
1584
|
+
if (!disposal?.deleteOnComplete || !disposal.storageKey) return "kept";
|
|
1585
|
+
const raw = await env.MEDIA_BUCKET.head(disposal.storageKey);
|
|
1586
|
+
const location = splitVideoStorageKey(disposal.storageKey);
|
|
1587
|
+
const lock = raw && location ? await effectiveLock(env.MEDIA_BUCKET, location.root, location.key, raw) : null;
|
|
1588
|
+
if (lock?.state && isStateLocked(lock.state, /* @__PURE__ */ new Date())) {
|
|
1589
|
+
await recordLockAudit(context, {
|
|
1590
|
+
action: "original_retained",
|
|
1591
|
+
keys: [location?.key ?? disposal.storageKey],
|
|
1592
|
+
before: lock.state.lock,
|
|
1593
|
+
after: null,
|
|
1594
|
+
outcome: "skipped",
|
|
1595
|
+
root: location?.root ?? ""
|
|
1596
|
+
});
|
|
1597
|
+
return "retained";
|
|
1598
|
+
}
|
|
1599
|
+
await env.MEDIA_BUCKET.delete(disposal.storageKey);
|
|
1600
|
+
if (env.KEYS_BUCKET) await deletePrefix(env.KEYS_BUCKET, `${disposal.storageKey}/`);
|
|
1601
|
+
return "deleted";
|
|
1602
|
+
}
|
|
1603
|
+
var ORIGINAL_NOT_RETAINED = {
|
|
1604
|
+
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.",
|
|
1605
|
+
code: "original_not_retained"
|
|
1606
|
+
};
|
|
1607
|
+
var PRIOR_OUTPUTS_LIST_LIMIT = 100;
|
|
1608
|
+
async function hasPriorTranscodeOutputs(bucket, videoFolder) {
|
|
1609
|
+
const listing = await bucket.list({ prefix: videoFolder, limit: PRIOR_OUTPUTS_LIST_LIMIT });
|
|
1610
|
+
return listing.objects.some((object) => !isVideoRawKey2(object.key));
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
// src/object-lock/transcode-marker.ts
|
|
1614
|
+
var MARKER_NAME = "transcode-completed";
|
|
1615
|
+
var markerKey = (root, videoFolder) => sidecarKey(root, `${videoFolder}/${MARKER_NAME}`);
|
|
1616
|
+
async function markTranscodeCompleted(env, videoId, ticketLocation) {
|
|
1617
|
+
const location = ticketLocation ?? await rememberedLocation(env, videoId);
|
|
1618
|
+
if (!location) return;
|
|
1619
|
+
await env.MEDIA_BUCKET.put(markerKey(location.root, location.folder), (/* @__PURE__ */ new Date()).toISOString(), {
|
|
1620
|
+
httpMetadata: { contentType: "text/plain" }
|
|
1621
|
+
});
|
|
1622
|
+
}
|
|
1623
|
+
async function rememberedLocation(env, videoId) {
|
|
1624
|
+
const disposal = await readRawDisposal(env.MEDIA_KV, videoId);
|
|
1625
|
+
const raw = disposal?.storageKey ? splitVideoStorageKey(disposal.storageKey) : null;
|
|
1626
|
+
const folder = raw?.key.split("/").slice(0, 3).join("/");
|
|
1627
|
+
return raw && folder ? { root: raw.root, folder } : null;
|
|
1628
|
+
}
|
|
1629
|
+
async function hasCompletedTranscode(bucket, root, videoFolder) {
|
|
1630
|
+
return await bucket.head(markerKey(root, videoFolder)) !== null;
|
|
1631
|
+
}
|
|
1632
|
+
async function forgetTranscodeCompleted(bucket, root, videoFolder) {
|
|
1633
|
+
await bucket.delete(markerKey(root, videoFolder));
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
// src/routes/media-delete.ts
|
|
1637
|
+
async function handleVideoDelete(context) {
|
|
1638
|
+
const videoId = context.req.param("videoId") ?? "";
|
|
1639
|
+
const visibilityParam = context.req.query("visibility");
|
|
1640
|
+
const visibilities = visibilityParam === "public" || visibilityParam === "private" ? [visibilityParam] : ["public", "private"];
|
|
1641
|
+
const folders = visibilities.map((visibility) => `${visibility}/${videoBasePath(videoId)}`);
|
|
1642
|
+
const blocked = await assertWritable(context, folders, "delete");
|
|
1643
|
+
if (blocked) return blocked;
|
|
1644
|
+
let deletedCount = 0;
|
|
1645
|
+
let deletedKeyCount = 0;
|
|
1646
|
+
for (const folder of folders) {
|
|
1647
|
+
const deleted = await guardedDeletePrefix(context, `${folder}/`);
|
|
1648
|
+
if ("blocked" in deleted) return deleted.blocked;
|
|
1649
|
+
deletedCount += deleted.deletedCount;
|
|
1650
|
+
if (deleted.deletedCount > 0) {
|
|
1651
|
+
await forgetTranscodeCompleted(context.env.MEDIA_BUCKET, getRequestState(context).storagePrefix, folder);
|
|
1652
|
+
}
|
|
1653
|
+
if (!folder.startsWith("private/")) continue;
|
|
1654
|
+
const deletedKeys = await deleteFolderKeys(context, folder);
|
|
1655
|
+
if ("blocked" in deletedKeys) return deletedKeys.blocked;
|
|
1656
|
+
deletedKeyCount += deletedKeys.deletedCount;
|
|
1657
|
+
}
|
|
1658
|
+
return context.json({ videoId, deletedCount, deletedKeyCount });
|
|
1659
|
+
}
|
|
1660
|
+
function handleKeyDelete(context) {
|
|
1661
|
+
return deleteMediaKey(context, context.req.param("key") ?? "");
|
|
1662
|
+
}
|
|
1663
|
+
async function deleteMediaKey(context, key) {
|
|
1664
|
+
const fullKey = storageKey(context, key);
|
|
1665
|
+
if (isVariantKey(key)) {
|
|
1666
|
+
return context.json({ error: "Not found" }, 404);
|
|
1667
|
+
}
|
|
1668
|
+
if (isReservedLockKey(key)) return reservedKeyResponse(context, key);
|
|
1669
|
+
const exists = await context.env.MEDIA_BUCKET.head(fullKey);
|
|
1670
|
+
if (!exists) {
|
|
1671
|
+
return context.json({ error: "Not found" }, 404);
|
|
1672
|
+
}
|
|
1673
|
+
const blocked = await assertWritable(context, [key], "delete");
|
|
1674
|
+
if (blocked) return blocked;
|
|
1675
|
+
await context.env.MEDIA_BUCKET.delete(fullKey);
|
|
1676
|
+
const variants = await guardedDeletePrefix(context, variantPrefix(key));
|
|
1677
|
+
if ("blocked" in variants) return variants.blocked;
|
|
1678
|
+
const keys = await deleteKeysFor(context, key, exists);
|
|
1679
|
+
if ("blocked" in keys) return keys.blocked;
|
|
1680
|
+
return new Response(null, { status: 204 });
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
// src/routes/grant-bearer.ts
|
|
1684
|
+
var grantError = (status, code, error) => Response.json({ error, code }, { status });
|
|
1685
|
+
var invalidGrant2 = () => grantError(401, DownloadGrantErrorCode2.INVALID, "Invalid download grant");
|
|
1686
|
+
var grantGone2 = () => grantError(410, DownloadGrantErrorCode2.GONE, "This download grant is no longer valid");
|
|
1687
|
+
async function readBearer(context, grants) {
|
|
1688
|
+
const bearer = context.req.header("Authorization")?.match(/^Bearer\s+(\S+)$/i)?.[1];
|
|
1689
|
+
if (!bearer && context.req.query("token") !== void 0) {
|
|
1690
|
+
return grantError(401, DownloadGrantErrorCode2.IN_QUERY, "Send the grant in Authorization: Bearer, never in a URL");
|
|
1691
|
+
}
|
|
1692
|
+
const parsed = bearer ? parseGrantSecret(bearer) : null;
|
|
1693
|
+
const stub = parsed ? grantsObjectById(grants, parsed.objectId) : null;
|
|
1694
|
+
if (!parsed || !stub) return invalidGrant2();
|
|
1695
|
+
return {
|
|
1696
|
+
stub,
|
|
1697
|
+
parsed,
|
|
1698
|
+
secretHash: await hashGrantSecret(parsed.secret),
|
|
1699
|
+
publicId: formatGrantPublicId(parsed)
|
|
1700
|
+
};
|
|
1701
|
+
}
|
|
1702
|
+
async function identifyDownloadGrant(anyContext) {
|
|
1703
|
+
const context = anyContext;
|
|
1704
|
+
const namespaces = grantNamespaces(context);
|
|
1705
|
+
if (!namespaces) return notConfigured2(context);
|
|
1706
|
+
const bearer = await readBearer(context, namespaces.grants);
|
|
1707
|
+
if (bearer instanceof Response) return bearer;
|
|
1708
|
+
const identified = await callGrants(bearer.stub, DownloadGrantPath.IDENTIFY, {
|
|
1709
|
+
id: bearer.parsed.grantId,
|
|
1710
|
+
secretHash: bearer.secretHash
|
|
1711
|
+
});
|
|
1712
|
+
if (!identified.ok) return relayGrantFailure(identified);
|
|
1713
|
+
return { publicId: bearer.publicId, grant: await identified.json() };
|
|
1714
|
+
}
|
|
1715
|
+
async function grantedObject(context, bearer, grant) {
|
|
1716
|
+
const object = await context.env.MEDIA_BUCKET.head(grant.storageKey);
|
|
1717
|
+
if (!object) {
|
|
1718
|
+
await callGrants(bearer.stub, DownloadGrantPath.REVOKE_ALL);
|
|
1719
|
+
return grantGone2();
|
|
1720
|
+
}
|
|
1721
|
+
if (object.version !== grant.version || isObjectExpired(object.customMetadata)) return grantGone2();
|
|
1722
|
+
return object;
|
|
1723
|
+
}
|
|
1724
|
+
async function authorizeGrant(context, action) {
|
|
1725
|
+
const namespaces = grantNamespaces(context);
|
|
1726
|
+
if (!namespaces) return notConfigured2(context);
|
|
1727
|
+
const bearer = await readBearer(context, namespaces.grants);
|
|
1728
|
+
if (bearer instanceof Response) return bearer;
|
|
1729
|
+
const authorized = await callGrants(bearer.stub, DownloadGrantPath.AUTHORIZE, {
|
|
1730
|
+
id: bearer.parsed.grantId,
|
|
1731
|
+
secretHash: bearer.secretHash
|
|
1732
|
+
});
|
|
1733
|
+
if (!authorized.ok) return relayGrantFailure(authorized);
|
|
1734
|
+
const grant = await authorized.json();
|
|
1735
|
+
if (grant.storagePrefix !== getRequestState(context).storagePrefix) return invalidGrant2();
|
|
1736
|
+
const refusal2 = await gateGrantAccess(context, { action, grant });
|
|
1737
|
+
if (refusal2) return refusal2;
|
|
1738
|
+
const object = await grantedObject(context, bearer, grant);
|
|
1739
|
+
if (object instanceof Response) return object;
|
|
1740
|
+
return { ...bearer, grant, object };
|
|
1741
|
+
}
|
|
1742
|
+
function headHeaders({ grant, object }) {
|
|
1743
|
+
const headers = new Headers({
|
|
1744
|
+
"Content-Type": object.httpMetadata?.contentType ?? "application/octet-stream",
|
|
1745
|
+
"Content-Length": String(object.size),
|
|
1746
|
+
ETag: object.httpEtag,
|
|
1747
|
+
"Cache-Control": "private, no-store",
|
|
1748
|
+
[ENCRYPTED_HEADER]: object.customMetadata?.enc ? "true" : "false",
|
|
1749
|
+
[GRANT_EXPIRES_AT_HEADER]: String(grant.expiresAt),
|
|
1750
|
+
[GRANT_KEY_HEADER]: grant.key
|
|
1751
|
+
});
|
|
1752
|
+
applyIntegrityHeaders(headers, object.customMetadata);
|
|
1753
|
+
applyImageSizeHeaders(headers, object.customMetadata);
|
|
1754
|
+
if (grant.metadata) headers.set(GRANT_METADATA_HEADER, encodeURIComponent(JSON.stringify(grant.metadata)));
|
|
1755
|
+
return headers;
|
|
1756
|
+
}
|
|
1757
|
+
async function handleHead(context) {
|
|
1758
|
+
if (context.req.method !== "HEAD") return new Response(null, { status: 405, headers: { Allow: "HEAD" } });
|
|
1759
|
+
const authorized = await authorizeGrant(context, "head");
|
|
1760
|
+
if (authorized instanceof Response) return authorized;
|
|
1761
|
+
return new Response(null, { status: 200, headers: headHeaders(authorized) });
|
|
1762
|
+
}
|
|
1763
|
+
function parseGrantLinkRequest(body, grant) {
|
|
1764
|
+
const defaultExpiresIn = Math.min(DownloadGrantLimits.DEFAULT_LINK_EXPIRES_IN, grant.link.maxExpiresIn);
|
|
1765
|
+
const expiresIn = parseCount(body.expiresIn, defaultExpiresIn, MAX_LINK_TTL_SECONDS);
|
|
1766
|
+
if (expiresIn === null)
|
|
1767
|
+
return { error: "expiresIn must be a positive number of seconds", code: "invalid_expires_in" };
|
|
1768
|
+
const maxUses = parseCount(body.maxUses, 1, Number.MAX_SAFE_INTEGER);
|
|
1769
|
+
if (maxUses === null) return { error: "maxUses must be a positive integer", code: "invalid_max_uses" };
|
|
1770
|
+
const metadata = parseMetadata(body.metadata, DownloadGrantLimits.MAX_METADATA_BYTES);
|
|
1771
|
+
if (metadata === null)
|
|
1772
|
+
return { error: "metadata must map strings to strings, 1 KB at most", code: "invalid_metadata" };
|
|
1773
|
+
const filename = typeof body.filename === "string" && body.filename ? body.filename : void 0;
|
|
1774
|
+
const parsed = {
|
|
1775
|
+
expiresIn,
|
|
1776
|
+
maxUses,
|
|
1777
|
+
...filename ? { filename } : {},
|
|
1778
|
+
...metadata ? { metadata } : {}
|
|
1779
|
+
};
|
|
1780
|
+
return parsed;
|
|
1781
|
+
}
|
|
1782
|
+
async function handleIssueLink(context) {
|
|
1783
|
+
const namespaces = grantNamespaces(context);
|
|
1784
|
+
if (!namespaces) return notConfigured2(context);
|
|
1785
|
+
const authorized = await authorizeGrant(context, "issue-link");
|
|
1786
|
+
if (authorized instanceof Response) return authorized;
|
|
1787
|
+
const { grant, object, stub } = authorized;
|
|
1788
|
+
const parsed = parseGrantLinkRequest(await context.req.json().catch(() => null) ?? {}, grant);
|
|
1789
|
+
if ("error" in parsed) return context.json(parsed, 400);
|
|
1790
|
+
const requestedExpiresAt = Math.min(
|
|
1791
|
+
Date.now() + parsed.expiresIn * 1e3,
|
|
1792
|
+
objectExpiresAt(object.customMetadata) ?? Number.POSITIVE_INFINITY,
|
|
1793
|
+
grant.expiresAt
|
|
1794
|
+
);
|
|
1795
|
+
const decision = await decideCredential(context, {
|
|
1796
|
+
kind: "download-link",
|
|
1797
|
+
scopes: [TokenScope2.MEDIA_READ],
|
|
1798
|
+
path: grant.key,
|
|
1799
|
+
expiresAt: requestedExpiresAt
|
|
1800
|
+
});
|
|
1801
|
+
if (!decision.allow) return decision.response;
|
|
1802
|
+
const credential = { id: authorized.parsed.grantId, secretHash: authorized.secretHash };
|
|
1803
|
+
const spent = await callGrants(stub, DownloadGrantPath.ISSUE, { ...credential, ...parsed });
|
|
1804
|
+
if (!spent.ok) return relayGrantFailure(spent);
|
|
1805
|
+
const expiresAt = cappedExpiry(requestedExpiresAt, decision.expiresAt, (epochMs) => epochMs);
|
|
1806
|
+
const created = await storeLink(context, namespaces.links, {
|
|
1807
|
+
storageKey: grant.storageKey,
|
|
1808
|
+
key: grant.key,
|
|
1809
|
+
expiresAt,
|
|
1810
|
+
maxUses: parsed.maxUses,
|
|
1811
|
+
...parsed.filename ? { filename: parsed.filename } : {},
|
|
1812
|
+
storagePrefix: grant.storagePrefix,
|
|
1813
|
+
...grant.context ? { context: grant.context } : {},
|
|
1814
|
+
grantId: authorized.publicId,
|
|
1815
|
+
...parsed.metadata ? { metadata: parsed.metadata } : {}
|
|
1816
|
+
});
|
|
1817
|
+
if (!created) {
|
|
1818
|
+
await callGrants(stub, DownloadGrantPath.REFUND, { id: credential.id });
|
|
1819
|
+
return context.json({ error: "Could not create the download link" }, 500);
|
|
1820
|
+
}
|
|
1821
|
+
return context.json({ ...created, expiresAt, maxUses: parsed.maxUses });
|
|
1822
|
+
}
|
|
1823
|
+
async function handleDeleteFile(context) {
|
|
1824
|
+
const authorized = await authorizeGrant(context, "delete-file");
|
|
1825
|
+
if (authorized instanceof Response) return authorized;
|
|
1826
|
+
const { grant, stub, publicId } = authorized;
|
|
1827
|
+
if (!grant.canDelete) {
|
|
1828
|
+
return grantError(403, DownloadGrantErrorCode2.CANNOT_DELETE, "This download grant can't delete its file");
|
|
1829
|
+
}
|
|
1830
|
+
const deleted = await deleteMediaKey(context, grant.key);
|
|
1831
|
+
if (deleted.status !== 204) return deleted;
|
|
1832
|
+
await callGrants(stub, DownloadGrantPath.REVOKE_ALL);
|
|
1833
|
+
addRouteResult(context, {
|
|
1834
|
+
grantDeletedKey: grant.key,
|
|
1835
|
+
revokedGrant: { publicId, grant: { ...grant, revokedAt: Date.now() }, by: "bearer" }
|
|
1836
|
+
});
|
|
1837
|
+
return deleted;
|
|
1838
|
+
}
|
|
1839
|
+
async function handleRevoke(context) {
|
|
1840
|
+
const namespaces = grantNamespaces(context);
|
|
1841
|
+
if (!namespaces) return notConfigured2(context);
|
|
1842
|
+
const bearer = await readBearer(context, namespaces.grants);
|
|
1843
|
+
if (bearer instanceof Response) return bearer;
|
|
1844
|
+
const revoked = await callGrants(bearer.stub, DownloadGrantPath.REVOKE, {
|
|
1845
|
+
id: bearer.parsed.grantId,
|
|
1846
|
+
secretHash: bearer.secretHash
|
|
1847
|
+
});
|
|
1848
|
+
if (!revoked.ok) return relayGrantFailure(revoked);
|
|
1849
|
+
const grant = await revoked.json();
|
|
1850
|
+
if (grant.storagePrefix === getRequestState(context).storagePrefix) {
|
|
1851
|
+
addRouteResult(context, { revokedGrant: { publicId: bearer.publicId, grant, by: "bearer" } });
|
|
1852
|
+
}
|
|
1853
|
+
return new Response(null, { status: 204 });
|
|
1854
|
+
}
|
|
1855
|
+
var grantBearer = new Hono3().get("/file", handleHead).post("/links", handleIssueLink).delete("/file", handleDeleteFile).delete("/", handleRevoke);
|
|
1856
|
+
|
|
1857
|
+
// src/routes/grant-owner.ts
|
|
1858
|
+
import { DownloadGrantLimits as DownloadGrantLimits2, relativeMediaPath as relativeMediaPath2, TokenScope as TokenScope3 } from "@devultur/core";
|
|
1859
|
+
import { Hono as Hono4 } from "hono";
|
|
1860
|
+
var GRANT_DELETE_SCOPE = "media:delete";
|
|
1861
|
+
var invalid = (code, error) => ({ error, code });
|
|
1862
|
+
function parseLinkCaps(link) {
|
|
1863
|
+
const maxExpiresIn = parseCount(
|
|
1864
|
+
link?.maxExpiresIn,
|
|
1865
|
+
DownloadGrantLimits2.DEFAULT_LINK_EXPIRES_IN,
|
|
1866
|
+
DownloadGrantLimits2.MAX_LINK_EXPIRES_IN
|
|
1867
|
+
);
|
|
1868
|
+
if (maxExpiresIn === null) {
|
|
1869
|
+
return invalid("invalid_link_caps", `link.maxExpiresIn must be 1..${DownloadGrantLimits2.MAX_LINK_EXPIRES_IN}`);
|
|
1870
|
+
}
|
|
1871
|
+
const maxUses = parseCount(
|
|
1872
|
+
link?.maxUses,
|
|
1873
|
+
DownloadGrantLimits2.DEFAULT_LINK_MAX_USES,
|
|
1874
|
+
DownloadGrantLimits2.MAX_LINK_MAX_USES
|
|
1875
|
+
);
|
|
1876
|
+
if (maxUses === null) {
|
|
1877
|
+
return invalid("invalid_link_caps", `link.maxUses must be 1..${DownloadGrantLimits2.MAX_LINK_MAX_USES}`);
|
|
1878
|
+
}
|
|
1879
|
+
return { maxExpiresIn, maxUses };
|
|
1880
|
+
}
|
|
1881
|
+
function parseGrantRequest(body) {
|
|
1882
|
+
const key = typeof body.key === "string" ? body.key : "";
|
|
1883
|
+
if (relativeMediaPath2(key) === null) return invalid("invalid_key", "key must be a visibility-prefixed media key");
|
|
1884
|
+
const expiresIn = body.expiresIn === void 0 ? void 0 : parseCount(body.expiresIn, 0, Number.MAX_SAFE_INTEGER);
|
|
1885
|
+
if (expiresIn === null) return invalid("invalid_expires_in", "expiresIn must be a positive number of seconds");
|
|
1886
|
+
if (body.link !== void 0 && (typeof body.link !== "object" || body.link === null)) {
|
|
1887
|
+
return invalid("invalid_link_caps", "link must be an object");
|
|
1888
|
+
}
|
|
1889
|
+
const link = parseLinkCaps(body.link);
|
|
1890
|
+
if ("error" in link) return link;
|
|
1891
|
+
const maxLinks = parseCount(body.maxLinks, DownloadGrantLimits2.DEFAULT_MAX_LINKS, DownloadGrantLimits2.MAX_MAX_LINKS);
|
|
1892
|
+
if (maxLinks === null)
|
|
1893
|
+
return invalid("invalid_max_links", `maxLinks must be 1..${DownloadGrantLimits2.MAX_MAX_LINKS}`);
|
|
1894
|
+
if (body.canDelete !== void 0 && typeof body.canDelete !== "boolean") {
|
|
1895
|
+
return invalid("invalid_can_delete", "canDelete must be a boolean");
|
|
1896
|
+
}
|
|
1897
|
+
const metadata = parseMetadata(body.metadata, DownloadGrantLimits2.MAX_METADATA_BYTES);
|
|
1898
|
+
if (metadata === null) {
|
|
1899
|
+
return invalid(
|
|
1900
|
+
"invalid_metadata",
|
|
1901
|
+
`metadata must map strings to strings, ${DownloadGrantLimits2.MAX_METADATA_BYTES} bytes at most`
|
|
1902
|
+
);
|
|
1903
|
+
}
|
|
1904
|
+
return {
|
|
1905
|
+
key,
|
|
1906
|
+
...expiresIn ? { expiresIn } : {},
|
|
1907
|
+
link,
|
|
1908
|
+
maxLinks,
|
|
1909
|
+
canDelete: body.canDelete === true,
|
|
1910
|
+
...metadata ? { metadata } : {}
|
|
1911
|
+
};
|
|
1912
|
+
}
|
|
1913
|
+
function grantExpiry(expiresIn, fileExpiresAt, now) {
|
|
1914
|
+
const cap = fileExpiresAt ?? now + DownloadGrantLimits2.MAX_LIFETIME_SECONDS * 1e3;
|
|
1915
|
+
return expiresIn === void 0 ? cap : Math.min(now + expiresIn * 1e3, cap);
|
|
1916
|
+
}
|
|
1917
|
+
async function handleCreateGrant(context) {
|
|
1918
|
+
const namespaces = grantNamespaces(context);
|
|
1919
|
+
if (!namespaces) return notConfigured2(context);
|
|
1920
|
+
const parsed = parseGrantRequest(await context.req.json().catch(() => null) ?? {});
|
|
1921
|
+
if ("error" in parsed) return context.json(parsed, 400);
|
|
1922
|
+
const fullKey = storageKey(context, parsed.key);
|
|
1923
|
+
const object = await context.env.MEDIA_BUCKET.head(fullKey);
|
|
1924
|
+
if (!object) return context.json({ error: "Not found" }, 404);
|
|
1925
|
+
if (isObjectExpired(object.customMetadata)) return expiredResponse();
|
|
1926
|
+
const requestedExpiresAt = grantExpiry(parsed.expiresIn, objectExpiresAt(object.customMetadata), Date.now());
|
|
1927
|
+
const decision = await decideCredential(context, {
|
|
1928
|
+
kind: "download-grant",
|
|
1929
|
+
scopes: parsed.canDelete ? [TokenScope3.MEDIA_READ, GRANT_DELETE_SCOPE] : [TokenScope3.MEDIA_READ],
|
|
1930
|
+
path: parsed.key,
|
|
1931
|
+
expiresAt: requestedExpiresAt
|
|
1932
|
+
});
|
|
1933
|
+
if (!decision.allow) return decision.response;
|
|
1934
|
+
const { objectId, stub } = fileGrantsObject(namespaces.grants, fullKey);
|
|
1935
|
+
const grantId = newGrantId();
|
|
1936
|
+
const secret = newGrantSecret();
|
|
1937
|
+
const scope = getRequestState(context);
|
|
1938
|
+
const record = {
|
|
1939
|
+
id: grantId,
|
|
1940
|
+
secretHash: await hashGrantSecret(secret),
|
|
1941
|
+
storageKey: fullKey,
|
|
1942
|
+
key: parsed.key,
|
|
1943
|
+
version: object.version,
|
|
1944
|
+
storagePrefix: scope.storagePrefix,
|
|
1945
|
+
expiresAt: cappedExpiry(requestedExpiresAt, decision.expiresAt, (epochMs) => epochMs),
|
|
1946
|
+
link: parsed.link,
|
|
1947
|
+
maxLinks: parsed.maxLinks,
|
|
1948
|
+
canDelete: parsed.canDelete,
|
|
1949
|
+
...parsed.metadata ? { metadata: parsed.metadata } : {},
|
|
1950
|
+
...scope.context ? { context: scope.context } : {},
|
|
1951
|
+
createdAt: Date.now()
|
|
1952
|
+
};
|
|
1953
|
+
const created = await callGrants(stub, DownloadGrantPath.CREATE, record);
|
|
1954
|
+
if (!created.ok) return context.json({ error: "Could not create the download grant" }, 500);
|
|
1955
|
+
const publicId = formatGrantPublicId({ objectId, grantId });
|
|
1956
|
+
const { secretHash: _secretHash, ...info } = record;
|
|
1957
|
+
addRouteResult(context, { issuedGrant: { publicId, grant: { ...info, linksIssued: 0, revokedAt: null } } });
|
|
1958
|
+
const grant = {
|
|
1959
|
+
grant: formatGrantSecret({ objectId, grantId, secret }),
|
|
1960
|
+
id: publicId,
|
|
1961
|
+
key: record.key,
|
|
1962
|
+
expiresAt: record.expiresAt,
|
|
1963
|
+
link: record.link,
|
|
1964
|
+
maxLinks: record.maxLinks,
|
|
1965
|
+
canDelete: record.canDelete,
|
|
1966
|
+
...record.metadata ? { metadata: record.metadata } : {}
|
|
1967
|
+
};
|
|
1968
|
+
return context.json(grant);
|
|
1969
|
+
}
|
|
1970
|
+
async function handleListGrants(context) {
|
|
1971
|
+
const namespaces = grantNamespaces(context);
|
|
1972
|
+
if (!namespaces) return notConfigured2(context);
|
|
1973
|
+
const key = context.req.query("key") ?? "";
|
|
1974
|
+
if (relativeMediaPath2(key) === null) {
|
|
1975
|
+
return context.json({ error: "key must be a visibility-prefixed media key", code: "invalid_key" }, 400);
|
|
1976
|
+
}
|
|
1977
|
+
const { objectId, stub } = fileGrantsObject(namespaces.grants, storageKey(context, key));
|
|
1978
|
+
const listed = await callGrants(stub, DownloadGrantPath.LIST);
|
|
1979
|
+
if (!listed.ok) return context.json({ error: "Could not list the download grants" }, 500);
|
|
1980
|
+
const grants = await listed.json();
|
|
1981
|
+
return context.json(grants.map((grant) => grantListEntry(objectId, grant)));
|
|
1982
|
+
}
|
|
1983
|
+
var grantNotFound = (context) => context.json({ error: "Download grant not found", code: "grant_not_found" }, 404);
|
|
1984
|
+
async function handleRevokeGrant(context) {
|
|
1985
|
+
const namespaces = grantNamespaces(context);
|
|
1986
|
+
if (!namespaces) return notConfigured2(context);
|
|
1987
|
+
const publicId = context.req.param("id") ?? "";
|
|
1988
|
+
const location = parseGrantPublicId(publicId);
|
|
1989
|
+
const stub = location ? grantsObjectById(namespaces.grants, location.objectId) : null;
|
|
1990
|
+
if (!location || !stub) return grantNotFound(context);
|
|
1991
|
+
const found = await callGrants(stub, DownloadGrantPath.GET, { id: location.grantId });
|
|
1992
|
+
if (!found.ok) return grantNotFound(context);
|
|
1993
|
+
const grant = await found.json();
|
|
1994
|
+
if (grant.storagePrefix !== getRequestState(context).storagePrefix) return grantNotFound(context);
|
|
1995
|
+
const refusal2 = await gateGrantAccess(context, { action: "owner-revoke", grant });
|
|
1996
|
+
if (refusal2) return refusal2;
|
|
1997
|
+
const revoked = await callGrants(stub, DownloadGrantPath.REVOKE, { id: location.grantId });
|
|
1998
|
+
if (!revoked.ok) return context.json({ error: "Could not revoke the download grant" }, 500);
|
|
1999
|
+
addRouteResult(context, {
|
|
2000
|
+
revokedGrant: { publicId, grant: await revoked.json(), by: "owner" }
|
|
2001
|
+
});
|
|
2002
|
+
return new Response(null, { status: 204 });
|
|
2003
|
+
}
|
|
2004
|
+
var grantOwner = new Hono4().post("/", handleCreateGrant).get("/", handleListGrants).delete("/:id", handleRevokeGrant);
|
|
2005
|
+
|
|
1277
2006
|
// src/plugins/download-links.ts
|
|
1278
2007
|
function downloadLinks() {
|
|
1279
|
-
const issue = new
|
|
2008
|
+
const issue = new Hono5().post("/", handleDownloadLink);
|
|
1280
2009
|
return {
|
|
1281
2010
|
name: "download-links",
|
|
1282
2011
|
routes: [
|
|
1283
2012
|
{ path: "/v1/token/download", app: issue },
|
|
1284
|
-
{ path: "/v1/download", app: download }
|
|
2013
|
+
{ path: "/v1/download", app: download },
|
|
2014
|
+
{ path: "/v1/token/grant", app: grantOwner },
|
|
2015
|
+
{ path: "/v1/grant", app: grantBearer }
|
|
1285
2016
|
]
|
|
1286
2017
|
};
|
|
1287
2018
|
}
|
|
@@ -1336,7 +2067,7 @@ import {
|
|
|
1336
2067
|
} from "@devultur/core";
|
|
1337
2068
|
|
|
1338
2069
|
// src/routes/viewer-token-claims.ts
|
|
1339
|
-
import { coversScope, isWithinPathPrefix, relativeMediaPath as
|
|
2070
|
+
import { coversScope, isWithinPathPrefix, relativeMediaPath as relativeMediaPath3, TokenScope as TokenScope4 } from "@devultur/core";
|
|
1340
2071
|
var TOKEN_REJECTED_CODE = "token_rejected";
|
|
1341
2072
|
function isOtherAudience(payload, storagePrefix2) {
|
|
1342
2073
|
if (payload.aud === void 0) return false;
|
|
@@ -1345,12 +2076,12 @@ function isOtherAudience(payload, storagePrefix2) {
|
|
|
1345
2076
|
function isOutsideResource(payload, key) {
|
|
1346
2077
|
if (payload.res === void 0) return false;
|
|
1347
2078
|
if (typeof payload.res !== "string") return true;
|
|
1348
|
-
const relativePath =
|
|
2079
|
+
const relativePath = relativeMediaPath3(key);
|
|
1349
2080
|
return relativePath === null || !isWithinPathPrefix(relativePath, payload.res);
|
|
1350
2081
|
}
|
|
1351
2082
|
function lacksReadScope(payload) {
|
|
1352
2083
|
const scopes = readTokenScopes(payload);
|
|
1353
|
-
return scopes !== void 0 && !coversScope(scopes,
|
|
2084
|
+
return scopes !== void 0 && !coversScope(scopes, TokenScope4.MEDIA_READ);
|
|
1354
2085
|
}
|
|
1355
2086
|
function rejectionReason(payload, target) {
|
|
1356
2087
|
if (isOtherAudience(payload, target.storagePrefix)) return "audience";
|
|
@@ -1368,7 +2099,7 @@ function viewerTokenClaimRejection(payload, target) {
|
|
|
1368
2099
|
var MAX_FILE_TTL_SECONDS = 365 * 24 * 60 * 60;
|
|
1369
2100
|
var MAX_FILES_COUNTER_TTL_SECONDS = 24 * 60 * 60;
|
|
1370
2101
|
var MAX_FILES_PREFIX = "upload-token-files";
|
|
1371
|
-
var
|
|
2102
|
+
var failure2 = (status, code, error) => ({
|
|
1372
2103
|
status,
|
|
1373
2104
|
code,
|
|
1374
2105
|
error
|
|
@@ -1384,12 +2115,12 @@ function generatedKindPath(mediaType, id, filename) {
|
|
|
1384
2115
|
}
|
|
1385
2116
|
function tokenRejected(reason, path) {
|
|
1386
2117
|
console.warn(`[upload] token rejected (${reason}) for path ${String(path)}`);
|
|
1387
|
-
return
|
|
2118
|
+
return failure2(403, TOKEN_REJECTED_CODE, "Token rejected");
|
|
1388
2119
|
}
|
|
1389
2120
|
function assetTarget(rawPath, visibility, grant) {
|
|
1390
2121
|
const assetPath = normalizeAssetPath(rawPath);
|
|
1391
2122
|
if (!assetPath) {
|
|
1392
|
-
return
|
|
2123
|
+
return failure2(400, "invalid_path", "path must be a relative path of [A-Za-z0-9._-] segments, without `..`");
|
|
1393
2124
|
}
|
|
1394
2125
|
const relativePath = assetRelativePath(assetPath);
|
|
1395
2126
|
if (grant && !isWithinPathPrefix2(relativePath, grant.pathPrefix)) return tokenRejected("resource", relativePath);
|
|
@@ -1398,7 +2129,7 @@ function assetTarget(rawPath, visibility, grant) {
|
|
|
1398
2129
|
}
|
|
1399
2130
|
function resolveUploadTarget(body, mediaType, visibility, grant) {
|
|
1400
2131
|
if (grant && body.path === void 0) {
|
|
1401
|
-
return
|
|
2132
|
+
return failure2(400, "path_required", "an upload token can only create files at an explicit `path`");
|
|
1402
2133
|
}
|
|
1403
2134
|
if (grant && body.visibility && body.visibility !== grant.visibility) return tokenRejected("visibility", body.path);
|
|
1404
2135
|
if (body.path !== void 0) return assetTarget(body.path, grant?.visibility ?? visibility, grant);
|
|
@@ -1409,7 +2140,7 @@ function resolveExpiresAt(requested, grant, now, maxTtlSeconds) {
|
|
|
1409
2140
|
const hasRequest = requested !== void 0 && requested !== null;
|
|
1410
2141
|
const isValid = typeof requested === "number" && Number.isInteger(requested) && requested > 0;
|
|
1411
2142
|
if (hasRequest && (!isValid || requested > MAX_FILE_TTL_SECONDS)) {
|
|
1412
|
-
return
|
|
2143
|
+
return failure2(400, "invalid_expires_in", "expiresIn must be a positive integer number of seconds (max 1 year)");
|
|
1413
2144
|
}
|
|
1414
2145
|
const candidates = [hasRequest ? requested : void 0, grant?.fileExpiresIn, maxTtlSeconds];
|
|
1415
2146
|
const ttls = candidates.filter((ttl) => typeof ttl === "number" && ttl > 0);
|
|
@@ -1417,7 +2148,7 @@ function resolveExpiresAt(requested, grant, now, maxTtlSeconds) {
|
|
|
1417
2148
|
}
|
|
1418
2149
|
function resolveDeclaredSha256(raw) {
|
|
1419
2150
|
if (raw === void 0 || raw === null) return void 0;
|
|
1420
|
-
return normalizeSha2562(raw) ??
|
|
2151
|
+
return normalizeSha2562(raw) ?? failure2(400, "invalid_sha256", "sha256 must be a 64-character hex string");
|
|
1421
2152
|
}
|
|
1422
2153
|
function effectiveMaxBytes(ruleMax, grant) {
|
|
1423
2154
|
const limits = [ruleMax, grant?.maxBytes].filter((limit) => typeof limit === "number");
|
|
@@ -1428,7 +2159,7 @@ async function claimTokenFileSlot(kv, grant) {
|
|
|
1428
2159
|
const counterKey = `${MAX_FILES_PREFIX}:${grant.tokenId}`;
|
|
1429
2160
|
const used = Number(await kv.get(counterKey) ?? "0");
|
|
1430
2161
|
if (used >= grant.maxFiles) {
|
|
1431
|
-
return
|
|
2162
|
+
return failure2(403, "max_files_reached", `the upload token already created ${grant.maxFiles} files`);
|
|
1432
2163
|
}
|
|
1433
2164
|
await kv.put(counterKey, String(used + 1), { expirationTtl: MAX_FILES_COUNTER_TTL_SECONDS });
|
|
1434
2165
|
return null;
|
|
@@ -1436,7 +2167,7 @@ async function claimTokenFileSlot(kv, grant) {
|
|
|
1436
2167
|
function resolveObjectCacheControl(requested, target, rules) {
|
|
1437
2168
|
if (!target.isAsset || !target.assetPath) return void 0;
|
|
1438
2169
|
if (requested !== void 0 && requested !== null) {
|
|
1439
|
-
return resolveCacheControl(requested) ??
|
|
2170
|
+
return resolveCacheControl(requested) ?? failure2(400, "invalid_cache_control", "cacheControl must be immutable, revalidate, no-cache or a valid header");
|
|
1440
2171
|
}
|
|
1441
2172
|
return matchCacheRule(rules, target.assetPath)?.cacheControl ?? DEFAULT_ASSET_CACHE_CONTROL;
|
|
1442
2173
|
}
|
|
@@ -1449,10 +2180,10 @@ function inferMediaType(contentType) {
|
|
|
1449
2180
|
function resolveUploadMediaType(declared, contentType, isAssetRequest) {
|
|
1450
2181
|
const inferredType = inferMediaType(contentType);
|
|
1451
2182
|
if (declared && inferredType && declared !== inferredType) {
|
|
1452
|
-
return
|
|
2183
|
+
return failure2(400, "media_type_mismatch", `mediaType "${declared}" does not match content type ${contentType}`);
|
|
1453
2184
|
}
|
|
1454
2185
|
const mediaType = declared ?? inferredType ?? (isAssetRequest ? MediaType.FILE : null);
|
|
1455
|
-
return mediaType ??
|
|
2186
|
+
return mediaType ?? failure2(400, "unsupported_content_type", `Unsupported content type: ${contentType}`);
|
|
1456
2187
|
}
|
|
1457
2188
|
|
|
1458
2189
|
// src/routes/token-upload.ts
|
|
@@ -1560,6 +2291,7 @@ var WorkerAuthErrorCode = {
|
|
|
1560
2291
|
var MEDIA_PREFIX = "/v1/media/";
|
|
1561
2292
|
var DOWNLOAD_PREFIX = "/v1/download/";
|
|
1562
2293
|
var WEBHOOKS_PREFIX = "/v1/webhooks/";
|
|
2294
|
+
var GRANT_PATH = "/v1/grant";
|
|
1563
2295
|
var UPLOAD_CREATE_PATH = "/v1/upload";
|
|
1564
2296
|
var UPLOAD_URL_PREFIX = "/v1/upload/";
|
|
1565
2297
|
var isReadMethod = (method) => method === "GET" || method === "HEAD";
|
|
@@ -1568,6 +2300,7 @@ function isOpenRoute(method, path) {
|
|
|
1568
2300
|
if (path === "/health") return isReadMethod(method);
|
|
1569
2301
|
if (path === "/v1/metrics/playback") return method === "POST";
|
|
1570
2302
|
if (path.startsWith(MEDIA_PREFIX) || path.startsWith(DOWNLOAD_PREFIX)) return isReadMethod(method);
|
|
2303
|
+
if (path === GRANT_PATH || path.startsWith(`${GRANT_PATH}/`)) return true;
|
|
1571
2304
|
return path.startsWith(WEBHOOKS_PREFIX) && method === "POST";
|
|
1572
2305
|
}
|
|
1573
2306
|
function isUploadUrl(path) {
|
|
@@ -1631,7 +2364,7 @@ function defaultPlugins(config = {}) {
|
|
|
1631
2364
|
|
|
1632
2365
|
// src/routes/assets.ts
|
|
1633
2366
|
import { ASSET_NAMESPACE, normalizeAssetPrefix as normalizeAssetPrefix2 } from "@devultur/core";
|
|
1634
|
-
import { Hono as
|
|
2367
|
+
import { Hono as Hono6 } from "hono";
|
|
1635
2368
|
|
|
1636
2369
|
// src/object-lock/delivery.ts
|
|
1637
2370
|
import { effectiveUntil as effectiveUntil3 } from "@devultur/core";
|
|
@@ -1738,7 +2471,7 @@ async function listedEntries(bucket, listing, storageRoot, visibility) {
|
|
|
1738
2471
|
}
|
|
1739
2472
|
return page;
|
|
1740
2473
|
}
|
|
1741
|
-
var assets = new
|
|
2474
|
+
var assets = new Hono6();
|
|
1742
2475
|
assets.get("/", async (context) => {
|
|
1743
2476
|
const prefix = parseListPrefix(context.req.query("prefix"));
|
|
1744
2477
|
if (prefix === null) {
|
|
@@ -1774,7 +2507,7 @@ import {
|
|
|
1774
2507
|
resolveSecurity,
|
|
1775
2508
|
visibilityFromKey
|
|
1776
2509
|
} from "@devultur/core";
|
|
1777
|
-
import { Hono as
|
|
2510
|
+
import { Hono as Hono8 } from "hono";
|
|
1778
2511
|
|
|
1779
2512
|
// src/keys-bucket.ts
|
|
1780
2513
|
import { decodeEncryptionMeta as decodeEncryptionMeta3 } from "@devultur/core";
|
|
@@ -1852,70 +2585,6 @@ async function applyStatusEvent(kv, event) {
|
|
|
1852
2585
|
await kv.put(statusKey(videoId), JSON.stringify(record), { expirationTtl: KV_JOB_TTL_SECONDS });
|
|
1853
2586
|
}
|
|
1854
2587
|
|
|
1855
|
-
// src/transcode/raw-retention.ts
|
|
1856
|
-
import { isVideoRawKey as isVideoRawKey2 } from "@devultur/core";
|
|
1857
|
-
var disposalKey = (videoId) => `${KvPrefix.TRANSCODE_RAW}:${videoId}`;
|
|
1858
|
-
async function readRawDisposal(kv, videoId) {
|
|
1859
|
-
return kv.get(disposalKey(videoId), "json");
|
|
1860
|
-
}
|
|
1861
|
-
async function rememberRawDisposal(kv, videoId, disposal) {
|
|
1862
|
-
await kv.put(disposalKey(videoId), JSON.stringify(disposal), { expirationTtl: KV_JOB_TTL_SECONDS });
|
|
1863
|
-
}
|
|
1864
|
-
async function disposeRawAfterTranscode(context, videoId) {
|
|
1865
|
-
const { env } = context;
|
|
1866
|
-
const disposal = await readRawDisposal(env.MEDIA_KV, videoId);
|
|
1867
|
-
if (!disposal?.deleteOnComplete || !disposal.storageKey) return "kept";
|
|
1868
|
-
const raw = await env.MEDIA_BUCKET.head(disposal.storageKey);
|
|
1869
|
-
const location = splitVideoStorageKey(disposal.storageKey);
|
|
1870
|
-
const lock = raw && location ? await effectiveLock(env.MEDIA_BUCKET, location.root, location.key, raw) : null;
|
|
1871
|
-
if (lock?.state && isStateLocked(lock.state, /* @__PURE__ */ new Date())) {
|
|
1872
|
-
await recordLockAudit(context, {
|
|
1873
|
-
action: "original_retained",
|
|
1874
|
-
keys: [location?.key ?? disposal.storageKey],
|
|
1875
|
-
before: lock.state.lock,
|
|
1876
|
-
after: null,
|
|
1877
|
-
outcome: "skipped",
|
|
1878
|
-
root: location?.root ?? ""
|
|
1879
|
-
});
|
|
1880
|
-
return "retained";
|
|
1881
|
-
}
|
|
1882
|
-
await env.MEDIA_BUCKET.delete(disposal.storageKey);
|
|
1883
|
-
if (env.KEYS_BUCKET) await deletePrefix(env.KEYS_BUCKET, `${disposal.storageKey}/`);
|
|
1884
|
-
return "deleted";
|
|
1885
|
-
}
|
|
1886
|
-
var ORIGINAL_NOT_RETAINED = {
|
|
1887
|
-
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.",
|
|
1888
|
-
code: "original_not_retained"
|
|
1889
|
-
};
|
|
1890
|
-
var PRIOR_OUTPUTS_LIST_LIMIT = 100;
|
|
1891
|
-
async function hasPriorTranscodeOutputs(bucket, videoFolder) {
|
|
1892
|
-
const listing = await bucket.list({ prefix: videoFolder, limit: PRIOR_OUTPUTS_LIST_LIMIT });
|
|
1893
|
-
return listing.objects.some((object) => !isVideoRawKey2(object.key));
|
|
1894
|
-
}
|
|
1895
|
-
|
|
1896
|
-
// src/object-lock/transcode-marker.ts
|
|
1897
|
-
var MARKER_NAME = "transcode-completed";
|
|
1898
|
-
var markerKey = (root, videoFolder) => sidecarKey(root, `${videoFolder}/${MARKER_NAME}`);
|
|
1899
|
-
async function markTranscodeCompleted(env, videoId, ticketLocation) {
|
|
1900
|
-
const location = ticketLocation ?? await rememberedLocation(env, videoId);
|
|
1901
|
-
if (!location) return;
|
|
1902
|
-
await env.MEDIA_BUCKET.put(markerKey(location.root, location.folder), (/* @__PURE__ */ new Date()).toISOString(), {
|
|
1903
|
-
httpMetadata: { contentType: "text/plain" }
|
|
1904
|
-
});
|
|
1905
|
-
}
|
|
1906
|
-
async function rememberedLocation(env, videoId) {
|
|
1907
|
-
const disposal = await readRawDisposal(env.MEDIA_KV, videoId);
|
|
1908
|
-
const raw = disposal?.storageKey ? splitVideoStorageKey(disposal.storageKey) : null;
|
|
1909
|
-
const folder = raw?.key.split("/").slice(0, 3).join("/");
|
|
1910
|
-
return raw && folder ? { root: raw.root, folder } : null;
|
|
1911
|
-
}
|
|
1912
|
-
async function hasCompletedTranscode(bucket, root, videoFolder) {
|
|
1913
|
-
return await bucket.head(markerKey(root, videoFolder)) !== null;
|
|
1914
|
-
}
|
|
1915
|
-
async function forgetTranscodeCompleted(bucket, root, videoFolder) {
|
|
1916
|
-
await bucket.delete(markerKey(root, videoFolder));
|
|
1917
|
-
}
|
|
1918
|
-
|
|
1919
2588
|
// src/object-lock/overwrite.ts
|
|
1920
2589
|
function sameObject(object) {
|
|
1921
2590
|
return {
|
|
@@ -2059,7 +2728,7 @@ function logCaptionsError(context, error) {
|
|
|
2059
2728
|
}
|
|
2060
2729
|
|
|
2061
2730
|
// src/routes/webhooks.ts
|
|
2062
|
-
import { Hono as
|
|
2731
|
+
import { Hono as Hono7 } from "hono";
|
|
2063
2732
|
|
|
2064
2733
|
// src/transcode/metrics.ts
|
|
2065
2734
|
var OPERATOR_EVENT_FIELDS = ["encode", "posterKey", "posterFormats"];
|
|
@@ -2083,9 +2752,9 @@ function perTitleMetrics(encode) {
|
|
|
2083
2752
|
...encodeParams ? { encodeParams } : {}
|
|
2084
2753
|
};
|
|
2085
2754
|
}
|
|
2086
|
-
var
|
|
2755
|
+
var isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2087
2756
|
function parseEncodeMetrics(event) {
|
|
2088
|
-
if (!
|
|
2757
|
+
if (!isRecord(event) || event.event !== "transcode.complete" || !isRecord(event.encode)) return null;
|
|
2089
2758
|
const { encode } = event;
|
|
2090
2759
|
if (!isNonNegativeNumber(encode.seconds) || !isNonNegativeNumber(event.duration)) return null;
|
|
2091
2760
|
return {
|
|
@@ -2178,7 +2847,7 @@ async function loadOutputBaseUrl(kv, videoId, fallbackUrl) {
|
|
|
2178
2847
|
}
|
|
2179
2848
|
|
|
2180
2849
|
// src/routes/webhooks.ts
|
|
2181
|
-
var webhooks = new
|
|
2850
|
+
var webhooks = new Hono7();
|
|
2182
2851
|
webhooks.post("/transcode", async (context) => {
|
|
2183
2852
|
const webhookSecret = context.env.TRANSCODE_WEBHOOK_SECRET;
|
|
2184
2853
|
if (!webhookSecret) {
|
|
@@ -2400,7 +3069,7 @@ async function transcodedDuration(kv, videoId) {
|
|
|
2400
3069
|
const meta = await kv.get(`${KvPrefix.TRANSCODE_META}:${videoId}`, "json");
|
|
2401
3070
|
return meta?.duration ?? void 0;
|
|
2402
3071
|
}
|
|
2403
|
-
var captions = new
|
|
3072
|
+
var captions = new Hono8();
|
|
2404
3073
|
captions.post("/", async (context) => {
|
|
2405
3074
|
const body = await context.req.json();
|
|
2406
3075
|
if (!body.key || !body.locales?.length) {
|
|
@@ -2511,19 +3180,19 @@ captions.post("/", async (context) => {
|
|
|
2511
3180
|
});
|
|
2512
3181
|
} catch (err) {
|
|
2513
3182
|
logCaptionsError(`job failed for ${videoId}`, err);
|
|
2514
|
-
const
|
|
3183
|
+
const failure5 = toCaptionsFailure(err);
|
|
2515
3184
|
await forwardToClientWebhook(
|
|
2516
3185
|
{
|
|
2517
3186
|
event: "captions.failed",
|
|
2518
3187
|
videoId,
|
|
2519
|
-
error:
|
|
2520
|
-
reason:
|
|
2521
|
-
retriable:
|
|
3188
|
+
error: failure5.error,
|
|
3189
|
+
reason: failure5.reason,
|
|
3190
|
+
retriable: failure5.retriable
|
|
2522
3191
|
},
|
|
2523
3192
|
webhookData
|
|
2524
3193
|
);
|
|
2525
3194
|
const conflict = captionConflictResponse(context, err);
|
|
2526
|
-
return conflict ?? context.json({ error:
|
|
3195
|
+
return conflict ?? context.json({ error: failure5.error, code: failure5.code }, failure5.status);
|
|
2527
3196
|
}
|
|
2528
3197
|
});
|
|
2529
3198
|
captions.get("/:transcriptId", async (context) => {
|
|
@@ -2552,7 +3221,7 @@ import {
|
|
|
2552
3221
|
toPrivateCacheControl,
|
|
2553
3222
|
visibilityFromKey as visibilityFromKey2
|
|
2554
3223
|
} from "@devultur/core";
|
|
2555
|
-
import { Hono as
|
|
3224
|
+
import { Hono as Hono9 } from "hono";
|
|
2556
3225
|
|
|
2557
3226
|
// src/image/cloudflare.ts
|
|
2558
3227
|
var IMAGE_RESIZING_VIA = "image-resizing";
|
|
@@ -2964,52 +3633,6 @@ function cacheDelivered(waitUntil, target, delivered) {
|
|
|
2964
3633
|
}
|
|
2965
3634
|
}
|
|
2966
3635
|
|
|
2967
|
-
// src/routes/media-delete.ts
|
|
2968
|
-
import { videoBasePath } from "@devultur/core";
|
|
2969
|
-
async function handleVideoDelete(context) {
|
|
2970
|
-
const videoId = context.req.param("videoId") ?? "";
|
|
2971
|
-
const visibilityParam = context.req.query("visibility");
|
|
2972
|
-
const visibilities = visibilityParam === "public" || visibilityParam === "private" ? [visibilityParam] : ["public", "private"];
|
|
2973
|
-
const folders = visibilities.map((visibility) => `${visibility}/${videoBasePath(videoId)}`);
|
|
2974
|
-
const blocked = await assertWritable(context, folders, "delete");
|
|
2975
|
-
if (blocked) return blocked;
|
|
2976
|
-
let deletedCount = 0;
|
|
2977
|
-
let deletedKeyCount = 0;
|
|
2978
|
-
for (const folder of folders) {
|
|
2979
|
-
const deleted = await guardedDeletePrefix(context, `${folder}/`);
|
|
2980
|
-
if ("blocked" in deleted) return deleted.blocked;
|
|
2981
|
-
deletedCount += deleted.deletedCount;
|
|
2982
|
-
if (deleted.deletedCount > 0) {
|
|
2983
|
-
await forgetTranscodeCompleted(context.env.MEDIA_BUCKET, getRequestState(context).storagePrefix, folder);
|
|
2984
|
-
}
|
|
2985
|
-
if (!folder.startsWith("private/")) continue;
|
|
2986
|
-
const deletedKeys = await deleteFolderKeys(context, folder);
|
|
2987
|
-
if ("blocked" in deletedKeys) return deletedKeys.blocked;
|
|
2988
|
-
deletedKeyCount += deletedKeys.deletedCount;
|
|
2989
|
-
}
|
|
2990
|
-
return context.json({ videoId, deletedCount, deletedKeyCount });
|
|
2991
|
-
}
|
|
2992
|
-
async function handleKeyDelete(context) {
|
|
2993
|
-
const key = context.req.param("key") ?? "";
|
|
2994
|
-
const fullKey = storageKey(context, key);
|
|
2995
|
-
if (isVariantKey(key)) {
|
|
2996
|
-
return context.json({ error: "Not found" }, 404);
|
|
2997
|
-
}
|
|
2998
|
-
if (isReservedLockKey(key)) return reservedKeyResponse(context, key);
|
|
2999
|
-
const exists = await context.env.MEDIA_BUCKET.head(fullKey);
|
|
3000
|
-
if (!exists) {
|
|
3001
|
-
return context.json({ error: "Not found" }, 404);
|
|
3002
|
-
}
|
|
3003
|
-
const blocked = await assertWritable(context, [key], "delete");
|
|
3004
|
-
if (blocked) return blocked;
|
|
3005
|
-
await context.env.MEDIA_BUCKET.delete(fullKey);
|
|
3006
|
-
const variants = await guardedDeletePrefix(context, variantPrefix(key));
|
|
3007
|
-
if ("blocked" in variants) return variants.blocked;
|
|
3008
|
-
const keys = await deleteKeysFor(context, key, exists);
|
|
3009
|
-
if ("blocked" in keys) return keys.blocked;
|
|
3010
|
-
return new Response(null, { status: 204 });
|
|
3011
|
-
}
|
|
3012
|
-
|
|
3013
3636
|
// src/routes/storyboard-vtt.ts
|
|
3014
3637
|
import { createDecryptStream as createDecryptStream3 } from "@devultur/core";
|
|
3015
3638
|
var STORYBOARD_VTT = /(?:^|\/)video\/[^/]+\/storyboard\/[^/]+\.vtt$/;
|
|
@@ -3038,7 +3661,7 @@ function downloadDisposition(storedDisposition, key, filename) {
|
|
|
3038
3661
|
if (storedDisposition?.startsWith("inline")) return `attachment${storedDisposition.slice("inline".length)}`;
|
|
3039
3662
|
return contentDispositionAttachment(key.split("/").pop() || "download");
|
|
3040
3663
|
}
|
|
3041
|
-
var media = new
|
|
3664
|
+
var media = new Hono9();
|
|
3042
3665
|
media.use("*", contentSafety);
|
|
3043
3666
|
media.get("/:key{.+}", async (context) => {
|
|
3044
3667
|
const key = context.req.param("key");
|
|
@@ -3237,10 +3860,10 @@ async function serveEncryptionKey(context, key, tokenPayload) {
|
|
|
3237
3860
|
}
|
|
3238
3861
|
|
|
3239
3862
|
// src/routes/token.ts
|
|
3240
|
-
import { computeFingerprint as computeFingerprint2, DEFAULT_SECURITY, TokenScope as
|
|
3241
|
-
import { Hono as
|
|
3863
|
+
import { computeFingerprint as computeFingerprint2, DEFAULT_SECURITY, TokenScope as TokenScope5 } from "@devultur/core";
|
|
3864
|
+
import { Hono as Hono10 } from "hono";
|
|
3242
3865
|
var DEFAULT_VIEWER_TOKEN_TTL_SECONDS = 7200;
|
|
3243
|
-
var token = new
|
|
3866
|
+
var token = new Hono10();
|
|
3244
3867
|
token.post("/", async (context) => {
|
|
3245
3868
|
const body = await context.req.json();
|
|
3246
3869
|
if (!body.userId) {
|
|
@@ -3253,7 +3876,7 @@ token.post("/", async (context) => {
|
|
|
3253
3876
|
);
|
|
3254
3877
|
}
|
|
3255
3878
|
const clientIp = context.req.header("CF-Connecting-IP") ?? context.req.header("X-Forwarded-For") ?? "unknown";
|
|
3256
|
-
const requestedScopes = body.scopes ?? [
|
|
3879
|
+
const requestedScopes = body.scopes ?? [TokenScope5.MEDIA_READ];
|
|
3257
3880
|
if (!Array.isArray(requestedScopes) || hasInvalidScope(requestedScopes)) {
|
|
3258
3881
|
return context.json({ error: "scopes must be non-empty strings without spaces", code: "invalid_scopes" }, 400);
|
|
3259
3882
|
}
|
|
@@ -3301,7 +3924,7 @@ import {
|
|
|
3301
3924
|
generateId as generateId2,
|
|
3302
3925
|
resolveSecurity as resolveSecurity3,
|
|
3303
3926
|
resolveTranscodePreset,
|
|
3304
|
-
TokenScope as
|
|
3927
|
+
TokenScope as TokenScope6,
|
|
3305
3928
|
TranscodeStatus as TranscodeStatus3,
|
|
3306
3929
|
videoBasePath as videoBasePath2,
|
|
3307
3930
|
videoHlsPath,
|
|
@@ -3311,7 +3934,7 @@ import {
|
|
|
3311
3934
|
videoThumbnailPath,
|
|
3312
3935
|
visibilityFromKey as visibilityFromKey3
|
|
3313
3936
|
} from "@devultur/core";
|
|
3314
|
-
import { Hono as
|
|
3937
|
+
import { Hono as Hono11 } from "hono";
|
|
3315
3938
|
|
|
3316
3939
|
// src/transcode/plan.ts
|
|
3317
3940
|
import { MAIN_VARIANT } from "@devultur/core";
|
|
@@ -3586,7 +4209,7 @@ function postMachine(request, retry) {
|
|
|
3586
4209
|
retry
|
|
3587
4210
|
);
|
|
3588
4211
|
}
|
|
3589
|
-
async function
|
|
4212
|
+
async function failure3(response, error) {
|
|
3590
4213
|
const detail = await response.text();
|
|
3591
4214
|
const status = response.status === 429 || response.status >= 500 ? 503 : 500;
|
|
3592
4215
|
console.error(`[transcode] fly: ${error} (status ${response.status}):`, detail);
|
|
@@ -3598,7 +4221,7 @@ async function createFlyMachine(request, retry) {
|
|
|
3598
4221
|
const errorBody = await response.text();
|
|
3599
4222
|
const isSuspended = response.status === 412 || errorBody.includes("suspended");
|
|
3600
4223
|
if (!isSuspended) {
|
|
3601
|
-
return
|
|
4224
|
+
return failure3(new Response(errorBody, { status: response.status }), "Failed to create transcode job");
|
|
3602
4225
|
}
|
|
3603
4226
|
if (!await resumeFlyApp(request.appName, request.apiToken)) {
|
|
3604
4227
|
console.error("[transcode] fly: failed to resume suspended app:", errorBody);
|
|
@@ -3612,7 +4235,7 @@ async function createFlyMachine(request, retry) {
|
|
|
3612
4235
|
}
|
|
3613
4236
|
const retried = await postMachine(request, retry);
|
|
3614
4237
|
if (retried.ok) return { ok: true, machine: await retried.json() };
|
|
3615
|
-
return
|
|
4238
|
+
return failure3(retried, "Failed to create transcode job after resume");
|
|
3616
4239
|
}
|
|
3617
4240
|
function createFlyRunner(env, retry) {
|
|
3618
4241
|
const guest = flyGuestFromEnv(env);
|
|
@@ -4250,7 +4873,7 @@ function parseSecurityMeta2(raw) {
|
|
|
4250
4873
|
}
|
|
4251
4874
|
var SERVICE_TOKEN_TTL_SECONDS = 3600;
|
|
4252
4875
|
var isVideoId = (videoId, key) => Boolean(videoId) && videoId !== key;
|
|
4253
|
-
var transcode = new
|
|
4876
|
+
var transcode = new Hono11();
|
|
4254
4877
|
transcode.post("/", async (context) => {
|
|
4255
4878
|
const body = await context.req.json();
|
|
4256
4879
|
if (!body.key) {
|
|
@@ -4310,7 +4933,7 @@ transcode.post("/", async (context) => {
|
|
|
4310
4933
|
const requestedExp = nowSeconds + SERVICE_TOKEN_TTL_SECONDS;
|
|
4311
4934
|
const decision = await decideCredential(context, {
|
|
4312
4935
|
kind: "service",
|
|
4313
|
-
scopes: [
|
|
4936
|
+
scopes: [TokenScope6.MEDIA_READ],
|
|
4314
4937
|
path: body.key,
|
|
4315
4938
|
expiresAt: requestedExp * 1e3
|
|
4316
4939
|
});
|
|
@@ -4320,7 +4943,7 @@ transcode.post("/", async (context) => {
|
|
|
4320
4943
|
{
|
|
4321
4944
|
sub: `${SERVICE_SUBJECT_PREFIX}${videoId}`,
|
|
4322
4945
|
typ: SERVICE_TOKEN_TYPE,
|
|
4323
|
-
...scopeClaim([
|
|
4946
|
+
...scopeClaim([TokenScope6.MEDIA_READ]),
|
|
4324
4947
|
...audienceClaim(scope.storagePrefix),
|
|
4325
4948
|
bind: { sessionBinding: false, ipBinding: false },
|
|
4326
4949
|
iat: nowSeconds,
|
|
@@ -4442,16 +5065,16 @@ transcode.get("/:jobId", async (context) => {
|
|
|
4442
5065
|
|
|
4443
5066
|
// src/routes/upload.ts
|
|
4444
5067
|
import { MediaType as MediaType2, resolveSecurity as resolveSecurity5, resolveVisibility, UploadRequestKind as UploadRequestKind2 } from "@devultur/core";
|
|
4445
|
-
import { Hono as
|
|
5068
|
+
import { Hono as Hono12 } from "hono";
|
|
4446
5069
|
|
|
4447
5070
|
// src/object-lock/upload-lock.ts
|
|
4448
5071
|
import { canChange as canChange3, resolveLock, validateLock as validateLock6 } from "@devultur/core";
|
|
4449
|
-
var
|
|
5072
|
+
var failure4 = (status, code, error) => ({ status, code, error });
|
|
4450
5073
|
function requestedLock(lock, hold) {
|
|
4451
|
-
if (hold !== void 0 && typeof hold !== "boolean") return
|
|
5074
|
+
if (hold !== void 0 && typeof hold !== "boolean") return failure4(400, "invalid_lock", "hold must be a boolean");
|
|
4452
5075
|
if (lock === void 0 && hold === void 0) return void 0;
|
|
4453
5076
|
const validation = validateLock6(lock ?? {});
|
|
4454
|
-
if (!validation.ok) return
|
|
5077
|
+
if (!validation.ok) return failure4(400, "invalid_lock", validation.errors.join("; "));
|
|
4455
5078
|
return { ...validation.lock, ...hold !== void 0 ? { hold } : {} };
|
|
4456
5079
|
}
|
|
4457
5080
|
function satisfiesMinimum(minimum, lock, now) {
|
|
@@ -4464,10 +5087,10 @@ function resolveUploadLock(input) {
|
|
|
4464
5087
|
if (requested && "code" in requested) return requested;
|
|
4465
5088
|
const minimum = input.grant?.lock;
|
|
4466
5089
|
const resolution = resolveLock(requested ?? minimum, input.appDefault);
|
|
4467
|
-
if (!resolution.ok) return
|
|
5090
|
+
if (!resolution.ok) return failure4(400, "invalid_lock", resolution.errors.join("; "));
|
|
4468
5091
|
const lock = resolution.lock;
|
|
4469
5092
|
if (minimum && !satisfiesMinimum(minimum, lock, input.now)) {
|
|
4470
|
-
return
|
|
5093
|
+
return failure4(403, "lock_below_minimum", "The upload token requires a stricter lock than the request asks for");
|
|
4471
5094
|
}
|
|
4472
5095
|
return lock.retention || lock.hold ? lock : void 0;
|
|
4473
5096
|
}
|
|
@@ -4910,20 +5533,11 @@ function uploadPendingIdOf(uploadUrl) {
|
|
|
4910
5533
|
if (id === null) return void 0;
|
|
4911
5534
|
return PENDING_ID_PATTERN.test(id) ? id : null;
|
|
4912
5535
|
}
|
|
4913
|
-
var isRecord3 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
4914
|
-
function withLegacyFieldNames(pending) {
|
|
4915
|
-
const { callerContext, objectMetadata, ...current } = pending;
|
|
4916
|
-
return {
|
|
4917
|
-
...current,
|
|
4918
|
-
...current.context === void 0 && isRecord3(callerContext) ? { context: callerContext } : {},
|
|
4919
|
-
...current.uploadMetadata === void 0 && isRecord3(objectMetadata) ? { uploadMetadata: objectMetadata } : {}
|
|
4920
|
-
};
|
|
4921
|
-
}
|
|
4922
5536
|
function parsePending(raw) {
|
|
4923
5537
|
if (!raw) return null;
|
|
4924
5538
|
try {
|
|
4925
5539
|
const pending = JSON.parse(raw);
|
|
4926
|
-
return pending?.storageKey ?
|
|
5540
|
+
return pending?.storageKey ? pending : null;
|
|
4927
5541
|
} catch {
|
|
4928
5542
|
return null;
|
|
4929
5543
|
}
|
|
@@ -5175,7 +5789,7 @@ async function handleMultipartAbort(context) {
|
|
|
5175
5789
|
}
|
|
5176
5790
|
|
|
5177
5791
|
// src/routes/upload.ts
|
|
5178
|
-
var upload = new
|
|
5792
|
+
var upload = new Hono12();
|
|
5179
5793
|
upload.post("/", async (context) => {
|
|
5180
5794
|
const body = await context.req.json();
|
|
5181
5795
|
const isAssetRequest = body.path !== void 0;
|
|
@@ -5658,7 +6272,7 @@ async function retainedSummary(options) {
|
|
|
5658
6272
|
// src/index.ts
|
|
5659
6273
|
function createWorker(options) {
|
|
5660
6274
|
const { middleware, routes, hooks } = composePlugins(options?.plugins ?? defaultPlugins());
|
|
5661
|
-
const app = new
|
|
6275
|
+
const app = new Hono13();
|
|
5662
6276
|
app.use("*", logger());
|
|
5663
6277
|
app.use(
|
|
5664
6278
|
"*",
|
|
@@ -5666,7 +6280,17 @@ function createWorker(options) {
|
|
|
5666
6280
|
origin: "*",
|
|
5667
6281
|
allowMethods: ["GET", "HEAD", "POST", "PUT", "DELETE", "OPTIONS"],
|
|
5668
6282
|
allowHeaders: ["Content-Type", "Authorization", "Range", SESSION_HEADER2, SHA256_HEADER3],
|
|
5669
|
-
exposeHeaders: [
|
|
6283
|
+
exposeHeaders: [
|
|
6284
|
+
SHA256_HEADER3,
|
|
6285
|
+
SHA256_VERIFIED_HEADER2,
|
|
6286
|
+
REPR_DIGEST_HEADER2,
|
|
6287
|
+
EXPIRES_AT_HEADER2,
|
|
6288
|
+
ENCRYPTED_HEADER2,
|
|
6289
|
+
GRANT_EXPIRES_AT_HEADER2,
|
|
6290
|
+
GRANT_KEY_HEADER2,
|
|
6291
|
+
GRANT_METADATA_HEADER2,
|
|
6292
|
+
"Content-Range"
|
|
6293
|
+
]
|
|
5670
6294
|
})
|
|
5671
6295
|
);
|
|
5672
6296
|
app.use("*", async (context, next) => {
|
|
@@ -5694,6 +6318,7 @@ function createWorker(options) {
|
|
|
5694
6318
|
return app;
|
|
5695
6319
|
}
|
|
5696
6320
|
export {
|
|
6321
|
+
DownloadGrants,
|
|
5697
6322
|
DownloadLink,
|
|
5698
6323
|
ENCRYPTION_KEY_MISSING_CODE,
|
|
5699
6324
|
HOLD_PAGE_SIZE,
|
|
@@ -5724,6 +6349,7 @@ export {
|
|
|
5724
6349
|
getRequestState,
|
|
5725
6350
|
getRouteResult,
|
|
5726
6351
|
hashKey,
|
|
6352
|
+
identifyDownloadGrant,
|
|
5727
6353
|
imageOriginAuthorization,
|
|
5728
6354
|
isAuthenticated,
|
|
5729
6355
|
isImageOriginRequest,
|