diff --git a/lib/api/routes/icon_sets.js b/lib/api/routes/icon_sets.js new file mode 100644 index 0000000..a7c7364 --- /dev/null +++ b/lib/api/routes/icon_sets.js @@ -0,0 +1,51 @@ +// lib/api/routes/icon_sets.js +import { Router } from 'express'; +import multer from 'multer'; +import { requireOwner } from '../cap.js'; +import { asyncWrap } from '../errors.js'; +import * as sets from '../../icons/sets.js'; +import { processFile, unpackZip, fetchUrl, isZip } from '../../icons/ingest.js'; + +export const router = Router(); +const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 6 * 1024 * 1024, files: 50 } }); + +// GET /api/icon-sets — list sets + their icons (open; 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' + : req.params.file.endsWith('.png') ? 'image/png' : 'image/jpeg'; + 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 }); +})); diff --git a/server.js b/server.js index fe58301..3da3232 100644 --- a/server.js +++ b/server.js @@ -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 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);