From 671064bf3fbcd4610794298f40baef5dc6ca1809 Mon Sep 17 00:00:00 2001 From: seb Date: Sat, 1 Aug 2026 01:36:49 +0200 Subject: [PATCH] u --- README.md | 24 +++++++++++++- scripts/test-api.js | 52 +++++++++++++++++++++++++++++++ src/index.js | 7 +++++ src/schema.js | 2 +- src/sql/pic.js | 24 ++++++++++++-- src/store/article/addImage.js | 2 +- src/store/pic/getImageByHash.js | 28 +++++++++++++++++ src/store/pic/getThumbByHash.js | 27 ++++++++++++++++ src/store/pic/resolvePicAccess.js | 20 ++++++++++++ 9 files changed, 181 insertions(+), 5 deletions(-) create mode 100644 src/store/pic/getImageByHash.js create mode 100644 src/store/pic/getThumbByHash.js create mode 100644 src/store/pic/resolvePicAccess.js diff --git a/README.md b/README.md index 129be7d..10f47f1 100644 --- a/README.md +++ b/README.md @@ -146,7 +146,7 @@ Backend mapping: | `updateBySku` | `(googleAuthId, sku, { name })` | same shape as `getBySku` | | `addImage` | `(googleAuthId, sku, imageBuffer)` | image result (see below) | -`addImage` accepts a full-size AVIF `Buffer`. Full image: if already AVIF and ≤1000×1000, stored as-is; otherwise scaled to max 1000×1000 (`pic.binary`). Thumb always scaled to max 200×200 (`pic.thumbbinary`) via `sharp`. Computes `hash` / `thumbhash`, links `articlepic`. Auth like `getBySku`. Errors: `Article not found`, `Not authorized`. +`addImage` accepts a full-size AVIF `Buffer`. Full image: if already AVIF and ≤1000×1000, stored as-is; otherwise scaled to max 1000×1000 (`pic.fullbinary`). Thumb always scaled to max 200×200 (`pic.thumbbinary`) via `sharp`. Computes `hash` / `thumbhash`, links `articlepic`. Auth like `getBySku`. Errors: `Article not found`, `Not authorized`. `addImage` returns: @@ -188,6 +188,26 @@ Auth via the article's `categoryarticle` category + `resolveCategoryAccess`. Err --- +## `pic` + +Lookup by `pic.hash` (SHA-256 hex from `addImage`), not `thumbhash`. + +| Method | Signature | Returns | +| --- | --- | --- | +| `getImageByHash` | `(googleAuthId, hash)` | `{ buffer, w, h, size, contentType }` | +| `getThumbByHash` | `(googleAuthId, hash)` | `{ buffer, w, h, contentType }` | + +Auth via `articlepic` → `categoryarticle` → `resolveCategoryAccess`. Errors: `Image not found`, `Not authorized`. + +`contentType` is always `image/avif`. In dashback: + +```js +const { buffer, contentType } = await store.pic.getImageByHash(req.user.id, hash); +res.set('Content-Type', contentType).send(buffer); +``` + +--- + ## Backend route cheatsheet | Route | Store call | @@ -204,6 +224,8 @@ Auth via the article's `categoryarticle` category + `resolveCategoryAccess`. Err | `GET /api/article/:sku` | `article.getBySku(googleAuthId, sku)` | | `PATCH /api/article/:sku` | `article.updateBySku(googleAuthId, sku, { name })` | | `POST /api/article/:sku/image` | `article.addImage(googleAuthId, sku, imageBuffer)` | +| `GET /api/image/:hash` | `pic.getImageByHash(googleAuthId, hash)` | +| `GET /api/image/:hash/thumb` | `pic.getThumbByHash(googleAuthId, hash)` | --- diff --git a/scripts/test-api.js b/scripts/test-api.js index 4a9bbb8..7d0f1cb 100644 --- a/scripts/test-api.js +++ b/scripts/test-api.js @@ -127,6 +127,21 @@ const MENU = { }, ], }, + pic: { + label: 'pic', + items: [ + { + value: 'pic.getImageByHash', + label: 'getImageByHash', + hint: 'googleAuthId, hash → meta', + }, + { + value: 'pic.getThumbByHash', + label: 'getThumbByHash', + hint: 'googleAuthId, hash → meta', + }, + ], + }, companyuser: { label: 'companyuser', items: [ @@ -443,6 +458,43 @@ async function runMethod(client, action) { await client.article.addImage(Number(googleAuthId), sku, image), ]; } + case 'pic.getImageByHash': { + const googleAuthId = await askInt('google_auth id'); + if (googleAuthId == null) return; + const hash = await askText('hash (SHA-256 hex)', { + validate: (v) => (!v?.trim() ? 'Required' : undefined), + }); + if (hash == null) return; + const image = await client.pic.getImageByHash(Number(googleAuthId), hash.trim()); + return [ + 'result', + { + w: image.w, + h: image.h, + size: image.size, + contentType: image.contentType, + bufferLength: image.buffer.length, + }, + ]; + } + case 'pic.getThumbByHash': { + const googleAuthId = await askInt('google_auth id'); + if (googleAuthId == null) return; + const hash = await askText('hash (SHA-256 hex)', { + validate: (v) => (!v?.trim() ? 'Required' : undefined), + }); + if (hash == null) return; + const image = await client.pic.getThumbByHash(Number(googleAuthId), hash.trim()); + return [ + 'result', + { + w: image.w, + h: image.h, + contentType: image.contentType, + bufferLength: image.buffer.length, + }, + ]; + } case 'companyuser.add': { const companyId = await askInt('Company id'); if (companyId == null) return; diff --git a/src/index.js b/src/index.js index 67fa7d9..c5c6a1d 100644 --- a/src/index.js +++ b/src/index.js @@ -27,6 +27,8 @@ import googleAuthGetByEmail from './store/google_auth/getByEmail.js'; import googleAuthListAllowedEmails from './store/google_auth/listAllowedEmails.js'; import googleAuthRemoveAllowedEmail from './store/google_auth/removeAllowedEmail.js'; import googleAuthSetSelectedCompany from './store/google_auth/setSelectedCompany.js'; +import picGetImageByHash from './store/pic/getImageByHash.js'; +import picGetThumbByHash from './store/pic/getThumbByHash.js'; import storePurge from './store/purge.js'; import storeInitialize from './store/initialize.js'; @@ -84,6 +86,11 @@ export default class LibDashStore { setSelectedCompany: googleAuthSetSelectedCompany(pool), }; + this.pic = { + getImageByHash: picGetImageByHash(pool), + getThumbByHash: picGetThumbByHash(pool), + }; + this.purge = storePurge(pool); this.initialize = storeInitialize(pool); diff --git a/src/schema.js b/src/schema.js index d8bb552..29ecb8f 100644 --- a/src/schema.js +++ b/src/schema.js @@ -91,7 +91,7 @@ BEGIN thumbh INT NOT NULL, thumbhash NVARCHAR(64) NOT NULL, source NVARCHAR(MAX) NOT NULL, - [binary] VARBINARY(MAX) NOT NULL, + fullbinary VARBINARY(MAX) NOT NULL, thumbbinary VARBINARY(MAX) NOT NULL ); END diff --git a/src/sql/pic.js b/src/sql/pic.js index 270201b..1867592 100644 --- a/src/sql/pic.js +++ b/src/sql/pic.js @@ -1,9 +1,29 @@ export const CREATE = ` INSERT INTO pic ( - w, h, size, hash, thumbw, thumbh, thumbhash, source, [binary], thumbbinary + w, h, size, hash, thumbw, thumbh, thumbhash, source, fullbinary, thumbbinary ) OUTPUT INSERTED.id VALUES ( - @w, @h, @size, @hash, @thumbw, @thumbh, @thumbhash, @source, @binary, @thumbbinary + @w, @h, @size, @hash, @thumbw, @thumbh, @thumbhash, @source, @fullbinary, @thumbbinary ) `; + +export const GET_CATEGORY_ID_BY_HASH = ` +SELECT TOP 1 ca.category_id AS categoryId +FROM pic p +INNER JOIN articlepic ap ON ap.pic_id = p.id +INNER JOIN categoryarticle ca ON ca.article_id = ap.article_id +WHERE p.hash = @hash +`; + +export const GET_FULL_BY_HASH = ` +SELECT w, h, size, fullbinary AS buffer +FROM pic +WHERE hash = @hash +`; + +export const GET_THUMB_BY_HASH = ` +SELECT thumbw AS w, thumbh AS h, thumbbinary AS buffer +FROM pic +WHERE hash = @hash +`; diff --git a/src/store/article/addImage.js b/src/store/article/addImage.js index 52c07ba..9f4541c 100644 --- a/src/store/article/addImage.js +++ b/src/store/article/addImage.js @@ -42,7 +42,7 @@ export default function addImage(pool) { .input('thumbh', processed.thumb.h) .input('thumbhash', processed.thumb.hash) .input('source', '') - .input('binary', processed.full.buffer) + .input('fullbinary', processed.full.buffer) .input('thumbbinary', processed.thumb.buffer) .query(CREATE_PIC); diff --git a/src/store/pic/getImageByHash.js b/src/store/pic/getImageByHash.js new file mode 100644 index 0000000..c41c908 --- /dev/null +++ b/src/store/pic/getImageByHash.js @@ -0,0 +1,28 @@ +import { GET_FULL_BY_HASH } from '../../sql/pic.js'; +import resolvePicAccess from './resolvePicAccess.js'; + +export default function getImageByHash(pool) { + const authorize = resolvePicAccess(pool); + + return async (googleAuthId, hash) => { + await authorize(googleAuthId, hash); + + const result = await pool + .request() + .input('hash', hash) + .query(GET_FULL_BY_HASH); + + const row = result.recordset[0]; + if (!row?.buffer) { + throw new Error('Image not found'); + } + + return { + buffer: row.buffer, + w: row.w, + h: row.h, + size: row.size, + contentType: 'image/avif', + }; + }; +} diff --git a/src/store/pic/getThumbByHash.js b/src/store/pic/getThumbByHash.js new file mode 100644 index 0000000..777ce3a --- /dev/null +++ b/src/store/pic/getThumbByHash.js @@ -0,0 +1,27 @@ +import { GET_THUMB_BY_HASH } from '../../sql/pic.js'; +import resolvePicAccess from './resolvePicAccess.js'; + +export default function getThumbByHash(pool) { + const authorize = resolvePicAccess(pool); + + return async (googleAuthId, hash) => { + await authorize(googleAuthId, hash); + + const result = await pool + .request() + .input('hash', hash) + .query(GET_THUMB_BY_HASH); + + const row = result.recordset[0]; + if (!row?.buffer) { + throw new Error('Image not found'); + } + + return { + buffer: row.buffer, + w: row.w, + h: row.h, + contentType: 'image/avif', + }; + }; +} diff --git a/src/store/pic/resolvePicAccess.js b/src/store/pic/resolvePicAccess.js new file mode 100644 index 0000000..2e7d6ac --- /dev/null +++ b/src/store/pic/resolvePicAccess.js @@ -0,0 +1,20 @@ +import { GET_CATEGORY_ID_BY_HASH } from '../../sql/pic.js'; +import resolveCategoryAccess from '../category/resolveCategoryAccess.js'; + +export default function resolvePicAccess(pool) { + const access = resolveCategoryAccess(pool); + + return async (googleAuthId, hash) => { + const result = await pool + .request() + .input('hash', hash) + .query(GET_CATEGORY_ID_BY_HASH); + + const categoryId = result.recordset[0]?.categoryId; + if (!categoryId) { + throw new Error('Image not found'); + } + + await access(googleAuthId, categoryId); + }; +}