Upload and Download URLs
Both backends support generating time-limited URLs that let a client upload or download assets without routing binary data through your application server. The upload protocol differs between backends:
Backend |
Upload protocol |
Download (private) |
|---|---|---|
|
Presigned |
Presigned |
|
tus |
Nginx |
Both backends return the same UploadUrlResult model. Your application code
and client-side code can be written against the common interface and swapped at
configuration time.
Upload URLs
S3 — Presigned PUT
The classic S3 presigned upload flow has three steps:
Request a presigned URL from your backend API.
PUT the file directly to S3 using the returned URL and headers.
Confirm the upload — your backend polls
exists()or receives an S3 event notification.
from granite_assets import S3AssetRepositoryConfig, build_asset_repository
config = S3AssetRepositoryConfig(
bucket="my-bucket",
region="eu-west-1",
presign_ttl_seconds=900, # 15 minutes
)
repo = build_asset_repository(config)
upload = repo.build_upload_url(
key="uploads/user-42/avatar.jpg",
content_type="image/jpeg",
ttl_seconds=600, # override config default
)
print(upload.url) # https://my-bucket.s3.eu-west-1.amazonaws.com/...
print(upload.method) # "PUT"
print(upload.headers) # {"Content-Type": "image/jpeg"}
print(upload.expires_at) # datetime (UTC)
print(upload.key) # "uploads/user-42/avatar.jpg"
Local Nginx — tus (via tusd)
The local backend uses the tus resumable upload protocol with a tusd server. This supports arbitrarily large files and automatic resume on connection loss.
The upload flow has three steps:
Request a tus creation URL from your backend API.
POST to create the upload resource; receive a
Locationheader.PATCH the file data in one or more chunks to the
LocationURL.
Tusd must be configured and running. Set tusd_url and upload_secret
on the repository config:
import os
from granite_assets import LocalNginxAssetRepositoryConfig, build_asset_repository
config = LocalNginxAssetRepositoryConfig(
storage_path="/srv/assets",
base_url="https://media.example.com/assets",
tusd_url="http://localhost:1080", # or internal service name
upload_secret=os.environ["UPLOAD_SECRET"],
upload_ttl_seconds=3600,
)
repo = build_asset_repository(config)
upload = repo.build_upload_url(
key="invoices/inv-001.pdf",
content_type="application/pdf",
visibility=AssetVisibility.PRIVATE,
)
print(upload.url) # http://localhost:1080/files/
print(upload.method) # "POST"
print(upload.headers)
# {
# "Tus-Resumable": "1.0.0",
# "Upload-Metadata": "asset-key aW52…, content-type YXBw…, …",
# "Content-Length": "0",
# }
Note
LocalNginxAssetRepositoryConfig requires both tusd_url and
upload_secret to be set. Omitting either raises
AssetAccessNotSupportedError. If you do not need upload URL support,
simply leave these fields unset.
The Upload-Metadata header embeds five fields (base64-encoded values):
Field |
Content |
|---|---|
|
Logical key for the asset |
|
MIME type |
|
|
|
Unix timestamp when the token expires |
|
HMAC-SHA256 hex digest (see below) |
The upload token is computed as:
payload = f"{expires}:{key}:{visibility}:{content_type}"
token = hmac.new(upload_secret, payload, "sha256").hexdigest()
Your tusd pre-create hook must replicate this computation to validate
incoming uploads. See Infrastructure Setup for a complete hook example.
Common FastAPI Endpoint (works with both backends)
The UploadUrlResult model is identical for both backends. You can write
a single endpoint that serves both:
import asyncio
from fastapi import FastAPI
from pydantic import BaseModel
from datetime import datetime
app = FastAPI()
class UploadUrlResponse(BaseModel):
url: str
method: str
headers: dict[str, str]
expires_at: datetime
key: str
@app.get("/api/upload-url", response_model=UploadUrlResponse)
async def get_upload_url(key: str, content_type: str) -> UploadUrlResponse:
# repo can be LocalNginxAssetRepository or S3AssetRepository —
# the calling code is identical either way.
result = await asyncio.to_thread(repo.build_upload_url, key, content_type)
return UploadUrlResponse(
url=result.url,
method=result.method,
headers=result.headers,
expires_at=result.expires_at,
key=result.key,
)
Client-Side Upload (JavaScript)
For S3 (method = "PUT"):
const { url, method, headers, key } = await fetch(
'/api/upload-url?' + new URLSearchParams({ key, content_type })
).then(r => r.json());
// Simple PUT (S3 presigned)
await fetch(url, { method, headers, body: fileBlob });
await fetch('/api/confirm', { method: 'POST',
body: JSON.stringify({ key }) });
For tusd (method = "POST"), use the tus-js-client
library which handles creation + chunked PATCH automatically:
import { Upload } from 'tus-js-client';
const { url, headers, key } = await fetch(
'/api/upload-url?' + new URLSearchParams({ key, content_type })
).then(r => r.json());
const upload = new Upload(file, {
endpoint: url,
headers: headers, // includes Tus-Resumable + Upload-Metadata
removeFingerprintOnSuccess: true,
onSuccess: () => fetch('/api/confirm', { method: 'POST',
body: JSON.stringify({ key }) }),
onError: (err) => console.error('Upload failed:', err),
});
upload.start();
Download URLs
S3 — Three URL modes for private assets
The S3 backend supports three URL modes for build_download_url, selected
by the configuration fields you set. See Download URL modes in the
infrastructure guide for a full comparison.
Mode 1 — CloudFront signed URL (recommended for production)
Requires a CloudFront key pair and trusted_key_groups on the cache
behavior. The URL is time-limited and never exposes the S3 domain.
import os
from granite_assets import S3AssetRepositoryConfig, build_asset_repository
config = S3AssetRepositoryConfig(
bucket="my-bucket",
region="eu-west-1",
public_base_url="https://d111….cloudfront.net",
cf_key_id=os.environ["CF_KEY_ID"],
cf_private_key=open("/path/to/private_key.pem").read(),
presign_ttl_seconds=3600,
)
repo = build_asset_repository(config)
dl = repo.build_download_url("invoices/inv-001.pdf", ttl_seconds=300)
print(dl.url) # https://d111….cloudfront.net/private/…?Expires=…&Signature=…
print(dl.expires_at) # datetime (UTC)
print(dl.is_permanent) # False
Mode 2 — Plain CloudFront URL (permanent, no signing)
Set cf_unsigned_urls=True when no viewer-access restriction is required.
The URL is permanent; protect access at the application layer.
config = S3AssetRepositoryConfig(
bucket="my-bucket",
region="eu-west-1",
public_base_url="https://d111….cloudfront.net",
cf_unsigned_urls=True,
)
repo = build_asset_repository(config)
dl = repo.build_download_url("invoices/inv-001.pdf")
print(dl.url) # https://d111….cloudfront.net/private/invoices/inv-001.pdf
print(dl.is_permanent) # True
Mode 3 — S3 presigned URL (fallback, no CloudFront fields set)
config = S3AssetRepositoryConfig(
bucket="my-bucket",
region="eu-west-1",
presign_ttl_seconds=300,
)
repo = build_asset_repository(config)
dl = repo.build_download_url("invoices/inv-001.pdf", ttl_seconds=300)
print(dl.url) # https://my-bucket.s3.eu-west-1.amazonaws.com/...
print(dl.expires_at) # datetime (UTC)
print(dl.is_permanent) # False
Local Nginx — Secure Link
Private assets served by Nginx are protected with
ngx_http_secure_link_module. The token is an MD5 digest of
"{expires}{uri} {secret}", URL-safe base64-encoded without padding —
exactly what Nginx verifies server-side.
Activate secure link support by setting secure_link_secret:
import os
from granite_assets import LocalNginxAssetRepositoryConfig, build_asset_repository
config = LocalNginxAssetRepositoryConfig(
storage_path="/srv/assets",
base_url="https://media.example.com/assets",
secure_link_secret=os.environ["SECURE_LINK_SECRET"],
secure_link_ttl_seconds=3600,
)
repo = build_asset_repository(config)
# Public asset — permanent URL, no token
pub = repo.build_public_url("avatars/user-42.jpg")
print(pub.url) # https://media.example.com/assets/public/avatars/...
print(pub.is_permanent) # True
# Private asset — Nginx secure_link URL
dl = repo.build_download_url("invoices/inv-001.pdf", ttl_seconds=300)
print(dl.url)
# https://media.example.com/assets/private/invoices/inv-001.pdf
# ?md5=TOKEN&expires=1747000000
print(dl.is_permanent) # False
Note
If secure_link_secret is not configured, calling
build_download_url() on a private asset raises
AssetAccessNotSupportedError. You must then proxy downloads through
your application layer and stream the file body manually.
Security Considerations
Validate the asset key before generating any URL. Never pass user-supplied strings directly as the key — strip path traversal sequences (
.., leading slashes) and enforce an allowlist of characters.Use the shortest practical TTL. The default of 3600 s is generous; for one-time download links, 60–300 s is often sufficient.
S3 Content-Type enforcement — the
Content-Typeheader is part of the presigned request signature. A client that sends a different content-type will receive403 Forbiddenfrom S3.S3 IP restriction — optionally restrict presigned URLs to the expected client IP via an S3 bucket policy condition (
aws:SourceIp).tusd upload-token validation — always implement the
pre-createhook to verify the HMAC-SHA256upload-tokeninUpload-Metadata. Without this, any client that can reach the tusd port could upload arbitrary files. Usehmac.compare_digest(constant-time) for the comparison.upload_secret rotation — rotate
upload_secretindependently ofsecure_link_secret; they serve different trust boundaries (write vs read).