From 5d2758bcce7a6ed09273f8a43331a5f0c79dfeda Mon Sep 17 00:00:00 2001 From: Simon Abler Date: Wed, 11 Mar 2026 17:11:25 +0000 Subject: [PATCH] =?UTF-8?q?fix(barcode):=20validate=20EAN-13/8,=20UPC-A,?= =?UTF-8?q?=20ITF-14=20before=20bwip-js=20=E2=80=94=20frontend=20+=20backe?= =?UTF-8?q?nd?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: bwip-js threw opaque internal errors (bwipp.ean13badLength, bwipp.ean13badCheckDigit) that escaped as unhandled 500s with a Content-Type mismatch warning. The frontend had no format validation and fired requests on every keystroke regardless of digit count. Backend (barcodes.service.ts): New private validateStandardText(type, text) method called at the top of both toPng() and toSvg() before bwip-js is invoked: EAN-13: must match /^\d{12,13}$/, check digit verified if 13 digits given EAN-8: must match /^\d{7,8}$/, check digit verified if 8 digits given UPC-A: must match /^\d{11,12}$/, check digit verified if 12 digits given ITF-14: must match /^\d{13,14}$/ CODE128 / CODE39 / PDF417 / DATAMATRIX: no pre-validation (bwip handles) Throws BadRequestException with a clear, human-readable message. Private eanCheckDigit() / eanCheckDigitValid() helpers implement the standard Luhn-style EAN weight (odd×1, even×3). Frontend (barcode-editor-item.component.ts/.html): New barcodeTextValidator(getType) ValidatorFn mirrors the backend rules. Applied via addValidators() on the text control; re-runs when type changes. Invalid requests are blocked (EMPTY returned from switchMap). Validation error shown inline below the text input (is-invalid + feedback). API errors from catchError are also surfaced via errorMsg. --- .../src/app/barcode/barcodes.service.ts | 59 ++++++++++++++ .../barcode-editor-item.component.html | 8 ++ .../barcode/barcode-editor-item.component.ts | 76 ++++++++++++++++++- 3 files changed, 141 insertions(+), 2 deletions(-) diff --git a/apps/server/src/app/barcode/barcodes.service.ts b/apps/server/src/app/barcode/barcodes.service.ts index 41a7162..7a904ee 100644 --- a/apps/server/src/app/barcode/barcodes.service.ts +++ b/apps/server/src/app/barcode/barcodes.service.ts @@ -27,6 +27,7 @@ export class BarcodesService { scale?: number; height?: number; }): Promise { + this.validateStandardText(params.type, params.text); try { const { type, text, includetext = false, scale = 3, height } = params; const opts: any = { bcid: type, text, includetext, scale }; @@ -54,6 +55,64 @@ export class BarcodesService { } } + + // --------------------------------------------------------------------------- + // Standard barcode pre-validation (runs before bwip-js to give clear errors) + // --------------------------------------------------------------------------- + + /** + * Validates the text payload for standard barcode types that have strict + * format requirements. Throws BadRequestException with a clear message so + * the bwip-js opaque error never reaches the client. + */ + private validateStandardText(type: StandardBarcodeType, text: string): void { + const t = text.trim(); + + switch (type) { + case StandardBarcodeType.EAN13: { + if (!/^\d{12,13}$/.test(t)) + throw new BadRequestException('EAN-13 requires 12 or 13 digits (check digit is optional — it will be verified).'); + if (t.length === 13 && !this.eanCheckDigitValid(t)) + throw new BadRequestException(`EAN-13 check digit is wrong. Expected ${this.eanCheckDigit(t.slice(0, 12))}, got ${t[12]}.`); + break; + } + case StandardBarcodeType.EAN8: { + if (!/^\d{7,8}$/.test(t)) + throw new BadRequestException('EAN-8 requires 7 or 8 digits (check digit is optional — it will be verified).'); + if (t.length === 8 && !this.eanCheckDigitValid(t)) + throw new BadRequestException(`EAN-8 check digit is wrong. Expected ${this.eanCheckDigit(t.slice(0, 7))}, got ${t[7]}.`); + break; + } + case StandardBarcodeType.UPCA: { + if (!/^\d{11,12}$/.test(t)) + throw new BadRequestException('UPC-A requires 11 or 12 digits (check digit is optional — it will be verified).'); + if (t.length === 12 && !this.eanCheckDigitValid(t)) + throw new BadRequestException(`UPC-A check digit is wrong. Expected ${this.eanCheckDigit(t.slice(0, 11))}, got ${t[11]}.`); + break; + } + case StandardBarcodeType.ITF14: { + if (!/^\d{13,14}$/.test(t)) + throw new BadRequestException('ITF-14 requires 13 or 14 digits.'); + break; + } + // CODE128, CODE39, PDF417, DATAMATRIX accept arbitrary content — bwip handles it + } + } + + /** Compute EAN/UPC check digit for a digit string (without check digit). */ + private eanCheckDigit(digits: string): string { + let sum = 0; + for (let i = 0; i < digits.length; i++) { + sum += parseInt(digits[i], 10) * (i % 2 === 0 ? 1 : 3); + } + return String((10 - (sum % 10)) % 10); + } + + /** Verify that the last character is the correct EAN check digit. */ + private eanCheckDigitValid(full: string): boolean { + return this.eanCheckDigit(full.slice(0, -1)) === full[full.length - 1]; + } + // --------------------------------------------------------------------------- // GS1 — shared helpers // --------------------------------------------------------------------------- diff --git a/apps/simonapi/src/app/features/barcode/barcode-editor-item.component.html b/apps/simonapi/src/app/features/barcode/barcode-editor-item.component.html index 8532144..f5a102d 100644 --- a/apps/simonapi/src/app/features/barcode/barcode-editor-item.component.html +++ b/apps/simonapi/src/app/features/barcode/barcode-editor-item.component.html @@ -52,8 +52,16 @@ type="text" formControlName="text" placeholder="Text / Numbers" + [class.is-invalid]="form.controls['text'].errors?.['barcodeFormat']" /> + @if (form.controls['text'].errors?.['barcodeFormat']) { +
+ ⚠️ {{ form.controls['text'].errors?.['barcodeFormat'] }} +
+ } @else if (errorMsg) { +
⚠️ {{ errorMsg }}
+ }
diff --git a/apps/simonapi/src/app/features/barcode/barcode-editor-item.component.ts b/apps/simonapi/src/app/features/barcode/barcode-editor-item.component.ts index 7972a17..fbc860b 100644 --- a/apps/simonapi/src/app/features/barcode/barcode-editor-item.component.ts +++ b/apps/simonapi/src/app/features/barcode/barcode-editor-item.component.ts @@ -1,12 +1,65 @@ import { Component, EventEmitter, OnDestroy, OnInit, Output, Input, inject } from '@angular/core'; import { CommonModule } from '@angular/common'; -import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { AbstractControl, FormBuilder, ReactiveFormsModule, ValidationErrors, ValidatorFn, Validators } from '@angular/forms'; import { LucideAngularModule, BarcodeIcon, Trash2Icon } from 'lucide-angular'; import { BarcodeService } from './barcode.service'; import { BarcodeRequest, StandardBarcodeType } from './models'; import { Subject, EMPTY } from 'rxjs'; import { catchError, debounceTime, distinctUntilChanged, map, startWith, switchMap, takeUntil } from 'rxjs/operators'; + +/** EAN check-digit calculation (Luhn-style, same logic as backend). */ +function eanCheckDigit(digits: string): string { + let sum = 0; + for (let i = 0; i < digits.length; i++) { + sum += parseInt(digits[i], 10) * (i % 2 === 0 ? 1 : 3); + } + return String((10 - (sum % 10)) % 10); +} + +/** + * Returns a ValidatorFn that checks barcode text against the selected type. + * The validator is re-created each time the type changes via setValidators(). + */ +function barcodeTextValidator(getType: () => string): ValidatorFn { + return (ctrl: AbstractControl): ValidationErrors | null => { + const text: string = (ctrl.value ?? '').trim(); + if (!text) return null; // required is handled by Validators.required + const type = getType(); + + switch (type) { + case 'ean13': { + if (!/^\d{12,13}$/.test(text)) + return { barcodeFormat: 'EAN-13: exactly 12 or 13 digits required' }; + if (text.length === 13 && eanCheckDigit(text.slice(0, 12)) !== text[12]) + return { barcodeFormat: `EAN-13: wrong check digit (expected ${eanCheckDigit(text.slice(0, 12))})` }; + return null; + } + case 'ean8': { + if (!/^\d{7,8}$/.test(text)) + return { barcodeFormat: 'EAN-8: exactly 7 or 8 digits required' }; + if (text.length === 8 && eanCheckDigit(text.slice(0, 7)) !== text[7]) + return { barcodeFormat: `EAN-8: wrong check digit (expected ${eanCheckDigit(text.slice(0, 7))})` }; + return null; + } + case 'upca': { + if (!/^\d{11,12}$/.test(text)) + return { barcodeFormat: 'UPC-A: exactly 11 or 12 digits required' }; + if (text.length === 12 && eanCheckDigit(text.slice(0, 11)) !== text[11]) + return { barcodeFormat: `UPC-A: wrong check digit (expected ${eanCheckDigit(text.slice(0, 11))})` }; + return null; + } + case 'itf14': { + if (!/^\d{13,14}$/.test(text)) + return { barcodeFormat: 'ITF-14: exactly 13 or 14 digits required' }; + return null; + } + default: + return null; + } + }; +} + @Component({ standalone: true, selector: 'app-barcode-editor-item', @@ -26,6 +79,7 @@ export class BarcodeEditorItemComponent implements OnInit, OnDestroy { previewUrl: string | null = null; loading = false; + errorMsg: string | null = null; private destroy$ = new Subject(); form = this.fb.group({ @@ -37,12 +91,29 @@ export class BarcodeEditorItemComponent implements OnInit, OnDestroy { }); ngOnInit(): void { + // Attach type-aware validator and re-run it when the type changes + const textCtrl = this.form.controls['text']; + const getType = () => this.form.controls['type'].value; + textCtrl.addValidators(barcodeTextValidator(getType)); + + this.form.controls['type'].valueChanges.pipe(takeUntil(this.destroy$)).subscribe(() => { + textCtrl.updateValueAndValidity(); + }); + this.form.valueChanges.pipe( startWith(this.form.getRawValue()), map(() => this.buildReq()), distinctUntilChanged((a, b) => JSON.stringify(a) === JSON.stringify(b)), debounceTime(300), switchMap(req => { + // Show validation error inline instead of firing the request + const formatErr = textCtrl.errors?.['barcodeFormat']; + if (formatErr) { + this.errorMsg = formatErr; + this.revokePreview(); + return EMPTY; + } + this.errorMsg = null; if (!this.form.valid || !req.text) { this.revokePreview(); return EMPTY; @@ -50,8 +121,9 @@ export class BarcodeEditorItemComponent implements OnInit, OnDestroy { this.loading = true; return this.api.preview$(req).pipe( catchError(err => { - console.error(err); + this.errorMsg = err?.error?.message ?? err?.message ?? 'Preview failed'; this.revokePreview(); + this.loading = false; return EMPTY; }) );