Merge feat/device-icons: device icons, last-seen timer & uploadable icon sets (2.5.0)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1055
docs/superpowers/plans/2026-06-09-device-icons.md
Normal file
@@ -0,0 +1,117 @@
|
||||
# Device icons, last-seen timer & uploadable icon sets — design
|
||||
|
||||
Date: 2026-06-09
|
||||
Feature area: Void dashboard → LAN Devices band (`lan_devices`, migration 024)
|
||||
|
||||
## Goal
|
||||
Let the user assign an icon to each discovered LAN device (device-type icon OR
|
||||
brand logo — "both"), show how long ago an absent device was last seen, and
|
||||
manage/extend the available icons by uploading new icon sets from Settings.
|
||||
|
||||
## Background (existing code reused)
|
||||
- `lan_devices` table (migration 024): MAC-keyed inventory; already has
|
||||
`last_seen timestamptz` and `present boolean`. No icon column yet.
|
||||
- `public/views/devices_band.js`: renders tiles + an edit (✎) flow; `/api/devices`
|
||||
PATCH (`lib/api/routes/devices.js`, zod `patchBody`).
|
||||
- **Existing icon proxy** (reused for brand logos): `GET /api/icons/:slug.png`
|
||||
→ `lib/health/icons.js#getIcon()` fetches `walkxcode/dashboard-icons` PNGs via
|
||||
jsDelivr and caches them to `/var/lib/void/icons`. `validSlug = ^[a-z0-9-]+$`.
|
||||
`public/components/service_tile.js` renders `<img src=/api/icons/${slug}.png>`
|
||||
with a letter fallback on error.
|
||||
|
||||
## Icon model
|
||||
A device's `icon` value is one of:
|
||||
- `set:<set>:<name>` → bundled/uploaded type icon, served `/api/icon-sets/<set>/<name>`
|
||||
- `brand:<slug>` → dashboard-icons logo, served `/api/icons/<slug>.png` (existing)
|
||||
- `NULL` → auto-default chosen by group/vendor (pure function)
|
||||
|
||||
Auto-default mapping (group → bundled `devices` set):
|
||||
Network→router, Entertainment→tv, Smart Home→plug, Personal→phone, else→unknown.
|
||||
|
||||
## Components
|
||||
|
||||
### 1. Data — migration 02x
|
||||
`ALTER TABLE lan_devices ADD COLUMN icon text;` (nullable). No backfill (NULL =
|
||||
auto-default). Down: drop column.
|
||||
|
||||
### 2. Bundled type-icon set (the "set of favicons")
|
||||
Download ~15 **Tabler Icons** (MIT) SVGs into the repo at
|
||||
`public/icons/devices/` as the read-only bundled set named `devices`:
|
||||
router, phone, tablet, laptop, desktop, tv, speaker, camera, printer, console,
|
||||
plug, server, watch, nas, unknown. Monochrome line icons → match blackflame.
|
||||
|
||||
### 3. Uploadable icon sets (persistent, outside git)
|
||||
- Storage: `/var/lib/void/icon-sets/<set>/<name>.(svg|png)` (persistent volume,
|
||||
survives redeploys — NOT in git-tracked `public/`). Env override
|
||||
`ICON_SETS_DIR`, default `/var/lib/void/icon-sets`.
|
||||
- A "set" is a directory of icon files. Set/name validated `^[a-z0-9-]+$`.
|
||||
- **Three ingest methods**, all converging on the same per-file processor:
|
||||
1. **Multi-file** — one or more SVG/PNG files.
|
||||
2. **Zip archive** — server unpacks; each entry runs the per-file processor.
|
||||
Reject path traversal / absolute paths / nested dirs (flatten basenames);
|
||||
skip non-image entries; cap entry count + uncompressed total (zip-bomb
|
||||
guard).
|
||||
3. **URL ingest** — server fetches a remote URL; if the payload is a zip it is
|
||||
unpacked (as above), otherwise treated as a single image. http/https only
|
||||
(scheme allowlist, SSRF guard), 8 s timeout, total size cap.
|
||||
- **Per-file processor (shared):** validate name slug + extension; magic-byte
|
||||
check (PNG/JPEG/SVG); **sanitize SVGs** (strip `<script>`, `on*` handlers,
|
||||
external refs — uploaded SVGs render inline → XSS risk even behind CF Access);
|
||||
enforce per-file size cap (e.g. 256 KB); write into the set dir.
|
||||
|
||||
### 4. API (`lib/api/routes/`)
|
||||
- `GET /api/icon-sets` → `[{ set, readonly, icons:[name…] }]` (bundled `devices`
|
||||
scanned from `public/icons/devices`, uploads scanned from ICON_SETS_DIR).
|
||||
- `GET /api/icon-sets/:set/:file` → serve the icon (correct Content-Type;
|
||||
Cache-Control). Validates slugs; 404 on miss.
|
||||
- `POST /api/icon-sets/:set` (requireOwner) → create/extend a set. Accepts
|
||||
EITHER multipart files (one or more SVG/PNG, and/or a `.zip`) OR a JSON body
|
||||
`{ url }` for URL ingest. All inputs run through the shared per-file processor
|
||||
(§3). Returns the updated set. SSRF guard + size/timeout caps on URL ingest.
|
||||
- `DELETE /api/icon-sets/:set` (requireOwner) → remove an uploaded set; the
|
||||
bundled `devices` set is read-only (409).
|
||||
- Extend `patchBody` in `devices.js` with
|
||||
`icon: z.string().regex(/^(set:[a-z0-9-]+:[a-z0-9-]+|brand:[a-z0-9-]+)$/).nullable().optional()`.
|
||||
- Ensure `GET /api/devices` returns `icon` and `last_seen`.
|
||||
|
||||
### 5. Frontend — `devices_band.js`
|
||||
- `resolveIcon(iconRef)` (pure): `set:` → `/api/icon-sets/<set>/<name>`;
|
||||
`brand:` → `/api/icons/<slug>.png`; null → auto-default → `set:devices:<name>`.
|
||||
`<img>` with the existing letter fallback on error.
|
||||
- Tile shows the icon. Edit (✎) mode gains an **icon picker** with two tabs:
|
||||
- **Type**: grid grouped by set (bundled `devices` first, then uploads),
|
||||
fetched from `GET /api/icon-sets`.
|
||||
- **Brand**: a search box → live preview from `/api/icons/<slug>.png`.
|
||||
Selecting writes `icon` via the existing PATCH.
|
||||
- `relativeTime(ts)` (pure): <60s "just now"; <60m "Nm ago"; <24h "Nh ago";
|
||||
else "Nd ago". Shown as "seen Nh ago" on **absent** tiles only (present tiles
|
||||
keep the online dot).
|
||||
|
||||
### 6. Settings — expandable "Icon sets" section
|
||||
- Collapsible panel (`public/views/settings*` pattern): lists each set as a grid
|
||||
of its icons; uploaded sets get a Delete; bundled `devices` is read-only.
|
||||
- Upload control: a set-name field plus three inputs → `POST /api/icon-sets/:set`,
|
||||
refresh the list on success:
|
||||
- multi-file picker (SVG/PNG),
|
||||
- zip picker (`.zip`),
|
||||
- a URL field ("ingest from URL" — image or zip).
|
||||
|
||||
## Testing (vitest)
|
||||
Pure/unit: `resolveIcon`, `relativeTime`, auto-default mapping, SVG sanitizer,
|
||||
slug validation, `patchBody` icon regex, zip-entry guard (traversal/zip-bomb),
|
||||
URL SSRF/scheme guard. Integration: icon-sets list/upload/delete (tmp dir via
|
||||
env override), multi-file + zip + URL ingest (mock fetcher) all landing files,
|
||||
devices PATCH accepts/round-trips `icon`, GET returns icon+last_seen. Reuse the
|
||||
existing icon-proxy test patterns.
|
||||
|
||||
## Deploy
|
||||
Per backup-before-major-updates: `pct snapshot` void-db (310) + void-app (311),
|
||||
run the migration, deploy via the health-gated script, headless-render the
|
||||
Devices band + Settings panel to confirm icons + picker + last-seen display.
|
||||
Ensure ICON_SETS_DIR exists + is writable by the `void` user; document the env
|
||||
var. Commit + push to Gitea `Hynes/Void-Homelab`; wiki page update.
|
||||
|
||||
## Out of scope (YAGNI)
|
||||
Per-icon recolor/theming, auto-icon-by-vendor guessing beyond the group default,
|
||||
icon sets shared across other Void features, scraping a whole remote icon-pack
|
||||
repo (URL ingest is single-file or single-zip, not a directory crawl).
|
||||
@@ -5,32 +5,9 @@ import { requireOwner } from '../cap.js';
|
||||
import { validate } from '../validate.js';
|
||||
import * as devices from '../../db/repos/lan_devices.js';
|
||||
import { isRandomizedMac } from '../../infra/scan.js';
|
||||
import * as agents from '../../db/repos/agents.js';
|
||||
import { timingSafeStrEqual } from '../../auth/safe_compare.js';
|
||||
import { accessOwnerEmail } from '../../auth/cf_access.js';
|
||||
import { softAuth } from '../soft_auth.js';
|
||||
|
||||
export const router = Router();
|
||||
|
||||
// Soft auth: identifies the actor if auth is present but never blocks the request.
|
||||
// Owner-only sub-routes enforce 401/403 via requireOwner.
|
||||
async function softAuth(req, _res, next) {
|
||||
try {
|
||||
const cfEmail = await accessOwnerEmail(req);
|
||||
if (cfEmail) { req.actor = { kind: 'user', id: null }; return next(); }
|
||||
const auth = req.headers.authorization || '';
|
||||
const [scheme, token] = auth.split(' ');
|
||||
if (scheme === 'Bearer' && token) {
|
||||
if (process.env.OWNER_TOKEN && timingSafeStrEqual(token, process.env.OWNER_TOKEN)) {
|
||||
req.actor = { kind: 'user', id: null }; return next();
|
||||
}
|
||||
try {
|
||||
const agent = await agents.verifyToken(token);
|
||||
if (agent) req.actor = { kind: 'agent', id: agent.id, capabilities: agent.capabilities || {}, scopes: agent.scopes || {} };
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
next();
|
||||
}
|
||||
const GROUP_ORDER = ['Smart Home', 'Entertainment', 'Personal', 'Network', 'Flagged'];
|
||||
|
||||
router.use(softAuth);
|
||||
@@ -53,12 +30,14 @@ router.get('/discovered', requireOwner, asyncWrap(async (_req, res) => {
|
||||
}));
|
||||
|
||||
const macParam = z.object({ mac: z.string().regex(/^[0-9a-f]{2}(:[0-9a-f]{2}){5}$/i) });
|
||||
export const iconRef = z.string().regex(/^(set:[a-z0-9-]+:[a-z0-9-]+|brand:[a-z0-9-]+)$/).nullable();
|
||||
const patchBody = z.object({
|
||||
name: z.string().max(120).optional(),
|
||||
grp: z.enum(['Smart Home', 'Entertainment', 'Personal', 'Network', 'Flagged']).optional(),
|
||||
status: z.enum(['new', 'known', 'ignored']).optional(),
|
||||
note: z.string().max(500).optional(),
|
||||
flagged: z.boolean().optional()
|
||||
flagged: z.boolean().optional(),
|
||||
icon: iconRef.optional()
|
||||
});
|
||||
|
||||
// PATCH /devices/:mac — name / edit / promote (owner). This is "add from discovered".
|
||||
|
||||
54
lib/api/routes/icon_sets.js
Normal file
@@ -0,0 +1,54 @@
|
||||
// lib/api/routes/icon_sets.js
|
||||
import { Router } from 'express';
|
||||
import multer from 'multer';
|
||||
import { requireOwner } from '../cap.js';
|
||||
import { asyncWrap, errorMiddleware } from '../errors.js';
|
||||
import { softAuth } from '../soft_auth.js';
|
||||
import * as sets from '../../icons/sets.js';
|
||||
import { processFile, unpackZip, fetchUrl, isZip } from '../../icons/ingest.js';
|
||||
|
||||
export const router = Router();
|
||||
router.use(softAuth);
|
||||
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 6 * 1024 * 1024, files: 50 } });
|
||||
|
||||
// GET /api/icon-sets — list sets + their icons (open; <img> can't send bearer).
|
||||
router.get('/', asyncWrap(async (_req, res) => res.json(await sets.listSets())));
|
||||
|
||||
// GET /api/icon-sets/:set/:file — serve one icon.
|
||||
router.get('/:set/:file', asyncWrap(async (req, res) => {
|
||||
let buf;
|
||||
try { buf = await sets.readIcon(req.params.set, req.params.file); }
|
||||
catch (e) { return res.status(e.message === 'bad_slug' ? 400 : 404).end(); }
|
||||
const ct = req.params.file.endsWith('.svg') ? 'image/svg+xml' : 'image/png';
|
||||
res.set('Content-Type', ct).set('Cache-Control', 'public, max-age=86400').send(buf);
|
||||
}));
|
||||
|
||||
// POST /api/icon-sets/:set — owner upload: multipart files (incl .zip) and/or { url }.
|
||||
router.post('/:set', requireOwner, upload.array('files'), asyncWrap(async (req, res) => {
|
||||
const set = req.params.set;
|
||||
const items = []; // [{name, buffer}]
|
||||
for (const f of req.files || []) {
|
||||
if (isZip(f.buffer)) items.push(...unpackZip(f.buffer));
|
||||
else items.push(processFile({ name: f.originalname, buffer: f.buffer }));
|
||||
}
|
||||
if (req.body?.url) {
|
||||
const { buffer } = await fetchUrl(req.body.url);
|
||||
if (isZip(buffer)) items.push(...unpackZip(buffer));
|
||||
else {
|
||||
const name = new URL(req.body.url).pathname.split('/').pop() || 'icon.png';
|
||||
items.push(processFile({ name, buffer }));
|
||||
}
|
||||
}
|
||||
if (!items.length) return res.status(400).json({ error: { code: 'no_icons' } });
|
||||
for (const it of items) await sets.writeIcon(set, it.name, it.buffer);
|
||||
res.json((await sets.listSets()).find(s => s.set === set) || { set, icons: [] });
|
||||
}));
|
||||
|
||||
// DELETE /api/icon-sets/:set — owner remove an uploaded set.
|
||||
router.delete('/:set', requireOwner, asyncWrap(async (req, res) => {
|
||||
try { await sets.deleteSet(req.params.set); }
|
||||
catch (e) { return res.status(e.message === 'reserved_set' ? 409 : 400).json({ error: { code: e.message } }); }
|
||||
res.json({ ok: true });
|
||||
}));
|
||||
|
||||
router.use(errorMiddleware);
|
||||
25
lib/api/soft_auth.js
Normal file
@@ -0,0 +1,25 @@
|
||||
// lib/api/soft_auth.js — shared middleware
|
||||
// Soft auth: identifies the actor if auth is present but never blocks the request.
|
||||
// Owner-only sub-routes enforce 401/403 via requireOwner.
|
||||
import * as agents from '../db/repos/agents.js';
|
||||
import { timingSafeStrEqual } from '../auth/safe_compare.js';
|
||||
import { accessOwnerEmail } from '../auth/cf_access.js';
|
||||
|
||||
export async function softAuth(req, _res, next) {
|
||||
try {
|
||||
const cfEmail = await accessOwnerEmail(req);
|
||||
if (cfEmail) { req.actor = { kind: 'user', id: null }; return next(); }
|
||||
const auth = req.headers.authorization || '';
|
||||
const [scheme, token] = auth.split(' ');
|
||||
if (scheme === 'Bearer' && token) {
|
||||
if (process.env.OWNER_TOKEN && timingSafeStrEqual(token, process.env.OWNER_TOKEN)) {
|
||||
req.actor = { kind: 'user', id: null }; return next();
|
||||
}
|
||||
try {
|
||||
const agent = await agents.verifyToken(token);
|
||||
if (agent) req.actor = { kind: 'agent', id: agent.id, capabilities: agent.capabilities || {}, scopes: agent.scopes || {} };
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
next();
|
||||
}
|
||||
4
lib/db/migrations/025_lan_device_icon.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
-- 025_lan_device_icon.sql
|
||||
-- Per-device icon reference: 'set:<set>:<name>' (type icon) or 'brand:<slug>'
|
||||
-- (dashboard-icons logo). NULL => UI auto-defaults from the device group.
|
||||
ALTER TABLE lan_devices ADD COLUMN IF NOT EXISTS icon text;
|
||||
@@ -1,6 +1,6 @@
|
||||
import { pool } from '../pool.js';
|
||||
|
||||
const COLS = 'mac, ip, vendor, name, grp, note, status, randomized, flagged, first_seen, last_seen, present';
|
||||
const COLS = 'mac, ip, vendor, name, grp, note, status, randomized, flagged, first_seen, last_seen, present, icon';
|
||||
|
||||
export async function listKnown() {
|
||||
const { rows } = await pool.query(
|
||||
@@ -70,7 +70,7 @@ export async function prune() {
|
||||
return rowCount;
|
||||
}
|
||||
|
||||
const PATCHABLE = ['name', 'grp', 'note', 'status', 'flagged'];
|
||||
const PATCHABLE = ['name', 'grp', 'note', 'status', 'flagged', 'icon'];
|
||||
export async function update(mac, patch) {
|
||||
const sets = [], vals = [];
|
||||
for (const k of PATCHABLE) {
|
||||
|
||||
129
lib/icons/ingest.js
Normal file
@@ -0,0 +1,129 @@
|
||||
// lib/icons/ingest.js
|
||||
import path from 'node:path';
|
||||
import dns from 'node:dns';
|
||||
import AdmZip from 'adm-zip';
|
||||
import { sanitizeSvg } from './sanitize.js';
|
||||
|
||||
export const MAX_FILE = 256 * 1024; // 256 KB per icon
|
||||
export const MAX_ZIP_ENTRIES = 200;
|
||||
export const MAX_ZIP_TOTAL = 5 * 1024 * 1024; // 5 MB uncompressed
|
||||
export const MAX_URL_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
const EXT = { '.svg': 'image/svg+xml', '.png': 'image/png' };
|
||||
const PNG_SIG = [0x89,0x50,0x4e,0x47];
|
||||
|
||||
function slugBase(name) {
|
||||
return path.basename(name, path.extname(name)).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
|
||||
}
|
||||
function magicOk(ext, buf) {
|
||||
if (ext === '.png') return PNG_SIG.every((b, i) => buf[i] === b);
|
||||
if (ext === '.svg') return buf.toString('utf8', 0, 400).includes('<svg');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate + normalize one icon. Returns { name, buffer, ext, contentType }. Throws on invalid.
|
||||
export function processFile({ name, buffer }) {
|
||||
const ext = path.extname(name).toLowerCase();
|
||||
if (!EXT[ext]) throw new Error('unsupported_type');
|
||||
if (!buffer || buffer.length === 0) throw new Error('empty');
|
||||
if (buffer.length > MAX_FILE) throw new Error('too_large');
|
||||
if (!magicOk(ext, buffer)) throw new Error('bad_magic');
|
||||
const base = slugBase(name);
|
||||
if (!base) throw new Error('bad_name');
|
||||
const out = ext === '.svg' ? Buffer.from(sanitizeSvg(buffer)) : buffer;
|
||||
return { name: `${base}${ext}`, buffer: out, ext, contentType: EXT[ext] };
|
||||
}
|
||||
|
||||
// Extract image entries from a zip buffer; flatten basenames, skip traversal/junk.
|
||||
export function unpackZip(buffer) {
|
||||
const zip = new AdmZip(buffer);
|
||||
const entries = zip.getEntries();
|
||||
if (entries.length > MAX_ZIP_ENTRIES) throw new Error('too_many_entries');
|
||||
const out = []; let total = 0;
|
||||
for (const e of entries) {
|
||||
if (e.isDirectory) continue;
|
||||
const ext = path.extname(e.entryName).toLowerCase();
|
||||
if (!EXT[ext]) continue; // skip non-images
|
||||
if (/(^|[\\/])\.\.([\\/]|$)/.test(e.entryName)) continue; // skip traversal
|
||||
const data = e.getData();
|
||||
total += data.length;
|
||||
if (total > MAX_ZIP_TOTAL) throw new Error('zip_too_big');
|
||||
try { out.push(processFile({ name: path.basename(e.entryName), buffer: data })); }
|
||||
catch { /* skip individually-invalid entries */ }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const PRIVATE_HOST = /^(localhost|127\.|0\.0\.0\.0|10\.|192\.168\.|169\.254\.|172\.(1[6-9]|2\d|3[01])\.|\[?::1\]?)/i;
|
||||
|
||||
/**
|
||||
* Returns true if the given IP string is a blocked (loopback, private,
|
||||
* link-local, or ULA) address that should not be fetched.
|
||||
* Handles both IPv4 and IPv6.
|
||||
*/
|
||||
export function isBlockedAddress(ip) {
|
||||
if (!ip) return true;
|
||||
// IPv6 loopback
|
||||
if (ip === '::1') return true;
|
||||
// IPv4-mapped loopback ::ffff:127.x.x.x
|
||||
const v4mapped = ip.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i);
|
||||
const v4 = v4mapped ? v4mapped[1] : ip;
|
||||
|
||||
if (/^\d+\.\d+\.\d+\.\d+$/.test(v4)) {
|
||||
const parts = v4.split('.').map(Number);
|
||||
const [a, b] = parts;
|
||||
// 0.0.0.0
|
||||
if (a === 0) return true;
|
||||
// 127.0.0.0/8 — loopback
|
||||
if (a === 127) return true;
|
||||
// 10.0.0.0/8 — private
|
||||
if (a === 10) return true;
|
||||
// 172.16.0.0/12 — private
|
||||
if (a === 172 && b >= 16 && b <= 31) return true;
|
||||
// 192.168.0.0/16 — private
|
||||
if (a === 192 && b === 168) return true;
|
||||
// 169.254.0.0/16 — link-local
|
||||
if (a === 169 && b === 254) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// IPv6 checks (expand to lower-case for prefix matching)
|
||||
const lower = ip.toLowerCase();
|
||||
// ::1 is caught above; handle full-form loopback
|
||||
if (lower === '0:0:0:0:0:0:0:1') return true;
|
||||
// fe80::/10 — link-local (fe80 – febf)
|
||||
if (/^fe[89ab][0-9a-f]:/i.test(lower)) return true;
|
||||
// fc00::/7 — ULA (fc00 – fdff)
|
||||
if (/^f[cd][0-9a-f]{2}:/i.test(lower)) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function dnsLookupAll(hostname) {
|
||||
return new Promise((resolve, reject) =>
|
||||
dns.lookup(hostname, { all: true }, (err, addrs) => err ? reject(err) : resolve(addrs))
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch a remote icon or zip. SSRF guard: http/https only, no localhost/private,
|
||||
// DNS-resolved address check, size + timeout caps. `fetcher` injectable for tests.
|
||||
export async function fetchUrl(url, { fetcher } = {}) {
|
||||
let u;
|
||||
try { u = new URL(url); } catch { throw new Error('bad_url'); }
|
||||
if (u.protocol !== 'http:' && u.protocol !== 'https:') throw new Error('bad_scheme');
|
||||
// Fast literal-hostname guard (catches raw IP strings and 'localhost' without DNS)
|
||||
if (PRIVATE_HOST.test(u.hostname)) throw new Error('blocked_host');
|
||||
// DNS resolution guard — only when using the real fetcher (not in tests)
|
||||
if (!fetcher) {
|
||||
const addrs = await dnsLookupAll(u.hostname).catch(() => []);
|
||||
if (addrs.some(a => isBlockedAddress(a.address))) throw new Error('blocked_host');
|
||||
}
|
||||
const realFetcher = fetcher ?? fetch;
|
||||
const res = await realFetcher(url, { signal: AbortSignal.timeout(8000), redirect: 'error' });
|
||||
if (!res.ok) throw new Error('fetch_failed');
|
||||
const ab = await res.arrayBuffer();
|
||||
if (ab.byteLength > MAX_URL_BYTES) throw new Error('too_large');
|
||||
return { buffer: Buffer.from(ab) };
|
||||
}
|
||||
|
||||
export function isZip(buf) { return buf && buf.length > 4 && buf[0] === 0x50 && buf[1] === 0x4b; }
|
||||
16
lib/icons/sanitize.js
Normal file
@@ -0,0 +1,16 @@
|
||||
// lib/icons/sanitize.js
|
||||
// Focused SVG sanitizer for owner-uploaded icons. NOT a general-purpose
|
||||
// sanitizer — it removes the script/handler/foreignObject/js-uri vectors that
|
||||
// matter for inline-rendered icons. (Owner-only upload behind CF Access.)
|
||||
export function sanitizeSvg(input) {
|
||||
let s = Buffer.isBuffer(input) ? input.toString('utf8') : String(input);
|
||||
s = s.replace(/<script[\s\S]*?<\/script>/gi, '');
|
||||
s = s.replace(/<foreignObject[\s\S]*?<\/foreignObject>/gi, '');
|
||||
s = s.replace(/\son[a-z]+\s*=\s*"[^"]*"/gi, '');
|
||||
s = s.replace(/\son[a-z]+\s*=\s*'[^']*'/gi, '');
|
||||
// Unquoted handlers, e.g. <svg onload=alert(1)>. Value runs until whitespace,
|
||||
// quote, or the tag's closing > / />.
|
||||
s = s.replace(/\son[a-z]+\s*=\s*[^\s">]+/gi, '');
|
||||
s = s.replace(/(href|xlink:href)\s*=\s*("|')\s*javascript:[^"']*\2/gi, '$1=$2#$2');
|
||||
return s;
|
||||
}
|
||||
52
lib/icons/sets.js
Normal 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)$/;
|
||||
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 });
|
||||
}
|
||||
14
package-lock.json
generated
@@ -1,15 +1,16 @@
|
||||
{
|
||||
"name": "void-server",
|
||||
"version": "2.0.0-alpha.16",
|
||||
"version": "2.5.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "void-server",
|
||||
"version": "2.0.0-alpha.16",
|
||||
"version": "2.5.0",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"@mozilla/readability": "^0.6.0",
|
||||
"adm-zip": "^0.5.17",
|
||||
"bcrypt": "^6.0.0",
|
||||
"dompurify": "^3.4.7",
|
||||
"dotenv": "^17.4.2",
|
||||
@@ -965,6 +966,15 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/adm-zip": {
|
||||
"version": "0.5.17",
|
||||
"resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.17.tgz",
|
||||
"integrity": "sha512-+Ut8d9LLqwEvHHJl1+PIHqoyDxFgVN847JTVM3Izi3xHDWPE4UtzzXysMZQs64DMcrJfBeS/uoEP4AD3HQHnQQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "void-server",
|
||||
"version": "2.4.0",
|
||||
"version": "2.5.0",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
@@ -12,6 +12,7 @@
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"@mozilla/readability": "^0.6.0",
|
||||
"adm-zip": "^0.5.17",
|
||||
"bcrypt": "^6.0.0",
|
||||
"dompurify": "^3.4.7",
|
||||
"dotenv": "^17.4.2",
|
||||
|
||||
@@ -11,11 +11,14 @@ function token() { return localStorage.getItem(TOKEN_KEY) || ''; }
|
||||
|
||||
async function call(method, path, body) {
|
||||
const headers = { 'Authorization': 'Bearer ' + token() };
|
||||
if (body !== undefined) headers['Content-Type'] = 'application/json';
|
||||
// FormData bodies: let the browser set the multipart/form-data boundary
|
||||
// automatically — do NOT set Content-Type or JSON.stringify.
|
||||
const isFormData = body instanceof FormData;
|
||||
if (body !== undefined && !isFormData) headers['Content-Type'] = 'application/json';
|
||||
const res = await fetch(path, {
|
||||
method,
|
||||
headers,
|
||||
body: body === undefined ? undefined : JSON.stringify(body)
|
||||
body: body === undefined ? undefined : (isFormData ? body : JSON.stringify(body))
|
||||
});
|
||||
if (res.status === 401) { await promptForToken(); return call(method, path, body); }
|
||||
if (res.status === 204) return null;
|
||||
@@ -61,11 +64,14 @@ function promptForToken() {
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: (p) => call('GET', p),
|
||||
post: (p, body) => call('POST', p, body ?? {}),
|
||||
put: (p, body) => call('PUT', p, body ?? {}),
|
||||
patch: (p, body) => call('PATCH', p, body ?? {}),
|
||||
del: (p) => call('DELETE', p),
|
||||
get: (p) => call('GET', p),
|
||||
post: (p, body) => call('POST', p, body ?? {}),
|
||||
put: (p, body) => call('PUT', p, body ?? {}),
|
||||
patch: (p, body) => call('PATCH', p, body ?? {}),
|
||||
del: (p) => call('DELETE', p),
|
||||
// POST a FormData body (multipart/form-data). Content-Type is omitted so
|
||||
// the browser appends the correct multipart boundary automatically.
|
||||
postForm: (p, fd) => call('POST', p, fd),
|
||||
setToken: (v) => localStorage.setItem(TOKEN_KEY, v),
|
||||
hasToken: () => !!token()
|
||||
};
|
||||
|
||||
20
public/icons/devices/camera.svg
Normal file
@@ -0,0 +1,20 @@
|
||||
<!--
|
||||
tags: [video, photo, aperture, camera, content, entertainment, multimedia, broadcast, audio]
|
||||
category: Media
|
||||
version: "1.0"
|
||||
unicode: "ea54"
|
||||
-->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M5 7h1a2 2 0 0 0 2 -2a1 1 0 0 1 1 -1h6a1 1 0 0 1 1 1a2 2 0 0 0 2 2h1a2 2 0 0 1 2 2v9a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-9a2 2 0 0 1 2 -2" />
|
||||
<path d="M9 13a3 3 0 1 0 6 0a3 3 0 0 0 -6 0" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 560 B |
23
public/icons/devices/console.svg
Normal file
@@ -0,0 +1,23 @@
|
||||
<!--
|
||||
tags: [game, play, entertainment, console, joystick, joypad, controller, device, gamepad, hardware]
|
||||
category: Devices
|
||||
version: "1.68"
|
||||
unicode: "f1d2"
|
||||
-->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M12 5h3.5a5 5 0 0 1 0 10h-5.5l-4.015 4.227a2.3 2.3 0 0 1 -3.923 -2.035l1.634 -8.173a5 5 0 0 1 4.904 -4.019h3.4" />
|
||||
<path d="M14 15l4.07 4.284a2.3 2.3 0 0 0 3.925 -2.023l-1.6 -8.232" />
|
||||
<path d="M8 9v2" />
|
||||
<path d="M7 10h2" />
|
||||
<path d="M14 10h2" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 634 B |
22
public/icons/devices/desktop.svg
Normal file
@@ -0,0 +1,22 @@
|
||||
<!--
|
||||
tags: [monitor, computer, imac, device, desktop, hardware, technology, electronic, gadget, equipment]
|
||||
category: Devices
|
||||
version: "1.0"
|
||||
unicode: "ea89"
|
||||
-->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M3 5a1 1 0 0 1 1 -1h16a1 1 0 0 1 1 1v10a1 1 0 0 1 -1 1h-16a1 1 0 0 1 -1 -1v-10" />
|
||||
<path d="M7 20h10" />
|
||||
<path d="M9 16v4" />
|
||||
<path d="M15 16v4" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 533 B |
20
public/icons/devices/laptop.svg
Normal file
@@ -0,0 +1,20 @@
|
||||
<!--
|
||||
tags: [workstation, mac, notebook, portable, screen, computer, device, laptop, hardware, technology]
|
||||
category: Devices
|
||||
version: "1.2"
|
||||
unicode: "eb64"
|
||||
-->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M3 19l18 0" />
|
||||
<path d="M5 7a1 1 0 0 1 1 -1h12a1 1 0 0 1 1 1v8a1 1 0 0 1 -1 1h-12a1 1 0 0 1 -1 -1l0 -8" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 487 B |
21
public/icons/devices/nas.svg
Normal file
@@ -0,0 +1,21 @@
|
||||
<!--
|
||||
tags: [storage, data, memory, database, repository, records, information, table, content, record]
|
||||
category: Database
|
||||
version: "1.0"
|
||||
unicode: "ea88"
|
||||
-->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M4 6a8 3 0 1 0 16 0a8 3 0 1 0 -16 0" />
|
||||
<path d="M4 6v6a8 3 0 0 0 16 0v-6" />
|
||||
<path d="M4 12v6a8 3 0 0 0 16 0v-6" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 497 B |
21
public/icons/devices/phone.svg
Normal file
@@ -0,0 +1,21 @@
|
||||
<!--
|
||||
tags: [iphone, phone, smartphone, cellphone, device, mobile, hardware, technology, electronic, gadget]
|
||||
category: Devices
|
||||
version: "1.0"
|
||||
unicode: "ea8a"
|
||||
-->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M6 5a2 2 0 0 1 2 -2h8a2 2 0 0 1 2 2v14a2 2 0 0 1 -2 2h-8a2 2 0 0 1 -2 -2v-14" />
|
||||
<path d="M11 4h2" />
|
||||
<path d="M12 17v.01" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 510 B |
22
public/icons/devices/plug.svg
Normal file
@@ -0,0 +1,22 @@
|
||||
<!--
|
||||
tags: [electricity, charger, socket, connection, plug, hardware, technology, electronic, gadget, equipment]
|
||||
category: Devices
|
||||
version: "1.6"
|
||||
unicode: "ebd9"
|
||||
-->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M9.785 6l8.215 8.215l-2.054 2.054a5.81 5.81 0 1 1 -8.215 -8.215l2.054 -2.054" />
|
||||
<path d="M4 20l3.5 -3.5" />
|
||||
<path d="M15 4l-3.5 3.5" />
|
||||
<path d="M20 9l-3.5 3.5" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 556 B |
21
public/icons/devices/printer.svg
Normal file
@@ -0,0 +1,21 @@
|
||||
<!--
|
||||
tags: [fax, office, device, printer, hardware, technology, electronic, gadget, equipment]
|
||||
category: Devices
|
||||
version: "1.0"
|
||||
unicode: "eb0e"
|
||||
-->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M17 17h2a2 2 0 0 0 2 -2v-4a2 2 0 0 0 -2 -2h-14a2 2 0 0 0 -2 2v4a2 2 0 0 0 2 2h2" />
|
||||
<path d="M17 9v-4a2 2 0 0 0 -2 -2h-6a2 2 0 0 0 -2 2v4" />
|
||||
<path d="M7 15a2 2 0 0 1 2 -2h6a2 2 0 0 1 2 2v4a2 2 0 0 1 -2 2h-6a2 2 0 0 1 -2 -2l0 -4" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 604 B |
24
public/icons/devices/router.svg
Normal file
@@ -0,0 +1,24 @@
|
||||
<!--
|
||||
tags: [wifi, device, wireless, signal, station, cast, router, hardware, technology, electronic]
|
||||
category: Devices
|
||||
version: "1.0"
|
||||
unicode: "eb18"
|
||||
-->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M3 15a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v4a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2l0 -4" />
|
||||
<path d="M17 17l0 .01" />
|
||||
<path d="M13 17l0 .01" />
|
||||
<path d="M15 13l0 -2" />
|
||||
<path d="M11.75 8.75a4 4 0 0 1 6.5 0" />
|
||||
<path d="M8.5 6.5a8 8 0 0 1 13 0" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 622 B |
22
public/icons/devices/server.svg
Normal file
@@ -0,0 +1,22 @@
|
||||
<!--
|
||||
tags: [storage, hosting, www, server, hardware, technology, electronic, gadget, equipment]
|
||||
category: Devices
|
||||
version: "1.0"
|
||||
unicode: "eb1f"
|
||||
-->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M3 7a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v2a3 3 0 0 1 -3 3h-12a3 3 0 0 1 -3 -3" />
|
||||
<path d="M3 15a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v2a3 3 0 0 1 -3 3h-12a3 3 0 0 1 -3 -3l0 -2" />
|
||||
<path d="M7 8l0 .01" />
|
||||
<path d="M7 16l0 .01" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 594 B |
21
public/icons/devices/speaker.svg
Normal file
@@ -0,0 +1,21 @@
|
||||
<!--
|
||||
tags: [voice, loud, microphone, loudspeaker, event, protest, speaker, shout, listen, speakerphone]
|
||||
category: Media
|
||||
version: "1.31"
|
||||
unicode: "ed61"
|
||||
-->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M18 8a3 3 0 0 1 0 6" />
|
||||
<path d="M10 8v11a1 1 0 0 1 -1 1h-1a1 1 0 0 1 -1 -1v-5" />
|
||||
<path d="M12 8l4.524 -3.77a.9 .9 0 0 1 1.476 .692v12.156a.9 .9 0 0 1 -1.476 .692l-4.524 -3.77h-8a1 1 0 0 1 -1 -1v-4a1 1 0 0 1 1 -1h8" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 599 B |
20
public/icons/devices/tablet.svg
Normal file
@@ -0,0 +1,20 @@
|
||||
<!--
|
||||
tags: [ipad, mobile, touchscreen, portable, device, tablet, hardware, technology, electronic, gadget]
|
||||
category: Devices
|
||||
version: "1.0"
|
||||
unicode: "ea8c"
|
||||
-->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M5 4a1 1 0 0 1 1 -1h12a1 1 0 0 1 1 1v16a1 1 0 0 1 -1 1h-12a1 1 0 0 1 -1 -1v-16" />
|
||||
<path d="M11 17a1 1 0 1 0 2 0a1 1 0 0 0 -2 0" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 513 B |
20
public/icons/devices/tv.svg
Normal file
@@ -0,0 +1,20 @@
|
||||
<!--
|
||||
tags: [screen, display, movie, film, watch, audio, video, media, device, hardware]
|
||||
category: Devices
|
||||
version: "1.0"
|
||||
unicode: "ea8d"
|
||||
-->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M3 9a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v9a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2l0 -9" />
|
||||
<path d="M16 3l-4 4l-4 -4" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 475 B |
21
public/icons/devices/unknown.svg
Normal file
@@ -0,0 +1,21 @@
|
||||
<!--
|
||||
category: System
|
||||
tags: [mystery, undefined, unclear, unidentified, uncertain, ambiguous, obscure, unseen, anonymous, unspecified]
|
||||
unicode: "fef4"
|
||||
version: "3.5"
|
||||
-->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M5 5a2 2 0 0 1 2 -2h10a2 2 0 0 1 2 2v14a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2l0 -14" />
|
||||
<path d="M12 16v.01" />
|
||||
<path d="M12 13a2 2 0 0 0 .914 -3.782a1.98 1.98 0 0 0 -2.414 .483" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 572 B |
21
public/icons/devices/watch.svg
Normal file
@@ -0,0 +1,21 @@
|
||||
<!--
|
||||
tags: [arm, hour, date, minutes, sec., timer, device, watch, hardware, technology]
|
||||
category: Devices
|
||||
version: "1.8"
|
||||
unicode: "ebf9"
|
||||
-->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M6 9a3 3 0 0 1 3 -3h6a3 3 0 0 1 3 3v6a3 3 0 0 1 -3 3h-6a3 3 0 0 1 -3 -3v-6" />
|
||||
<path d="M9 18v3h6v-3" />
|
||||
<path d="M9 6v-3h6v3" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 494 B |
@@ -646,3 +646,15 @@ body.drawer-open #scrim { opacity: 1; pointer-events: auto; }
|
||||
.sv-cluster .st-fill.warn { background: var(--warn); }
|
||||
.sv-cluster .st-fill.bad { background: var(--bad); }
|
||||
.sv-cluster .sv-subhdr { color: var(--muted); font-size: 10px; text-transform: uppercase; letter-spacing: .08em; margin: 11px 0 5px; font-family: var(--font-mono); }
|
||||
|
||||
.dv-icon { width: 20px; height: 20px; object-fit: contain; opacity: .9; }
|
||||
.dv-icon-fb { width: 20px; height: 20px; display: grid; place-items: center; font-size: 11px; background: var(--panel-2, #1b1b22); border-radius: 4px; }
|
||||
.dv-seen { font-size: 11px; color: var(--muted, #8a8a94); }
|
||||
.icon-picker { border: 1px solid var(--border, #2a2a36); border-radius: 6px; padding: 6px; margin-top: 6px; max-width: 320px; }
|
||||
.ip-tabs { display: flex; gap: 4px; margin-bottom: 6px; }
|
||||
.ip-tab.active { color: var(--accent, #ff4f2e); border-bottom: 1px solid var(--accent, #ff4f2e); }
|
||||
.ip-grid { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.ip-icon { width: 34px; height: 34px; display: grid; place-items: center; background: transparent; border: 1px solid var(--border, #2a2a36); border-radius: 4px; cursor: pointer; }
|
||||
.ip-icon img { width: 22px; height: 22px; object-fit: contain; }
|
||||
.ip-set-hd, .isp-hd { font-size: 12px; margin: 6px 0 3px; text-transform: capitalize; }
|
||||
.isp-upload { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
// newly-seen devices. Kept SEPARATE from Little Blue's homelab-service band.
|
||||
import { el, mount, clear } from '../dom.js';
|
||||
import { api } from '../api.js';
|
||||
import { resolveIcon, relativeTime, autoDefaultIcon } from './icon_util.js';
|
||||
import { iconPicker } from './icon_picker.js';
|
||||
|
||||
let host;
|
||||
const GROUPS = ['Smart Home', 'Entertainment', 'Personal', 'Network', 'Flagged'];
|
||||
@@ -13,26 +15,50 @@ function tile(d) {
|
||||
clear(t);
|
||||
const edit = el('button', { class: 'dv-edit-btn', title: 'Edit device' }, '✎');
|
||||
edit.onclick = editMode;
|
||||
mount(t,
|
||||
const ref = d.icon || autoDefaultIcon(d.grp);
|
||||
const src = resolveIcon(ref);
|
||||
const img = el('img', { class: 'dv-icon', src, alt: '' });
|
||||
img.onerror = () => {
|
||||
if (src && src.endsWith('.svg')) { img.src = src.replace(/\.svg$/, '.png'); return; }
|
||||
img.replaceWith(el('div', { class: 'dv-icon-fb' }, (d.name?.[0] || '?').toUpperCase()));
|
||||
};
|
||||
const seen = d.present === false && d.last_seen
|
||||
? el('span', { class: 'dv-seen' }, 'seen ' + relativeTime(d.last_seen)) : null;
|
||||
mount(t, img,
|
||||
el('span', { class: 'dv-nm' }, d.name || 'Unknown'),
|
||||
el('span', { class: 'dv-ip' }, d.ip || ''),
|
||||
d.mac ? el('span', { class: 'dv-mac' }, d.mac) : null,
|
||||
el('span', { class: 'dv-vendor' },
|
||||
(d.vendor || '') + (d.randomized ? ' · randomized' : '') + (d.present === false ? ' · absent' : '')),
|
||||
seen,
|
||||
d.mac ? edit : null);
|
||||
}
|
||||
function editMode() {
|
||||
clear(t);
|
||||
let chosenIcon = d.icon || null;
|
||||
const nameI = el('input', { class: 'dv-edit-name', value: d.name || '' });
|
||||
const grpS = el('select', { class: 'dv-edit-grp' }, ...GROUPS.map(g => el('option', { value: g }, g)));
|
||||
grpS.value = d.grp || 'Flagged';
|
||||
const pickerWrap = el('div', { class: 'dv-picker-wrap' });
|
||||
pickerWrap.style.display = 'none';
|
||||
const iconBtn = el('button', { class: 'ghost' }, 'Icon');
|
||||
iconBtn.onclick = () => {
|
||||
if (pickerWrap.style.display === 'none') {
|
||||
clear(pickerWrap);
|
||||
pickerWrap.append(iconPicker(chosenIcon, ref => { chosenIcon = ref; iconBtn.textContent = 'Icon ✓'; pickerWrap.style.display = 'none'; }));
|
||||
pickerWrap.style.display = 'block';
|
||||
} else pickerWrap.style.display = 'none';
|
||||
};
|
||||
const save = el('button', { class: 'dv-add' }, 'Save');
|
||||
save.onclick = async () => { await api.patch('/api/devices/' + d.mac, { name: nameI.value.trim() || null, grp: grpS.value }); load(); };
|
||||
save.onclick = async () => {
|
||||
await api.patch('/api/devices/' + d.mac, { name: nameI.value.trim() || null, grp: grpS.value, icon: chosenIcon });
|
||||
load();
|
||||
};
|
||||
const del = el('button', { class: 'ghost dv-ignore' }, 'Delete');
|
||||
del.onclick = async () => { await api.del('/api/devices/' + d.mac); load(); };
|
||||
const cancel = el('button', { class: 'ghost' }, 'Cancel');
|
||||
cancel.onclick = view;
|
||||
mount(t, el('span', { class: 'dv-mac' }, d.mac), nameI, grpS, save, del, cancel);
|
||||
mount(t, el('span', { class: 'dv-mac' }, d.mac), nameI, grpS, iconBtn, save, del, cancel, pickerWrap);
|
||||
}
|
||||
view();
|
||||
return t;
|
||||
|
||||
56
public/views/icon_picker.js
Normal file
@@ -0,0 +1,56 @@
|
||||
// public/views/icon_picker.js — inline picker with Type + Brand tabs.
|
||||
import { el, mount, clear } from '../dom.js';
|
||||
import { api } from '../api.js';
|
||||
|
||||
// onPick(ref) called with 'set:<set>:<name>' or 'brand:<slug>'. Returns an element.
|
||||
export function iconPicker(currentRef, onPick) {
|
||||
const box = el('div', { class: 'icon-picker' });
|
||||
const tabs = el('div', { class: 'ip-tabs' });
|
||||
const body = el('div', { class: 'ip-body' });
|
||||
const typeTab = el('button', { class: 'ip-tab active' }, 'Type');
|
||||
const brandTab = el('button', { class: 'ip-tab' }, 'Brand');
|
||||
typeTab.onclick = () => { typeTab.classList.add('active'); brandTab.classList.remove('active'); showType(); };
|
||||
brandTab.onclick = () => { brandTab.classList.add('active'); typeTab.classList.remove('active'); showBrand(); };
|
||||
|
||||
async function showType() {
|
||||
clear(body);
|
||||
body.append(el('div', { class: 'muted' }, 'Loading…'));
|
||||
let list = [];
|
||||
try { list = await api.get('/api/icon-sets'); } catch { /* ignore */ }
|
||||
clear(body);
|
||||
for (const s of list) {
|
||||
const grid = el('div', { class: 'ip-grid' }, s.icons.map(file => {
|
||||
const name = file.replace(/\.[a-z]+$/, '');
|
||||
const ref = `set:${s.set}:${name}`;
|
||||
const b = el('button', { class: 'ip-icon', title: name },
|
||||
el('img', { src: `/api/icon-sets/${s.set}/${file}` }));
|
||||
b.onclick = () => onPick(ref);
|
||||
return b;
|
||||
}));
|
||||
body.append(el('div', { class: 'ip-set' },
|
||||
el('div', { class: 'ip-set-hd' }, s.set + (s.readonly ? '' : ' ·')),
|
||||
grid));
|
||||
}
|
||||
}
|
||||
|
||||
function showBrand() {
|
||||
clear(body);
|
||||
const inp = el('input', { class: 'dv-edit-name', placeholder: 'brand slug e.g. apple, google-nest' });
|
||||
const prev = el('div', { class: 'ip-grid' });
|
||||
inp.oninput = () => {
|
||||
const slug = inp.value.trim().toLowerCase().replace(/[^a-z0-9-]/g, '');
|
||||
clear(prev);
|
||||
if (!slug) return;
|
||||
const b = el('button', { class: 'ip-icon' },
|
||||
el('img', { src: `/api/icons/${slug}.png` }));
|
||||
b.onclick = () => onPick(`brand:${slug}`);
|
||||
prev.append(b);
|
||||
};
|
||||
body.append(inp, prev);
|
||||
}
|
||||
|
||||
mount(tabs, typeTab, brandTab);
|
||||
mount(box, tabs, body);
|
||||
showType();
|
||||
return box;
|
||||
}
|
||||
70
public/views/icon_sets_panel.js
Normal file
@@ -0,0 +1,70 @@
|
||||
// Icon sets management panel — list, upload, delete custom icon sets.
|
||||
import { el, mount, clear } from '../dom.js';
|
||||
import { api } from '../api.js';
|
||||
|
||||
export function iconSetsPanel() {
|
||||
const root = el('div', { class: 'icon-sets-panel' });
|
||||
|
||||
async function refresh() {
|
||||
clear(root);
|
||||
let list = [];
|
||||
try { list = await api.get('/api/icon-sets'); } catch {
|
||||
root.appendChild(el('div', { class: 'muted' }, 'unavailable'));
|
||||
return;
|
||||
}
|
||||
for (const s of list) {
|
||||
const grid = el('div', { class: 'ip-grid' },
|
||||
s.icons.map(f =>
|
||||
el('div', { class: 'ip-icon' },
|
||||
el('img', { src: `/api/icon-sets/${s.set}/${f}`, title: f })
|
||||
)
|
||||
)
|
||||
);
|
||||
const head = el('div', { class: 'isp-hd' },
|
||||
el('b', {}, s.set),
|
||||
el('span', { class: 'muted' }, ` ${s.icons.length}`)
|
||||
);
|
||||
if (!s.readonly) {
|
||||
const del = el('button', { class: 'ghost' }, 'Delete');
|
||||
del.addEventListener('click', async () => {
|
||||
await api.del('/api/icon-sets/' + s.set);
|
||||
refresh();
|
||||
});
|
||||
head.appendChild(del);
|
||||
}
|
||||
root.appendChild(el('div', { class: 'isp-set' }, head, grid));
|
||||
}
|
||||
root.appendChild(uploadForm(refresh));
|
||||
}
|
||||
|
||||
refresh();
|
||||
return root;
|
||||
}
|
||||
|
||||
function uploadForm(onDone) {
|
||||
const setI = el('input', { class: 'dv-edit-name', placeholder: 'new set name (a-z0-9-)' });
|
||||
const fileI = el('input', { type: 'file', accept: '.svg,.png,.zip', multiple: true });
|
||||
const urlI = el('input', { class: 'dv-edit-name', placeholder: 'or ingest from URL (image or .zip)' });
|
||||
const err = el('span', { class: 'muted', style: { fontSize: '11px' } }, '');
|
||||
const up = el('button', { class: 'dv-add' }, 'Upload');
|
||||
|
||||
up.addEventListener('click', async () => {
|
||||
const set = setI.value.trim().toLowerCase();
|
||||
if (!/^[a-z0-9-]+$/.test(set)) { err.textContent = 'set name: a-z 0-9 - only'; return; }
|
||||
if (!fileI.files.length && !urlI.value.trim()) { err.textContent = 'pick files or a URL'; return; }
|
||||
const fd = new FormData();
|
||||
for (const f of fileI.files) fd.append('files', f);
|
||||
if (urlI.value.trim()) fd.append('url', urlI.value.trim());
|
||||
up.textContent = 'Uploading…'; up.disabled = true;
|
||||
try {
|
||||
await api.postForm('/api/icon-sets/' + set, fd);
|
||||
onDone();
|
||||
} catch {
|
||||
err.textContent = 'upload failed';
|
||||
up.textContent = 'Upload';
|
||||
up.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
return el('div', { class: 'isp-upload' }, setI, fileI, urlI, up, err);
|
||||
}
|
||||
24
public/views/icon_util.js
Normal file
@@ -0,0 +1,24 @@
|
||||
// public/views/icon_util.js — pure helpers (no DOM), unit-tested.
|
||||
const GROUP_DEFAULT = {
|
||||
Network: 'router', Entertainment: 'tv', 'Smart Home': 'plug', Personal: 'phone'
|
||||
};
|
||||
export function autoDefaultIcon(grp) {
|
||||
return `set:devices:${GROUP_DEFAULT[grp] || 'unknown'}`;
|
||||
}
|
||||
// Note: bundled 'devices' icons are .svg; brand icons are served .png by the proxy.
|
||||
export function resolveIcon(ref) {
|
||||
if (typeof ref !== 'string') return null;
|
||||
let m = ref.match(/^set:([a-z0-9-]+):([a-z0-9-]+)$/);
|
||||
if (m) return `/api/icon-sets/${m[1]}/${m[2]}.svg`;
|
||||
m = ref.match(/^brand:([a-z0-9-]+)$/);
|
||||
if (m) return `/api/icons/${m[1]}.png`;
|
||||
return null;
|
||||
}
|
||||
export function relativeTime(iso, now = Date.now()) {
|
||||
const t = typeof iso === 'number' ? iso : Date.parse(iso);
|
||||
const s = Math.max(0, Math.floor((now - t) / 1000));
|
||||
if (s < 60) return 'just now';
|
||||
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
|
||||
if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
|
||||
return `${Math.floor(s / 86400)}d ago`;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
// #/settings — API tokens, agents, and a placeholder for Orthos Mode.
|
||||
import { el, mount } from '../dom.js';
|
||||
import { api } from '../api.js';
|
||||
import { iconSetsPanel } from './icon_sets_panel.js';
|
||||
|
||||
function section(title, sub, bodyEl) {
|
||||
return el('div', { class: 'card settings-card' },
|
||||
@@ -99,10 +100,29 @@ async function renderAgents(c) {
|
||||
export async function render(main) {
|
||||
const tokensBody = el('div', { class: 'settings-body' });
|
||||
const agentsBody = el('div', { class: 'settings-body' });
|
||||
|
||||
// Icon sets — collapsible; panel is lazy-created on first expand so
|
||||
// /api/icon-sets is not fetched while the section is collapsed.
|
||||
let isPanel = null;
|
||||
const iconSetsWrap = el('div', { class: 'settings-body' });
|
||||
const isToggle = el('button', { class: 'ghost' }, '▸ Icon sets');
|
||||
isToggle.addEventListener('click', () => {
|
||||
if (!isPanel) {
|
||||
// First expand: create and append the panel.
|
||||
isPanel = iconSetsPanel();
|
||||
iconSetsWrap.appendChild(isPanel);
|
||||
}
|
||||
const open = isPanel.style.display !== 'none';
|
||||
isPanel.style.display = open ? 'none' : 'block';
|
||||
isToggle.textContent = (open ? '▸' : '▾') + ' Icon sets';
|
||||
});
|
||||
iconSetsWrap.appendChild(isToggle);
|
||||
|
||||
mount(main,
|
||||
el('h1', { class: 'view-h1' }, '◆ Settings'),
|
||||
section('API Tokens', 'Bearer tokens for agents (e.g. the MCP external-research agent). The secret is shown once at creation.', tokensBody),
|
||||
section('Agents', 'Seeded Cradle agents and what each is allowed to do.', agentsBody),
|
||||
section('Icon Sets', 'Upload or delete custom icon packs for device and service icons.', iconSetsWrap),
|
||||
section('Orthos Mode', 'Local-first answering — Orthos answers first, Claude escalates when needed.',
|
||||
el('div', { class: 'muted' }, 'Paused for a future project (arrives with the local-agent layer).'))
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@ import * as queue from './lib/jobs/queue.js';
|
||||
import { registerWorkers } from './lib/jobs/index.js';
|
||||
import { router as ingestRouter } from './lib/api/routes/ingest.js';
|
||||
import { router as iconsRouter } from './lib/api/routes/icons.js';
|
||||
import { router as iconSetsRouter } from './lib/api/routes/icon_sets.js';
|
||||
import { router as devicesRouter } from './lib/api/routes/devices.js';
|
||||
import { startCron } from './lib/cron/index.js';
|
||||
import { seedFromConfig } from './lib/health/registry.js';
|
||||
@@ -53,6 +54,10 @@ export function createApp() {
|
||||
// slugs are sanitized to [a-z0-9-] to prevent path traversal.
|
||||
app.use('/api/icons', iconsRouter);
|
||||
|
||||
// /api/icon-sets/* — GET routes are open (same <img> reason as above);
|
||||
// POST/DELETE are protected by requireOwner inside the router.
|
||||
app.use('/api/icon-sets', iconSetsRouter);
|
||||
|
||||
// /api/devices — band data is public (like the static devices.json it replaces);
|
||||
// discovered/edit/scan sub-routes use requireOwner (401/403) internally.
|
||||
app.use('/api/devices', devicesRouter);
|
||||
|
||||
72
tests/api/icon_sets.test.js
Normal file
@@ -0,0 +1,72 @@
|
||||
// tests/api/icon_sets.test.js
|
||||
import { describe, it, expect, beforeAll, beforeEach } from 'vitest';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import request from 'supertest';
|
||||
import { createApp } from '../../server.js';
|
||||
import * as sets from '../../lib/icons/sets.js';
|
||||
|
||||
const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0]);
|
||||
|
||||
let app;
|
||||
let setsDir;
|
||||
let bundledDir;
|
||||
|
||||
beforeAll(() => {
|
||||
process.env.OWNER_TOKEN = 'test-token';
|
||||
app = createApp();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// Set up fresh temp dirs with a fake bundled device icon so the bundled set is non-empty.
|
||||
setsDir = mkdtempSync(path.join(tmpdir(), 'iconsets-'));
|
||||
bundledDir = path.join(setsDir, '__bundled');
|
||||
mkdirSync(bundledDir, { recursive: true });
|
||||
writeFileSync(path.join(bundledDir, 'router.svg'), '<svg><path/></svg>');
|
||||
sets._setDirs({ setsDir, bundledDir });
|
||||
});
|
||||
|
||||
const owner = r => r.set('Authorization', 'Bearer test-token');
|
||||
|
||||
describe('GET /api/icon-sets', () => {
|
||||
it('returns 200 with an array including the bundled devices set', async () => {
|
||||
const res = await request(app).get('/api/icon-sets');
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body)).toBe(true);
|
||||
const dev = res.body.find(s => s.set === 'devices');
|
||||
expect(dev).toBeDefined();
|
||||
expect(dev.readonly).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/icon-sets/:set', () => {
|
||||
it('returns 401 (not 500) without auth', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/icon-sets/mytest')
|
||||
.attach('files', PNG, { filename: 'router.png', contentType: 'image/png' });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('returns 200 and uploads the icon with owner auth', async () => {
|
||||
const res = await owner(
|
||||
request(app).post('/api/icon-sets/mytest')
|
||||
).attach('files', PNG, { filename: 'router.png', contentType: 'image/png' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.set).toBe('mytest');
|
||||
expect(res.body.icons).toContain('router.png');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/icon-sets/:set/:file', () => {
|
||||
it('serves a previously uploaded icon with image/png content-type', async () => {
|
||||
// Upload first
|
||||
await owner(
|
||||
request(app).post('/api/icon-sets/mytest')
|
||||
).attach('files', PNG, { filename: 'router.png', contentType: 'image/png' });
|
||||
|
||||
const res = await request(app).get('/api/icon-sets/mytest/router.png');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-type']).toContain('image/png');
|
||||
});
|
||||
});
|
||||
15
tests/icons/devices_icon.test.js
Normal file
@@ -0,0 +1,15 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { z } from 'zod';
|
||||
import { iconRef } from '../../lib/api/routes/devices.js';
|
||||
|
||||
describe('icon ref validation', () => {
|
||||
it('accepts set + brand refs and null', () => {
|
||||
expect(iconRef.safeParse('set:devices:router').success).toBe(true);
|
||||
expect(iconRef.safeParse('brand:apple').success).toBe(true);
|
||||
expect(iconRef.safeParse(null).success).toBe(true);
|
||||
});
|
||||
it('rejects junk', () => {
|
||||
expect(iconRef.safeParse('set:bad').success).toBe(false);
|
||||
expect(iconRef.safeParse('javascript:alert').success).toBe(false);
|
||||
});
|
||||
});
|
||||
79
tests/icons/ingest.test.js
Normal file
@@ -0,0 +1,79 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import AdmZip from 'adm-zip';
|
||||
import { processFile, unpackZip, fetchUrl, isBlockedAddress, MAX_FILE } from '../../lib/icons/ingest.js';
|
||||
|
||||
const PNG = Buffer.from([0x89,0x50,0x4e,0x47,0x0d,0x0a,0x1a,0x0a, 0,0,0,0]);
|
||||
|
||||
describe('processFile', () => {
|
||||
it('slugifies name, keeps png', () => {
|
||||
const r = processFile({ name: 'My Router.png', buffer: PNG });
|
||||
expect(r.name).toBe('my-router.png');
|
||||
expect(r.buffer).toBe(PNG);
|
||||
});
|
||||
it('sanitizes svg', () => {
|
||||
const r = processFile({ name: 'x.svg', buffer: Buffer.from('<svg><script>1</script><path/></svg>') });
|
||||
expect(r.buffer.toString()).not.toMatch(/script/i);
|
||||
});
|
||||
it('rejects non-image extension', () => {
|
||||
expect(() => processFile({ name: 'x.exe', buffer: PNG })).toThrow();
|
||||
});
|
||||
it('rejects oversize', () => {
|
||||
expect(() => processFile({ name: 'x.png', buffer: Buffer.alloc(MAX_FILE + 1, 1) })).toThrow();
|
||||
});
|
||||
it('rejects png with bad magic', () => {
|
||||
expect(() => processFile({ name: 'x.png', buffer: Buffer.from('not a png') })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('unpackZip', () => {
|
||||
it('extracts images, skips non-image junk', () => {
|
||||
const z = new AdmZip();
|
||||
z.addFile('a.png', PNG);
|
||||
z.addFile('notes.txt', Buffer.from('hi'));
|
||||
const out = unpackZip(z.toBuffer());
|
||||
expect(out.map(f => f.name)).toEqual(['a.png']);
|
||||
});
|
||||
|
||||
it('skips path-traversal entries', () => {
|
||||
// adm-zip's addFile() sanitizes '../' at write time (zipnamefix), so it
|
||||
// can't produce a real traversal entry. Build one by mutating the entry
|
||||
// name at the raw level *after* addFile — this survives serialization and
|
||||
// stores '../evil.png' verbatim in the zip bytes.
|
||||
const z = new AdmZip();
|
||||
z.addFile('a.png', PNG);
|
||||
z.addFile('placeholder.png', PNG);
|
||||
const entries = z.getEntries();
|
||||
entries[1].entryName = '../evil.png';
|
||||
const buf = z.toBuffer();
|
||||
// Sanity check: the traversal entry name really is in the serialized bytes.
|
||||
expect(buf.includes(Buffer.from('../evil.png'))).toBe(true);
|
||||
const out = unpackZip(buf);
|
||||
expect(out.map(f => f.name)).toEqual(['a.png']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isBlockedAddress', () => {
|
||||
it('blocks loopback IPv4', () => { expect(isBlockedAddress('127.0.0.1')).toBe(true); });
|
||||
it('blocks private 10/8', () => { expect(isBlockedAddress('10.1.2.3')).toBe(true); });
|
||||
it('blocks private 192.168/16', () => { expect(isBlockedAddress('192.168.0.1')).toBe(true); });
|
||||
it('blocks link-local 169.254/16', () => { expect(isBlockedAddress('169.254.1.1')).toBe(true); });
|
||||
it('blocks 0.0.0.0', () => { expect(isBlockedAddress('0.0.0.0')).toBe(true); });
|
||||
it('blocks IPv6 loopback ::1', () => { expect(isBlockedAddress('::1')).toBe(true); });
|
||||
it('blocks IPv6 ULA fc00::1', () => { expect(isBlockedAddress('fc00::1')).toBe(true); });
|
||||
it('allows public 8.8.8.8', () => { expect(isBlockedAddress('8.8.8.8')).toBe(false); });
|
||||
it('allows public 1.1.1.1', () => { expect(isBlockedAddress('1.1.1.1')).toBe(false); });
|
||||
});
|
||||
|
||||
describe('fetchUrl', () => {
|
||||
it('rejects non-http schemes', async () => {
|
||||
await expect(fetchUrl('file:///etc/passwd')).rejects.toThrow();
|
||||
});
|
||||
it('rejects localhost/private hosts', async () => {
|
||||
await expect(fetchUrl('http://127.0.0.1/x.png')).rejects.toThrow();
|
||||
});
|
||||
it('fetches via injected fetcher', async () => {
|
||||
const fake = async () => ({ ok: true, arrayBuffer: async () => PNG.buffer.slice(PNG.byteOffset, PNG.byteOffset + PNG.length), headers: new Map([['content-type','image/png']]) });
|
||||
const r = await fetchUrl('https://example.com/x.png', { fetcher: fake });
|
||||
expect(Buffer.isBuffer(r.buffer)).toBe(true);
|
||||
});
|
||||
});
|
||||
29
tests/icons/sanitize.test.js
Normal file
@@ -0,0 +1,29 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { sanitizeSvg } from '../../lib/icons/sanitize.js';
|
||||
|
||||
describe('sanitizeSvg', () => {
|
||||
it('strips <script> tags', () => {
|
||||
const out = sanitizeSvg('<svg><script>alert(1)</script><path d="M0 0"/></svg>');
|
||||
expect(out).not.toMatch(/script/i);
|
||||
expect(out).toMatch(/<path/);
|
||||
});
|
||||
it('strips on* event handlers', () => {
|
||||
const out = sanitizeSvg('<svg onload="x()"><rect onclick="y()"/></svg>');
|
||||
expect(out).not.toMatch(/onload|onclick/i);
|
||||
});
|
||||
it('strips unquoted on* handlers', () => {
|
||||
const out = sanitizeSvg('<svg onload=alert(1)><rect onclick=go()/></svg>');
|
||||
expect(out).not.toMatch(/onload|onclick/i);
|
||||
});
|
||||
it('neutralizes javascript: hrefs', () => {
|
||||
const out = sanitizeSvg('<svg><a href="javascript:alert(1)">x</a></svg>');
|
||||
expect(out).not.toMatch(/javascript:/i);
|
||||
});
|
||||
it('drops <foreignObject>', () => {
|
||||
const out = sanitizeSvg('<svg><foreignObject><body>x</body></foreignObject></svg>');
|
||||
expect(out).not.toMatch(/foreignObject/i);
|
||||
});
|
||||
it('accepts a Buffer', () => {
|
||||
expect(sanitizeSvg(Buffer.from('<svg><path/></svg>'))).toMatch(/<svg/);
|
||||
});
|
||||
});
|
||||
37
tests/icons/sets.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
29
tests/views/icon_util.test.js
Normal file
@@ -0,0 +1,29 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { resolveIcon, relativeTime, autoDefaultIcon } from '../../public/views/icon_util.js';
|
||||
|
||||
describe('autoDefaultIcon', () => {
|
||||
it('maps groups to bundled icons', () => {
|
||||
expect(autoDefaultIcon('Network')).toBe('set:devices:router');
|
||||
expect(autoDefaultIcon('Entertainment')).toBe('set:devices:tv');
|
||||
expect(autoDefaultIcon('Smart Home')).toBe('set:devices:plug');
|
||||
expect(autoDefaultIcon('Personal')).toBe('set:devices:phone');
|
||||
expect(autoDefaultIcon('whatever')).toBe('set:devices:unknown');
|
||||
});
|
||||
});
|
||||
describe('resolveIcon', () => {
|
||||
it('resolves set + brand refs', () => {
|
||||
expect(resolveIcon('set:devices:router')).toBe('/api/icon-sets/devices/router.svg');
|
||||
expect(resolveIcon('set:mine:nas')).toBe('/api/icon-sets/mine/nas.svg');
|
||||
expect(resolveIcon('brand:apple')).toBe('/api/icons/apple.png');
|
||||
});
|
||||
it('returns null for junk', () => { expect(resolveIcon('nope')).toBeNull(); });
|
||||
});
|
||||
describe('relativeTime', () => {
|
||||
const base = Date.parse('2026-06-09T12:00:00Z');
|
||||
it('formats buckets', () => {
|
||||
expect(relativeTime('2026-06-09T11:59:30Z', base)).toBe('just now');
|
||||
expect(relativeTime('2026-06-09T11:40:00Z', base)).toBe('20m ago');
|
||||
expect(relativeTime('2026-06-09T09:00:00Z', base)).toBe('3h ago');
|
||||
expect(relativeTime('2026-06-06T12:00:00Z', base)).toBe('3d ago');
|
||||
});
|
||||
});
|
||||