Compare commits

..

4 Commits

Author SHA1 Message Date
seb
369fbb2a70 u 2026-10-24 06:19:34 +02:00
seb
464021318a u 2026-10-24 05:13:13 +02:00
seb
9929e322a3 u 2026-10-24 02:57:32 +02:00
seb
ebbeb6437a u 2026-10-24 02:31:35 +02:00
12 changed files with 446 additions and 39 deletions

View File

@@ -115,6 +115,7 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route
| [`routes/category/_id/attributevalue.put.md`](routes/category/_id/attributevalue.put.md) | `PUT /category/{id}/attributevalue` | | [`routes/category/_id/attributevalue.put.md`](routes/category/_id/attributevalue.put.md) | `PUT /category/{id}/attributevalue` |
| [`routes/category/delete-image.md`](routes/category/delete-image.md) | `DELETE /category/{id}/image` | | [`routes/category/delete-image.md`](routes/category/delete-image.md) | `DELETE /category/{id}/image` |
| [`routes/category/add-image.md`](routes/category/add-image.md) | `POST /category/{id}/image` | | [`routes/category/add-image.md`](routes/category/add-image.md) | `POST /category/{id}/image` |
| [`routes/category/_id/image/generate.md`](routes/category/_id/image/generate.md) | `POST /category/{id}/image/generate` |
| [`routes/category/_id/property.get.md`](routes/category/_id/property.get.md) | `GET /category/{id}/property` | | [`routes/category/_id/property.get.md`](routes/category/_id/property.get.md) | `GET /category/{id}/property` |
| [`routes/category/_id/property.put.md`](routes/category/_id/property.put.md) | `PUT /category/{id}/property` | | [`routes/category/_id/property.put.md`](routes/category/_id/property.put.md) | `PUT /category/{id}/property` |
| [`routes/category/_id/property/_propertyId.md`](routes/category/_id/property/_propertyId.md) | `DELETE /category/{id}/property/{propertyId}` | | [`routes/category/_id/property/_propertyId.md`](routes/category/_id/property/_propertyId.md) | `DELETE /category/{id}/property/{propertyId}` |
@@ -275,6 +276,7 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route
| [`routes/unit/list.md`](routes/unit/list.md) | `GET /unit` | | [`routes/unit/list.md`](routes/unit/list.md) | `GET /unit` |
| [`routes/unit/upsert.md`](routes/unit/upsert.md) | `PUT /unit` | | [`routes/unit/upsert.md`](routes/unit/upsert.md) | `PUT /unit` |
| [`routes/v1/category.md`](routes/v1/category.md) | `GET /v1/category` | | [`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/pos/client.md`](routes/pos/client.md) | `GET /v1/client` |
| [`routes/v1/customer.md`](routes/v1/customer.md) | `GET /v1/customer` | | [`routes/v1/customer.md`](routes/v1/customer.md) | `GET /v1/customer` |
| [`routes/v1/customergroup.md`](routes/v1/customergroup.md) | `GET /v1/customergroup` | | [`routes/v1/customergroup.md`](routes/v1/customergroup.md) | `GET /v1/customergroup` |
@@ -314,7 +316,7 @@ Clients subscribe via [`GET /events`](routes/events.md) (`text/event-stream`). A
| `attributename` | `POST /attributename`, `PUT /attributename/order`, `DELETE /attributename/{id}`, `PATCH /attributename/{id}`, `DELETE /attributevalue/{id}/image`, `POST /attributevalue/{id}/image` | | `attributename` | `POST /attributename`, `PUT /attributename/order`, `DELETE /attributename/{id}`, `PATCH /attributename/{id}`, `DELETE /attributevalue/{id}/image`, `POST /attributevalue/{id}/image` |
| `brand` | `POST /brand`, `DELETE /brand/{id}`, `PATCH /brand/{id}`, `DELETE /brand/{id}/image`, `POST /brand/{id}/image` | | `brand` | `POST /brand`, `DELETE /brand/{id}`, `PATCH /brand/{id}`, `DELETE /brand/{id}/image`, `POST /brand/{id}/image` |
| `carrier` | `POST /carrier`, `PUT /carrier/order`, `DELETE /carrier/{id}`, `PATCH /carrier/{id}` | | `carrier` | `POST /carrier`, `PUT /carrier/order`, `DELETE /carrier/{id}`, `PATCH /carrier/{id}` |
| `category` | `POST /category`, `PUT /category/order`, `DELETE /category/{id}`, `PATCH /category/{id}`, `DELETE /category/{id}/image`, `POST /category/{id}/image` | | `category` | `POST /category`, `PUT /category/order`, `DELETE /category/{id}`, `PATCH /category/{id}`, `DELETE /category/{id}/image`, `POST /category/{id}/image`, `POST /category/{id}/image/generate` |
| `delivery` | `DELETE /delivery/{id}/file/{fileId}`, `POST /delivery/{id}/pdf`, `POST /stock/accept` | | `delivery` | `DELETE /delivery/{id}/file/{fileId}`, `POST /delivery/{id}/pdf`, `POST /stock/accept` |
| `labeltemplate` | `POST /labeltemplate`, `DELETE /labeltemplate/{id}`, `PATCH /labeltemplate/{id}`, `PUT /labeltemplate/{id}` | | `labeltemplate` | `POST /labeltemplate`, `DELETE /labeltemplate/{id}`, `PATCH /labeltemplate/{id}`, `PUT /labeltemplate/{id}` |
| `ledger` | `DELETE /shipment/{id}`, `POST /stock/accept`, `POST /stock/adjust`, `POST /stock/ship` | | `ledger` | `DELETE /shipment/{id}`, `POST /stock/accept`, `POST /stock/adjust`, `POST /stock/ship` |

View File

@@ -1,6 +1,6 @@
# Dash API Routes Overview (All) # Dash API Routes Overview (All)
Overview of all 268 API endpoints across all modules. Overview of all 270 API endpoints across all modules.
### Notation ### Notation
@@ -329,6 +329,9 @@ _none_
#### Add category image — `POST /category/{id}/image` #### Add category image — `POST /category/{id}/image`
- Params: `id: int, image/avif` - Params: `id: int, image/avif`
- Returns: `201 { category: { id: int, thumbW: int, thumbH: int, thumbHash: str } }` - Returns: `201 { category: { id: int, thumbW: int, thumbH: int, thumbHash: str } }`
#### Generate category mosaic image from contents — `POST /category/{id}/image/generate`
- Params: `id: int, body?: { size?: int }`
- Returns: `200 { category: { id: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str, size: int, grid: str, articleCount: int } }`
#### List category properties — `GET /category/{id}/property` #### List category properties — `GET /category/{id}/property`
- Params: `id: int` - Params: `id: int`
- Returns: `200 { properties: { id: int, propertyId: int, name: str, exclusive: bool, value: str, sort: int, categoryId?: int }[] }` - Returns: `200 { properties: { id: int, propertyId: int, name: str, exclusive: bool, value: str, sort: int, categoryId?: int }[] }`
@@ -924,6 +927,9 @@ _none_
#### JTL-POS category synchronization — `GET /v1/category` #### JTL-POS category synchronization — `GET /v1/category`
- Params: `query: lastChangedCategory?: str, limit?: int` - Params: `query: lastChangedCategory?: str, limit?: int`
- Returns: `200 {}[]` - Returns: `200 {}[]`
#### POS category image delivery — `GET /v1/cimage`
- Params: `query: path: str, size?: int, w?: int, h?: int, format?: str`
- Returns: `200 jpeg`
#### POS discovery and pairing — `GET /v1/client` #### POS discovery and pairing — `GET /v1/client`
- Params: `query: authCode: str, name?: str` - 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 }` - 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 }`
@@ -943,7 +949,7 @@ _none_
- Params: `query: mandantId?: str, body: { orders?: {}[] }` - Params: `query: mandantId?: str, body: { orders?: {}[] }`
- Returns: `200 { status: str, externalOrderId: str, message: str }[]` - Returns: `200 { status: str, externalOrderId: str, message: str }[]`
#### POS product image delivery — `GET /v1/pimage` #### POS product image delivery — `GET /v1/pimage`
- Params: `query: path: str, w?: int, h?: int, format?: str` - Params: `query: path: str, size?: int, w?: int, h?: int, format?: str`
- Returns: `200 jpeg` - 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`

View File

@@ -1,6 +1,6 @@
# Dash API Routes Overview # Dash API Routes Overview
Overview of all 189 API endpoints across all modules. Overview of all 190 API endpoints across all modules.
### Notation ### Notation
@@ -253,6 +253,9 @@ _none_
#### Delete category image — `DELETE /category/{id}/image` #### Delete category image — `DELETE /category/{id}/image`
- Params: `id: int` - Params: `id: int`
- Returns: `200 { category: { id: int, thumbW: int|null, thumbH: int|null, thumbHash: str|null } }` - Returns: `200 { category: { id: int, thumbW: int|null, thumbH: int|null, thumbHash: str|null } }`
#### Generate category mosaic image from contents — `POST /category/{id}/image/generate`
- Params: `id: int, body?: { size?: int }`
- Returns: `200 { category: { id: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str, size: int, grid: str, articleCount: int } }`
#### List category properties — `GET /category/{id}/property` #### List category properties — `GET /category/{id}/property`
- Params: `id: int` - Params: `id: int`
- Returns: `200 { properties: { id: int, propertyId: int, name: str, exclusive: bool, value: str, sort: int, categoryId?: int }[] }` - Returns: `200 { properties: { id: int, propertyId: int, name: str, exclusive: bool, value: str, sort: int, categoryId?: int }[] }`

View File

@@ -0,0 +1,64 @@
# `POST /category/{id}/image/generate`
Protected. Requires a valid session cookie (`credentials: 'include'`).
Generate category mosaic image from contents
Generates a 2x2, 3x3, or 4x4 mosaic image from the category's and its subcategories'
articles (grouped by subcategory) and sets it as the new main picture for the category.
If size is not specified, defaults to 3 (3x3), falling back to 2 (2x2) if fewer than 7 images are available.
## Path parameters
| Param | Required | Type | Description |
|-------|----------|------|-------------|
| `id` | yes | integer | Category id |
## Body (JSON) — _optional_
| Field | Required | Type | Description |
|-------|----------|------|-------------|
| `size` | no | integer | Mosaic grid size (2 for 2x2, 3 for 3x3, 4 for 4x4). Defaults to 3x3 (or 2x2 if < 7 images). |
```json
{
"size": 1
}
```
## Signals
On success, emits SSE signal: `category` (see [`GET /events`](../events.md)).
## Response `200`
Mosaic image generated and linked as category main picture
```json
{
"category": {
"id": 1,
"fullHash": "…",
"thumbW": 1,
"thumbH": 1,
"thumbHash": "…",
"size": 1,
"grid": "…",
"articleCount": 1
}
}
```
## Errors
| Status | Description |
|--------|-------------|
| 400 | Invalid category ID or size — `{"error":"…"}` |
| 401 | Unauthorized — `{"error":"…"}` |
| 403 | Not authorized — `{"error":"…"}` |
| 404 | Category not found or no article images found — `{"error":"…"}` |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -0,0 +1,34 @@
# `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 (padded to 1:1 with adaptive aspect-ratio borders: thinner borders for vertical images, wider borders for horizontal images).
## Query parameters
| Param | Required | Type | Description |
|-------|----------|------|-------------|
| `path` | yes | string | SHA-256 hash of the category image |
| `size` | no | integer | Max dimension (width & height) |
| `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

@@ -12,6 +12,7 @@ Returns product image by hash (`path` query param) scaled to preview size.
| Param | Required | Type | Description | | Param | Required | Type | Description |
|-------|----------|------|-------------| |-------|----------|------|-------------|
| `path` | yes | string | SHA-256 hash of the product image | | `path` | yes | string | SHA-256 hash of the product image |
| `size` | no | integer | Max dimension (width & height) |
| `w` | no | integer | Max width | | `w` | no | integer | Max width |
| `h` | no | integer | Max height | | `h` | no | integer | Max height |
| `format` | no | string | Optional target format (defaults to jpeg/png or client Accept preference) | | `format` | no | string | Optional target format (defaults to jpeg/png or client Accept preference) |

BIN
scratch/curl-pimage.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

View File

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

View File

@@ -907,4 +907,122 @@ export function register(app, basePath = '') {
}); });
// #endregion // #endregion
/**
* @openapi
* /category/{id}/image/generate:
* post:
* tags: [Category]
* summary: Generate category mosaic image from contents
* description: |
* Generates a 2x2, 3x3, or 4x4 mosaic image from the category's and its subcategories'
* articles (grouped by subcategory) and sets it as the new main picture for the category.
* If size is not specified, defaults to 3 (3x3), falling back to 2 (2x2) if fewer than 7 images are available.
* security:
* - cookieAuth: []
* x-signals: [category]
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* description: Category id
* requestBody:
* required: false
* content:
* application/json:
* schema:
* type: object
* properties:
* size:
* type: integer
* enum: [2, 3, 4]
* description: Mosaic grid size (2 for 2x2, 3 for 3x3, 4 for 4x4). Defaults to 3x3 (or 2x2 if < 7 images).
* responses:
* 200:
* description: Mosaic image generated and linked as category main picture
* content:
* application/json:
* schema:
* type: object
* properties:
* category:
* type: object
* properties:
* id:
* type: integer
* fullHash:
* type: string
* thumbW:
* type: integer
* thumbH:
* type: integer
* thumbHash:
* type: string
* size:
* type: integer
* grid:
* type: string
* articleCount:
* type: integer
* 400:
* description: Invalid category ID or size
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 401:
* description: Unauthorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 403:
* description: Not authorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 404:
* description: Category not found or no article images found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
// #region POST /category/:id/image/generate
app.post(
`${basePath}/category/:id/image/generate`,
express.json(),
async (req, res) => {
const parsed = parsePositiveInt(req.params.id, 'id');
if (parsed.error) {
return res.status(400).json({ error: parsed.error });
}
const rawSize = req.body?.size;
let size = undefined;
if (rawSize !== undefined && rawSize !== null && rawSize !== '') {
size = Number(rawSize);
if (![2, 3, 4].includes(size)) {
return res.status(400).json({ error: 'Invalid size, must be 2, 3, or 4' });
}
}
try {
const category = await db.generateImage(req.user.id, parsed.id, { size });
emitCategorySignal(req);
res.status(200).json({ category });
} catch (error) {
const status = categoryErrorStatus(error);
if (status === 500) console.error('Generate category image failed:', error);
res.status(status).json({ error: error.message });
}
},
);
// #endregion
} }

View File

@@ -44,3 +44,9 @@ export async function deleteImage(googleAuthId, categoryId) {
const store = await getStore(); const store = await getStore();
return store.category.deleteImage(googleAuthId, categoryId); return store.category.deleteImage(googleAuthId, categoryId);
} }
export async function generateImage(googleAuthId, categoryId, options) {
const store = await getStore();
return store.category.generateImage(googleAuthId, categoryId, options);
}

View File

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

View File

@@ -1,7 +1,10 @@
import sharp from 'sharp'; import sharp from 'sharp';
import * as db from '../../db/posterminal.js'; 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 = 200;
const CIMAGE_DEFAULT_H = 200;
/** /**
* @openapi * @openapi
@@ -21,6 +24,13 @@ const MAX_DIMENSION = 200;
* description: SHA-256 hash of the product image * description: SHA-256 hash of the product image
* example: "71de4085ce2b1a53317832421009d844eec0c36a3ec071a31661cc2a212e15e6" * example: "71de4085ce2b1a53317832421009d844eec0c36a3ec071a31661cc2a212e15e6"
* - in: query * - in: query
* name: size
* required: false
* schema:
* type: integer
* default: 200
* description: Max dimension (width & height)
* - in: query
* name: w * name: w
* required: false * required: false
* schema: * schema:
@@ -85,16 +95,142 @@ const MAX_DIMENSION = 200;
* example: "No image was found for path 'abc'." * example: "No image was found for path 'abc'."
*/ */
export async function handlePimage(req, res) { 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 adaptive aspect-ratio borders: thinner borders for vertical images, wider borders for horizontal images).
* 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
* 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 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() : ''; const rawPath = typeof req.query.path === 'string' ? req.query.path.trim() : '';
if (!rawPath) { if (!rawPath) {
return res.status(400).json({ Message: "Missing required query parameter 'path'." }); return res.status(400).json({ Message: "Missing required query parameter 'path'." });
} }
const widthParam = req.query.w !== undefined ? Number(req.query.w) : MAX_DIMENSION; const hasSize = req.query.size !== undefined && Number.isFinite(Number(req.query.size)) && Number(req.query.size) > 0;
const heightParam = req.query.h !== undefined ? Number(req.query.h) : MAX_DIMENSION; const hasW = req.query.w !== undefined && Number.isFinite(Number(req.query.w)) && Number(req.query.w) > 0;
const targetW = Number.isFinite(widthParam) && widthParam > 0 ? Math.min(Math.round(widthParam), 2000) : MAX_DIMENSION; const hasH = req.query.h !== undefined && Number.isFinite(Number(req.query.h)) && Number(req.query.h) > 0;
const targetH = Number.isFinite(heightParam) && heightParam > 0 ? Math.min(Math.round(heightParam), 2000) : MAX_DIMENSION;
let targetW;
let targetH;
if (isCimage) {
if (hasW && !hasH) {
targetW = Math.min(Math.round(Number(req.query.w)), 2000);
targetH = targetW;
} else if (hasH && !hasW) {
targetH = Math.min(Math.round(Number(req.query.h)), 2000);
targetW = targetH;
} 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 if (hasSize) {
const s = Math.min(Math.round(Number(req.query.size)), 2000);
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 { try {
const image = await db.getPosImageByHash(rawPath, { width: targetW, height: targetH }); const image = await db.getPosImageByHash(rawPath, { width: targetW, height: targetH });
@@ -102,7 +238,7 @@ export async function handlePimage(req, res) {
return res.status(404).json({ Message: `No image was found for path '${rawPath}'.` }); 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-v5' : ''}"`;
const ifNoneMatch = req.headers['if-none-match']; const ifNoneMatch = req.headers['if-none-match'];
if (ifNoneMatch === etag || ifNoneMatch === image.fullHash || ifNoneMatch === `"${image.fullHash}"`) { if (ifNoneMatch === etag || ifNoneMatch === image.fullHash || ifNoneMatch === `"${image.fullHash}"`) {
res.setHeader('ETag', etag); res.setHeader('ETag', etag);
@@ -110,47 +246,83 @@ export async function handlePimage(req, res) {
return res.status(304).end(); return res.status(304).end();
} }
let buffer = image.buffer; const imageSharp = sharp(image.buffer);
let contentType = 'image/jpeg';
try { let pipeline;
const imageSharp = sharp(buffer); if (isCimage) {
const meta = await imageSharp.metadata(); const meta = await imageSharp.metadata();
const origW = meta.width || targetW;
const origH = meta.height || targetH;
const r = origW / origH;
let pipeline = imageSharp.resize(targetW, targetH, { // Adaptive fill ratio:
fit: 'inside', // Vertical images (r < 1) get much less border (height fills up to ~94% of tile).
withoutEnlargement: true, // Horizontal images (r > 1) get a bit more border (width fills down to ~65% of tile).
}); // Square images (r = 1) get ~75% fill (~12.5% border on all sides).
let fillRatio;
const accept = String(req.headers.accept || ''); if (r < 1.0) {
const formatParam = typeof req.query.format === 'string' ? req.query.format.toLowerCase() : ''; const t = Math.max(0, Math.min(1, (1.0 - r) / 0.5));
fillRatio = 0.75 + t * (0.94 - 0.75);
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 { } else {
pipeline = pipeline.jpeg({ quality: 85 }); const t = Math.max(0, Math.min(1, (r - 1.0) / 0.8));
contentType = 'image/jpeg'; fillRatio = 0.75 - t * (0.75 - 0.65);
} }
buffer = await pipeline.toBuffer(); const innerW = Math.max(1, Math.round(targetW * fillRatio));
} catch (resizeErr) { const innerH = Math.max(1, Math.round(targetH * fillRatio));
console.warn(`[pimage] resize/convert fallback for ${rawPath}:`, resizeErr.message);
const intermediateBuffer = await imageSharp
.resize(innerW, innerH, {
fit: 'contain',
background: '#ffffff',
withoutEnlargement: true,
})
.flatten({ background: '#ffffff' })
.toBuffer();
pipeline = sharp(intermediateBuffer)
.resize(targetW, targetH, {
fit: 'contain',
background: '#ffffff',
withoutEnlargement: true,
})
.flatten({ background: '#ffffff' });
} else {
pipeline = imageSharp
.resize(targetW, targetH, {
fit: 'inside',
withoutEnlargement: true,
})
.flatten({ background: '#ffffff' });
} }
const accept = String(req.headers.accept || '');
const formatParam = typeof req.query.format === 'string' ? req.query.format.toLowerCase() : '';
let contentType = 'image/jpeg';
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') {
pipeline = pipeline.png();
contentType = 'image/png';
} else {
pipeline = pipeline.jpeg({ quality: 85 });
contentType = 'image/jpeg';
}
const buffer = await pipeline.toBuffer();
res.setHeader('Content-Type', contentType); res.setHeader('Content-Type', contentType);
res.setHeader('Content-Length', String(buffer.length)); res.setHeader('Content-Length', String(buffer.length));
res.setHeader('Cache-Control', 'public, max-age=86400'); res.setHeader('Cache-Control', 'public, max-age=86400');
res.setHeader('ETag', etag); res.setHeader('ETag', etag);
return res.status(200).send(buffer); return res.status(200).send(buffer);
} catch (err) { } 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' }); return res.status(500).json({ Message: 'Internal server error' });
} }
} }