Saving to Disk
Writing the file
After validation passes, write the file to the uploads directory and record metadata in the database:
// src/storage.ts — add save function
import { writeFileSync } from "node:fs";
import { join } from "node:path";
import db from "./db.js";
import { UPLOAD_DIR } from "./storage.js";
export function saveFile(
userId: string,
originalName: string,
storedName: string,
mimeType: string,
buffer: Buffer,
): { id: string; size: number } {
const filePath = join(UPLOAD_DIR, storedName);
// Write to disk
writeFileSync(filePath, buffer);
// Record in database
const id = crypto.randomUUID();
const size = buffer.length;
db.prepare(
"INSERT INTO files (id, user_id, original_name, stored_name, mime_type, size) VALUES (?, ?, ?, ?, ?, ?)",
).run(id, userId, originalName, storedName, mimeType, size);
return { id, size };
} The complete upload route
import { saveFile } from "../storage.js";
route.post("/files", {
resolve: async (c) => {
// Authentication (simplified — use your auth course patterns)
const userId = "user-alice"; // Replace with real auth
try {
const { fields, file } = await parseMultipartWithLimits(c.request);
if (!file) {
return Response.json({ error: "No file uploaded" }, { status: 400 });
}
// Read and validate
const chunks: Buffer[] = [];
for await (const chunk of file.stream) {
chunks.push(Buffer.from(chunk));
}
const buffer = Buffer.concat(chunks);
const validType = await validateMimeType(buffer, file.mimeType);
if (!validType) {
return Response.json({ error: "File type not allowed" }, { status: 400 });
}
const storedName = sanitizeFilename(file.filename);
const { id, size } = saveFile(userId, file.filename, storedName, validType, buffer);
return Response.json(
{
id,
name: file.filename,
mimeType: validType,
size,
url: `/files/${id}`,
},
{ status: 201 },
);
} catch (err: any) {
if (err.message?.includes("limit")) {
return Response.json({ error: err.message }, { status: 413 });
}
return Response.json({ error: "Upload failed" }, { status: 500 });
}
},
}); Listing and deleting files
// List user's files
route.get("/files", {
resolve: (c) => {
const userId = "user-alice"; // Replace with real auth
const files = db.prepare(
"SELECT id, original_name, mime_type, size, is_public, created_at FROM files WHERE user_id = ? ORDER BY created_at DESC"
).all(userId);
return Response.json({ data: files });
},
}),
// Delete a file
route.delete("/files/:id", {
resolve: (c) => {
const userId = "user-alice";
const file = db.prepare("SELECT stored_name FROM files WHERE id = ? AND user_id = ?")
.get(c.params.id, userId) as { stored_name: string } | undefined;
if (!file) return Response.json({ error: "File not found" }, { status: 404 });
// Delete from disk
const filePath = join(UPLOAD_DIR, file.stored_name);
try { unlinkSync(filePath); } catch { /* file might already be gone */ }
// Delete from database
db.prepare("DELETE FROM files WHERE id = ?").run(c.params.id);
return new Response(null, { status: 204 });
},
}), Try it
# Upload a file
curl -X POST http://localhost:3000/files \
-F "[email protected]"
# { "id": "...", "name": "photo.jpg", "mimeType": "image/jpeg", "size": 12345, "url": "/files/..." }
# List files
curl http://localhost:3000/files
# Delete a file
curl -X DELETE http://localhost:3000/files/FILE_ID Why stored_name is not the file ID
The file ID is a UUID used in URLs and the API. The stored name is a UUID with an extension, used on disk. They are separate because:
- The API ID might change format (from UUID to something shorter)
- The stored name includes the extension (needed for MIME type detection on serve)
- Separating them avoids coupling the API surface to the storage format
Exercises
Exercise 1: Upload a file. Check the uploads/ directory. Verify the file exists with the UUID-based name.
Exercise 2: Upload, then delete. Verify the file is removed from both the database and the disk.
Exercise 3: Upload two files with the same original name. Verify they get different stored names (no collisions).
Why do we delete the file from disk before deleting the database record?