@transloadit/utils 4.5.1 → 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 CHANGED
@@ -10,8 +10,18 @@ npm install @transloadit/utils
10
10
 
11
11
  ## Web / Edge usage
12
12
 
13
+ Everything in the root export runs on WebCrypto, so it works in browsers (secure origins only:
14
+ `https://` or `localhost`), edge runtimes, and Node.
15
+
13
16
  ```ts
14
- import { signParams, verifyWebhookSignature } from '@transloadit/utils'
17
+ import {
18
+ getSignedSmartCdnUrl,
19
+ getSmartCdnUrl,
20
+ parseSmartCdnUrl,
21
+ signParams,
22
+ stripSmartCdnAuth,
23
+ verifyWebhookSignature,
24
+ } from '@transloadit/utils'
15
25
 
16
26
  const signature = await signParams(paramsString, authSecret)
17
27
  const verified = await verifyWebhookSignature({
@@ -19,8 +29,38 @@ const verified = await verifyWebhookSignature({
19
29
  signatureHeader,
20
30
  authSecret,
21
31
  })
32
+ const url = await getSignedSmartCdnUrl({
33
+ workspace,
34
+ template,
35
+ input,
36
+ authKey,
37
+ authSecret,
38
+ })
22
39
  ```
23
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
+
24
64
  ## Node usage
25
65
 
26
66
  ```ts
@@ -55,9 +95,17 @@ for (const source of imageCandidates.sources) {
55
95
 
56
96
  ## API
57
97
 
58
- - `signParams(paramsString, authSecret, algorithm?)`: WebCrypto-based HMAC signature for params.
98
+ - `signParams(paramsString, authSecret, algorithm?)`: WebCrypto-based HMAC signature for params
99
+ (`sha1`, `sha256`, `sha384`, `sha512`).
59
100
  - `verifyWebhookSignature({ rawBody, signatureHeader, authSecret })`: validates webhook signatures.
101
+ - `getSignedSmartCdnUrl(options)`: async, WebCrypto-based Smart CDN URL signer. Byte-identical to
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`.
60
108
  - `signParamsSync(paramsString, authSecret, algorithm?)`: Node-only sync signature helper.
61
- - `getSignedSmartCdnUrl(options)`: Node-only Smart CDN URL signer.
109
+ - `getSignedSmartCdnUrl(options)` from `@transloadit/utils/node`: synchronous Smart CDN URL signer.
62
110
  - `getSignedSmartCdnImageCandidates(options)`: deterministic structured, signed AVIF and WebP
63
111
  candidates plus the original fallback URL.
package/dist/index.d.ts CHANGED
@@ -1,5 +1,8 @@
1
- export type SignatureAlgorithm = 'sha1' | 'sha256' | 'sha384';
1
+ import type { SmartCdnUrlOptions } from './smartCdn.ts';
2
+ export type SignatureAlgorithm = 'sha1' | 'sha256' | 'sha384' | 'sha512';
3
+ export type { ParsedSmartCdnUrl, ParseSmartCdnUrlOptions, SmartCdnUnsignedUrlOptions, SmartCdnUrlOptions, SmartCdnUrlParams, } from './smartCdn.ts';
2
4
  export * from './assemblyInstructionsCompiler.ts';
5
+ export { getSmartCdnUrl, parseSmartCdnUrl, stripSmartCdnAuth } from './smartCdn.ts';
3
6
  export declare const signParams: (paramsString: string, authSecret: string, algorithm?: SignatureAlgorithm) => Promise<string>;
4
7
  export type VerifyWebhookSignatureOptions = {
5
8
  rawBody: string;
@@ -7,4 +10,9 @@ export type VerifyWebhookSignatureOptions = {
7
10
  authSecret: string;
8
11
  };
9
12
  export declare const verifyWebhookSignature: (options: VerifyWebhookSignatureOptions) => Promise<boolean>;
13
+ /**
14
+ * Signs a Smart CDN URL with WebCrypto, so it works in browsers, edge runtimes and Node alike.
15
+ * Produces the same URL as the synchronous `getSignedSmartCdnUrl` from `@transloadit/utils/node`.
16
+ */
17
+ export declare const getSignedSmartCdnUrl: (opts: SmartCdnUrlOptions) => Promise<string>;
10
18
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,kBAAkB,GAAG,MAAM,GAAG,QAAQ,GAAG,QAAQ,CAAA;AAE7D,cAAc,mCAAmC,CAAA;AAiDjD,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"}
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,14 +1,18 @@
1
+ import { finishSmartCdnUrl, prepareSmartCdnUrl } from "./smartCdn.js";
1
2
  export * from "./assemblyInstructionsCompiler.js";
3
+ export { getSmartCdnUrl, parseSmartCdnUrl, stripSmartCdnAuth } from "./smartCdn.js";
2
4
  const algorithmMap = {
3
5
  sha1: 'SHA-1',
4
6
  sha256: 'SHA-256',
5
7
  sha384: 'SHA-384',
8
+ sha512: 'SHA-512',
6
9
  };
7
- const isSignatureAlgorithm = (value) => value === 'sha1' || value === 'sha256' || value === 'sha384';
10
+ const isSignatureAlgorithm = (value) => value === 'sha1' || value === 'sha256' || value === 'sha384' || value === 'sha512';
8
11
  const getSubtle = () => {
9
12
  const subtle = globalThis.crypto?.subtle;
10
13
  if (!subtle) {
11
- throw new Error('Web Crypto is required to sign Transloadit payloads');
14
+ // Browsers only expose crypto.subtle on secure origins (https:// or localhost).
15
+ throw new Error('Web Crypto is required to sign Transloadit payloads; browsers only provide crypto.subtle on secure origins (https:// or localhost)');
12
16
  }
13
17
  return subtle;
14
18
  };
@@ -55,3 +59,12 @@ export const verifyWebhookSignature = async (options) => {
55
59
  const expected = await hmacHex(normalized, options.authSecret, options.rawBody);
56
60
  return safeCompare(expected, signature);
57
61
  };
62
+ /**
63
+ * Signs a Smart CDN URL with WebCrypto, so it works in browsers, edge runtimes and Node alike.
64
+ * Produces the same URL as the synchronous `getSignedSmartCdnUrl` from `@transloadit/utils/node`.
65
+ */
66
+ export const getSignedSmartCdnUrl = async (opts) => {
67
+ const prepared = prepareSmartCdnUrl(opts);
68
+ const signature = await hmacHex('sha256', opts.authSecret, prepared.stringToSign);
69
+ return finishSmartCdnUrl(prepared, signature);
70
+ };
package/dist/node.d.ts CHANGED
@@ -1,5 +1,8 @@
1
1
  import type { SignatureAlgorithm } from './index.ts';
2
+ import type { SmartCdnUrlOptions } from './smartCdn.ts';
2
3
  export type { SignatureAlgorithm } from './index.ts';
4
+ export type { ParsedSmartCdnUrl, ParseSmartCdnUrlOptions, SmartCdnUnsignedUrlOptions, SmartCdnUrlOptions, SmartCdnUrlParams, } from './smartCdn.ts';
5
+ export { getSmartCdnUrl, parseSmartCdnUrl, stripSmartCdnAuth } from './smartCdn.ts';
3
6
  export type SignatureAlgorithmInput = SignatureAlgorithm | (string & {});
4
7
  /** Image formats supported by the responsive-image Built-in. */
5
8
  export type SmartCdnImageFormat = 'avif' | 'png' | 'webp';
@@ -38,38 +41,8 @@ export interface SmartCdnImageCandidatesOptions {
38
41
  /** Workspace slug. */
39
42
  workspace: string;
40
43
  }
41
- export type SmartCdnUrlOptions = {
42
- /**
43
- * Workspace slug.
44
- */
45
- workspace: string;
46
- /**
47
- * Template slug or template ID.
48
- */
49
- template: string;
50
- /**
51
- * Input value that is provided as `${fields.input}` in the template.
52
- */
53
- input: string;
54
- /**
55
- * Additional parameters for the URL query string.
56
- */
57
- urlParams?: Record<string, boolean | number | string | (boolean | number | string)[]>;
58
- /**
59
- * Expiration timestamp of the signature in milliseconds since UNIX epoch.
60
- * Defaults to 1 hour from now.
61
- */
62
- expiresAt?: number;
63
- /**
64
- * Transloadit auth key used to sign the URL.
65
- */
66
- authKey: string;
67
- /**
68
- * Transloadit auth secret used to sign the URL.
69
- */
70
- authSecret: string;
71
- };
72
44
  export declare const signParamsSync: (paramsString: string, authSecret: string, algorithm?: SignatureAlgorithmInput) => string;
45
+ /** Synchronous Smart CDN URL signer (Node). The root export has an async WebCrypto twin. */
73
46
  export declare const getSignedSmartCdnUrl: (opts: SmartCdnUrlOptions) => string;
74
47
  /**
75
48
  * Builds deterministic signed Smart CDN candidates for server-rendered `<picture>` elements.
@@ -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;AAIpD,YAAY,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAA;AAEpD,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;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,MAAM,CAAC,MAAM,EAAE,OAAO,GAAG,MAAM,GAAG,MAAM,GAAG,CAAC,OAAO,GAAG,MAAM,GAAG,MAAM,CAAC,EAAE,CAAC,CAAA;IACrF;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB;;OAEG;IACH,OAAO,EAAE,MAAM,CAAA;IACf;;OAEG;IACH,UAAU,EAAE,MAAM,CAAA;CACnB,CAAA;AAkED,eAAO,MAAM,cAAc,GACzB,cAAc,MAAM,EACpB,YAAY,MAAM,EAClB,YAAW,uBAAkC,KAC5C,MAKF,CAAA;AAED,eAAO,MAAM,oBAAoB,GAAI,MAAM,kBAAkB,KAAG,MA8B/D,CAAA;AAED;;;;;GAKG;AACH,wBAAgB,gCAAgC,CAC9C,IAAI,EAAE,8BAA8B,GACnC,uBAAuB,CAsDzB"}
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
@@ -1,4 +1,6 @@
1
1
  import { createHmac } from 'node:crypto';
2
+ import { finishSmartCdnUrl, prepareSmartCdnUrl } from "./smartCdn.js";
3
+ export { getSmartCdnUrl, parseSmartCdnUrl, stripSmartCdnAuth } from "./smartCdn.js";
2
4
  const defaultSmartCdnImageFormats = {
3
5
  avif: 45,
4
6
  webp: 75,
@@ -57,35 +59,13 @@ export const signParamsSync = (paramsString, authSecret, algorithm = 'sha384') =
57
59
  .digest('hex');
58
60
  return `${algorithm}:${signature}`;
59
61
  };
62
+ /** Synchronous Smart CDN URL signer (Node). The root export has an async WebCrypto twin. */
60
63
  export const getSignedSmartCdnUrl = (opts) => {
61
- if (opts.workspace == null || opts.workspace === '')
62
- throw new TypeError('workspace is required');
63
- if (opts.template == null || opts.template === '')
64
- throw new TypeError('template is required');
65
- if (opts.input == null)
66
- throw new TypeError('input is required');
67
- const workspaceSlug = encodeURIComponent(opts.workspace);
68
- const templateSlug = encodeURIComponent(opts.template);
69
- const inputField = encodeURIComponent(opts.input);
70
- const expiresAt = opts.expiresAt || Date.now() + 60 * 60 * 1000;
71
- const queryParams = new URLSearchParams();
72
- for (const [key, value] of Object.entries(opts.urlParams || {})) {
73
- if (Array.isArray(value)) {
74
- for (const val of value) {
75
- queryParams.append(key, `${val}`);
76
- }
77
- }
78
- else {
79
- queryParams.append(key, `${value}`);
80
- }
81
- }
82
- queryParams.set('auth_key', opts.authKey);
83
- queryParams.set('exp', `${expiresAt}`);
84
- queryParams.sort();
85
- const stringToSign = `${workspaceSlug}/${templateSlug}/${inputField}?${queryParams}`;
86
- const signature = createHmac('sha256', opts.authSecret).update(stringToSign).digest('hex');
87
- queryParams.set('sig', `sha256:${signature}`);
88
- return `https://${workspaceSlug}.tlcdn.com/${templateSlug}/${inputField}?${queryParams}`;
64
+ const prepared = prepareSmartCdnUrl(opts);
65
+ const signature = createHmac('sha256', opts.authSecret)
66
+ .update(prepared.stringToSign)
67
+ .digest('hex');
68
+ return finishSmartCdnUrl(prepared, signature);
89
69
  };
90
70
  /**
91
71
  * Builds deterministic signed Smart CDN candidates for server-rendered `<picture>` elements.
@@ -0,0 +1,113 @@
1
+ /**
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.
6
+ */
7
+ export type SmartCdnUrlParams = Record<string, boolean | number | string | (boolean | number | string)[]>;
8
+ export type SmartCdnUrlOptions = {
9
+ /**
10
+ * Workspace slug.
11
+ */
12
+ workspace: string;
13
+ /**
14
+ * Template slug or template ID.
15
+ */
16
+ template: string;
17
+ /**
18
+ * Input value that is provided as `${fields.input}` in the template.
19
+ */
20
+ input: string;
21
+ /**
22
+ * Additional parameters for the URL query string.
23
+ */
24
+ urlParams?: SmartCdnUrlParams;
25
+ /**
26
+ * Expiration timestamp of the signature in milliseconds since UNIX epoch.
27
+ * Defaults to 1 hour from now.
28
+ */
29
+ expiresAt?: number;
30
+ /**
31
+ * Transloadit auth key used to sign the URL.
32
+ */
33
+ authKey: string;
34
+ /**
35
+ * Transloadit auth secret used to sign the URL.
36
+ */
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;
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'>;
51
+ /** A Smart CDN URL with everything but its signature in place. */
52
+ export interface PreparedSmartCdnUrl {
53
+ /** `workspace/template/input?sortedQuery`, the message the auth secret signs with HMAC-SHA256. */
54
+ stringToSign: string;
55
+ /** URL-encoded path segments and the sorted query (without `sig`). */
56
+ parts: {
57
+ workspaceSlug: string;
58
+ templateSlug: string;
59
+ inputField: string;
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;
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;
91
+ }
92
+ /** Validates the options and assembles the string to sign; the caller supplies the HMAC. */
93
+ export declare const prepareSmartCdnUrl: (opts: SmartCdnUrlOptions) => PreparedSmartCdnUrl;
94
+ /** Appends the `sig` parameter and returns the final `https://{workspace}.tlcdn.com/…` URL. */
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;
113
+ //# sourceMappingURL=smartCdn.d.ts.map
@@ -0,0 +1 @@
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"}
@@ -0,0 +1,226 @@
1
+ /**
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.
6
+ */
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) => {
13
+ if (opts.workspace == null || opts.workspace === '')
14
+ throw new TypeError('workspace is required');
15
+ if (opts.template == null || opts.template === '')
16
+ throw new TypeError('template is required');
17
+ if (opts.input == null)
18
+ throw new TypeError('input is required');
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) => {
37
+ const queryParams = new URLSearchParams();
38
+ for (const [key, value] of Object.entries(urlParams || {})) {
39
+ if (Array.isArray(value)) {
40
+ for (const val of value) {
41
+ queryParams.append(key, `${val}`);
42
+ }
43
+ }
44
+ else {
45
+ queryParams.append(key, `${value}`);
46
+ }
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);
58
+ queryParams.set('auth_key', opts.authKey);
59
+ queryParams.set('exp', `${expiresAt}`);
60
+ queryParams.sort();
61
+ return {
62
+ stringToSign: `${workspaceSlug}/${templateSlug}/${inputField}?${queryParams}`,
63
+ parts: {
64
+ workspaceSlug,
65
+ templateSlug,
66
+ inputField,
67
+ queryParams,
68
+ baseUrl: resolveBaseUrl(opts.baseUrl, workspaceSlug),
69
+ },
70
+ };
71
+ };
72
+ /** Appends the `sig` parameter and returns the final `https://{workspace}.tlcdn.com/…` URL. */
73
+ export const finishSmartCdnUrl = ({ parts }, signatureHex) => {
74
+ const { baseUrl, templateSlug, inputField, queryParams } = parts;
75
+ queryParams.set('sig', `sha256:${signatureHex}`);
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
+ };
226
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@transloadit/utils",
3
- "version": "4.5.1",
3
+ "version": "4.7.0",
4
4
  "description": "Transloadit shared utilities",
5
5
  "type": "module",
6
6
  "license": "MIT",