feat: compress uploads and serve resized image variants via /api/img
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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',
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user