Skip to content

Intermediate · Authentication and files end to end · topic 23 of 34 · 30 min

Adding image attachments to notes

Explanation

To attach a photo to a note, the browser sends the file to object storage such as Amazon S3 or Cloudflare R2, and your database saves only a key: the file's name inside that storage. Your server checks the type and size and hands out short-lived upload and download links, so big files never pass through it.

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).

Think of it like this: Think of the cloakroom at a temple or a railway station. You hand over your bag and get a small token with a number. You keep the token, not the bag. Your database is the pocket that keeps the token (the key); the cloakroom is the object storage that keeps the bag (the file).

Step 1: choosing the file in the browser

html
<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>
accept= only filters the file picker; the server must still check the type.
javascript
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 });
Runs in the browser. FormData sends the file as multipart/form-data; do not set Content-Type yourself.

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.

  1. Browser: POST /api/notes/3/attachments/presign with { fileName, type, size }.
  2. 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.
  3. Browser: uploads the file with fetch(url, { method: "PUT", body: file }).
  4. Browser: POST /api/notes/3/attachments with { key } to confirm.
  5. Server: checks the object exists and its size, then saves a row in the attachments table.
javascript
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 });
Making a presigned upload URL (runs on a server with Node.js).
sql
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
);
The database keeps the key and facts about the file, never the file itself.
Careful: Never trust the file name or type the browser sends. Allow a short list of types (JPG, PNG, WebP), set a size limit (for example 5 MB), and build the storage key yourself. A name like "../../config.js" must never decide where a file goes.

Step 3: showing, protecting and deleting files

NeedHow
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 phonesResize to a few sizes (for example 400px and 1200px wide) and convert to WebP, with a background job or a service like Cloudinary
Note deletedDelete the attachment rows and also delete the files from storage, or you pay to store orphans forever
Tip: A presigned PUT URL cannot limit the file size by itself. Either use an S3 presigned POST with a content-length-range rule, or check the real size after upload and delete the file if it is too big.

Watch the lesson

Adding image attachments to notes · 1/6

Image attachments

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)

Task 1 · Code · Javascript

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.

Task 2 · Code · Javascript

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).

Task 3 · Code · SQL

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

Question 1

1.What does the Notes database save for an image attachment?

Question 2

2.What is the main benefit of a presigned upload URL?

Question 3

3.Why build the storage key yourself instead of using the uploaded file name?

Question 4

4.How should private Notes attachments be shown to their owner?

Question 5

5.A user deletes a note with two photos. What else should happen?

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.

← Previous: Protecting pages and API routes