From f87b0b646b159f20e76e9f78285bc37afe609710 Mon Sep 17 00:00:00 2001 From: Marius Kleidl Date: Fri, 22 Nov 2024 15:42:32 +0100 Subject: [PATCH 01/10] Add method for generating signed Smart CDN URLs --- tests/test_client.py | 22 ++++++++++++++++++ transloadit/client.py | 53 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/tests/test_client.py b/tests/test_client.py index f56670c..312e5bd 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,4 +1,5 @@ import unittest +from unittest import mock import requests_mock from six.moves import urllib @@ -94,3 +95,24 @@ def test_get_bill(self, mock): response = self.transloadit.get_bill(month, year) self.assertEqual(response.data["ok"], "BILL_FOUND") + + def test_get_signed_smart_cdn_url(self): + client = Transloadit("foo_key", "foo_secret") + + # Freeze time to 2024-05-01T00:00:00.000Z for consistent signatures + with mock.patch('time.time', return_value=1714521600): + url = client.get_signed_smart_cdn_url( + workspace="foo_workspace", + template="foo_template", + input="foo/input", + url_params={ + "foo": "bar", + "aaa": 42 # Should be sorted as first param + } + ) + + expected_url = ( + "https://foo_workspace.tlcdn.com/foo_template/foo%2Finput?aaa=42&auth_key=foo_key&exp=1714525200000&foo=bar&sig=sha256:995dd1aae135fb77fa98b0e6946bd9768e0443a6028eba0361c03807e8fb68a5" + ) + + self.assertEqual(url, expected_url) diff --git a/transloadit/client.py b/transloadit/client.py index bec0214..c034ed6 100644 --- a/transloadit/client.py +++ b/transloadit/client.py @@ -1,4 +1,8 @@ import typing +import hmac +import hashlib +import time +from urllib.parse import urlencode, quote_plus from typing import Optional @@ -168,3 +172,52 @@ def get_bill(self, month: int, year: int): Return an instance of """ return self.request.get(f"/bill/{year}-{month:02d}") + + def get_signed_smart_cdn_url( + self, + workspace: str, + template: str, + input: str, + url_params: Optional[dict] = None, + expires_in: Optional[int] = 60 * 60 * 1000 # 1 hour + ) -> str: + """ + Construct a signed Smart CDN URL. + See https://transloadit.com/docs/topics/signature-authentication/#smart-cdn + + :Args: + - workspace (str): Workspace slug + - template (str): Template slug or template ID + - input (str): Input value that is provided as ${fields.input} in the template + - url_params (Optional[dict]): Additional parameters for the URL query string + - expires_in (Optional[int]): Expiration time of signature in milliseconds. Defaults to 1 hour. + + :Returns: + str: The signed Smart CDN URL + """ + workspace_slug = quote_plus(workspace) + template_slug = quote_plus(template) + input_field = quote_plus(input) + + # Convert url_params values to strings + params = {} + if url_params: + params.update({k: str(v) for k, v in url_params.items()}) + + params["auth_key"] = self.auth_key + params["exp"] = str(int(time.time() * 1000) + expires_in) + + # Sort params alphabetically + sorted_params = dict(sorted(params.items())) + query_string = urlencode(sorted_params) + + string_to_sign = f"{workspace_slug}/{template_slug}/{input_field}?{query_string}" + algorithm = "sha256" + + signature = hmac.new( + self.auth_secret.encode("utf-8"), + string_to_sign.encode("utf-8"), + hashlib.sha256 + ).hexdigest() + + return f"https://{workspace_slug}.tlcdn.com/{template_slug}/{input_field}?{query_string}&sig={algorithm}:{signature}" From 0016c2eca1b9e01aa1b0b45a5576131b6732574d Mon Sep 17 00:00:00 2001 From: Marius Kleidl Date: Mon, 25 Nov 2024 16:25:52 +0100 Subject: [PATCH 02/10] Allow duplicate URL query parameters --- tests/test_client.py | 4 ++-- transloadit/client.py | 30 +++++++++++++++++++----------- 2 files changed, 21 insertions(+), 13 deletions(-) diff --git a/tests/test_client.py b/tests/test_client.py index 312e5bd..4b9e1bd 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -107,12 +107,12 @@ def test_get_signed_smart_cdn_url(self): input="foo/input", url_params={ "foo": "bar", - "aaa": 42 # Should be sorted as first param + "aaa": [42, 21] # Should be sorted before `foo` } ) expected_url = ( - "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" ) self.assertEqual(url, expected_url) diff --git a/transloadit/client.py b/transloadit/client.py index c034ed6..00ce446 100644 --- a/transloadit/client.py +++ b/transloadit/client.py @@ -189,35 +189,43 @@ def get_signed_smart_cdn_url( - workspace (str): Workspace slug - template (str): Template slug or template ID - input (str): Input value that is provided as ${fields.input} in the template - - url_params (Optional[dict]): Additional parameters for the URL query string + - url_params (Optional[dict]): Additional parameters for the URL query string. Values can be strings, numbers, booleans or arrays thereof. - expires_in (Optional[int]): Expiration time of signature in milliseconds. Defaults to 1 hour. :Returns: str: The signed Smart CDN URL + + :Raises: + ValueError: If url_params contains values that are not strings, numbers, booleans or arrays """ workspace_slug = quote_plus(workspace) template_slug = quote_plus(template) input_field = quote_plus(input) - # Convert url_params values to strings - params = {} + params = [] if url_params: - params.update({k: str(v) for k, v in url_params.items()}) + for k, v in url_params.items(): + if isinstance(v, (str, int, float, bool)): + params.append((k, str(v))) + elif isinstance(v, (list, tuple)): + params.append((k, [str(vv) for vv in v])) + else: + raise ValueError(f"URL parameter values must be strings, numbers, booleans or arrays. Got {type(v)} for {k}") - params["auth_key"] = self.auth_key - params["exp"] = str(int(time.time() * 1000) + expires_in) + params.append(("auth_key", self.auth_key)) + params.append(("exp", str(int(time.time() * 1000) + expires_in))) - # Sort params alphabetically - sorted_params = dict(sorted(params.items())) - query_string = urlencode(sorted_params) + # Sort params alphabetically by key + sorted_params = sorted(params, key=lambda x: x[0]) + query_string = urlencode(sorted_params, doseq=True) string_to_sign = f"{workspace_slug}/{template_slug}/{input_field}?{query_string}" algorithm = "sha256" - signature = hmac.new( + signature = algorithm + ":" + hmac.new( self.auth_secret.encode("utf-8"), string_to_sign.encode("utf-8"), hashlib.sha256 ).hexdigest() - return f"https://{workspace_slug}.tlcdn.com/{template_slug}/{input_field}?{query_string}&sig={algorithm}:{signature}" + return f"https://{workspace_slug}.tlcdn.com/{template_slug}/{input_field}?{query_string}&sig={quote_plus(signature)}" From f0ed5dcfd045cdb9e8511fc617c279d0602d9d54 Mon Sep 17 00:00:00 2001 From: Marius Kleidl <1375043+Acconut@users.noreply.github.com> Date: Tue, 26 Nov 2024 11:35:25 +0100 Subject: [PATCH 03/10] Define types inside dictionary --- transloadit/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/transloadit/client.py b/transloadit/client.py index 00ce446..753b378 100644 --- a/transloadit/client.py +++ b/transloadit/client.py @@ -178,7 +178,7 @@ def get_signed_smart_cdn_url( workspace: str, template: str, input: str, - url_params: Optional[dict] = None, + url_params: Optional[dict[str, Union[str, int, float, bool, List[Union[str, int, float, bool]]]] = None, expires_in: Optional[int] = 60 * 60 * 1000 # 1 hour ) -> str: """ From cec942cbd66cfda130d6261a1e0deca252709640 Mon Sep 17 00:00:00 2001 From: Marius Kleidl Date: Tue, 26 Nov 2024 11:42:52 +0100 Subject: [PATCH 04/10] fixup! Define types inside dictionary --- transloadit/client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/transloadit/client.py b/transloadit/client.py index 753b378..a953da0 100644 --- a/transloadit/client.py +++ b/transloadit/client.py @@ -4,7 +4,7 @@ import time from urllib.parse import urlencode, quote_plus -from typing import Optional +from typing import Optional, Union, List from . import assembly, request, template @@ -178,7 +178,7 @@ def get_signed_smart_cdn_url( workspace: str, template: str, input: str, - url_params: Optional[dict[str, Union[str, int, float, bool, List[Union[str, int, float, bool]]]] = None, + url_params: Optional[dict[str, Union[str, int, float, bool, List[Union[str, int, float, bool]]]]] = None, expires_in: Optional[int] = 60 * 60 * 1000 # 1 hour ) -> str: """ From 68bc07ca7e66449582f4c6ff6e4f3c026fabc878 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 26 Nov 2024 14:11:45 +0100 Subject: [PATCH 05/10] Add note on running tests locally --- README.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/README.md b/README.md index b10d3a4..0f775f5 100644 --- a/README.md +++ b/README.md @@ -42,3 +42,25 @@ For fully working examples, take a look at [`examples/`](https://github.com/tran ## Documentation See [readthedocs](https://transloadit.readthedocs.io) for full API documentation. + +## Contributing + +### Running tests + +If you have a global installation of `poetry`, you can run the tests with: + +```bash +poetry run pytest --cov=transloadit tests +``` + +If you can't use a global installation of `poetry`, e.g. when using Nix Home Manager, you can create a Python virtual environment and install Poetry there: + +```bash +python -m venv .venv && source .venv/bin/activate && pip install poetry && poetry install +``` + +Then to run the tests: + +```bash +source .venv/bin/activate && poetry run pytest --cov=transloadit tests +``` From de551fd8c1de86f88e71f8bfe4cba669c02b7961 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 26 Nov 2024 14:27:34 +0100 Subject: [PATCH 06/10] Create node-smartcdn-sig.ts --- tests/node-smartcdn-sig.ts | 91 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100755 tests/node-smartcdn-sig.ts diff --git a/tests/node-smartcdn-sig.ts b/tests/node-smartcdn-sig.ts new file mode 100755 index 0000000..2873f84 --- /dev/null +++ b/tests/node-smartcdn-sig.ts @@ -0,0 +1,91 @@ +#!/usr/bin/env tsx +// Reference Smart CDN (https://transloadit.com/services/content-delivery/) Signature implementation +// And CLI tester to see if our SDK's implementation +// matches Node's + +/// + +import { createHash, createHmac } from 'crypto' + +interface SmartCDNParams { + workspace: string + template: string + input: string + expire_at_ms?: number + auth_key?: string + auth_secret?: string + url_params?: Record +} + +function signSmartCDNUrl(params: SmartCDNParams): string { + const { + workspace, + template, + input, + expire_at_ms, + auth_key, + auth_secret, + url_params = {}, + } = params + + if (!workspace) throw new Error('workspace is required') + if (!template) throw new Error('template is required') + if (input === null || input === undefined) + throw new Error('input must be a string') + if (!auth_key) throw new Error('auth_key is required') + if (!auth_secret) throw new Error('auth_secret is required') + + const workspaceSlug = encodeURIComponent(workspace) + const templateSlug = encodeURIComponent(template) + const inputField = encodeURIComponent(input) + + const expireAt = expire_at_ms ?? Date.now() + 60 * 60 * 1000 // 1 hour default + + const queryParams: Record = {} + + // Handle url_params + Object.entries(url_params).forEach(([key, value]) => { + if (value === null || value === undefined) return + if (Array.isArray(value)) { + value.forEach((val) => { + if (val === null || val === undefined) return + ;(queryParams[key] ||= []).push(String(val)) + }) + } else { + queryParams[key] = [String(value)] + } + }) + + queryParams.auth_key = [auth_key] + queryParams.exp = [String(expireAt)] + + // Sort parameters to ensure consistent ordering + const sortedParams = Object.entries(queryParams) + .sort() + .map(([key, values]) => + values.map((v) => `${encodeURIComponent(key)}=${encodeURIComponent(v)}`) + ) + .flat() + .join('&') + + const stringToSign = `${workspaceSlug}/${templateSlug}/${inputField}?${sortedParams}` + const signature = createHmac('sha256', auth_secret) + .update(stringToSign) + .digest('hex') + + const finalParams = `${sortedParams}&sig=${encodeURIComponent( + `sha256:${signature}` + )}` + return `https://${workspaceSlug}.tlcdn.com/${templateSlug}/${inputField}?${finalParams}` +} + +// Read JSON from stdin +let jsonInput = '' +process.stdin.on('data', (chunk) => { + jsonInput += chunk +}) + +process.stdin.on('end', () => { + const params = JSON.parse(jsonInput) + console.log(signSmartCDNUrl(params)) +}) From 65d5f90d59d3f4c2a0e86b606687f9c1525e7609 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 26 Nov 2024 14:38:36 +0100 Subject: [PATCH 07/10] Add Node reference implementation parity testing in CI --- .github/workflows/ci.yml | 14 +++- tests/test_client.py | 175 +++++++++++++++++++++++++++++++++++---- transloadit/client.py | 16 ++-- 3 files changed, 181 insertions(+), 24 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bbab26f..ab426b4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,17 +8,23 @@ jobs: strategy: matrix: os: [ubuntu-latest, windows-latest] - python-version: ["3.9", "3.10", "3.11", "3.12"] + python-version: ['3.9', '3.10', '3.11', '3.12'] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '20' + - name: Install tsx + run: npm install -g tsx - name: Set up Python uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} architecture: x64 - cache: "pip" + cache: 'pip' - name: Install Poetry manager run: pip install --upgrade poetry @@ -29,3 +35,5 @@ jobs: - name: Test with pytest run: | poetry run pytest --cov=transloadit tests + env: + TEST_NODE_PARITY: 1 diff --git a/tests/test_client.py b/tests/test_client.py index 4b9e1bd..9fa17c7 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,5 +1,10 @@ import unittest from unittest import mock +import json +import os +import subprocess +import time +from pathlib import Path import requests_mock from six.moves import urllib @@ -8,9 +13,43 @@ from transloadit.client import Transloadit +def get_expected_url(params): + """Get expected URL from Node.js reference implementation.""" + if os.getenv('TEST_NODE_PARITY') != '1': + return None + + # Check for tsx before trying to use it + tsx_path = subprocess.run(['which', 'tsx'], capture_output=True) + if tsx_path.returncode != 0: + raise RuntimeError('tsx command not found. Please install it with: npm install -g tsx') + + script_path = Path(__file__).parent / 'node-smartcdn-sig.ts' + json_input = json.dumps(params) + + result = subprocess.run( + ['tsx', str(script_path)], + input=json_input, + capture_output=True, + text=True + ) + + if result.returncode != 0: + raise RuntimeError(f'Node script failed: {result.stderr}') + + return result.stdout.strip() + + class ClientTest(unittest.TestCase): def setUp(self): self.transloadit = Transloadit("key", "secret") + # Use fixed timestamp for all Smart CDN tests + self.expire_at_ms = 1732550672867 + + def assert_parity_with_node(self, url, params, message=''): + """Assert that our URL matches the Node.js reference implementation.""" + expected_url = get_expected_url(params) + if expected_url is not None: + self.assertEqual(expected_url, url, message or 'URL should match Node.js reference implementation') @requests_mock.Mocker() def test_get_assembly(self, mock): @@ -97,22 +136,130 @@ def test_get_bill(self, mock): self.assertEqual(response.data["ok"], "BILL_FOUND") def test_get_signed_smart_cdn_url(self): - client = Transloadit("foo_key", "foo_secret") + """Test Smart CDN URL signing with various scenarios.""" + client = Transloadit("test-key", "test-secret") + + # Test basic URL generation + params = { + 'workspace': 'workspace', + 'template': 'template', + 'input': 'file.jpg', + 'auth_key': 'test-key', + 'auth_secret': 'test-secret', + 'expire_at_ms': self.expire_at_ms + } + + with mock.patch('time.time', return_value=self.expire_at_ms/1000 - 3600): + url = client.get_signed_smart_cdn_url( + params['workspace'], + params['template'], + params['input'], + {}, + 3600 * 1000 # 1 hour + ) + + expected_url = 'https://workspace.tlcdn.com/template/file.jpg?auth_key=test-key&exp=1732550672867&sig=sha256%3Ad994b8a737db1c43d6e04a07018dc33e8e28b23b27854bd6383d828a212cfffb' + self.assertEqual(url, expected_url, 'Basic URL should match expected') + self.assert_parity_with_node(url, params) + + # Test with different input field + params['input'] = 'input.jpg' + with mock.patch('time.time', return_value=self.expire_at_ms/1000 - 3600): + url = client.get_signed_smart_cdn_url( + params['workspace'], + params['template'], + params['input'], + {}, + 3600 * 1000 + ) + + expected_url = 'https://workspace.tlcdn.com/template/input.jpg?auth_key=test-key&exp=1732550672867&sig=sha256%3A75991f02828d194792c9c99f8fea65761bcc4c62dbb287a84f642033128297c0' + self.assertEqual(url, expected_url, 'URL with different input should match expected') + self.assert_parity_with_node(url, params) - # Freeze time to 2024-05-01T00:00:00.000Z for consistent signatures - with mock.patch('time.time', return_value=1714521600): + # Test with additional parameters + params['input'] = 'file.jpg' + params['url_params'] = {'width': 100} + with mock.patch('time.time', return_value=self.expire_at_ms/1000 - 3600): url = client.get_signed_smart_cdn_url( - workspace="foo_workspace", - template="foo_template", - input="foo/input", - url_params={ - "foo": "bar", - "aaa": [42, 21] # Should be sorted before `foo` - } + params['workspace'], + params['template'], + params['input'], + params['url_params'], + 3600 * 1000 ) - expected_url = ( - "https://foo_workspace.tlcdn.com/foo_template/foo%2Finput?aaa=42&aaa=21&auth_key=foo_key&exp=1714525200000&foo=bar&sig=sha256%3A9a8df3bb28eea621b46ec808a250b7903b2546be7e66c048956d4f30b8da7519" + expected_url = 'https://workspace.tlcdn.com/template/file.jpg?auth_key=test-key&exp=1732550672867&width=100&sig=sha256%3Ae5271d8fb6482d9351ebe4285b6fc75539c4d311ff125c4d76d690ad71c258ef' + self.assertEqual(url, expected_url, 'URL with additional params should match expected') + self.assert_parity_with_node(url, params) + + # Test with empty parameter string + params['url_params'] = {'width': '', 'height': '200'} + with mock.patch('time.time', return_value=self.expire_at_ms/1000 - 3600): + url = client.get_signed_smart_cdn_url( + params['workspace'], + params['template'], + params['input'], + params['url_params'], + 3600 * 1000 + ) + + expected_url = 'https://workspace.tlcdn.com/template/file.jpg?auth_key=test-key&exp=1732550672867&height=200&width=&sig=sha256%3A1a26733c859f070bc3d83eb3174650d7a0155642e44a5ac448a43bc728bc0f85' + self.assertEqual(url, expected_url, 'URL with empty param should match expected') + self.assert_parity_with_node(url, params) + + # Test with null parameter (should be excluded) + params['url_params'] = {'width': None, 'height': '200'} + with mock.patch('time.time', return_value=self.expire_at_ms/1000 - 3600): + url = client.get_signed_smart_cdn_url( + params['workspace'], + params['template'], + params['input'], + params['url_params'], + 3600 * 1000 + ) + + expected_url = 'https://workspace.tlcdn.com/template/file.jpg?auth_key=test-key&exp=1732550672867&height=200&sig=sha256%3Adb740ebdfad6e766ebf6516ed5ff6543174709f8916a254f8d069c1701cef517' + self.assertEqual(url, expected_url, 'URL with null param should match expected') + self.assert_parity_with_node(url, params) + + # Test with only empty parameter + params['url_params'] = {'width': ''} + with mock.patch('time.time', return_value=self.expire_at_ms/1000 - 3600): + url = client.get_signed_smart_cdn_url( + params['workspace'], + params['template'], + params['input'], + params['url_params'], + 3600 * 1000 + ) + + expected_url = 'https://workspace.tlcdn.com/template/file.jpg?auth_key=test-key&exp=1732550672867&width=&sig=sha256%3A840426f9ac72dde02fd080f09b2304d659fdd41e630b1036927ec1336c312e9d' + self.assertEqual(url, expected_url, 'URL with only empty param should match expected') + self.assert_parity_with_node(url, params) + + # Test default expiry (should be about 1 hour from now) + params['url_params'] = {} + del params['expire_at_ms'] + now = time.time() + url = client.get_signed_smart_cdn_url( + params['workspace'], + params['template'], + params['input'] ) - - self.assertEqual(url, expected_url) + + import re + match = re.search(r'exp=(\d+)', url) + self.assertIsNotNone(match, 'URL should contain expiry timestamp') + + expiry = int(match.group(1)) + now_ms = int(now * 1000) + one_hour = 60 * 60 * 1000 + + self.assertGreater(expiry, now_ms, 'Expiry should be in the future') + self.assertLess(expiry, now_ms + one_hour + 5000, 'Expiry should be about 1 hour from now') + self.assertGreater(expiry, now_ms + one_hour - 5000, 'Expiry should be about 1 hour from now') + + # For parity test, set the exact expiry time to match Node.js + params['expire_at_ms'] = expiry + self.assert_parity_with_node(url, params) diff --git a/transloadit/client.py b/transloadit/client.py index a953da0..77f8125 100644 --- a/transloadit/client.py +++ b/transloadit/client.py @@ -172,13 +172,13 @@ def get_bill(self, month: int, year: int): Return an instance of """ return self.request.get(f"/bill/{year}-{month:02d}") - + def get_signed_smart_cdn_url( self, workspace: str, template: str, input: str, - url_params: Optional[dict[str, Union[str, int, float, bool, List[Union[str, int, float, bool]]]]] = None, + url_params: Optional[dict[str, Union[str, int, float, bool, List[Union[str, int, float, bool]], None]]] = None, expires_in: Optional[int] = 60 * 60 * 1000 # 1 hour ) -> str: """ @@ -189,14 +189,14 @@ def get_signed_smart_cdn_url( - workspace (str): Workspace slug - template (str): Template slug or template ID - input (str): Input value that is provided as ${fields.input} in the template - - url_params (Optional[dict]): Additional parameters for the URL query string. Values can be strings, numbers, booleans or arrays thereof. + - url_params (Optional[dict]): Additional parameters for the URL query string. Values can be strings, numbers, booleans, arrays thereof, or None. - expires_in (Optional[int]): Expiration time of signature in milliseconds. Defaults to 1 hour. :Returns: str: The signed Smart CDN URL :Raises: - ValueError: If url_params contains values that are not strings, numbers, booleans or arrays + ValueError: If url_params contains values that are not strings, numbers, booleans, arrays, or None """ workspace_slug = quote_plus(workspace) template_slug = quote_plus(template) @@ -205,12 +205,14 @@ def get_signed_smart_cdn_url( params = [] if url_params: for k, v in url_params.items(): - if isinstance(v, (str, int, float, bool)): + if v is None: + continue # Skip None values + elif isinstance(v, (str, int, float, bool)): params.append((k, str(v))) elif isinstance(v, (list, tuple)): params.append((k, [str(vv) for vv in v])) else: - raise ValueError(f"URL parameter values must be strings, numbers, booleans or arrays. Got {type(v)} for {k}") + raise ValueError(f"URL parameter values must be strings, numbers, booleans, arrays, or None. Got {type(v)} for {k}") params.append(("auth_key", self.auth_key)) params.append(("exp", str(int(time.time() * 1000) + expires_in))) @@ -221,7 +223,7 @@ def get_signed_smart_cdn_url( string_to_sign = f"{workspace_slug}/{template_slug}/{input_field}?{query_string}" algorithm = "sha256" - + signature = algorithm + ":" + hmac.new( self.auth_secret.encode("utf-8"), string_to_sign.encode("utf-8"), From 540863655dcf361fa7e2d8fa96d3b8a25fd187e2 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 26 Nov 2024 14:44:16 +0100 Subject: [PATCH 08/10] Skip Node.js parity testing on Windows --- tests/test_client.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/test_client.py b/tests/test_client.py index 9fa17c7..e334054 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -2,6 +2,7 @@ from unittest import mock import json import os +import platform import subprocess import time from pathlib import Path @@ -18,6 +19,11 @@ def get_expected_url(params): if os.getenv('TEST_NODE_PARITY') != '1': return None + # Skip Node.js parity testing on Windows + if platform.system() == 'Windows': + print('Skipping Node.js parity testing on Windows') + return None + # Check for tsx before trying to use it tsx_path = subprocess.run(['which', 'tsx'], capture_output=True) if tsx_path.returncode != 0: From fcf75e0b77de4a3155a2b4d0efa79b02ba9b47b7 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 26 Nov 2024 14:51:34 +0100 Subject: [PATCH 09/10] Only support expires_at_ms --- tests/test_client.py | 12 ++++++------ transloadit/client.py | 8 +++++--- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/tests/test_client.py b/tests/test_client.py index e334054..1b4c207 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -161,7 +161,7 @@ def test_get_signed_smart_cdn_url(self): params['template'], params['input'], {}, - 3600 * 1000 # 1 hour + expires_at_ms=self.expire_at_ms ) expected_url = 'https://workspace.tlcdn.com/template/file.jpg?auth_key=test-key&exp=1732550672867&sig=sha256%3Ad994b8a737db1c43d6e04a07018dc33e8e28b23b27854bd6383d828a212cfffb' @@ -176,7 +176,7 @@ def test_get_signed_smart_cdn_url(self): params['template'], params['input'], {}, - 3600 * 1000 + expires_at_ms=self.expire_at_ms ) expected_url = 'https://workspace.tlcdn.com/template/input.jpg?auth_key=test-key&exp=1732550672867&sig=sha256%3A75991f02828d194792c9c99f8fea65761bcc4c62dbb287a84f642033128297c0' @@ -192,7 +192,7 @@ def test_get_signed_smart_cdn_url(self): params['template'], params['input'], params['url_params'], - 3600 * 1000 + expires_at_ms=self.expire_at_ms ) expected_url = 'https://workspace.tlcdn.com/template/file.jpg?auth_key=test-key&exp=1732550672867&width=100&sig=sha256%3Ae5271d8fb6482d9351ebe4285b6fc75539c4d311ff125c4d76d690ad71c258ef' @@ -207,7 +207,7 @@ def test_get_signed_smart_cdn_url(self): params['template'], params['input'], params['url_params'], - 3600 * 1000 + expires_at_ms=self.expire_at_ms ) expected_url = 'https://workspace.tlcdn.com/template/file.jpg?auth_key=test-key&exp=1732550672867&height=200&width=&sig=sha256%3A1a26733c859f070bc3d83eb3174650d7a0155642e44a5ac448a43bc728bc0f85' @@ -222,7 +222,7 @@ def test_get_signed_smart_cdn_url(self): params['template'], params['input'], params['url_params'], - 3600 * 1000 + expires_at_ms=self.expire_at_ms ) expected_url = 'https://workspace.tlcdn.com/template/file.jpg?auth_key=test-key&exp=1732550672867&height=200&sig=sha256%3Adb740ebdfad6e766ebf6516ed5ff6543174709f8916a254f8d069c1701cef517' @@ -237,7 +237,7 @@ def test_get_signed_smart_cdn_url(self): params['template'], params['input'], params['url_params'], - 3600 * 1000 + expires_at_ms=self.expire_at_ms ) expected_url = 'https://workspace.tlcdn.com/template/file.jpg?auth_key=test-key&exp=1732550672867&width=&sig=sha256%3A840426f9ac72dde02fd080f09b2304d659fdd41e630b1036927ec1336c312e9d' diff --git a/transloadit/client.py b/transloadit/client.py index 77f8125..4d659d2 100644 --- a/transloadit/client.py +++ b/transloadit/client.py @@ -179,7 +179,7 @@ def get_signed_smart_cdn_url( template: str, input: str, url_params: Optional[dict[str, Union[str, int, float, bool, List[Union[str, int, float, bool]], None]]] = None, - expires_in: Optional[int] = 60 * 60 * 1000 # 1 hour + expires_at_ms: Optional[int] = None ) -> str: """ Construct a signed Smart CDN URL. @@ -190,7 +190,7 @@ def get_signed_smart_cdn_url( - template (str): Template slug or template ID - input (str): Input value that is provided as ${fields.input} in the template - url_params (Optional[dict]): Additional parameters for the URL query string. Values can be strings, numbers, booleans, arrays thereof, or None. - - expires_in (Optional[int]): Expiration time of signature in milliseconds. Defaults to 1 hour. + - expires_at_ms (Optional[int]): Timestamp in milliseconds since epoch when the signature is no longer valid. Defaults to 1 hour from now. :Returns: str: The signed Smart CDN URL @@ -202,6 +202,8 @@ def get_signed_smart_cdn_url( template_slug = quote_plus(template) input_field = quote_plus(input) + expiry = expires_at_ms if expires_at_ms is not None else int(time.time() * 1000) + 60 * 60 * 1000 # 1 hour default + params = [] if url_params: for k, v in url_params.items(): @@ -215,7 +217,7 @@ def get_signed_smart_cdn_url( raise ValueError(f"URL parameter values must be strings, numbers, booleans, arrays, or None. Got {type(v)} for {k}") params.append(("auth_key", self.auth_key)) - params.append(("exp", str(int(time.time() * 1000) + expires_in))) + params.append(("exp", str(expiry))) # Sort params alphabetically by key sorted_params = sorted(params, key=lambda x: x[0]) From ad0165374b8c7d9ee12f9dcde3766baf6280334a Mon Sep 17 00:00:00 2001 From: Marius Kleidl <1375043+Acconut@users.noreply.github.com> Date: Thu, 28 Nov 2024 11:17:29 +0100 Subject: [PATCH 10/10] Update transloadit/client.py --- transloadit/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/transloadit/client.py b/transloadit/client.py index 4d659d2..eeeab24 100644 --- a/transloadit/client.py +++ b/transloadit/client.py @@ -190,7 +190,7 @@ def get_signed_smart_cdn_url( - template (str): Template slug or template ID - input (str): Input value that is provided as ${fields.input} in the template - url_params (Optional[dict]): Additional parameters for the URL query string. Values can be strings, numbers, booleans, arrays thereof, or None. - - expires_at_ms (Optional[int]): Timestamp in milliseconds since epoch when the signature is no longer valid. Defaults to 1 hour from now. + - expires_at_ms (Optional[int]): Timestamp in milliseconds since UNIX epoch when the signature is no longer valid. Defaults to 1 hour from now. :Returns: str: The signed Smart CDN URL