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 }); } }); }