@mieweb/pulsevault 0.0.2 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +184 -0
- package/LICENSE +15 -39
- package/OPERATIONS.md +223 -0
- package/PROTOCOL.md +312 -0
- package/README.md +532 -122
- package/augment.d.ts +1 -0
- package/augment.js +3 -0
- package/core.d.ts +1 -0
- package/core.js +7 -0
- package/dist/app.d.ts +90 -34
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +23 -33
- package/dist/app.js.map +1 -1
- package/dist/augment.d.ts +14 -7
- package/dist/augment.d.ts.map +1 -1
- package/dist/augment.js.map +1 -1
- package/dist/core.d.ts +108 -0
- package/dist/core.d.ts.map +1 -0
- package/dist/core.js +431 -0
- package/dist/core.js.map +1 -0
- package/dist/lib/authorize.d.ts +19 -0
- package/dist/lib/authorize.d.ts.map +1 -0
- package/dist/lib/authorize.js +2 -0
- package/dist/lib/authorize.js.map +1 -0
- package/dist/lib/capability-token.d.ts +80 -0
- package/dist/lib/capability-token.d.ts.map +1 -0
- package/dist/lib/capability-token.js +126 -0
- package/dist/lib/capability-token.js.map +1 -0
- package/dist/lib/checksum.d.ts +45 -0
- package/dist/lib/checksum.d.ts.map +1 -0
- package/dist/lib/checksum.js +102 -0
- package/dist/lib/checksum.js.map +1 -0
- package/dist/lib/deeplinks.d.ts +37 -21
- package/dist/lib/deeplinks.d.ts.map +1 -1
- package/dist/lib/deeplinks.js +54 -13
- package/dist/lib/deeplinks.js.map +1 -1
- package/dist/lib/magic.d.ts +30 -5
- package/dist/lib/magic.d.ts.map +1 -1
- package/dist/lib/magic.js +51 -14
- package/dist/lib/magic.js.map +1 -1
- package/dist/lib/options.d.ts +39 -0
- package/dist/lib/options.d.ts.map +1 -0
- package/dist/lib/options.js +103 -0
- package/dist/lib/options.js.map +1 -0
- package/dist/lib/pulsevaultTus.d.ts +52 -26
- package/dist/lib/pulsevaultTus.d.ts.map +1 -1
- package/dist/lib/pulsevaultTus.js +111 -43
- package/dist/lib/pulsevaultTus.js.map +1 -1
- package/dist/lib/request.d.ts +24 -0
- package/dist/lib/request.d.ts.map +1 -0
- package/dist/lib/request.js +16 -0
- package/dist/lib/request.js.map +1 -0
- package/dist/lib/uuid.d.ts.map +1 -1
- package/dist/lib/uuid.js +1 -1
- package/dist/lib/uuid.js.map +1 -1
- package/dist/routes/pulsevault.d.ts +18 -16
- package/dist/routes/pulsevault.d.ts.map +1 -1
- package/dist/routes/pulsevault.js +120 -251
- package/dist/routes/pulsevault.js.map +1 -1
- package/dist/storage/local.d.ts +31 -10
- package/dist/storage/local.d.ts.map +1 -1
- package/dist/storage/local.js +166 -82
- package/dist/storage/local.js.map +1 -1
- package/dist/storage/s3.d.ts +109 -0
- package/dist/storage/s3.d.ts.map +1 -0
- package/dist/storage/s3.js +432 -0
- package/dist/storage/s3.js.map +1 -0
- package/dist/storage/types.d.ts +69 -17
- package/dist/storage/types.d.ts.map +1 -1
- package/package.json +35 -7
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
const DEFAULT_EXPIRY_SECONDS = 1800; // 30 minutes — long enough for one upload session.
|
|
3
|
+
const DEFAULT_CLOCK_TOLERANCE_SECONDS = 30;
|
|
4
|
+
function base64urlEncode(input) {
|
|
5
|
+
return Buffer.from(input, 'utf8').toString('base64url');
|
|
6
|
+
}
|
|
7
|
+
function sign(payload, secret) {
|
|
8
|
+
return createHmac('sha256', secret).update(payload).digest('base64url');
|
|
9
|
+
}
|
|
10
|
+
/** Constant-time string comparison, tolerant of mismatched lengths (returns `false` rather than throwing). */
|
|
11
|
+
function timingSafeEqualStrings(a, b) {
|
|
12
|
+
const bufA = Buffer.from(a, 'utf8');
|
|
13
|
+
const bufB = Buffer.from(b, 'utf8');
|
|
14
|
+
if (bufA.length !== bufB.length)
|
|
15
|
+
return false;
|
|
16
|
+
return timingSafeEqual(bufA, bufB);
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Mint a stateless, HMAC-signed capability token scoped to one artifact (or
|
|
20
|
+
* session-anchor artifact). No server-side session table — verification is a
|
|
21
|
+
* pure function of `(token, secret)`, so the issuing deployment can rotate
|
|
22
|
+
* keys, change its TTL policy, or revoke at the artifact level entirely on
|
|
23
|
+
* its own schedule. See `createCapabilityAuthorize` to use this as the
|
|
24
|
+
* plugin's `authorize` hook directly.
|
|
25
|
+
*/
|
|
26
|
+
export function issueCapabilityToken(artifactId, secret, opts) {
|
|
27
|
+
const now = Math.floor(Date.now() / 1000);
|
|
28
|
+
const claims = {
|
|
29
|
+
artifactId,
|
|
30
|
+
iat: now,
|
|
31
|
+
exp: now + (opts.expirySeconds ?? DEFAULT_EXPIRY_SECONDS),
|
|
32
|
+
kid: opts.keyId,
|
|
33
|
+
issuer: opts.issuer,
|
|
34
|
+
};
|
|
35
|
+
const payload = base64urlEncode(JSON.stringify(claims));
|
|
36
|
+
return `${payload}.${sign(payload, secret)}`;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Verify a token minted by `issueCapabilityToken`. Returns the authorized
|
|
40
|
+
* `artifactId` on success, or `null` for any failure (malformed token,
|
|
41
|
+
* unknown `kid`, bad signature, expired, issued too far in the future, or
|
|
42
|
+
* issuer mismatch) — deliberately collapsed to one failure shape so callers
|
|
43
|
+
* can't accidentally branch on *why* a token failed and leak which part was
|
|
44
|
+
* wrong to an attacker.
|
|
45
|
+
*/
|
|
46
|
+
export function verifyCapabilityToken(token, lookupSecret, opts) {
|
|
47
|
+
const dot = token.indexOf('.');
|
|
48
|
+
if (dot < 0)
|
|
49
|
+
return null;
|
|
50
|
+
const payload = token.slice(0, dot);
|
|
51
|
+
const signature = token.slice(dot + 1);
|
|
52
|
+
if (!payload || !signature)
|
|
53
|
+
return null;
|
|
54
|
+
let claims;
|
|
55
|
+
try {
|
|
56
|
+
claims = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
if (typeof claims.artifactId !== 'string' ||
|
|
62
|
+
typeof claims.iat !== 'number' ||
|
|
63
|
+
typeof claims.exp !== 'number' ||
|
|
64
|
+
typeof claims.kid !== 'string' ||
|
|
65
|
+
typeof claims.issuer !== 'string') {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
const secret = lookupSecret(claims.kid);
|
|
69
|
+
if (!secret)
|
|
70
|
+
return null;
|
|
71
|
+
if (!timingSafeEqualStrings(signature, sign(payload, secret)))
|
|
72
|
+
return null;
|
|
73
|
+
const tolerance = opts.clockToleranceSeconds ?? DEFAULT_CLOCK_TOLERANCE_SECONDS;
|
|
74
|
+
const now = Math.floor(Date.now() / 1000);
|
|
75
|
+
if (claims.iat > now + tolerance)
|
|
76
|
+
return null;
|
|
77
|
+
if (claims.exp < now - tolerance)
|
|
78
|
+
return null;
|
|
79
|
+
if (claims.issuer !== opts.issuer)
|
|
80
|
+
return null;
|
|
81
|
+
return { artifactId: claims.artifactId };
|
|
82
|
+
}
|
|
83
|
+
/** Pull a bearer token from the `Authorization` header, falling back to `ctx.token` (the `resolve`-phase query-string forward). */
|
|
84
|
+
function extractToken(request, ctx) {
|
|
85
|
+
const header = request.headers.authorization;
|
|
86
|
+
if (typeof header === 'string' && header.startsWith('Bearer ')) {
|
|
87
|
+
return header.slice('Bearer '.length);
|
|
88
|
+
}
|
|
89
|
+
return ctx.token;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Ready-made `authorize` hook backed by `verifyCapabilityToken`. Pass this
|
|
93
|
+
* directly as the plugin's `authorize` option for a secure-by-default setup
|
|
94
|
+
* with no custom auth code:
|
|
95
|
+
*
|
|
96
|
+
* ```ts
|
|
97
|
+
* await app.register(pulseVault, {
|
|
98
|
+
* // ...
|
|
99
|
+
* authorize: createCapabilityAuthorize((kid) => keys[kid] ?? null, {
|
|
100
|
+
* issuer: "https://vault.acme-hospital.org",
|
|
101
|
+
* }),
|
|
102
|
+
* });
|
|
103
|
+
* ```
|
|
104
|
+
*
|
|
105
|
+
* Authorizes the request if the token's `artifactId` matches either the
|
|
106
|
+
* artifact being acted on, or that artifact's `relatedTo` session anchor —
|
|
107
|
+
* so one token issued for a video also covers its captions and (under
|
|
108
|
+
* `uploadUnit: "beat"`) every beat and the pulse manifest uploaded in the
|
|
109
|
+
* same session, without minting a token per artifact.
|
|
110
|
+
*/
|
|
111
|
+
export function createCapabilityAuthorize(lookupSecret, opts) {
|
|
112
|
+
return async (request, ctx) => {
|
|
113
|
+
const token = extractToken(request, ctx);
|
|
114
|
+
if (!token) {
|
|
115
|
+
throw Object.assign(new Error('Missing capability token'), { statusCode: 401 });
|
|
116
|
+
}
|
|
117
|
+
const verified = verifyCapabilityToken(token, lookupSecret, opts);
|
|
118
|
+
if (!verified) {
|
|
119
|
+
throw Object.assign(new Error('Invalid or expired capability token'), { statusCode: 403 });
|
|
120
|
+
}
|
|
121
|
+
if (ctx.artifactId !== verified.artifactId && ctx.relatedTo !== verified.artifactId) {
|
|
122
|
+
throw Object.assign(new Error('Token does not authorize this artifact'), { statusCode: 403 });
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
//# sourceMappingURL=capability-token.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"capability-token.js","sourceRoot":"","sources":["../../src/lib/capability-token.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { S3Storage } from '../storage/s3.js';
|
|
2
|
+
import type { PulseVaultValidatePayload } from './magic.js';
|
|
3
|
+
export type ChecksumAlgorithm = 'sha256' | 'sha1' | 'md5';
|
|
4
|
+
export type ParsedChecksum = {
|
|
5
|
+
algorithm: ChecksumAlgorithm;
|
|
6
|
+
digest: string;
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* Parse the client-supplied `Upload-Metadata.checksum` value, format
|
|
10
|
+
* `<algorithm>:<hex digest>` (e.g. `sha256:9f86d0...`). Returns `null` for a
|
|
11
|
+
* missing or malformed value — checksum verification is opt-in per upload,
|
|
12
|
+
* not required, unless the caller treats a `null` parse as a rejection.
|
|
13
|
+
*/
|
|
14
|
+
export declare function parseChecksumMetadata(raw: string | undefined | null): ParsedChecksum | null;
|
|
15
|
+
/**
|
|
16
|
+
* Build a `validatePayload` (or `validateCaptionsPayload`/`validateProjectPayload`)
|
|
17
|
+
* hook that verifies a client-supplied checksum against the finalized bytes
|
|
18
|
+
* before the upload is marked ready — for adapters that expose `localPath`
|
|
19
|
+
* (the built-in local adapter does; see `createS3ChecksumValidator` for S3/R2).
|
|
20
|
+
*
|
|
21
|
+
* This is at-rest corruption/tampering detection on the *finished* file, not
|
|
22
|
+
* a per-chunk integrity check — the TUS protocol's Checksum extension would
|
|
23
|
+
* cover chunks in flight, but the `@tus/server` version in use doesn't
|
|
24
|
+
* implement it. This fills the same gap for the finished artifact instead.
|
|
25
|
+
*
|
|
26
|
+
* Uploads with no `checksum` metadata are passed through unchanged (the
|
|
27
|
+
* client opted out); a checksum that's present but doesn't match is rejected
|
|
28
|
+
* with 422 and the bytes are removed, same semantics as `createMp4Sniffer`.
|
|
29
|
+
* Chain with another validator (e.g. `createMp4Sniffer`) via `next`:
|
|
30
|
+
*
|
|
31
|
+
* ```ts
|
|
32
|
+
* validatePayload: createChecksumValidator(createMp4Sniffer(storage)),
|
|
33
|
+
* ```
|
|
34
|
+
*/
|
|
35
|
+
export declare function createChecksumValidator(next?: PulseVaultValidatePayload): PulseVaultValidatePayload;
|
|
36
|
+
/**
|
|
37
|
+
* `createChecksumValidator` for the S3/R2 backend — streams the whole
|
|
38
|
+
* finalized object through a hash digest via `S3Storage.digestAll`, since S3
|
|
39
|
+
* has no local path to hash directly. Only worth using when checksum
|
|
40
|
+
* verification is explicitly wanted; it's a full extra download per upload,
|
|
41
|
+
* but (unlike buffering the object via `readAll`) never holds the whole
|
|
42
|
+
* object in memory at once.
|
|
43
|
+
*/
|
|
44
|
+
export declare function createS3ChecksumValidator(storage: S3Storage, next?: PulseVaultValidatePayload): PulseVaultValidatePayload;
|
|
45
|
+
//# sourceMappingURL=checksum.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"checksum.d.ts","sourceRoot":"","sources":["../../src/lib/checksum.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAClD,OAAO,KAAK,EAAE,yBAAyB,EAAE,MAAM,YAAY,CAAC;AAE5D,MAAM,MAAM,iBAAiB,GAAG,QAAQ,GAAG,MAAM,GAAG,KAAK,CAAC;AAG1D,MAAM,MAAM,cAAc,GAAG;IAAE,SAAS,EAAE,iBAAiB,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC;AAE9E;;;;;GAKG;AACH,wBAAgB,qBAAqB,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,GAAG,cAAc,GAAG,IAAI,CAS3F;AAsBD;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,uBAAuB,CACrC,IAAI,CAAC,EAAE,yBAAyB,GAC/B,yBAAyB,CAkB3B;AAED;;;;;;;GAOG;AACH,wBAAgB,yBAAyB,CACvC,OAAO,EAAE,SAAS,EAClB,IAAI,CAAC,EAAE,yBAAyB,GAC/B,yBAAyB,CAmB3B"}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { createReadStream } from 'node:fs';
|
|
3
|
+
import { pipeline } from 'node:stream/promises';
|
|
4
|
+
const SUPPORTED_ALGORITHMS = ['sha256', 'sha1', 'md5'];
|
|
5
|
+
/**
|
|
6
|
+
* Parse the client-supplied `Upload-Metadata.checksum` value, format
|
|
7
|
+
* `<algorithm>:<hex digest>` (e.g. `sha256:9f86d0...`). Returns `null` for a
|
|
8
|
+
* missing or malformed value — checksum verification is opt-in per upload,
|
|
9
|
+
* not required, unless the caller treats a `null` parse as a rejection.
|
|
10
|
+
*/
|
|
11
|
+
export function parseChecksumMetadata(raw) {
|
|
12
|
+
if (!raw)
|
|
13
|
+
return null;
|
|
14
|
+
const sep = raw.indexOf(':');
|
|
15
|
+
if (sep < 0)
|
|
16
|
+
return null;
|
|
17
|
+
const algorithm = raw.slice(0, sep).toLowerCase();
|
|
18
|
+
const digest = raw.slice(sep + 1).toLowerCase();
|
|
19
|
+
if (!isSupportedAlgorithm(algorithm))
|
|
20
|
+
return null;
|
|
21
|
+
if (!/^[0-9a-f]+$/.test(digest) || digest.length === 0)
|
|
22
|
+
return null;
|
|
23
|
+
return { algorithm, digest };
|
|
24
|
+
}
|
|
25
|
+
function isSupportedAlgorithm(value) {
|
|
26
|
+
return SUPPORTED_ALGORITHMS.includes(value);
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Hashes the file at `path` via a stream instead of `fs.readFile` so a
|
|
30
|
+
* multi-gigabyte upload (`maxUploadSize: Infinity` is a supported
|
|
31
|
+
* configuration) never has to be held in memory as a single `Buffer` just to
|
|
32
|
+
* compute its checksum.
|
|
33
|
+
*/
|
|
34
|
+
async function digestLocalFile(path, algorithm) {
|
|
35
|
+
const hash = createHash(algorithm);
|
|
36
|
+
await pipeline(createReadStream(path), hash);
|
|
37
|
+
return hash.digest('hex');
|
|
38
|
+
}
|
|
39
|
+
function checksumError(message) {
|
|
40
|
+
return Object.assign(new Error(message), { statusCode: 422 });
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Build a `validatePayload` (or `validateCaptionsPayload`/`validateProjectPayload`)
|
|
44
|
+
* hook that verifies a client-supplied checksum against the finalized bytes
|
|
45
|
+
* before the upload is marked ready — for adapters that expose `localPath`
|
|
46
|
+
* (the built-in local adapter does; see `createS3ChecksumValidator` for S3/R2).
|
|
47
|
+
*
|
|
48
|
+
* This is at-rest corruption/tampering detection on the *finished* file, not
|
|
49
|
+
* a per-chunk integrity check — the TUS protocol's Checksum extension would
|
|
50
|
+
* cover chunks in flight, but the `@tus/server` version in use doesn't
|
|
51
|
+
* implement it. This fills the same gap for the finished artifact instead.
|
|
52
|
+
*
|
|
53
|
+
* Uploads with no `checksum` metadata are passed through unchanged (the
|
|
54
|
+
* client opted out); a checksum that's present but doesn't match is rejected
|
|
55
|
+
* with 422 and the bytes are removed, same semantics as `createMp4Sniffer`.
|
|
56
|
+
* Chain with another validator (e.g. `createMp4Sniffer`) via `next`:
|
|
57
|
+
*
|
|
58
|
+
* ```ts
|
|
59
|
+
* validatePayload: createChecksumValidator(createMp4Sniffer(storage)),
|
|
60
|
+
* ```
|
|
61
|
+
*/
|
|
62
|
+
export function createChecksumValidator(next) {
|
|
63
|
+
return async (request, ctx) => {
|
|
64
|
+
const checksum = parseChecksumMetadata(ctx.checksum);
|
|
65
|
+
if (checksum) {
|
|
66
|
+
if (!ctx.localPath) {
|
|
67
|
+
throw checksumError('Checksum verification requested but this storage adapter has no local path — use createS3ChecksumValidator for S3/R2 storage');
|
|
68
|
+
}
|
|
69
|
+
const actual = await digestLocalFile(ctx.localPath, checksum.algorithm);
|
|
70
|
+
if (actual !== checksum.digest) {
|
|
71
|
+
throw checksumError(`Checksum mismatch: expected ${checksum.algorithm}:${checksum.digest}, got ${checksum.algorithm}:${actual}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (next)
|
|
75
|
+
await next(request, ctx);
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* `createChecksumValidator` for the S3/R2 backend — streams the whole
|
|
80
|
+
* finalized object through a hash digest via `S3Storage.digestAll`, since S3
|
|
81
|
+
* has no local path to hash directly. Only worth using when checksum
|
|
82
|
+
* verification is explicitly wanted; it's a full extra download per upload,
|
|
83
|
+
* but (unlike buffering the object via `readAll`) never holds the whole
|
|
84
|
+
* object in memory at once.
|
|
85
|
+
*/
|
|
86
|
+
export function createS3ChecksumValidator(storage, next) {
|
|
87
|
+
return async (request, ctx) => {
|
|
88
|
+
const checksum = parseChecksumMetadata(ctx.checksum);
|
|
89
|
+
if (checksum) {
|
|
90
|
+
const actual = await storage.digestAll(ctx.artifactId, checksum.algorithm);
|
|
91
|
+
if (actual === null) {
|
|
92
|
+
throw Object.assign(new Error(`Cannot validate upload ${ctx.artifactId}: no object bytes available`), { statusCode: 500 });
|
|
93
|
+
}
|
|
94
|
+
if (actual !== checksum.digest) {
|
|
95
|
+
throw checksumError(`Checksum mismatch: expected ${checksum.algorithm}:${checksum.digest}, got ${checksum.algorithm}:${actual}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (next)
|
|
99
|
+
await next(request, ctx);
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
//# sourceMappingURL=checksum.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"checksum.js","sourceRoot":"","sources":["../../src/lib/checksum.ts"],"names":[],"mappings":"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"}
|
package/dist/lib/deeplinks.d.ts
CHANGED
|
@@ -1,32 +1,48 @@
|
|
|
1
|
-
export type ConfigureDestinationLinkOptions = {
|
|
2
|
-
/** Full origin of the PulseVault server, e.g. `https://example.com` or `http://192.168.1.10:3030`. */
|
|
3
|
-
server: string;
|
|
4
|
-
/** Opaque token forwarded to the server's `authorize` hook. Omit for unauthenticated servers. */
|
|
5
|
-
token?: string;
|
|
6
|
-
/** Human-readable name shown in the Pulse app's destination list. Defaults to the server hostname. */
|
|
7
|
-
name?: string;
|
|
8
|
-
};
|
|
9
1
|
export type UploadLinkOptions = {
|
|
10
|
-
/**
|
|
2
|
+
/**
|
|
3
|
+
* Full base URL the client should upload to — origin *plus* whatever
|
|
4
|
+
* `prefix` you registered the plugin under (e.g.
|
|
5
|
+
* `https://vault.example.org/pulsevault`, not just `https://vault.example.org`).
|
|
6
|
+
* The client builds every request as `${server}/<path>` with no separate
|
|
7
|
+
* prefix concept of its own. Must be `https://` (or `http://localhost`/a
|
|
8
|
+
* private IP for local dev).
|
|
9
|
+
*/
|
|
11
10
|
server: string;
|
|
12
|
-
/** Opaque token forwarded to the server's `authorize` hook
|
|
11
|
+
/** Opaque token forwarded to the server's `authorize` hook — typically minted with `issueCapabilityToken`. Omit for unauthenticated servers. */
|
|
13
12
|
token?: string;
|
|
14
13
|
/**
|
|
15
|
-
* Server-side
|
|
16
|
-
* `
|
|
17
|
-
* Generate with `crypto.randomUUID()`.
|
|
14
|
+
* Server-side artifact UUID. Used as both the app's local draft key and the
|
|
15
|
+
* `artifactId` in `Upload-Metadata`, so the app skips a separate reserve
|
|
16
|
+
* step. Generate with `crypto.randomUUID()`.
|
|
18
17
|
*/
|
|
19
|
-
|
|
18
|
+
artifactId: string;
|
|
19
|
+
/**
|
|
20
|
+
* Per-session override of the deployment-wide `uploadUnit` advertised via
|
|
21
|
+
* `GET /capabilities` (PROTOCOL.md §8). Omit to let the client fall back to
|
|
22
|
+
* whatever `/capabilities` reports — the same behavior as before this field
|
|
23
|
+
* existed. Set this when an operator wants "beat" and "merged" sessions
|
|
24
|
+
* live at the same time (e.g. different pairing flows, a/b testing, a
|
|
25
|
+
* gradual rollout) rather than one fixed value for the whole deployment:
|
|
26
|
+
* `/capabilities` can only ever report one current value, and a client
|
|
27
|
+
* that reads it separately from opening the link is racing whatever the
|
|
28
|
+
* server was serving at that moment, not the value this specific session
|
|
29
|
+
* was paired under.
|
|
30
|
+
*/
|
|
31
|
+
uploadUnit?: 'beat' | 'merged';
|
|
20
32
|
};
|
|
21
|
-
/**
|
|
22
|
-
* Build a `pulsecam://` deep link that adds this server as a saved upload
|
|
23
|
-
* destination in the Pulse app. No server request is made when the link is
|
|
24
|
-
* opened — the app stores the config locally.
|
|
25
|
-
*/
|
|
26
|
-
export declare function buildConfigureDestinationLink(opts: ConfigureDestinationLinkOptions): string;
|
|
27
33
|
/**
|
|
28
34
|
* Build a `pulsecam://` deep link that opens the Pulse app directly on the
|
|
29
|
-
* upload screen for a specific
|
|
35
|
+
* upload screen for a specific artifact, pointed at this server.
|
|
36
|
+
*
|
|
37
|
+
* No `mode` param — there's only one mode, so it was always redundant. `v`
|
|
38
|
+
* lets the app refuse/explain a future incompatible link shape instead of
|
|
39
|
+
* misparsing it.
|
|
40
|
+
*
|
|
41
|
+
* Throws if `server` isn't `https://` (or the narrow localhost/private-IP dev
|
|
42
|
+
* exception PROTOCOL.md §3 carves out) — failing fast here, at the one place
|
|
43
|
+
* that constructs the link, is more reliable than relying on every client to
|
|
44
|
+
* independently reject a plaintext origin the operator never should have
|
|
45
|
+
* issued in the first place.
|
|
30
46
|
*/
|
|
31
47
|
export declare function buildUploadLink(opts: UploadLinkOptions): string;
|
|
32
48
|
//# sourceMappingURL=deeplinks.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"deeplinks.d.ts","sourceRoot":"","sources":["../../src/lib/deeplinks.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM
|
|
1
|
+
{"version":3,"file":"deeplinks.d.ts","sourceRoot":"","sources":["../../src/lib/deeplinks.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,iBAAiB,GAAG;IAC9B;;;;;;;OAOG;IACH,MAAM,EAAE,MAAM,CAAC;IACf,gJAAgJ;IAChJ,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;;;;OAIG;IACH,UAAU,EAAE,MAAM,CAAC;IACnB;;;;;;;;;;;OAWG;IACH,UAAU,CAAC,EAAE,MAAM,GAAG,QAAQ,CAAC;CAChC,CAAC;AA4BF;;;;;;;;;;;;;GAaG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,iBAAiB,GAAG,MAAM,CA0B/D"}
|
package/dist/lib/deeplinks.js
CHANGED
|
@@ -1,24 +1,65 @@
|
|
|
1
|
+
/** Deep-link wire format version. Bump when the param shape changes incompatibly. */
|
|
2
|
+
const LINK_VERSION = '1';
|
|
1
3
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
4
|
+
* Private/dev origins allowed over plain http — mirrors the Pulse client's
|
|
5
|
+
* own `isPrivateDevOrigin` check (PROTOCOL.md §3) exactly, so a link this
|
|
6
|
+
* helper is willing to build is also one the client is willing to accept.
|
|
7
|
+
* Never extended beyond non-globally-routable address space (RFC 1918
|
|
8
|
+
* private ranges, RFC 6598 carrier-grade NAT, link-local) plus
|
|
9
|
+
* loopback/localhost — never a public IP or domain just because it "looks
|
|
10
|
+
* internal."
|
|
5
11
|
*/
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
12
|
+
function isPrivateDevOrigin(url) {
|
|
13
|
+
if (url.protocol !== 'http:')
|
|
14
|
+
return false;
|
|
15
|
+
const host = url.hostname;
|
|
16
|
+
return (host === 'localhost' ||
|
|
17
|
+
host === '127.0.0.1' ||
|
|
18
|
+
/^10\.\d+\.\d+\.\d+$/.test(host) ||
|
|
19
|
+
/^192\.168\.\d+\.\d+$/.test(host) ||
|
|
20
|
+
/^172\.(1[6-9]|2\d|3[01])\.\d+\.\d+$/.test(host) ||
|
|
21
|
+
/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\.\d+\.\d+$/.test(host) || // RFC 6598, 100.64.0.0/10
|
|
22
|
+
/^169\.254\.\d+\.\d+$/.test(host) // link-local
|
|
23
|
+
);
|
|
13
24
|
}
|
|
14
25
|
/**
|
|
15
26
|
* Build a `pulsecam://` deep link that opens the Pulse app directly on the
|
|
16
|
-
* upload screen for a specific
|
|
27
|
+
* upload screen for a specific artifact, pointed at this server.
|
|
28
|
+
*
|
|
29
|
+
* No `mode` param — there's only one mode, so it was always redundant. `v`
|
|
30
|
+
* lets the app refuse/explain a future incompatible link shape instead of
|
|
31
|
+
* misparsing it.
|
|
32
|
+
*
|
|
33
|
+
* Throws if `server` isn't `https://` (or the narrow localhost/private-IP dev
|
|
34
|
+
* exception PROTOCOL.md §3 carves out) — failing fast here, at the one place
|
|
35
|
+
* that constructs the link, is more reliable than relying on every client to
|
|
36
|
+
* independently reject a plaintext origin the operator never should have
|
|
37
|
+
* issued in the first place.
|
|
17
38
|
*/
|
|
18
39
|
export function buildUploadLink(opts) {
|
|
19
|
-
|
|
40
|
+
let server;
|
|
41
|
+
try {
|
|
42
|
+
server = new URL(opts.server);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
throw new Error(`buildUploadLink: \`server\` is not a valid URL: ${opts.server}`);
|
|
46
|
+
}
|
|
47
|
+
if (server.protocol !== 'https:' && !isPrivateDevOrigin(server)) {
|
|
48
|
+
throw new Error(`buildUploadLink: \`server\` must be https:// (got "${opts.server}") — ` +
|
|
49
|
+
'the only exception is http://localhost or a private IP literal for local development.');
|
|
50
|
+
}
|
|
51
|
+
if (opts.uploadUnit !== undefined && opts.uploadUnit !== 'beat' && opts.uploadUnit !== 'merged') {
|
|
52
|
+
throw new Error(`buildUploadLink: \`uploadUnit\` must be "beat" or "merged" (got "${opts.uploadUnit}")`);
|
|
53
|
+
}
|
|
54
|
+
const params = new URLSearchParams({
|
|
55
|
+
v: LINK_VERSION,
|
|
56
|
+
artifactId: opts.artifactId,
|
|
57
|
+
server: opts.server,
|
|
58
|
+
});
|
|
20
59
|
if (opts.token)
|
|
21
|
-
params.set(
|
|
60
|
+
params.set('token', opts.token);
|
|
61
|
+
if (opts.uploadUnit)
|
|
62
|
+
params.set('uploadUnit', opts.uploadUnit);
|
|
22
63
|
return `pulsecam://?${params.toString()}`;
|
|
23
64
|
}
|
|
24
65
|
//# sourceMappingURL=deeplinks.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"deeplinks.js","sourceRoot":"","sources":["../../src/lib/deeplinks.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"deeplinks.js","sourceRoot":"","sources":["../../src/lib/deeplinks.ts"],"names":[],"mappings":"AAiCA,qFAAqF;AACrF,MAAM,YAAY,GAAG,GAAG,CAAC;AAEzB;;;;;;;;GAQG;AACH,SAAS,kBAAkB,CAAC,GAAQ;IAClC,IAAI,GAAG,CAAC,QAAQ,KAAK,OAAO;QAAE,OAAO,KAAK,CAAC;IAC3C,MAAM,IAAI,GAAG,GAAG,CAAC,QAAQ,CAAC;IAC1B,OAAO,CACL,IAAI,KAAK,WAAW;QACpB,IAAI,KAAK,WAAW;QACpB,qBAAqB,CAAC,IAAI,CAAC,IAAI,CAAC;QAChC,sBAAsB,CAAC,IAAI,CAAC,IAAI,CAAC;QACjC,qCAAqC,CAAC,IAAI,CAAC,IAAI,CAAC;QAChD,mDAAmD,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,0BAA0B;QAC5F,sBAAsB,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,aAAa;KAChD,CAAC;AACJ,CAAC;AAED;;;;;;;;;;;;;GAaG;AACH,MAAM,UAAU,eAAe,CAAC,IAAuB;IACrD,IAAI,MAAW,CAAC;IAChB,IAAI,CAAC;QACH,MAAM,GAAG,IAAI,GAAG,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;IAChC,CAAC;IAAC,MAAM,CAAC;QACP,MAAM,IAAI,KAAK,CAAC,mDAAmD,IAAI,CAAC,MAAM,EAAE,CAAC,CAAC;IACpF,CAAC;IACD,IAAI,MAAM,CAAC,QAAQ,KAAK,QAAQ,IAAI,CAAC,kBAAkB,CAAC,MAAM,CAAC,EAAE,CAAC;QAChE,MAAM,IAAI,KAAK,CACb,sDAAsD,IAAI,CAAC,MAAM,OAAO;YACtE,uFAAuF,CAC1F,CAAC;IACJ,CAAC;IAED,IAAI,IAAI,CAAC,UAAU,KAAK,SAAS,IAAI,IAAI,CAAC,UAAU,KAAK,MAAM,IAAI,IAAI,CAAC,UAAU,KAAK,QAAQ,EAAE,CAAC;QAChG,MAAM,IAAI,KAAK,CAAC,oEAAoE,IAAI,CAAC,UAAU,IAAI,CAAC,CAAC;IAC3G,CAAC;IAED,MAAM,MAAM,GAAG,IAAI,eAAe,CAAC;QACjC,CAAC,EAAE,YAAY;QACf,UAAU,EAAE,IAAI,CAAC,UAAU;QAC3B,MAAM,EAAE,IAAI,CAAC,MAAM;KACpB,CAAC,CAAC;IACH,IAAI,IAAI,CAAC,KAAK;QAAE,MAAM,CAAC,GAAG,CAAC,OAAO,EAAE,IAAI,CAAC,KAAK,CAAC,CAAC;IAChD,IAAI,IAAI,CAAC,UAAU;QAAE,MAAM,CAAC,GAAG,CAAC,YAAY,EAAE,IAAI,CAAC,UAAU,CAAC,CAAC;IAC/D,OAAO,eAAe,MAAM,CAAC,QAAQ,EAAE,EAAE,CAAC;AAC5C,CAAC"}
|
package/dist/lib/magic.d.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import type { LocalStorage } from
|
|
1
|
+
import type { PulseVaultRequest } from './request.js';
|
|
2
|
+
import type { LocalStorage } from '../storage/local.js';
|
|
3
|
+
import type { S3Storage } from '../storage/s3.js';
|
|
4
|
+
import type { UploadKind } from '../storage/types.js';
|
|
3
5
|
/**
|
|
4
6
|
* Optional plugin-level hook: after TUS writes the final byte but before the
|
|
5
7
|
* upload is marked ready (or the consumer's `onUploadComplete` runs),
|
|
6
8
|
* validate the payload bytes. Throw to reject — the plugin translates the
|
|
7
|
-
* throw into a 422 response, calls `storage.remove?.(
|
|
9
|
+
* throw into a 422 response, calls `storage.remove?.(artifactId)` to free the
|
|
8
10
|
* disk, and never flips the sidecar to `"ready"`.
|
|
9
11
|
*
|
|
10
12
|
* The `localPath` field is populated for adapters that expose
|
|
@@ -12,12 +14,16 @@ import type { LocalStorage } from "../storage/local.js";
|
|
|
12
14
|
* bytes don't live on local disk (S3, etc.), `localPath` is `null` and the
|
|
13
15
|
* validator has to fetch bytes through whatever API the adapter provides.
|
|
14
16
|
*/
|
|
15
|
-
export type PulseVaultValidatePayload = (request:
|
|
16
|
-
|
|
17
|
+
export type PulseVaultValidatePayload = (request: PulseVaultRequest, ctx: {
|
|
18
|
+
artifactId: string;
|
|
17
19
|
size: number;
|
|
18
20
|
uploadId: string;
|
|
21
|
+
/** Which artifact kind this upload is. Runs for every kind — branch on this if you only want a check applied to one. */
|
|
22
|
+
kind: UploadKind;
|
|
19
23
|
/** Absolute local path to the finalized bytes, or `null` if unavailable. */
|
|
20
24
|
localPath: string | null;
|
|
25
|
+
/** Client-supplied `<algorithm>:<hex>` digest, if `Upload-Metadata.checksum` was sent. */
|
|
26
|
+
checksum?: string;
|
|
21
27
|
}) => void | Promise<void>;
|
|
22
28
|
/**
|
|
23
29
|
* Check whether a file's first bytes match the ISO base media file format
|
|
@@ -47,4 +53,23 @@ export declare function sniffMp4(filePath: string): Promise<boolean>;
|
|
|
47
53
|
* ```
|
|
48
54
|
*/
|
|
49
55
|
export declare function createMp4Sniffer(storage: LocalStorage): PulseVaultValidatePayload;
|
|
56
|
+
/**
|
|
57
|
+
* `createMp4Sniffer` for the S3 / R2 backend. Instead of opening a local file,
|
|
58
|
+
* it asks the adapter for the first 12 bytes of the finalized object via a
|
|
59
|
+
* small ranged GET (`S3Storage.readHeader`) and applies the same `ftyp` check.
|
|
60
|
+
* Runs in the same lifecycle slot — after `@tus/s3-store` completes the
|
|
61
|
+
* multipart upload (so the object is readable) but before `markReady` — so a
|
|
62
|
+
* non-MP4 upload is removed and never served.
|
|
63
|
+
*
|
|
64
|
+
* Usage:
|
|
65
|
+
* ```ts
|
|
66
|
+
* const storage = await createS3Storage({ ... });
|
|
67
|
+
* await app.register(pulseVault, {
|
|
68
|
+
* storage,
|
|
69
|
+
* validatePayload: createS3Mp4Sniffer(storage),
|
|
70
|
+
* // ...
|
|
71
|
+
* });
|
|
72
|
+
* ```
|
|
73
|
+
*/
|
|
74
|
+
export declare function createS3Mp4Sniffer(storage: S3Storage): PulseVaultValidatePayload;
|
|
50
75
|
//# sourceMappingURL=magic.d.ts.map
|
package/dist/lib/magic.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"magic.d.ts","sourceRoot":"","sources":["../../src/lib/magic.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;
|
|
1
|
+
{"version":3,"file":"magic.d.ts","sourceRoot":"","sources":["../../src/lib/magic.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,cAAc,CAAC;AACtD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AACxD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAClD,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,qBAAqB,CAAC;AAEtD;;;;;;;;;;;GAWG;AACH,MAAM,MAAM,yBAAyB,GAAG,CACtC,OAAO,EAAE,iBAAiB,EAC1B,GAAG,EAAE;IACH,UAAU,EAAE,MAAM,CAAC;IACnB,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,wHAAwH;IACxH,IAAI,EAAE,UAAU,CAAC;IACjB,4EAA4E;IAC5E,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,0FAA0F;IAC1F,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB,KACE,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;AAE1B;;;;;;;;;GASG;AACH,wBAAsB,QAAQ,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAejE;AAaD;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,YAAY,GAAG,yBAAyB,CAgBjF;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,SAAS,GAAG,yBAAyB,CAehF"}
|
package/dist/lib/magic.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import fs from
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
2
|
/**
|
|
3
3
|
* Check whether a file's first bytes match the ISO base media file format
|
|
4
4
|
* (`ftyp` box at offset 4), which covers MP4, MOV, M4V, 3GP, and related
|
|
@@ -12,7 +12,7 @@ import fs from "node:fs/promises";
|
|
|
12
12
|
export async function sniffMp4(filePath) {
|
|
13
13
|
let fd;
|
|
14
14
|
try {
|
|
15
|
-
fd = await fs.open(filePath,
|
|
15
|
+
fd = await fs.open(filePath, 'r');
|
|
16
16
|
}
|
|
17
17
|
catch {
|
|
18
18
|
return false;
|
|
@@ -22,19 +22,23 @@ export async function sniffMp4(filePath) {
|
|
|
22
22
|
const { bytesRead } = await fd.read(buf, 0, 12, 0);
|
|
23
23
|
if (bytesRead < 12)
|
|
24
24
|
return false;
|
|
25
|
-
|
|
26
|
-
// four bytes are the box size and the remaining four after "ftyp" are
|
|
27
|
-
// the brand (e.g. "isom", "mp42", "qt ") — brand validation is left
|
|
28
|
-
// to downstream tools.
|
|
29
|
-
return (buf[4] === 0x66 &&
|
|
30
|
-
buf[5] === 0x74 &&
|
|
31
|
-
buf[6] === 0x79 &&
|
|
32
|
-
buf[7] === 0x70);
|
|
25
|
+
return hasFtypBox(buf);
|
|
33
26
|
}
|
|
34
27
|
finally {
|
|
35
28
|
await fd.close();
|
|
36
29
|
}
|
|
37
30
|
}
|
|
31
|
+
/**
|
|
32
|
+
* Whether a buffer's first 12 bytes carry an ISO base media `ftyp` box.
|
|
33
|
+
* Bytes 4..7 must spell "ftyp" (ASCII 0x66 0x74 0x79 0x70). The first four
|
|
34
|
+
* bytes are the box size and the four after "ftyp" are the brand (e.g.
|
|
35
|
+
* "isom", "mp42", "qt ") — brand validation is left to downstream tools.
|
|
36
|
+
*/
|
|
37
|
+
function hasFtypBox(buf) {
|
|
38
|
+
if (buf.length < 12)
|
|
39
|
+
return false;
|
|
40
|
+
return buf[4] === 0x66 && buf[5] === 0x74 && buf[6] === 0x79 && buf[7] === 0x70;
|
|
41
|
+
}
|
|
38
42
|
/**
|
|
39
43
|
* Build a `validatePayload` hook that enforces every uploaded file is a
|
|
40
44
|
* valid MP4-family container. Only works with `LocalStorage` (or any
|
|
@@ -52,14 +56,47 @@ export async function sniffMp4(filePath) {
|
|
|
52
56
|
* ```
|
|
53
57
|
*/
|
|
54
58
|
export function createMp4Sniffer(storage) {
|
|
55
|
-
return async (_request, {
|
|
56
|
-
const localPath = await storage.getLocalPath(
|
|
59
|
+
return async (_request, { artifactId }) => {
|
|
60
|
+
const localPath = await storage.getLocalPath(artifactId);
|
|
57
61
|
if (!localPath) {
|
|
58
|
-
throw Object.assign(new Error(`Cannot validate upload ${
|
|
62
|
+
throw Object.assign(new Error(`Cannot validate upload ${artifactId}: no local path available`), { statusCode: 500 });
|
|
59
63
|
}
|
|
60
64
|
const ok = await sniffMp4(localPath);
|
|
61
65
|
if (!ok) {
|
|
62
|
-
throw Object.assign(new Error(
|
|
66
|
+
throw Object.assign(new Error('Uploaded bytes are not a valid MP4 (missing ftyp header)'), {
|
|
67
|
+
statusCode: 422,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* `createMp4Sniffer` for the S3 / R2 backend. Instead of opening a local file,
|
|
74
|
+
* it asks the adapter for the first 12 bytes of the finalized object via a
|
|
75
|
+
* small ranged GET (`S3Storage.readHeader`) and applies the same `ftyp` check.
|
|
76
|
+
* Runs in the same lifecycle slot — after `@tus/s3-store` completes the
|
|
77
|
+
* multipart upload (so the object is readable) but before `markReady` — so a
|
|
78
|
+
* non-MP4 upload is removed and never served.
|
|
79
|
+
*
|
|
80
|
+
* Usage:
|
|
81
|
+
* ```ts
|
|
82
|
+
* const storage = await createS3Storage({ ... });
|
|
83
|
+
* await app.register(pulseVault, {
|
|
84
|
+
* storage,
|
|
85
|
+
* validatePayload: createS3Mp4Sniffer(storage),
|
|
86
|
+
* // ...
|
|
87
|
+
* });
|
|
88
|
+
* ```
|
|
89
|
+
*/
|
|
90
|
+
export function createS3Mp4Sniffer(storage) {
|
|
91
|
+
return async (_request, { artifactId }) => {
|
|
92
|
+
const header = await storage.readHeader(artifactId, 12);
|
|
93
|
+
if (!header) {
|
|
94
|
+
throw Object.assign(new Error(`Cannot validate upload ${artifactId}: no object bytes available`), { statusCode: 500 });
|
|
95
|
+
}
|
|
96
|
+
if (!hasFtypBox(header)) {
|
|
97
|
+
throw Object.assign(new Error('Uploaded bytes are not a valid MP4 (missing ftyp header)'), {
|
|
98
|
+
statusCode: 422,
|
|
99
|
+
});
|
|
63
100
|
}
|
|
64
101
|
};
|
|
65
102
|
}
|
package/dist/lib/magic.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"magic.js","sourceRoot":"","sources":["../../src/lib/magic.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,kBAAkB,CAAC;
|
|
1
|
+
{"version":3,"file":"magic.js","sourceRoot":"","sources":["../../src/lib/magic.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,kBAAkB,CAAC;AAiClC;;;;;;;;;GASG;AACH,MAAM,CAAC,KAAK,UAAU,QAAQ,CAAC,QAAgB;IAC7C,IAAI,EAAiB,CAAC;IACtB,IAAI,CAAC;QACH,EAAE,GAAG,MAAM,EAAE,CAAC,IAAI,CAAC,QAAQ,EAAE,GAAG,CAAC,CAAC;IACpC,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,KAAK,CAAC;IACf,CAAC;IACD,IAAI,CAAC;QACH,MAAM,GAAG,GAAG,MAAM,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC;QAC7B,MAAM,EAAE,SAAS,EAAE,GAAG,MAAM,EAAE,CAAC,IAAI,CAAC,GAAG,EAAE,CAAC,EAAE,EAAE,EAAE,CAAC,CAAC,CAAC;QACnD,IAAI,SAAS,GAAG,EAAE;YAAE,OAAO,KAAK,CAAC;QACjC,OAAO,UAAU,CAAC,GAAG,CAAC,CAAC;IACzB,CAAC;YAAS,CAAC;QACT,MAAM,EAAE,CAAC,KAAK,EAAE,CAAC;IACnB,CAAC;AACH,CAAC;AAED;;;;;GAKG;AACH,SAAS,UAAU,CAAC,GAAW;IAC7B,IAAI,GAAG,CAAC,MAAM,GAAG,EAAE;QAAE,OAAO,KAAK,CAAC;IAClC,OAAO,GAAG,CAAC,CAAC,CAAC,KAAK,IAAI,IAAI,GAAG,CAAC,CAAC,CAAC,KAAK,IAAI,IAAI,GAAG,CAAC,CAAC,CAAC,KAAK,IAAI,IAAI,GAAG,CAAC,CAAC,CAAC,KAAK,IAAI,CAAC;AAClF,CAAC;AAED;;;;;;;;;;;;;;;GAeG;AACH,MAAM,UAAU,gBAAgB,CAAC,OAAqB;IACpD,OAAO,KAAK,EAAE,QAAQ,EAAE,EAAE,UAAU,EAAE,EAAE,EAAE;QACxC,MAAM,SAAS,GAAG,MAAM,OAAO,CAAC,YAAY,CAAC,UAAU,CAAC,CAAC;QACzD,IAAI,CAAC,SAAS,EAAE,CAAC;YACf,MAAM,MAAM,CAAC,MAAM,CACjB,IAAI,KAAK,CAAC,0BAA0B,UAAU,2BAA2B,CAAC,EAC1E,EAAE,UAAU,EAAE,GAAG,EAAE,CACpB,CAAC;QACJ,CAAC;QACD,MAAM,EAAE,GAAG,MAAM,QAAQ,CAAC,SAAS,CAAC,CAAC;QACrC,IAAI,CAAC,EAAE,EAAE,CAAC;YACR,MAAM,MAAM,CAAC,MAAM,CAAC,IAAI,KAAK,CAAC,0DAA0D,CAAC,EAAE;gBACzF,UAAU,EAAE,GAAG;aAChB,CAAC,CAAC;QACL,CAAC;IACH,CAAC,CAAC;AACJ,CAAC;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,UAAU,kBAAkB,CAAC,OAAkB;IACnD,OAAO,KAAK,EAAE,QAAQ,EAAE,EAAE,UAAU,EAAE,EAAE,EAAE;QACxC,MAAM,MAAM,GAAG,MAAM,OAAO,CAAC,UAAU,CAAC,UAAU,EAAE,EAAE,CAAC,CAAC;QACxD,IAAI,CAAC,MAAM,EAAE,CAAC;YACZ,MAAM,MAAM,CAAC,MAAM,CACjB,IAAI,KAAK,CAAC,0BAA0B,UAAU,6BAA6B,CAAC,EAC5E,EAAE,UAAU,EAAE,GAAG,EAAE,CACpB,CAAC;QACJ,CAAC;QACD,IAAI,CAAC,UAAU,CAAC,MAAM,CAAC,EAAE,CAAC;YACxB,MAAM,MAAM,CAAC,MAAM,CAAC,IAAI,KAAK,CAAC,0DAA0D,CAAC,EAAE;gBACzF,UAAU,EAAE,GAAG;aAChB,CAAC,CAAC;QACL,CAAC;IACH,CAAC,CAAC;AACJ,CAAC"}
|