@transloadit/utils 4.6.0 → 4.7.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 +36 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/node.d.ts +2 -1
- package/dist/node.d.ts.map +1 -1
- package/dist/node.js +1 -0
- package/dist/smartCdn.d.ts +64 -4
- package/dist/smartCdn.d.ts.map +1 -1
- package/dist/smartCdn.js +197 -13
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -14,7 +14,14 @@ Everything in the root export runs on WebCrypto, so it works in browsers (secure
|
|
|
14
14
|
`https://` or `localhost`), edge runtimes, and Node.
|
|
15
15
|
|
|
16
16
|
```ts
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
getSignedSmartCdnUrl,
|
|
19
|
+
getSmartCdnUrl,
|
|
20
|
+
parseSmartCdnUrl,
|
|
21
|
+
signParams,
|
|
22
|
+
stripSmartCdnAuth,
|
|
23
|
+
verifyWebhookSignature,
|
|
24
|
+
} from '@transloadit/utils'
|
|
18
25
|
|
|
19
26
|
const signature = await signParams(paramsString, authSecret)
|
|
20
27
|
const verified = await verifyWebhookSignature({
|
|
@@ -31,6 +38,29 @@ const url = await getSignedSmartCdnUrl({
|
|
|
31
38
|
})
|
|
32
39
|
```
|
|
33
40
|
|
|
41
|
+
### Smart CDN URL grammar
|
|
42
|
+
|
|
43
|
+
The URL builders and parser share one grammar, so a URL built here parses back into the options
|
|
44
|
+
that built it (and vice versa):
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
// Unsigned, for workspaces that do not require signature authentication.
|
|
48
|
+
const publicUrl = getSmartCdnUrl({ workspace, template, input, urlParams: { w: 640 } })
|
|
49
|
+
|
|
50
|
+
// Inverse of the builders: percent-decodes once, keeps repeated params as arrays,
|
|
51
|
+
// and returns `auth_key`/`exp`/`sig` separately as `auth`.
|
|
52
|
+
const { workspace, template, input, urlParams, auth } = parseSmartCdnUrl(url)
|
|
53
|
+
|
|
54
|
+
// Drops `auth_key`, `exp`, `sig` (and api2's `hsh`), leaving every other byte untouched.
|
|
55
|
+
const unsigned = stripSmartCdnAuth(url)
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Both builders accept a `baseUrl` that replaces `https://{workspace}.tlcdn.com`, for example a local
|
|
59
|
+
api2's URL Transform endpoint `https://api2-devdock.transloadit.dev/file/{workspace}` (a literal
|
|
60
|
+
`{workspace}` is substituted). The signature does not cover the host, so treat `baseUrl` as trusted
|
|
61
|
+
configuration and never derive it from user input. Pass the same `baseUrl` to `parseSmartCdnUrl` to
|
|
62
|
+
parse URLs built with it.
|
|
63
|
+
|
|
34
64
|
## Node usage
|
|
35
65
|
|
|
36
66
|
```ts
|
|
@@ -70,6 +100,11 @@ for (const source of imageCandidates.sources) {
|
|
|
70
100
|
- `verifyWebhookSignature({ rawBody, signatureHeader, authSecret })`: validates webhook signatures.
|
|
71
101
|
- `getSignedSmartCdnUrl(options)`: async, WebCrypto-based Smart CDN URL signer. Byte-identical to
|
|
72
102
|
the Node variant below.
|
|
103
|
+
- `getSmartCdnUrl(options)`: unsigned Smart CDN URL builder (same options minus credentials/expiry).
|
|
104
|
+
- `parseSmartCdnUrl(url, { baseUrl?, workspace? })`: parses a Smart CDN URL into
|
|
105
|
+
`{ workspace, template, input, urlParams, auth?, baseUrl? }`; throws on anything else.
|
|
106
|
+
- `stripSmartCdnAuth(url)`: removes the signature parameters, byte-for-byte otherwise.
|
|
107
|
+
- `baseUrl` (option of both builders): trusted replacement for `https://{workspace}.tlcdn.com`.
|
|
73
108
|
- `signParamsSync(paramsString, authSecret, algorithm?)`: Node-only sync signature helper.
|
|
74
109
|
- `getSignedSmartCdnUrl(options)` from `@transloadit/utils/node`: synchronous Smart CDN URL signer.
|
|
75
110
|
- `getSignedSmartCdnImageCandidates(options)`: deterministic structured, signed AVIF and WebP
|
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { SmartCdnUrlOptions } from './smartCdn.ts';
|
|
2
2
|
export type SignatureAlgorithm = 'sha1' | 'sha256' | 'sha384' | 'sha512';
|
|
3
|
-
export type { SmartCdnUrlOptions } from './smartCdn.ts';
|
|
3
|
+
export type { ParsedSmartCdnUrl, ParseSmartCdnUrlOptions, SmartCdnUnsignedUrlOptions, SmartCdnUrlOptions, SmartCdnUrlParams, } from './smartCdn.ts';
|
|
4
4
|
export * from './assemblyInstructionsCompiler.ts';
|
|
5
|
+
export { getSmartCdnUrl, parseSmartCdnUrl, stripSmartCdnAuth } from './smartCdn.ts';
|
|
5
6
|
export declare const signParams: (paramsString: string, authSecret: string, algorithm?: SignatureAlgorithm) => Promise<string>;
|
|
6
7
|
export type VerifyWebhookSignatureOptions = {
|
|
7
8
|
rawBody: string;
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,eAAe,CAAA;AAIvD,MAAM,MAAM,kBAAkB,GAAG,MAAM,GAAG,QAAQ,GAAG,QAAQ,GAAG,QAAQ,CAAA;AAExE,YAAY,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,eAAe,CAAA;AAIvD,MAAM,MAAM,kBAAkB,GAAG,MAAM,GAAG,QAAQ,GAAG,QAAQ,GAAG,QAAQ,CAAA;AAExE,YAAY,EACV,iBAAiB,EACjB,uBAAuB,EACvB,0BAA0B,EAC1B,kBAAkB,EAClB,iBAAiB,GAClB,MAAM,eAAe,CAAA;AAEtB,cAAc,mCAAmC,CAAA;AACjD,OAAO,EAAE,cAAc,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,MAAM,eAAe,CAAA;AAqDnF,eAAO,MAAM,UAAU,GACrB,cAAc,MAAM,EACpB,YAAY,MAAM,EAClB,YAAW,kBAA6B,KACvC,OAAO,CAAC,MAAM,CAOhB,CAAA;AAED,MAAM,MAAM,6BAA6B,GAAG;IAC1C,OAAO,EAAE,MAAM,CAAA;IACf,eAAe,CAAC,EAAE,MAAM,CAAA;IACxB,UAAU,EAAE,MAAM,CAAA;CACnB,CAAA;AAED,eAAO,MAAM,sBAAsB,GACjC,SAAS,6BAA6B,KACrC,OAAO,CAAC,OAAO,CAkBjB,CAAA;AAED;;;GAGG;AACH,eAAO,MAAM,oBAAoB,GAAU,MAAM,kBAAkB,KAAG,OAAO,CAAC,MAAM,CAInF,CAAA"}
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { finishSmartCdnUrl, prepareSmartCdnUrl } from "./smartCdn.js";
|
|
2
2
|
export * from "./assemblyInstructionsCompiler.js";
|
|
3
|
+
export { getSmartCdnUrl, parseSmartCdnUrl, stripSmartCdnAuth } from "./smartCdn.js";
|
|
3
4
|
const algorithmMap = {
|
|
4
5
|
sha1: 'SHA-1',
|
|
5
6
|
sha256: 'SHA-256',
|
package/dist/node.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { SignatureAlgorithm } from './index.ts';
|
|
2
2
|
import type { SmartCdnUrlOptions } from './smartCdn.ts';
|
|
3
3
|
export type { SignatureAlgorithm } from './index.ts';
|
|
4
|
-
export type { SmartCdnUrlOptions } from './smartCdn.ts';
|
|
4
|
+
export type { ParsedSmartCdnUrl, ParseSmartCdnUrlOptions, SmartCdnUnsignedUrlOptions, SmartCdnUrlOptions, SmartCdnUrlParams, } from './smartCdn.ts';
|
|
5
|
+
export { getSmartCdnUrl, parseSmartCdnUrl, stripSmartCdnAuth } from './smartCdn.ts';
|
|
5
6
|
export type SignatureAlgorithmInput = SignatureAlgorithm | (string & {});
|
|
6
7
|
/** Image formats supported by the responsive-image Built-in. */
|
|
7
8
|
export type SmartCdnImageFormat = 'avif' | 'png' | 'webp';
|
package/dist/node.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"node.d.ts","sourceRoot":"","sources":["../src/node.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAA;AACpD,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,eAAe,CAAA;AAMvD,YAAY,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAA;AACpD,YAAY,
|
|
1
|
+
{"version":3,"file":"node.d.ts","sourceRoot":"","sources":["../src/node.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAA;AACpD,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,eAAe,CAAA;AAMvD,YAAY,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAA;AACpD,YAAY,EACV,iBAAiB,EACjB,uBAAuB,EACvB,0BAA0B,EAC1B,kBAAkB,EAClB,iBAAiB,GAClB,MAAM,eAAe,CAAA;AAEtB,OAAO,EAAE,cAAc,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,MAAM,eAAe,CAAA;AAEnF,MAAM,MAAM,uBAAuB,GAAG,kBAAkB,GAAG,CAAC,MAAM,GAAG,EAAE,CAAC,CAAA;AAExE,gEAAgE;AAChE,MAAM,MAAM,mBAAmB,GAAG,MAAM,GAAG,KAAK,GAAG,MAAM,CAAA;AAEzD,oEAAoE;AACpE,MAAM,WAAW,sBAAsB;IACrC,GAAG,EAAE,MAAM,CAAA;IACX,KAAK,EAAE,MAAM,CAAA;CACd;AAED,2DAA2D;AAC3D,MAAM,WAAW,mBAAmB;IAClC,UAAU,EAAE,SAAS,sBAAsB,EAAE,CAAA;IAC7C,MAAM,EAAE,mBAAmB,CAAA;IAC3B,OAAO,EAAE,MAAM,CAAA;CAChB;AAED,wDAAwD;AACxD,MAAM,WAAW,uBAAuB;IACtC,WAAW,EAAE,MAAM,CAAA;IACnB,OAAO,EAAE,SAAS,mBAAmB,EAAE,CAAA;CACxC;AAED,8EAA8E;AAC9E,MAAM,WAAW,8BAA8B;IAC7C,6DAA6D;IAC7D,OAAO,EAAE,MAAM,CAAA;IACf,gEAAgE;IAChE,UAAU,EAAE,MAAM,CAAA;IAClB,uFAAuF;IACvF,SAAS,EAAE,MAAM,CAAA;IACjB,yEAAyE;IACzE,OAAO,CAAC,EAAE,QAAQ,CAAC,OAAO,CAAC,MAAM,CAAC,mBAAmB,EAAE,MAAM,CAAC,CAAC,CAAC,CAAA;IAChE,6EAA6E;IAC7E,KAAK,EAAE,MAAM,CAAA;IACb,6EAA6E;IAC7E,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,oFAAoF;IACpF,MAAM,EAAE,SAAS,MAAM,EAAE,CAAA;IACzB,sBAAsB;IACtB,SAAS,EAAE,MAAM,CAAA;CAClB;AAkED,eAAO,MAAM,cAAc,GACzB,cAAc,MAAM,EACpB,YAAY,MAAM,EAClB,YAAW,uBAAkC,KAC5C,MAKF,CAAA;AAED,4FAA4F;AAC5F,eAAO,MAAM,oBAAoB,GAAI,MAAM,kBAAkB,KAAG,MAM/D,CAAA;AAED;;;;;GAKG;AACH,wBAAgB,gCAAgC,CAC9C,IAAI,EAAE,8BAA8B,GACnC,uBAAuB,CAsDzB"}
|
package/dist/node.js
CHANGED
package/dist/smartCdn.d.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Smart CDN URL
|
|
3
|
-
* asynchronous WebCrypto signer (`@transloadit/utils`)
|
|
4
|
-
*
|
|
2
|
+
* Smart CDN URL grammar shared by the synchronous Node signer (`@transloadit/utils/node`) and the
|
|
3
|
+
* asynchronous WebCrypto signer (`@transloadit/utils`): building (signed and unsigned), parsing,
|
|
4
|
+
* and stripping signature parameters. Only the HMAC differs between the two signers, so the
|
|
5
|
+
* string-to-sign and the final URL are assembled here and cannot drift apart.
|
|
5
6
|
*/
|
|
7
|
+
export type SmartCdnUrlParams = Record<string, boolean | number | string | (boolean | number | string)[]>;
|
|
6
8
|
export type SmartCdnUrlOptions = {
|
|
7
9
|
/**
|
|
8
10
|
* Workspace slug.
|
|
@@ -19,7 +21,7 @@ export type SmartCdnUrlOptions = {
|
|
|
19
21
|
/**
|
|
20
22
|
* Additional parameters for the URL query string.
|
|
21
23
|
*/
|
|
22
|
-
urlParams?:
|
|
24
|
+
urlParams?: SmartCdnUrlParams;
|
|
23
25
|
/**
|
|
24
26
|
* Expiration timestamp of the signature in milliseconds since UNIX epoch.
|
|
25
27
|
* Defaults to 1 hour from now.
|
|
@@ -33,7 +35,19 @@ export type SmartCdnUrlOptions = {
|
|
|
33
35
|
* Transloadit auth secret used to sign the URL.
|
|
34
36
|
*/
|
|
35
37
|
authSecret: string;
|
|
38
|
+
/**
|
|
39
|
+
* Base URL that replaces `https://{workspace}.tlcdn.com`, e.g. a local api2's URL Transform
|
|
40
|
+
* endpoint `https://api2-devdock.transloadit.dev/file/{workspace}`. A literal `{workspace}` is
|
|
41
|
+
* substituted with the encoded workspace slug; a trailing slash is ignored.
|
|
42
|
+
*
|
|
43
|
+
* **Trusted configuration only.** The signature does not cover the host, so a base URL taken from
|
|
44
|
+
* user input would let anyone redirect a signed URL (auth key included) to an origin of their
|
|
45
|
+
* choosing. Never derive it from request data.
|
|
46
|
+
*/
|
|
47
|
+
baseUrl?: string;
|
|
36
48
|
};
|
|
49
|
+
/** Options for an unsigned Smart CDN URL: the signed options without credentials or expiry. */
|
|
50
|
+
export type SmartCdnUnsignedUrlOptions = Omit<SmartCdnUrlOptions, 'authKey' | 'authSecret' | 'expiresAt'>;
|
|
37
51
|
/** A Smart CDN URL with everything but its signature in place. */
|
|
38
52
|
export interface PreparedSmartCdnUrl {
|
|
39
53
|
/** `workspace/template/input?sortedQuery`, the message the auth secret signs with HMAC-SHA256. */
|
|
@@ -44,10 +58,56 @@ export interface PreparedSmartCdnUrl {
|
|
|
44
58
|
templateSlug: string;
|
|
45
59
|
inputField: string;
|
|
46
60
|
queryParams: URLSearchParams;
|
|
61
|
+
/** Resolved origin + path prefix that precedes `/{template}/{input}`. */
|
|
62
|
+
baseUrl: string;
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
/** The components of a Smart CDN URL, as produced by `parseSmartCdnUrl`. */
|
|
66
|
+
export interface ParsedSmartCdnUrl {
|
|
67
|
+
workspace: string;
|
|
68
|
+
template: string;
|
|
69
|
+
input: string;
|
|
70
|
+
/** Every query parameter except the signature ones; repeated parameters become arrays. */
|
|
71
|
+
urlParams: Record<string, string | string[]>;
|
|
72
|
+
/** Present when the URL carries `auth_key`, `exp` and `sig`. */
|
|
73
|
+
auth?: {
|
|
74
|
+
key: string;
|
|
75
|
+
/** Milliseconds since UNIX epoch. */
|
|
76
|
+
expiresAt: number;
|
|
77
|
+
/** The `sig` value, e.g. `sha256:…`. */
|
|
78
|
+
signature: string;
|
|
47
79
|
};
|
|
80
|
+
/** Only set when the URL was parsed against a custom `baseUrl`; feeds straight back into the builders. */
|
|
81
|
+
baseUrl?: string;
|
|
82
|
+
}
|
|
83
|
+
export interface ParseSmartCdnUrlOptions {
|
|
84
|
+
/**
|
|
85
|
+
* The same trusted `baseUrl` the URL was built with (with or without `{workspace}`). Without it
|
|
86
|
+
* only `https://{workspace}.tlcdn.com/…` URLs are accepted.
|
|
87
|
+
*/
|
|
88
|
+
baseUrl?: string;
|
|
89
|
+
/** Workspace slug for a `baseUrl` without a `{workspace}` placeholder, where the URL cannot tell. */
|
|
90
|
+
workspace?: string;
|
|
48
91
|
}
|
|
49
92
|
/** Validates the options and assembles the string to sign; the caller supplies the HMAC. */
|
|
50
93
|
export declare const prepareSmartCdnUrl: (opts: SmartCdnUrlOptions) => PreparedSmartCdnUrl;
|
|
51
94
|
/** Appends the `sig` parameter and returns the final `https://{workspace}.tlcdn.com/…` URL. */
|
|
52
95
|
export declare const finishSmartCdnUrl: ({ parts }: PreparedSmartCdnUrl, signatureHex: string) => string;
|
|
96
|
+
/**
|
|
97
|
+
* Builds an unsigned Smart CDN URL (`https://{workspace}.tlcdn.com/{template}/{input}?sortedQuery`)
|
|
98
|
+
* for workspaces that do not require signature authentication.
|
|
99
|
+
*/
|
|
100
|
+
export declare const getSmartCdnUrl: (opts: SmartCdnUnsignedUrlOptions) => string;
|
|
101
|
+
/**
|
|
102
|
+
* Removes the signature parameters (`auth_key`, `exp`, `sig`, and api2's `hsh`) from a Smart CDN
|
|
103
|
+
* URL. Every other byte of the URL is left untouched, so the result stays comparable with URLs
|
|
104
|
+
* produced elsewhere. Idempotent.
|
|
105
|
+
*/
|
|
106
|
+
export declare const stripSmartCdnAuth: (url: string) => string;
|
|
107
|
+
/**
|
|
108
|
+
* Parses a Smart CDN URL back into the options that built it: the inverse of `getSmartCdnUrl` and
|
|
109
|
+
* `getSignedSmartCdnUrl`. Path segments are percent-decoded exactly once; query parameters are
|
|
110
|
+
* decoded by `URLSearchParams` semantics; `auth_key`/`exp`/`sig` are returned separately as `auth`.
|
|
111
|
+
*/
|
|
112
|
+
export declare const parseSmartCdnUrl: (url: string, options?: ParseSmartCdnUrlOptions) => ParsedSmartCdnUrl;
|
|
53
113
|
//# sourceMappingURL=smartCdn.d.ts.map
|
package/dist/smartCdn.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"smartCdn.d.ts","sourceRoot":"","sources":["../src/smartCdn.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"smartCdn.d.ts","sourceRoot":"","sources":["../src/smartCdn.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAQH,MAAM,MAAM,iBAAiB,GAAG,MAAM,CACpC,MAAM,EACN,OAAO,GAAG,MAAM,GAAG,MAAM,GAAG,CAAC,OAAO,GAAG,MAAM,GAAG,MAAM,CAAC,EAAE,CAC1D,CAAA;AAED,MAAM,MAAM,kBAAkB,GAAG;IAC/B;;OAEG;IACH,SAAS,EAAE,MAAM,CAAA;IACjB;;OAEG;IACH,QAAQ,EAAE,MAAM,CAAA;IAChB;;OAEG;IACH,KAAK,EAAE,MAAM,CAAA;IACb;;OAEG;IACH,SAAS,CAAC,EAAE,iBAAiB,CAAA;IAC7B;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB;;OAEG;IACH,OAAO,EAAE,MAAM,CAAA;IACf;;OAEG;IACH,UAAU,EAAE,MAAM,CAAA;IAClB;;;;;;;;OAQG;IACH,OAAO,CAAC,EAAE,MAAM,CAAA;CACjB,CAAA;AAED,+FAA+F;AAC/F,MAAM,MAAM,0BAA0B,GAAG,IAAI,CAC3C,kBAAkB,EAClB,SAAS,GAAG,YAAY,GAAG,WAAW,CACvC,CAAA;AAED,kEAAkE;AAClE,MAAM,WAAW,mBAAmB;IAClC,kGAAkG;IAClG,YAAY,EAAE,MAAM,CAAA;IACpB,sEAAsE;IACtE,KAAK,EAAE;QACL,aAAa,EAAE,MAAM,CAAA;QACrB,YAAY,EAAE,MAAM,CAAA;QACpB,UAAU,EAAE,MAAM,CAAA;QAClB,WAAW,EAAE,eAAe,CAAA;QAC5B,yEAAyE;QACzE,OAAO,EAAE,MAAM,CAAA;KAChB,CAAA;CACF;AAED,4EAA4E;AAC5E,MAAM,WAAW,iBAAiB;IAChC,SAAS,EAAE,MAAM,CAAA;IACjB,QAAQ,EAAE,MAAM,CAAA;IAChB,KAAK,EAAE,MAAM,CAAA;IACb,0FAA0F;IAC1F,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC,CAAA;IAC5C,gEAAgE;IAChE,IAAI,CAAC,EAAE;QACL,GAAG,EAAE,MAAM,CAAA;QACX,qCAAqC;QACrC,SAAS,EAAE,MAAM,CAAA;QACjB,wCAAwC;QACxC,SAAS,EAAE,MAAM,CAAA;KAClB,CAAA;IACD,0GAA0G;IAC1G,OAAO,CAAC,EAAE,MAAM,CAAA;CACjB;AAED,MAAM,WAAW,uBAAuB;IACtC;;;OAGG;IACH,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,qGAAqG;IACrG,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AAyCD,4FAA4F;AAC5F,eAAO,MAAM,kBAAkB,GAAI,MAAM,kBAAkB,KAAG,mBAuB7D,CAAA;AAED,+FAA+F;AAC/F,eAAO,MAAM,iBAAiB,GAAI,WAAW,mBAAmB,EAAE,cAAc,MAAM,KAAG,MAIxF,CAAA;AAED;;;GAGG;AACH,eAAO,MAAM,cAAc,GAAI,MAAM,0BAA0B,KAAG,MAWjE,CAAA;AAUD;;;;GAIG;AACH,eAAO,MAAM,iBAAiB,GAAI,KAAK,MAAM,KAAG,MAsB/C,CAAA;AAwDD;;;;GAIG;AACH,eAAO,MAAM,gBAAgB,GAC3B,KAAK,MAAM,EACX,UAAS,uBAA4B,KACpC,iBAiDF,CAAA"}
|
package/dist/smartCdn.js
CHANGED
|
@@ -1,22 +1,41 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Smart CDN URL
|
|
3
|
-
* asynchronous WebCrypto signer (`@transloadit/utils`)
|
|
4
|
-
*
|
|
2
|
+
* Smart CDN URL grammar shared by the synchronous Node signer (`@transloadit/utils/node`) and the
|
|
3
|
+
* asynchronous WebCrypto signer (`@transloadit/utils`): building (signed and unsigned), parsing,
|
|
4
|
+
* and stripping signature parameters. Only the HMAC differs between the two signers, so the
|
|
5
|
+
* string-to-sign and the final URL are assembled here and cannot drift apart.
|
|
5
6
|
*/
|
|
6
|
-
|
|
7
|
-
|
|
7
|
+
const SMART_CDN_HOST_SUFFIX = '.tlcdn.com';
|
|
8
|
+
const WORKSPACE_PLACEHOLDER = '{workspace}';
|
|
9
|
+
/** Query parameters that carry the signature; `hsh` is an api2-side hash that is stripped too. */
|
|
10
|
+
const SIGNATURE_PARAMS = new Set(['auth_key', 'exp', 'sig']);
|
|
11
|
+
const STRIPPED_PARAMS = new Set([...SIGNATURE_PARAMS, 'hsh']);
|
|
12
|
+
const validateRequired = (opts) => {
|
|
8
13
|
if (opts.workspace == null || opts.workspace === '')
|
|
9
14
|
throw new TypeError('workspace is required');
|
|
10
15
|
if (opts.template == null || opts.template === '')
|
|
11
16
|
throw new TypeError('template is required');
|
|
12
17
|
if (opts.input == null)
|
|
13
18
|
throw new TypeError('input is required');
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
19
|
+
};
|
|
20
|
+
const resolveBaseUrl = (baseUrl, workspaceSlug) => {
|
|
21
|
+
if (baseUrl == null)
|
|
22
|
+
return `https://${workspaceSlug}${SMART_CDN_HOST_SUFFIX}`;
|
|
23
|
+
const resolved = baseUrl.replace(/\/+$/, '').split(WORKSPACE_PLACEHOLDER).join(workspaceSlug);
|
|
24
|
+
let parsed;
|
|
25
|
+
try {
|
|
26
|
+
parsed = new URL(resolved);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
throw new TypeError(`baseUrl must be an absolute URL, got '${baseUrl}'`);
|
|
30
|
+
}
|
|
31
|
+
if (parsed.search !== '' || parsed.hash !== '') {
|
|
32
|
+
throw new TypeError('baseUrl must not contain a query string or fragment');
|
|
33
|
+
}
|
|
34
|
+
return resolved;
|
|
35
|
+
};
|
|
36
|
+
const buildQueryParams = (urlParams) => {
|
|
18
37
|
const queryParams = new URLSearchParams();
|
|
19
|
-
for (const [key, value] of Object.entries(
|
|
38
|
+
for (const [key, value] of Object.entries(urlParams || {})) {
|
|
20
39
|
if (Array.isArray(value)) {
|
|
21
40
|
for (const val of value) {
|
|
22
41
|
queryParams.append(key, `${val}`);
|
|
@@ -26,17 +45,182 @@ export const prepareSmartCdnUrl = (opts) => {
|
|
|
26
45
|
queryParams.append(key, `${value}`);
|
|
27
46
|
}
|
|
28
47
|
}
|
|
48
|
+
return queryParams;
|
|
49
|
+
};
|
|
50
|
+
/** Validates the options and assembles the string to sign; the caller supplies the HMAC. */
|
|
51
|
+
export const prepareSmartCdnUrl = (opts) => {
|
|
52
|
+
validateRequired(opts);
|
|
53
|
+
const workspaceSlug = encodeURIComponent(opts.workspace);
|
|
54
|
+
const templateSlug = encodeURIComponent(opts.template);
|
|
55
|
+
const inputField = encodeURIComponent(opts.input);
|
|
56
|
+
const expiresAt = opts.expiresAt || Date.now() + 60 * 60 * 1000;
|
|
57
|
+
const queryParams = buildQueryParams(opts.urlParams);
|
|
29
58
|
queryParams.set('auth_key', opts.authKey);
|
|
30
59
|
queryParams.set('exp', `${expiresAt}`);
|
|
31
60
|
queryParams.sort();
|
|
32
61
|
return {
|
|
33
62
|
stringToSign: `${workspaceSlug}/${templateSlug}/${inputField}?${queryParams}`,
|
|
34
|
-
parts: {
|
|
63
|
+
parts: {
|
|
64
|
+
workspaceSlug,
|
|
65
|
+
templateSlug,
|
|
66
|
+
inputField,
|
|
67
|
+
queryParams,
|
|
68
|
+
baseUrl: resolveBaseUrl(opts.baseUrl, workspaceSlug),
|
|
69
|
+
},
|
|
35
70
|
};
|
|
36
71
|
};
|
|
37
72
|
/** Appends the `sig` parameter and returns the final `https://{workspace}.tlcdn.com/…` URL. */
|
|
38
73
|
export const finishSmartCdnUrl = ({ parts }, signatureHex) => {
|
|
39
|
-
const {
|
|
74
|
+
const { baseUrl, templateSlug, inputField, queryParams } = parts;
|
|
40
75
|
queryParams.set('sig', `sha256:${signatureHex}`);
|
|
41
|
-
return
|
|
76
|
+
return `${baseUrl}/${templateSlug}/${inputField}?${queryParams}`;
|
|
77
|
+
};
|
|
78
|
+
/**
|
|
79
|
+
* Builds an unsigned Smart CDN URL (`https://{workspace}.tlcdn.com/{template}/{input}?sortedQuery`)
|
|
80
|
+
* for workspaces that do not require signature authentication.
|
|
81
|
+
*/
|
|
82
|
+
export const getSmartCdnUrl = (opts) => {
|
|
83
|
+
validateRequired(opts);
|
|
84
|
+
const workspaceSlug = encodeURIComponent(opts.workspace);
|
|
85
|
+
const templateSlug = encodeURIComponent(opts.template);
|
|
86
|
+
const inputField = encodeURIComponent(opts.input);
|
|
87
|
+
const queryParams = buildQueryParams(opts.urlParams);
|
|
88
|
+
queryParams.sort();
|
|
89
|
+
const query = queryParams.toString();
|
|
90
|
+
return `${resolveBaseUrl(opts.baseUrl, workspaceSlug)}/${templateSlug}/${inputField}${query === '' ? '' : `?${query}`}`;
|
|
91
|
+
};
|
|
92
|
+
const decodeOnce = (value, what) => {
|
|
93
|
+
try {
|
|
94
|
+
return decodeURIComponent(value);
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
throw new TypeError(`Not a Smart CDN URL: malformed percent-encoding in ${what}`);
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
/**
|
|
101
|
+
* Removes the signature parameters (`auth_key`, `exp`, `sig`, and api2's `hsh`) from a Smart CDN
|
|
102
|
+
* URL. Every other byte of the URL is left untouched, so the result stays comparable with URLs
|
|
103
|
+
* produced elsewhere. Idempotent.
|
|
104
|
+
*/
|
|
105
|
+
export const stripSmartCdnAuth = (url) => {
|
|
106
|
+
const hashIndex = url.indexOf('#');
|
|
107
|
+
const fragment = hashIndex === -1 ? '' : url.slice(hashIndex);
|
|
108
|
+
const withoutFragment = hashIndex === -1 ? url : url.slice(0, hashIndex);
|
|
109
|
+
const queryIndex = withoutFragment.indexOf('?');
|
|
110
|
+
if (queryIndex === -1)
|
|
111
|
+
return url;
|
|
112
|
+
const path = withoutFragment.slice(0, queryIndex);
|
|
113
|
+
const kept = withoutFragment
|
|
114
|
+
.slice(queryIndex + 1)
|
|
115
|
+
.split('&')
|
|
116
|
+
.filter((pair) => {
|
|
117
|
+
if (pair === '')
|
|
118
|
+
return false;
|
|
119
|
+
const rawName = pair.slice(0, pair.indexOf('=') === -1 ? pair.length : pair.indexOf('='));
|
|
120
|
+
let name = rawName;
|
|
121
|
+
try {
|
|
122
|
+
name = decodeURIComponent(rawName.replace(/\+/g, ' '));
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
// An undecodable name is never one of ours; keep it.
|
|
126
|
+
}
|
|
127
|
+
return !STRIPPED_PARAMS.has(name);
|
|
128
|
+
});
|
|
129
|
+
return `${path}${kept.length === 0 ? '' : `?${kept.join('&')}`}${fragment}`;
|
|
130
|
+
};
|
|
131
|
+
const notSmartCdnUrl = (detail) => new TypeError(`Not a Smart CDN URL: ${detail} (expected https://{workspace}.tlcdn.com/{template}/{input}, or the configured baseUrl)`);
|
|
132
|
+
/** Splits `origin + pathname` into the workspace slug and the `{template}/{input}` remainder. */
|
|
133
|
+
const locateSmartCdnPath = (parsed, options) => {
|
|
134
|
+
const full = `${parsed.origin}${parsed.pathname}`;
|
|
135
|
+
if (options.baseUrl == null) {
|
|
136
|
+
const match = /^([^.]+)\.tlcdn\.com$/i.exec(parsed.hostname);
|
|
137
|
+
if (match?.[1] == null || parsed.protocol !== 'https:') {
|
|
138
|
+
throw notSmartCdnUrl(`unexpected origin '${parsed.origin}'`);
|
|
139
|
+
}
|
|
140
|
+
return { workspaceSlug: match[1], remainder: parsed.pathname.slice(1) };
|
|
141
|
+
}
|
|
142
|
+
const template = options.baseUrl.replace(/\/+$/, '');
|
|
143
|
+
const placeholderIndex = template.indexOf(WORKSPACE_PLACEHOLDER);
|
|
144
|
+
if (placeholderIndex === -1) {
|
|
145
|
+
const prefix = `${template}/`;
|
|
146
|
+
if (!full.startsWith(prefix))
|
|
147
|
+
throw notSmartCdnUrl(`'${full}' is not under baseUrl '${template}'`);
|
|
148
|
+
const hostMatch = /^([^.]+)\.tlcdn\.com$/i.exec(parsed.hostname);
|
|
149
|
+
const workspaceSlug = options.workspace != null ? encodeURIComponent(options.workspace) : hostMatch?.[1];
|
|
150
|
+
if (workspaceSlug == null) {
|
|
151
|
+
throw notSmartCdnUrl('the workspace cannot be determined; pass `workspace` next to a baseUrl without {workspace}');
|
|
152
|
+
}
|
|
153
|
+
return { workspaceSlug, remainder: full.slice(prefix.length), baseUrl: template };
|
|
154
|
+
}
|
|
155
|
+
const before = template.slice(0, placeholderIndex);
|
|
156
|
+
const after = template.slice(placeholderIndex + WORKSPACE_PLACEHOLDER.length);
|
|
157
|
+
if (!full.startsWith(before))
|
|
158
|
+
throw notSmartCdnUrl(`'${full}' is not under baseUrl '${template}'`);
|
|
159
|
+
const rest = full.slice(before.length);
|
|
160
|
+
const slashIndex = rest.indexOf('/');
|
|
161
|
+
const workspaceSlug = slashIndex === -1 ? rest : rest.slice(0, slashIndex);
|
|
162
|
+
const afterPart = slashIndex === -1 ? '' : rest.slice(slashIndex);
|
|
163
|
+
if (workspaceSlug === '' || !afterPart.startsWith(`${after}/`)) {
|
|
164
|
+
throw notSmartCdnUrl(`'${full}' does not match baseUrl '${template}'`);
|
|
165
|
+
}
|
|
166
|
+
return {
|
|
167
|
+
workspaceSlug,
|
|
168
|
+
remainder: afterPart.slice(after.length + 1),
|
|
169
|
+
baseUrl: `${before}${workspaceSlug}${after}`,
|
|
170
|
+
};
|
|
171
|
+
};
|
|
172
|
+
/**
|
|
173
|
+
* Parses a Smart CDN URL back into the options that built it: the inverse of `getSmartCdnUrl` and
|
|
174
|
+
* `getSignedSmartCdnUrl`. Path segments are percent-decoded exactly once; query parameters are
|
|
175
|
+
* decoded by `URLSearchParams` semantics; `auth_key`/`exp`/`sig` are returned separately as `auth`.
|
|
176
|
+
*/
|
|
177
|
+
export const parseSmartCdnUrl = (url, options = {}) => {
|
|
178
|
+
let parsed;
|
|
179
|
+
try {
|
|
180
|
+
parsed = new URL(url);
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
throw notSmartCdnUrl(`'${url}' is not an absolute URL`);
|
|
184
|
+
}
|
|
185
|
+
const { workspaceSlug, remainder, baseUrl } = locateSmartCdnPath(parsed, options);
|
|
186
|
+
const slashIndex = remainder.indexOf('/');
|
|
187
|
+
if (slashIndex === -1)
|
|
188
|
+
throw notSmartCdnUrl('missing the input segment');
|
|
189
|
+
const templateSlug = remainder.slice(0, slashIndex);
|
|
190
|
+
if (templateSlug === '')
|
|
191
|
+
throw notSmartCdnUrl('missing the template segment');
|
|
192
|
+
const urlParams = {};
|
|
193
|
+
const signature = {};
|
|
194
|
+
for (const [key, value] of new URLSearchParams(parsed.search)) {
|
|
195
|
+
if (SIGNATURE_PARAMS.has(key)) {
|
|
196
|
+
signature[key] = value;
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
const existing = urlParams[key];
|
|
200
|
+
if (existing === undefined)
|
|
201
|
+
urlParams[key] = value;
|
|
202
|
+
else if (Array.isArray(existing))
|
|
203
|
+
existing.push(value);
|
|
204
|
+
else
|
|
205
|
+
urlParams[key] = [existing, value];
|
|
206
|
+
}
|
|
207
|
+
let auth;
|
|
208
|
+
const present = Object.keys(signature).length;
|
|
209
|
+
if (present > 0) {
|
|
210
|
+
if (present !== SIGNATURE_PARAMS.size) {
|
|
211
|
+
throw notSmartCdnUrl('incomplete signature parameters; expected auth_key, exp and sig together');
|
|
212
|
+
}
|
|
213
|
+
const expiresAt = Number(signature.exp);
|
|
214
|
+
if (!Number.isInteger(expiresAt))
|
|
215
|
+
throw notSmartCdnUrl(`exp '${signature.exp}' is not a timestamp`);
|
|
216
|
+
auth = { key: signature.auth_key, expiresAt, signature: signature.sig };
|
|
217
|
+
}
|
|
218
|
+
return {
|
|
219
|
+
workspace: decodeOnce(workspaceSlug, 'the workspace'),
|
|
220
|
+
template: decodeOnce(templateSlug, 'the template'),
|
|
221
|
+
input: decodeOnce(remainder.slice(slashIndex + 1), 'the input'),
|
|
222
|
+
urlParams,
|
|
223
|
+
...(auth && { auth }),
|
|
224
|
+
...(baseUrl != null && { baseUrl }),
|
|
225
|
+
};
|
|
42
226
|
};
|