diff --git a/docs/api/README.md b/docs/api/README.md index f3ebe4e..4ea7ede 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -205,6 +205,7 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route | [`routes/v1/category.md`](routes/v1/category.md) | `GET /v1/category` | | [`routes/pos/client.md`](routes/pos/client.md) | `GET /v1/client` | | [`routes/pos/init.md`](routes/pos/init.md) | `GET /v1/init` | +| [`routes/pos/pimage.md`](routes/pos/pimage.md) | `GET /v1/pimage` | | [`routes/v1/product.md`](routes/v1/product.md) | `GET /v1/product` | | [`routes/vfs/_*path.md`](routes/vfs/_*path.md) | `GET /vfs/{*path}` | | [`routes/warehouse/list.md`](routes/warehouse/list.md) | `GET /warehouse` | diff --git a/docs/api/routes-all.md b/docs/api/routes-all.md index c3a46a6..b252735 100644 --- a/docs/api/routes-all.md +++ b/docs/api/routes-all.md @@ -1,6 +1,6 @@ # Dash API Routes Overview (All) -Overview of all 182 API endpoints across all modules. +Overview of all 183 API endpoints across all modules. ### Notation @@ -569,6 +569,9 @@ _none_ #### POS sync initialization counts — `GET /v1/init` - Params: `query: lastChangedProduct?: int, lastChangedCategory?: int, lastChangedCustomer?: int, lastChangedCustomerGroup?: int, lastChangedCompositeProduct?: int, lastChangedDeletedEntity?: int` - Returns: `200 { version: str, product_count: str, category_count: str, customer_count: str, customerGroup_count: str, compositeProduct_count: str, configurationGroup_count: str, configurationItem_count: str, deletedEntity_count: str, max_orderId_count: str }` +#### POS product image delivery — `GET /v1/pimage` +- Params: `query: path: str, w?: int, h?: int, format?: str` +- Returns: `200 jpeg` #### JTL-POS product catalog synchronization — `GET /v1/product` - Params: `query: lastChangedProduct?: str, limit?: int` - Returns: `200 obj[]` diff --git a/docs/api/routes/pos/pimage.md b/docs/api/routes/pos/pimage.md new file mode 100644 index 0000000..0d754b7 --- /dev/null +++ b/docs/api/routes/pos/pimage.md @@ -0,0 +1,33 @@ +# `GET /v1/pimage` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +POS product image delivery + +Returns product image by hash (`path` query param) scaled to preview size. + + +## Query parameters + +| Param | Required | Type | Description | +|-------|----------|------|-------------| +| `path` | yes | string | SHA-256 hash of the product image | +| `w` | no | integer | Max width | +| `h` | no | integer | Max height | +| `format` | no | string | Optional target format (defaults to jpeg/png or client Accept preference) | + +## Response `200` + +Resized image binary + +## Errors + +| Status | Description | +|--------|-------------| +| 304 | Image not modified | +| 400 | Missing required query parameter 'path' | +| 404 | Image not found | + +--- + +_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._ diff --git a/scripts/generateApiDocs.js b/scripts/generateApiDocs.js index bcbd12e..57d24b4 100644 --- a/scripts/generateApiDocs.js +++ b/scripts/generateApiDocs.js @@ -36,6 +36,7 @@ const OMITTED_ROUTES = new Set([ 'GET /v1/category', 'GET /v1/client', 'GET /v1/init', + 'GET /v1/pimage', 'GET /v1/product', ]); diff --git a/src/api/index.js b/src/api/index.js index aedf875..7dbbca5 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -46,6 +46,8 @@ let httpsServer; let starting; let startedAt; +const PRIVATE_HOST_REGEX = /^(127\.0\.0\.1|localhost|192\.168\.\d{1,3}\.\d{1,3}|10\.\d{1,3}\.\d{1,3}\.\d{1,3}|172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3})$/; + function isAllowedOrigin(origin) { if (!origin) return false; if (env.corsOrigins.includes(origin)) return true; @@ -54,6 +56,9 @@ function isAllowedOrigin(origin) { if (u.hostname === 'quixpos.com' || u.hostname.endsWith('.quixpos.com')) { return true; } + if (PRIVATE_HOST_REGEX.test(u.hostname)) { + return true; + } } catch { // ignore invalid URL } @@ -73,6 +78,26 @@ function corsMiddleware(req, res, next) { return next(); } + if (req.path.startsWith('/v1')) { + if (origin) { + res.setHeader('Access-Control-Allow-Origin', origin); + res.setHeader('Vary', 'Origin'); + res.setHeader('Access-Control-Allow-Credentials', 'true'); + } else { + res.setHeader('Access-Control-Allow-Origin', '*'); + } + const reqHeaders = req.headers['access-control-request-headers']; + res.setHeader( + 'Access-Control-Allow-Headers', + reqHeaders || 'Content-Type, Authorization, x-auth-token, authtoken, auth-token, token, x-token' + ); + res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,PATCH,DELETE,OPTIONS'); + if (req.method === 'OPTIONS') { + return res.sendStatus(204); + } + return next(); + } + if (origin && isAllowedOrigin(origin)) { res.setHeader('Access-Control-Allow-Origin', origin); res.setHeader('Vary', 'Origin'); diff --git a/src/db/posterminal.js b/src/db/posterminal.js index 9bdccd2..ec82d4b 100644 --- a/src/db/posterminal.js +++ b/src/db/posterminal.js @@ -1,4 +1,4 @@ -import { getStore } from './store.js'; +import { getStore, getProvider } from './store.js'; export async function list(googleAuthId) { const store = await getStore(); @@ -100,4 +100,79 @@ export async function setMaxKnownTombstoneId(id, maxknowntombstoneid) { return store.posterminal.setMaxKnownTombstoneId(id, maxknowntombstoneid); } +/** + * Retrieves an image buffer by hash for POS terminals. + * @param {string} hash - SHA-256 hash or thumbhash + * @param {object} [options] + * @param {number} [options.width=200] + * @param {number} [options.height=200] + * @returns {Promise<{buffer: Buffer, w: number, h: number, fullHash: string} | null>} + */ +export async function getPosImageByHash(hash, { width = 200, height = 200 } = {}) { + if (!hash || typeof hash !== 'string') return null; + + const cleanHash = hash.trim(); + if (!cleanHash) return null; + + const dotIndex = cleanHash.lastIndexOf('.'); + const baseHash = dotIndex > 0 ? cleanHash.slice(0, dotIndex) : cleanHash; + + const provider = await getProvider(); + const isSqlite = provider?.dialect === 'sqlite3'; + + if (isSqlite) { + const result = await provider.query( + 'SELECT w, h, size, hash, thumbw, thumbh, thumbhash FROM pic WHERE hash = @hash OR thumbhash = @hash OR hash = @baseHash OR thumbhash = @baseHash LIMIT 1', + { hash: cleanHash, baseHash } + ); + const row = result.rows[0]; + if (!row) return null; + + const fullHash = row.hash; + const useThumb = width <= 200 && height <= 200; + let buffer = null; + + if (useThumb) { + buffer = await provider.readPicThumb?.(fullHash); + } + if (!buffer) { + buffer = await provider.readPicFull?.(fullHash); + } + if (!buffer && !useThumb) { + buffer = await provider.readPicThumb?.(fullHash); + } + if (!buffer) return null; + + return { + buffer: Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer), + w: row.w, + h: row.h, + fullHash, + }; + } + + const result = await provider.query( + 'SELECT TOP 1 w, h, size, hash, thumbw, thumbh, thumbhash, fullbinary, thumbbinary FROM pic WHERE hash = @hash OR thumbhash = @hash OR hash = @baseHash OR thumbhash = @baseHash', + { hash: cleanHash, baseHash } + ); + const row = result.rows[0]; + if (!row) return null; + + const fullHash = row.hash; + const useThumb = width <= 200 && height <= 200; + const rawBuffer = useThumb + ? (row.thumbbinary || row.fullbinary) + : (row.fullbinary || row.thumbbinary); + + if (!rawBuffer) return null; + + return { + buffer: Buffer.isBuffer(rawBuffer) ? rawBuffer : Buffer.from(rawBuffer), + w: row.w, + h: row.h, + fullHash, + }; +} + + diff --git a/src/v1/index.js b/src/v1/index.js index ef3e466..f6765ce 100644 --- a/src/v1/index.js +++ b/src/v1/index.js @@ -4,6 +4,7 @@ import { handleClient } from './routes/client.js'; import { handleInit } from './routes/init.js'; import { handleCategory } from './routes/category.js'; import { handleProduct } from './routes/product.js'; +import { handlePimage } from './routes/pimage.js'; const router = Router(); @@ -17,5 +18,9 @@ router.get('/init', posAuthMiddleware, handleInit); router.get('/category', posAuthMiddleware, handleCategory); router.get('/product', posAuthMiddleware, handleProduct); +// POS image delivery +router.get('/pimage', handlePimage); +router.get('/cimage', handlePimage); + export default router; diff --git a/src/v1/routes/pimage.js b/src/v1/routes/pimage.js new file mode 100644 index 0000000..1822862 --- /dev/null +++ b/src/v1/routes/pimage.js @@ -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' }); + } +}