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/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 +``` 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)) +}) diff --git a/tests/test_client.py b/tests/test_client.py index f56670c..1b4c207 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,4 +1,11 @@ import unittest +from unittest import mock +import json +import os +import platform +import subprocess +import time +from pathlib import Path import requests_mock from six.moves import urllib @@ -7,9 +14,48 @@ 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 + + # 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: + 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): @@ -94,3 +140,132 @@ 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): + """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'], + {}, + 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' + 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'], + {}, + 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' + self.assertEqual(url, expected_url, 'URL with different input should match expected') + self.assert_parity_with_node(url, params) + + # 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( + params['workspace'], + params['template'], + params['input'], + params['url_params'], + 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' + 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'], + 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' + 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'], + 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' + 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'], + 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' + 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'] + ) + + 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 bec0214..eeeab24 100644 --- a/transloadit/client.py +++ b/transloadit/client.py @@ -1,6 +1,10 @@ import typing +import hmac +import hashlib +import time +from urllib.parse import urlencode, quote_plus -from typing import Optional +from typing import Optional, Union, List from . import assembly, request, template @@ -168,3 +172,64 @@ 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]]] = None, + expires_at_ms: Optional[int] = None + ) -> 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. Values can be strings, numbers, booleans, arrays thereof, or None. + - 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 + + :Raises: + 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) + 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(): + 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, arrays, or None. Got {type(v)} for {k}") + + params.append(("auth_key", self.auth_key)) + params.append(("exp", str(expiry))) + + # 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 = 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={quote_plus(signature)}"