Skip to content

Commit b61ee13

Browse files
authored
Merge pull request #22 from simonabler/22-fix-barcode-validation
fix(barcode): validate EAN-13/8, UPC-A, ITF-14 before bwip-js — front…
2 parents 76e8b16 + 5d2758b commit b61ee13

3 files changed

Lines changed: 141 additions & 2 deletions

File tree

apps/server/src/app/barcode/barcodes.service.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export class BarcodesService {
2727
scale?: number;
2828
height?: number;
2929
}): Promise<Buffer> {
30+
this.validateStandardText(params.type, params.text);
3031
try {
3132
const { type, text, includetext = false, scale = 3, height } = params;
3233
const opts: any = { bcid: type, text, includetext, scale };
@@ -54,6 +55,64 @@ export class BarcodesService {
5455
}
5556
}
5657

58+
59+
// ---------------------------------------------------------------------------
60+
// Standard barcode pre-validation (runs before bwip-js to give clear errors)
61+
// ---------------------------------------------------------------------------
62+
63+
/**
64+
* Validates the text payload for standard barcode types that have strict
65+
* format requirements. Throws BadRequestException with a clear message so
66+
* the bwip-js opaque error never reaches the client.
67+
*/
68+
private validateStandardText(type: StandardBarcodeType, text: string): void {
69+
const t = text.trim();
70+
71+
switch (type) {
72+
case StandardBarcodeType.EAN13: {
73+
if (!/^\d{12,13}$/.test(t))
74+
throw new BadRequestException('EAN-13 requires 12 or 13 digits (check digit is optional — it will be verified).');
75+
if (t.length === 13 && !this.eanCheckDigitValid(t))
76+
throw new BadRequestException(`EAN-13 check digit is wrong. Expected ${this.eanCheckDigit(t.slice(0, 12))}, got ${t[12]}.`);
77+
break;
78+
}
79+
case StandardBarcodeType.EAN8: {
80+
if (!/^\d{7,8}$/.test(t))
81+
throw new BadRequestException('EAN-8 requires 7 or 8 digits (check digit is optional — it will be verified).');
82+
if (t.length === 8 && !this.eanCheckDigitValid(t))
83+
throw new BadRequestException(`EAN-8 check digit is wrong. Expected ${this.eanCheckDigit(t.slice(0, 7))}, got ${t[7]}.`);
84+
break;
85+
}
86+
case StandardBarcodeType.UPCA: {
87+
if (!/^\d{11,12}$/.test(t))
88+
throw new BadRequestException('UPC-A requires 11 or 12 digits (check digit is optional — it will be verified).');
89+
if (t.length === 12 && !this.eanCheckDigitValid(t))
90+
throw new BadRequestException(`UPC-A check digit is wrong. Expected ${this.eanCheckDigit(t.slice(0, 11))}, got ${t[11]}.`);
91+
break;
92+
}
93+
case StandardBarcodeType.ITF14: {
94+
if (!/^\d{13,14}$/.test(t))
95+
throw new BadRequestException('ITF-14 requires 13 or 14 digits.');
96+
break;
97+
}
98+
// CODE128, CODE39, PDF417, DATAMATRIX accept arbitrary content — bwip handles it
99+
}
100+
}
101+
102+
/** Compute EAN/UPC check digit for a digit string (without check digit). */
103+
private eanCheckDigit(digits: string): string {
104+
let sum = 0;
105+
for (let i = 0; i < digits.length; i++) {
106+
sum += parseInt(digits[i], 10) * (i % 2 === 0 ? 1 : 3);
107+
}
108+
return String((10 - (sum % 10)) % 10);
109+
}
110+
111+
/** Verify that the last character is the correct EAN check digit. */
112+
private eanCheckDigitValid(full: string): boolean {
113+
return this.eanCheckDigit(full.slice(0, -1)) === full[full.length - 1];
114+
}
115+
57116
// ---------------------------------------------------------------------------
58117
// GS1 — shared helpers
59118
// ---------------------------------------------------------------------------

apps/simonapi/src/app/features/barcode/barcode-editor-item.component.html

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,16 @@
5252
type="text"
5353
formControlName="text"
5454
placeholder="Text / Numbers"
55+
[class.is-invalid]="form.controls['text'].errors?.['barcodeFormat']"
5556
/>
5657
</label>
58+
@if (form.controls['text'].errors?.['barcodeFormat']) {
59+
<div class="invalid-feedback d-block small text-danger mt-1">
60+
⚠️ {{ form.controls['text'].errors?.['barcodeFormat'] }}
61+
</div>
62+
} @else if (errorMsg) {
63+
<div class="small text-danger mt-1">⚠️ {{ errorMsg }}</div>
64+
}
5765
</div>
5866

5967
<fieldset>

apps/simonapi/src/app/features/barcode/barcode-editor-item.component.ts

Lines changed: 74 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,65 @@
11
import { Component, EventEmitter, OnDestroy, OnInit, Output, Input, inject } from '@angular/core';
22
import { CommonModule } from '@angular/common';
3-
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
3+
import { AbstractControl, FormBuilder, ReactiveFormsModule, ValidationErrors, ValidatorFn, Validators } from '@angular/forms';
44
import { LucideAngularModule, BarcodeIcon, Trash2Icon } from 'lucide-angular';
55
import { BarcodeService } from './barcode.service';
66
import { BarcodeRequest, StandardBarcodeType } from './models';
77
import { Subject, EMPTY } from 'rxjs';
88
import { catchError, debounceTime, distinctUntilChanged, map, startWith, switchMap, takeUntil } from 'rxjs/operators';
99

10+
11+
/** EAN check-digit calculation (Luhn-style, same logic as backend). */
12+
function eanCheckDigit(digits: string): string {
13+
let sum = 0;
14+
for (let i = 0; i < digits.length; i++) {
15+
sum += parseInt(digits[i], 10) * (i % 2 === 0 ? 1 : 3);
16+
}
17+
return String((10 - (sum % 10)) % 10);
18+
}
19+
20+
/**
21+
* Returns a ValidatorFn that checks barcode text against the selected type.
22+
* The validator is re-created each time the type changes via setValidators().
23+
*/
24+
function barcodeTextValidator(getType: () => string): ValidatorFn {
25+
return (ctrl: AbstractControl): ValidationErrors | null => {
26+
const text: string = (ctrl.value ?? '').trim();
27+
if (!text) return null; // required is handled by Validators.required
28+
const type = getType();
29+
30+
switch (type) {
31+
case 'ean13': {
32+
if (!/^\d{12,13}$/.test(text))
33+
return { barcodeFormat: 'EAN-13: exactly 12 or 13 digits required' };
34+
if (text.length === 13 && eanCheckDigit(text.slice(0, 12)) !== text[12])
35+
return { barcodeFormat: `EAN-13: wrong check digit (expected ${eanCheckDigit(text.slice(0, 12))})` };
36+
return null;
37+
}
38+
case 'ean8': {
39+
if (!/^\d{7,8}$/.test(text))
40+
return { barcodeFormat: 'EAN-8: exactly 7 or 8 digits required' };
41+
if (text.length === 8 && eanCheckDigit(text.slice(0, 7)) !== text[7])
42+
return { barcodeFormat: `EAN-8: wrong check digit (expected ${eanCheckDigit(text.slice(0, 7))})` };
43+
return null;
44+
}
45+
case 'upca': {
46+
if (!/^\d{11,12}$/.test(text))
47+
return { barcodeFormat: 'UPC-A: exactly 11 or 12 digits required' };
48+
if (text.length === 12 && eanCheckDigit(text.slice(0, 11)) !== text[11])
49+
return { barcodeFormat: `UPC-A: wrong check digit (expected ${eanCheckDigit(text.slice(0, 11))})` };
50+
return null;
51+
}
52+
case 'itf14': {
53+
if (!/^\d{13,14}$/.test(text))
54+
return { barcodeFormat: 'ITF-14: exactly 13 or 14 digits required' };
55+
return null;
56+
}
57+
default:
58+
return null;
59+
}
60+
};
61+
}
62+
1063
@Component({
1164
standalone: true,
1265
selector: 'app-barcode-editor-item',
@@ -26,6 +79,7 @@ export class BarcodeEditorItemComponent implements OnInit, OnDestroy {
2679

2780
previewUrl: string | null = null;
2881
loading = false;
82+
errorMsg: string | null = null;
2983
private destroy$ = new Subject<void>();
3084

3185
form = this.fb.group({
@@ -37,21 +91,39 @@ export class BarcodeEditorItemComponent implements OnInit, OnDestroy {
3791
});
3892

3993
ngOnInit(): void {
94+
// Attach type-aware validator and re-run it when the type changes
95+
const textCtrl = this.form.controls['text'];
96+
const getType = () => this.form.controls['type'].value;
97+
textCtrl.addValidators(barcodeTextValidator(getType));
98+
99+
this.form.controls['type'].valueChanges.pipe(takeUntil(this.destroy$)).subscribe(() => {
100+
textCtrl.updateValueAndValidity();
101+
});
102+
40103
this.form.valueChanges.pipe(
41104
startWith(this.form.getRawValue()),
42105
map(() => this.buildReq()),
43106
distinctUntilChanged((a, b) => JSON.stringify(a) === JSON.stringify(b)),
44107
debounceTime(300),
45108
switchMap(req => {
109+
// Show validation error inline instead of firing the request
110+
const formatErr = textCtrl.errors?.['barcodeFormat'];
111+
if (formatErr) {
112+
this.errorMsg = formatErr;
113+
this.revokePreview();
114+
return EMPTY;
115+
}
116+
this.errorMsg = null;
46117
if (!this.form.valid || !req.text) {
47118
this.revokePreview();
48119
return EMPTY;
49120
}
50121
this.loading = true;
51122
return this.api.preview$(req).pipe(
52123
catchError(err => {
53-
console.error(err);
124+
this.errorMsg = err?.error?.message ?? err?.message ?? 'Preview failed';
54125
this.revokePreview();
126+
this.loading = false;
55127
return EMPTY;
56128
})
57129
);

0 commit comments

Comments
 (0)