321 lines
11 KiB
JavaScript
321 lines
11 KiB
JavaScript
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 =
|
|
`<svg xmlns="http://www.w3.org/2000/svg" width="${canvasW}" height="${canvasH}">` +
|
|
`<rect width="${canvasW}" height="${canvasH}" fill="#ffffff"/>` +
|
|
`<image href="data:image/png;base64,${b64}" x="${offsetX}" y="${offsetY}" width="${scaledPng.width}" height="${scaledPng.height}"/>` +
|
|
`</svg>`;
|
|
|
|
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' });
|
|
}
|
|
}
|