This commit is contained in:
seb
2026-10-07 21:23:10 +02:00
parent a29ad50b74
commit 7d537aaa6b
33 changed files with 598 additions and 327 deletions

View File

@@ -134,9 +134,9 @@
* type: number
* nullable: true
* unit:
* type: object
* $ref: '#/components/schemas/Unit'
* brand:
* type: object
* $ref: '#/components/schemas/ArticleBrand'
* nullable: true
* categoryIds:
* type: array
@@ -353,7 +353,157 @@
* type: array
* description: Brand pictures sorted by sort
* items:
* $ref: '#/components/schemas/BrandPic'
* ArticleBrand:
* type: object
* required: [name]
* properties:
* id:
* type: integer
* nullable: true
* name:
* type: string
* fullHash:
* type: string
* nullable: true
* thumbW:
* type: integer
* nullable: true
* thumbH:
* type: integer
* nullable: true
* thumbHash:
* type: string
* nullable: true
* StuecklisteItem:
* type: object
* required: [id, articleId, sku, name, count, sort]
* properties:
* id:
* type: integer
* articleId:
* type: integer
* sku:
* type: string
* name:
* type: string
* count:
* type: integer
* sort:
* type: integer
* ArticleChild:
* type: object
* required: [id, sku, name, parentsort, attributeValueId, attributeValue]
* properties:
* id:
* type: integer
* sku:
* type: string
* name:
* type: string
* parentsort:
* type: integer
* attributeValueId:
* type: integer
* attributeValue:
* type: string
* ArticleOptionItem:
* type: object
* required: [id, articleId, sku, name, count, sort, mandated]
* properties:
* id:
* type: integer
* articleId:
* type: integer
* sku:
* type: string
* name:
* type: string
* count:
* type: integer
* sort:
* type: integer
* mandated:
* type: boolean
* CategoryTreeNode:
* type: object
* required: [id, sort, posterminalIds]
* properties:
* id:
* type: integer
* parentId:
* type: integer
* nullable: true
* sort:
* type: integer
* name:
* type: string
* nullable: true
* slug:
* type: string
* nullable: true
* fullHash:
* type: string
* nullable: true
* thumbW:
* type: integer
* nullable: true
* thumbH:
* type: integer
* nullable: true
* thumbHash:
* type: string
* nullable: true
* posterminalIds:
* type: array
* items:
* type: integer
* CategoryArticle:
* type: object
* required: [id, sku, name, slug, track_stock, optionsCount, articleListCount, childCount]
* properties:
* id:
* type: integer
* sku:
* type: string
* name:
* type: string
* slug:
* type: string
* net:
* type: number
* nullable: true
* taxRate:
* type: number
* nullable: true
* track_stock:
* type: boolean
* optionsCount:
* type: integer
* articleListCount:
* type: integer
* childCount:
* type: integer
* parentArticleId:
* type: integer
* nullable: true
* parentSku:
* type: string
* nullable: true
* fullHash:
* type: string
* nullable: true
* thumbW:
* type: integer
* nullable: true
* thumbH:
* type: integer
* nullable: true
* thumbHash:
* type: string
* nullable: true
* brand:
* $ref: '#/components/schemas/ArticleBrand'
* nullable: true
* NumberSequence:
* type: object
* required: [id, type, prefix, padding, next]
@@ -1151,6 +1301,20 @@
* type: array
* items:
* type: object
* required: [manifestarticle_id, sku, name, warehouse_id, amount, text]
* properties:
* manifestarticle_id:
* type: integer
* sku:
* type: string
* name:
* type: string
* warehouse_id:
* type: integer
* amount:
* type: number
* text:
* type: string
* ledger:
* type: array
* items:

View File

@@ -11,7 +11,7 @@ import { emit } from '../sse.js';
import { buildArtikelCsv, toWindows1252 } from '../../util/artikelCsv.js';
const AVIF_BODY_LIMIT = '20mb';
const FULL_HASH_RE = /^[a-fA-F0-9]{64}$/;
const FULL_HASH_RE = /^[a-zA-Z0-9]{8}$/;
/** Notify SSE subscribers after create / rename-ish list-level article changes. */
function emitArticlesSignal(req) {
@@ -44,9 +44,9 @@ function parseSku(value) {
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 { error: 'fullHash must be an 8-char alphanumeric string' };
}
return { fullHash: fullHash.toLowerCase() };
return { fullHash };
}
function articleErrorStatus(error) {
@@ -720,7 +720,8 @@ export function register(app, basePath = '') {
* summary: Replace POS prices by SKU
* description: |
* Replace-all prices for the company POS customer group.
* Each entry: `price` (net), `tax` (rate, ensured via tax.ensure), optional `minmultiplier` (default 1).
* Each entry accepts `net` OR `gross` price (or legacy `price`), `taxRate` OR `tax` (percentage, e.g. 19 for 19%, ensured via tax.ensure), optional `minmultiplier` (default 1).
* When `gross` is provided, net price is computed as `gross / (1 + taxRate / 100)`.
* Empty `prices` clears all POS prices. Returns the same shape as GET by SKU (article `prices` array contains `{ minmultiplier, net, taxRate, customergroupId }`).
* security:
* - cookieAuth: []
@@ -744,23 +745,31 @@ export function register(app, basePath = '') {
* type: array
* items:
* type: object
* required: [price, tax]
* properties:
* net:
* type: number
* description: Net price (excl. VAT). Either `net`, `gross`, or legacy `price` is required.
* gross:
* type: number
* description: Gross price (incl. VAT). Net is computed as `gross / (1 + taxRate / 100)`.
* price:
* type: number
* description: Net price
* description: Legacy alias for `net`.
* taxRate:
* type: number
* description: Tax rate percentage (e.g. 19 for 19%, 7 for 7%, 0 for 0%).
* tax:
* type: number
* description: Tax rate (e.g. 19)
* description: Alias for `taxRate` percentage.
* minmultiplier:
* type: number
* description: Defaults to 1; must be unique per request
* description: Defaults to 1; must be unique per request.
* example:
* prices:
* - price: 10.5
* tax: 19
* - price: 9.9
* tax: 19
* - net: 10.5
* taxRate: 19
* - gross: 11.9
* taxRate: 19
* minmultiplier: 10
* responses:
* 200:
@@ -936,7 +945,7 @@ export function register(app, basePath = '') {
* required: true
* schema:
* type: string
* pattern: '^[a-fA-F0-9]{64}$'
* pattern: '^[a-zA-Z0-9]{8}$'
* responses:
* 204:
* description: Deleted
@@ -1017,11 +1026,11 @@ export function register(app, basePath = '') {
* type: array
* items:
* type: string
* pattern: '^[a-fA-F0-9]{64}$'
* pattern: '^[a-zA-Z0-9]{8}$'
* example:
* fullHashes:
* - "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
* - "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
* - "aB1cD2eF"
* - "gH3iJ4kL"
* responses:
* 200:
* description: New order
@@ -1806,20 +1815,7 @@ export function register(app, basePath = '') {
* children:
* type: array
* items:
* type: object
* properties:
* id:
* type: integer
* sku:
* type: string
* name:
* type: string
* parentsort:
* type: integer
* attributeValueId:
* type: integer
* attributeValue:
* type: string
* $ref: '#/components/schemas/ArticleChild'
* 404:
* description: Article not found
*/
@@ -1930,7 +1926,7 @@ export function register(app, basePath = '') {
* children:
* type: array
* items:
* type: object
* $ref: '#/components/schemas/ArticleChild'
* 400:
* description: Missing sku or attribute value, or invalid input
* 404:
@@ -2080,20 +2076,7 @@ export function register(app, basePath = '') {
* items:
* type: array
* items:
* type: object
* properties:
* id:
* type: integer
* articleId:
* type: integer
* sku:
* type: string
* name:
* type: string
* count:
* type: integer
* sort:
* type: integer
* $ref: '#/components/schemas/StuecklisteItem'
* 404:
* description: Article not found
*/
@@ -2161,7 +2144,7 @@ export function register(app, basePath = '') {
* items:
* type: array
* items:
* type: object
* $ref: '#/components/schemas/StuecklisteItem'
* 400:
* description: Article is a parent, has options, or contains itself
* 404:
@@ -2216,22 +2199,7 @@ export function register(app, basePath = '') {
* options:
* type: array
* items:
* type: object
* properties:
* id:
* type: integer
* articleId:
* type: integer
* sku:
* type: string
* name:
* type: string
* count:
* type: integer
* sort:
* type: integer
* mandated:
* type: boolean
* $ref: '#/components/schemas/ArticleOptionItem'
* 404:
* description: Article not found
*/
@@ -2302,7 +2270,7 @@ export function register(app, basePath = '') {
* options:
* type: array
* items:
* type: object
* $ref: '#/components/schemas/ArticleOptionItem'
* 400:
* description: Article is a parent, a Stückliste, or contains itself
* 404:

View File

@@ -74,13 +74,11 @@ export function register(app, basePath = '') {
* tree:
* type: array
* items:
* type: object
* additionalProperties: true
* $ref: '#/components/schemas/CategoryTreeNode'
* articles:
* type: array
* items:
* type: object
* additionalProperties: true
* $ref: '#/components/schemas/CategoryArticle'
* 400:
* description: No company selected
* content:
@@ -326,48 +324,7 @@ export function register(app, basePath = '') {
* articles:
* type: array
* items:
* type: object
* properties:
* id:
* type: integer
* sku:
* type: string
* name:
* type: string
* slug:
* type: string
* net:
* type: number
* nullable: true
* taxRate:
* type: number
* nullable: true
* track_stock:
* type: boolean
* optionsCount:
* type: integer
* articleListCount:
* type: integer
* childCount:
* type: integer
* description: Number of child articles belonging to this article
* fullHash:
* type: string
* nullable: true
* thumbW:
* type: integer
* nullable: true
* thumbH:
* type: integer
* nullable: true
* thumbHash:
* type: string
* nullable: true
* brand:
* type: object
* nullable: true
* additionalProperties: true
* additionalProperties: true
* $ref: '#/components/schemas/CategoryArticle'
* 400:
* description: Invalid id
* content:

View File

@@ -1,15 +1,15 @@
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 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 a 64-char hex SHA-256' };
return { error: 'fullHash must be an 8-char alphanumeric string' };
}
return { fullHash: fullHash.toLowerCase() };
return { fullHash };
}
function picErrorStatus(error) {
@@ -47,7 +47,7 @@ export function register(app, basePath = '') {
* tags: [Image]
* summary: Full image by hash
* description: |
* Returns the full AVIF by `pic.hash` (SHA-256 hex).
* Returns the full AVIF by `pic.hash` (8-char alphanumeric Base62).
* Auth via article category access. Browser-cacheable (`private`, `immutable`, `ETag`).
* security:
* - cookieAuth: []
@@ -58,8 +58,8 @@ export function register(app, basePath = '') {
* required: true
* schema:
* type: string
* pattern: '^[a-fA-F0-9]{64}$'
* description: SHA-256 hex of the full image bytes
* pattern: '^[a-zA-Z0-9]{8}$'
* description: 8-char alphanumeric hash of the full image bytes
* responses:
* 200:
* description: AVIF image bytes
@@ -143,8 +143,8 @@ export function register(app, basePath = '') {
* required: true
* schema:
* type: string
* pattern: '^[a-fA-F0-9]{64}$'
* description: SHA-256 hex of the full image bytes
* pattern: '^[a-zA-Z0-9]{8}$'
* description: 8-char alphanumeric hash of the full image bytes
* responses:
* 200:
* description: AVIF thumb bytes

View File

@@ -479,7 +479,7 @@ export function register(app, basePath = '') {
* Files are written to the company's VFS root directory and saved to the database.
* An optional `targetDir` form field specifies a subfolder within the VFS.
*
* Writing to read-only database paths (`image/*` or filenames containing 64-character hex hashes) is
* Writing to read-only database paths (`image/*` or filenames containing 8-character image hashes) is
* forbidden and automatically skipped during upload.
* security:
* - cookieAuth: []
@@ -813,7 +813,7 @@ export function register(app, basePath = '') {
* - Binary body: raw binary buffer with optional `Content-Type` header
* - Plain text: raw UTF-8 string
*
* Modifying read-only database paths (`image/*` or filenames containing 64-character hex hashes) is blocked with HTTP 403.
* Modifying read-only database paths (`image/*` or filenames containing 8-character image hashes) is blocked with HTTP 403.
* security:
* - cookieAuth: []
* x-signals: [vfs]
@@ -959,7 +959,7 @@ export function register(app, basePath = '') {
* summary: Delete a virtual filesystem file
* description: |
* Deletes a file from both the on-disk virtual filesystem and database for the authenticated user's selected company.
* Deleting read-only database files (`image/*` or filenames containing 64-character hex hashes) is blocked with HTTP 403.
* Deleting read-only database files (`image/*` or filenames containing 8-character image hashes) is blocked with HTTP 403.
* security:
* - cookieAuth: []
* x-signals: [vfs]

View File

@@ -236,7 +236,7 @@ function extractImageInputs(args = {}) {
/**
* Fetches a database image by its content hash using store or authenticated local endpoint.
* @param {string} hash 64-char SHA-256 hex
* @param {string} hash 8-char SHA-256 Base62
* @param {number} googleAuthId
* @returns {Promise<Buffer>}
*/
@@ -323,7 +323,7 @@ export function createAnalyzeImageTool({ googleAuthId, model }) {
// ── Load the bytes ────────────────────────────────────────────
const items = [];
for (const item of inputs) {
// Only a path that actually carries a 64-char content hash is a database image.
// Only a path that actually carries an 8-char content hash is a database image.
// A bare "images/…" prefix is just the VFS folder, so it must NOT be treated as
// a DB lookup or `images/candidates/x.avif` would be misread as a hash path.
const hash = extractFullHash(item.path);
@@ -355,7 +355,7 @@ export function createAnalyzeImageTool({ googleAuthId, model }) {
throw new ToolError(
`Image not found or empty: "${item.path}". ` +
(hash
? 'Database images must be addressed as "image/<fullHash>" with a 64-char hash.'
? 'Database images must be addressed as "image/<fullHash>" with an 8-char hash.'
: 'Store it first (search_images writes to "images/…") or check the path with find_files.')
);
}

View File

@@ -134,7 +134,7 @@ function resolveImagesApiUrl() {
}
/**
* Fetches a database image by its 64-char content hash using store or authenticated local endpoint.
* Fetches a database image by its 8-char content hash using store or authenticated local endpoint.
* @param {string} hash
* @param {number} googleAuthId
* @returns {Promise<Buffer>}

View File

@@ -4,7 +4,7 @@ import { getStore } from '../db/store.js';
import env from '../config/env.js';
import { getDefaultSkills } from './skill.js';
const FULL_HASH_REGEX = /^[a-fA-F0-9]{64}$/;
const FULL_HASH_REGEX = /^[a-zA-Z0-9]{8}$/;
const VIRTUAL_IMAGE_FOLDERS = new Set(['image', 'images']);
export function getMimeType(filePath) {
@@ -55,18 +55,26 @@ export function getMimeType(filePath) {
export function extractFullHash(p) {
if (typeof p !== 'string' || !p.trim()) return null;
const clean = p.trim().replace(/^vfs:\/+/i, '').replace(/^([a-zA-Z]:|\/)+/, '');
const parts = clean.split('/');
const parts = clean.split('/').filter(Boolean);
if (parts.length === 0) return null;
const first = parts[0].toLowerCase();
const last = parts[parts.length - 1];
const ext = path.extname(last).toLowerCase();
const withoutExt = last.replace(/\.[a-zA-Z0-9]+$/, '');
if (FULL_HASH_REGEX.test(withoutExt)) {
return withoutExt.toLowerCase();
}
for (const part of parts) {
const seg = part.replace(/\.[a-zA-Z0-9]+$/, '');
if (FULL_HASH_REGEX.test(seg)) {
return seg.toLowerCase();
// Under virtual image folder (e.g. image/aB1cD2eF, images/aB1cD2eF.avif)
if (VIRTUAL_IMAGE_FOLDERS.has(first)) {
if (parts.length === 2 && FULL_HASH_REGEX.test(withoutExt)) {
return withoutExt;
}
}
// Direct hash (e.g. "aB1cD2eF" or "aB1cD2eF.avif")
if (parts.length === 1 && FULL_HASH_REGEX.test(withoutExt) && (!ext || ext === '.avif')) {
return withoutExt;
}
return null;
}