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
@@ -29,7 +29,7 @@
class="tile"
:class="{ 'is-uploading': item.status === 'uploading' }"
>
<img :src="item.url ?? item.preview" alt="" />
<img :src="imageUrl(item.url, 400) ?? item.preview" alt="" />
<div v-if="item.status === 'uploading'" class="tile__progress">
<a-progress type="circle" :percent="item.percent" :size="40" :stroke-width="8" stroke-color="#fff" trail-color="rgba(255,255,255,.3)" :show-info="false" />
@@ -64,6 +64,8 @@ import { computed, onBeforeUnmount, ref, watch } from 'vue';
import { message, Upload } from 'ant-design-vue';
import { CloudUploadOutlined, DeleteOutlined, EyeOutlined, LoadingOutlined } from '@ant-design/icons-vue';
import api from '../../api/client';
import { imageUrl } from '../../utils/image';
import { checkImageFile, uploadErrorMessage } from '../../utils/upload';
const props = withDefaults(
defineProps<{
@@ -116,8 +118,9 @@ watch(
);
function beforeUpload(file: File) {
if (!file.type.startsWith('image/')) {
message.error(`«${file.name}» تصویر نیست`);
const problem = checkImageFile(file);
if (problem) {
message.error(problem);
return Upload.LIST_IGNORE;
}
return true;
@@ -171,10 +174,7 @@ function doUpload({ file }: { file: File & { uid?: string } }) {
sync();
})
.catch((e) => {
const msg =
(e as { response?: { data?: { message?: string } } })?.response?.data?.message ??
`بارگذاری «${file.name}» ناموفق بود`;
message.error(msg);
message.error(uploadErrorMessage(e, file.name));
dropItem(uid);
});
}
@@ -17,6 +17,7 @@
:max-count="1"
accept="image/*"
:custom-request="doUpload"
:before-upload="beforeUpload"
@remove="handleRemove"
@preview="handlePreview"
>
@@ -37,9 +38,11 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { message } from 'ant-design-vue';
import { message, Upload } from 'ant-design-vue';
import { PlusOutlined } from '@ant-design/icons-vue';
import api from '../../api/client';
import { imageUrl } from '../../utils/image';
import { checkImageFile, uploadErrorMessage } from '../../utils/upload';
const props = withDefaults(
defineProps<{
@@ -67,6 +70,7 @@ interface UiFile {
name: string;
status: 'done';
url: string;
thumbUrl?: string;
}
const fileList = ref<UiFile[]>([]);
@@ -77,7 +81,9 @@ watch(
if (!url) {
fileList.value = [];
} else if (fileList.value[0]?.url !== url) {
fileList.value = [{ uid: url, name: url.split('/').pop() ?? 'file', status: 'done', url }];
fileList.value = [
{ uid: url, name: url.split('/').pop() ?? 'file', status: 'done', url, thumbUrl: imageUrl(url, 400) },
];
}
},
{ immediate: true },
@@ -85,6 +91,15 @@ watch(
const placeholder = computed(() => props.placeholder);
function beforeUpload(file: File) {
const problem = checkImageFile(file);
if (problem) {
message.error(problem);
return Upload.LIST_IGNORE;
}
return true;
}
async function doUpload({ file }: { file: File }) {
const form = new FormData();
form.append('file', file);
@@ -98,10 +113,7 @@ async function doUpload({ file }: { file: File }) {
emit('update:modelValue', data.url);
emit('ok', data);
} catch (e) {
const msg =
(e as { response?: { data?: { message?: string } } })?.response?.data?.message ??
'بارگذاری فایل ناموفق بود';
message.error(msg);
message.error(uploadErrorMessage(e, file.name));
} finally {
uploading.value = false;
}
+21
View File
@@ -0,0 +1,21 @@
// Uploaded images through the API's resize endpoint (GET <api>/img/:file?w=).
// Anything that is not a plain upload path (blob:, data:, bundled assets,
// absolute URLs) is returned untouched.
const UPLOAD_PREFIXES = ['/uploads/', '/api/uploads/'];
/** Must match IMAGE_WIDTHS in apps/api image-variants.service.ts. */
export type ImageWidth = 200 | 400 | 600 | 800 | 1200 | 1600;
const apiBase = (() => {
const base = import.meta.env.VITE_API_BASE_URL ?? '/api/';
return base.endsWith('/') ? base : `${base}/`;
})();
export function imageUrl(url: string | null | undefined, width: ImageWidth): string | undefined {
if (!url) return undefined;
const prefix = UPLOAD_PREFIXES.find((p) => url.startsWith(p));
if (!prefix) return url;
const name = url.slice(prefix.length);
if (!name || name.includes('/')) return url;
return `${apiBase}img/${encodeURIComponent(name)}?w=${width}`;
}
+37
View File
@@ -0,0 +1,37 @@
// Checks and error wording shared by the image uploaders. The limits mirror
// AdminUploadsService in apps/api.
export const MAX_UPLOAD_BYTES = 8 * 1024 * 1024;
const IMAGE_TYPES = /^image\/(jpeg|png|gif|webp|svg\+xml)$/;
/** Null when the file may be uploaded, otherwise the message to show. */
export function checkImageFile(file: File): string | null {
if (!IMAGE_TYPES.test(file.type)) {
return `«${file.name}» پشتیبانی نمی‌شود؛ فقط تصویر JPG، PNG، WebP، GIF یا SVG`;
}
if (file.size > MAX_UPLOAD_BYTES) {
return `حجم «${file.name}» بیشتر از ۸ مگابایت است؛ فایل کوچک‌تری انتخاب کنید`;
}
return null;
}
/**
* The message for a failed upload request. A 413 comes from the reverse proxy,
* not the API, so its body is HTML rather than `{ message }`.
*/
export function uploadErrorMessage(e: unknown, fileName?: string): string {
const res = (e as { response?: { status?: number; data?: unknown } })?.response;
if (!res) return 'اتصال به سرور برقرار نشد؛ اینترنت را بررسی کنید و دوباره تلاش کنید';
if (res.status === 413) {
return `حجم ${fileName ? `«${fileName}»` : 'فایل'} بیشتر از حد مجاز سرور است`;
}
if (res.status === 403) return 'اجازهٔ بارگذاری فایل را ندارید';
const raw = (res.data as { message?: unknown } | undefined)?.message;
const serverMessage = Array.isArray(raw) ? raw[0] : raw;
if (typeof serverMessage === 'string' && serverMessage) return serverMessage;
return fileName
? `بارگذاری «${fileName}» انجام نشد؛ دوباره تلاش کنید`
: 'بارگذاری فایل انجام نشد؛ دوباره تلاش کنید';
}
+2 -1
View File
@@ -32,7 +32,7 @@
>
<template #description>
<div v-for="hit in surveyMajorityHits" :key="hit.id" class="survey-majority__row">
<img v-if="hit.imageUrl" :src="hit.imageUrl" alt="" />
<img v-if="hit.imageUrl" :src="imageUrl(hit.imageUrl, 200)" alt="" />
<span>
<b>{{ hit.title || 'بدون عنوان' }}</b>
— ▲ {{ hit.likes }} لایک (حد: {{ surveyMajorityThreshold }})
@@ -314,6 +314,7 @@ import {
UserAddOutlined,
} from '@ant-design/icons-vue';
import { jdate, price } from '../utils/filters';
import { imageUrl } from '../utils/image';
import { useLayoutStore } from '../stores/layout';
import { useUsersStore } from '../stores/users';
import api from '../api/client';
@@ -16,7 +16,7 @@
width="90px"
height="90px"
style="object-fit: scale-down"
:src="text && text.image ? text.image : noImage"
:src="text && text.image ? imageUrl(text.image, 200) : noImage"
/>
</template>
@@ -54,6 +54,7 @@ import { buildCategoryModel } from './CategoryTbl';
import { useUsersStore } from '../../../stores/users';
import { useDeleteHandler } from '../../../composables/useDeleteHandler';
import api from '../../../api/client';
import { imageUrl } from '../../../utils/image';
const users = useUsersStore();
const hasPermission = (rid: string) => Boolean(users.hasPermission(rid));
@@ -22,7 +22,7 @@
<template #image="{ record }">
<img
v-if="record.ProductImage && record.ProductImage.length"
:src="record.ProductImage[0].image"
:src="imageUrl(record.ProductImage[0].image, 200)"
class="product-thumb"
alt=""
/>
@@ -122,6 +122,7 @@ import AccountingSyncButton from '../../../components/accounting/AccountingSyncB
import { buildProductListModel } from './productListModel';
import { useUsersStore } from '../../../stores/users';
import { isBranch } from '../../../utils';
import { imageUrl } from '../../../utils/image';
import { useDeleteHandler } from '../../../composables/useDeleteHandler';
import api from '../../../api/client';
@@ -10,7 +10,7 @@
>
<template #description>
<div v-for="hit in majorityHits" :key="hit.id" class="majority-hit">
<img v-if="hit.imageUrl" :src="hit.imageUrl" class="thumb" alt="" />
<img v-if="hit.imageUrl" :src="imageUrl(hit.imageUrl, 200)" class="thumb" alt="" />
<div class="majority-hit__text">
<b>{{ hit.title || 'بدون عنوان' }}</b>
<div>▲ {{ hit.likes }} لایک · {{ hit.statusLabel }} · {{ hit.suggestedWeightGrams }} گرم</div>
@@ -59,7 +59,7 @@
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'image'">
<img v-if="record.imageUrl" :src="record.imageUrl" class="thumb" alt="" />
<img v-if="record.imageUrl" :src="imageUrl(record.imageUrl, 200)" class="thumb" alt="" />
</template>
<template v-else-if="column.key === 'title'">
{{ record.title || '—' }}
@@ -148,6 +148,7 @@ import { onMounted, reactive, ref } from 'vue';
import { message } from 'ant-design-vue';
import { useUsersStore } from '../../../stores/users';
import api from '../../../api/client';
import { imageUrl } from '../../../utils/image';
import ImageUploadInput from '../../../components/upload/ImageUploadInput.vue';
type SurveyRow = {
@@ -43,7 +43,7 @@
width="90px"
height="90px"
style="object-fit: scale-down"
:src="record.thumb && record.thumb.full ? record.thumb.full : noImage"
:src="record.thumb && record.thumb.full ? imageUrl(record.thumb.full, 200) : noImage"
/>
</template>
@@ -130,6 +130,7 @@
<script setup lang="ts">
import { optionText } from '../../../utils/select';
import { imageUrl } from '../../../utils/image';
import { computed, onMounted, reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import { message } from 'ant-design-vue';
@@ -173,7 +174,7 @@ function getVariant(item: CartItem): CartVariant & Record<string, any> {
}
function getImage(product: Record<string, any>) {
return product?.thumb?.full ?? product?.ProductImage?.[0]?.image ?? noImage;
return imageUrl(product?.thumb?.full ?? product?.ProductImage?.[0]?.image, 200) ?? noImage;
}
/** Same as the admin builder: 0 stock means uncapped, which keeps pre-orders orderable. */
@@ -32,7 +32,7 @@
width="90px"
height="90px"
style="object-fit: scale-down"
:src="record.thumb && record.thumb.full ? record.thumb.full : noImage"
:src="record.thumb && record.thumb.full ? imageUrl(record.thumb.full, 200) : noImage"
/>
</template>
@@ -94,7 +94,7 @@
width="90px"
height="90px"
style="object-fit: scale-down"
:src="record.thumb && record.thumb.full ? record.thumb.full : noImage"
:src="record.thumb && record.thumb.full ? imageUrl(record.thumb.full, 200) : noImage"
/>
</template>
@@ -162,6 +162,7 @@
<script setup lang="ts">
import { optionText } from '../../utils/select';
import { imageUrl } from '../../utils/image';
import { computed, onMounted, reactive, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { message } from 'ant-design-vue';
@@ -207,7 +208,7 @@ const columns = [
];
function getImage(product: Record<string, any>) {
return product?.thumb?.full ?? product?.ProductImage?.[0]?.image ?? noImage;
return imageUrl(product?.thumb?.full ?? product?.ProductImage?.[0]?.image, 200) ?? noImage;
}
function addToCart(record: Record<string, any>) {
@@ -33,7 +33,7 @@
width="90px"
height="90px"
style="object-fit: scale-down"
:src="record.thumb && record.thumb.full ? record.thumb.full : noImage"
:src="record.thumb && record.thumb.full ? imageUrl(record.thumb.full, 200) : noImage"
/>
</template>
</XTable>
@@ -96,6 +96,7 @@ import { message } from 'ant-design-vue';
import XTable from '../../components/x-table/XTable.vue';
import XTbl, { Xtc } from '../../components/x-table/model';
import api from '../../api/client';
import { imageUrl } from '../../utils/image';
const noImage = '/img/no-items.png';
const loading = ref(false);
+3 -2
View File
@@ -21,7 +21,7 @@
width="90px"
height="90px"
style="object-fit: scale-down"
:src="record.thumb && record.thumb.full ? record.thumb.full : noImage"
:src="record.thumb && record.thumb.full ? imageUrl(record.thumb.full, 200) : noImage"
/>
</template>
@@ -205,6 +205,7 @@
<script setup lang="ts">
import { optionText } from '../../utils/select';
import { imageUrl } from '../../utils/image';
import { computed, onMounted, reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import { message } from 'ant-design-vue';
@@ -294,7 +295,7 @@ function getVariant(item: CartItem): CartVariant & Record<string, any> {
}
function getImage(product: Record<string, any>) {
return product?.thumb?.full ?? product?.ProductImage?.[0]?.image ?? noImage;
return imageUrl(product?.thumb?.full ?? product?.ProductImage?.[0]?.image, 200) ?? noImage;
}
/** legacy totalWeight — note it does NOT multiply by qty. Preserved. */
+2
View File
@@ -16,6 +16,8 @@ module.exports = {
external: [
'@prisma/client',
'.prisma/client',
// Native image library, loaded lazily by ImageVariantService; absent = no resizing.
'sharp',
'@nestjs/microservices',
'@nestjs/microservices/*',
'@nestjs/websockets',
+2 -1
View File
@@ -31,7 +31,8 @@
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
"rxjs": "^7.8.1",
"sharp": "^0.35.5"
},
"devDependencies": {
"@nestjs/cli": "^10.4.5",
@@ -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',
});
}
}
+17 -1
View File
@@ -25,6 +25,22 @@ export class UnauthorizedError extends Error {
}
}
/**
* A 413 is the reverse proxy refusing the body before the API sees it, so it
* carries HTML, not `{ message }`; everything else uses the API's own wording.
*/
async function uploadErrorText(res: Response): Promise<string> {
if (res.status === 413) return 'حجم فایل بیشتر از حد مجاز سرور است؛ فایل کوچک‌تری انتخاب کنید';
try {
const body = (await res.json()) as { message?: unknown };
const raw = Array.isArray(body?.message) ? body.message[0] : body?.message;
if (typeof raw === 'string' && raw) return raw;
} catch {
// not JSON
}
return 'بارگذاری فایل انجام نشد؛ دوباره تلاش کنید';
}
function readToken(): string | null {
try {
return localStorage.getItem(TOKEN_KEY);
@@ -760,7 +776,7 @@ export const api = {
throw new UnauthorizedError('/customer/me/documents');
}
if (!res.ok) {
throw new Error(`آپلود فایل ناموفق بود (${res.status})`);
throw new Error(await uploadErrorText(res));
}
return res.json();
},
@@ -22,7 +22,7 @@
aria-label="بزرگ‌نمایی تصویر"
@click="previewOpen = true"
>
<img v-lazy="lookup.imageUrl" :alt="lookup?.productTitle ?? ''" />
<img v-lazy="imageUrl(lookup.imageUrl, 200)" :alt="lookup?.productTitle ?? ''" />
</button>
<div v-else class="oi__art oi__art--empty" aria-hidden="true">
<Icon icon="vuesax-linear:diamonds" size="18" />
@@ -63,7 +63,7 @@
<button type="button" class="oi__previewClose" aria-label="بستن" @click="previewOpen = false">
<Icon icon="vuesax-linear:close-circle" size="22" />
</button>
<img class="oi__previewImg" :src="lookup.imageUrl" :alt="lookup.productTitle ?? ''" />
<img class="oi__previewImg" :src="imageUrl(lookup.imageUrl, 1600)" :alt="lookup.productTitle ?? ''" />
</div>
</Transition>
</Teleport>
@@ -75,6 +75,7 @@ import { computed, ref, watch, onBeforeUnmount } from 'vue'
import Icon from '../icon/Icon.vue'
import { orderItemLookup } from '../../composables/useOrderItemProduct'
import type { OrderItemDetail } from '../../api/client'
import { imageUrl } from '../../utils/image'
const props = defineProps<{ value: OrderItemDetail }>()
@@ -300,7 +300,7 @@
:aria-label="`تصویر ${i + 1}`"
@click="imageIndex = i"
>
<img :src="s" :alt="`${title} ${i + 1}`" draggable="false" />
<img :src="thumbs[i] ?? s" :alt="`${title} ${i + 1}`" draggable="false" />
</button>
</div>
</div>
@@ -335,6 +335,7 @@ import { useFiltersStore } from '../../stores/filtersStore'
import { useCartStore, type CartItem } from '../../stores/cartStore'
import { isUserLoggedIn } from '../../utils/checkAccess'
import { api, type ProductSummary } from '../../api/client'
import { imageUrl } from '../../utils/image'
// `initial`: the card's own ProductSummary when opened from a list (see
// ProductMorph.vue) — lets the page render whole on its first frame.
@@ -420,7 +421,12 @@ const categoryId = computed(() => productDetail.productRef.value?.category?.id ?
const categoryTitle = computed(() => productDetail.productRef.value?.category?.title ?? '')
const brandTitle = computed(() => productDetail.productRef.value?.brand?.title ?? '')
const hasImages = computed(() => (productDetail.productRef.value?.images.length ?? 0) > 0)
const slides = computed(() => productDetail.productRef.value?.images.map((i) => i.url) ?? [])
const slides = computed(
() => productDetail.productRef.value?.images.map((i) => imageUrl(i.url, 1600) ?? i.url) ?? [],
)
const thumbs = computed(
() => productDetail.productRef.value?.images.map((i) => imageUrl(i.url, 200) ?? i.url) ?? [],
)
// Keep the gallery index valid if the product (and so its image list) changes.
watch(slides, (list) => {
@@ -42,7 +42,7 @@
<script setup lang="ts">
import { ref } from 'vue'
import Icon from '../icon/Icon.vue'
import { api } from '../../api/client'
import { api, UnauthorizedError } from '../../api/client'
const props = defineProps<{
uploadText: string
@@ -60,6 +60,15 @@ const preview = ref<string | null>(null)
/** 8 MB — the same ceiling the API enforces, checked here for a fast message. */
const MAX_BYTES = 8 * 1024 * 1024
const ACCEPTED = /^(image\/(jpeg|png|webp)|application\/pdf)$/
function failureText(e: unknown): string {
if (e instanceof UnauthorizedError) return 'برای بارگذاری، دوباره وارد حساب شوید'
// fetch rejects with a TypeError only when the request never got a response.
if (e instanceof TypeError) return 'اتصال به سرور برقرار نشد؛ اینترنت را بررسی کنید'
if (e instanceof Error && e.message) return e.message
return 'بارگذاری فایل انجام نشد؛ دوباره تلاش کنید'
}
async function onPick(event: Event) {
const target = event.target as HTMLInputElement
@@ -70,8 +79,12 @@ async function onPick(event: Event) {
error.value = null
if (!ACCEPTED.test(file.type)) {
error.value = 'این نوع فایل پشتیبانی نمی‌شود؛ فقط تصویر JPG، PNG، WebP یا PDF'
return
}
if (file.size > MAX_BYTES) {
error.value = 'حجم فایل بیش از حد مجاز است (۸ مگابایت)'
error.value = 'حجم فایل بیشتر از ۸ مگابایت است؛ فایل کوچک‌تری انتخاب کنید'
return
}
@@ -81,8 +94,8 @@ async function onPick(event: Event) {
fileId.value = stored.id
preview.value = file.type.startsWith('image/') ? URL.createObjectURL(file) : null
emit('done', stored.id)
} catch {
error.value = 'بارگذاری انجام نشد. دوباره تلاش کنید.'
} catch (e) {
error.value = failureText(e)
fileId.value = null
preview.value = null
} finally {
@@ -20,6 +20,7 @@ import { computed, ref, unref, type Ref } from 'vue'
import type { ProductSummary, ProductVariantSummary } from '../api/client'
import { useSessionStore } from '../stores/session'
import { useWageQuoteStore } from '../stores/wageQuoteStore'
import { imageUrl } from '../utils/image'
/** A variant can be ordered when it has stock, or when it is flagged pre-order. */
export function isVariantOrderable(v: ProductVariantSummary): boolean {
@@ -41,7 +42,8 @@ function getCheapestVariant(variants: ProductVariantSummary[] | undefined): Prod
export function useProduct(productItem: ProductSummary, initialVariant?: ProductVariantSummary) {
const product = ref<ProductSummary>(productItem)
const image = computed(() => product.value.images?.[0]?.url)
// Card-sized: product cards, cart lines and home panels all render this.
const image = computed(() => imageUrl(product.value.images?.[0]?.url, 600))
const id = computed(() => product.value.id)
const title = computed(() => product.value.title)
const variants = computed(() => product.value.variants)
+16
View File
@@ -0,0 +1,16 @@
// Uploaded images through the API's resize endpoint (GET /api/img/:file?w=).
// Anything that is not a plain upload path (blob:, data:, bundled assets,
// absolute URLs) is returned untouched.
const UPLOAD_PREFIXES = ['/uploads/', '/api/uploads/']
/** Must match IMAGE_WIDTHS in apps/api image-variants.service.ts. */
export type ImageWidth = 200 | 400 | 600 | 800 | 1200 | 1600
export function imageUrl(url: string | null | undefined, width: ImageWidth): string | undefined {
if (!url) return undefined
const prefix = UPLOAD_PREFIXES.find((p) => url.startsWith(p))
if (!prefix) return url
const name = url.slice(prefix.length)
if (!name || name.includes('/')) return url
return `/api/img/${encodeURIComponent(name)}?w=${width}`
}
+2 -1
View File
@@ -122,6 +122,7 @@ import type { HomeCategory } from '../stores/configStore'
import { useFiltersStore } from '../stores/filtersStore'
import { api, type AdminSlide, type GoldPriceQuote, type ProductSummary } from '../api/client'
import { useCachedReads } from '../api/cachedReads'
import { imageUrl } from '../utils/image'
import { useCatalogStore } from '../stores/catalogStore'
import { KEYS } from '../stores/queryCache'
@@ -191,7 +192,7 @@ const listProducts = computed(() => products.value.slice(0, LIST_LIMIT))
const slides = computed<HeroSlide[]>(() =>
(adminSlides.value ?? [])
.filter((s) => s.image)
.map((s, i) => ({ key: `${i}-${s.image}`, image: s.image })),
.map((s, i) => ({ key: `${i}-${s.image}`, image: imageUrl(s.image, 1200) })),
)
// Opens as a morph out of the tapped card (ProductMorph.vue, mounted in
@@ -48,7 +48,7 @@
<div><dt>ساعت ثبت</dt><dd dir="ltr">{{ timeOf(item.createdAt) }}</dd></div>
</dl>
<p v-if="item.description" class="mks__note">{{ item.description }}</p>
<img v-if="item.image" class="mks__photo" :src="item.image" alt="" />
<img v-if="item.image" class="mks__photo" :src="imageUrl(item.image, 400)" alt="" loading="lazy" />
</article>
<section v-if="!rows.length && !loading && !failed" class="mks__empty">
@@ -77,6 +77,7 @@ import PageBar from '../../components/ui/PageBar.vue'
import Spinner from '../../components/Spinner.vue'
import { jdate } from '../../utils/filters'
import { api, type PreOrderRow } from '../../api/client'
import { imageUrl } from '../../utils/image'
const router = useRouter()
const STEPS = ['ثبت سفارش', 'تایید ادمین', 'تایید کارگاه', 'ارسال', 'تحویل شده']
@@ -10,7 +10,7 @@
<AppPullRefresh v-model="refreshing" @refresh="onRefresh">
<container class="svf__body">
<article v-for="item in rows" :key="item.id" class="svf__card">
<img class="svf__photo" :src="item.imageUrl" alt="" />
<img class="svf__photo" :src="imageUrl(item.imageUrl, 800)" alt="" loading="lazy" />
<div class="svf__meta">
<b v-if="item.title">{{ item.title }}</b>
<span>وزن پیشنهادی: {{ item.suggestedWeightGrams }} گرم</span>
@@ -67,6 +67,7 @@ import PageBar from '../../components/ui/PageBar.vue'
import Spinner from '../../components/Spinner.vue'
import { toast } from '../../composables/useToast'
import { api, type ProductSurveyRow } from '../../api/client'
import { imageUrl } from '../../utils/image'
const router = useRouter()
const rows = ref<ProductSurveyRow[]>([])
@@ -26,7 +26,7 @@
<section class="svm__list">
<h2 class="svm__listTitle">ارسال‌های قبلی</h2>
<article v-for="item in rows" :key="item.id" class="svm__card">
<img class="svm__photo" :src="item.imageUrl" alt="" />
<img class="svm__photo" :src="imageUrl(item.imageUrl, 400)" alt="" loading="lazy" />
<div class="svm__meta">
<b>{{ item.title || 'بدون عنوان' }}</b>
<span>{{ item.suggestedWeightGrams }} گرم</span>
@@ -51,6 +51,7 @@ import Spinner from '../../components/Spinner.vue'
import SingleImageUploader from '../../components/upload/SingleImageUploader.vue'
import { toast } from '../../composables/useToast'
import { api, type ProductSurveyRow } from '../../api/client'
import { imageUrl } from '../../utils/image'
const form = reactive({
file_id: null as string | null,