u
This commit is contained in:
@@ -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` |
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
44
docs/api/routes/article/add-image.md
Normal file
44
docs/api/routes/article/add-image.md
Normal file
@@ -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`._
|
||||
@@ -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 || {};
|
||||
|
||||
@@ -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 {};
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user