From c77337507f6e6afdc5060673ce184e46eafe567f Mon Sep 17 00:00:00 2001 From: seb Date: Fri, 31 Jul 2026 20:10:14 +0200 Subject: [PATCH] u --- docs/api/README.md | 1 + docs/api/openapi.json | 156 +++++++++++++++++++++++++++ docs/api/routes/article/add-image.md | 44 ++++++++ scripts/generateApiDocs.js | 39 ++++++- src/api/openapi.js | 33 ++++++ src/api/routes/article.js | 99 +++++++++++++++++ src/db/article.js | 5 + 7 files changed, 372 insertions(+), 5 deletions(-) create mode 100644 docs/api/routes/article/add-image.md diff --git a/docs/api/README.md b/docs/api/README.md index 31445f7..a995e55 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -33,6 +33,7 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route | [`routes/article/create.md`](routes/article/create.md) | `POST /article` | | [`routes/article/get.md`](routes/article/get.md) | `GET /article/{sku}` | | [`routes/article/update.md`](routes/article/update.md) | `PATCH /article/{sku}` | +| [`routes/article/add-image.md`](routes/article/add-image.md) | `POST /article/{sku}/image` | | [`routes/auth/login.md`](routes/auth/login.md) | `POST /auth/login` | | [`routes/auth/logout.md`](routes/auth/logout.md) | `POST /auth/logout` | | [`routes/auth/me.md`](routes/auth/me.md) | `GET /auth/me` | diff --git a/docs/api/openapi.json b/docs/api/openapi.json index 54ddd33..86e24fa 100644 --- a/docs/api/openapi.json +++ b/docs/api/openapi.json @@ -1012,6 +1012,100 @@ } } }, + "/article/{sku}/image": { + "post": { + "tags": [ + "Article" + ], + "summary": "Add article image", + "description": "Accepts a full-size AVIF body (`Content-Type: image/avif`).\nFull: stored as-is when already AVIF and ≤1000×1000; otherwise scaled to max 1000×1000.\nThumb always scaled to max 200×200. Stores hash/thumbhash, links `articlepic`.\nAuth like GET by SKU.\n", + "security": [ + { + "cookieAuth": [] + } + ], + "x-markdown": "article/add-image.md", + "parameters": [ + { + "in": "path", + "name": "sku", + "required": true, + "schema": { + "type": "string" + }, + "description": "Article SKU" + } + ], + "requestBody": { + "required": true, + "content": { + "image/avif": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "responses": { + "201": { + "description": "Image stored and linked", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "image": { + "$ref": "#/components/schemas/ArticleImage" + } + } + } + } + } + }, + "400": { + "description": "Missing body or invalid image", + "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": "Article not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, "/auth/selected-company": { "put": { "tags": [ @@ -1378,6 +1472,68 @@ "type": "object", "description": "Full article payload (unit, brand, categoryIds, photos, eans, prices, …)", "additionalProperties": true + }, + "ArticleImage": { + "type": "object", + "required": [ + "picId", + "sort", + "full", + "thumb", + "hash", + "thumbHash" + ], + "properties": { + "picId": { + "type": "integer" + }, + "sort": { + "type": "integer" + }, + "full": { + "type": "object", + "required": [ + "w", + "h", + "size" + ], + "properties": { + "w": { + "type": "integer" + }, + "h": { + "type": "integer" + }, + "size": { + "type": "integer", + "description": "Byte length of full AVIF (as-is when already AVIF ≤1000×1000)" + } + } + }, + "thumb": { + "type": "object", + "required": [ + "w", + "h" + ], + "properties": { + "w": { + "type": "integer" + }, + "h": { + "type": "integer" + } + } + }, + "hash": { + "type": "string", + "description": "SHA-256 hex of full image bytes" + }, + "thumbHash": { + "type": "string", + "description": "Base64 thumbhash of thumb preview" + } + } } } } diff --git a/docs/api/routes/article/add-image.md b/docs/api/routes/article/add-image.md new file mode 100644 index 0000000..9f4fe97 --- /dev/null +++ b/docs/api/routes/article/add-image.md @@ -0,0 +1,44 @@ +# `POST /article/{sku}/image` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +Add article image + +Accepts a full-size AVIF body (`Content-Type: image/avif`). +Full: stored as-is when already AVIF and ≤1000×1000; otherwise scaled to max 1000×1000. +Thumb always scaled to max 200×200. Stores hash/thumbhash, links `articlepic`. +Auth like GET by SKU. + + +## Path parameters + +| Param | Required | Type | Description | +|-------|----------|------|-------------| +| `sku` | yes | string | Article SKU | + +## Body + +Raw binary (`image/avif`). + +## Response `201` + +Image stored and linked + +```json +{ + "image": { … } +} +``` + +## Errors + +| Status | Description | +|--------|-------------| +| 400 | Missing body or invalid image — `{"error":"…"}` | +| 401 | Unauthorized — `{"error":"…"}` | +| 403 | Not authorized — `{"error":"…"}` | +| 404 | Article 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 0ed5316..81ac36c 100644 --- a/scripts/generateApiDocs.js +++ b/scripts/generateApiDocs.js @@ -103,6 +103,38 @@ function contentSchema(content) { return json?.schema ?? null; } +function renderRequestBody(requestBody) { + if (!requestBody?.content) return null; + const lines = []; + const types = Object.keys(requestBody.content); + const jsonSchema = contentSchema(requestBody.content); + + if (jsonSchema) { + lines.push('## Body (JSON)'); + lines.push(''); + lines.push(formatSchema(jsonSchema, spec.components)); + lines.push(''); + return lines; + } + + lines.push('## Body'); + lines.push(''); + for (const type of types) { + const schema = requestBody.content[type]?.schema; + if (schema?.format === 'binary') { + lines.push(`Raw binary (\`${type}\`).`); + } else { + lines.push(`\`${type}\``); + if (schema) { + lines.push(''); + lines.push(formatSchema(schema, spec.components)); + } + } + lines.push(''); + } + return lines; +} + function docsFileFor(apiPath, method) { const custom = spec.paths[apiPath]?.[method]?.['x-markdown']; if (custom) return custom; @@ -172,11 +204,8 @@ function renderOperation(apiPath, method, operation) { } if (operation.requestBody) { - const schema = contentSchema(operation.requestBody.content); - lines.push('## Body (JSON)'); - lines.push(''); - lines.push(formatSchema(schema, spec.components)); - lines.push(''); + const bodyLines = renderRequestBody(operation.requestBody); + if (bodyLines) lines.push(...bodyLines); } const responses = operation.responses || {}; diff --git a/src/api/openapi.js b/src/api/openapi.js index 0db4e5e..47623a3 100644 --- a/src/api/openapi.js +++ b/src/api/openapi.js @@ -72,6 +72,39 @@ * type: object * description: Full article payload (unit, brand, categoryIds, photos, eans, prices, …) * additionalProperties: true + * ArticleImage: + * type: object + * required: [picId, sort, full, thumb, hash, thumbHash] + * properties: + * picId: + * type: integer + * sort: + * type: integer + * full: + * type: object + * required: [w, h, size] + * properties: + * w: + * type: integer + * h: + * type: integer + * size: + * type: integer + * description: Byte length of full AVIF (as-is when already AVIF ≤1000×1000) + * thumb: + * type: object + * required: [w, h] + * properties: + * w: + * type: integer + * h: + * type: integer + * hash: + * type: string + * description: SHA-256 hex of full image bytes + * thumbHash: + * type: string + * description: Base64 thumbhash of thumb preview */ export {}; diff --git a/src/api/routes/article.js b/src/api/routes/article.js index 2ae0bf0..f0c3164 100644 --- a/src/api/routes/article.js +++ b/src/api/routes/article.js @@ -1,5 +1,8 @@ +import express from 'express'; import * as db from '../../db/article.js'; +const AVIF_BODY_LIMIT = '20mb'; + function parsePositiveInt(value, field) { const id = Number(value); if (!Number.isInteger(id) || id <= 0) { @@ -16,6 +19,11 @@ function articleErrorStatus(error) { return 500; } +function isLikelyImageDecodeError(error) { + const msg = error?.message || ''; + return /unsupported|corrupt|invalid|input buffer|vips/i.test(msg); +} + /** * Registers article routes (requires auth middleware). * @param {import('express').Application} app - Express app @@ -256,4 +264,95 @@ export function register(app, basePath = '') { res.status(status).json({ error: error.message }); } }); + + /** + * @openapi + * /article/{sku}/image: + * post: + * tags: [Article] + * summary: Add article image + * description: | + * Accepts a full-size AVIF body (`Content-Type: image/avif`). + * Full: stored as-is when already AVIF and ≤1000×1000; otherwise scaled to max 1000×1000. + * Thumb always scaled to max 200×200. Stores hash/thumbhash, links `articlepic`. + * Auth like GET by SKU. + * security: + * - cookieAuth: [] + * x-markdown: article/add-image.md + * parameters: + * - in: path + * name: sku + * required: true + * schema: + * type: string + * description: Article SKU + * requestBody: + * required: true + * content: + * image/avif: + * schema: + * type: string + * format: binary + * responses: + * 201: + * description: Image stored and linked + * content: + * application/json: + * schema: + * type: object + * properties: + * image: + * $ref: '#/components/schemas/ArticleImage' + * 400: + * description: Missing body or invalid image + * 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: Article not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + */ + app.post( + `${basePath}/article/:sku/image`, + express.raw({ type: ['image/avif', 'application/octet-stream'], limit: AVIF_BODY_LIMIT }), + async (req, res) => { + const sku = typeof req.params.sku === 'string' ? req.params.sku.trim() : ''; + if (!sku) { + return res.status(400).json({ error: 'sku 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 image = await db.addImage(req.user.id, decodeURIComponent(sku), imageBuffer); + res.status(201).json({ image }); + } catch (error) { + if (isLikelyImageDecodeError(error)) { + return res.status(400).json({ error: 'Invalid image' }); + } + const status = articleErrorStatus(error); + if (status === 500) console.error('Add article image failed:', error); + res.status(status).json({ error: error.message }); + } + }, + ); } diff --git a/src/db/article.js b/src/db/article.js index f38c2d8..745d3ab 100644 --- a/src/db/article.js +++ b/src/db/article.js @@ -14,3 +14,8 @@ export async function updateBySku(googleAuthId, sku, data) { const store = await getStore(); return store.article.updateBySku(googleAuthId, sku, data); } + +export async function addImage(googleAuthId, sku, imageBuffer) { + const store = await getStore(); + return store.article.addImage(googleAuthId, sku, imageBuffer); +}