This commit is contained in:
seb
2026-09-27 16:19:43 +02:00
parent 69f27a2942
commit 3e64fff747
13 changed files with 697 additions and 7 deletions

View File

@@ -207,9 +207,25 @@
* properties:
* article:
* $ref: '#/components/schemas/SupplierArticleInfo'
* BrandPic:
* type: object
* required: [id, sort, fullHash, thumbW, thumbH, thumbHash]
* properties:
* id:
* type: integer
* sort:
* type: integer
* fullHash:
* type: string
* thumbW:
* type: integer
* thumbH:
* type: integer
* thumbHash:
* type: string
* Brand:
* type: object
* required: [id, name]
* required: [id, name, pics]
* properties:
* id:
* type: integer
@@ -218,6 +234,11 @@
* gsprpinfo:
* type: string
* nullable: true
* pics:
* type: array
* description: Brand pictures sorted by sort
* items:
* $ref: '#/components/schemas/BrandPic'
* NumberSequence:
* type: object
* required: [id, type, prefix, padding, next]

View File

@@ -1,5 +1,8 @@
import express from 'express';
import * as db from '../../db/brand.js';
const AVIF_BODY_LIMIT = 10 * 1024 * 1024;
function requireSelectedCompany(req) {
const companyId = req.user.selectedCompanyId;
if (companyId == null) {
@@ -15,6 +18,7 @@ function brandErrorStatus(error) {
if (error.message === 'Brand not found') return 404;
if (error.message === 'Invalid brand') return 400;
if (error.message === 'Brand in use') return 409;
if (error.message === 'Image not found') return 404;
return 500;
}
@@ -237,4 +241,55 @@ export function register(app, basePath = '') {
res.status(status).json({ error: error.message });
}
});
app.post(
`${basePath}/brand/:id/image`,
express.raw({ type: ['image/avif', 'application/octet-stream'], limit: AVIF_BODY_LIMIT }),
async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
return res.status(400).json({ error: selected.error });
}
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) {
return res.status(400).json({ error: 'id is required' });
}
const imageBuffer = Buffer.isBuffer(req.body) ? req.body : null;
if (!imageBuffer?.length) {
return res.status(400).json({ error: 'AVIF image body is required' });
}
try {
const brand = await db.addImage(req.user.id, id, imageBuffer);
res.status(201).json({ brand });
} catch (error) {
const status = brandErrorStatus(error);
if (status === 500) console.error('Add brand image failed:', error);
res.status(status).json({ error: error.message });
}
}
);
app.delete(`${basePath}/brand/:id/image`, async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
return res.status(400).json({ error: selected.error });
}
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) {
return res.status(400).json({ error: 'id is required' });
}
try {
const brand = await db.deleteImage(req.user.id, id);
res.status(200).json({ brand });
} catch (error) {
const status = brandErrorStatus(error);
if (status === 500) console.error('Delete brand image failed:', error);
res.status(status).json({ error: error.message });
}
});
}

View File

@@ -4,6 +4,7 @@ const ALLOWED_KEYS = [
'ui.theme',
'keys.tavily',
'keys.openrouter',
'keys.serper',
'labelprinter.addr',
'labelprinter.type',
];
@@ -205,7 +206,7 @@ export function register(app, basePath = '') {
* description: |
* Sets (upserts) a company setting for the selected company.
* Only a fixed allow-list of keys can be set: `ui.theme`, `keys.tavily`,
* `keys.openrouter`, `labelprinter.addr`, `labelprinter.type`.
* `keys.openrouter`, `keys.serper`, `labelprinter.addr`, `labelprinter.type`.
* security:
* - cookieAuth: []
* x-markdown: companysetting/set.md

347
src/api/routes/serper.js Normal file
View File

@@ -0,0 +1,347 @@
import sharp from 'sharp';
import * as dbCompanySetting from '../../db/companysetting.js';
import env from '../../config/env.js';
const CONCURRENCY = 6;
const FETCH_TIMEOUT_MS = 6000;
function requireSelectedCompany(req) {
const companyId = req.user?.selectedCompanyId;
if (companyId == null) {
return { error: 'No company selected' };
}
return { companyId };
}
/**
* Downloads an image (trying thumbnailUrl then imageUrl, or vice versa) and
* scales it to max 200×200 WebP base64 data URL.
*
* @param {object} img
* @param {string} [img.imageUrl]
* @param {string} [img.thumbnailUrl]
* @param {object} options
* @param {AbortSignal} [options.signal]
* @param {boolean} [options.preferSource=false]
* @returns {Promise<{ dataUrl: string, width: number, height: number, size: number }>}
*/
async function generateWebpThumbnail(img, { signal, preferSource = false } = {}) {
const urls = preferSource
? [img.imageUrl, img.thumbnailUrl].filter(Boolean)
: [img.thumbnailUrl, img.imageUrl].filter(Boolean);
if (urls.length === 0) {
throw new Error('No image URL available');
}
let lastError = null;
for (const url of urls) {
if (signal?.aborted) throw new Error('Aborted');
try {
const fetchSignal = signal
? AbortSignal.any([signal, AbortSignal.timeout(FETCH_TIMEOUT_MS)])
: AbortSignal.timeout(FETCH_TIMEOUT_MS);
const response = await fetch(url, {
headers: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8',
},
signal: fetchSignal,
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const thumb = await sharp(buffer)
.rotate()
.resize(200, 200, {
fit: 'inside',
withoutEnlargement: true,
})
.webp({ quality: 80 })
.toBuffer({ resolveWithObject: true });
const dataUrl = `data:image/webp;base64,${thumb.data.toString('base64')}`;
return {
dataUrl,
width: thumb.info.width,
height: thumb.info.height,
size: thumb.info.size,
};
} catch (err) {
lastError = err;
}
}
throw lastError || new Error('Failed to load thumbnail');
}
/**
* Registers Serper image search routes.
* @param {import('express').Application} app
* @param {string} [basePath='']
*/
export function register(app, basePath = '') {
/**
* @openapi
* /serper/images:
* post:
* tags: [Serper]
* summary: Search images via Serper.dev and stream WebP thumbnails (SSE)
* description: |
* Queries Google Image search via `https://google.serper.dev/images` using the
* company setting `keys.serper` (or server `SERPER_API_KEY`), and streams the
* results via Server-Sent Events (`text/event-stream`).
*
* 1. First emits an `images` event with the full search result list (including `imageUrl`).
* 2. Then streams `thumb` events with WebP base64 thumbnails (max 200×200) as they are generated.
* 3. Emits `done` when all thumbnails have completed (or `error` on failure).
* security:
* - cookieAuth: []
* x-markdown: serper/images.md
* x-sse-events:
* - name: images
* description: First event; list of search result images with id, url, width, and height
* payload: '{ "images": [ { "id": 0, "url": "…", "width": 1000, "height": 800 } ] }'
* - name: thumb
* description: Emitted for each image as its 200×200 max WebP thumbnail is generated
* payload: '{ "id": 0, "dataUrl": "data:image/webp;base64,…" }'
* - name: done
* description: Emitted after all thumbnails have finished streaming; closes the SSE stream
* payload: '{ "total": 100 }'
* - name: error
* description: Emitted when an error occurs during Serper request or processing
* payload: '{ "error": "…" }'
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [q]
* properties:
* q:
* type: string
* description: Image search query (e.g. "Purize logo")
* gl:
* type: string
* default: de
* description: Country code
* hl:
* type: string
* default: de
* description: Language code
* num:
* type: integer
* default: 100
* minimum: 1
* maximum: 100
* description: Number of search results to fetch
* preferSource:
* type: boolean
* default: false
* description: Whether to prefer downloading from the full imageUrl before falling back to Google thumbnailUrl
* example:
* q: "Purize logo"
* gl: "de"
* hl: "de"
* num: 100
* responses:
* 200:
* description: |
* `text/event-stream` — emits `images`, `thumb`, `done` (and `error` on failure).
* content:
* text/event-stream:
* schema:
* type: string
* 400:
* description: Query missing, invalid input, or Serper API key not configured
* 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: Company not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
const handleImagesSearch = async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
return res.status(400).json({ error: selected.error });
}
const query = typeof req.body?.q === 'string'
? req.body.q
: (typeof req.body?.query === 'string' ? req.body.query : '');
if (!query.trim()) {
return res.status(400).json({ error: 'q is required' });
}
let apiKey = null;
try {
const setting = await dbCompanySetting.getByKey(req.user.id, 'keys.serper', { maskWriteonly: false });
apiKey = setting?.value || env.serperApiKey;
} catch (err) {
console.warn('Serper images: failed to read company setting keys.serper:', err?.message || err);
apiKey = env.serperApiKey;
}
if (!apiKey) {
return res.status(400).json({
error: 'Serper API key not configured (companysetting: keys.serper)',
});
}
const gl = typeof req.body?.gl === 'string' && req.body.gl.trim() ? req.body.gl.trim() : 'de';
const hl = typeof req.body?.hl === 'string' && req.body.hl.trim() ? req.body.hl.trim() : 'de';
let num = Number(req.body?.num);
if (!Number.isFinite(num) || num <= 0) num = 100;
num = Math.min(100, Math.max(1, Math.floor(num)));
const preferSource = Boolean(req.body?.preferSource);
// ── Open SSE Stream ──────────────────────────────────────────────
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no');
if (typeof res.flushHeaders === 'function') {
res.flushHeaders();
}
const abortController = new AbortController();
const onClose = () => {
abortController.abort();
};
req.on('close', onClose);
res.on('close', onClose);
const sseWrite = (event, data) => {
if (abortController.signal.aborted || res.writableEnded || res.destroyed) return;
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
if (typeof res.flush === 'function') res.flush();
};
// ── Fetch from Serper ────────────────────────────────────────────
let serperData;
try {
const raw = JSON.stringify({
q: query.trim(),
gl,
hl,
num,
});
const serperResponse = await fetch('https://google.serper.dev/images', {
method: 'POST',
headers: {
'X-API-KEY': apiKey,
'Content-Type': 'application/json',
},
body: raw,
signal: abortController.signal,
});
if (!serperResponse.ok) {
let errMessage = `${serperResponse.status} ${serperResponse.statusText}`;
try {
const errBody = await serperResponse.json();
if (errBody?.message) errMessage = errBody.message;
} catch {
/* ignore json parse failure */
}
sseWrite('error', { error: `Serper API error: ${errMessage}` });
return res.end();
}
serperData = await serperResponse.json();
} catch (err) {
if (abortController.signal.aborted) return;
sseWrite('error', { error: `Failed to fetch images from Serper: ${err.message}` });
return res.end();
}
const rawImages = Array.isArray(serperData?.images) ? serperData.images : [];
// ── 1. Emit list of search results (id, url, width, height) ─────
const images = rawImages.map((img, id) => ({
id,
url: img.imageUrl,
width: img.imageWidth,
height: img.imageHeight,
}));
sseWrite('images', { images });
if (images.length === 0) {
sseWrite('done', { total: 0 });
return res.end();
}
// ── 2. Stream WebP thumbnails (id, dataUrl) ──────────────────────
let cursor = 0;
async function worker() {
while (cursor < rawImages.length) {
if (abortController.signal.aborted) break;
const id = cursor++;
const img = rawImages[id];
try {
const thumb = await generateWebpThumbnail(img, {
signal: abortController.signal,
preferSource,
});
sseWrite('thumb', {
id,
dataUrl: thumb.dataUrl,
});
} catch {
if (abortController.signal.aborted) break;
sseWrite('thumb', {
id,
dataUrl: null,
});
}
}
}
const workers = Array.from(
{ length: Math.min(CONCURRENCY, rawImages.length) },
() => worker()
);
await Promise.all(workers);
if (!abortController.signal.aborted && !res.writableEnded) {
sseWrite('done', { total: rawImages.length });
res.end();
}
};
app.post(`${basePath}/serper/images`, handleImagesSearch);
app.post(`${basePath}/search/images`, handleImagesSearch);
}

View File

@@ -17,6 +17,7 @@ export default {
jwtSecret: process.env.JWT_SECRET || 'fallback_secret_please_change_in_production',
dbProvider: process.env.DB_PROVIDER || (process.env.SQLITE_PATH ? 'sqlite' : 'mssql'),
openrouterFreeKey: process.env.OPENROUTER_FREE_KEY,
serperApiKey: process.env.SERPER_API_KEY,
sqlite: {
filename: process.env.SQLITE_PATH || process.env.SQLITE_FILENAME || resolve(__dirname, '../../data/dash.db'),
},

View File

@@ -14,3 +14,13 @@ export async function remove(googleAuthId, id) {
const store = await getStore();
return store.brand.delete(googleAuthId, id);
}
export async function addImage(googleAuthId, brandId, image) {
const store = await getStore();
return store.brand.addImage(googleAuthId, brandId, image);
}
export async function deleteImage(googleAuthId, brandId) {
const store = await getStore();
return store.brand.deleteImage(googleAuthId, brandId);
}