Direct uploads¶
Reference
Large files should not pass through your API processes. With presigned uploads, the API checks the request and signs it, the client uploads straight to S3 (or an S3-compatible store that supports POST uploads, such as MinIO), and the API then confirms what was stored.
from ninja_devx.contrib.uploads import S3Signer, UploadController, UploadPolicy
class AvatarUploads(UploadController):
signer = S3Signer(bucket="media", prefix="uploads/")
policy = UploadPolicy(
content_types=("image/png", "image/jpeg", "image/webp"), max_bytes=2_000_000
)
key_prefix = "avatars/"
mount(api, {"/uploads/avatars": AvatarUploads})
The flow¶
-
Sign.
POST /uploads/avatars/with{"filename": "me.png", "content_type": "image/png", "size": 48213}:{ "url": "https://media.s3.amazonaws.com/", "fields": {"key": "uploads/avatars/42/9f1c.../me.png", "Content-Type": "image/png", "policy": "...", "x-amz-signature": "..."}, "key": "uploads/avatars/42/9f1c.../me.png", "expires_at": "2026-01-01T00:10:00Z" }Unsupported types get 415, and sizes over
max_bytesget 413. -
Upload. The client sends
multipart/form-datatourlwith every entry offields, thenfile. The storage service enforces the content type and the size range, so a client cannot upload more than the policy allows. -
Confirm.
POST /uploads/avatars/completewith{"key": ...}returnskey,size,content_type,checksum_sha256,version_idandetag, read from storage. It answers 404 for missing/unissued keys or another owner, 410 for expired authorization, and 422 for a size/type/checksum mismatch. Repeating completion returns the same metadata; a changed completed version/ETag returns 409. Save the key and version ID on your model.
Keys and safety¶
- Keys are
<signer prefix><key_prefix><user id>/<random uuid>/<safe filename>. Clients cannot choose the path or confirm someone else's upload. A still-valid signed form can overwrite its own key; use versioning and read the returned version ID to pin verified bytes. - File names are reduced to ASCII letters, digits,
.,_and-(safe_filename). - Signed forms expire after
UploadPolicy.expires_in(10 minutes by default). - Override
owner_prefix(request)to group uploads by tenant instead of user. - Treat uploaded files as untrusted. Serve them from a separate domain, with
Content-Disposition: attachmentfor anything that isn't an image.
Other storage¶
A signer implements presign(key, *, content_type, max_bytes, expires_in, checksum_sha256=None),
stat(key), and delete(key, *, version_id=None). S3Signer(client=...) accepts a configured boto3 client, for example for MinIO:
Testing¶
from ninja_devx.contrib.uploads import FakeSigner
signer = FakeSigner()
class TestUploads(AvatarUploads):
signer = signer
def test_upload(ninja_client, user):
client = ninja_client(TestUploads)
key = client.post(
"/", json={"filename": "a.png", "content_type": "image/png", "size": 10}, user=user
).json()["key"]
signer.store(key, size=10, content_type="image/png") # what the browser would do
assert client.post("/complete", json={"key": key}, user=user).status_code == 200
Durable completion and cleanup¶
Install ninja_devx and run migrations on UploadController.upload_database (default:
default). Signing stores a pending authorization tied to the owner, controller namespace,
exact expected byte count, content type, checksum and expiry. Completion locks this record
and marks it complete only after storage metadata matches. A matching key prefix alone
is insufficient authorization.
For content integrity and immutable references, enable both checks:
class VerifiedUploads(AvatarUploads):
policy = UploadPolicy(
content_types=("image/png",), max_bytes=2_000_000,
require_checksum=True, require_version=True,
)
Clients send checksum_sha256 as base64 SHA-256 with the signing request. S3 POST policy
binds that checksum, and completion checks the value returned by HEAD. The store must
support checksum-aware HEAD and have bucket versioning enabled when required. A checksum
verifies bytes, not file safety or MIME magic; perform application-specific inspection
before serving sensitive upload types. Without versioning, a returned key is not an
immutable reference. Without a requested checksum, only the configured metadata checks
are guaranteed.
Run bounded cleanup from a job or management command:
AvatarUploads().cleanup_expired(limit=100) removes the current storage object for expired
pending records after policy.cleanup_grace (15 minutes by default), then marks those
records expired. Completed and live authorizations are preserved. Storage failures leave
the record retryable. Configure a bucket lifecycle rule for noncurrent versions and
abandoned multipart uploads; this command does not enumerate arbitrary bucket objects.
Use a grace longer than the maximum upload duration allowed by your ingress/storage.
A custom owner_prefix should include both tenant and user when tenants contain users
who must not confirm one another's uploads. S3 client credentials and bucket access are
configured by the application; no storage keys are embedded in generated clients.