diff --git a/src/lib/fontCache.test.ts b/src/lib/fontCache.test.ts index 87bd494f..459382da 100644 --- a/src/lib/fontCache.test.ts +++ b/src/lib/fontCache.test.ts @@ -1,5 +1,13 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { getFont, getFontFamily, getAllFonts, loadFontFile, removeFont, subscribe } from './fontCache'; +import { + getFont, + getFontFamily, + getAllFonts, + loadFontFile, + removeFont, + subscribe, + MAX_FONT_BYTES, +} from './fontCache'; function clearCache(): void { for (const font of getAllFonts()) { @@ -120,4 +128,20 @@ describe('fontCache', () => { await loadFontFile(makeFakeFile('arial.ttf'), 'ARIAL.TTF'); expect(listener).not.toHaveBeenCalled(); }); + + // ── validation ──────────────────────────────────────────────────────────────── + + it('loadFontFile rejects files without a TTF/OTF extension', async () => { + const file = new File(['x'], 'arial.woff2', { type: 'font/woff2' }); + await expect(loadFontFile(file, 'ARIAL.WOFF2')).rejects.toThrow(/Not a TTF\/OTF font/); + }); + + it('loadFontFile rejects files above the byte cap', async () => { + const oversized = new File( + [new Uint8Array(MAX_FONT_BYTES + 1)], + 'big.ttf', + { type: 'font/ttf' }, + ); + await expect(loadFontFile(oversized, 'BIG.TTF')).rejects.toThrow(/too large/); + }); }); diff --git a/src/lib/fontCache.ts b/src/lib/fontCache.ts index 77f4891e..cfd85eb5 100644 --- a/src/lib/fontCache.ts +++ b/src/lib/fontCache.ts @@ -5,6 +5,8 @@ * so Konva (canvas) can render text using it. */ +import { hydrateLocalStoragePrefix, safeLocalStorageSet } from "./localStorageBucket"; + export interface CachedFont { id: string; /** Original printer filename e.g. "ARIAL.TTF" (uppercased for lookup) */ @@ -16,6 +18,14 @@ export interface CachedFont { } const LS_PREFIX = 'zpl-font-'; + +/** Hard cap on a single font file. Browser MIME types for fonts are + * inconsistent (TTF often arrives as `application/octet-stream` or empty); + * we accept by extension and rely on this byte cap to bound damage. */ +export const MAX_FONT_BYTES = 4 * 1024 * 1024; + +const FONT_EXT_RE = /\.(ttf|otf)$/i; + const cache = new Map(); const listeners = new Set<() => void>(); @@ -44,19 +54,11 @@ async function registerFontFace(entry: CachedFont): Promise { } } -// Hydrate from localStorage on module load -for (let i = 0; i < localStorage.length; i++) { - const key = localStorage.key(i); - if (!key?.startsWith(LS_PREFIX)) continue; - try { - const entry = JSON.parse(localStorage.getItem(key) ?? 'null') as CachedFont; - cache.set(entry.name, entry); - // Re-register fonts asynchronously — canvas renders after React mounts - void registerFontFace(entry); - } catch { - // ignore corrupt entries - } -} +hydrateLocalStoragePrefix(LS_PREFIX, (entry) => { + cache.set(entry.name, entry); + // Re-register asynchronously — canvas renders after React mounts. + void registerFontFace(entry); +}); /** Look up a cached font by printer filename (case-insensitive). */ export function getFont(printerName: string): CachedFont | undefined { @@ -72,8 +74,15 @@ export function getAllFonts(): CachedFont[] { return [...cache.values()]; } -/** Load a TTF/OTF File into the cache under the given printer font name. */ +/** Load a TTF/OTF File into the cache under the given printer font name. + * Rejects on non-TTF/OTF extension or oversized files. */ export async function loadFontFile(file: File, printerName: string): Promise { + if (!FONT_EXT_RE.test(file.name)) { + throw new Error(`Not a TTF/OTF font: ${file.name}`); + } + if (file.size > MAX_FONT_BYTES) { + throw new Error(`Font too large: ${file.name} (${file.size} bytes, max ${MAX_FONT_BYTES})`); + } return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = async () => { @@ -82,11 +91,7 @@ export async function loadFontFile(file: File, printerName: string): Promise { expect(img.width).toBe(50); expect(getAllImages()).toHaveLength(1); }); + + it('loadImageFile rejects non-image MIME types', async () => { + const file = new File(['hello'], 'bad.txt', { type: 'text/plain' }); + await expect(loadImageFile(file)).rejects.toThrow(/Not an image/); + }); + + it('loadImageFile rejects files above the byte cap', async () => { + // File constructor accepts size from chunks; pad with a buffer larger than cap. + const oversized = new File( + [new Uint8Array(MAX_IMAGE_BYTES + 1)], + 'big.png', + { type: 'image/png' }, + ); + await expect(loadImageFile(oversized)).rejects.toThrow(/too large/); + }); }); diff --git a/src/lib/imageCache.ts b/src/lib/imageCache.ts index 73558e3f..36179110 100644 --- a/src/lib/imageCache.ts +++ b/src/lib/imageCache.ts @@ -4,6 +4,8 @@ * so they survive page reloads. */ +import { hydrateLocalStoragePrefix, safeLocalStorageSet } from "./localStorageBucket"; + export interface CachedImage { id: string; name: string; @@ -17,19 +19,17 @@ export interface CachedImage { const LS_PREFIX = 'zpl-img-'; +/** Hard cap on a single image's source bytes. localStorage quota across all + * origins is ~5 MiB; capping per-image at 2 MiB stops one oversized drop + * from filling the entire cache. The UI's `accept="image/*"` is a hint + * only — this is the authoritative limit. */ +export const MAX_IMAGE_BYTES = 2 * 1024 * 1024; + const cache = new Map(); -// Hydrate from localStorage on module load -for (let i = 0; i < localStorage.length; i++) { - const key = localStorage.key(i); - if (!key?.startsWith(LS_PREFIX)) continue; - try { - const entry = JSON.parse(localStorage.getItem(key) ?? 'null') as CachedImage; - cache.set(entry.id, entry); - } catch { - // ignore corrupt entries - } -} +hydrateLocalStoragePrefix(LS_PREFIX, (entry) => { + cache.set(entry.id, entry); +}); export function getImage(id: string): CachedImage | undefined { return cache.get(id); @@ -41,11 +41,7 @@ export function getAllImages(): CachedImage[] { export function putImage(img: CachedImage): void { cache.set(img.id, img); - try { - localStorage.setItem(LS_PREFIX + img.id, JSON.stringify(img)); - } catch { - // localStorage full — image stays in memory only - } + safeLocalStorageSet(LS_PREFIX + img.id, JSON.stringify(img)); } export function removeImage(id: string): void { @@ -53,8 +49,15 @@ export function removeImage(id: string): void { localStorage.removeItem(LS_PREFIX + id); } -/** Load a File into the cache. Returns the CachedImage entry. */ -export function loadImageFile(file: File): Promise { +/** Load a File into the cache. Returns the CachedImage entry. Rejects on + * non-image MIME type, oversized files, or decode failures. */ +export async function loadImageFile(file: File): Promise { + if (!file.type.startsWith('image/')) { + throw new Error(`Not an image: ${file.name}`); + } + if (file.size > MAX_IMAGE_BYTES) { + throw new Error(`Image too large: ${file.name} (${file.size} bytes, max ${MAX_IMAGE_BYTES})`); + } return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => { diff --git a/src/lib/localStorageBucket.test.ts b/src/lib/localStorageBucket.test.ts new file mode 100644 index 00000000..80f7d405 --- /dev/null +++ b/src/lib/localStorageBucket.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect, vi } from "vitest"; +import { + hydrateLocalStoragePrefix, + safeLocalStorageSet, +} from "./localStorageBucket"; + +// The vitest setup's localStorage shim retains keys after `clear()` (sets to +// undefined), so cross-test ghost keys would leak into hydration. Each test +// uses a unique prefix instead of relying on `clear`. + +describe("hydrateLocalStoragePrefix", () => { + it("forwards parsed entries that match the prefix", () => { + localStorage.setItem("hyd-a-1", JSON.stringify({ id: "1" })); + localStorage.setItem("hyd-a-2", JSON.stringify({ id: "2" })); + localStorage.setItem("other-x", JSON.stringify({ id: "x" })); + + const seen: { id: string }[] = []; + hydrateLocalStoragePrefix<{ id: string }>("hyd-a-", (e) => seen.push(e)); + + expect(seen.map((e) => e.id).sort()).toEqual(["1", "2"]); + }); + + it("silently drops corrupt JSON", () => { + localStorage.setItem("hyd-b-good", JSON.stringify({ id: "good" })); + localStorage.setItem("hyd-b-bad", "{not-json"); + + const seen: { id: string }[] = []; + hydrateLocalStoragePrefix<{ id: string }>("hyd-b-", (e) => seen.push(e)); + + expect(seen).toEqual([{ id: "good" }]); + }); + + it("drops non-object primitives (number, bool, null, array)", () => { + localStorage.setItem("hyd-c-num", "42"); + localStorage.setItem("hyd-c-bool", "true"); + localStorage.setItem("hyd-c-null", "null"); + localStorage.setItem("hyd-c-arr", "[1,2]"); + localStorage.setItem("hyd-c-obj", JSON.stringify({ id: "ok" })); + + const seen: { id: string }[] = []; + hydrateLocalStoragePrefix<{ id: string }>("hyd-c-", (e) => seen.push(e)); + + expect(seen).toEqual([{ id: "ok" }]); + }); + + it("snapshots keys so an accept callback can mutate localStorage", () => { + localStorage.setItem("hyd-d-1", JSON.stringify({ id: "1" })); + localStorage.setItem("hyd-d-2", JSON.stringify({ id: "2" })); + localStorage.setItem("hyd-d-3", JSON.stringify({ id: "3" })); + + const seen: string[] = []; + hydrateLocalStoragePrefix<{ id: string }>("hyd-d-", (e) => { + seen.push(e.id); + // Mutating the store mid-iteration must not cause us to skip entries. + localStorage.removeItem(`hyd-d-${e.id}`); + }); + + expect(seen.sort()).toEqual(["1", "2", "3"]); + }); +}); + +describe("safeLocalStorageSet", () => { + it("writes the value when storage accepts it", () => { + safeLocalStorageSet("sls-write-key", "v"); + expect(localStorage.getItem("sls-write-key")).toBe("v"); + }); + + it("swallows quota errors", () => { + const spy = vi + .spyOn(localStorage, "setItem") + .mockImplementation(() => { + throw new Error("QuotaExceededError"); + }); + try { + expect(() => safeLocalStorageSet("sls-quota-key", "v")).not.toThrow(); + } finally { + spy.mockRestore(); + } + }); +}); diff --git a/src/lib/localStorageBucket.ts b/src/lib/localStorageBucket.ts new file mode 100644 index 00000000..609a298d --- /dev/null +++ b/src/lib/localStorageBucket.ts @@ -0,0 +1,54 @@ +/** + * Prefix-keyed localStorage helpers shared by the image / font caches. + * + * Both caches persist data-URL-bearing entries so reloads survive, but they + * must never crash the app: quota exhaustion and corrupt JSON are routine. + * These helpers centralise the two failure modes (quota on write, corrupt + * entries on hydration) so each cache module stays focused on its domain. + */ + +/** + * Iterate every localStorage entry whose key starts with `prefix`, parse it + * as JSON, and forward the parsed value to `accept`. Corrupt entries and + * non-object primitives (numbers, booleans, null, arrays) are silently + * dropped — runtime hydration must never throw, and `T` is contractually + * an object shape. + * + * Keys are snapshotted before iteration so an `accept` callback that + * removes or adds localStorage entries can't shift indexes mid-loop. + */ +export function hydrateLocalStoragePrefix( + prefix: string, + accept: (entry: T) => void, +): void { + const keys: string[] = []; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key?.startsWith(prefix)) keys.push(key); + } + for (const key of keys) { + const raw = localStorage.getItem(key); + if (raw === null) continue; + try { + const entry: unknown = JSON.parse(raw); + if (entry !== null && typeof entry === "object" && !Array.isArray(entry)) { + accept(entry as T); + } + } catch { + // ignore corrupt entries + } + } +} + +/** + * Best-effort `setItem` that swallows quota errors. Callers keep an + * in-memory copy as the authoritative state; localStorage is only the + * cross-reload survival mechanism. + */ +export function safeLocalStorageSet(key: string, value: string): void { + try { + localStorage.setItem(key, value); + } catch { + // localStorage full — caller's in-memory copy stays authoritative + } +} diff --git a/src/registry/registry.test.ts b/src/registry/registry.test.ts index 74c878d8..4d140545 100644 --- a/src/registry/registry.test.ts +++ b/src/registry/registry.test.ts @@ -384,6 +384,26 @@ describe('serial.toZPL', () => { expect(zpl).toContain('^SF1,3,Y'); expect(zpl).toContain('^FD001^FS'); }); + + it('strips ^/~ and other non-charset chars from ZPL-imported content', () => { + // contentSpec restricts to alphanumerics at input; toZPL re-applies the + // filter so ZPL-imported designs can't smuggle ^ (command), ~ (format), + // or , (parameter separator) into the ^SN start parameter or FD payload. + const zpl = def.toZPL(makeObj('serial', { + content: 'a^b,c', increment: 1, fontHeight: 30, fontWidth: 0, rotation: 'N', zplMode: 'SN', + })); + expect(zpl).toContain('^SNabc,1,Y^FDabc^FS'); + }); + + it('uses sanitized length for ^SF pad-digits', () => { + // Pad-digits must match the actually-emitted FD payload, not the raw + // (pre-sanitisation) content length. + const zpl = def.toZPL(makeObj('serial', { + content: 'ab^cd', increment: 1, fontHeight: 30, fontWidth: 0, rotation: 'N', zplMode: 'SF', + })); + // 'ab^cd' → 'abcd' (4 chars after stripping ^) + expect(zpl).toContain('^SF1,4,Y^FDabcd^FS'); + }); }); // ── registry completeness ───────────────────────────────────────────────────── diff --git a/src/registry/serial.tsx b/src/registry/serial.tsx index 43536aeb..d79ac0fe 100644 --- a/src/registry/serial.tsx +++ b/src/registry/serial.tsx @@ -1,7 +1,7 @@ import type { ObjectTypeDefinition } from '../types/ObjectType'; import { useT } from '../lib/useT'; import { inputCls, labelCls } from '../components/Properties/styles'; -import { fieldPos } from './zplHelpers'; +import { fieldPos, fdField } from './zplHelpers'; import { filterContent, type ContentSpec } from './contentSpec'; import { RotationSelect } from '../components/Properties/RotationSelect'; import { NumberInput } from '../components/Properties/NumberInput'; @@ -34,12 +34,17 @@ export const serial: ObjectTypeDefinition = { toZPL: (obj) => { const p = obj.props; const field = `${fieldPos(obj)}^A0${p.rotation},${p.fontHeight},${p.fontWidth}`; + // Re-apply the input charset filter at emit time so ZPL-imported content + // (which bypasses the in-app filter) can't smuggle ^/~ into the ^SN start + // parameter or comma-split the parameter list. fdField additionally + // hex-escapes any survivors in the FD payload — belt and suspenders. + const safe = filterContent(p.content, serialSpec); if (p.zplMode === 'SF') { // ^SF: increment, pad-digits (derived from content length), change-per-label - return `${field}^SF${p.increment},${p.content.length},Y^FD${p.content}^FS`; + return `${field}^SF${p.increment},${safe.length},Y${fdField(safe)}`; } // ^SN: start, increment, change-per-label - return `${field}^SN${p.content},${p.increment},Y^FD${p.content}^FS`; + return `${field}^SN${safe},${p.increment},Y${fdField(safe)}`; }, PropertiesPanel: ({ obj, onChange }) => {