From 9e99e0664f8a70f7391ca3e4da9942e0de87b0c2 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 9 Jun 2026 08:37:56 +1000 Subject: [PATCH] feat(icons): filesystem icon-set store (bundled read-only + uploads) Co-Authored-By: Claude Opus 4.8 --- lib/icons/sets.js | 52 ++++++++++++++++++++++++++++++++++++++++ tests/icons/sets.test.js | 37 ++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+) create mode 100644 lib/icons/sets.js create mode 100644 tests/icons/sets.test.js diff --git a/lib/icons/sets.js b/lib/icons/sets.js new file mode 100644 index 0000000..a452cb5 --- /dev/null +++ b/lib/icons/sets.js @@ -0,0 +1,52 @@ +// lib/icons/sets.js +import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises'; +import path from 'node:path'; + +const BUNDLED_SET = 'devices'; // read-only, ships in public/icons/devices +let setsDir = process.env.ICON_SETS_DIR || '/var/lib/void/icon-sets'; +let bundledDir = path.resolve('public/icons/devices'); +export function _setDirs({ setsDir: s, bundledDir: b }) { if (s) setsDir = s; if (b) bundledDir = b; } + +const SLUG = /^[a-z0-9-]+$/; +const FILE = /^[a-z0-9-]+\.(svg|png|jpg|jpeg)$/; +function okSet(s) { return SLUG.test(s); } + +async function listDir(dir) { + try { return (await readdir(dir)).filter(f => FILE.test(f)).sort(); } catch { return []; } +} + +export async function listSets() { + const out = [{ set: BUNDLED_SET, readonly: true, icons: await listDir(bundledDir) }]; + let uploaded = []; + try { uploaded = await readdir(setsDir, { withFileTypes: true }); } catch { /* none yet */ } + for (const d of uploaded) { + if (d.isDirectory() && okSet(d.name) && d.name !== BUNDLED_SET) { + out.push({ set: d.name, readonly: false, icons: await listDir(path.join(setsDir, d.name)) }); + } + } + return out; +} + +// Resolve an on-disk path for serving. Throws on bad slugs. +export function iconPath(set, file) { + if (!okSet(set) || !FILE.test(file)) throw new Error('bad_slug'); + return set === BUNDLED_SET ? path.join(bundledDir, file) : path.join(setsDir, set, file); +} + +export async function readIcon(set, file) { + return readFile(iconPath(set, file)); +} + +export async function writeIcon(set, name, buffer) { + if (set === BUNDLED_SET) throw new Error('reserved_set'); + if (!okSet(set) || !FILE.test(name)) throw new Error('bad_slug'); + const dir = path.join(setsDir, set); + await mkdir(dir, { recursive: true }); + await writeFile(path.join(dir, name), buffer); +} + +export async function deleteSet(set) { + if (set === BUNDLED_SET) throw new Error('reserved_set'); + if (!okSet(set)) throw new Error('bad_slug'); + await rm(path.join(setsDir, set), { recursive: true, force: true }); +} diff --git a/tests/icons/sets.test.js b/tests/icons/sets.test.js new file mode 100644 index 0000000..fe4cf54 --- /dev/null +++ b/tests/icons/sets.test.js @@ -0,0 +1,37 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { mkdtempSync, writeFileSync, mkdirSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import * as sets from '../../lib/icons/sets.js'; + +const PNG = Buffer.from([0x89,0x50,0x4e,0x47,0x0d,0x0a,0x1a,0x0a]); +let dir; +beforeEach(() => { dir = mkdtempSync(path.join(tmpdir(), 'iconsets-')); sets._setDirs({ setsDir: dir, bundledDir: path.join(dir, '__bundled') }); mkdirSync(path.join(dir, '__bundled'), { recursive: true }); writeFileSync(path.join(dir, '__bundled', 'router.svg'), ''); }); + +describe('sets store', () => { + it('lists the read-only bundled set', async () => { + const list = await sets.listSets(); + const dev = list.find(s => s.set === 'devices'); + expect(dev.readonly).toBe(true); + expect(dev.icons).toContain('router.svg'); + }); + it('writes + lists an uploaded set', async () => { + await sets.writeIcon('mine', 'nas.png', PNG); + const mine = (await sets.listSets()).find(s => s.set === 'mine'); + expect(mine.readonly).toBe(false); + expect(mine.icons).toContain('nas.png'); + }); + it('refuses to write the reserved bundled set', async () => { + await expect(sets.writeIcon('devices', 'x.png', PNG)).rejects.toThrow(); + }); + it('deletes an uploaded set, not the bundled one', async () => { + await sets.writeIcon('mine', 'a.png', PNG); + await sets.deleteSet('mine'); + expect((await sets.listSets()).find(s => s.set === 'mine')).toBeUndefined(); + await expect(sets.deleteSet('devices')).rejects.toThrow(); + }); + it('rejects bad slugs (traversal)', async () => { + await expect(sets.writeIcon('../x', 'a.png', PNG)).rejects.toThrow(); + expect(() => sets.iconPath('mine', '../../etc/passwd')).toThrow(); + }); +});