feat: compress uploads and serve resized image variants via /api/img

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
mahmood
2026-11-14 15:07:11 +03:30
co-authored by Cursor
parent b71fb678be
commit 3fe0a930c7
32 changed files with 976 additions and 52 deletions
@@ -1,13 +1,15 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../../prisma/prisma.module';
import { AdminUploadsController } from './admin-uploads.controller';
import { PublicImagesController } from './public-images.controller';
import { PublicUploadsController } from './public-uploads.controller';
import { AdminUploadsService } from './admin-uploads.service';
import { ImageVariantService } from './image-variants.service';
@Module({
imports: [PrismaModule],
controllers: [AdminUploadsController, PublicUploadsController],
providers: [AdminUploadsService],
exports: [AdminUploadsService],
controllers: [AdminUploadsController, PublicUploadsController, PublicImagesController],
providers: [AdminUploadsService, ImageVariantService],
exports: [AdminUploadsService, ImageVariantService],
})
export class AdminUploadsModule {}
@@ -0,0 +1,77 @@
import { BadRequestException } from '@nestjs/common';
import { mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { AdminUploadsService } from './admin-uploads.service';
import type { ImageVariantService } from './image-variants.service';
describe('AdminUploadsService.store', () => {
let dir: string;
const prevUploadDir = process.env.UPLOAD_DIR;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'uploads-'));
process.env.UPLOAD_DIR = dir;
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
if (prevUploadDir === undefined) delete process.env.UPLOAD_DIR;
else process.env.UPLOAD_DIR = prevUploadDir;
});
function build(compressUpload: ImageVariantService['compressUpload']) {
const create = jest.fn(async ({ data }) => ({ id: 'f1', ...data }));
const prisma = { uploadedFile: { create } };
const variants = { compressUpload, warm: jest.fn() } as unknown as ImageVariantService;
const svc = new AdminUploadsService(prisma as never, variants);
return { svc, create, variants };
}
const file = (over: Partial<{ originalname: string; mimetype: string; size: number; buffer: Buffer }> = {}) => {
const buffer = over.buffer ?? Buffer.alloc(2 * 1024 * 1024, 1);
return { originalname: 'ring.jpg', mimetype: 'image/jpeg', size: buffer.length, buffer, ...over };
};
it('stores the compressed WebP and records its real size and type', async () => {
const small = Buffer.from('compressed');
const { svc, create, variants } = build(async () => ({ buffer: small, ext: '.webp', mimeType: 'image/webp' }));
const row = await svc.store(file(), 'product.gallery', 'admin-1');
const [name] = readdirSync(dir);
expect(name).toMatch(/^[a-f0-9]{32}\.webp$/);
expect(readFileSync(join(dir, name)).equals(small)).toBe(true);
expect(row).toMatchObject({
url: `/uploads/${name}`,
originalName: 'ring.jpg',
mimeType: 'image/webp',
sizeBytes: small.length,
});
expect(create).toHaveBeenCalledTimes(1);
expect(variants.warm).toHaveBeenCalledWith(name);
});
it('stores the file as sent when compression is skipped', async () => {
const { svc } = build(async () => null);
const pdf = file({ originalname: 'license.pdf', mimetype: 'application/pdf' });
const row = await svc.store(pdf, 'business-license', null);
const [name] = readdirSync(dir);
expect(name).toMatch(/\.pdf$/);
expect(row).toMatchObject({ mimeType: 'application/pdf', sizeBytes: pdf.size });
});
it('rejects oversized and unsupported files with a clear message', async () => {
const { svc } = build(async () => null);
await expect(svc.store(undefined, undefined, null)).rejects.toThrow('فایلی انتخاب نشده است');
await expect(
svc.store(file({ buffer: Buffer.alloc(8 * 1024 * 1024 + 1) }), undefined, null),
).rejects.toThrow('بیشتر از ۸ مگابایت');
await expect(
svc.store(file({ originalname: 'a.heic', mimetype: 'image/heic' }), undefined, null),
).rejects.toBeInstanceOf(BadRequestException);
});
});
@@ -3,6 +3,7 @@ import { createHash, randomUUID } from 'node:crypto';
import { mkdir, writeFile } from 'node:fs/promises';
import { extname, join } from 'node:path';
import { PrismaService } from '../../prisma/prisma.service';
import { ImageVariantService } from './image-variants.service';
import { getUploadDir } from './upload-path';
/**
@@ -19,6 +20,9 @@ import { getUploadDir } from './upload-path';
* Deliberately NOT ported: legacy's commented-out webp re-encoding, watermarking
* and thumbnailing. That code is dead in legacy too — every branch of it is
* inside a `/* ... *​/` block, leaving only the plain `$file->move(...)` live.
* Raster images over ~500 KB are re-encoded to WebP before storing
* (ImageVariantService.compressUpload); PDF, SVG and GIF are stored as sent.
* Display-sized copies are rendered separately and served from /api/img.
*/
@Injectable()
export class AdminUploadsService {
@@ -32,36 +36,43 @@ export class AdminUploadsService {
/** 8 MB. Legacy relied on PHP's upload_max_filesize; this makes it explicit. */
private static readonly MAX_BYTES = 8 * 1024 * 1024;
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly variants: ImageVariantService,
) {}
async store(
file: { originalname: string; mimetype: string; size: number; buffer: Buffer } | undefined,
purpose: string | undefined,
actorAdminUserId: string | null,
) {
if (!file) throw new BadRequestException('فایلی ارسال نشده است');
if (!file) throw new BadRequestException('فایلی انتخاب نشده است');
if (file.size > AdminUploadsService.MAX_BYTES) {
throw new BadRequestException('حجم فایل بیش از حد مجاز است (۸ مگابایت)');
throw new BadRequestException('حجم فایل بیشتر از ۸ مگابایت است؛ فایل کوچک‌تری انتخاب کنید');
}
if (!AdminUploadsService.ALLOWED.test(file.mimetype)) {
throw new BadRequestException('نوع فایل مجاز نیست');
throw new BadRequestException('این نوع فایل پشتیبانی نمی‌شود؛ فقط JPG، PNG، WebP، GIF، SVG یا PDF');
}
const compressed = await this.variants.compressUpload(file.buffer, file.mimetype, file.originalname);
const buffer = compressed?.buffer ?? file.buffer;
// legacy: md5(time() . original_name) . '.' . ext
const ext = extname(file.originalname).toLowerCase();
const ext = compressed?.ext ?? extname(file.originalname).toLowerCase();
const stored =
createHash('md5').update(`${Date.now()}${file.originalname}${randomUUID()}`).digest('hex') +
ext;
await mkdir(this.dir, { recursive: true });
await writeFile(join(this.dir, stored), file.buffer);
await writeFile(join(this.dir, stored), buffer);
this.variants.warm(stored);
return this.prisma.uploadedFile.create({
data: {
url: `/uploads/${stored}`,
originalName: file.originalname,
mimeType: file.mimetype,
sizeBytes: file.size,
mimeType: compressed?.mimeType ?? file.mimetype,
sizeBytes: buffer.length,
purpose: purpose ?? null,
uploadedByAdminId: actorAdminUserId,
},
@@ -0,0 +1,139 @@
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { ImageVariantService } from './image-variants.service';
describe('ImageVariantService', () => {
let dir: string;
const prevUploadDir = process.env.UPLOAD_DIR;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'img-variants-'));
process.env.UPLOAD_DIR = dir;
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
if (prevUploadDir === undefined) delete process.env.UPLOAD_DIR;
else process.env.UPLOAD_DIR = prevUploadDir;
});
function withoutSharp(svc: ImageVariantService) {
(svc as unknown as { sharp: null }).sharp = null;
return svc;
}
it('snaps requested widths up to the allowed set', () => {
expect(ImageVariantService.snapWidth('150')).toBe(200);
expect(ImageVariantService.snapWidth(400)).toBe(400);
expect(ImageVariantService.snapWidth('401')).toBe(600);
expect(ImageVariantService.snapWidth('99999')).toBe(1600);
expect(ImageVariantService.snapWidth(undefined)).toBe(800);
expect(ImageVariantService.snapWidth('abc')).toBe(800);
expect(ImageVariantService.snapWidth('-5')).toBe(800);
});
it('returns null for missing files and traversal attempts', async () => {
const svc = withoutSharp(new ImageVariantService());
await expect(svc.resolve('nope.jpg', 400)).resolves.toBeNull();
await expect(svc.resolve('../secret.jpg', 400)).resolves.toBeNull();
});
it('serves the original when sharp is unavailable', async () => {
writeFileSync(join(dir, 'a.jpg'), Buffer.from([0xff, 0xd8, 0xff]));
const svc = withoutSharp(new ImageVariantService());
const img = await svc.resolve('a.jpg', 600);
expect(img).toEqual({ path: join(dir, 'a.jpg'), type: 'image/jpeg', optimized: false });
});
it('never resizes svg, gif or pdf', async () => {
writeFileSync(join(dir, 'logo.svg'), '<svg/>');
writeFileSync(join(dir, 'anim.gif'), 'GIF89a');
writeFileSync(join(dir, 'doc.pdf'), '%PDF');
const svc = new ImageVariantService();
await expect(svc.resolve('logo.svg', 400)).resolves.toMatchObject({ type: 'image/svg+xml', optimized: false });
await expect(svc.resolve('anim.gif', 400)).resolves.toMatchObject({ type: 'image/gif', optimized: false });
await expect(svc.resolve('doc.pdf', 400)).resolves.toMatchObject({ type: 'application/pdf', optimized: false });
});
describe('compressUpload', () => {
const KB = 1024;
const big = Buffer.alloc(3000 * KB);
/** A sharp stand-in whose output size depends on the requested quality and side. */
function fakeSharp(svc: ImageVariantService, sizeFor: (side: number, quality: number) => number) {
const calls: Array<{ side: number; quality: number }> = [];
(svc as unknown as { sharp: unknown }).sharp = () => {
let side = 0;
let quality = 0;
const p = {
rotate: () => p,
resize: (o: { width: number }) => ((side = o.width), p),
webp: (o: { quality: number }) => ((quality = o.quality), p),
toFile: jest.fn(),
toBuffer: async () => {
calls.push({ side, quality });
return Buffer.alloc(sizeFor(side, quality));
},
};
return p;
};
return calls;
}
it('stops at the first attempt under 500 KB', async () => {
const svc = new ImageVariantService();
const calls = fakeSharp(svc, (_side, q) => (q >= 80 ? 900 * KB : 450 * KB));
const out = await svc.compressUpload(big, 'image/jpeg', 'photo.jpg');
expect(calls).toEqual([
{ side: 2000, quality: 82 },
{ side: 2000, quality: 72 },
]);
expect(out).toMatchObject({ ext: '.webp', mimeType: 'image/webp' });
expect(out!.buffer.length).toBe(450 * KB);
});
it('keeps the smallest result when nothing reaches the target', async () => {
const svc = new ImageVariantService();
const calls = fakeSharp(svc, (side) => side * KB);
const out = await svc.compressUpload(big, 'image/png', 'photo.png');
expect(calls).toHaveLength(5);
expect(out!.buffer.length).toBe(1280 * KB);
});
it('leaves small files, non-raster types and missing sharp alone', async () => {
const svc = new ImageVariantService();
const calls = fakeSharp(svc, () => 10 * KB);
await expect(svc.compressUpload(Buffer.alloc(400 * KB), 'image/jpeg', 'a.jpg')).resolves.toBeNull();
await expect(svc.compressUpload(big, 'application/pdf', 'a.pdf')).resolves.toBeNull();
await expect(svc.compressUpload(big, 'image/gif', 'a.gif')).resolves.toBeNull();
await expect(svc.compressUpload(big, 'image/svg+xml', 'a.svg')).resolves.toBeNull();
expect(calls).toHaveLength(0);
await expect(withoutSharp(new ImageVariantService()).compressUpload(big, 'image/jpeg', 'a.jpg')).resolves.toBeNull();
});
it('keeps the upload when re-encoding would not make it smaller', async () => {
const svc = new ImageVariantService();
fakeSharp(svc, () => 4000 * KB);
await expect(svc.compressUpload(big, 'image/webp', 'a.webp')).resolves.toBeNull();
});
});
it('falls back to the original when a render fails', async () => {
writeFileSync(join(dir, 'broken.png'), 'not really a png');
const svc = new ImageVariantService();
const toFile = jest.fn().mockRejectedValue(new Error('bad input'));
const pipeline = { rotate: () => pipeline, resize: () => pipeline, webp: () => pipeline, toFile };
(svc as unknown as { sharp: unknown }).sharp = () => pipeline;
const img = await svc.resolve('broken.png', 400);
expect(toFile).toHaveBeenCalledTimes(1);
expect(img).toMatchObject({ type: 'image/png', optimized: false });
});
});
@@ -0,0 +1,205 @@
import { Injectable, Logger } from '@nestjs/common';
import { mkdir, rename, stat, unlink } from 'node:fs/promises';
import { basename, dirname, extname, join } from 'node:path';
import { getUploadDir, mimeForUpload, resolveStoredUpload } from './upload-path';
/** Widths the image endpoint renders; anything else snaps up to the next one. */
export const IMAGE_WIDTHS = [200, 400, 600, 800, 1200, 1600] as const;
export type ImageWidth = (typeof IMAGE_WIDTHS)[number];
/** Rendered right after an upload so the first shopper never waits on a resize. */
const WARM_WIDTHS: ImageWidth[] = [600, 1600];
/** GIF is skipped so animations survive; SVG and PDF are not raster. */
const RESIZABLE = new Set(['.jpg', '.jpeg', '.png', '.webp', '.avif']);
const RESIZABLE_MIME = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/avif']);
const WEBP_QUALITY = 78;
/** Stored originals aim for this size... */
export const ORIGINAL_TARGET_BYTES = 500 * 1024;
/** ...and only exceed this one if even the smallest attempt cannot get under it. */
export const ORIGINAL_CEILING_BYTES = 1024 * 1024;
/** Tried in order until one lands under the target; the last is the smallest. */
const ORIGINAL_ATTEMPTS = [
{ side: 2000, quality: 82 },
{ side: 2000, quality: 72 },
{ side: 1600, quality: 72 },
{ side: 1600, quality: 62 },
{ side: 1280, quality: 60 },
] as const;
interface SharpPipeline {
rotate(): SharpPipeline;
resize(opts: {
width: number;
height?: number;
fit?: 'inside';
withoutEnlargement: boolean;
}): SharpPipeline;
webp(opts: { quality: number }): SharpPipeline;
toFile(path: string): Promise<unknown>;
toBuffer(): Promise<Buffer>;
}
type SharpFactory = (input: string | Buffer, opts?: { failOn?: 'none' }) => SharpPipeline;
export interface CompressedUpload {
buffer: Buffer;
ext: '.webp';
mimeType: 'image/webp';
}
export interface ServedImage {
path: string;
type: string;
/** False when the original is served because no smaller variant exists. */
optimized: boolean;
}
/**
* In-process image work with `sharp`: compressing uploads before they are
* stored, and resized WebP copies cached on disk under
* `<UPLOAD_DIR>/.variants/<width>/`. The dot prefix keeps the cache out of the
* public `/uploads/` mount (`dotfiles: 'deny'`).
*
* `sharp` is loaded lazily: without it (or if a render fails) uploads are
* stored and served untouched, so a missing native binary never breaks images.
*/
@Injectable()
export class ImageVariantService {
private readonly logger = new Logger(ImageVariantService.name);
private readonly cacheDir = join(getUploadDir(), '.variants');
private readonly inFlight = new Map<string, Promise<string | null>>();
private sharp: SharpFactory | null | undefined;
static snapWidth(raw: unknown): ImageWidth {
const n = Number(raw);
if (!Number.isFinite(n) || n <= 0) return 800;
return IMAGE_WIDTHS.find((w) => w >= n) ?? IMAGE_WIDTHS[IMAGE_WIDTHS.length - 1];
}
static isResizable(filename: string): boolean {
return RESIZABLE.has(extname(filename).toLowerCase());
}
async resolve(filename: string, width: ImageWidth): Promise<ServedImage | null> {
const original = resolveStoredUpload(filename);
if (!original) return null;
const fallback: ServedImage = {
path: original,
type: mimeForUpload(original) ?? 'application/octet-stream',
optimized: false,
};
if (!ImageVariantService.isResizable(original)) return fallback;
const variant = await this.variant(original, width);
if (!variant) return fallback;
// An already-small upload can come out larger after re-encoding.
const [v, o] = await Promise.all([stat(variant), stat(original)]);
if (v.size >= o.size) return fallback;
return { path: variant, type: 'image/webp', optimized: true };
}
/**
* Shrinks an uploaded raster image before it is written to disk, so an 8 MB
* phone photo is stored as a WebP of roughly ORIGINAL_TARGET_BYTES. Returns
* null — store the upload as-is — when the file is already small, is not a
* resizable format, sharp is missing, or re-encoding would not make it smaller.
*/
async compressUpload(
buffer: Buffer,
mimeType: string,
filename: string,
): Promise<CompressedUpload | null> {
if (buffer.length <= ORIGINAL_TARGET_BYTES) return null;
if (!RESIZABLE_MIME.has(mimeType)) return null;
const sharp = this.loadSharp();
if (!sharp) return null;
let smallest: Buffer | null = null;
try {
for (const { side, quality } of ORIGINAL_ATTEMPTS) {
const out = await sharp(buffer, { failOn: 'none' })
.rotate()
.resize({ width: side, height: side, fit: 'inside', withoutEnlargement: true })
.webp({ quality })
.toBuffer();
if (!smallest || out.length < smallest.length) smallest = out;
if (out.length <= ORIGINAL_TARGET_BYTES) break;
}
} catch (err) {
this.logger.warn(`compress failed for ${filename}: ${(err as Error).message}`);
return null;
}
if (!smallest || smallest.length >= buffer.length) return null;
if (smallest.length > ORIGINAL_CEILING_BYTES) {
this.logger.warn(`${filename} is still ${smallest.length} bytes after compression`);
}
return { buffer: smallest, ext: '.webp', mimeType: 'image/webp' };
}
/** Fire-and-forget pre-render for a freshly stored upload. */
warm(filename: string): void {
if (!ImageVariantService.isResizable(filename)) return;
for (const width of WARM_WIDTHS) void this.resolve(filename, width);
}
private async variant(original: string, width: ImageWidth): Promise<string | null> {
const target = join(this.cacheDir, String(width), `${basename(original)}.webp`);
if (await isFresh(target, original)) return target;
const pending = this.inFlight.get(target);
if (pending) return pending;
const job = this.render(original, target, width).finally(() => this.inFlight.delete(target));
this.inFlight.set(target, job);
return job;
}
private async render(original: string, target: string, width: ImageWidth): Promise<string | null> {
const sharp = this.loadSharp();
if (!sharp) return null;
const tmp = `${target}.${process.pid}.${Date.now()}.tmp`;
try {
await mkdir(dirname(target), { recursive: true });
await sharp(original, { failOn: 'none' })
.rotate()
.resize({ width, withoutEnlargement: true })
.webp({ quality: WEBP_QUALITY })
.toFile(tmp);
await rename(tmp, target);
return target;
} catch (err) {
this.logger.warn(`resize failed for ${basename(original)} @${width}: ${(err as Error).message}`);
await unlink(tmp).catch(() => undefined);
return null;
}
}
private loadSharp(): SharpFactory | null {
if (this.sharp !== undefined) return this.sharp;
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
this.sharp = require('sharp') as SharpFactory;
} catch {
this.sharp = null;
this.logger.warn('sharp is not installed; images are served without resizing');
}
return this.sharp;
}
}
async function isFresh(target: string, original: string): Promise<boolean> {
try {
const [t, o] = await Promise.all([stat(target), stat(original)]);
return t.mtimeMs >= o.mtimeMs;
} catch {
return false;
}
}
@@ -0,0 +1,45 @@
import {
Controller,
Get,
NotFoundException,
Param,
Query,
Res,
StreamableFile,
} from '@nestjs/common';
import type { Response } from 'express';
import { createReadStream } from 'node:fs';
import { Public } from '../auth/guards';
import { ImageVariantService } from './image-variants.service';
/**
* Resized images at GET /api/img/:filename?w=<width>, for the same files
* `/uploads/<file>` serves. Upload names are unique hashes and never rewritten,
* so a rendered variant is cached as immutable; the untouched original (no
* sharp, or nothing smaller to offer) keeps the shorter `/uploads` lifetime so
* a later variant can replace it in browsers.
*/
@Public()
@Controller('img')
export class PublicImagesController {
constructor(private readonly variants: ImageVariantService) {}
@Get(':filename')
async serve(
@Param('filename') filename: string,
@Query('w') w: string | undefined,
@Res({ passthrough: true }) res: Response,
): Promise<StreamableFile> {
const image = await this.variants.resolve(filename, ImageVariantService.snapWidth(w));
if (!image) throw new NotFoundException('فایل یافت نشد');
res.setHeader(
'Cache-Control',
image.optimized ? 'public, max-age=31536000, immutable' : 'public, max-age=86400',
);
return new StreamableFile(createReadStream(image.path), {
type: image.type,
disposition: 'inline',
});
}
}