feat(icons): filesystem icon-set store (bundled read-only + uploads)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
root
2026-06-09 08:37:56 +10:00
parent 207ea906ee
commit 9e99e0664f
2 changed files with 89 additions and 0 deletions

52
lib/icons/sets.js Normal file
View File

@@ -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 });
}

37
tests/icons/sets.test.js Normal file
View File

@@ -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'), '<svg><path/></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();
});
});