Serving Files¶
res.file() streams a file from disk to the client. It guesses the content type, sets a Content-Disposition, and reads the file in chunks with aiofiles so a large file neither loads into memory nor blocks the event loop.
async def logo(req, res, ctx):
res.file('images/logo.png')
This page covers the whole surface: what gets sent, how to confine a read to one directory with within, how that relates to app.ASSETS(), and what res.file() deliberately does not do.
The basics¶
res.file('images/cat.jpg') # inline, browser renders it
res.file('reports/q1.pdf', filename='Q1_Report.pdf') # download, browser saves it
res.file('video.mp4', chunk_size=65536) # larger read buffer
Parameters:
| Name | Default | Meaning |
|---|---|---|
filepath | required | The file to send. Relative to within when that is given, otherwise used as-is. |
filename | None | Send as an attachment under this name. Omit for inline display. |
chunk_size | 4096 | Bytes read per iteration while streaming. |
within | None | Confine the read to this directory. See below. |
What gets sent¶
On success the response is a 200 with the file streamed as the body:
| Header | Value |
|---|---|
Content-Type | Guessed from the extension, falling back to application/octet-stream |
Content-Disposition | inline; filename="<basename>", or attachment; filename="<filename>" when you pass filename |
Any of the following produce a 404 with the body File not found and no content headers:
- the path does not exist
- the path is a directory, so there is no directory listing to leak
- the path resolves outside
within, whenwithinis given withinitself does not exist
No Content-Length is sent
The body is streamed, and Heaven does not stat the file first, so responses are chunked without a declared length.
Confining a read with within¶
res.file() on its own serves exactly the path you hand it. That is the right behaviour for a low-level primitive, and it is fine when the path is a constant you wrote. It stops being fine the moment any part of the path comes from the request:
async def download(req, res, ctx):
res.file(f"uploads/{req.params.get('name')}") # a name of ../../etc/passwd escapes uploads/
Passing within fixes that by confining the read to one directory:
async def download(req, res, ctx):
res.file(req.params.get('name'), within='/var/lib/app/uploads')
The rule¶
Both the root and the requested path are fully resolved before anything is opened, and the read only proceeds if the result sits inside the root. Resolution happens at the filesystem level rather than by inspecting the string, which is why it holds regardless of how the path was spelled.
Why resolution beats pattern matching
Rejecting paths that literally contain .. looks equivalent and is not. The ASGI path reaches your handler already percent-decoded, so %2e%2e and ..%2f arrive as .. after any string filter has run, and tricks like ....// collapse to the same place. Resolving the path first means every spelling of "outside the root" ends up at the same comparison.
Choosing a root¶
within is a directory, not a project-relative name, and it can be anywhere the process can reach:
res.file(name, within='/var/lib/app/uploads') # absolute, used as given
res.file(name, within='/mnt/nfs/media') # a mounted share
res.file(name, within='uploads') # relative, resolved against the working directory
A relative root resolves against the working directory, which is wherever the process was started rather than where your module lives. If that distinction matters, anchor it explicitly:
from os import path
UPLOADS = path.join(path.dirname(path.realpath(__file__)), 'uploads')
async def download(req, res, ctx):
res.file(req.params.get('name'), within=UPLOADS)
Two ways to pass the path¶
filepath may be relative to within, or the full path to a file beneath it. Both forms mean the same thing, so you can build the path yourself and still get the check:
res.file(name, within=UPLOADS) # resolved against the root
res.file(path.join(UPLOADS, name), within=UPLOADS) # already absolute, still verified
What is rejected¶
Given within='/srv/media':
| Requested | Result |
|---|---|
clips/intro.mp4 | served from /srv/media/clips/intro.mp4 |
clips/../intro.mp4 | served, because it stays inside the root |
../secrets.env | 404 |
../../../../etc/passwd | 404 |
/etc/passwd | 404, an absolute path outside the root does not override it |
a symlink in the root pointing to /etc/passwd | 404, symlinks are resolved before the check |
/srv/media-private/notes.txt | 404, a sibling sharing the name prefix is still outside |
The last two are the cases a hand-rolled check usually misses. Symlinks only reveal themselves once the path is resolved, and a prefix comparison that forgets the trailing separator treats /srv/media-private as living inside /srv/media.
within is opt-in
Omitting it leaves the path unconfined, which is unchanged behaviour and correct for trusted, hard-coded paths. Add it whenever the path is derived from the request, including indirectly through a database lookup keyed on user input.
Access-controlled downloads¶
within answers "can this path be read", not "may this caller read it". Authorisation stays your handler's job, and the two compose:
from os import path
INVOICES = path.join(path.dirname(path.realpath(__file__)), 'invoices')
async def invoice(req, res, ctx):
invoice_id = req.params.get('id')
record = await ctx.db.fetchrow(
'SELECT filename FROM invoices WHERE id = $1 AND account = $2',
invoice_id, ctx.account_id,
)
if not record:
res.status = 404
res.body = b'Not found'
return
res.file(record['filename'], filename=f'invoice-{invoice_id}.pdf', within=INVOICES)
The ownership check decides whether this account may see the invoice at all. within guarantees that whatever filename turns out to hold, the read cannot leave invoices/, which matters because that value came out of a database rather than out of your source.
Relationship with app.ASSETS()¶
app.ASSETS() is a thin wrapper that registers a wildcard route and calls res.file(..., within=<the asset folder>) for you:
app.ASSETS('assets') # serves ./assets at /assets/*
app.ASSETS('assets', relative_to=__file__) # anchored to the module, not the cwd
The containment guarantees are identical, since it is the same code path. Reach for ASSETS when you want a whole folder mounted at a url prefix, and for res.file() when individual reads need a handler around them, whether for authorisation, logging, or a computed filename.
Receiving uploads¶
Sending a file out is only half of it. On the way in, Heaven reads the whole request body before your handler runs, because req.body, req.json and req.form are ordinary properties that have to already have the data. That is the right trade for a JSON payload and the wrong one for a large file.
Cap what a buffered route will accept¶
app = App(max_body_size=10 * 1024 * 1024) # 10MB
A request whose body goes past the cap gets 413 Payload too large, and Heaven stops keeping the body at that point. Memory holds at the ceiling no matter how much more is sent, which is the point of the setting: it bounds what a single request can cost you, rather than checking the size once everything is already in memory.
Heaven does keep reading past the limit, discarding as it goes. That is deliberate. Answering mid-upload while the client is still sending resets the connection, and the client sees a broken pipe instead of the reason it was refused. Draining costs bandwidth but nothing in memory, and it means the 413 actually arrives.
There is no cap unless you set one
max_body_size defaults to None, meaning unlimited, so that upgrading does not start rejecting uploads that used to work. Set it for anything reachable from the internet, and keep a matching client_max_body_size in your proxy so oversized requests die before they reach Python at all.
Stream large uploads instead of buffering them¶
Register the route with stream=True and the body is left unread, so your handler can consume it in chunks and write each one straight to its destination:
from aiofiles import open as async_open
app.POST('/upload', receive, stream=True)
async def receive(req, res, ctx):
async with async_open('/var/lib/app/uploads/incoming.bin', 'wb') as f:
async for chunk in req.stream():
await f.write(chunk)
res.body = {'ok': True}
Peak memory is one chunk regardless of upload size. An 80MB upload through a streaming route costs about 36KB of Python heap, against roughly 160MB buffered.
A streaming route is a different contract, so the two halves do not mix:
req.bodyandreq.jsonraise on a streaming route, because nothing was buffered for them to read.req.formworks on a streaming route, but it must be awaited:form = await req.form. See the next section.req.stream()raises on a normal route, because the body was already read and is sitting onreq.body.- The body arrives once and is not retained, so it can only be consumed once: by
req.stream()or by awaitingreq.form, not both.
max_body_size does not apply to streaming routes
A streaming route exists precisely to accept things larger than the cap, and it is your handler that decides how much to accept. Count the bytes as you write them and stop when you have had enough:
async def receive(req, res, ctx):
written = 0
async with async_open(target, 'wb') as f:
async for chunk in req.stream():
written += len(chunk)
if written > LIMIT:
res.status = 413
return
await f.write(chunk)
Parse a form without buffering it¶
The most common reason to want streaming is a file upload, and file uploads usually arrive as multipart/form-data with fields alongside the file. On a streaming route, await req.form parses the body incrementally as it arrives:
app.POST('/upload', receive, stream=True)
async def receive(req, res, ctx):
form = await req.form
form.get('title') # fields work as usual
video = form.get('video') # a File object
video.save('/var/lib/app/uploads/incoming.bin')
res.body = {'ok': True, 'size': video.size}
Field values are held in memory, capped at 1MB each. File parts are written to a spooled temporary file as they arrive: below 256KB they stay in memory, above it they move to disk, so a 20MB upload peaks at a few hundred KB of Python heap instead of hundreds of MB. The temp file is cleaned up automatically when the request is garbage collected; call .save(path) to keep the upload.
A streamed File offers more than .content:
.filenameand.content_type: what the client declared..size: bytes received for this part..save(path): copy to a destination in chunks, never holding the whole file..file: a file object positioned at the start, for handing to anything that reads files..content: still the whole file as bytes. It reads a spilled part back off disk, so reach for.save()or.fileinstead for anything large.
Awaiting req.form on a buffered route is harmless (the form is parsed already and the await returns it unchanged), so handler code can be written one way and moved between the two kinds of route. Reading fields on a streaming route without awaiting raises a RuntimeError saying to await first.
The ceilings live in heaven.form as module constants: FIELD_LIMIT (1MB per field value), SPOOL_LIMIT (256KB before a file part moves to disk), HEADERS_LIMIT (16KB of headers per part) and PARTS_LIMIT (1000 parts). Set them before serving if your forms are unusual:
import heaven.form
heaven.form.FIELD_LIMIT = 8 * 1024 * 1024
A form that breaks a ceiling, or a multipart body that ends without its closing boundary, raises ValueError. Heaven drains the rest of the body before the error surfaces, for the same reason max_body_size does: answering mid-upload resets the connection, and draining first means the client actually receives the error response.
Which one to use¶
| Situation | Approach |
|---|---|
| JSON, form fields, a small avatar | Default buffering, with max_body_size set |
| Video, backups, anything unbounded | stream=True and write it as it arrives |
| A form with fields and a large file | stream=True and await req.form |
| Uploads from the public internet | Both: a cap on normal routes, streaming where large files are expected |
What res.file() does not do¶
No range requests or caching headers
No ETag, Last-Modified, Content-Length, or Accept-Ranges is sent. Clients cannot resume a broken transfer, seek within a file, or make a conditional re-request, which rules this out for video scrubbing.
Serve large or heavily requested static files from Nginx, Caddy, or a CDN, which handle all of the above and do it faster. Keep res.file() for small files and for downloads that need a handler in front of them.
Testing it¶
Earth drives the real handler, and consumes the streamed body so you can assert on it directly:
class DownloadTest(IsolatedAsyncioTestCase):
async def test_confined_to_the_upload_folder(self):
async with app.earth.test() as earth:
req, res, ctx = await earth.GET('/download/report.pdf')
self.assertEqual(res.status, 200)
req, res, ctx = await earth.GET('/download/../../../etc/passwd')
self.assertEqual(res.status, 404)
Write the escape case as a test rather than checking it by hand once. It is the kind of guarantee that a later refactor can quietly remove.