u
This commit is contained in:
156
src/v1/routes/pimage.js
Normal file
156
src/v1/routes/pimage.js
Normal file
@@ -0,0 +1,156 @@
|
||||
import sharp from 'sharp';
|
||||
import * as db from '../../db/posterminal.js';
|
||||
|
||||
const 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: 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 rawPath = typeof req.query.path === 'string' ? req.query.path.trim() : '';
|
||||
|
||||
if (!rawPath) {
|
||||
return res.status(400).json({ Message: "Missing required query parameter 'path'." });
|
||||
}
|
||||
|
||||
const widthParam = req.query.w !== undefined ? Number(req.query.w) : MAX_DIMENSION;
|
||||
const heightParam = req.query.h !== undefined ? Number(req.query.h) : MAX_DIMENSION;
|
||||
const targetW = Number.isFinite(widthParam) && widthParam > 0 ? Math.min(Math.round(widthParam), 2000) : MAX_DIMENSION;
|
||||
const targetH = Number.isFinite(heightParam) && heightParam > 0 ? Math.min(Math.round(heightParam), 2000) : MAX_DIMENSION;
|
||||
|
||||
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}"`;
|
||||
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();
|
||||
}
|
||||
|
||||
let buffer = image.buffer;
|
||||
let contentType = 'image/jpeg';
|
||||
|
||||
try {
|
||||
const imageSharp = sharp(buffer);
|
||||
const meta = await imageSharp.metadata();
|
||||
|
||||
let pipeline = imageSharp.resize(targetW, targetH, {
|
||||
fit: 'inside',
|
||||
withoutEnlargement: true,
|
||||
});
|
||||
|
||||
const accept = String(req.headers.accept || '');
|
||||
const formatParam = typeof req.query.format === 'string' ? req.query.format.toLowerCase() : '';
|
||||
|
||||
if (formatParam === 'avif' || (!formatParam && accept.includes('image/avif'))) {
|
||||
pipeline = pipeline.avif({ effort: 2 });
|
||||
contentType = 'image/avif';
|
||||
} else if (formatParam === 'webp' || (!formatParam && accept.includes('image/webp'))) {
|
||||
pipeline = pipeline.webp({ quality: 85 });
|
||||
contentType = 'image/webp';
|
||||
} else if (formatParam === 'png' || (!formatParam && meta.hasAlpha)) {
|
||||
pipeline = pipeline.png();
|
||||
contentType = 'image/png';
|
||||
} else {
|
||||
pipeline = pipeline.jpeg({ quality: 85 });
|
||||
contentType = 'image/jpeg';
|
||||
}
|
||||
|
||||
buffer = await pipeline.toBuffer();
|
||||
} catch (resizeErr) {
|
||||
console.warn(`[pimage] resize/convert fallback for ${rawPath}:`, resizeErr.message);
|
||||
}
|
||||
|
||||
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/pimage error:', err);
|
||||
return res.status(500).json({ Message: 'Internal server error' });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user