Skip to content

Object storage

Experimental

New in 0.7: its API may still change in any release, as its use shows what's missing. Scope and stability lists the tools that are settled.

Files in S3, or anything that speaks its API (MinIO, Cloudflare R2, Backblaze B2, Google Cloud Storage's interoperability...), without passing through the app: S3Presigner signs URLs and upload forms with AWS's Signature Version 4, and the browser uses them directly. Nothing to install, and no SDK.

Every class and function: the API reference.

from fastapi_alcazaba.uploads import S3Presigner

s3 = S3Presigner(
    bucket="uploads",
    region="eu-west-1",
    access_key=settings.s3_access_key,
    secret_key=settings.s3_secret_key,  # a SecretStr
)

The bucket stays private: each URL is for one object, one method, and a few minutes (expires, 300 seconds by default, 7 days at most). Give the access key a user that may only do what the URLs need on this bucket.

Uploads

@app.post("/avatar/upload")
async def avatar_upload(user: CurrentUser) -> UploadForm:
    return s3.upload_form(
        f"avatars/{user.id}/{uuid4()}",
        max_size=2_000_000,
        content_type_prefix="image/",
    )

The browser posts the file to url, with fields first and the file last, as file:

const body = new FormData();
for (const [name, value] of Object.entries(form.fields)) body.append(name, value);
body.append("Content-Type", file.type);
body.append("file", file);
await fetch(form.url, { method: "POST", body });
  • Bounded by the service: a file smaller than min_size (1 byte) or larger than max_size is refused by S3, as is one that declares another type than content_type, or one not starting with content_type_prefix. upload_url signs a PUT instead, of any size: prefer the form.
  • A key of its own for each upload: a form overwrites what's under its key.
  • Checked before use: the form bounds the size and the declared type, not what the file is. Read it back from the bucket and check it before using it, or serving it.

Downloads

@app.get("/invoices/{invoice_id}")
async def invoice(invoice_id: int, user: CurrentUser) -> RedirectResponse:
    invoice = await db.invoices.get(invoice_id, owner=user.id)  # yours to authorize
    url = s3.download_url(f"invoices/{invoice.id}.pdf", filename="invoice.pdf", expires=60)
    return RedirectResponse(url, status_code=303)

With filename, browsers download the object as that file (Content-Disposition: attachment) rather than show it, so an uploaded HTML or SVG never runs on the bucket's origin. content_type overrides the type it's served with.

Keys and services

  • Object keys are 1 to 1024 bytes, without a leading /, an empty, . or .. segment, or control characters: a key made from user input can't escape its prefix.
  • Other services: endpoint is their URL (https://<account>.r2.cloudflarestorage.com, http://localhost:9000 for MinIO), and the bucket goes in the path: what most of them want. With AWS, the bucket is in the host (path_style=False). Plain http is only for localhost.
  • Temporary credentials (STS, an instance's role): session_token is signed into each URL. The URLs stop working when the credentials expire, whatever their expires.