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)

S3

Presigned PUT

Presigned GET (S3 or CloudFront)

LocalNginx

tus POST + PATCH

Nginx secure_link signed URL

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:

  1. Request a presigned URL from your backend API.

  2. PUT the file directly to S3 using the returned URL and headers.

  3. 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:

  1. Request a tus creation URL from your backend API.

  2. POST to create the upload resource; receive a Location header.

  3. PATCH the file data in one or more chunks to the Location URL.

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

asset-key

Logical key for the asset

content-type

MIME type

visibility

"public" or "private"

upload-expires

Unix timestamp when the token expires

upload-token

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

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-Type header is part of the presigned request signature. A client that sends a different content-type will receive 403 Forbidden from 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-create hook to verify the HMAC-SHA256 upload-token in Upload-Metadata. Without this, any client that can reach the tusd port could upload arbitrary files. Use hmac.compare_digest (constant-time) for the comparison.

  • upload_secret rotation — rotate upload_secret independently of secure_link_secret; they serve different trust boundaries (write vs read).