import * as db from '../../db/pic.js'; const FULL_HASH_RE = /^[a-zA-Z0-9]{8}$/; /** Content-addressed by SHA-256 (Base62, 8 chars) — 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 an 8-char alphanumeric string' }; } return { fullHash }; } function picErrorStatus(error) { if (error.message === 'No company selected') return 400; 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` (8-char alphanumeric Base62). * 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-zA-Z0-9]{8}$' * description: 8-char alphanumeric hash 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' */ const imagePaths = [`${basePath}/image/:fullHash`]; if (basePath) imagePaths.push('/image/:fullHash'); app.get(imagePaths, 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-zA-Z0-9]{8}$' * description: 8-char alphanumeric hash 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' */ const thumbPaths = [`${basePath}/thumb/:fullHash`]; if (basePath) thumbPaths.push('/thumb/:fullHash'); app.get(thumbPaths, 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 }); } }); /** * @openapi * /smallthumb/{fullHash}: * get: * tags: [Image] * summary: Small thumb image by hash * description: | * Returns the small thumb AVIF (40px max edge) for `pic.hash` (fullHash — not thumbhash). * Auth via article category access. Browser-cacheable (`private`, `immutable`, `ETag`). * security: * - cookieAuth: [] * x-markdown: smallthumb/get.md * parameters: * - in: path * name: fullHash * required: true * schema: * type: string * pattern: '^[a-zA-Z0-9]{8}$' * description: 8-char alphanumeric hash of the full image bytes * responses: * 200: * description: AVIF small 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' */ const smallThumbPaths = [`${basePath}/smallthumb/:fullHash`]; if (basePath) smallThumbPaths.push('/smallthumb/:fullHash'); app.get(smallThumbPaths, 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.getSmallThumbByHash(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 smallthumb by hash failed:', error); res.status(status).json({ error: error.message }); } }); }