feat: image upload optimization with sharp, thumbnails, and structured error codes (#304)

- Add sharp for server-side image processing (WebP conversion + thumbnails)
- New shared backend utility for image upload, optimization, and cleanup
- Return structured error codes from upload endpoints (IMAGE_TOO_LARGE, INVALID_TYPE, etc.)
- Frontend error code mapping with i18n support (EN + DE)
- MedicationAvatar tries thumbnail first, falls back to full image
- Error display in MedicationsPage, MobileEditModal, and Auth avatar upload

Closes #302
This commit is contained in:
Daniel Volz
2026-02-24 23:52:59 +01:00
committed by GitHub
parent 7a32b2045e
commit 96b2a0c96f
15 changed files with 916 additions and 93 deletions
+30 -34
View File
@@ -1,4 +1,5 @@
import { randomBytes } from "node:crypto";
import { resolve } from "node:path";
import argon2 from "argon2";
import { eq, sql } from "drizzle-orm";
import type { FastifyInstance } from "fastify";
@@ -8,6 +9,12 @@ import { getDataDir } from "../db/db-utils.js";
import { refreshTokens, users } from "../db/schema.js";
import { getAuthState, requireAuth } from "../plugins/auth.js";
import type { AuthUser } from "../types/fastify.js";
import {
ALLOWED_IMAGE_MIME_TYPES,
removeImageFiles,
streamToBuffer,
writeOptimizedImageSet,
} from "../utils/image-upload.js";
// =============================================================================
// Argon2id Configuration - State of the Art Password Hashing
@@ -82,6 +89,8 @@ const updateProfileSchema = z.object({
// Auth Routes
// =============================================================================
export async function authRoutes(app: FastifyInstance) {
const IMAGES_DIR = resolve(getDataDir(), "images");
// Token TTLs
const accessTtlMinutes = 15;
const refreshTtlDays = 14;
@@ -462,36 +471,35 @@ export async function authRoutes(app: FastifyInstance) {
const data = await request.file();
if (!data) {
return reply.status(400).send({ error: "No file uploaded" });
return reply.status(400).send({ error: "No file uploaded", code: "NO_FILE" });
}
// Validate file type
const allowedTypes = ["image/jpeg", "image/png", "image/webp", "image/gif"];
if (!allowedTypes.includes(data.mimetype)) {
return reply.status(400).send({ error: "Invalid file type. Allowed: JPEG, PNG, WebP, GIF" });
if (!ALLOWED_IMAGE_MIME_TYPES.includes(data.mimetype)) {
return reply.status(400).send({ error: "Invalid file type", code: "INVALID_TYPE" });
}
// Generate unique filename
const ext = data.filename.split(".").pop() || "jpg";
const filename = `avatar_${authUser.id}_${Date.now()}.${ext}`;
let uploadBuffer: Buffer;
try {
uploadBuffer = await streamToBuffer(data.file);
} catch (error) {
if (error instanceof Error && error.message === "IMAGE_TOO_LARGE") {
return reply.status(400).send({ error: "Image too large", code: "IMAGE_TOO_LARGE" });
}
throw error;
}
// Save file
const fs = await import("node:fs/promises");
const path = await import("node:path");
const imagesDir = path.join(getDataDir(), "images");
await fs.mkdir(imagesDir, { recursive: true });
const buffer = await data.toBuffer();
await fs.writeFile(path.join(imagesDir, filename), buffer);
let filename: string;
try {
({ filename } = await writeOptimizedImageSet(IMAGES_DIR, `avatar_${authUser.id}`, uploadBuffer));
} catch {
return reply.status(400).send({ error: "Invalid image", code: "INVALID_IMAGE" });
}
// Delete old avatar if exists
const [user] = await db.select().from(users).where(eq(users.id, authUser.id));
if (user?.avatarUrl) {
try {
await fs.unlink(path.join(imagesDir, user.avatarUrl));
} catch {
// Ignore if file doesn't exist
}
removeImageFiles(IMAGES_DIR, user.avatarUrl);
}
// Update user
@@ -522,13 +530,7 @@ export async function authRoutes(app: FastifyInstance) {
}
// Delete file
const fs = await import("node:fs/promises");
const path = await import("node:path");
try {
await fs.unlink(path.join(getDataDir(), "images", user.avatarUrl));
} catch {
// Ignore if file doesn't exist
}
removeImageFiles(IMAGES_DIR, user.avatarUrl);
// Update user
await db.update(users).set({ avatarUrl: null, updatedAt: new Date() }).where(eq(users.id, authUser.id));
@@ -555,13 +557,7 @@ export async function authRoutes(app: FastifyInstance) {
// Delete avatar file if exists
const [user] = await db.select().from(users).where(eq(users.id, authUser.id));
if (user?.avatarUrl) {
const fs = await import("node:fs/promises");
const path = await import("node:path");
try {
await fs.unlink(path.join(getDataDir(), "images", user.avatarUrl));
} catch {
// Ignore if file doesn't exist
}
removeImageFiles(IMAGES_DIR, user.avatarUrl);
}
// Delete user - cascade delete handles all related data
+28 -23
View File
@@ -1,6 +1,4 @@
import { createWriteStream, existsSync, unlinkSync } from "node:fs";
import { extname, resolve } from "node:path";
import { pipeline } from "node:stream/promises";
import { resolve } from "node:path";
import { and, eq, like } from "drizzle-orm";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
@@ -10,6 +8,12 @@ import { doseTracking, medications, userSettings } from "../db/schema.js";
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
import { env } from "../plugins/env.js";
import type { AuthUser } from "../types/fastify.js";
import {
ALLOWED_IMAGE_MIME_TYPES,
removeImageFiles,
streamToBuffer,
writeOptimizedImageSet,
} from "../utils/image-upload.js";
import { type Intake, parseIntakesJson, parseLocalDateTime, parseTakenByJson } from "../utils/scheduler-utils.js";
const IMAGES_DIR = resolve(getDataDir(), "images");
@@ -693,10 +697,7 @@ export async function medicationRoutes(app: FastifyInstance) {
.where(and(eq(medications.id, idNum), eq(medications.userId, userId)));
if (!existing) return reply.notFound();
if (existing.imageUrl) {
const imagePath = resolve(IMAGES_DIR, existing.imageUrl);
if (existsSync(imagePath)) unlinkSync(imagePath);
}
if (existing.imageUrl) removeImageFiles(IMAGES_DIR, existing.imageUrl);
const deleted = await db
.delete(medications)
@@ -719,24 +720,31 @@ export async function medicationRoutes(app: FastifyInstance) {
if (!existing) return reply.notFound();
const data = await req.file();
if (!data) return reply.badRequest("No file uploaded");
if (!data) return reply.status(400).send({ error: "No file uploaded", code: "NO_FILE" });
const allowedTypes = ["image/jpeg", "image/png", "image/webp", "image/gif"];
if (!allowedTypes.includes(data.mimetype)) {
return reply.badRequest("Invalid file type. Allowed: JPEG, PNG, WebP, GIF");
if (!ALLOWED_IMAGE_MIME_TYPES.includes(data.mimetype)) {
return reply.status(400).send({ error: "Invalid file type", code: "INVALID_TYPE" });
}
const ext = extname(data.filename) || ".jpg";
const filename = `med-${idNum}-${Date.now()}${ext}`;
const filepath = resolve(IMAGES_DIR, filename);
let uploadBuffer: Buffer;
try {
uploadBuffer = await streamToBuffer(data.file);
} catch (error) {
if (error instanceof Error && error.message === "IMAGE_TOO_LARGE") {
return reply.status(400).send({ error: "Image too large", code: "IMAGE_TOO_LARGE" });
}
throw error;
}
await pipeline(data.file, createWriteStream(filepath));
let filename: string;
try {
({ filename } = await writeOptimizedImageSet(IMAGES_DIR, `med-${idNum}`, uploadBuffer));
} catch {
return reply.status(400).send({ error: "Invalid image", code: "INVALID_IMAGE" });
}
// Delete old image if exists
if (existing.imageUrl) {
const oldPath = resolve(IMAGES_DIR, existing.imageUrl);
if (existsSync(oldPath)) unlinkSync(oldPath);
}
if (existing.imageUrl) removeImageFiles(IMAGES_DIR, existing.imageUrl);
await db
.update(medications)
@@ -758,10 +766,7 @@ export async function medicationRoutes(app: FastifyInstance) {
.where(and(eq(medications.id, idNum), eq(medications.userId, userId)));
if (!existing) return reply.notFound();
if (existing.imageUrl) {
const filepath = resolve(IMAGES_DIR, existing.imageUrl);
if (existsSync(filepath)) unlinkSync(filepath);
}
if (existing.imageUrl) removeImageFiles(IMAGES_DIR, existing.imageUrl);
await db
.update(medications)
+80
View File
@@ -0,0 +1,80 @@
import { existsSync, unlinkSync } from "node:fs";
import { writeFile } from "node:fs/promises";
import { extname, resolve } from "node:path";
import sharp from "sharp";
export const ALLOWED_IMAGE_MIME_TYPES = ["image/jpeg", "image/png", "image/webp", "image/gif"];
export const MAX_IMAGE_UPLOAD_BYTES = 10 * 1024 * 1024;
export function getThumbFilename(imageFilename: string): string {
const ext = extname(imageFilename);
const base = ext ? imageFilename.slice(0, -ext.length) : imageFilename;
return `${base}-thumb.webp`;
}
export function removeImageFiles(imagesDir: string, imageFilename: string): void {
const fullPath = resolve(imagesDir, imageFilename);
if (existsSync(fullPath)) unlinkSync(fullPath);
const thumbFilename = getThumbFilename(imageFilename);
if (thumbFilename !== imageFilename) {
const thumbPath = resolve(imagesDir, thumbFilename);
if (existsSync(thumbPath)) unlinkSync(thumbPath);
}
}
export async function streamToBuffer(stream: NodeJS.ReadableStream): Promise<Buffer> {
const chunks: Buffer[] = [];
let totalSize = 0;
for await (const chunk of stream) {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
totalSize += buffer.length;
if (totalSize > MAX_IMAGE_UPLOAD_BYTES) {
throw new Error("IMAGE_TOO_LARGE");
}
chunks.push(buffer);
}
return Buffer.concat(chunks);
}
export async function writeOptimizedImageSet(
imagesDir: string,
filePrefix: string,
uploadBuffer: Buffer,
options?: {
maxEdgePx?: number;
thumbSizePx?: number;
fullQuality?: number;
thumbQuality?: number;
}
): Promise<{ filename: string; thumbFilename: string }> {
const maxEdgePx = options?.maxEdgePx ?? 1600;
const thumbSizePx = options?.thumbSizePx ?? 96;
const fullQuality = options?.fullQuality ?? 82;
const thumbQuality = options?.thumbQuality ?? 76;
const filename = `${filePrefix}-${Date.now()}.webp`;
const thumbFilename = getThumbFilename(filename);
const filepath = resolve(imagesDir, filename);
const thumbFilepath = resolve(imagesDir, thumbFilename);
const optimizedBuffer = await sharp(uploadBuffer, { failOn: "error" })
.rotate()
.resize({ width: maxEdgePx, height: maxEdgePx, fit: "inside", withoutEnlargement: true })
.webp({ quality: fullQuality })
.toBuffer();
const thumbBuffer = await sharp(uploadBuffer, { failOn: "error" })
.rotate()
.resize({ width: thumbSizePx, height: thumbSizePx, fit: "cover", position: "attention" })
.webp({ quality: thumbQuality })
.toBuffer();
await writeFile(filepath, optimizedBuffer);
await writeFile(thumbFilepath, thumbBuffer);
return { filename, thumbFilename };
}