Compress and resize photos on upload via sharp

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 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-22 10:48:24 +00:00
parent ca006f9aea
commit 4ab9eb4035
2 changed files with 21 additions and 14 deletions

View file

@ -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]