Intermediate · Authentication and files end to end · topic 23 of 34 · 30 min
Adding image attachments to notes
Explanation
Where do files live?
Asha wants to attach a photo of her class timetable to a note. You could save the image inside the database or on your server's disk, but both are bad ideas: databases get slow and huge, and many hosting platforms wipe the server disk on every deploy.
Object storage is a service made for files. You put a file in a "bucket" (a big folder) under a key, like users/1/notes/3/a8f2-timetable.png, and get it back by that key. Popular choices are Amazon S3, Cloudflare R2, Supabase Storage and Cloudinary (which also resizes images for you).
Step 1: choosing the file in the browser
<form id="attach-form">
<label for="photo">Attach a photo</label>
<input id="photo" name="photo" type="file" accept="image/png, image/jpeg, image/webp">
<button type="submit">Upload</button>
</form>const file = document.querySelector('#photo').files[0];
// Small apps can send the file to their own API with FormData:
const form = new FormData();
form.append('photo', file);
await fetch('/api/notes/3/attachments', { method: 'POST', body: form });Step 2: presigned uploads (the better way)
A presigned URL is a link that your server signs with its secret storage key. It allows exactly one action (for example "upload one file to this key") for a few minutes. The browser uploads straight to storage with it, so a 5 MB photo never passes through your server and your secret key never reaches the browser.
- Browser: POST /api/notes/3/attachments/presign with { fileName, type, size }.
- Server: checks login, that note 3 is the user's, and the type and size limits. Makes a safe unique key and a presigned PUT URL valid for 5 minutes.
- Browser: uploads the file with fetch(url, { method: "PUT", body: file }).
- Browser: POST /api/notes/3/attachments with { key } to confirm.
- Server: checks the object exists and its size, then saves a row in the attachments table.
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
const s3 = new S3Client({ region: 'ap-south-1' }); // Mumbai; R2 also speaks the S3 API
const key = makeStorageKey(req.userId, noteId, fileName);
const url = await getSignedUrl(
s3,
new PutObjectCommand({ Bucket: process.env.BUCKET, Key: key, ContentType: type }),
{ expiresIn: 300 },
);
res.json({ url, key });CREATE TABLE attachments (
id INTEGER PRIMARY KEY,
note_id INTEGER NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
storage_key TEXT NOT NULL UNIQUE,
file_name TEXT NOT NULL,
content_type TEXT NOT NULL,
size_bytes INTEGER NOT NULL,
created_at TEXT
);Step 3: showing, protecting and deleting files
| Need | How |
|---|---|
| Public images (like a profile photo) | Serve through a CDN (content delivery network): copies cached in cities near users, so they load fast in Chennai and Delhi |
| Private files (like Notes attachments) | Keep the bucket private; the API checks ownership, then returns a signed GET URL valid for a few minutes |
| Big photos from phones | Resize to a few sizes (for example 400px and 1200px wide) and convert to WebP, with a background job or a service like Cloudinary |
| Note deleted | Delete the attachment rows and also delete the files from storage, or you pay to store orphans forever |
Watch the lesson
Adding image attachments to notes · 1/6
Let us attach a photo to a note. First question: where does the file live?
Prefer a video by a teacher? Find free videos on “presigned url upload to s3 from browser tutorial” on YouTube ↗
In real life: Uploading a document on a job portal
When you upload your resume PDF on a job portal, the site usually gets a short-lived upload link from its server and sends the file straight to cloud storage. The portal's database saves only the storage key, the file name and the size. When a recruiter opens your profile, the server checks they are allowed and gives a download link that works for a few minutes. If you delete your profile, a clean-up job removes the file from storage too.
Practice (0/3 done)
Write validateAttachment(file). file is { name, type, size } (size in bytes) or null. Return { ok: false, error: "No file chosen" } if file is missing; { ok: false, error: "Only JPG, PNG or WebP images" } if type is not image/jpeg, image/png or image/webp; { ok: false, error: "File is larger than 5 MB" } if size is more than 5 * 1024 * 1024; otherwise { ok: true }. Check in that order.
Write makeStorageKey(userId, noteId, fileName, newId) that returns "users/<userId>/notes/<noteId>/<id>-<safeName>", where id = newId(). Build safeName like this: lowercase the name. If the part after the last "." is 1 to 5 letters or digits, that is the extension; the rest is the base. Otherwise there is no extension and the whole name is the base. In the base, replace every run of characters that are not a-z or 0-9 with "-", remove "-" from both ends, cut to 40 characters (then remove a trailing "-" again), and use "file" if it is empty. safeName = base + "." + extension (or just base).
For Asha (user_id 1), list every one of her notes with the number of attachments and their total size in bytes. Notes with no attachments must still appear with 0 and 0. Columns: title, files, total_bytes. Order by title A to Z.
Quick quiz
Read more (free)
Pass the quiz (60% or more) or mark this topic as done when you are ready.
Your progress is saved on this device only.