Step 06 of 8 · optional
File uploads
Accept files safely: small uploads through the API, large ones straight to S3-compatible storage with presigned URLs, validation, private downloads and progress in React.
Avatars, receipts, CSV imports, videos: sooner or later every product accepts files. Uploads look simple (an <input type="file"> and a POST) but touch everything that's hard in a backend: large request bodies, slow connections, storage that must outlive your servers, untrusted content that could be malware or a script in disguise, and access control on the way back out. This lesson covers both standard approaches, uploading through your API and uploading directly to object storage, then validation, serving files safely, and the React side.
Where files live
Not on your server's disk. Containers are replaced on every deploy, and with several instances, a file saved on one isn't visible to the others. Files belong in object storage: Amazon S3, or an S3-compatible service such as Cloudflare R2, Google Cloud Storage, or MinIO for local development. You store objects under keys (like avatars/u_42/3f9c….webp) in a bucket, and you keep the metadata (owner, original name, size, type, status) in your database.
// apps/api/src/storage.ts
import { S3Client } from '@aws-sdk/client-s3'
import { config } from './config.ts'
export const s3 = new S3Client({
region: config.S3_REGION,
endpoint: config.S3_ENDPOINT, // set for MinIO/R2; leave undefined for AWS
forcePathStyle: !!config.S3_ENDPOINT, // MinIO needs path-style URLs
})
export const BUCKET = config.S3_BUCKETFor local development, run MinIO in a container (docker run -p 9000:9000 minio/minio server /data) and point S3_ENDPOINT at it.
Approach 1: upload through the API
For small files (avatars, documents up to a few MB), the simplest route is a multipart form POST to your API, which validates the file and writes it to storage:
// apps/api/src/uploads/routes.ts
import { PutObjectCommand } from '@aws-sdk/client-s3'
import { Hono } from 'hono'
import { bodyLimit } from 'hono/body-limit'
import { BUCKET, s3 } from '../storage.ts'
const MAX_BYTES = 5 * 1024 * 1024
export const avatarRoutes = new Hono().post(
'/avatar',
bodyLimit({ maxSize: MAX_BYTES + 64 * 1024, onError: (c) => c.json({ error: { code: 'too_large', message: 'Max 5 MB' } }, 413) }),
async (c) => {
const body = await c.req.parseBody()
const file = body['file']
if (!(file instanceof File)) return c.json({ error: { code: 'no_file', message: 'Attach a file named "file"' } }, 400)
const bytes = new Uint8Array(await file.arrayBuffer())
const type = sniffImageType(bytes)
if (!type) return c.json({ error: { code: 'bad_type', message: 'Upload a PNG, JPEG or WebP image' } }, 415)
const key = `avatars/${crypto.randomUUID()}.${type.ext}`
await s3.send(new PutObjectCommand({ Bucket: BUCKET, Key: key, Body: bytes, ContentType: type.mime }))
return c.json({ key }, 201)
},
)The bodyLimit middleware rejects oversized requests while they're still arriving, before your handler reads them into memory. The file is read into memory here, which is fine at 5 MB but not at 500 MB; that's what the second approach is for.
Never trust the file's name or declared type
Both the filename and the Content-Type come from the client and can say anything. A file named cat.png with type image/png can be an HTML page with a script in it. Check the content instead, starting with the "magic bytes" at the start of the file:
// apps/api/src/uploads/sniff.ts
const SIGNATURES = [
{ mime: 'image/png', ext: 'png', bytes: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] },
{ mime: 'image/jpeg', ext: 'jpg', bytes: [0xff, 0xd8, 0xff] },
] as const
export function sniffImageType(data: Uint8Array): { mime: string; ext: string } | undefined {
for (const sig of SIGNATURES) if (sig.bytes.every((b, i) => data[i] === b)) return { mime: sig.mime, ext: sig.ext }
// WebP: "RIFF" .... "WEBP"
const ascii = (from: number, to: number) => String.fromCharCode(...data.subarray(from, to))
if (ascii(0, 4) === 'RIFF' && ascii(8, 12) === 'WEBP') return { mime: 'image/webp', ext: 'webp' }
return undefined
}Generate the storage key yourself (a random ID), never from the user's filename, which avoids path tricks and collisions. Keep the original name only as metadata, for display. For stronger guarantees with images, re-encode them (with an image library such as sharp, in a background job): the output is a clean image regardless of what the input contained.
Approach 2: direct uploads with presigned URLs
Routing a 2 GB video through your API ties up a server for minutes, and every byte crosses your network twice. Instead, let the browser upload directly to object storage, using a presigned URL: a URL your API signs with its credentials that allows one specific operation (upload this key, of this type) for a few minutes.
// apps/api/src/uploads/presign.ts
import { HeadObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3'
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
import { BUCKET, s3 } from '../storage.ts'
const ALLOWED = new Map([['application/pdf', 'pdf'], ['image/png', 'png'], ['image/jpeg', 'jpg']])
const MAX_BYTES = 50 * 1024 * 1024
export async function startUpload(userId: number, input: { name: string; size: number; type: string }) {
const ext = ALLOWED.get(input.type)
if (!ext) throw new Error('unsupported file type')
if (input.size <= 0 || input.size > MAX_BYTES) throw new Error('file too large')
const key = `uploads/pending/${userId}/${crypto.randomUUID()}.${ext}`
const url = await getSignedUrl(
s3,
new PutObjectCommand({ Bucket: BUCKET, Key: key, ContentType: input.type, ContentLength: input.size }),
{ expiresIn: 300, signableHeaders: new Set(['content-type']) }, // also sign the type, not just the length
)
// ...insert into uploads (id, user_id, key, original_name, size, type, status='pending')...
return { key, url }
}
export async function verifyUpload(key: string, expected: { size: number; type: string }) {
const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET, Key: key }))
return head.ContentLength === expected.size && head.ContentType === expected.type
}The presigner signs Content-Length by default, but not Content-Type; signableHeaders adds it, and you can confirm both appear in the URL's X-Amz-SignedHeaders parameter. Signing them into the URL means the storage service rejects a PUT whose headers don't match, so a client can't use a URL issued for a 1 MB PDF to upload 5 GB of something else. On complete, the API checks the object really exists with the expected size, then marks the upload ready (and can move it out of the pending/ prefix). A bucket lifecycle rule that deletes objects under uploads/pending/ after a day cleans up uploads that were started and abandoned.
Two more details:
- The bucket needs a CORS configuration allowing
PUTfrom your site's origin, because the browser is now talking to the storage service's origin directly. - For multi-gigabyte files, use multipart uploads (presign each part; the browser uploads parts in parallel and retries failed ones), which S3-compatible services all support.
Serving files back safely
Keep the bucket private. When a user should see a file, check they're allowed to, then redirect to a short-lived presigned GET URL:
import { GetObjectCommand } from '@aws-sdk/client-s3'
app.get('/api/files/:id', requireUser, async (c) => {
const file = await findFileForUser(Number(c.req.param('id')), c.get('user').id) // ownership check
if (!file) return c.json({ error: { code: 'not_found' } }, 404)
const url = await getSignedUrl(
s3,
new GetObjectCommand({
Bucket: BUCKET,
Key: file.key,
ResponseContentDisposition: `attachment; filename="${file.safeName}"`,
}),
{ expiresIn: 60 },
)
return c.redirect(url, 302)
})Public, non-sensitive files such as product images can live under a public prefix behind a CDN instead, with long cache lifetimes because their keys never change.
Processing and scanning
Work that happens after an upload, such as making thumbnails, extracting text from PDFs, transcoding video or scanning for malware, belongs in a background job, not in the request: it's slow and it can fail and be retried. Mark the upload processing, enqueue a job with its ID, and update the status when it's done. The UI shows "Processing…" until then. Organisations that accept files from the public usually scan every upload with an antivirus engine before anyone else can download it.
The React side
Show a preview before uploading with an object URL, and report progress. fetch can't report upload progress in browsers yet, so use XMLHttpRequest for the PUT:
// apps/web/src/uploads/put-with-progress.ts
export function putWithProgress(url: string, file: File, onProgress: (fraction: number) => void): Promise<void> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.open('PUT', url)
xhr.setRequestHeader('Content-Type', file.type)
xhr.upload.onprogress = (e) => e.lengthComputable && onProgress(e.loaded / e.total)
xhr.onload = () => (xhr.status >= 200 && xhr.status < 300 ? resolve() : reject(new Error(`upload failed: ${xhr.status}`)))
xhr.onerror = () => reject(new Error('network error during upload'))
xhr.send(file)
})
}function DocumentUpload() {
const [progress, setProgress] = useState<number | null>(null)
const [error, setError] = useState<string | null>(null)
async function upload(file: File) {
setError(null)
try {
const { uploadId, url } = await unwrap(await api.api.uploads.$post({ json: { name: file.name, size: file.size, type: file.type } }))
await putWithProgress(url, file, setProgress)
await unwrap(await api.api.uploads[':id'].complete.$post({ param: { id: String(uploadId) } }))
} catch (e) {
setError((e as Error).message)
} finally {
setProgress(null)
}
}
return (
<div>
<input type="file" accept="application/pdf,image/png,image/jpeg" onChange={(e) => e.target.files?.[0] && upload(e.target.files[0])} />
{progress !== null && <progress value={progress} max={1} />}
{error && <p role="alert">{error}</p>}
</div>
)
}The accept attribute only filters the file picker; it's a convenience, not validation. The API's checks are what count.
Practice
Add profile pictures to the shop app:
POST /api/me/avataraccepts a multipart upload (max 2 MB), sniffs the type (PNG, JPEG or WebP only), stores it under a random key, and saves the key on the user. Test it withcurl -F [email protected], and with a text file renamed to.png(it must be rejected).GET /api/users/:id/avatarredirects to a presigned GET URL valid for 5 minutes, or returns 404.- In React, show a preview with
URL.createObjectURLbefore uploading, and revoke the URL afterwards. - Explain in a sentence or two why the avatar endpoint doesn't need presigned uploads, and what you'd change if users could upload 200 MB videos.
Show solution
// apps/api/src/users/avatar.ts
import { GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3'
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
import { Hono } from 'hono'
import { bodyLimit } from 'hono/body-limit'
import { requireUser, type AuthEnv } from '../auth/middleware.ts'
import { pool } from '../db.ts'
import { BUCKET, s3 } from '../storage.ts'
import { sniffImageType } from '../uploads/sniff.ts'
const MAX = 2 * 1024 * 1024
export const avatarRoutes = new Hono<AuthEnv>()
.post(
'/me/avatar',
requireUser,
bodyLimit({ maxSize: MAX + 64 * 1024, onError: (c) => c.json({ error: { code: 'too_large', message: 'Max 2 MB' } }, 413) }),
async (c) => {
const file = (await c.req.parseBody())['file']
if (!(file instanceof File)) return c.json({ error: { code: 'no_file', message: 'Attach a file' } }, 400)
const bytes = new Uint8Array(await file.arrayBuffer())
const type = sniffImageType(bytes)
if (!type) return c.json({ error: { code: 'bad_type', message: 'PNG, JPEG or WebP only' } }, 415)
const key = `avatars/${c.get('user').id}/${crypto.randomUUID()}.${type.ext}`
await s3.send(new PutObjectCommand({ Bucket: BUCKET, Key: key, Body: bytes, ContentType: type.mime }))
await pool.query('UPDATE users SET avatar_key = $1 WHERE id = $2', [key, c.get('user').id])
return c.json({ key }, 201)
},
)
.get('/users/:id/avatar', async (c) => {
const { rows } = await pool.query<{ avatar_key: string | null }>('SELECT avatar_key FROM users WHERE id = $1', [
Number(c.req.param('id')),
])
const key = rows[0]?.avatar_key
if (!key) return c.json({ error: { code: 'not_found' } }, 404)
const url = await getSignedUrl(s3, new GetObjectCommand({ Bucket: BUCKET, Key: key }), { expiresIn: 300 })
return c.redirect(url, 302)
})// apps/web/src/account/AvatarPicker.tsx
import { useEffect, useState } from 'react'
export function AvatarPicker({ onUploaded }: { onUploaded: () => void }) {
const [file, setFile] = useState<File | null>(null)
const [preview, setPreview] = useState<string | null>(null)
useEffect(() => {
if (!file) return
const url = URL.createObjectURL(file)
setPreview(url)
return () => URL.revokeObjectURL(url) // free the memory when the file changes or the component unmounts
}, [file])
async function save() {
if (!file) return
const form = new FormData()
form.append('file', file)
const res = await fetch('/api/me/avatar', { method: 'POST', body: form })
if (res.ok) onUploaded()
}
return (
<div>
<input type="file" accept="image/png,image/jpeg,image/webp" onChange={(e) => setFile(e.target.files?.[0] ?? null)} />
{preview && <img src={preview} alt="Preview of your new profile picture" width={96} height={96} />}
<button onClick={save} disabled={!file}>Save</button>
</div>
)
}The migration adds avatar_key text to users. Don't set Content-Type yourself when sending FormData: the browser adds multipart/form-data with the correct boundary. A renamed text file fails sniffImageType and gets a 415, because the check reads the bytes, not the name.
For 2 MB avatars, a request through the API costs little and keeps validation simple: the server sees the bytes before anything is stored. For 200 MB videos, switch to presigned direct uploads (with ContentLength signed into the URL, or multipart uploads), verify the object with HEAD on completion, then sniff and transcode it in a background job, since the API never sees the bytes during upload.
Key takeaways
- Store files in object storage under keys you generate, with metadata and ownership in your database.
- Small files can go through the API with a body limit; large files go directly to storage with short-lived presigned URLs that fix the content type and length.
- Never trust filenames or declared types: sniff the content, re-encode images, and generate your own keys.
- Keep buckets private, authorise every download and redirect to a short-lived presigned GET, and never serve uploads as active content from your own origin.
- Process and scan uploads in background jobs, and show progress in the browser with
XMLHttpRequest.
Check yourself
Quiz
0/4 correct · 4 left
1.A user uploads evil.png with Content-Type image/png, but the bytes are an HTML page. What catches it?
2.Why use presigned URLs for large uploads?
3.Why is serving an uploaded SVG inline from your main domain dangerous?
4.What does signing ContentLength and ContentType into a presigned PUT URL achieve?
Finished reading?
Mark it complete to fill in your roadmap.