Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add method for generating signed Smart CDN URLs #33

Merged
merged 10 commits into from
Nov 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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