From 3e64fff747d66f3b5c2a4cd5de4497374ff305ed Mon Sep 17 00:00:00 2001 From: seb Date: Sun, 27 Sep 2026 16:19:43 +0200 Subject: [PATCH] u --- docs/api/README.md | 1 + docs/api/openapi.json | 183 +++++++++++++- docs/api/routes/brand/list.md | 12 +- docs/api/routes/brand/upsert.md | 12 +- docs/api/routes/companysetting/set.md | 2 +- docs/api/routes/serper/images.md | 54 ++++ scripts/generateApiDocs.js | 1 + src/api/openapi.js | 23 +- src/api/routes/brand.js | 55 ++++ src/api/routes/companysetting.js | 3 +- src/api/routes/serper.js | 347 ++++++++++++++++++++++++++ src/config/env.js | 1 + src/db/brand.js | 10 + 13 files changed, 697 insertions(+), 7 deletions(-) create mode 100644 docs/api/routes/serper/images.md create mode 100644 src/api/routes/serper.js diff --git a/docs/api/README.md b/docs/api/README.md index 201a015..c243862 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -132,6 +132,7 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route | [`routes/quote/delete.md`](routes/quote/delete.md) | `DELETE /quote/{id}` | | [`routes/quote/get.md`](routes/quote/get.md) | `GET /quote/{id}` | | [`routes/quote/update.md`](routes/quote/update.md) | `PATCH /quote/{id}` | +| [`routes/serper/images.md`](routes/serper/images.md) | `POST /serper/images` | | [`routes/stock/adjust.md`](routes/stock/adjust.md) | `POST /stock/adjust` | | [`routes/supplier/list.md`](routes/supplier/list.md) | `GET /supplier` | | [`routes/supplier/create.md`](routes/supplier/create.md) | `POST /supplier` | diff --git a/docs/api/openapi.json b/docs/api/openapi.json index 6001285..09e4c25 100644 --- a/docs/api/openapi.json +++ b/docs/api/openapi.json @@ -91,6 +91,10 @@ { "name": "Stock", "description": "Manual stock additions and removals" + }, + { + "name": "Serper", + "description": "Serper Google image search and thumbnail streaming" } ], "paths": { @@ -986,6 +990,142 @@ } } }, + "/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\ncompany setting `keys.serper` (or server `SERPER_API_KEY`), and streams the\nresults via Server-Sent Events (`text/event-stream`).\n\n1. First emits an `images` event with the full search result list (including `imageUrl`).\n2. Then streams `thumb` events with WebP base64 thumbnails (max 200×200) as they are generated.\n3. Emits `done` when all thumbnails have completed (or `error` on failure).\n", + "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).\n", + "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" + } + } + } + } + } + } + }, "/quote": { "get": { "tags": [ @@ -6617,7 +6757,7 @@ "CompanySetting" ], "summary": "Upsert company setting", - "description": "Sets (upserts) a company setting for the selected company.\nOnly a fixed allow-list of keys can be set: `ui.theme`, `keys.tavily`,\n`keys.openrouter`, `labelprinter.addr`, `labelprinter.type`.\n", + "description": "Sets (upserts) a company setting for the selected company.\nOnly a fixed allow-list of keys can be set: `ui.theme`, `keys.tavily`,\n`keys.openrouter`, `keys.serper`, `labelprinter.addr`, `labelprinter.type`.\n", "security": [ { "cookieAuth": [] @@ -10834,11 +10974,43 @@ } ] }, + "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" + "name", + "pics" ], "properties": { "id": { @@ -10850,6 +11022,13 @@ "gsprpinfo": { "type": "string", "nullable": true + }, + "pics": { + "type": "array", + "description": "Brand pictures sorted by sort", + "items": { + "$ref": "#/components/schemas/BrandPic" + } } } }, diff --git a/docs/api/routes/brand/list.md b/docs/api/routes/brand/list.md index 368c9de..8dd1ed8 100644 --- a/docs/api/routes/brand/list.md +++ b/docs/api/routes/brand/list.md @@ -16,7 +16,17 @@ Brand list { "id": 1, "name": "…", - "gsprpinfo": "…" + "gsprpinfo": "…", + "pics": [ + { + "id": 1, + "sort": 1, + "fullHash": "…", + "thumbW": 1, + "thumbH": 1, + "thumbHash": "…" + } + ] } ] } diff --git a/docs/api/routes/brand/upsert.md b/docs/api/routes/brand/upsert.md index a82d922..810020c 100644 --- a/docs/api/routes/brand/upsert.md +++ b/docs/api/routes/brand/upsert.md @@ -25,7 +25,17 @@ Upserted brand "brand": { "id": 1, "name": "…", - "gsprpinfo": "…" + "gsprpinfo": "…", + "pics": [ + { + "id": 1, + "sort": 1, + "fullHash": "…", + "thumbW": 1, + "thumbH": 1, + "thumbHash": "…" + } + ] } } ``` diff --git a/docs/api/routes/companysetting/set.md b/docs/api/routes/companysetting/set.md index 4d0b05d..3c74c49 100644 --- a/docs/api/routes/companysetting/set.md +++ b/docs/api/routes/companysetting/set.md @@ -6,7 +6,7 @@ Upsert company setting 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`. ## Path parameters diff --git a/docs/api/routes/serper/images.md b/docs/api/routes/serper/images.md new file mode 100644 index 0000000..c80328d --- /dev/null +++ b/docs/api/routes/serper/images.md @@ -0,0 +1,54 @@ +# `POST /serper/images` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +Search images via Serper.dev and stream WebP thumbnails (SSE) + +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). + + +## Body (JSON) + +```json +{ + "q": "Purize logo", + "gl": "de", + "hl": "de", + "num": 100 +} +``` + +## SSE Events + +The response is a `text/event-stream`. Events are emitted in order until the stream closes. + +| Event | Description | Payload | +|-------|-------------|---------| +| `images` | First event; list of search result images with id, url, width, and height | `{ "images": [ { "id": 0, "url": "…", "width": 1000, "height": 800 } ] }` | +| `thumb` | Emitted for each image as its 200×200 max WebP thumbnail is generated | `{ "id": 0, "dataUrl": "data:image/webp;base64,…" }` | +| `done` | Emitted after all thumbnails have finished streaming; closes the SSE stream | `{ "total": 100 }` | +| `error` | Emitted when an error occurs during Serper request or processing | `{ "error": "…" }` | + +## Response `200` + +`text/event-stream` — emits `images`, `thumb`, `done` (and `error` on failure). + + +## Errors + +| Status | Description | +|--------|-------------| +| 400 | Query missing, invalid input, or Serper API key not configured — `{"error":"…"}` | +| 401 | Unauthorized — `{"error":"…"}` | +| 403 | Not authorized — `{"error":"…"}` | +| 404 | Company not found — `{"error":"…"}` | + +--- + +_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._ diff --git a/scripts/generateApiDocs.js b/scripts/generateApiDocs.js index b5c7fbb..b71bf3c 100644 --- a/scripts/generateApiDocs.js +++ b/scripts/generateApiDocs.js @@ -48,6 +48,7 @@ const options = { { name: 'Printer', description: 'Company-scoped receipt and label printers' }, { name: 'Ledger', description: 'Append-only ledger transactions' }, { name: 'Stock', description: 'Manual stock additions and removals' }, + { name: 'Serper', description: 'Serper Google image search and thumbnail streaming' }, ], }, apis: [ diff --git a/src/api/openapi.js b/src/api/openapi.js index 7d62cdd..4c0011a 100644 --- a/src/api/openapi.js +++ b/src/api/openapi.js @@ -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] diff --git a/src/api/routes/brand.js b/src/api/routes/brand.js index 4d404b3..f8d5f02 100644 --- a/src/api/routes/brand.js +++ b/src/api/routes/brand.js @@ -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 }); + } + }); } diff --git a/src/api/routes/companysetting.js b/src/api/routes/companysetting.js index 1807e8d..e38057f 100644 --- a/src/api/routes/companysetting.js +++ b/src/api/routes/companysetting.js @@ -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 diff --git a/src/api/routes/serper.js b/src/api/routes/serper.js new file mode 100644 index 0000000..436010c --- /dev/null +++ b/src/api/routes/serper.js @@ -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); +} diff --git a/src/config/env.js b/src/config/env.js index 727e1be..0efc2f6 100644 --- a/src/config/env.js +++ b/src/config/env.js @@ -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'), }, diff --git a/src/db/brand.js b/src/db/brand.js index a5062af..59be0f5 100644 --- a/src/db/brand.js +++ b/src/db/brand.js @@ -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); +}