import { optimizeImage } from 'wasm-image-optimization'; import * as db from '../../db/posterminal.js'; const PIMAGE_DEFAULT_W = 200; const PIMAGE_DEFAULT_H = 200; const CIMAGE_DEFAULT_W = 200; const CIMAGE_DEFAULT_H = 200; const CIMAGE_MAX_DIMENSION = 200; /** * @openapi * /v1/pimage: * get: * tags: [POS] * summary: POS product image delivery * description: | * Returns product image by hash (`path` query param) scaled to preview size. * x-markdown: pos/pimage.md * parameters: * - in: query * name: path * required: true * schema: * type: string * description: SHA-256 hash of the product image * example: "71de4085ce2b1a53317832421009d844eec0c36a3ec071a31661cc2a212e15e6" * - in: query * name: size * required: false * schema: * type: integer * default: 200 * description: Max dimension (width & height) * - in: query * name: w * required: false * schema: * type: integer * default: 200 * description: Max width * - in: query * name: h * required: false * schema: * type: integer * default: 200 * description: Max height * - in: query * name: format * required: false * schema: * type: string * enum: [jpeg, png, webp, avif] * description: Optional target format (defaults to jpeg/png or client Accept preference) * responses: * 200: * description: Resized image binary * content: * image/jpeg: * schema: * type: string * format: binary * image/png: * schema: * type: string * format: binary * image/webp: * schema: * type: string * format: binary * image/avif: * schema: * type: string * format: binary * 304: * description: Image not modified * 400: * description: Missing required query parameter 'path' * content: * application/json: * schema: * type: object * properties: * Message: * type: string * example: "Missing required query parameter 'path'." * 404: * description: Image not found * content: * application/json: * schema: * type: object * properties: * Message: * type: string * example: "No image was found for path 'abc'." */ export async function handlePimage(req, res) { const isCimage = req.path?.includes('cimage') || req.originalUrl?.includes('/cimage'); return servePosImage(req, res, { isCimage }); } /** * @openapi * /v1/cimage: * get: * tags: [POS] * summary: POS category image delivery * description: | * Returns category image by hash (`path` query param) scaled to preview size (padded to 1:1 with white background). * x-markdown: pos/cimage.md * parameters: * - in: query * name: path * required: true * schema: * type: string * description: SHA-256 hash of the category image * example: "71de4085ce2b1a53317832421009d844eec0c36a3ec071a31661cc2a212e15e6" * - in: query * name: size * required: false * schema: * type: integer * default: 200 * maximum: 200 * description: Max dimension (width & height, capped to 200) * - in: query * name: w * required: false * schema: * type: integer * default: 200 * maximum: 200 * description: Max width (capped to 200) * - in: query * name: h * required: false * schema: * type: integer * default: 200 * maximum: 200 * description: Max height (capped to 200) * - in: query * name: format * required: false * schema: * type: string * enum: [jpeg, png, webp, avif] * description: Optional target format (defaults to jpeg/png or client Accept preference) * responses: * 200: * description: Resized image binary * content: * image/jpeg: * schema: * type: string * format: binary * image/png: * schema: * type: string * format: binary * image/webp: * schema: * type: string * format: binary * image/avif: * schema: * type: string * format: binary * 304: * description: Image not modified * 400: * description: Missing required query parameter 'path' * content: * application/json: * schema: * type: object * properties: * Message: * type: string * example: "Missing required query parameter 'path'." * 404: * description: Image not found * content: * application/json: * schema: * type: object * properties: * Message: * type: string * example: "No image was found for path 'abc'." */ export async function handleCimage(req, res) { return servePosImage(req, res, { isCimage: true }); } async function servePosImage(req, res, { isCimage = false } = {}) { const rawPath = typeof req.query.path === 'string' ? req.query.path.trim() : ''; if (!rawPath) { return res.status(400).json({ Message: "Missing required query parameter 'path'." }); } const hasSize = req.query.size !== undefined && Number.isFinite(Number(req.query.size)) && Number(req.query.size) > 0; const hasW = req.query.w !== undefined && Number.isFinite(Number(req.query.w)) && Number(req.query.w) > 0; const hasH = req.query.h !== undefined && Number.isFinite(Number(req.query.h)) && Number(req.query.h) > 0; let targetW; let targetH; if (isCimage) { if (hasW && !hasH) { targetW = Math.min(Math.round(Number(req.query.w)), CIMAGE_MAX_DIMENSION); targetH = targetW; } else if (hasH && !hasW) { targetH = Math.min(Math.round(Number(req.query.h)), CIMAGE_MAX_DIMENSION); targetW = targetH; } else if (hasW && hasH) { targetW = Math.min(Math.round(Number(req.query.w)), CIMAGE_MAX_DIMENSION); targetH = Math.min(Math.round(Number(req.query.h)), CIMAGE_MAX_DIMENSION); } else if (hasSize) { const s = Math.min(Math.round(Number(req.query.size)), CIMAGE_MAX_DIMENSION); targetW = s; targetH = s; } else { targetW = CIMAGE_DEFAULT_W; targetH = CIMAGE_DEFAULT_H; } } else { const defaultW = hasSize ? Number(req.query.size) : PIMAGE_DEFAULT_W; const defaultH = hasSize ? Number(req.query.size) : PIMAGE_DEFAULT_H; const widthParam = hasW ? Number(req.query.w) : defaultW; const heightParam = hasH ? Number(req.query.h) : defaultH; targetW = Math.min(Math.round(widthParam), 2000); targetH = Math.min(Math.round(heightParam), 2000); } try { const image = await db.getPosImageByHash(rawPath, { width: targetW, height: targetH }); if (!image) { return res.status(404).json({ Message: `No image was found for path '${rawPath}'.` }); } const etag = `"${image.fullHash}-${targetW}x${targetH}${isCimage ? '-c-v6' : ''}"`; const ifNoneMatch = req.headers['if-none-match']; if (ifNoneMatch === etag || ifNoneMatch === image.fullHash || ifNoneMatch === `"${image.fullHash}"`) { res.setHeader('ETag', etag); res.setHeader('Cache-Control', 'public, max-age=86400'); return res.status(304).end(); } const meta = await optimizeImage({ image: image.buffer, format: 'none' }); const srcW = meta.originalWidth || targetW; const srcH = meta.originalHeight || targetH; const scale = Math.min(1, targetW / srcW, targetH / srcH); const scaledW = Math.max(1, Math.round(srcW * scale)); const scaledH = Math.max(1, Math.round(srcH * scale)); const scaledPng = await optimizeImage({ image: image.buffer, width: scale < 1 ? scaledW : undefined, height: scale < 1 ? scaledH : undefined, fit: 'fill', format: 'png', }); const canvasW = isCimage ? targetW : scaledPng.width; const canvasH = isCimage ? targetH : scaledPng.height; const offsetX = Math.round((canvasW - scaledPng.width) / 2); const offsetY = Math.round((canvasH - scaledPng.height) / 2); const b64 = Buffer.from(scaledPng.data).toString('base64'); const flatSvg = `` + `` + `` + ``; const accept = String(req.headers.accept || ''); const formatParam = typeof req.query.format === 'string' ? req.query.format.toLowerCase() : ''; let contentType = 'image/jpeg'; let targetFormat = 'jpeg'; let quality = 85; let speed = 6; if (formatParam === 'avif' || (!formatParam && accept.includes('image/avif'))) { targetFormat = 'avif'; speed = 8; contentType = 'image/avif'; } else if (formatParam === 'webp' || (!formatParam && accept.includes('image/webp'))) { targetFormat = 'webp'; quality = 85; contentType = 'image/webp'; } else if (formatParam === 'png') { targetFormat = 'png'; contentType = 'image/png'; } else { targetFormat = 'jpeg'; quality = 85; contentType = 'image/jpeg'; } const encoded = await optimizeImage({ image: flatSvg, format: targetFormat, quality, speed, }); const buffer = Buffer.from(encoded.data); res.setHeader('Content-Type', contentType); res.setHeader('Content-Length', String(buffer.length)); res.setHeader('Cache-Control', 'public, max-age=86400'); res.setHeader('ETag', etag); return res.status(200).send(buffer); } catch (err) { console.error(`POS /v1/${isCimage ? 'cimage' : 'pimage'} error:`, err); return res.status(500).json({ Message: 'Internal server error' }); } }