u
This commit is contained in:
@@ -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/v1/category.md`](routes/v1/category.md) | `GET /v1/category` |
|
||||||
| [`routes/pos/client.md`](routes/pos/client.md) | `GET /v1/client` |
|
| [`routes/pos/client.md`](routes/pos/client.md) | `GET /v1/client` |
|
||||||
| [`routes/pos/init.md`](routes/pos/init.md) | `GET /v1/init` |
|
| [`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/v1/product.md`](routes/v1/product.md) | `GET /v1/product` |
|
||||||
| [`routes/vfs/_*path.md`](routes/vfs/_*path.md) | `GET /vfs/{*path}` |
|
| [`routes/vfs/_*path.md`](routes/vfs/_*path.md) | `GET /vfs/{*path}` |
|
||||||
| [`routes/warehouse/list.md`](routes/warehouse/list.md) | `GET /warehouse` |
|
| [`routes/warehouse/list.md`](routes/warehouse/list.md) | `GET /warehouse` |
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Dash API Routes Overview (All)
|
# Dash API Routes Overview (All)
|
||||||
|
|
||||||
Overview of all 182 API endpoints across all modules.
|
Overview of all 183 API endpoints across all modules.
|
||||||
|
|
||||||
### Notation
|
### Notation
|
||||||
|
|
||||||
@@ -569,6 +569,9 @@ _none_
|
|||||||
#### POS sync initialization counts — `GET /v1/init`
|
#### POS sync initialization counts — `GET /v1/init`
|
||||||
- Params: `query: lastChangedProduct?: int, lastChangedCategory?: int, lastChangedCustomer?: int, lastChangedCustomerGroup?: int, lastChangedCompositeProduct?: int, lastChangedDeletedEntity?: int`
|
- 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 }`
|
- 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`
|
#### JTL-POS product catalog synchronization — `GET /v1/product`
|
||||||
- Params: `query: lastChangedProduct?: str, limit?: int`
|
- Params: `query: lastChangedProduct?: str, limit?: int`
|
||||||
- Returns: `200 obj[]`
|
- Returns: `200 obj[]`
|
||||||
|
|||||||
33
docs/api/routes/pos/pimage.md
Normal file
33
docs/api/routes/pos/pimage.md
Normal file
@@ -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`._
|
||||||
@@ -36,6 +36,7 @@ const OMITTED_ROUTES = new Set([
|
|||||||
'GET /v1/category',
|
'GET /v1/category',
|
||||||
'GET /v1/client',
|
'GET /v1/client',
|
||||||
'GET /v1/init',
|
'GET /v1/init',
|
||||||
|
'GET /v1/pimage',
|
||||||
'GET /v1/product',
|
'GET /v1/product',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ let httpsServer;
|
|||||||
let starting;
|
let starting;
|
||||||
let startedAt;
|
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) {
|
function isAllowedOrigin(origin) {
|
||||||
if (!origin) return false;
|
if (!origin) return false;
|
||||||
if (env.corsOrigins.includes(origin)) return true;
|
if (env.corsOrigins.includes(origin)) return true;
|
||||||
@@ -54,6 +56,9 @@ function isAllowedOrigin(origin) {
|
|||||||
if (u.hostname === 'quixpos.com' || u.hostname.endsWith('.quixpos.com')) {
|
if (u.hostname === 'quixpos.com' || u.hostname.endsWith('.quixpos.com')) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (PRIVATE_HOST_REGEX.test(u.hostname)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore invalid URL
|
// ignore invalid URL
|
||||||
}
|
}
|
||||||
@@ -73,6 +78,26 @@ function corsMiddleware(req, res, next) {
|
|||||||
return 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)) {
|
if (origin && isAllowedOrigin(origin)) {
|
||||||
res.setHeader('Access-Control-Allow-Origin', origin);
|
res.setHeader('Access-Control-Allow-Origin', origin);
|
||||||
res.setHeader('Vary', 'Origin');
|
res.setHeader('Vary', 'Origin');
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { getStore } from './store.js';
|
import { getStore, getProvider } from './store.js';
|
||||||
|
|
||||||
export async function list(googleAuthId) {
|
export async function list(googleAuthId) {
|
||||||
const store = await getStore();
|
const store = await getStore();
|
||||||
@@ -100,4 +100,79 @@ export async function setMaxKnownTombstoneId(id, maxknowntombstoneid) {
|
|||||||
return store.posterminal.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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { handleClient } from './routes/client.js';
|
|||||||
import { handleInit } from './routes/init.js';
|
import { handleInit } from './routes/init.js';
|
||||||
import { handleCategory } from './routes/category.js';
|
import { handleCategory } from './routes/category.js';
|
||||||
import { handleProduct } from './routes/product.js';
|
import { handleProduct } from './routes/product.js';
|
||||||
|
import { handlePimage } from './routes/pimage.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -17,5 +18,9 @@ router.get('/init', posAuthMiddleware, handleInit);
|
|||||||
router.get('/category', posAuthMiddleware, handleCategory);
|
router.get('/category', posAuthMiddleware, handleCategory);
|
||||||
router.get('/product', posAuthMiddleware, handleProduct);
|
router.get('/product', posAuthMiddleware, handleProduct);
|
||||||
|
|
||||||
|
// POS image delivery
|
||||||
|
router.get('/pimage', handlePimage);
|
||||||
|
router.get('/cimage', handlePimage);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
||||||
|
|||||||
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