From 4ab9eb4035f1b61a9c7cd6d9eb98bedc4428431b Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Wed, 22 Jul 2026 10:48:24 +0000 Subject: [PATCH] Compress and resize photos on upload via sharp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Buffer upload → auto-rotate (EXIF) → resize to 1800px max → JPEG 82% progressive. Typical phone photo drops from 4-8 MB to ~200-400 KB. sharp prebuilt binaries include libvips so no Alpine package changes needed. Co-Authored-By: Claude Sonnet 4.6 --- backend/package.json | 1 + backend/src/routes/photos.js | 34 ++++++++++++++++++++-------------- 2 files changed, 21 insertions(+), 14 deletions(-) diff --git a/backend/package.json b/backend/package.json index 981a9ba..81cab23 100644 --- a/backend/package.json +++ b/backend/package.json @@ -15,6 +15,7 @@ "jose": "^5.9.6", "nodemailer": "^6.9.16", "pg": "^8.13.1", + "sharp": "^0.33.0", "web-push": "^3.6.7" } } diff --git a/backend/src/routes/photos.js b/backend/src/routes/photos.js index 9466858..7d0d405 100644 --- a/backend/src/routes/photos.js +++ b/backend/src/routes/photos.js @@ -1,10 +1,10 @@ import { requireAuth, requireCap, hasCap } from '../auth.js' import { pool } from '../db.js' import { logEvent } from '../lib/task-core.js' -import { createWriteStream } from 'fs' -import { mkdir, unlink } from 'fs/promises' +import { mkdir, unlink, writeFile } from 'fs/promises' import { randomUUID } from 'crypto' -import { extname, join } from 'path' +import { join } from 'path' +import sharp from 'sharp' const ALLOWED_IMAGES = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp'] const STAGES = ['report', 'progress', 'resolution'] @@ -22,22 +22,28 @@ export async function photoRoutes(app, opts) { let fileData = null, stage = 'report' for await (const part of req.parts()) { if (part.type === 'file') { - fileData = part - // must consume the file stream inside the loop — save it now if (!ALLOWED_IMAGES.includes(part.mimetype)) { return reply.status(400).send({ error: 'Only JPEG, PNG and WebP images are allowed' }) } - const ext = extname(part.filename) || '.jpg' - const filename = randomUUID() + ext + + // Buffer the upload then process with sharp: + // auto-rotate (fixes phone EXIF orientation), resize to 1800px max, re-encode as JPEG + const chunks = [] + for await (const chunk of part.file) chunks.push(chunk) + const raw = Buffer.concat(chunks) + + const processed = await sharp(raw) + .rotate() + .resize(1800, 1800, { fit: 'inside', withoutEnlargement: true }) + .jpeg({ quality: 82, progressive: true }) + .toBuffer() + + const filename = randomUUID() + '.jpg' const dir = join(UPLOADS_DIR, 'tasks', String(taskId)) await mkdir(dir, { recursive: true }) + await writeFile(join(dir, filename), processed) - let size = 0 - const dest = createWriteStream(join(dir, filename)) - for await (const chunk of part.file) { dest.write(chunk); size += chunk.length } - await new Promise(r => dest.end(r)) - - fileData = { filename: part.filename, mimetype: part.mimetype, savedAs: filename, size } + fileData = { originalName: part.filename, savedAs: filename, size: processed.length } } else { const val = await part.value if (part.fieldname === 'stage' && STAGES.includes(String(val))) stage = String(val) @@ -49,7 +55,7 @@ export async function photoRoutes(app, opts) { const { rows: ins } = await pool.query( `INSERT INTO task_photos (task_id, file_name, file_path, mime_type, file_size, stage, uploaded_by) VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *`, - [taskId, fileData.filename, filePath, fileData.mimetype, fileData.size, stage, req.user.email] + [taskId, fileData.originalName, filePath, 'image/jpeg', fileData.size, stage, req.user.email] ) await logEvent(taskId, 'photo', { note: `Photo added (${stage})`, userName: req.user.name }) return ins[0]