There is no upload class in Stario. A file a visitor sends is the request body, or part of it, and your handler decides what happens next: where the bytes go, what they are called, and who may read them later. Files serves them back. That is the whole machinery, and this page walks through it in the order you will meet it.
1. Receive the bytes#
For a file that fits comfortably in memory, read the body and write it:
import uuidfrom pathlib import Path from stario import Context, Writer UPLOAD_DIR = Path("var/uploads") async def upload(c: Context, w: Writer) -> None: data = await c.req.body(max_size=5 * 1024 * 1024) name = f"{uuid.uuid4()}.bin" (UPLOAD_DIR / name).write_bytes(data) w.json({"name": name}, status=201)For a large file, stream it to disk a chunk at a time so the whole upload is never in memory at once. Write to a temporary name and rename at the end, so a half-received file never sits under its final name:
async def upload_large(c: Context, w: Writer) -> None: name = f"{uuid.uuid4()}.bin" tmp = UPLOAD_DIR / f"{name}.part" with tmp.open("wb") as out: async for chunk in c.req.stream(): out.write(chunk) tmp.rename(UPLOAD_DIR / name) w.json({"name": name}, status=201)Use body() or stream() for one request, not both. If the visitor disconnects halfway, either raises ClientDisconnected; the .part file is the only thing left behind, and a cleanup job can remove old ones.
The browser side can be a plain <input type="file"> in a form with enctype="multipart/form-data", in which case the body is multipart and you parse it with a library of your choice. It can also be a Datastar file signal, where the file arrives as base64 inside the signals JSON; Datastar from Python covers that form.
2. Serve them back#
Files serves a directory as it is on disk. Build it once, attach it in bootstrap, and the file you wrote a moment ago is available on the next request, with an ETag and 304 for repeat visits.
from stario import App, Files, Span UPLOADS = Files("var/uploads", "/uploads") async def bootstrap(app: App, span: Span): await UPLOADS.attach(app) yieldUPLOADS.href(name) returns /uploads/<name> and raises if the file does not exist, so a view cannot link to something that is not there. Do not use Assets for uploads: it hashes the directory at startup and will not see new files.
These are visitor bytes, so serve them defanged: Files("var/uploads", "/uploads", untrusted=True) puts Content-Security-Policy: sandbox on every response, and Content-Disposition: attachment on HTML, XML, SVG, and JavaScript, so an uploaded page downloads instead of running in your origin.
If a download should prompt a save dialog or carry a name the visitor recognises, write that handler yourself:
import mimetypes from stario import Route DOWNLOAD = Route("GET /downloads/{name}") async def download(c: Context, w: Writer) -> None: path = (UPLOAD_DIR / c.match.params["name"]).resolve() if not path.is_relative_to(UPLOAD_DIR.resolve()) or not path.is_file(): w.text("Not found", status=404) return mime = mimetypes.guess_type(path.name)[0] or "application/octet-stream" w.headers.set("content-disposition", f'attachment; filename="{path.name}"') w.respond(path.read_bytes(), mime.encode("ascii"))3. Names, paths, and who may read#
Never use the client's filename as the name on disk. Generate your own: a UUID, a content hash, or an id from your database. Keep the original name in a database row next to the owner, the size, and the type, and use that row to decide whether the current user may see the file.
Everything a visitor sends is untrusted. In the download handler above, c.match.params["name"] is one sent path segment, but it may decode to more than one: %2F becomes / inside a capture, so /downloads/..%2Fx reaches the handler with name equal to ../x. Literal . and .. segments are normalized before matching, and percent-encoded ones redirect — but a .. smuggled inside a capture is yours to stop. The is_relative_to check is the control that stops it, not decoration. Check the declared type against the bytes if the type matters, and cap the size at the boundary rather than after the fact.
4. Limits#
The server refuses bodies over 10 MiB with a 413 — before the handler runs when Content-Length is known, during the read when it is not — and a stall of more than 30 seconds between chunks gets a 408. Both are STARIO_REQUESTS_* variables on Configuration. body(max_size=…) tightens the cap for one route; stream() always uses the server-wide limit, so count bytes in the loop if one route needs less.
5. When to use object storage#
If uploads are large or frequent, do not route every byte through Python. Have the handler issue a signed URL, let the browser put the object straight into the bucket, and record the key when the browser reports success. The app keeps the metadata and the permissions; the bucket keeps the bytes.
By the way#
out.write(chunk)is a blocking call. For a few megabytes it does not matter; for very large files or slow disks, move the write to a thread withasyncio.to_thread.A
Content-Dispositionofinlinerenders in the page;attachmentprompts a download.A file written by one process and served by another needs a shared directory. On one machine that is a path; across machines that is object storage.
The HTTP details of Files are on Files and assets.