Skip to content

Commit

Permalink
Merge pull request #33 from transloadit/smart-cdn-url-signature
Browse files Browse the repository at this point in the history
Add method for generating signed Smart CDN URLs
  • Loading branch information
Acconut authored Nov 28, 2024
2 parents b6118db + ad01653 commit 7a5f0dc
Show file tree
Hide file tree
Showing 5 changed files with 365 additions and 4 deletions.
14 changes: 11 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -29,3 +35,5 @@ jobs:
- name: Test with pytest
run: |
poetry run pytest --cov=transloadit tests
env:
TEST_NODE_PARITY: 1
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
91 changes: 91 additions & 0 deletions tests/node-smartcdn-sig.ts
Original file line number Diff line number Diff line change
@@ -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

/// <reference types="node" />

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<string, any>
}

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<string, string[]> = {}

// 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))
})
175 changes: 175 additions & 0 deletions tests/test_client.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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):
Expand Down Expand Up @@ -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)
Loading

0 comments on commit 7a5f0dc

Please sign in to comment.