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)}"