This commit is contained in:
seb
2026-10-25 22:38:01 +01:00
parent c07e4dc083
commit fb2dafd920
5 changed files with 50 additions and 97 deletions

View File

@@ -4,7 +4,7 @@ Protected. Requires a valid session cookie (`credentials: 'include'`).
POS category image delivery 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). Returns category image by hash (`path` query param) scaled to preview size (padded to 1:1 with white background).
## Query parameters ## Query parameters
@@ -12,9 +12,9 @@ Returns category image by hash (`path` query param) scaled to preview size (padd
| Param | Required | Type | Description | | Param | Required | Type | Description |
|-------|----------|------|-------------| |-------|----------|------|-------------|
| `path` | yes | string | SHA-256 hash of the category image | | `path` | yes | string | SHA-256 hash of the category image |
| `size` | no | integer | Max dimension (width & height) | | `size` | no | integer | Max dimension (width & height, capped to 200) |
| `w` | no | integer | Max width | | `w` | no | integer | Max width (capped to 200) |
| `h` | no | integer | Max height | | `h` | no | integer | Max height (capped to 200) |
| `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) |
## Response `200` ## Response `200`

View File

@@ -34,8 +34,10 @@ export default {
openrouterFreeKey: process.env.OPENROUTER_FREE_KEY, openrouterFreeKey: process.env.OPENROUTER_FREE_KEY,
openrouterFreeModel: process.env.OPENROUTER_FREE_MODEL || 'openrouter/free', openrouterFreeModel: process.env.OPENROUTER_FREE_MODEL || 'openrouter/free',
openrouterPaidModel: process.env.OPENROUTER_PAID_MODEL || 'openai/gpt-6-luna:floor', openrouterPaidModel: process.env.OPENROUTER_PAID_MODEL || 'openai/gpt-6-luna:floor',
storageDir: process.env.STORAGE_DIR || resolve(__dirname, '../../data/storage'),
sqlite: { sqlite: {
filename: process.env.SQLITE_PATH || process.env.SQLITE_FILENAME || resolve(__dirname, '../../data/dash.db'), filename: process.env.SQLITE_PATH || process.env.SQLITE_FILENAME || resolve(__dirname, '../../data/dash.db'),
storageDir: process.env.STORAGE_DIR || resolve(__dirname, '../../data/storage'),
}, },
mssql: { mssql: {
server: process.env.MSSQL_SERVER, server: process.env.MSSQL_SERVER,
@@ -45,6 +47,7 @@ export default {
port: Number(process.env.MSSQL_PORT) || 1433, port: Number(process.env.MSSQL_PORT) || 1433,
encrypt: process.env.MSSQL_ENCRYPT === 'true', encrypt: process.env.MSSQL_ENCRYPT === 'true',
trustServerCertificate: process.env.MSSQL_TRUST_SERVER_CERTIFICATE === 'true', trustServerCertificate: process.env.MSSQL_TRUST_SERVER_CERTIFICATE === 'true',
storageDir: process.env.STORAGE_DIR || resolve(__dirname, '../../data/storage'),
}, },
backup: { backup: {
enabled: process.env.BACKUP_ENABLED !== 'false', enabled: process.env.BACKUP_ENABLED !== 'false',

View File

@@ -140,9 +140,10 @@ export async function getPosImageByHash(hash, { width = 200, height = 200 } = {}
const provider = await getProvider(); const provider = await getProvider();
const isSqlite = provider?.dialect === 'sqlite3'; const isSqlite = provider?.dialect === 'sqlite3';
if (isSqlite) {
const result = await provider.query( 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', isSqlite
? 'SELECT w, h, size, hash, thumbw, thumbh, thumbhash FROM pic WHERE hash = @hash OR thumbhash = @hash OR hash = @baseHash OR thumbhash = @baseHash LIMIT 1'
: 'SELECT TOP 1 w, h, size, hash, thumbw, thumbh, thumbhash FROM pic WHERE hash = @hash OR thumbhash = @hash OR hash = @baseHash OR thumbhash = @baseHash',
{ hash: cleanHash, baseHash } { hash: cleanHash, baseHash }
); );
const row = result.rows[0]; const row = result.rows[0];
@@ -169,29 +170,6 @@ export async function getPosImageByHash(hash, { width = 200, height = 200 } = {}
h: row.h, h: row.h,
fullHash, 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,
};
} }

View File

@@ -127,6 +127,7 @@ function getMssqlConfig() {
user, user,
password, password,
port, port,
storageDir: env.storageDir,
pool: { pool: {
max: 10, max: 10,
min: 0, min: 0,
@@ -153,7 +154,7 @@ export async function getStore() {
if (env.dbProvider === 'sqlite' || env.dbProvider === 'better-sqlite3') { if (env.dbProvider === 'sqlite' || env.dbProvider === 'better-sqlite3') {
provider = new BetterSqlite3Provider(env.sqlite); provider = new BetterSqlite3Provider(env.sqlite);
} else { } else {
provider = new MssqlProvider(getMssqlConfig()); provider = new MssqlProvider(getMssqlConfig(), { storageDir: env.storageDir });
} }
await provider.initialize(); await provider.initialize();
store = wrapStoreWithTiming(await new LibDashStore(provider)); store = wrapStoreWithTiming(await new LibDashStore(provider));

View File

@@ -5,6 +5,7 @@ const PIMAGE_DEFAULT_W = 200;
const PIMAGE_DEFAULT_H = 200; const PIMAGE_DEFAULT_H = 200;
const CIMAGE_DEFAULT_W = 200; const CIMAGE_DEFAULT_W = 200;
const CIMAGE_DEFAULT_H = 200; const CIMAGE_DEFAULT_H = 200;
const CIMAGE_MAX_DIMENSION = 200;
/** /**
* @openapi * @openapi
@@ -106,7 +107,7 @@ export async function handlePimage(req, res) {
* tags: [POS] * tags: [POS]
* summary: POS category image delivery * summary: POS category image delivery
* description: | * 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). * Returns category image by hash (`path` query param) scaled to preview size (padded to 1:1 with white background).
* x-markdown: pos/cimage.md * x-markdown: pos/cimage.md
* parameters: * parameters:
* - in: query * - in: query
@@ -122,21 +123,24 @@ export async function handlePimage(req, res) {
* schema: * schema:
* type: integer * type: integer
* default: 200 * default: 200
* description: Max dimension (width & height) * maximum: 200
* description: Max dimension (width & height, capped to 200)
* - in: query * - in: query
* name: w * name: w
* required: false * required: false
* schema: * schema:
* type: integer * type: integer
* default: 200 * default: 200
* description: Max width * maximum: 200
* description: Max width (capped to 200)
* - in: query * - in: query
* name: h * name: h
* required: false * required: false
* schema: * schema:
* type: integer * type: integer
* default: 200 * default: 200
* description: Max height * maximum: 200
* description: Max height (capped to 200)
* - in: query * - in: query
* name: format * name: format
* required: false * required: false
@@ -207,16 +211,16 @@ async function servePosImage(req, res, { isCimage = false } = {}) {
if (isCimage) { if (isCimage) {
if (hasW && !hasH) { if (hasW && !hasH) {
targetW = Math.min(Math.round(Number(req.query.w)), 2000); targetW = Math.min(Math.round(Number(req.query.w)), CIMAGE_MAX_DIMENSION);
targetH = targetW; targetH = targetW;
} else if (hasH && !hasW) { } else if (hasH && !hasW) {
targetH = Math.min(Math.round(Number(req.query.h)), 2000); targetH = Math.min(Math.round(Number(req.query.h)), CIMAGE_MAX_DIMENSION);
targetW = targetH; targetW = targetH;
} else if (hasW && hasH) { } else if (hasW && hasH) {
targetW = Math.min(Math.round(Number(req.query.w)), 2000); targetW = Math.min(Math.round(Number(req.query.w)), CIMAGE_MAX_DIMENSION);
targetH = Math.min(Math.round(Number(req.query.h)), 2000); targetH = Math.min(Math.round(Number(req.query.h)), CIMAGE_MAX_DIMENSION);
} else if (hasSize) { } else if (hasSize) {
const s = Math.min(Math.round(Number(req.query.size)), 2000); const s = Math.min(Math.round(Number(req.query.size)), CIMAGE_MAX_DIMENSION);
targetW = s; targetW = s;
targetH = s; targetH = s;
} else { } else {
@@ -238,7 +242,7 @@ async function servePosImage(req, res, { isCimage = false } = {}) {
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}${isCimage ? '-c-v5' : ''}"`; const etag = `"${image.fullHash}-${targetW}x${targetH}${isCimage ? '-c-v6' : ''}"`;
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);
@@ -248,53 +252,20 @@ async function servePosImage(req, res, { isCimage = false } = {}) {
const imageSharp = sharp(image.buffer); const imageSharp = sharp(image.buffer);
let pipeline; const resizeOptions = isCimage
if (isCimage) { ? {
const meta = await imageSharp.metadata(); fit: 'contain',
const origW = meta.width || targetW; background: '#ffffff',
const origH = meta.height || targetH; withoutEnlargement: true,
const r = origW / origH;
// Adaptive fill ratio:
// Vertical images (r < 1) get much less border (height fills up to ~94% of tile).
// 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;
if (r < 1.0) {
const t = Math.max(0, Math.min(1, (1.0 - r) / 0.5));
fillRatio = 0.75 + t * (0.94 - 0.75);
} else {
const t = Math.max(0, Math.min(1, (r - 1.0) / 0.8));
fillRatio = 0.75 - t * (0.75 - 0.65);
} }
: {
const innerW = Math.max(1, Math.round(targetW * fillRatio));
const innerH = Math.max(1, Math.round(targetH * fillRatio));
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', fit: 'inside',
withoutEnlargement: true, withoutEnlargement: true,
}) };
let pipeline = imageSharp
.resize(targetW, targetH, resizeOptions)
.flatten({ background: '#ffffff' }); .flatten({ background: '#ffffff' });
}
const accept = String(req.headers.accept || ''); const accept = String(req.headers.accept || '');
const formatParam = typeof req.query.format === 'string' ? req.query.format.toLowerCase() : ''; const formatParam = typeof req.query.format === 'string' ? req.query.format.toLowerCase() : '';