diff --git a/docs/api/README.md b/docs/api/README.md index 6c910fe..bf35e4e 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -34,6 +34,8 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route | [`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/article/delete-image.md`](routes/article/delete-image.md) | `DELETE /article/{sku}/image/{fullHash}` | +| [`routes/article/order-images.md`](routes/article/order-images.md) | `PUT /article/{sku}/images/order` | | [`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 0bb4653..5b6b00a 100644 --- a/docs/api/openapi.json +++ b/docs/api/openapi.json @@ -1302,6 +1302,198 @@ } } }, + "/article/{sku}/image/{fullHash}": { + "delete": { + "tags": [ + "Article" + ], + "summary": "Delete article image", + "description": "Removes `articlepic` and the `pic` row for this article (`fullHash`). Auth like GET by SKU.", + "security": [ + { + "cookieAuth": [] + } + ], + "x-markdown": "article/delete-image.md", + "parameters": [ + { + "in": "path", + "name": "sku", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "fullHash", + "required": true, + "schema": { + "type": "string", + "pattern": "^[a-fA-F0-9]{64}$" + } + } + ], + "responses": { + "204": { + "description": "Deleted" + }, + "400": { + "description": "Invalid hash", + "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 or image not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/article/{sku}/images/order": { + "put": { + "tags": [ + "Article" + ], + "summary": "Reorder article images", + "description": "Sets `articlepic.sort` to `0..n-1` in the given order.\n`fullHashes` must list every image of the article exactly once.\n", + "security": [ + { + "cookieAuth": [] + } + ], + "x-markdown": "article/order-images.md", + "parameters": [ + { + "in": "path", + "name": "sku", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "fullHashes" + ], + "properties": { + "fullHashes": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[a-fA-F0-9]{64}$" + } + } + }, + "example": { + "fullHashes": [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ] + } + } + } + } + }, + "responses": { + "200": { + "description": "New order", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "fullHashes": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + }, + "400": { + "description": "Invalid image order", + "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": [ diff --git a/docs/api/routes/article/delete-image.md b/docs/api/routes/article/delete-image.md new file mode 100644 index 0000000..2ba8502 --- /dev/null +++ b/docs/api/routes/article/delete-image.md @@ -0,0 +1,33 @@ +# `DELETE /article/{sku}/image/{fullHash}` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +Delete article image + +Removes `articlepic` and the `pic` row for this article (`fullHash`). Auth like GET by SKU. + +## Path parameters + +| Param | Required | Type | Description | +|-------|----------|------|-------------| +| `sku` | yes | string | | +| `fullHash` | yes | string | | + +## Response `204` + +Deleted + +_No content_ + +## Errors + +| Status | Description | +|--------|-------------| +| 400 | Invalid hash — `{"error":"…"}` | +| 401 | Unauthorized — `{"error":"…"}` | +| 403 | Not authorized — `{"error":"…"}` | +| 404 | Article or image not found — `{"error":"…"}` | + +--- + +_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._ diff --git a/docs/api/routes/article/order-images.md b/docs/api/routes/article/order-images.md new file mode 100644 index 0000000..6b7c5b6 --- /dev/null +++ b/docs/api/routes/article/order-images.md @@ -0,0 +1,49 @@ +# `PUT /article/{sku}/images/order` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +Reorder article images + +Sets `articlepic.sort` to `0..n-1` in the given order. +`fullHashes` must list every image of the article exactly once. + + +## Path parameters + +| Param | Required | Type | Description | +|-------|----------|------|-------------| +| `sku` | yes | string | | + +## Body (JSON) + +```json +{ + "fullHashes": [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ] +} +``` + +## Response `200` + +New order + +```json +{ + "fullHashes": [] +} +``` + +## Errors + +| Status | Description | +|--------|-------------| +| 400 | Invalid image order — `{"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/src/api/routes/article.js b/src/api/routes/article.js index 64debe6..4419566 100644 --- a/src/api/routes/article.js +++ b/src/api/routes/article.js @@ -2,6 +2,7 @@ import express from 'express'; import * as db from '../../db/article.js'; const AVIF_BODY_LIMIT = '20mb'; +const FULL_HASH_RE = /^[a-fA-F0-9]{64}$/; function parsePositiveInt(value, field) { const id = Number(value); @@ -11,11 +12,27 @@ function parsePositiveInt(value, field) { return { id }; } +function parseSku(value) { + const sku = typeof value === 'string' ? value.trim() : ''; + if (!sku) return { error: 'sku is required' }; + return { sku: decodeURIComponent(sku) }; +} + +function parseFullHash(value) { + const fullHash = typeof value === 'string' ? value.trim() : ''; + if (!FULL_HASH_RE.test(fullHash)) { + return { error: 'fullHash must be a 64-char hex SHA-256' }; + } + return { fullHash: fullHash.toLowerCase() }; +} + function articleErrorStatus(error) { if (error.message === 'Not authorized') return 403; if (error.message === 'Parent not found') return 404; if (error.message === 'Article not found') return 404; + if (error.message === 'Image not found') return 404; if (error.message === 'Name required') return 400; + if (error.message === 'Invalid image order') return 400; return 500; } @@ -347,4 +364,176 @@ export function register(app, basePath = '') { } }, ); + + /** + * @openapi + * /article/{sku}/image/{fullHash}: + * delete: + * tags: [Article] + * summary: Delete article image + * description: Removes `articlepic` and the `pic` row for this article (`fullHash`). Auth like GET by SKU. + * security: + * - cookieAuth: [] + * x-markdown: article/delete-image.md + * parameters: + * - in: path + * name: sku + * required: true + * schema: + * type: string + * - in: path + * name: fullHash + * required: true + * schema: + * type: string + * pattern: '^[a-fA-F0-9]{64}$' + * responses: + * 204: + * description: Deleted + * 400: + * description: Invalid hash + * 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 or image not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + */ + app.delete(`${basePath}/article/:sku/image/:fullHash`, async (req, res) => { + const skuParsed = parseSku(req.params.sku); + if (skuParsed.error) { + return res.status(400).json({ error: skuParsed.error }); + } + const hashParsed = parseFullHash(req.params.fullHash); + if (hashParsed.error) { + return res.status(400).json({ error: hashParsed.error }); + } + + try { + await db.deleteImage(req.user.id, skuParsed.sku, hashParsed.fullHash); + res.sendStatus(204); + } catch (error) { + const status = articleErrorStatus(error); + if (status === 500) console.error('Delete article image failed:', error); + res.status(status).json({ error: error.message }); + } + }); + + /** + * @openapi + * /article/{sku}/images/order: + * put: + * tags: [Article] + * summary: Reorder article images + * description: | + * Sets `articlepic.sort` to `0..n-1` in the given order. + * `fullHashes` must list every image of the article exactly once. + * security: + * - cookieAuth: [] + * x-markdown: article/order-images.md + * parameters: + * - in: path + * name: sku + * required: true + * schema: + * type: string + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [fullHashes] + * properties: + * fullHashes: + * type: array + * items: + * type: string + * pattern: '^[a-fA-F0-9]{64}$' + * example: + * fullHashes: + * - "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + * - "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + * responses: + * 200: + * description: New order + * content: + * application/json: + * schema: + * type: object + * properties: + * fullHashes: + * type: array + * items: + * type: string + * 400: + * description: Invalid image order + * 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.put(`${basePath}/article/:sku/images/order`, async (req, res) => { + const skuParsed = parseSku(req.params.sku); + if (skuParsed.error) { + return res.status(400).json({ error: skuParsed.error }); + } + + const rawHashes = req.body?.fullHashes; + if (!Array.isArray(rawHashes)) { + return res.status(400).json({ error: 'fullHashes is required' }); + } + + const fullHashes = []; + for (const value of rawHashes) { + const parsed = parseFullHash(value); + if (parsed.error) { + return res.status(400).json({ error: parsed.error }); + } + fullHashes.push(parsed.fullHash); + } + + try { + const result = await db.orderImages(req.user.id, skuParsed.sku, { fullHashes }); + res.status(200).json(result); + } catch (error) { + const status = articleErrorStatus(error); + if (status === 500) console.error('Order article images failed:', error); + res.status(status).json({ error: error.message }); + } + }); }