@devultur/self-host 1.5.1 → 1.6.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 +9 -1
- package/dist/{chunk-PQO3X57I.js → chunk-BPHMKSQ7.js} +1 -0
- package/dist/index.d.ts +132 -5
- package/dist/index.js +813 -175
- 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,7 +740,7 @@ 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";
|
|
@@ -792,6 +811,35 @@ var DownloadLink = class {
|
|
|
792
811
|
}
|
|
793
812
|
};
|
|
794
813
|
|
|
814
|
+
// src/grant-credential.ts
|
|
815
|
+
import { DOWNLOAD_GRANT_ID_PREFIX, DOWNLOAD_GRANT_PREFIX } from "@devultur/core";
|
|
816
|
+
var OBJECT_ID = "[0-9a-f]{64}";
|
|
817
|
+
var GRANT_ID = "[A-Za-z0-9_-]{22}";
|
|
818
|
+
var SECRET = "[A-Za-z0-9_-]{43}";
|
|
819
|
+
var SECRET_PATTERN = new RegExp(`^${DOWNLOAD_GRANT_PREFIX}(${OBJECT_ID})\\.(${GRANT_ID})\\.(${SECRET})$`);
|
|
820
|
+
var PUBLIC_ID_PATTERN = new RegExp(`^${DOWNLOAD_GRANT_ID_PREFIX}(${OBJECT_ID})\\.(${GRANT_ID})$`);
|
|
821
|
+
function randomBase64Url(bytes) {
|
|
822
|
+
const random = crypto.getRandomValues(new Uint8Array(bytes));
|
|
823
|
+
return btoa(String.fromCharCode(...random)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
824
|
+
}
|
|
825
|
+
var newGrantId = () => randomBase64Url(16);
|
|
826
|
+
var newGrantSecret = () => randomBase64Url(32);
|
|
827
|
+
function formatGrantSecret({ objectId, grantId, secret }) {
|
|
828
|
+
return `${DOWNLOAD_GRANT_PREFIX}${objectId}.${grantId}.${secret}`;
|
|
829
|
+
}
|
|
830
|
+
function formatGrantPublicId({ objectId, grantId }) {
|
|
831
|
+
return `${DOWNLOAD_GRANT_ID_PREFIX}${objectId}.${grantId}`;
|
|
832
|
+
}
|
|
833
|
+
function parseGrantSecret(value) {
|
|
834
|
+
const match = SECRET_PATTERN.exec(value);
|
|
835
|
+
return match ? { objectId: match[1], grantId: match[2], secret: match[3] } : null;
|
|
836
|
+
}
|
|
837
|
+
function parseGrantPublicId(value) {
|
|
838
|
+
const match = PUBLIC_ID_PATTERN.exec(value);
|
|
839
|
+
return match ? { objectId: match[1], grantId: match[2] } : null;
|
|
840
|
+
}
|
|
841
|
+
var hashGrantSecret = (secret) => hashKey(secret);
|
|
842
|
+
|
|
795
843
|
// src/route-result.ts
|
|
796
844
|
var ROUTE_RESULT_VARIABLE = "routeResult";
|
|
797
845
|
function getRouteResult(context) {
|
|
@@ -988,8 +1036,8 @@ function allowedUploadMetadata(metadata) {
|
|
|
988
1036
|
if (!metadata) return {};
|
|
989
1037
|
return Object.fromEntries(Object.entries(metadata).filter(([key]) => !isWorkerMetadataKey(key)));
|
|
990
1038
|
}
|
|
991
|
-
function targetFailure(context,
|
|
992
|
-
return context.json({ error:
|
|
1039
|
+
function targetFailure(context, failure5) {
|
|
1040
|
+
return context.json({ error: failure5.error, code: failure5.code }, failure5.status);
|
|
993
1041
|
}
|
|
994
1042
|
function alreadyExists(context, key) {
|
|
995
1043
|
return context.json(
|
|
@@ -1162,10 +1210,7 @@ var DEFAULT_LINK_TTL_SECONDS = 15 * 60;
|
|
|
1162
1210
|
var MAX_LINK_TTL_SECONDS = 7 * 24 * 60 * 60;
|
|
1163
1211
|
var MAX_LINK_USES = 100;
|
|
1164
1212
|
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
|
-
}
|
|
1213
|
+
var newLinkId = () => randomBase64Url(16);
|
|
1169
1214
|
function linkStub(namespace, linkId) {
|
|
1170
1215
|
return namespace.get(namespace.idFromName(linkId));
|
|
1171
1216
|
}
|
|
@@ -1205,9 +1250,8 @@ async function handleDownloadLink(context) {
|
|
|
1205
1250
|
});
|
|
1206
1251
|
if (!decision.allow) return decision.response;
|
|
1207
1252
|
const expiresAt = cappedExpiry(requestedExpiresAt, decision.expiresAt, (epochMs) => epochMs);
|
|
1208
|
-
const linkId = newLinkId();
|
|
1209
1253
|
const scope = getRequestState(context);
|
|
1210
|
-
const
|
|
1254
|
+
const created = await storeLink(context, namespace, {
|
|
1211
1255
|
storageKey: fullKey,
|
|
1212
1256
|
key: parsed.key,
|
|
1213
1257
|
expiresAt,
|
|
@@ -1215,14 +1259,18 @@ async function handleDownloadLink(context) {
|
|
|
1215
1259
|
...parsed.filename ? { filename: parsed.filename } : {},
|
|
1216
1260
|
storagePrefix: scope.storagePrefix,
|
|
1217
1261
|
...scope.context ? { context: scope.context } : {}
|
|
1218
|
-
};
|
|
1262
|
+
});
|
|
1263
|
+
if (!created) return context.json({ error: "Could not create the download link" }, 500);
|
|
1264
|
+
return context.json({ ...created, expiresAt, maxUses: parsed.maxUses });
|
|
1265
|
+
}
|
|
1266
|
+
async function storeLink(context, namespace, record) {
|
|
1267
|
+
const linkId = newLinkId();
|
|
1219
1268
|
const created = await linkStub(namespace, linkId).fetch(`https://download-link${DownloadLinkPath.CREATE}`, {
|
|
1220
1269
|
method: "POST",
|
|
1221
1270
|
body: JSON.stringify(record)
|
|
1222
1271
|
});
|
|
1223
|
-
if (!created.ok) return
|
|
1224
|
-
|
|
1225
|
-
return context.json({ url: `${origin}/v1/download/${linkId}`, expiresAt, maxUses: parsed.maxUses });
|
|
1272
|
+
if (!created.ok) return null;
|
|
1273
|
+
return { id: linkId, url: `${new URL(context.req.url).origin}/v1/download/${linkId}` };
|
|
1226
1274
|
}
|
|
1227
1275
|
function attachmentDisposition(record, storedDisposition) {
|
|
1228
1276
|
if (record.filename) return contentDispositionAttachment2(record.filename);
|
|
@@ -1274,14 +1322,700 @@ download.get("/:linkId", async (context) => {
|
|
|
1274
1322
|
return response;
|
|
1275
1323
|
});
|
|
1276
1324
|
|
|
1325
|
+
// src/routes/grant-bearer.ts
|
|
1326
|
+
import {
|
|
1327
|
+
DownloadGrantErrorCode as DownloadGrantErrorCode2,
|
|
1328
|
+
DownloadGrantLimits,
|
|
1329
|
+
ENCRYPTED_HEADER,
|
|
1330
|
+
GRANT_EXPIRES_AT_HEADER,
|
|
1331
|
+
GRANT_KEY_HEADER,
|
|
1332
|
+
GRANT_METADATA_HEADER,
|
|
1333
|
+
TokenScope as TokenScope2
|
|
1334
|
+
} from "@devultur/core";
|
|
1335
|
+
import { Hono as Hono3 } from "hono";
|
|
1336
|
+
|
|
1337
|
+
// src/download-grants.ts
|
|
1338
|
+
import { DownloadGrantErrorCode } from "@devultur/core";
|
|
1339
|
+
var DownloadGrantPath = {
|
|
1340
|
+
/** Stores a new grant. Body: the record without `linksIssued`/`revokedAt`. */
|
|
1341
|
+
CREATE: "/create",
|
|
1342
|
+
/** The live grant for `{ id, secretHash }`. */
|
|
1343
|
+
AUTHORIZE: "/authorize",
|
|
1344
|
+
/** The grant for `{ id, secretHash }`, live or not: who it belongs to, before deciding anything. */
|
|
1345
|
+
IDENTIFY: "/identify",
|
|
1346
|
+
/** Spends one link of a live grant. Body: `{ id, secretHash, expiresIn, maxUses }`. */
|
|
1347
|
+
ISSUE: "/issue",
|
|
1348
|
+
/** Gives back a link spent by `ISSUE` whose creation failed. Body: `{ id }`. */
|
|
1349
|
+
REFUND: "/refund",
|
|
1350
|
+
/** Revokes `{ id }` (the owner) or `{ id, secretHash }` (the bearer). Idempotent. */
|
|
1351
|
+
REVOKE: "/revoke",
|
|
1352
|
+
/** One grant by `{ id }`, live or not, for the owner. */
|
|
1353
|
+
GET: "/get",
|
|
1354
|
+
/** Every grant of the file, for the owner. */
|
|
1355
|
+
LIST: "/list",
|
|
1356
|
+
/** Revokes every grant of the file: it's gone. They stay listed until they expire. */
|
|
1357
|
+
REVOKE_ALL: "/revoke-all"
|
|
1358
|
+
};
|
|
1359
|
+
var GRANT_KEY_PREFIX = "grant:";
|
|
1360
|
+
var grantKey = (id) => `${GRANT_KEY_PREFIX}${id}`;
|
|
1361
|
+
var failure = (status, code, error) => Response.json({ error, code }, { status });
|
|
1362
|
+
var invalidGrant = () => failure(401, DownloadGrantErrorCode.INVALID, "Invalid download grant");
|
|
1363
|
+
var grantGone = () => failure(410, DownloadGrantErrorCode.GONE, "This download grant is no longer valid");
|
|
1364
|
+
var notFound2 = () => failure(404, DownloadGrantErrorCode.NOT_FOUND, "Download grant not found");
|
|
1365
|
+
function grantInfoOf(record) {
|
|
1366
|
+
const { secretHash: _secretHash, ...info } = record;
|
|
1367
|
+
return info;
|
|
1368
|
+
}
|
|
1369
|
+
function isGrantDead(record, now = Date.now()) {
|
|
1370
|
+
return record.revokedAt !== null || record.expiresAt <= now;
|
|
1371
|
+
}
|
|
1372
|
+
function fitsLinkCaps(caps, expiresIn, maxUses) {
|
|
1373
|
+
return expiresIn <= caps.maxExpiresIn && maxUses <= caps.maxUses;
|
|
1374
|
+
}
|
|
1375
|
+
var DownloadGrants = class {
|
|
1376
|
+
state;
|
|
1377
|
+
storage;
|
|
1378
|
+
constructor(state) {
|
|
1379
|
+
this.state = state;
|
|
1380
|
+
this.storage = state.storage;
|
|
1381
|
+
}
|
|
1382
|
+
async fetch(request) {
|
|
1383
|
+
if (request.method !== "POST") return new Response(null, { status: 405 });
|
|
1384
|
+
const handler = this.handlerFor(new URL(request.url).pathname);
|
|
1385
|
+
if (!handler) return new Response(null, { status: 404 });
|
|
1386
|
+
const body = await request.json().catch(() => ({}));
|
|
1387
|
+
return this.state.blockConcurrencyWhile(() => handler(body));
|
|
1388
|
+
}
|
|
1389
|
+
handlerFor(pathname) {
|
|
1390
|
+
switch (pathname) {
|
|
1391
|
+
case DownloadGrantPath.CREATE:
|
|
1392
|
+
return (body) => this.create(body);
|
|
1393
|
+
case DownloadGrantPath.AUTHORIZE:
|
|
1394
|
+
return (body) => this.authorize(body);
|
|
1395
|
+
case DownloadGrantPath.IDENTIFY:
|
|
1396
|
+
return (body) => this.identify(body);
|
|
1397
|
+
case DownloadGrantPath.ISSUE:
|
|
1398
|
+
return (body) => this.issue(body);
|
|
1399
|
+
case DownloadGrantPath.REFUND:
|
|
1400
|
+
return (body) => this.refund(body);
|
|
1401
|
+
case DownloadGrantPath.REVOKE:
|
|
1402
|
+
return (body) => this.revoke(body);
|
|
1403
|
+
case DownloadGrantPath.GET:
|
|
1404
|
+
return (body) => this.getOne(body);
|
|
1405
|
+
case DownloadGrantPath.LIST:
|
|
1406
|
+
return () => this.list();
|
|
1407
|
+
case DownloadGrantPath.REVOKE_ALL:
|
|
1408
|
+
return () => this.revokeAll();
|
|
1409
|
+
default:
|
|
1410
|
+
return null;
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
async create(record) {
|
|
1414
|
+
if (await this.storage.get(grantKey(record.id))) {
|
|
1415
|
+
return failure(409, "grant_exists", "Grant id already in use");
|
|
1416
|
+
}
|
|
1417
|
+
await this.storage.put(grantKey(record.id), { ...record, linksIssued: 0, revokedAt: null });
|
|
1418
|
+
await this.scheduleAlarm();
|
|
1419
|
+
return Response.json({ ok: true }, { status: 201 });
|
|
1420
|
+
}
|
|
1421
|
+
async recordOf(id) {
|
|
1422
|
+
return typeof id === "string" ? this.storage.get(grantKey(id)) : void 0;
|
|
1423
|
+
}
|
|
1424
|
+
/** The record whose secret hash matches, live or not, or 401. */
|
|
1425
|
+
async matchingRecord(body) {
|
|
1426
|
+
const record = await this.recordOf(body.id);
|
|
1427
|
+
const secretHash = typeof body.secretHash === "string" ? body.secretHash : "";
|
|
1428
|
+
if (!record || !isConstantTimeEqual(secretHash, record.secretHash)) return invalidGrant();
|
|
1429
|
+
return record;
|
|
1430
|
+
}
|
|
1431
|
+
/** The live record whose secret hash matches, or the response explaining why not. */
|
|
1432
|
+
async liveRecord(body) {
|
|
1433
|
+
const record = await this.matchingRecord(body);
|
|
1434
|
+
if (record instanceof Response) return record;
|
|
1435
|
+
if (isGrantDead(record)) return grantGone();
|
|
1436
|
+
return record;
|
|
1437
|
+
}
|
|
1438
|
+
async identify(body) {
|
|
1439
|
+
const record = await this.matchingRecord(body);
|
|
1440
|
+
return record instanceof Response ? record : Response.json(grantInfoOf(record));
|
|
1441
|
+
}
|
|
1442
|
+
async authorize(body) {
|
|
1443
|
+
const record = await this.liveRecord(body);
|
|
1444
|
+
return record instanceof Response ? record : Response.json(grantInfoOf(record));
|
|
1445
|
+
}
|
|
1446
|
+
/** Spends one link: caps first (a refused link spends nothing), then the total. */
|
|
1447
|
+
async issue(body) {
|
|
1448
|
+
const record = await this.liveRecord(body);
|
|
1449
|
+
if (record instanceof Response) return record;
|
|
1450
|
+
if (!fitsLinkCaps(record.link, Number(body.expiresIn), Number(body.maxUses))) {
|
|
1451
|
+
return Response.json(
|
|
1452
|
+
{
|
|
1453
|
+
error: "The link exceeds the grant's caps",
|
|
1454
|
+
code: DownloadGrantErrorCode.LINK_EXCEEDS_GRANT,
|
|
1455
|
+
link: record.link
|
|
1456
|
+
},
|
|
1457
|
+
{ status: 400 }
|
|
1458
|
+
);
|
|
1459
|
+
}
|
|
1460
|
+
if (record.linksIssued >= record.maxLinks) {
|
|
1461
|
+
return failure(410, DownloadGrantErrorCode.EXHAUSTED, "This download grant has issued all its links");
|
|
1462
|
+
}
|
|
1463
|
+
const spent = { ...record, linksIssued: record.linksIssued + 1 };
|
|
1464
|
+
await this.storage.put(grantKey(record.id), spent);
|
|
1465
|
+
return Response.json(grantInfoOf(spent));
|
|
1466
|
+
}
|
|
1467
|
+
async refund(body) {
|
|
1468
|
+
const record = await this.recordOf(body.id);
|
|
1469
|
+
if (!record) return notFound2();
|
|
1470
|
+
await this.storage.put(grantKey(record.id), { ...record, linksIssued: Math.max(0, record.linksIssued - 1) });
|
|
1471
|
+
return new Response(null, { status: 204 });
|
|
1472
|
+
}
|
|
1473
|
+
/** Without `secretHash` the route already authorized the owner; with it, only the grant's own secret revokes it. */
|
|
1474
|
+
async revoke(body) {
|
|
1475
|
+
const record = await this.recordOf(body.id);
|
|
1476
|
+
if (!record) return body.secretHash === void 0 ? notFound2() : invalidGrant();
|
|
1477
|
+
const isBearer = body.secretHash !== void 0;
|
|
1478
|
+
if (isBearer && !isConstantTimeEqual(String(body.secretHash), record.secretHash)) return invalidGrant();
|
|
1479
|
+
if (record.revokedAt !== null) return Response.json(grantInfoOf(record));
|
|
1480
|
+
const revoked = { ...record, revokedAt: Date.now() };
|
|
1481
|
+
await this.storage.put(grantKey(record.id), revoked);
|
|
1482
|
+
return Response.json(grantInfoOf(revoked));
|
|
1483
|
+
}
|
|
1484
|
+
async getOne(body) {
|
|
1485
|
+
const record = await this.recordOf(body.id);
|
|
1486
|
+
return record ? Response.json(grantInfoOf(record)) : notFound2();
|
|
1487
|
+
}
|
|
1488
|
+
async records() {
|
|
1489
|
+
const stored = await this.storage.list({ prefix: GRANT_KEY_PREFIX });
|
|
1490
|
+
return [...stored.values()];
|
|
1491
|
+
}
|
|
1492
|
+
async list() {
|
|
1493
|
+
const records = await this.records();
|
|
1494
|
+
return Response.json(records.sort((first, second) => first.createdAt - second.createdAt).map(grantInfoOf));
|
|
1495
|
+
}
|
|
1496
|
+
async revokeAll() {
|
|
1497
|
+
const now = Date.now();
|
|
1498
|
+
for (const record of await this.records()) {
|
|
1499
|
+
if (record.revokedAt === null) await this.storage.put(grantKey(record.id), { ...record, revokedAt: now });
|
|
1500
|
+
}
|
|
1501
|
+
return new Response(null, { status: 204 });
|
|
1502
|
+
}
|
|
1503
|
+
/** Alarm at the earliest expiry among the stored grants; none left: no alarm. */
|
|
1504
|
+
async scheduleAlarm() {
|
|
1505
|
+
const expiries = (await this.records()).map((record) => record.expiresAt);
|
|
1506
|
+
if (expiries.length === 0) {
|
|
1507
|
+
await this.storage.deleteAlarm();
|
|
1508
|
+
return;
|
|
1509
|
+
}
|
|
1510
|
+
await this.storage.setAlarm(Math.min(...expiries));
|
|
1511
|
+
}
|
|
1512
|
+
/** Drops the grants past their expiry (revoked ones stay listed until then) and schedules the next alarm. */
|
|
1513
|
+
async alarm() {
|
|
1514
|
+
const now = Date.now();
|
|
1515
|
+
for (const record of await this.records()) {
|
|
1516
|
+
if (record.expiresAt <= now) await this.storage.delete(grantKey(record.id));
|
|
1517
|
+
}
|
|
1518
|
+
await this.scheduleAlarm();
|
|
1519
|
+
}
|
|
1520
|
+
};
|
|
1521
|
+
|
|
1522
|
+
// src/routes/grant-shared.ts
|
|
1523
|
+
function grantNamespaces(context) {
|
|
1524
|
+
const { DOWNLOAD_GRANTS: grants, DOWNLOAD_LINKS: links } = context.env;
|
|
1525
|
+
return grants && links ? { grants, links } : null;
|
|
1526
|
+
}
|
|
1527
|
+
function notConfigured2(context) {
|
|
1528
|
+
return context.json({ error: "Download grants are not configured", code: "not_configured" }, 501);
|
|
1529
|
+
}
|
|
1530
|
+
function fileGrantsObject(namespace, storageKey2) {
|
|
1531
|
+
const id = namespace.idFromName(storageKey2);
|
|
1532
|
+
return { objectId: id.toString(), stub: namespace.get(id) };
|
|
1533
|
+
}
|
|
1534
|
+
function grantsObjectById(namespace, objectId) {
|
|
1535
|
+
try {
|
|
1536
|
+
return namespace.get(namespace.idFromString(objectId));
|
|
1537
|
+
} catch {
|
|
1538
|
+
return null;
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
function callGrants(stub, path, body = {}) {
|
|
1542
|
+
return stub.fetch(`https://download-grants${path}`, { method: "POST", body: JSON.stringify(body) });
|
|
1543
|
+
}
|
|
1544
|
+
var relayGrantFailure = (response) => new Response(response.body, { status: response.status, headers: response.headers });
|
|
1545
|
+
function grantListEntry(objectId, grant) {
|
|
1546
|
+
return {
|
|
1547
|
+
id: formatGrantPublicId({ objectId, grantId: grant.id }),
|
|
1548
|
+
key: grant.key,
|
|
1549
|
+
expiresAt: grant.expiresAt,
|
|
1550
|
+
link: grant.link,
|
|
1551
|
+
maxLinks: grant.maxLinks,
|
|
1552
|
+
canDelete: grant.canDelete,
|
|
1553
|
+
...grant.metadata ? { metadata: grant.metadata } : {},
|
|
1554
|
+
linksIssued: grant.linksIssued,
|
|
1555
|
+
createdAt: grant.createdAt,
|
|
1556
|
+
revokedAt: grant.revokedAt
|
|
1557
|
+
};
|
|
1558
|
+
}
|
|
1559
|
+
var isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1560
|
+
function parseMetadata(value, maxBytes) {
|
|
1561
|
+
if (value === void 0) return void 0;
|
|
1562
|
+
if (!isPlainObject(value)) return null;
|
|
1563
|
+
if (!Object.values(value).every((entry) => typeof entry === "string")) return null;
|
|
1564
|
+
const size = new TextEncoder().encode(JSON.stringify(value)).byteLength;
|
|
1565
|
+
return size <= maxBytes ? value : null;
|
|
1566
|
+
}
|
|
1567
|
+
function parseCount(value, fallback2, max) {
|
|
1568
|
+
const count = value ?? fallback2;
|
|
1569
|
+
return typeof count === "number" && Number.isSafeInteger(count) && count > 0 && count <= max ? count : null;
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
// src/routes/media-delete.ts
|
|
1573
|
+
import { videoBasePath } from "@devultur/core";
|
|
1574
|
+
|
|
1575
|
+
// src/transcode/raw-retention.ts
|
|
1576
|
+
import { isVideoRawKey as isVideoRawKey2 } from "@devultur/core";
|
|
1577
|
+
var disposalKey = (videoId) => `${KvPrefix.TRANSCODE_RAW}:${videoId}`;
|
|
1578
|
+
async function readRawDisposal(kv, videoId) {
|
|
1579
|
+
return kv.get(disposalKey(videoId), "json");
|
|
1580
|
+
}
|
|
1581
|
+
async function rememberRawDisposal(kv, videoId, disposal) {
|
|
1582
|
+
await kv.put(disposalKey(videoId), JSON.stringify(disposal), { expirationTtl: KV_JOB_TTL_SECONDS });
|
|
1583
|
+
}
|
|
1584
|
+
async function disposeRawAfterTranscode(context, videoId) {
|
|
1585
|
+
const { env } = context;
|
|
1586
|
+
const disposal = await readRawDisposal(env.MEDIA_KV, videoId);
|
|
1587
|
+
if (!disposal?.deleteOnComplete || !disposal.storageKey) return "kept";
|
|
1588
|
+
const raw = await env.MEDIA_BUCKET.head(disposal.storageKey);
|
|
1589
|
+
const location = splitVideoStorageKey(disposal.storageKey);
|
|
1590
|
+
const lock = raw && location ? await effectiveLock(env.MEDIA_BUCKET, location.root, location.key, raw) : null;
|
|
1591
|
+
if (lock?.state && isStateLocked(lock.state, /* @__PURE__ */ new Date())) {
|
|
1592
|
+
await recordLockAudit(context, {
|
|
1593
|
+
action: "original_retained",
|
|
1594
|
+
keys: [location?.key ?? disposal.storageKey],
|
|
1595
|
+
before: lock.state.lock,
|
|
1596
|
+
after: null,
|
|
1597
|
+
outcome: "skipped",
|
|
1598
|
+
root: location?.root ?? ""
|
|
1599
|
+
});
|
|
1600
|
+
return "retained";
|
|
1601
|
+
}
|
|
1602
|
+
await env.MEDIA_BUCKET.delete(disposal.storageKey);
|
|
1603
|
+
if (env.KEYS_BUCKET) await deletePrefix(env.KEYS_BUCKET, `${disposal.storageKey}/`);
|
|
1604
|
+
return "deleted";
|
|
1605
|
+
}
|
|
1606
|
+
var ORIGINAL_NOT_RETAINED = {
|
|
1607
|
+
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.",
|
|
1608
|
+
code: "original_not_retained"
|
|
1609
|
+
};
|
|
1610
|
+
var PRIOR_OUTPUTS_LIST_LIMIT = 100;
|
|
1611
|
+
async function hasPriorTranscodeOutputs(bucket, videoFolder) {
|
|
1612
|
+
const listing = await bucket.list({ prefix: videoFolder, limit: PRIOR_OUTPUTS_LIST_LIMIT });
|
|
1613
|
+
return listing.objects.some((object) => !isVideoRawKey2(object.key));
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
// src/object-lock/transcode-marker.ts
|
|
1617
|
+
var MARKER_NAME = "transcode-completed";
|
|
1618
|
+
var markerKey = (root, videoFolder) => sidecarKey(root, `${videoFolder}/${MARKER_NAME}`);
|
|
1619
|
+
async function markTranscodeCompleted(env, videoId, ticketLocation) {
|
|
1620
|
+
const location = ticketLocation ?? await rememberedLocation(env, videoId);
|
|
1621
|
+
if (!location) return;
|
|
1622
|
+
await env.MEDIA_BUCKET.put(markerKey(location.root, location.folder), (/* @__PURE__ */ new Date()).toISOString(), {
|
|
1623
|
+
httpMetadata: { contentType: "text/plain" }
|
|
1624
|
+
});
|
|
1625
|
+
}
|
|
1626
|
+
async function rememberedLocation(env, videoId) {
|
|
1627
|
+
const disposal = await readRawDisposal(env.MEDIA_KV, videoId);
|
|
1628
|
+
const raw = disposal?.storageKey ? splitVideoStorageKey(disposal.storageKey) : null;
|
|
1629
|
+
const folder = raw?.key.split("/").slice(0, 3).join("/");
|
|
1630
|
+
return raw && folder ? { root: raw.root, folder } : null;
|
|
1631
|
+
}
|
|
1632
|
+
async function hasCompletedTranscode(bucket, root, videoFolder) {
|
|
1633
|
+
return await bucket.head(markerKey(root, videoFolder)) !== null;
|
|
1634
|
+
}
|
|
1635
|
+
async function forgetTranscodeCompleted(bucket, root, videoFolder) {
|
|
1636
|
+
await bucket.delete(markerKey(root, videoFolder));
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
// src/routes/media-delete.ts
|
|
1640
|
+
async function handleVideoDelete(context) {
|
|
1641
|
+
const videoId = context.req.param("videoId") ?? "";
|
|
1642
|
+
const visibilityParam = context.req.query("visibility");
|
|
1643
|
+
const visibilities = visibilityParam === "public" || visibilityParam === "private" ? [visibilityParam] : ["public", "private"];
|
|
1644
|
+
const folders = visibilities.map((visibility) => `${visibility}/${videoBasePath(videoId)}`);
|
|
1645
|
+
const blocked = await assertWritable(context, folders, "delete");
|
|
1646
|
+
if (blocked) return blocked;
|
|
1647
|
+
let deletedCount = 0;
|
|
1648
|
+
let deletedKeyCount = 0;
|
|
1649
|
+
for (const folder of folders) {
|
|
1650
|
+
const deleted = await guardedDeletePrefix(context, `${folder}/`);
|
|
1651
|
+
if ("blocked" in deleted) return deleted.blocked;
|
|
1652
|
+
deletedCount += deleted.deletedCount;
|
|
1653
|
+
if (deleted.deletedCount > 0) {
|
|
1654
|
+
await forgetTranscodeCompleted(context.env.MEDIA_BUCKET, getRequestState(context).storagePrefix, folder);
|
|
1655
|
+
}
|
|
1656
|
+
if (!folder.startsWith("private/")) continue;
|
|
1657
|
+
const deletedKeys = await deleteFolderKeys(context, folder);
|
|
1658
|
+
if ("blocked" in deletedKeys) return deletedKeys.blocked;
|
|
1659
|
+
deletedKeyCount += deletedKeys.deletedCount;
|
|
1660
|
+
}
|
|
1661
|
+
return context.json({ videoId, deletedCount, deletedKeyCount });
|
|
1662
|
+
}
|
|
1663
|
+
function handleKeyDelete(context) {
|
|
1664
|
+
return deleteMediaKey(context, context.req.param("key") ?? "");
|
|
1665
|
+
}
|
|
1666
|
+
async function deleteMediaKey(context, key) {
|
|
1667
|
+
const fullKey = storageKey(context, key);
|
|
1668
|
+
if (isVariantKey(key)) {
|
|
1669
|
+
return context.json({ error: "Not found" }, 404);
|
|
1670
|
+
}
|
|
1671
|
+
if (isReservedLockKey(key)) return reservedKeyResponse(context, key);
|
|
1672
|
+
const exists = await context.env.MEDIA_BUCKET.head(fullKey);
|
|
1673
|
+
if (!exists) {
|
|
1674
|
+
return context.json({ error: "Not found" }, 404);
|
|
1675
|
+
}
|
|
1676
|
+
const blocked = await assertWritable(context, [key], "delete");
|
|
1677
|
+
if (blocked) return blocked;
|
|
1678
|
+
await context.env.MEDIA_BUCKET.delete(fullKey);
|
|
1679
|
+
const variants = await guardedDeletePrefix(context, variantPrefix(key));
|
|
1680
|
+
if ("blocked" in variants) return variants.blocked;
|
|
1681
|
+
const keys = await deleteKeysFor(context, key, exists);
|
|
1682
|
+
if ("blocked" in keys) return keys.blocked;
|
|
1683
|
+
return new Response(null, { status: 204 });
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
// src/routes/grant-bearer.ts
|
|
1687
|
+
var grantError = (status, code, error) => Response.json({ error, code }, { status });
|
|
1688
|
+
var invalidGrant2 = () => grantError(401, DownloadGrantErrorCode2.INVALID, "Invalid download grant");
|
|
1689
|
+
var grantGone2 = () => grantError(410, DownloadGrantErrorCode2.GONE, "This download grant is no longer valid");
|
|
1690
|
+
async function readBearer(context, grants) {
|
|
1691
|
+
const bearer = context.req.header("Authorization")?.match(/^Bearer\s+(\S+)$/i)?.[1];
|
|
1692
|
+
if (!bearer && context.req.query("token") !== void 0) {
|
|
1693
|
+
return grantError(401, DownloadGrantErrorCode2.IN_QUERY, "Send the grant in Authorization: Bearer, never in a URL");
|
|
1694
|
+
}
|
|
1695
|
+
const parsed = bearer ? parseGrantSecret(bearer) : null;
|
|
1696
|
+
const stub = parsed ? grantsObjectById(grants, parsed.objectId) : null;
|
|
1697
|
+
if (!parsed || !stub) return invalidGrant2();
|
|
1698
|
+
return {
|
|
1699
|
+
stub,
|
|
1700
|
+
parsed,
|
|
1701
|
+
secretHash: await hashGrantSecret(parsed.secret),
|
|
1702
|
+
publicId: formatGrantPublicId(parsed)
|
|
1703
|
+
};
|
|
1704
|
+
}
|
|
1705
|
+
async function identifyDownloadGrant(anyContext) {
|
|
1706
|
+
const context = anyContext;
|
|
1707
|
+
const namespaces = grantNamespaces(context);
|
|
1708
|
+
if (!namespaces) return notConfigured2(context);
|
|
1709
|
+
const bearer = await readBearer(context, namespaces.grants);
|
|
1710
|
+
if (bearer instanceof Response) return bearer;
|
|
1711
|
+
const identified = await callGrants(bearer.stub, DownloadGrantPath.IDENTIFY, {
|
|
1712
|
+
id: bearer.parsed.grantId,
|
|
1713
|
+
secretHash: bearer.secretHash
|
|
1714
|
+
});
|
|
1715
|
+
if (!identified.ok) return relayGrantFailure(identified);
|
|
1716
|
+
return { publicId: bearer.publicId, grant: await identified.json() };
|
|
1717
|
+
}
|
|
1718
|
+
async function grantedObject(context, bearer, grant) {
|
|
1719
|
+
const object = await context.env.MEDIA_BUCKET.head(grant.storageKey);
|
|
1720
|
+
if (!object) {
|
|
1721
|
+
await callGrants(bearer.stub, DownloadGrantPath.REVOKE_ALL);
|
|
1722
|
+
return grantGone2();
|
|
1723
|
+
}
|
|
1724
|
+
if (object.version !== grant.version || isObjectExpired(object.customMetadata)) return grantGone2();
|
|
1725
|
+
return object;
|
|
1726
|
+
}
|
|
1727
|
+
async function authorizeGrant(context, action) {
|
|
1728
|
+
const namespaces = grantNamespaces(context);
|
|
1729
|
+
if (!namespaces) return notConfigured2(context);
|
|
1730
|
+
const bearer = await readBearer(context, namespaces.grants);
|
|
1731
|
+
if (bearer instanceof Response) return bearer;
|
|
1732
|
+
const authorized = await callGrants(bearer.stub, DownloadGrantPath.AUTHORIZE, {
|
|
1733
|
+
id: bearer.parsed.grantId,
|
|
1734
|
+
secretHash: bearer.secretHash
|
|
1735
|
+
});
|
|
1736
|
+
if (!authorized.ok) return relayGrantFailure(authorized);
|
|
1737
|
+
const grant = await authorized.json();
|
|
1738
|
+
if (grant.storagePrefix !== getRequestState(context).storagePrefix) return invalidGrant2();
|
|
1739
|
+
const refusal2 = await gateGrantAccess(context, { action, grant });
|
|
1740
|
+
if (refusal2) return refusal2;
|
|
1741
|
+
const object = await grantedObject(context, bearer, grant);
|
|
1742
|
+
if (object instanceof Response) return object;
|
|
1743
|
+
return { ...bearer, grant, object };
|
|
1744
|
+
}
|
|
1745
|
+
function headHeaders({ grant, object }) {
|
|
1746
|
+
const headers = new Headers({
|
|
1747
|
+
"Content-Type": object.httpMetadata?.contentType ?? "application/octet-stream",
|
|
1748
|
+
"Content-Length": String(object.size),
|
|
1749
|
+
ETag: object.httpEtag,
|
|
1750
|
+
"Cache-Control": "private, no-store",
|
|
1751
|
+
[ENCRYPTED_HEADER]: object.customMetadata?.enc ? "true" : "false",
|
|
1752
|
+
[GRANT_EXPIRES_AT_HEADER]: String(grant.expiresAt),
|
|
1753
|
+
[GRANT_KEY_HEADER]: grant.key
|
|
1754
|
+
});
|
|
1755
|
+
applyIntegrityHeaders(headers, object.customMetadata);
|
|
1756
|
+
applyImageSizeHeaders(headers, object.customMetadata);
|
|
1757
|
+
if (grant.metadata) headers.set(GRANT_METADATA_HEADER, encodeURIComponent(JSON.stringify(grant.metadata)));
|
|
1758
|
+
return headers;
|
|
1759
|
+
}
|
|
1760
|
+
async function handleHead(context) {
|
|
1761
|
+
if (context.req.method !== "HEAD") return new Response(null, { status: 405, headers: { Allow: "HEAD" } });
|
|
1762
|
+
const authorized = await authorizeGrant(context, "head");
|
|
1763
|
+
if (authorized instanceof Response) return authorized;
|
|
1764
|
+
return new Response(null, { status: 200, headers: headHeaders(authorized) });
|
|
1765
|
+
}
|
|
1766
|
+
function parseGrantLinkRequest(body, grant) {
|
|
1767
|
+
const defaultExpiresIn = Math.min(DownloadGrantLimits.DEFAULT_LINK_EXPIRES_IN, grant.link.maxExpiresIn);
|
|
1768
|
+
const expiresIn = parseCount(body.expiresIn, defaultExpiresIn, MAX_LINK_TTL_SECONDS);
|
|
1769
|
+
if (expiresIn === null)
|
|
1770
|
+
return { error: "expiresIn must be a positive number of seconds", code: "invalid_expires_in" };
|
|
1771
|
+
const maxUses = parseCount(body.maxUses, 1, Number.MAX_SAFE_INTEGER);
|
|
1772
|
+
if (maxUses === null) return { error: "maxUses must be a positive integer", code: "invalid_max_uses" };
|
|
1773
|
+
const metadata = parseMetadata(body.metadata, DownloadGrantLimits.MAX_METADATA_BYTES);
|
|
1774
|
+
if (metadata === null)
|
|
1775
|
+
return { error: "metadata must map strings to strings, 1 KB at most", code: "invalid_metadata" };
|
|
1776
|
+
const filename = typeof body.filename === "string" && body.filename ? body.filename : void 0;
|
|
1777
|
+
const parsed = {
|
|
1778
|
+
expiresIn,
|
|
1779
|
+
maxUses,
|
|
1780
|
+
...filename ? { filename } : {},
|
|
1781
|
+
...metadata ? { metadata } : {}
|
|
1782
|
+
};
|
|
1783
|
+
return parsed;
|
|
1784
|
+
}
|
|
1785
|
+
async function handleIssueLink(context) {
|
|
1786
|
+
const namespaces = grantNamespaces(context);
|
|
1787
|
+
if (!namespaces) return notConfigured2(context);
|
|
1788
|
+
const authorized = await authorizeGrant(context, "issue-link");
|
|
1789
|
+
if (authorized instanceof Response) return authorized;
|
|
1790
|
+
const { grant, object, stub } = authorized;
|
|
1791
|
+
const parsed = parseGrantLinkRequest(await context.req.json().catch(() => null) ?? {}, grant);
|
|
1792
|
+
if ("error" in parsed) return context.json(parsed, 400);
|
|
1793
|
+
const requestedExpiresAt = Math.min(
|
|
1794
|
+
Date.now() + parsed.expiresIn * 1e3,
|
|
1795
|
+
objectExpiresAt(object.customMetadata) ?? Number.POSITIVE_INFINITY,
|
|
1796
|
+
grant.expiresAt
|
|
1797
|
+
);
|
|
1798
|
+
const decision = await decideCredential(context, {
|
|
1799
|
+
kind: "download-link",
|
|
1800
|
+
scopes: [TokenScope2.MEDIA_READ],
|
|
1801
|
+
path: grant.key,
|
|
1802
|
+
expiresAt: requestedExpiresAt
|
|
1803
|
+
});
|
|
1804
|
+
if (!decision.allow) return decision.response;
|
|
1805
|
+
const credential = { id: authorized.parsed.grantId, secretHash: authorized.secretHash };
|
|
1806
|
+
const spent = await callGrants(stub, DownloadGrantPath.ISSUE, { ...credential, ...parsed });
|
|
1807
|
+
if (!spent.ok) return relayGrantFailure(spent);
|
|
1808
|
+
const expiresAt = cappedExpiry(requestedExpiresAt, decision.expiresAt, (epochMs) => epochMs);
|
|
1809
|
+
const created = await storeLink(context, namespaces.links, {
|
|
1810
|
+
storageKey: grant.storageKey,
|
|
1811
|
+
key: grant.key,
|
|
1812
|
+
expiresAt,
|
|
1813
|
+
maxUses: parsed.maxUses,
|
|
1814
|
+
...parsed.filename ? { filename: parsed.filename } : {},
|
|
1815
|
+
storagePrefix: grant.storagePrefix,
|
|
1816
|
+
...grant.context ? { context: grant.context } : {},
|
|
1817
|
+
grantId: authorized.publicId,
|
|
1818
|
+
...parsed.metadata ? { metadata: parsed.metadata } : {}
|
|
1819
|
+
});
|
|
1820
|
+
if (!created) {
|
|
1821
|
+
await callGrants(stub, DownloadGrantPath.REFUND, { id: credential.id });
|
|
1822
|
+
return context.json({ error: "Could not create the download link" }, 500);
|
|
1823
|
+
}
|
|
1824
|
+
return context.json({ ...created, expiresAt, maxUses: parsed.maxUses });
|
|
1825
|
+
}
|
|
1826
|
+
async function handleDeleteFile(context) {
|
|
1827
|
+
const authorized = await authorizeGrant(context, "delete-file");
|
|
1828
|
+
if (authorized instanceof Response) return authorized;
|
|
1829
|
+
const { grant, stub, publicId } = authorized;
|
|
1830
|
+
if (!grant.canDelete) {
|
|
1831
|
+
return grantError(403, DownloadGrantErrorCode2.CANNOT_DELETE, "This download grant can't delete its file");
|
|
1832
|
+
}
|
|
1833
|
+
const deleted = await deleteMediaKey(context, grant.key);
|
|
1834
|
+
if (deleted.status !== 204) return deleted;
|
|
1835
|
+
await callGrants(stub, DownloadGrantPath.REVOKE_ALL);
|
|
1836
|
+
addRouteResult(context, {
|
|
1837
|
+
grantDeletedKey: grant.key,
|
|
1838
|
+
revokedGrant: { publicId, grant: { ...grant, revokedAt: Date.now() }, by: "bearer" }
|
|
1839
|
+
});
|
|
1840
|
+
return deleted;
|
|
1841
|
+
}
|
|
1842
|
+
async function handleRevoke(context) {
|
|
1843
|
+
const namespaces = grantNamespaces(context);
|
|
1844
|
+
if (!namespaces) return notConfigured2(context);
|
|
1845
|
+
const bearer = await readBearer(context, namespaces.grants);
|
|
1846
|
+
if (bearer instanceof Response) return bearer;
|
|
1847
|
+
const revoked = await callGrants(bearer.stub, DownloadGrantPath.REVOKE, {
|
|
1848
|
+
id: bearer.parsed.grantId,
|
|
1849
|
+
secretHash: bearer.secretHash
|
|
1850
|
+
});
|
|
1851
|
+
if (!revoked.ok) return relayGrantFailure(revoked);
|
|
1852
|
+
const grant = await revoked.json();
|
|
1853
|
+
if (grant.storagePrefix === getRequestState(context).storagePrefix) {
|
|
1854
|
+
addRouteResult(context, { revokedGrant: { publicId: bearer.publicId, grant, by: "bearer" } });
|
|
1855
|
+
}
|
|
1856
|
+
return new Response(null, { status: 204 });
|
|
1857
|
+
}
|
|
1858
|
+
var grantBearer = new Hono3().get("/file", handleHead).post("/links", handleIssueLink).delete("/file", handleDeleteFile).delete("/", handleRevoke);
|
|
1859
|
+
|
|
1860
|
+
// src/routes/grant-owner.ts
|
|
1861
|
+
import { DownloadGrantLimits as DownloadGrantLimits2, relativeMediaPath as relativeMediaPath2, TokenScope as TokenScope3 } from "@devultur/core";
|
|
1862
|
+
import { Hono as Hono4 } from "hono";
|
|
1863
|
+
var GRANT_DELETE_SCOPE = "media:delete";
|
|
1864
|
+
var invalid = (code, error) => ({ error, code });
|
|
1865
|
+
function parseLinkCaps(link) {
|
|
1866
|
+
const maxExpiresIn = parseCount(
|
|
1867
|
+
link?.maxExpiresIn,
|
|
1868
|
+
DownloadGrantLimits2.DEFAULT_LINK_EXPIRES_IN,
|
|
1869
|
+
DownloadGrantLimits2.MAX_LINK_EXPIRES_IN
|
|
1870
|
+
);
|
|
1871
|
+
if (maxExpiresIn === null) {
|
|
1872
|
+
return invalid("invalid_link_caps", `link.maxExpiresIn must be 1..${DownloadGrantLimits2.MAX_LINK_EXPIRES_IN}`);
|
|
1873
|
+
}
|
|
1874
|
+
const maxUses = parseCount(
|
|
1875
|
+
link?.maxUses,
|
|
1876
|
+
DownloadGrantLimits2.DEFAULT_LINK_MAX_USES,
|
|
1877
|
+
DownloadGrantLimits2.MAX_LINK_MAX_USES
|
|
1878
|
+
);
|
|
1879
|
+
if (maxUses === null) {
|
|
1880
|
+
return invalid("invalid_link_caps", `link.maxUses must be 1..${DownloadGrantLimits2.MAX_LINK_MAX_USES}`);
|
|
1881
|
+
}
|
|
1882
|
+
return { maxExpiresIn, maxUses };
|
|
1883
|
+
}
|
|
1884
|
+
function parseGrantRequest(body) {
|
|
1885
|
+
const key = typeof body.key === "string" ? body.key : "";
|
|
1886
|
+
if (relativeMediaPath2(key) === null) return invalid("invalid_key", "key must be a visibility-prefixed media key");
|
|
1887
|
+
const expiresIn = body.expiresIn === void 0 ? void 0 : parseCount(body.expiresIn, 0, Number.MAX_SAFE_INTEGER);
|
|
1888
|
+
if (expiresIn === null) return invalid("invalid_expires_in", "expiresIn must be a positive number of seconds");
|
|
1889
|
+
if (body.link !== void 0 && (typeof body.link !== "object" || body.link === null)) {
|
|
1890
|
+
return invalid("invalid_link_caps", "link must be an object");
|
|
1891
|
+
}
|
|
1892
|
+
const link = parseLinkCaps(body.link);
|
|
1893
|
+
if ("error" in link) return link;
|
|
1894
|
+
const maxLinks = parseCount(body.maxLinks, DownloadGrantLimits2.DEFAULT_MAX_LINKS, DownloadGrantLimits2.MAX_MAX_LINKS);
|
|
1895
|
+
if (maxLinks === null)
|
|
1896
|
+
return invalid("invalid_max_links", `maxLinks must be 1..${DownloadGrantLimits2.MAX_MAX_LINKS}`);
|
|
1897
|
+
if (body.canDelete !== void 0 && typeof body.canDelete !== "boolean") {
|
|
1898
|
+
return invalid("invalid_can_delete", "canDelete must be a boolean");
|
|
1899
|
+
}
|
|
1900
|
+
const metadata = parseMetadata(body.metadata, DownloadGrantLimits2.MAX_METADATA_BYTES);
|
|
1901
|
+
if (metadata === null) {
|
|
1902
|
+
return invalid(
|
|
1903
|
+
"invalid_metadata",
|
|
1904
|
+
`metadata must map strings to strings, ${DownloadGrantLimits2.MAX_METADATA_BYTES} bytes at most`
|
|
1905
|
+
);
|
|
1906
|
+
}
|
|
1907
|
+
return {
|
|
1908
|
+
key,
|
|
1909
|
+
...expiresIn ? { expiresIn } : {},
|
|
1910
|
+
link,
|
|
1911
|
+
maxLinks,
|
|
1912
|
+
canDelete: body.canDelete === true,
|
|
1913
|
+
...metadata ? { metadata } : {}
|
|
1914
|
+
};
|
|
1915
|
+
}
|
|
1916
|
+
function grantExpiry(expiresIn, fileExpiresAt, now) {
|
|
1917
|
+
const cap = fileExpiresAt ?? now + DownloadGrantLimits2.MAX_LIFETIME_SECONDS * 1e3;
|
|
1918
|
+
return expiresIn === void 0 ? cap : Math.min(now + expiresIn * 1e3, cap);
|
|
1919
|
+
}
|
|
1920
|
+
async function handleCreateGrant(context) {
|
|
1921
|
+
const namespaces = grantNamespaces(context);
|
|
1922
|
+
if (!namespaces) return notConfigured2(context);
|
|
1923
|
+
const parsed = parseGrantRequest(await context.req.json().catch(() => null) ?? {});
|
|
1924
|
+
if ("error" in parsed) return context.json(parsed, 400);
|
|
1925
|
+
const fullKey = storageKey(context, parsed.key);
|
|
1926
|
+
const object = await context.env.MEDIA_BUCKET.head(fullKey);
|
|
1927
|
+
if (!object) return context.json({ error: "Not found" }, 404);
|
|
1928
|
+
if (isObjectExpired(object.customMetadata)) return expiredResponse();
|
|
1929
|
+
const requestedExpiresAt = grantExpiry(parsed.expiresIn, objectExpiresAt(object.customMetadata), Date.now());
|
|
1930
|
+
const decision = await decideCredential(context, {
|
|
1931
|
+
kind: "download-grant",
|
|
1932
|
+
scopes: parsed.canDelete ? [TokenScope3.MEDIA_READ, GRANT_DELETE_SCOPE] : [TokenScope3.MEDIA_READ],
|
|
1933
|
+
path: parsed.key,
|
|
1934
|
+
expiresAt: requestedExpiresAt
|
|
1935
|
+
});
|
|
1936
|
+
if (!decision.allow) return decision.response;
|
|
1937
|
+
const { objectId, stub } = fileGrantsObject(namespaces.grants, fullKey);
|
|
1938
|
+
const grantId = newGrantId();
|
|
1939
|
+
const secret = newGrantSecret();
|
|
1940
|
+
const scope = getRequestState(context);
|
|
1941
|
+
const record = {
|
|
1942
|
+
id: grantId,
|
|
1943
|
+
secretHash: await hashGrantSecret(secret),
|
|
1944
|
+
storageKey: fullKey,
|
|
1945
|
+
key: parsed.key,
|
|
1946
|
+
version: object.version,
|
|
1947
|
+
storagePrefix: scope.storagePrefix,
|
|
1948
|
+
expiresAt: cappedExpiry(requestedExpiresAt, decision.expiresAt, (epochMs) => epochMs),
|
|
1949
|
+
link: parsed.link,
|
|
1950
|
+
maxLinks: parsed.maxLinks,
|
|
1951
|
+
canDelete: parsed.canDelete,
|
|
1952
|
+
...parsed.metadata ? { metadata: parsed.metadata } : {},
|
|
1953
|
+
...scope.context ? { context: scope.context } : {},
|
|
1954
|
+
createdAt: Date.now()
|
|
1955
|
+
};
|
|
1956
|
+
const created = await callGrants(stub, DownloadGrantPath.CREATE, record);
|
|
1957
|
+
if (!created.ok) return context.json({ error: "Could not create the download grant" }, 500);
|
|
1958
|
+
const publicId = formatGrantPublicId({ objectId, grantId });
|
|
1959
|
+
const { secretHash: _secretHash, ...info } = record;
|
|
1960
|
+
addRouteResult(context, { issuedGrant: { publicId, grant: { ...info, linksIssued: 0, revokedAt: null } } });
|
|
1961
|
+
const grant = {
|
|
1962
|
+
grant: formatGrantSecret({ objectId, grantId, secret }),
|
|
1963
|
+
id: publicId,
|
|
1964
|
+
key: record.key,
|
|
1965
|
+
expiresAt: record.expiresAt,
|
|
1966
|
+
link: record.link,
|
|
1967
|
+
maxLinks: record.maxLinks,
|
|
1968
|
+
canDelete: record.canDelete,
|
|
1969
|
+
...record.metadata ? { metadata: record.metadata } : {}
|
|
1970
|
+
};
|
|
1971
|
+
return context.json(grant);
|
|
1972
|
+
}
|
|
1973
|
+
async function handleListGrants(context) {
|
|
1974
|
+
const namespaces = grantNamespaces(context);
|
|
1975
|
+
if (!namespaces) return notConfigured2(context);
|
|
1976
|
+
const key = context.req.query("key") ?? "";
|
|
1977
|
+
if (relativeMediaPath2(key) === null) {
|
|
1978
|
+
return context.json({ error: "key must be a visibility-prefixed media key", code: "invalid_key" }, 400);
|
|
1979
|
+
}
|
|
1980
|
+
const { objectId, stub } = fileGrantsObject(namespaces.grants, storageKey(context, key));
|
|
1981
|
+
const listed = await callGrants(stub, DownloadGrantPath.LIST);
|
|
1982
|
+
if (!listed.ok) return context.json({ error: "Could not list the download grants" }, 500);
|
|
1983
|
+
const grants = await listed.json();
|
|
1984
|
+
return context.json(grants.map((grant) => grantListEntry(objectId, grant)));
|
|
1985
|
+
}
|
|
1986
|
+
var grantNotFound = (context) => context.json({ error: "Download grant not found", code: "grant_not_found" }, 404);
|
|
1987
|
+
async function handleRevokeGrant(context) {
|
|
1988
|
+
const namespaces = grantNamespaces(context);
|
|
1989
|
+
if (!namespaces) return notConfigured2(context);
|
|
1990
|
+
const publicId = context.req.param("id") ?? "";
|
|
1991
|
+
const location = parseGrantPublicId(publicId);
|
|
1992
|
+
const stub = location ? grantsObjectById(namespaces.grants, location.objectId) : null;
|
|
1993
|
+
if (!location || !stub) return grantNotFound(context);
|
|
1994
|
+
const found = await callGrants(stub, DownloadGrantPath.GET, { id: location.grantId });
|
|
1995
|
+
if (!found.ok) return grantNotFound(context);
|
|
1996
|
+
const grant = await found.json();
|
|
1997
|
+
if (grant.storagePrefix !== getRequestState(context).storagePrefix) return grantNotFound(context);
|
|
1998
|
+
const refusal2 = await gateGrantAccess(context, { action: "owner-revoke", grant });
|
|
1999
|
+
if (refusal2) return refusal2;
|
|
2000
|
+
const revoked = await callGrants(stub, DownloadGrantPath.REVOKE, { id: location.grantId });
|
|
2001
|
+
if (!revoked.ok) return context.json({ error: "Could not revoke the download grant" }, 500);
|
|
2002
|
+
addRouteResult(context, {
|
|
2003
|
+
revokedGrant: { publicId, grant: await revoked.json(), by: "owner" }
|
|
2004
|
+
});
|
|
2005
|
+
return new Response(null, { status: 204 });
|
|
2006
|
+
}
|
|
2007
|
+
var grantOwner = new Hono4().post("/", handleCreateGrant).get("/", handleListGrants).delete("/:id", handleRevokeGrant);
|
|
2008
|
+
|
|
1277
2009
|
// src/plugins/download-links.ts
|
|
1278
2010
|
function downloadLinks() {
|
|
1279
|
-
const issue = new
|
|
2011
|
+
const issue = new Hono5().post("/", handleDownloadLink);
|
|
1280
2012
|
return {
|
|
1281
2013
|
name: "download-links",
|
|
1282
2014
|
routes: [
|
|
1283
2015
|
{ path: "/v1/token/download", app: issue },
|
|
1284
|
-
{ path: "/v1/download", app: download }
|
|
2016
|
+
{ path: "/v1/download", app: download },
|
|
2017
|
+
{ path: "/v1/token/grant", app: grantOwner },
|
|
2018
|
+
{ path: "/v1/grant", app: grantBearer }
|
|
1285
2019
|
]
|
|
1286
2020
|
};
|
|
1287
2021
|
}
|
|
@@ -1336,7 +2070,7 @@ import {
|
|
|
1336
2070
|
} from "@devultur/core";
|
|
1337
2071
|
|
|
1338
2072
|
// src/routes/viewer-token-claims.ts
|
|
1339
|
-
import { coversScope, isWithinPathPrefix, relativeMediaPath as
|
|
2073
|
+
import { coversScope, isWithinPathPrefix, relativeMediaPath as relativeMediaPath3, TokenScope as TokenScope4 } from "@devultur/core";
|
|
1340
2074
|
var TOKEN_REJECTED_CODE = "token_rejected";
|
|
1341
2075
|
function isOtherAudience(payload, storagePrefix2) {
|
|
1342
2076
|
if (payload.aud === void 0) return false;
|
|
@@ -1345,12 +2079,12 @@ function isOtherAudience(payload, storagePrefix2) {
|
|
|
1345
2079
|
function isOutsideResource(payload, key) {
|
|
1346
2080
|
if (payload.res === void 0) return false;
|
|
1347
2081
|
if (typeof payload.res !== "string") return true;
|
|
1348
|
-
const relativePath =
|
|
2082
|
+
const relativePath = relativeMediaPath3(key);
|
|
1349
2083
|
return relativePath === null || !isWithinPathPrefix(relativePath, payload.res);
|
|
1350
2084
|
}
|
|
1351
2085
|
function lacksReadScope(payload) {
|
|
1352
2086
|
const scopes = readTokenScopes(payload);
|
|
1353
|
-
return scopes !== void 0 && !coversScope(scopes,
|
|
2087
|
+
return scopes !== void 0 && !coversScope(scopes, TokenScope4.MEDIA_READ);
|
|
1354
2088
|
}
|
|
1355
2089
|
function rejectionReason(payload, target) {
|
|
1356
2090
|
if (isOtherAudience(payload, target.storagePrefix)) return "audience";
|
|
@@ -1368,7 +2102,7 @@ function viewerTokenClaimRejection(payload, target) {
|
|
|
1368
2102
|
var MAX_FILE_TTL_SECONDS = 365 * 24 * 60 * 60;
|
|
1369
2103
|
var MAX_FILES_COUNTER_TTL_SECONDS = 24 * 60 * 60;
|
|
1370
2104
|
var MAX_FILES_PREFIX = "upload-token-files";
|
|
1371
|
-
var
|
|
2105
|
+
var failure2 = (status, code, error) => ({
|
|
1372
2106
|
status,
|
|
1373
2107
|
code,
|
|
1374
2108
|
error
|
|
@@ -1384,12 +2118,12 @@ function generatedKindPath(mediaType, id, filename) {
|
|
|
1384
2118
|
}
|
|
1385
2119
|
function tokenRejected(reason, path) {
|
|
1386
2120
|
console.warn(`[upload] token rejected (${reason}) for path ${String(path)}`);
|
|
1387
|
-
return
|
|
2121
|
+
return failure2(403, TOKEN_REJECTED_CODE, "Token rejected");
|
|
1388
2122
|
}
|
|
1389
2123
|
function assetTarget(rawPath, visibility, grant) {
|
|
1390
2124
|
const assetPath = normalizeAssetPath(rawPath);
|
|
1391
2125
|
if (!assetPath) {
|
|
1392
|
-
return
|
|
2126
|
+
return failure2(400, "invalid_path", "path must be a relative path of [A-Za-z0-9._-] segments, without `..`");
|
|
1393
2127
|
}
|
|
1394
2128
|
const relativePath = assetRelativePath(assetPath);
|
|
1395
2129
|
if (grant && !isWithinPathPrefix2(relativePath, grant.pathPrefix)) return tokenRejected("resource", relativePath);
|
|
@@ -1398,7 +2132,7 @@ function assetTarget(rawPath, visibility, grant) {
|
|
|
1398
2132
|
}
|
|
1399
2133
|
function resolveUploadTarget(body, mediaType, visibility, grant) {
|
|
1400
2134
|
if (grant && body.path === void 0) {
|
|
1401
|
-
return
|
|
2135
|
+
return failure2(400, "path_required", "an upload token can only create files at an explicit `path`");
|
|
1402
2136
|
}
|
|
1403
2137
|
if (grant && body.visibility && body.visibility !== grant.visibility) return tokenRejected("visibility", body.path);
|
|
1404
2138
|
if (body.path !== void 0) return assetTarget(body.path, grant?.visibility ?? visibility, grant);
|
|
@@ -1409,7 +2143,7 @@ function resolveExpiresAt(requested, grant, now, maxTtlSeconds) {
|
|
|
1409
2143
|
const hasRequest = requested !== void 0 && requested !== null;
|
|
1410
2144
|
const isValid = typeof requested === "number" && Number.isInteger(requested) && requested > 0;
|
|
1411
2145
|
if (hasRequest && (!isValid || requested > MAX_FILE_TTL_SECONDS)) {
|
|
1412
|
-
return
|
|
2146
|
+
return failure2(400, "invalid_expires_in", "expiresIn must be a positive integer number of seconds (max 1 year)");
|
|
1413
2147
|
}
|
|
1414
2148
|
const candidates = [hasRequest ? requested : void 0, grant?.fileExpiresIn, maxTtlSeconds];
|
|
1415
2149
|
const ttls = candidates.filter((ttl) => typeof ttl === "number" && ttl > 0);
|
|
@@ -1417,7 +2151,7 @@ function resolveExpiresAt(requested, grant, now, maxTtlSeconds) {
|
|
|
1417
2151
|
}
|
|
1418
2152
|
function resolveDeclaredSha256(raw) {
|
|
1419
2153
|
if (raw === void 0 || raw === null) return void 0;
|
|
1420
|
-
return normalizeSha2562(raw) ??
|
|
2154
|
+
return normalizeSha2562(raw) ?? failure2(400, "invalid_sha256", "sha256 must be a 64-character hex string");
|
|
1421
2155
|
}
|
|
1422
2156
|
function effectiveMaxBytes(ruleMax, grant) {
|
|
1423
2157
|
const limits = [ruleMax, grant?.maxBytes].filter((limit) => typeof limit === "number");
|
|
@@ -1428,7 +2162,7 @@ async function claimTokenFileSlot(kv, grant) {
|
|
|
1428
2162
|
const counterKey = `${MAX_FILES_PREFIX}:${grant.tokenId}`;
|
|
1429
2163
|
const used = Number(await kv.get(counterKey) ?? "0");
|
|
1430
2164
|
if (used >= grant.maxFiles) {
|
|
1431
|
-
return
|
|
2165
|
+
return failure2(403, "max_files_reached", `the upload token already created ${grant.maxFiles} files`);
|
|
1432
2166
|
}
|
|
1433
2167
|
await kv.put(counterKey, String(used + 1), { expirationTtl: MAX_FILES_COUNTER_TTL_SECONDS });
|
|
1434
2168
|
return null;
|
|
@@ -1436,7 +2170,7 @@ async function claimTokenFileSlot(kv, grant) {
|
|
|
1436
2170
|
function resolveObjectCacheControl(requested, target, rules) {
|
|
1437
2171
|
if (!target.isAsset || !target.assetPath) return void 0;
|
|
1438
2172
|
if (requested !== void 0 && requested !== null) {
|
|
1439
|
-
return resolveCacheControl(requested) ??
|
|
2173
|
+
return resolveCacheControl(requested) ?? failure2(400, "invalid_cache_control", "cacheControl must be immutable, revalidate, no-cache or a valid header");
|
|
1440
2174
|
}
|
|
1441
2175
|
return matchCacheRule(rules, target.assetPath)?.cacheControl ?? DEFAULT_ASSET_CACHE_CONTROL;
|
|
1442
2176
|
}
|
|
@@ -1449,10 +2183,10 @@ function inferMediaType(contentType) {
|
|
|
1449
2183
|
function resolveUploadMediaType(declared, contentType, isAssetRequest) {
|
|
1450
2184
|
const inferredType = inferMediaType(contentType);
|
|
1451
2185
|
if (declared && inferredType && declared !== inferredType) {
|
|
1452
|
-
return
|
|
2186
|
+
return failure2(400, "media_type_mismatch", `mediaType "${declared}" does not match content type ${contentType}`);
|
|
1453
2187
|
}
|
|
1454
2188
|
const mediaType = declared ?? inferredType ?? (isAssetRequest ? MediaType.FILE : null);
|
|
1455
|
-
return mediaType ??
|
|
2189
|
+
return mediaType ?? failure2(400, "unsupported_content_type", `Unsupported content type: ${contentType}`);
|
|
1456
2190
|
}
|
|
1457
2191
|
|
|
1458
2192
|
// src/routes/token-upload.ts
|
|
@@ -1560,6 +2294,7 @@ var WorkerAuthErrorCode = {
|
|
|
1560
2294
|
var MEDIA_PREFIX = "/v1/media/";
|
|
1561
2295
|
var DOWNLOAD_PREFIX = "/v1/download/";
|
|
1562
2296
|
var WEBHOOKS_PREFIX = "/v1/webhooks/";
|
|
2297
|
+
var GRANT_PATH = "/v1/grant";
|
|
1563
2298
|
var UPLOAD_CREATE_PATH = "/v1/upload";
|
|
1564
2299
|
var UPLOAD_URL_PREFIX = "/v1/upload/";
|
|
1565
2300
|
var isReadMethod = (method) => method === "GET" || method === "HEAD";
|
|
@@ -1568,6 +2303,7 @@ function isOpenRoute(method, path) {
|
|
|
1568
2303
|
if (path === "/health") return isReadMethod(method);
|
|
1569
2304
|
if (path === "/v1/metrics/playback") return method === "POST";
|
|
1570
2305
|
if (path.startsWith(MEDIA_PREFIX) || path.startsWith(DOWNLOAD_PREFIX)) return isReadMethod(method);
|
|
2306
|
+
if (path === GRANT_PATH || path.startsWith(`${GRANT_PATH}/`)) return true;
|
|
1571
2307
|
return path.startsWith(WEBHOOKS_PREFIX) && method === "POST";
|
|
1572
2308
|
}
|
|
1573
2309
|
function isUploadUrl(path) {
|
|
@@ -1631,7 +2367,7 @@ function defaultPlugins(config = {}) {
|
|
|
1631
2367
|
|
|
1632
2368
|
// src/routes/assets.ts
|
|
1633
2369
|
import { ASSET_NAMESPACE, normalizeAssetPrefix as normalizeAssetPrefix2 } from "@devultur/core";
|
|
1634
|
-
import { Hono as
|
|
2370
|
+
import { Hono as Hono6 } from "hono";
|
|
1635
2371
|
|
|
1636
2372
|
// src/object-lock/delivery.ts
|
|
1637
2373
|
import { effectiveUntil as effectiveUntil3 } from "@devultur/core";
|
|
@@ -1738,7 +2474,7 @@ async function listedEntries(bucket, listing, storageRoot, visibility) {
|
|
|
1738
2474
|
}
|
|
1739
2475
|
return page;
|
|
1740
2476
|
}
|
|
1741
|
-
var assets = new
|
|
2477
|
+
var assets = new Hono6();
|
|
1742
2478
|
assets.get("/", async (context) => {
|
|
1743
2479
|
const prefix = parseListPrefix(context.req.query("prefix"));
|
|
1744
2480
|
if (prefix === null) {
|
|
@@ -1774,7 +2510,7 @@ import {
|
|
|
1774
2510
|
resolveSecurity,
|
|
1775
2511
|
visibilityFromKey
|
|
1776
2512
|
} from "@devultur/core";
|
|
1777
|
-
import { Hono as
|
|
2513
|
+
import { Hono as Hono8 } from "hono";
|
|
1778
2514
|
|
|
1779
2515
|
// src/keys-bucket.ts
|
|
1780
2516
|
import { decodeEncryptionMeta as decodeEncryptionMeta3 } from "@devultur/core";
|
|
@@ -1852,70 +2588,6 @@ async function applyStatusEvent(kv, event) {
|
|
|
1852
2588
|
await kv.put(statusKey(videoId), JSON.stringify(record), { expirationTtl: KV_JOB_TTL_SECONDS });
|
|
1853
2589
|
}
|
|
1854
2590
|
|
|
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
2591
|
// src/object-lock/overwrite.ts
|
|
1920
2592
|
function sameObject(object) {
|
|
1921
2593
|
return {
|
|
@@ -2059,7 +2731,7 @@ function logCaptionsError(context, error) {
|
|
|
2059
2731
|
}
|
|
2060
2732
|
|
|
2061
2733
|
// src/routes/webhooks.ts
|
|
2062
|
-
import { Hono as
|
|
2734
|
+
import { Hono as Hono7 } from "hono";
|
|
2063
2735
|
|
|
2064
2736
|
// src/transcode/metrics.ts
|
|
2065
2737
|
var OPERATOR_EVENT_FIELDS = ["encode", "posterKey", "posterFormats"];
|
|
@@ -2178,7 +2850,7 @@ async function loadOutputBaseUrl(kv, videoId, fallbackUrl) {
|
|
|
2178
2850
|
}
|
|
2179
2851
|
|
|
2180
2852
|
// src/routes/webhooks.ts
|
|
2181
|
-
var webhooks = new
|
|
2853
|
+
var webhooks = new Hono7();
|
|
2182
2854
|
webhooks.post("/transcode", async (context) => {
|
|
2183
2855
|
const webhookSecret = context.env.TRANSCODE_WEBHOOK_SECRET;
|
|
2184
2856
|
if (!webhookSecret) {
|
|
@@ -2400,7 +3072,7 @@ async function transcodedDuration(kv, videoId) {
|
|
|
2400
3072
|
const meta = await kv.get(`${KvPrefix.TRANSCODE_META}:${videoId}`, "json");
|
|
2401
3073
|
return meta?.duration ?? void 0;
|
|
2402
3074
|
}
|
|
2403
|
-
var captions = new
|
|
3075
|
+
var captions = new Hono8();
|
|
2404
3076
|
captions.post("/", async (context) => {
|
|
2405
3077
|
const body = await context.req.json();
|
|
2406
3078
|
if (!body.key || !body.locales?.length) {
|
|
@@ -2511,19 +3183,19 @@ captions.post("/", async (context) => {
|
|
|
2511
3183
|
});
|
|
2512
3184
|
} catch (err) {
|
|
2513
3185
|
logCaptionsError(`job failed for ${videoId}`, err);
|
|
2514
|
-
const
|
|
3186
|
+
const failure5 = toCaptionsFailure(err);
|
|
2515
3187
|
await forwardToClientWebhook(
|
|
2516
3188
|
{
|
|
2517
3189
|
event: "captions.failed",
|
|
2518
3190
|
videoId,
|
|
2519
|
-
error:
|
|
2520
|
-
reason:
|
|
2521
|
-
retriable:
|
|
3191
|
+
error: failure5.error,
|
|
3192
|
+
reason: failure5.reason,
|
|
3193
|
+
retriable: failure5.retriable
|
|
2522
3194
|
},
|
|
2523
3195
|
webhookData
|
|
2524
3196
|
);
|
|
2525
3197
|
const conflict = captionConflictResponse(context, err);
|
|
2526
|
-
return conflict ?? context.json({ error:
|
|
3198
|
+
return conflict ?? context.json({ error: failure5.error, code: failure5.code }, failure5.status);
|
|
2527
3199
|
}
|
|
2528
3200
|
});
|
|
2529
3201
|
captions.get("/:transcriptId", async (context) => {
|
|
@@ -2552,7 +3224,7 @@ import {
|
|
|
2552
3224
|
toPrivateCacheControl,
|
|
2553
3225
|
visibilityFromKey as visibilityFromKey2
|
|
2554
3226
|
} from "@devultur/core";
|
|
2555
|
-
import { Hono as
|
|
3227
|
+
import { Hono as Hono9 } from "hono";
|
|
2556
3228
|
|
|
2557
3229
|
// src/image/cloudflare.ts
|
|
2558
3230
|
var IMAGE_RESIZING_VIA = "image-resizing";
|
|
@@ -2964,52 +3636,6 @@ function cacheDelivered(waitUntil, target, delivered) {
|
|
|
2964
3636
|
}
|
|
2965
3637
|
}
|
|
2966
3638
|
|
|
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
3639
|
// src/routes/storyboard-vtt.ts
|
|
3014
3640
|
import { createDecryptStream as createDecryptStream3 } from "@devultur/core";
|
|
3015
3641
|
var STORYBOARD_VTT = /(?:^|\/)video\/[^/]+\/storyboard\/[^/]+\.vtt$/;
|
|
@@ -3038,7 +3664,7 @@ function downloadDisposition(storedDisposition, key, filename) {
|
|
|
3038
3664
|
if (storedDisposition?.startsWith("inline")) return `attachment${storedDisposition.slice("inline".length)}`;
|
|
3039
3665
|
return contentDispositionAttachment(key.split("/").pop() || "download");
|
|
3040
3666
|
}
|
|
3041
|
-
var media = new
|
|
3667
|
+
var media = new Hono9();
|
|
3042
3668
|
media.use("*", contentSafety);
|
|
3043
3669
|
media.get("/:key{.+}", async (context) => {
|
|
3044
3670
|
const key = context.req.param("key");
|
|
@@ -3237,10 +3863,10 @@ async function serveEncryptionKey(context, key, tokenPayload) {
|
|
|
3237
3863
|
}
|
|
3238
3864
|
|
|
3239
3865
|
// src/routes/token.ts
|
|
3240
|
-
import { computeFingerprint as computeFingerprint2, DEFAULT_SECURITY, TokenScope as
|
|
3241
|
-
import { Hono as
|
|
3866
|
+
import { computeFingerprint as computeFingerprint2, DEFAULT_SECURITY, TokenScope as TokenScope5 } from "@devultur/core";
|
|
3867
|
+
import { Hono as Hono10 } from "hono";
|
|
3242
3868
|
var DEFAULT_VIEWER_TOKEN_TTL_SECONDS = 7200;
|
|
3243
|
-
var token = new
|
|
3869
|
+
var token = new Hono10();
|
|
3244
3870
|
token.post("/", async (context) => {
|
|
3245
3871
|
const body = await context.req.json();
|
|
3246
3872
|
if (!body.userId) {
|
|
@@ -3253,7 +3879,7 @@ token.post("/", async (context) => {
|
|
|
3253
3879
|
);
|
|
3254
3880
|
}
|
|
3255
3881
|
const clientIp = context.req.header("CF-Connecting-IP") ?? context.req.header("X-Forwarded-For") ?? "unknown";
|
|
3256
|
-
const requestedScopes = body.scopes ?? [
|
|
3882
|
+
const requestedScopes = body.scopes ?? [TokenScope5.MEDIA_READ];
|
|
3257
3883
|
if (!Array.isArray(requestedScopes) || hasInvalidScope(requestedScopes)) {
|
|
3258
3884
|
return context.json({ error: "scopes must be non-empty strings without spaces", code: "invalid_scopes" }, 400);
|
|
3259
3885
|
}
|
|
@@ -3301,7 +3927,7 @@ import {
|
|
|
3301
3927
|
generateId as generateId2,
|
|
3302
3928
|
resolveSecurity as resolveSecurity3,
|
|
3303
3929
|
resolveTranscodePreset,
|
|
3304
|
-
TokenScope as
|
|
3930
|
+
TokenScope as TokenScope6,
|
|
3305
3931
|
TranscodeStatus as TranscodeStatus3,
|
|
3306
3932
|
videoBasePath as videoBasePath2,
|
|
3307
3933
|
videoHlsPath,
|
|
@@ -3311,7 +3937,7 @@ import {
|
|
|
3311
3937
|
videoThumbnailPath,
|
|
3312
3938
|
visibilityFromKey as visibilityFromKey3
|
|
3313
3939
|
} from "@devultur/core";
|
|
3314
|
-
import { Hono as
|
|
3940
|
+
import { Hono as Hono11 } from "hono";
|
|
3315
3941
|
|
|
3316
3942
|
// src/transcode/plan.ts
|
|
3317
3943
|
import { MAIN_VARIANT } from "@devultur/core";
|
|
@@ -3586,7 +4212,7 @@ function postMachine(request, retry) {
|
|
|
3586
4212
|
retry
|
|
3587
4213
|
);
|
|
3588
4214
|
}
|
|
3589
|
-
async function
|
|
4215
|
+
async function failure3(response, error) {
|
|
3590
4216
|
const detail = await response.text();
|
|
3591
4217
|
const status = response.status === 429 || response.status >= 500 ? 503 : 500;
|
|
3592
4218
|
console.error(`[transcode] fly: ${error} (status ${response.status}):`, detail);
|
|
@@ -3598,7 +4224,7 @@ async function createFlyMachine(request, retry) {
|
|
|
3598
4224
|
const errorBody = await response.text();
|
|
3599
4225
|
const isSuspended = response.status === 412 || errorBody.includes("suspended");
|
|
3600
4226
|
if (!isSuspended) {
|
|
3601
|
-
return
|
|
4227
|
+
return failure3(new Response(errorBody, { status: response.status }), "Failed to create transcode job");
|
|
3602
4228
|
}
|
|
3603
4229
|
if (!await resumeFlyApp(request.appName, request.apiToken)) {
|
|
3604
4230
|
console.error("[transcode] fly: failed to resume suspended app:", errorBody);
|
|
@@ -3612,7 +4238,7 @@ async function createFlyMachine(request, retry) {
|
|
|
3612
4238
|
}
|
|
3613
4239
|
const retried = await postMachine(request, retry);
|
|
3614
4240
|
if (retried.ok) return { ok: true, machine: await retried.json() };
|
|
3615
|
-
return
|
|
4241
|
+
return failure3(retried, "Failed to create transcode job after resume");
|
|
3616
4242
|
}
|
|
3617
4243
|
function createFlyRunner(env, retry) {
|
|
3618
4244
|
const guest = flyGuestFromEnv(env);
|
|
@@ -4250,7 +4876,7 @@ function parseSecurityMeta2(raw) {
|
|
|
4250
4876
|
}
|
|
4251
4877
|
var SERVICE_TOKEN_TTL_SECONDS = 3600;
|
|
4252
4878
|
var isVideoId = (videoId, key) => Boolean(videoId) && videoId !== key;
|
|
4253
|
-
var transcode = new
|
|
4879
|
+
var transcode = new Hono11();
|
|
4254
4880
|
transcode.post("/", async (context) => {
|
|
4255
4881
|
const body = await context.req.json();
|
|
4256
4882
|
if (!body.key) {
|
|
@@ -4310,7 +4936,7 @@ transcode.post("/", async (context) => {
|
|
|
4310
4936
|
const requestedExp = nowSeconds + SERVICE_TOKEN_TTL_SECONDS;
|
|
4311
4937
|
const decision = await decideCredential(context, {
|
|
4312
4938
|
kind: "service",
|
|
4313
|
-
scopes: [
|
|
4939
|
+
scopes: [TokenScope6.MEDIA_READ],
|
|
4314
4940
|
path: body.key,
|
|
4315
4941
|
expiresAt: requestedExp * 1e3
|
|
4316
4942
|
});
|
|
@@ -4320,7 +4946,7 @@ transcode.post("/", async (context) => {
|
|
|
4320
4946
|
{
|
|
4321
4947
|
sub: `${SERVICE_SUBJECT_PREFIX}${videoId}`,
|
|
4322
4948
|
typ: SERVICE_TOKEN_TYPE,
|
|
4323
|
-
...scopeClaim([
|
|
4949
|
+
...scopeClaim([TokenScope6.MEDIA_READ]),
|
|
4324
4950
|
...audienceClaim(scope.storagePrefix),
|
|
4325
4951
|
bind: { sessionBinding: false, ipBinding: false },
|
|
4326
4952
|
iat: nowSeconds,
|
|
@@ -4442,16 +5068,16 @@ transcode.get("/:jobId", async (context) => {
|
|
|
4442
5068
|
|
|
4443
5069
|
// src/routes/upload.ts
|
|
4444
5070
|
import { MediaType as MediaType2, resolveSecurity as resolveSecurity5, resolveVisibility, UploadRequestKind as UploadRequestKind2 } from "@devultur/core";
|
|
4445
|
-
import { Hono as
|
|
5071
|
+
import { Hono as Hono12 } from "hono";
|
|
4446
5072
|
|
|
4447
5073
|
// src/object-lock/upload-lock.ts
|
|
4448
5074
|
import { canChange as canChange3, resolveLock, validateLock as validateLock6 } from "@devultur/core";
|
|
4449
|
-
var
|
|
5075
|
+
var failure4 = (status, code, error) => ({ status, code, error });
|
|
4450
5076
|
function requestedLock(lock, hold) {
|
|
4451
|
-
if (hold !== void 0 && typeof hold !== "boolean") return
|
|
5077
|
+
if (hold !== void 0 && typeof hold !== "boolean") return failure4(400, "invalid_lock", "hold must be a boolean");
|
|
4452
5078
|
if (lock === void 0 && hold === void 0) return void 0;
|
|
4453
5079
|
const validation = validateLock6(lock ?? {});
|
|
4454
|
-
if (!validation.ok) return
|
|
5080
|
+
if (!validation.ok) return failure4(400, "invalid_lock", validation.errors.join("; "));
|
|
4455
5081
|
return { ...validation.lock, ...hold !== void 0 ? { hold } : {} };
|
|
4456
5082
|
}
|
|
4457
5083
|
function satisfiesMinimum(minimum, lock, now) {
|
|
@@ -4464,10 +5090,10 @@ function resolveUploadLock(input) {
|
|
|
4464
5090
|
if (requested && "code" in requested) return requested;
|
|
4465
5091
|
const minimum = input.grant?.lock;
|
|
4466
5092
|
const resolution = resolveLock(requested ?? minimum, input.appDefault);
|
|
4467
|
-
if (!resolution.ok) return
|
|
5093
|
+
if (!resolution.ok) return failure4(400, "invalid_lock", resolution.errors.join("; "));
|
|
4468
5094
|
const lock = resolution.lock;
|
|
4469
5095
|
if (minimum && !satisfiesMinimum(minimum, lock, input.now)) {
|
|
4470
|
-
return
|
|
5096
|
+
return failure4(403, "lock_below_minimum", "The upload token requires a stricter lock than the request asks for");
|
|
4471
5097
|
}
|
|
4472
5098
|
return lock.retention || lock.hold ? lock : void 0;
|
|
4473
5099
|
}
|
|
@@ -5175,7 +5801,7 @@ async function handleMultipartAbort(context) {
|
|
|
5175
5801
|
}
|
|
5176
5802
|
|
|
5177
5803
|
// src/routes/upload.ts
|
|
5178
|
-
var upload = new
|
|
5804
|
+
var upload = new Hono12();
|
|
5179
5805
|
upload.post("/", async (context) => {
|
|
5180
5806
|
const body = await context.req.json();
|
|
5181
5807
|
const isAssetRequest = body.path !== void 0;
|
|
@@ -5658,7 +6284,7 @@ async function retainedSummary(options) {
|
|
|
5658
6284
|
// src/index.ts
|
|
5659
6285
|
function createWorker(options) {
|
|
5660
6286
|
const { middleware, routes, hooks } = composePlugins(options?.plugins ?? defaultPlugins());
|
|
5661
|
-
const app = new
|
|
6287
|
+
const app = new Hono13();
|
|
5662
6288
|
app.use("*", logger());
|
|
5663
6289
|
app.use(
|
|
5664
6290
|
"*",
|
|
@@ -5666,7 +6292,17 @@ function createWorker(options) {
|
|
|
5666
6292
|
origin: "*",
|
|
5667
6293
|
allowMethods: ["GET", "HEAD", "POST", "PUT", "DELETE", "OPTIONS"],
|
|
5668
6294
|
allowHeaders: ["Content-Type", "Authorization", "Range", SESSION_HEADER2, SHA256_HEADER3],
|
|
5669
|
-
exposeHeaders: [
|
|
6295
|
+
exposeHeaders: [
|
|
6296
|
+
SHA256_HEADER3,
|
|
6297
|
+
SHA256_VERIFIED_HEADER2,
|
|
6298
|
+
REPR_DIGEST_HEADER2,
|
|
6299
|
+
EXPIRES_AT_HEADER2,
|
|
6300
|
+
ENCRYPTED_HEADER2,
|
|
6301
|
+
GRANT_EXPIRES_AT_HEADER2,
|
|
6302
|
+
GRANT_KEY_HEADER2,
|
|
6303
|
+
GRANT_METADATA_HEADER2,
|
|
6304
|
+
"Content-Range"
|
|
6305
|
+
]
|
|
5670
6306
|
})
|
|
5671
6307
|
);
|
|
5672
6308
|
app.use("*", async (context, next) => {
|
|
@@ -5694,6 +6330,7 @@ function createWorker(options) {
|
|
|
5694
6330
|
return app;
|
|
5695
6331
|
}
|
|
5696
6332
|
export {
|
|
6333
|
+
DownloadGrants,
|
|
5697
6334
|
DownloadLink,
|
|
5698
6335
|
ENCRYPTION_KEY_MISSING_CODE,
|
|
5699
6336
|
HOLD_PAGE_SIZE,
|
|
@@ -5724,6 +6361,7 @@ export {
|
|
|
5724
6361
|
getRequestState,
|
|
5725
6362
|
getRouteResult,
|
|
5726
6363
|
hashKey,
|
|
6364
|
+
identifyDownloadGrant,
|
|
5727
6365
|
imageOriginAuthorization,
|
|
5728
6366
|
isAuthenticated,
|
|
5729
6367
|
isImageOriginRequest,
|