This commit is contained in:
seb
2026-10-24 02:57:32 +02:00
parent ebbeb6437a
commit 9929e322a3
6 changed files with 174 additions and 13 deletions

View File

@@ -275,6 +275,7 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route
| [`routes/unit/list.md`](routes/unit/list.md) | `GET /unit` |
| [`routes/unit/upsert.md`](routes/unit/upsert.md) | `PUT /unit` |
| [`routes/v1/category.md`](routes/v1/category.md) | `GET /v1/category` |
| [`routes/pos/cimage.md`](routes/pos/cimage.md) | `GET /v1/cimage` |
| [`routes/pos/client.md`](routes/pos/client.md) | `GET /v1/client` |
| [`routes/v1/customer.md`](routes/v1/customer.md) | `GET /v1/customer` |
| [`routes/v1/customergroup.md`](routes/v1/customergroup.md) | `GET /v1/customergroup` |

View File

@@ -1,6 +1,6 @@
# Dash API Routes Overview (All)
Overview of all 268 API endpoints across all modules.
Overview of all 269 API endpoints across all modules.
### Notation
@@ -924,6 +924,9 @@ _none_
#### JTL-POS category synchronization — `GET /v1/category`
- Params: `query: lastChangedCategory?: str, limit?: int`
- Returns: `200 {}[]`
#### POS category image delivery — `GET /v1/cimage`
- Params: `query: path: str, w?: int, h?: int, format?: str`
- Returns: `200 jpeg`
#### POS discovery and pairing — `GET /v1/client`
- Params: `query: authCode: str, name?: str`
- Returns: `200 { authCode: str|null, authToken: str|null, certificateFingerprint: str, certificateSerialNumber: str, mandantId: str, mandantName: str|null, mandantDatabase: str|null, serverFingerprint: str|null, name: str|null, serverTimestamp: str }`

View File

@@ -0,0 +1,33 @@
# `GET /v1/cimage`
Protected. Requires a valid session cookie (`credentials: 'include'`).
POS category image delivery
Returns category image by hash (`path` query param) scaled to preview size with 3:4 portrait ratio.
## Query parameters
| Param | Required | Type | Description |
|-------|----------|------|-------------|
| `path` | yes | string | SHA-256 hash of the category 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`._

View File

@@ -41,6 +41,7 @@ const OMITTED_ROUTES = new Set([
'GET /shipment/{id}/file/{fileId}',
'GET /thumb/{fullHash}',
'GET /v1/category',
'GET /v1/cimage',
'GET /v1/client',
'GET /v1/customer',
'GET /v1/customergroup',

View File

@@ -7,7 +7,7 @@ import { handleCustomerGroup } from './routes/customergroup.js';
import { handleCustomer } from './routes/customer.js';
import { handleProduct } from './routes/product.js';
import { handleDeletedEntity } from './routes/deletedentity.js';
import { handlePimage } from './routes/pimage.js';
import { handlePimage, handleCimage } from './routes/pimage.js';
import { handleOrder } from './routes/order.js';
const router = Router();
@@ -30,7 +30,7 @@ router.post('/order', posAuthMiddleware, handleOrder);
// POS image delivery
router.get('/pimage', handlePimage);
router.get('/cimage', handlePimage);
router.get('/cimage', handleCimage);
export default router;

View File

@@ -1,7 +1,10 @@
import sharp from 'sharp';
import * as db from '../../db/posterminal.js';
const MAX_DIMENSION = 200;
const PIMAGE_DEFAULT_W = 200;
const PIMAGE_DEFAULT_H = 200;
const CIMAGE_DEFAULT_W = 150;
const CIMAGE_DEFAULT_H = 200;
/**
* @openapi
@@ -85,16 +88,128 @@ const MAX_DIMENSION = 200;
* 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 with 3:4 portrait ratio.
* 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: w
* required: false
* schema:
* type: integer
* default: 150
* 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 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 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;
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)), 2000);
targetH = Math.min(Math.round((targetW * 4) / 3), 2000);
} else if (hasH && !hasW) {
targetH = Math.min(Math.round(Number(req.query.h)), 2000);
targetW = Math.min(Math.round((targetH * 3) / 4), 2000);
} else if (hasW && hasH) {
targetW = Math.min(Math.round(Number(req.query.w)), 2000);
targetH = Math.min(Math.round(Number(req.query.h)), 2000);
} else {
targetW = CIMAGE_DEFAULT_W;
targetH = CIMAGE_DEFAULT_H;
}
} else {
const widthParam = hasW ? Number(req.query.w) : PIMAGE_DEFAULT_W;
const heightParam = hasH ? Number(req.query.h) : PIMAGE_DEFAULT_H;
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 });
@@ -102,7 +217,7 @@ export async function handlePimage(req, res) {
return res.status(404).json({ Message: `No image was found for path '${rawPath}'.` });
}
const etag = `"${image.fullHash}-${targetW}x${targetH}"`;
const etag = `"${image.fullHash}-${targetW}x${targetH}${isCimage ? '-c' : ''}"`;
const ifNoneMatch = req.headers['if-none-match'];
if (ifNoneMatch === etag || ifNoneMatch === image.fullHash || ifNoneMatch === `"${image.fullHash}"`) {
res.setHeader('ETag', etag);
@@ -113,11 +228,19 @@ export async function handlePimage(req, res) {
const imageSharp = sharp(image.buffer);
const meta = await imageSharp.metadata();
let pipeline = imageSharp
.resize(targetW, targetH, {
const resizeOptions = isCimage
? {
fit: 'contain',
background: '#ffffff',
withoutEnlargement: true,
}
: {
fit: 'inside',
withoutEnlargement: true,
})
};
let pipeline = imageSharp
.resize(targetW, targetH, resizeOptions)
.flatten({ background: '#ffffff' });
const accept = String(req.headers.accept || '');
@@ -146,7 +269,7 @@ export async function handlePimage(req, res) {
res.setHeader('ETag', etag);
return res.status(200).send(buffer);
} catch (err) {
console.error('POS /v1/pimage error:', err);
console.error(`POS /v1/${isCimage ? 'cimage' : 'pimage'} error:`, err);
return res.status(500).json({ Message: 'Internal server error' });
}
}