From 166bdba19099fa9271b9bd7883b47f4478a4df17 Mon Sep 17 00:00:00 2001 From: Marius Kleidl <1375043+Acconut@users.noreply.github.com> Date: Thu, 28 Nov 2024 10:53:07 +0100 Subject: [PATCH] Add method for generating signed Smart CDN URLs (#201) * Add method for generating signed Smart CDN URLs * Do not URI encode workspace * Revert "Do not URI encode workspace" This reverts commit f645872680b05893a64ced9452f8e610ef712ed7. * Apply suggestions from code review Co-authored-by: Remco Haszing Co-authored-by: Mikael Finstad * Fix implementation * Allow duplicate parameters * Improve checks * Fix formatting * Use absolute expiration time * Test empty parameter * Update docs --------- Co-authored-by: Remco Haszing Co-authored-by: Mikael Finstad --- README.md | 27 ++++++++++ src/Transloadit.ts | 65 +++++++++++++++++++++++ test/unit/test-transloadit-client.test.ts | 22 ++++++++ 3 files changed, 114 insertions(+) diff --git a/README.md b/README.md index 739329b..48580f5 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) +- `expiresAt` - Expiration timestamp of the signature in milliseconds since UNIX epoch. Defaults to 1 hour from now. (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 3b9d543..5fa82cb 100644 --- a/src/Transloadit.ts +++ b/src/Transloadit.ts @@ -626,6 +626,47 @@ 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 || 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) + const inputField = encodeURIComponent(opts.input) + const expiresAt = opts.expiresAt || Date.now() + 60 * 60 * 1000 // 1 hour + + const queryParams = new URLSearchParams() + for (const [key, value] of Object.entries(opts.urlParams || {})) { + if (Array.isArray(value)) { + for (const val of value) { + queryParams.append(key, `${val}`) + } + } else { + queryParams.append(key, `${value}`) + } + } + + queryParams.set('auth_key', this._authKey) + 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. + queryParams.sort() + + const stringToSign = `${workspaceSlug}/${templateSlug}/${inputField}?${queryParams}` + const algorithm = 'sha256' + const signature = createHmac(algorithm, this._authSecret).update(stringToSign).digest('hex') + + queryParams.set('sig', `sha256:${signature}`) + const signedUrl = `https://${workspaceSlug}.tlcdn.com/${templateSlug}/${inputField}?${queryParams}` + return signedUrl + } + private _calcSignature(toSign: string, algorithm = 'sha384'): string { return `${algorithm}:${createHmac(algorithm, this._authSecret) .update(Buffer.from(toSign, 'utf-8')) @@ -960,3 +1001,27 @@ 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 timestamp of the signature in milliseconds since UNIX epoch. + * Defaults to 1 hour from now. + */ + expiresAt?: number +} diff --git a/test/unit/test-transloadit-client.test.ts b/test/unit/test-transloadit-client.test.ts index 2873212..591f783 100644 --- a/test/unit/test-transloadit-client.test.ts +++ b/test/unit/test-transloadit-client.test.ts @@ -343,4 +343,26 @@ describe('Transloadit', () => { ) }) }) + + describe('getSignedSmartCDNUrl', () => { + 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, 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&empty=&exp=1714525200000&foo=bar&sig=sha256%3A1ab71ef553df3507a9e2cf7beb8f921538bbef49a13a94a22ff49f2f030a5e9e' + ) + }) + }) })