Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion src/lib/fontCache.test.ts
Original file line number Diff line number Diff line change
@@ -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()) {
Expand Down Expand Up @@ -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/);
});
});
43 changes: 24 additions & 19 deletions src/lib/fontCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) */
Expand All @@ -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<string, CachedFont>();
const listeners = new Set<() => void>();

Expand Down Expand Up @@ -44,19 +54,11 @@ async function registerFontFace(entry: CachedFont): Promise<void> {
}
}

// 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<CachedFont>(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 {
Expand All @@ -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<CachedFont> {
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 () => {
Expand All @@ -82,11 +91,7 @@ export async function loadFontFile(file: File, printerName: string): Promise<Cac
const fontFamily = printerNameToFamily(name);
const entry: CachedFont = { id: crypto.randomUUID(), name, dataUrl, fontFamily };
cache.set(name, entry);
try {
localStorage.setItem(LS_PREFIX + name, JSON.stringify(entry));
} catch {
// localStorage full — font stays in memory only
}
safeLocalStorageSet(LS_PREFIX + name, JSON.stringify(entry));
await registerFontFace(entry);
notify();
resolve(entry);
Expand Down
24 changes: 23 additions & 1 deletion src/lib/imageCache.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { getImage, getAllImages, putImage, removeImage } from './imageCache';
import {
getImage,
getAllImages,
putImage,
removeImage,
loadImageFile,
MAX_IMAGE_BYTES,
} from './imageCache';
import type { CachedImage } from './imageCache';

function makeFakeImage(id: string): CachedImage {
Expand Down Expand Up @@ -66,4 +73,19 @@ describe('imageCache', () => {
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/);
});
});
39 changes: 21 additions & 18 deletions src/lib/imageCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
* so they survive page reloads.
*/

import { hydrateLocalStoragePrefix, safeLocalStorageSet } from "./localStorageBucket";

export interface CachedImage {
id: string;
name: string;
Expand All @@ -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<string, CachedImage>();

// 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<CachedImage>(LS_PREFIX, (entry) => {
cache.set(entry.id, entry);
});

export function getImage(id: string): CachedImage | undefined {
return cache.get(id);
Expand All @@ -41,20 +41,23 @@ 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 {
cache.delete(id);
localStorage.removeItem(LS_PREFIX + id);
}

/** Load a File into the cache. Returns the CachedImage entry. */
export function loadImageFile(file: File): Promise<CachedImage> {
/** 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<CachedImage> {
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 = () => {
Expand Down
52 changes: 52 additions & 0 deletions src/lib/localStorageBucket.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
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" }]);
});
});

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();
}
});
});
43 changes: 43 additions & 0 deletions src/lib/localStorageBucket.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/**
* 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 are
* silently dropped — runtime hydration must never throw.
*/
export function hydrateLocalStoragePrefix<T>(
prefix: string,
accept: (entry: T) => void,
): void {
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (!key?.startsWith(prefix)) continue;
const raw = localStorage.getItem(key);
if (raw === null) continue;
try {
accept(JSON.parse(raw) as T);
} catch {
// ignore corrupt entries
}
}
Comment thread
u8array marked this conversation as resolved.
}

/**
* 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
}
}
20 changes: 20 additions & 0 deletions src/registry/registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ─────────────────────────────────────────────────────
Expand Down
11 changes: 8 additions & 3 deletions src/registry/serial.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -34,12 +34,17 @@ export const serial: ObjectTypeDefinition<SerialProps> = {
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 }) => {
Expand Down