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

@@ -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]