From 2bd5073584c7bbcbb4ea620a474c9a7e159d19d6 Mon Sep 17 00:00:00 2001 From: Marius Kleidl Date: Fri, 22 Nov 2024 11:36:32 +0100 Subject: [PATCH 01/11] Add method for generating signed Smart CDN URLs --- README.md | 27 +++++++++++ src/Transloadit.ts | 55 +++++++++++++++++++++++ test/unit/test-transloadit-client.test.ts | 29 ++++++++++++ 3 files changed, 111 insertions(+) diff --git a/README.md b/README.md index 739329b9..d7e8288d 100644 --- a/README.md +++ b/README.md @@ -389,6 +389,33 @@ Calculates a signature for the given `params` JSON object. If the `params` objec This function returns an object with the key `signature` (containing the calculated signature string) and a key `params`, which contains the stringified version of the passed `params` object (including the set expires and authKey keys). +#### getSignedSmartCDNUrl(params) + +Constructs a signed Smart CDN URL, as defined in the [API documentation](https://transloadit.com/docs/topics/signature-authentication/#smart-cdn). `params` must be an object with the following properties: + +- `workspace` - Workspace slug (required) +- `template` - Template slug or template ID (required) +- `input` - Input value that is provided as `${fields.input}` in the template (required) +- `urlParams` - Object with additional parameters for the URL query string (optional) +- `expiresIn` - Expiration time of the signature in milliseconds. Defaults to 1 hour. (optional) + +Example: + +```js +const client = new Transloadit({ authKey: 'foo_key', authSecret: 'foo_secret' }) +const url = client.getSignedSmartCDNUrl({ + workspace: 'foo_workspace', + template: 'foo_template', + input: 'foo_input', + urlParams: { + foo: 'bar', + }, +}) + +// url is: +// https://foo_workspace.tlcdn.com/foo_template/foo_input?auth_key=foo_key&exp=1714525200000&foo=bar&sig=sha256:9548915ec70a5f0d05de9497289e792201ceec19a526fe315f4f4fd2e7e377ac +``` + ### Errors Errors from Node.js will be passed on and we use [GOT](https://github.com/sindresorhus/got) for HTTP requests and errors from there will also be passed on. When the HTTP response code is not 200, the error will be an `HTTPError`, which is a [got.HTTPError](https://github.com/sindresorhus/got#errors)) with some additional properties: diff --git a/src/Transloadit.ts b/src/Transloadit.ts index 3b9d543f..db7942f4 100644 --- a/src/Transloadit.ts +++ b/src/Transloadit.ts @@ -626,6 +626,38 @@ export class Transloadit { return { signature, params: jsonParams } } + /** + * Construct a signed Smart CDN URL. See https://transloadit.com/docs/topics/signature-authentication/#smart-cdn. + */ + getSignedSmartCDNUrl(opts: SmartCDNUrlOptions): string { + if (opts.workspace == null) throw new TypeError('workspace is required') + if (opts.template == null) throw new TypeError('template is required') + if (opts.input == null) throw new TypeError('input is required') + + const workspaceSlug = encodeURIComponent(opts.workspace) + const templateSlug = encodeURIComponent(opts.template) + const inputField = encodeURIComponent(opts.input) + const expiresIn = opts.expiresIn || 1 * 60 * 60 * 1000 // 1 hour + + // Convert urlParams to Record + const stringifiedParams: Record = {} + for (const [key, value] of Object.entries(opts.urlParams || {})) { + stringifiedParams[key] = `${value}` + } + + const queryParams = new URLSearchParams(stringifiedParams) + queryParams.set('auth_key', this._authKey) + queryParams.set('exp', `${Date.now() + expiresIn}`) + queryParams.sort() + + const stringToSign = `${workspaceSlug}/${templateSlug}/${inputField}?${queryParams}` + const algorithm = 'sha256' + const signature = createHmac(algorithm, this._authSecret).update(stringToSign).digest('hex') + + const signedUrl = `https://${workspaceSlug}.tlcdn.com/${templateSlug}/${inputField}?${queryParams}&sig=${algorithm}:${signature}` + return signedUrl + } + private _calcSignature(toSign: string, algorithm = 'sha384'): string { return `${algorithm}:${createHmac(algorithm, this._authSecret) .update(Buffer.from(toSign, 'utf-8')) @@ -960,3 +992,26 @@ export interface PaginationList { count: number items: T[] } + +export interface SmartCDNUrlOptions { + /** + * Workspace slug + */ + workspace: string + /** + * Template slug or template ID + */ + template: string + /** + * Input value that is provided as `${fields.input}` in the template + */ + input: string + /** + * Additional parameters for the URL query string + */ + urlParams?: Record + /** + * Expiration time of the signature in milliseconds. Defaults to 1 hour. + */ + expiresIn?: number +} diff --git a/test/unit/test-transloadit-client.test.ts b/test/unit/test-transloadit-client.test.ts index 2873212d..577b0690 100644 --- a/test/unit/test-transloadit-client.test.ts +++ b/test/unit/test-transloadit-client.test.ts @@ -343,4 +343,33 @@ describe('Transloadit', () => { ) }) }) + + describe('getSignedSmartCDNUrl', () => { + beforeAll(() => { + vi.useFakeTimers() + vi.setSystemTime('2024-05-01T00:00:00.000Z') + }) + + afterAll(() => { + vi.useRealTimers() + }) + + it('should return a signed url', () => { + const client = new Transloadit({ authKey: 'foo_key', authSecret: 'foo_secret' }) + + const url = client.getSignedSmartCDNUrl({ + workspace: 'foo_workspace', + template: 'foo_template', + input: 'foo/input', + urlParams: { + foo: 'bar', + aaa: 42, // Should be sorted as first param + }, + }) + + expect(url).toBe( + 'https://foo_workspace.tlcdn.com/foo_template/foo%2Finput?aaa=42&auth_key=foo_key&exp=1714525200000&foo=bar&sig=sha256:995dd1aae135fb77fa98b0e6946bd9768e0443a6028eba0361c03807e8fb68a5' + ) + }) + }) }) From f645872680b05893a64ced9452f8e610ef712ed7 Mon Sep 17 00:00:00 2001 From: Marius Kleidl Date: Fri, 22 Nov 2024 11:54:07 +0100 Subject: [PATCH 02/11] Do not URI encode workspace --- src/Transloadit.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Transloadit.ts b/src/Transloadit.ts index db7942f4..be8614b3 100644 --- a/src/Transloadit.ts +++ b/src/Transloadit.ts @@ -634,7 +634,7 @@ export class Transloadit { if (opts.template == null) throw new TypeError('template is required') if (opts.input == null) throw new TypeError('input is required') - const workspaceSlug = encodeURIComponent(opts.workspace) + const workspaceSlug = opts.workspace // No need to encode since it does not go in the path. const templateSlug = encodeURIComponent(opts.template) const inputField = encodeURIComponent(opts.input) const expiresIn = opts.expiresIn || 1 * 60 * 60 * 1000 // 1 hour From 6897c945afad97529eade8f2bb5853043d345ee4 Mon Sep 17 00:00:00 2001 From: Marius Kleidl Date: Fri, 22 Nov 2024 12:00:51 +0100 Subject: [PATCH 03/11] Revert "Do not URI encode workspace" This reverts commit f645872680b05893a64ced9452f8e610ef712ed7. --- src/Transloadit.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Transloadit.ts b/src/Transloadit.ts index be8614b3..db7942f4 100644 --- a/src/Transloadit.ts +++ b/src/Transloadit.ts @@ -634,7 +634,7 @@ export class Transloadit { if (opts.template == null) throw new TypeError('template is required') if (opts.input == null) throw new TypeError('input is required') - const workspaceSlug = opts.workspace // No need to encode since it does not go in the path. + const workspaceSlug = encodeURIComponent(opts.workspace) const templateSlug = encodeURIComponent(opts.template) const inputField = encodeURIComponent(opts.input) const expiresIn = opts.expiresIn || 1 * 60 * 60 * 1000 // 1 hour From 8311cfe3000d3074c3de2020d2417602fc86e849 Mon Sep 17 00:00:00 2001 From: Marius Kleidl <1375043+Acconut@users.noreply.github.com> Date: Mon, 25 Nov 2024 11:02:50 +0100 Subject: [PATCH 04/11] Apply suggestions from code review Co-authored-by: Remco Haszing Co-authored-by: Mikael Finstad --- src/Transloadit.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/Transloadit.ts b/src/Transloadit.ts index db7942f4..9df9bce6 100644 --- a/src/Transloadit.ts +++ b/src/Transloadit.ts @@ -637,7 +637,7 @@ export class Transloadit { const workspaceSlug = encodeURIComponent(opts.workspace) const templateSlug = encodeURIComponent(opts.template) const inputField = encodeURIComponent(opts.input) - const expiresIn = opts.expiresIn || 1 * 60 * 60 * 1000 // 1 hour + const expiresIn = opts.expiresIn || 60 * 60 * 1000 // 1 hour // Convert urlParams to Record const stringifiedParams: Record = {} @@ -648,13 +648,15 @@ export class Transloadit { const queryParams = new URLSearchParams(stringifiedParams) queryParams.set('auth_key', this._authKey) queryParams.set('exp', `${Date.now() + expiresIn}`) + // The signature changes depending on the order of the query parameters. We therefore sort them on the client- and server-side to ensure that we do not get mismatching signatures if a proxy changes the order of query parameters or implementations handle query parameters ordering differently. queryParams.sort() const stringToSign = `${workspaceSlug}/${templateSlug}/${inputField}?${queryParams}` const algorithm = 'sha256' const signature = createHmac(algorithm, this._authSecret).update(stringToSign).digest('hex') - const signedUrl = `https://${workspaceSlug}.tlcdn.com/${templateSlug}/${inputField}?${queryParams}&sig=${algorithm}:${signature}` + queryParams.set('sig', `sha256`:${signature}`) + const signedUrl = `https://${workspaceSlug}.tlcdn.com/${templateSlug}/${inputField}?${queryParams}` return signedUrl } @@ -1009,7 +1011,7 @@ export interface SmartCDNUrlOptions { /** * Additional parameters for the URL query string */ - urlParams?: Record + urlParams?: Record /** * Expiration time of the signature in milliseconds. Defaults to 1 hour. */ From 900fbdc73c25199def938961151d2112c5eefe66 Mon Sep 17 00:00:00 2001 From: Marius Kleidl Date: Mon, 25 Nov 2024 15:28:18 +0100 Subject: [PATCH 05/11] Fix implementation --- src/Transloadit.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Transloadit.ts b/src/Transloadit.ts index 9df9bce6..7b98f536 100644 --- a/src/Transloadit.ts +++ b/src/Transloadit.ts @@ -655,7 +655,7 @@ export class Transloadit { const algorithm = 'sha256' const signature = createHmac(algorithm, this._authSecret).update(stringToSign).digest('hex') - queryParams.set('sig', `sha256`:${signature}`) + queryParams.set('sig', `sha256:${signature}`) const signedUrl = `https://${workspaceSlug}.tlcdn.com/${templateSlug}/${inputField}?${queryParams}` return signedUrl } From dfe9722680f58ded1205eaab7ac5aa1470b14b7d Mon Sep 17 00:00:00 2001 From: Marius Kleidl Date: Mon, 25 Nov 2024 15:28:35 +0100 Subject: [PATCH 06/11] Allow duplicate parameters --- src/Transloadit.ts | 18 ++++++++++++------ test/unit/test-transloadit-client.test.ts | 4 ++-- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/src/Transloadit.ts b/src/Transloadit.ts index 7b98f536..c301c371 100644 --- a/src/Transloadit.ts +++ b/src/Transloadit.ts @@ -639,16 +639,22 @@ export class Transloadit { const inputField = encodeURIComponent(opts.input) const expiresIn = opts.expiresIn || 60 * 60 * 1000 // 1 hour - // Convert urlParams to Record - const stringifiedParams: Record = {} + const queryParams = new URLSearchParams() for (const [key, value] of Object.entries(opts.urlParams || {})) { - stringifiedParams[key] = `${value}` + if (Array.isArray(value)) { + for (const val of value) { + queryParams.append(key, `${val}`) + } + } else { + queryParams.append(key, `${value}`) + } } - const queryParams = new URLSearchParams(stringifiedParams) queryParams.set('auth_key', this._authKey) queryParams.set('exp', `${Date.now() + expiresIn}`) - // The signature changes depending on the order of the query parameters. We therefore sort them on the client- and server-side to ensure that we do not get mismatching signatures if a proxy changes the order of query parameters or implementations handle query parameters ordering differently. + // The signature changes depending on the order of the query parameters. We therefore sort them on the client- + // and server-side to ensure that we do not get mismatching signatures if a proxy changes the order of query + // parameters or implementations handle query parameters ordering differently. queryParams.sort() const stringToSign = `${workspaceSlug}/${templateSlug}/${inputField}?${queryParams}` @@ -1011,7 +1017,7 @@ export interface SmartCDNUrlOptions { /** * Additional parameters for the URL query string */ - urlParams?: Record + urlParams?: Record /** * Expiration time of the signature in milliseconds. Defaults to 1 hour. */ diff --git a/test/unit/test-transloadit-client.test.ts b/test/unit/test-transloadit-client.test.ts index 577b0690..2f57db42 100644 --- a/test/unit/test-transloadit-client.test.ts +++ b/test/unit/test-transloadit-client.test.ts @@ -363,12 +363,12 @@ describe('Transloadit', () => { input: 'foo/input', urlParams: { foo: 'bar', - aaa: 42, // Should be sorted as first param + aaa: [42, 21], // Should be sorted before `foo`. }, }) expect(url).toBe( - 'https://foo_workspace.tlcdn.com/foo_template/foo%2Finput?aaa=42&auth_key=foo_key&exp=1714525200000&foo=bar&sig=sha256:995dd1aae135fb77fa98b0e6946bd9768e0443a6028eba0361c03807e8fb68a5' + 'https://foo_workspace.tlcdn.com/foo_template/foo%2Finput?aaa=42&aaa=21&auth_key=foo_key&exp=1714525200000&foo=bar&sig=sha256%3A9a8df3bb28eea621b46ec808a250b7903b2546be7e66c048956d4f30b8da7519' ) }) }) From 9e4c33788e3edbd2ceccb5b25ddb127ec2a3f2b1 Mon Sep 17 00:00:00 2001 From: Marius Kleidl Date: Mon, 25 Nov 2024 15:31:16 +0100 Subject: [PATCH 07/11] Improve checks --- src/Transloadit.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Transloadit.ts b/src/Transloadit.ts index c301c371..bef41837 100644 --- a/src/Transloadit.ts +++ b/src/Transloadit.ts @@ -630,9 +630,9 @@ export class Transloadit { * Construct a signed Smart CDN URL. See https://transloadit.com/docs/topics/signature-authentication/#smart-cdn. */ getSignedSmartCDNUrl(opts: SmartCDNUrlOptions): string { - if (opts.workspace == null) throw new TypeError('workspace is required') - if (opts.template == null) throw new TypeError('template is required') - if (opts.input == null) throw new TypeError('input is required') + if (opts.workspace == null || opts.workspace === '') throw new TypeError('workspace is required') + if (opts.template == null || opts.template === '') throw new TypeError('template is required') + if (opts.input == null) throw new TypeError('input is required') // `input` can be an empty string. const workspaceSlug = encodeURIComponent(opts.workspace) const templateSlug = encodeURIComponent(opts.template) From a12d2a9b5d0ddd2a234ebc23b2b8161ea684cae5 Mon Sep 17 00:00:00 2001 From: Marius Kleidl Date: Mon, 25 Nov 2024 15:44:17 +0100 Subject: [PATCH 08/11] Fix formatting --- src/Transloadit.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Transloadit.ts b/src/Transloadit.ts index bef41837..655c2864 100644 --- a/src/Transloadit.ts +++ b/src/Transloadit.ts @@ -630,7 +630,8 @@ export class Transloadit { * Construct a signed Smart CDN URL. See https://transloadit.com/docs/topics/signature-authentication/#smart-cdn. */ getSignedSmartCDNUrl(opts: SmartCDNUrlOptions): string { - if (opts.workspace == null || opts.workspace === '') throw new TypeError('workspace is required') + if (opts.workspace == null || opts.workspace === '') + throw new TypeError('workspace is required') if (opts.template == null || opts.template === '') throw new TypeError('template is required') if (opts.input == null) throw new TypeError('input is required') // `input` can be an empty string. From a3099414aa7d6fb2138ae399d8656f72c53b8913 Mon Sep 17 00:00:00 2001 From: Marius Kleidl Date: Thu, 28 Nov 2024 10:44:16 +0100 Subject: [PATCH 09/11] Use absolute expiration time --- src/Transloadit.ts | 9 +++++---- test/unit/test-transloadit-client.test.ts | 10 +--------- 2 files changed, 6 insertions(+), 13 deletions(-) diff --git a/src/Transloadit.ts b/src/Transloadit.ts index 655c2864..5fa82cb7 100644 --- a/src/Transloadit.ts +++ b/src/Transloadit.ts @@ -638,7 +638,7 @@ export class Transloadit { const workspaceSlug = encodeURIComponent(opts.workspace) const templateSlug = encodeURIComponent(opts.template) const inputField = encodeURIComponent(opts.input) - const expiresIn = opts.expiresIn || 60 * 60 * 1000 // 1 hour + const expiresAt = opts.expiresAt || Date.now() + 60 * 60 * 1000 // 1 hour const queryParams = new URLSearchParams() for (const [key, value] of Object.entries(opts.urlParams || {})) { @@ -652,7 +652,7 @@ export class Transloadit { } queryParams.set('auth_key', this._authKey) - queryParams.set('exp', `${Date.now() + expiresIn}`) + queryParams.set('exp', `${expiresAt}`) // The signature changes depending on the order of the query parameters. We therefore sort them on the client- // and server-side to ensure that we do not get mismatching signatures if a proxy changes the order of query // parameters or implementations handle query parameters ordering differently. @@ -1020,7 +1020,8 @@ export interface SmartCDNUrlOptions { */ urlParams?: Record /** - * Expiration time of the signature in milliseconds. Defaults to 1 hour. + * Expiration timestamp of the signature in milliseconds since UNIX epoch. + * Defaults to 1 hour from now. */ - expiresIn?: number + expiresAt?: number } diff --git a/test/unit/test-transloadit-client.test.ts b/test/unit/test-transloadit-client.test.ts index 2f57db42..8d7b2ea3 100644 --- a/test/unit/test-transloadit-client.test.ts +++ b/test/unit/test-transloadit-client.test.ts @@ -345,15 +345,6 @@ describe('Transloadit', () => { }) describe('getSignedSmartCDNUrl', () => { - beforeAll(() => { - vi.useFakeTimers() - vi.setSystemTime('2024-05-01T00:00:00.000Z') - }) - - afterAll(() => { - vi.useRealTimers() - }) - it('should return a signed url', () => { const client = new Transloadit({ authKey: 'foo_key', authSecret: 'foo_secret' }) @@ -365,6 +356,7 @@ describe('Transloadit', () => { foo: 'bar', aaa: [42, 21], // Should be sorted before `foo`. }, + expiresAt: 1714525200000, }) expect(url).toBe( From 66452d1c7547b2615999028f42d235af5c9e06a8 Mon Sep 17 00:00:00 2001 From: Marius Kleidl Date: Thu, 28 Nov 2024 10:47:19 +0100 Subject: [PATCH 10/11] Test empty parameter --- test/unit/test-transloadit-client.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/unit/test-transloadit-client.test.ts b/test/unit/test-transloadit-client.test.ts index 8d7b2ea3..591f783a 100644 --- a/test/unit/test-transloadit-client.test.ts +++ b/test/unit/test-transloadit-client.test.ts @@ -355,12 +355,13 @@ describe('Transloadit', () => { urlParams: { foo: 'bar', aaa: [42, 21], // Should be sorted before `foo`. + empty: '', }, expiresAt: 1714525200000, }) expect(url).toBe( - 'https://foo_workspace.tlcdn.com/foo_template/foo%2Finput?aaa=42&aaa=21&auth_key=foo_key&exp=1714525200000&foo=bar&sig=sha256%3A9a8df3bb28eea621b46ec808a250b7903b2546be7e66c048956d4f30b8da7519' + 'https://foo_workspace.tlcdn.com/foo_template/foo%2Finput?aaa=42&aaa=21&auth_key=foo_key&empty=&exp=1714525200000&foo=bar&sig=sha256%3A1ab71ef553df3507a9e2cf7beb8f921538bbef49a13a94a22ff49f2f030a5e9e' ) }) }) From fbb307d4fa2dc1a716c594258867696240f9593d Mon Sep 17 00:00:00 2001 From: Marius Kleidl Date: Thu, 28 Nov 2024 10:48:33 +0100 Subject: [PATCH 11/11] Update docs --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index d7e8288d..48580f5e 100644 --- a/README.md +++ b/README.md @@ -397,7 +397,7 @@ Constructs a signed Smart CDN URL, as defined in the [API documentation](https:/ - `template` - Template slug or template ID (required) - `input` - Input value that is provided as `${fields.input}` in the template (required) - `urlParams` - Object with additional parameters for the URL query string (optional) -- `expiresIn` - Expiration time of the signature in milliseconds. Defaults to 1 hour. (optional) +- `expiresAt` - Expiration timestamp of the signature in milliseconds since UNIX epoch. Defaults to 1 hour from now. (optional) Example: