From 067503f4ed7628a75ca9c16a95c5e04e7c6315e7 Mon Sep 17 00:00:00 2001 From: seb Date: Sat, 1 Aug 2026 02:15:29 +0200 Subject: [PATCH] u --- docs/api/README.md | 2 + docs/api/openapi.json | 196 +++++++++++++++++++++++++++++++++ docs/api/routes/image/get.md | 33 ++++++ docs/api/routes/thumb/get.md | 33 ++++++ scripts/generateApiDocs.js | 1 + src/api/routes/pic.js | 207 +++++++++++++++++++++++++++++++++++ src/db/article.js | 10 ++ src/db/pic.js | 11 ++ src/db/store.js | 1 + 9 files changed, 494 insertions(+) create mode 100644 docs/api/routes/image/get.md create mode 100644 docs/api/routes/thumb/get.md create mode 100644 src/api/routes/pic.js create mode 100644 src/db/pic.js diff --git a/docs/api/README.md b/docs/api/README.md index a995e55..6c910fe 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -47,6 +47,8 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route | [`routes/company/delete.md`](routes/company/delete.md) | `DELETE /company/{id}` | | [`routes/company/update.md`](routes/company/update.md) | `PUT /company/{id}` | | [`routes/health.md`](routes/health.md) | `GET /health` | +| [`routes/image/get.md`](routes/image/get.md) | `GET /image/{fullHash}` | +| [`routes/thumb/get.md`](routes/thumb/get.md) | `GET /thumb/{fullHash}` | Machine-readable spec: [`openapi.json`](openapi.json). diff --git a/docs/api/openapi.json b/docs/api/openapi.json index 86e24fa..0bb4653 100644 --- a/docs/api/openapi.json +++ b/docs/api/openapi.json @@ -31,9 +31,205 @@ { "name": "Company", "description": "Company CRUD" + }, + { + "name": "Image", + "description": "Content-addressed AVIF by fullHash" } ], "paths": { + "/image/{fullHash}": { + "get": { + "tags": [ + "Image" + ], + "summary": "Full image by hash", + "description": "Returns the full AVIF by `pic.hash` (SHA-256 hex).\nAuth via article category access. Browser-cacheable (`private`, `immutable`, `ETag`).\n", + "security": [ + { + "cookieAuth": [] + } + ], + "x-markdown": "image/get.md", + "parameters": [ + { + "in": "path", + "name": "fullHash", + "required": true, + "schema": { + "type": "string", + "pattern": "^[a-fA-F0-9]{64}$" + }, + "description": "SHA-256 hex of the full image bytes" + } + ], + "responses": { + "200": { + "description": "AVIF image bytes", + "headers": { + "Cache-Control": { + "schema": { + "type": "string", + "example": "private, max-age=31536000, immutable" + } + }, + "ETag": { + "schema": { + "type": "string" + } + } + }, + "content": { + "image/avif": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "304": { + "description": "Not modified (If-None-Match matched ETag)" + }, + "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": "Image not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/thumb/{fullHash}": { + "get": { + "tags": [ + "Image" + ], + "summary": "Thumb image by hash", + "description": "Returns the thumb AVIF for `pic.hash` (fullHash — not thumbhash).\nAuth via article category access. Browser-cacheable (`private`, `immutable`, `ETag`).\n", + "security": [ + { + "cookieAuth": [] + } + ], + "x-markdown": "thumb/get.md", + "parameters": [ + { + "in": "path", + "name": "fullHash", + "required": true, + "schema": { + "type": "string", + "pattern": "^[a-fA-F0-9]{64}$" + }, + "description": "SHA-256 hex of the full image bytes" + } + ], + "responses": { + "200": { + "description": "AVIF thumb bytes", + "headers": { + "Cache-Control": { + "schema": { + "type": "string", + "example": "private, max-age=31536000, immutable" + } + }, + "ETag": { + "schema": { + "type": "string" + } + } + }, + "content": { + "image/avif": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "304": { + "description": "Not modified (If-None-Match matched ETag)" + }, + "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": "Image not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, "/health": { "get": { "tags": [ diff --git a/docs/api/routes/image/get.md b/docs/api/routes/image/get.md new file mode 100644 index 0000000..577e6bd --- /dev/null +++ b/docs/api/routes/image/get.md @@ -0,0 +1,33 @@ +# `GET /image/{fullHash}` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +Full image by hash + +Returns the full AVIF by `pic.hash` (SHA-256 hex). +Auth via article category access. Browser-cacheable (`private`, `immutable`, `ETag`). + + +## Path parameters + +| Param | Required | Type | Description | +|-------|----------|------|-------------| +| `fullHash` | yes | string | SHA-256 hex of the full image bytes | + +## Response `200` + +AVIF image bytes + +## Errors + +| Status | Description | +|--------|-------------| +| 304 | Not modified (If-None-Match matched ETag) | +| 400 | Invalid hash — `{"error":"…"}` | +| 401 | Unauthorized — `{"error":"…"}` | +| 403 | Not authorized — `{"error":"…"}` | +| 404 | Image not found — `{"error":"…"}` | + +--- + +_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._ diff --git a/docs/api/routes/thumb/get.md b/docs/api/routes/thumb/get.md new file mode 100644 index 0000000..825d48d --- /dev/null +++ b/docs/api/routes/thumb/get.md @@ -0,0 +1,33 @@ +# `GET /thumb/{fullHash}` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +Thumb image by hash + +Returns the thumb AVIF for `pic.hash` (fullHash — not thumbhash). +Auth via article category access. Browser-cacheable (`private`, `immutable`, `ETag`). + + +## Path parameters + +| Param | Required | Type | Description | +|-------|----------|------|-------------| +| `fullHash` | yes | string | SHA-256 hex of the full image bytes | + +## Response `200` + +AVIF thumb bytes + +## Errors + +| Status | Description | +|--------|-------------| +| 304 | Not modified (If-None-Match matched ETag) | +| 400 | Invalid hash — `{"error":"…"}` | +| 401 | Unauthorized — `{"error":"…"}` | +| 403 | Not authorized — `{"error":"…"}` | +| 404 | Image 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 81ac36c..4516596 100644 --- a/scripts/generateApiDocs.js +++ b/scripts/generateApiDocs.js @@ -33,6 +33,7 @@ const options = { { name: 'Article', description: 'Articles under categories' }, { name: 'Category', description: 'Category tree and membership' }, { name: 'Company', description: 'Company CRUD' }, + { name: 'Image', description: 'Content-addressed AVIF by fullHash' }, ], }, apis: [ diff --git a/src/api/routes/pic.js b/src/api/routes/pic.js new file mode 100644 index 0000000..ee9505a --- /dev/null +++ b/src/api/routes/pic.js @@ -0,0 +1,207 @@ +import * as db from '../../db/pic.js'; + +const FULL_HASH_RE = /^[a-fA-F0-9]{64}$/; +/** Content-addressed by SHA-256 — safe for long-lived browser cache (auth still required on first fetch). */ +const CACHE_CONTROL = 'private, max-age=31536000, immutable'; + +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 picErrorStatus(error) { + if (error.message === 'Not authorized') return 403; + if (error.message === 'Image not found') return 404; + return 500; +} + +function sendImmutableAvif(req, res, { buffer, contentType, fullHash }) { + const etag = `"${fullHash}"`; + res.setHeader('Content-Type', contentType); + res.setHeader('Cache-Control', CACHE_CONTROL); + res.setHeader('ETag', etag); + res.setHeader('Content-Length', String(buffer.length)); + + const ifNoneMatch = req.headers['if-none-match']; + if (ifNoneMatch === etag || ifNoneMatch === fullHash) { + return res.status(304).end(); + } + + return res.status(200).send(buffer); +} + +/** + * Registers content-addressed image routes (requires auth). + * @param {import('express').Application} app + * @param {string} [basePath=''] + */ +export function register(app, basePath = '') { + /** + * @openapi + * /image/{fullHash}: + * get: + * tags: [Image] + * summary: Full image by hash + * description: | + * Returns the full AVIF by `pic.hash` (SHA-256 hex). + * Auth via article category access. Browser-cacheable (`private`, `immutable`, `ETag`). + * security: + * - cookieAuth: [] + * x-markdown: image/get.md + * parameters: + * - in: path + * name: fullHash + * required: true + * schema: + * type: string + * pattern: '^[a-fA-F0-9]{64}$' + * description: SHA-256 hex of the full image bytes + * responses: + * 200: + * description: AVIF image bytes + * headers: + * Cache-Control: + * schema: + * type: string + * example: private, max-age=31536000, immutable + * ETag: + * schema: + * type: string + * content: + * image/avif: + * schema: + * type: string + * format: binary + * 304: + * description: Not modified (If-None-Match matched ETag) + * 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: Image not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + */ + app.get(`${basePath}/image/:fullHash`, async (req, res) => { + const parsed = parseFullHash(req.params.fullHash); + if (parsed.error) { + return res.status(400).json({ error: parsed.error }); + } + + try { + const image = await db.getImageByHash(req.user.id, parsed.fullHash); + sendImmutableAvif(req, res, { + buffer: image.buffer, + contentType: image.contentType, + fullHash: parsed.fullHash, + }); + } catch (error) { + const status = picErrorStatus(error); + if (status === 500) console.error('Get image by hash failed:', error); + res.status(status).json({ error: error.message }); + } + }); + + /** + * @openapi + * /thumb/{fullHash}: + * get: + * tags: [Image] + * summary: Thumb image by hash + * description: | + * Returns the thumb AVIF for `pic.hash` (fullHash — not thumbhash). + * Auth via article category access. Browser-cacheable (`private`, `immutable`, `ETag`). + * security: + * - cookieAuth: [] + * x-markdown: thumb/get.md + * parameters: + * - in: path + * name: fullHash + * required: true + * schema: + * type: string + * pattern: '^[a-fA-F0-9]{64}$' + * description: SHA-256 hex of the full image bytes + * responses: + * 200: + * description: AVIF thumb bytes + * headers: + * Cache-Control: + * schema: + * type: string + * example: private, max-age=31536000, immutable + * ETag: + * schema: + * type: string + * content: + * image/avif: + * schema: + * type: string + * format: binary + * 304: + * description: Not modified (If-None-Match matched ETag) + * 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: Image not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + */ + app.get(`${basePath}/thumb/:fullHash`, async (req, res) => { + const parsed = parseFullHash(req.params.fullHash); + if (parsed.error) { + return res.status(400).json({ error: parsed.error }); + } + + try { + const image = await db.getThumbByHash(req.user.id, parsed.fullHash); + sendImmutableAvif(req, res, { + buffer: image.buffer, + contentType: image.contentType, + fullHash: parsed.fullHash, + }); + } catch (error) { + const status = picErrorStatus(error); + if (status === 500) console.error('Get thumb by hash failed:', error); + res.status(status).json({ error: error.message }); + } + }); +} diff --git a/src/db/article.js b/src/db/article.js index 745d3ab..5e7ba4d 100644 --- a/src/db/article.js +++ b/src/db/article.js @@ -19,3 +19,13 @@ export async function addImage(googleAuthId, sku, imageBuffer) { const store = await getStore(); return store.article.addImage(googleAuthId, sku, imageBuffer); } + +export async function deleteImage(googleAuthId, sku, fullHash) { + const store = await getStore(); + return store.article.deleteImage(googleAuthId, sku, fullHash); +} + +export async function orderImages(googleAuthId, sku, data) { + const store = await getStore(); + return store.article.orderImages(googleAuthId, sku, data); +} diff --git a/src/db/pic.js b/src/db/pic.js new file mode 100644 index 0000000..23dc12b --- /dev/null +++ b/src/db/pic.js @@ -0,0 +1,11 @@ +import { getStore } from './store.js'; + +export async function getImageByHash(googleAuthId, fullHash) { + const store = await getStore(); + return store.pic.getImageByHash(googleAuthId, fullHash); +} + +export async function getThumbByHash(googleAuthId, fullHash) { + const store = await getStore(); + return store.pic.getThumbByHash(googleAuthId, fullHash); +} diff --git a/src/db/store.js b/src/db/store.js index 7b970d6..a223112 100644 --- a/src/db/store.js +++ b/src/db/store.js @@ -12,6 +12,7 @@ const STORE_NAMESPACES = [ 'company', 'category', 'article', + 'pic', 'lang', 'tax', 'google_auth',