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); if (!Number.isInteger(id) || id <= 0) { return { error: `${field} is required` }; } 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; if (error.message === 'Article has children') return 400; return 500; } /** * Registers article routes (requires auth middleware). * @param {import('express').Application} app - Express app * @param {string} [basePath=''] - Optional URL prefix for all routes in this module */ export function register(app, basePath = '') { /** * @openapi * /article: * post: * tags: [Article] * summary: Create article * description: Creates an article under an authorized category (`parentId`). * security: * - cookieAuth: [] * x-markdown: article/create.md * requestBody: * required: true * content: * application/json: * schema: * type: object * required: [parentId, name] * properties: * parentId: * type: integer * description: Category id * name: * type: string * example: * parentId: 10 * name: New article * responses: * 201: * description: Created * content: * application/json: * schema: * type: object * properties: * article: * $ref: '#/components/schemas/ArticleSummary' * 400: * description: Validation error * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 401: * description: Unauthorized * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 403: * description: Not authorized for parent category * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 404: * description: Parent not found * content: * application/json: * schema: * $ref: '#/components/schemas/Error' */ app.post(`${basePath}/article`, async (req, res) => { const parent = parsePositiveInt(req.body?.parentId, 'parentId'); if (parent.error) { return res.status(400).json({ error: parent.error }); } const name = typeof req.body?.name === 'string' ? req.body.name.trim() : ''; if (!name) { return res.status(400).json({ error: 'name is required' }); } try { const article = await db.createUnderParent({ googleAuthId: req.user.id, parentId: parent.id, name, }); res.status(201).json({ article }); } catch (error) { const status = articleErrorStatus(error); if (status === 500) console.error('Create article failed:', error); res.status(status).json({ error: error.message }); } }); /** * @openapi * /article/{sku}: * get: * tags: [Article] * summary: Get article by SKU * description: Returns article detail by SKU if the user may access its category. * security: * - cookieAuth: [] * x-markdown: article/get.md * parameters: * - in: path * name: sku * required: true * schema: * type: string * description: Article SKU * responses: * 200: * description: Article detail * content: * application/json: * schema: * type: object * properties: * article: * $ref: '#/components/schemas/ArticleDetail' * 400: * description: Missing sku * 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' * patch: * tags: [Article] * summary: Update article by SKU * description: Updates an article name by SKU; returns the same detail shape as GET. * security: * - cookieAuth: [] * x-markdown: article/update.md * parameters: * - in: path * name: sku * required: true * schema: * type: string * description: Article SKU * requestBody: * required: true * content: * application/json: * schema: * type: object * required: [name] * properties: * name: * type: string * example: * name: Updated name * responses: * 200: * description: Updated article detail * content: * application/json: * schema: * type: object * properties: * article: * $ref: '#/components/schemas/ArticleDetail' * 400: * description: Validation error * 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' * delete: * tags: [Article] * summary: Delete article by SKU * description: | * Deletes the article when it has no child articles. Cascades related rows; * nulls `manifestarticle.article_id` and appends a delete comment. * Auth like GET by SKU. * security: * - cookieAuth: [] * x-markdown: article/delete.md * parameters: * - in: path * name: sku * required: true * schema: * type: string * description: Article SKU * responses: * 204: * description: Deleted * 400: * description: Article has children * 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.get(`${basePath}/article/:sku`, 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' }); } try { const article = await db.getBySku(req.user.id, decodeURIComponent(sku)); res.status(200).json({ article }); } catch (error) { const status = articleErrorStatus(error); if (status === 500) console.error('Get article by sku failed:', error); res.status(status).json({ error: error.message }); } }); app.patch(`${basePath}/article/:sku`, 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 name = typeof req.body?.name === 'string' ? req.body.name.trim() : ''; if (!name) { return res.status(400).json({ error: 'name is required' }); } try { const article = await db.updateBySku(req.user.id, decodeURIComponent(sku), { name }); res.status(200).json({ article }); } catch (error) { const status = articleErrorStatus(error); if (status === 500) console.error('Update article by sku failed:', error); res.status(status).json({ error: error.message }); } }); app.delete(`${basePath}/article/:sku`, async (req, res) => { const parsed = parseSku(req.params.sku); if (parsed.error) { return res.status(400).json({ error: parsed.error }); } try { await db.remove(req.user.id, parsed.sku); res.sendStatus(204); } catch (error) { const status = articleErrorStatus(error); if (status === 500) console.error('Delete article failed:', error); 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) { const status = articleErrorStatus(error); if (status === 500) console.error('Add article image failed:', error); res.status(status).json({ error: error.message }); } }, ); /** * @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 }); } }); }