From b0d54a24ccfd937dae682f91228871aff8db90ee Mon Sep 17 00:00:00 2001 From: root Date: Tue, 2 Jun 2026 22:58:35 +1000 Subject: [PATCH] feat(health): local icon cache /api/icons/:slug.png (no CDN leak) Co-Authored-By: Claude Sonnet 4.6 --- lib/api/routes/icons.js | 14 ++++++++++++++ lib/health/icons.js | 31 +++++++++++++++++++++++++++++++ server.js | 5 +++++ tests/api/icons.test.js | 31 +++++++++++++++++++++++++++++++ 4 files changed, 81 insertions(+) create mode 100644 lib/api/routes/icons.js create mode 100644 lib/health/icons.js create mode 100644 tests/api/icons.test.js diff --git a/lib/api/routes/icons.js b/lib/api/routes/icons.js new file mode 100644 index 0000000..eabf1cc --- /dev/null +++ b/lib/api/routes/icons.js @@ -0,0 +1,14 @@ +import { Router } from 'express'; +import { getIcon, validSlug } from '../../health/icons.js'; +export const router = Router(); +router.get('/:file', async (req, res) => { + const slug = req.params.file.replace(/\.png$/, ''); + if (!validSlug(slug)) return res.status(400).json({ error: { code: 'bad_slug' } }); + try { + const buf = await getIcon(slug); + if (!buf) return res.status(404).end(); + res.set('Content-Type', 'image/png').set('Cache-Control', 'public, max-age=86400').send(buf); + } catch (e) { + res.status(e.message === 'invalid slug' ? 400 : 502).end(); + } +}); diff --git a/lib/health/icons.js b/lib/health/icons.js new file mode 100644 index 0000000..3022247 --- /dev/null +++ b/lib/health/icons.js @@ -0,0 +1,31 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import path from 'node:path'; + +let cacheDir = process.env.ICON_CACHE || '/var/lib/void/icons'; +let fetcher = defaultFetcher; +export function _setCacheDir(d) { cacheDir = d; } +export function _setFetcher(fn) { fetcher = fn || defaultFetcher; } + +const VALID = /^[a-z0-9-]+$/; +export function validSlug(slug) { return VALID.test(slug); } + +async function defaultFetcher(slug) { + const url = `https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/${slug}.png`; + const res = await fetch(url, { signal: AbortSignal.timeout(8000) }); + if (!res.ok) return null; + return Buffer.from(await res.arrayBuffer()); +} + +function isPng(buf) { return buf && buf.length > 8 && buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47; } + +// Returns a Buffer (cached or freshly fetched) or null if upstream has no icon. +export async function getIcon(slug) { + if (!validSlug(slug)) throw new Error('invalid slug'); + const file = path.join(cacheDir, `${slug}.png`); + try { return await readFile(file); } catch { /* miss → fetch */ } + const buf = await fetcher(slug); + if (!isPng(buf)) return null; + await mkdir(cacheDir, { recursive: true }); + await writeFile(file, buf); + return buf; +} diff --git a/server.js b/server.js index 8c6e015..807d49d 100644 --- a/server.js +++ b/server.js @@ -6,6 +6,7 @@ import { mountApi } from './lib/api/index.js'; 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 { startCron } from './lib/cron/index.js'; const VERSION = '2.0.0-alpha.7'; @@ -22,6 +23,10 @@ export function createApp() { // and need access to req.rawBody captured above. app.use('/api/ingest', ingestRouter); + // /api/icons/* bypasses agentOrOwner — tags can't send bearer headers; + // slugs are sanitized to [a-z0-9-] to prevent path traversal. + app.use('/api/icons', iconsRouter); + app.get('/health', async (_req, res) => { let db_ok = false; try { diff --git a/tests/api/icons.test.js b/tests/api/icons.test.js new file mode 100644 index 0000000..28d8490 --- /dev/null +++ b/tests/api/icons.test.js @@ -0,0 +1,31 @@ +import { describe, it, expect, beforeAll, beforeEach, vi } from 'vitest'; +import request from 'supertest'; +import { createApp } from '../../server.js'; +import * as icons from '../../lib/health/icons.js'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +let app; +const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3]); +beforeAll(() => { app = createApp(); }); +beforeEach(() => { icons._setCacheDir(path.join(tmpdir(), 'void-icons-' + Date.now() + '-' + Math.random().toString(36).slice(2))); icons._setFetcher(null); }); + +describe('icon cache', () => { + it('rejects an invalid slug', async () => { + const res = await request(app).get('/api/icons/..%2f..%2fetc%2fpasswd.png'); + expect(res.status).toBe(400); + }); + it('fetches once on miss then serves from cache', async () => { + const fetcher = vi.fn().mockResolvedValue(PNG); + icons._setFetcher(fetcher); + const r1 = await request(app).get('/api/icons/gitea.png'); + expect(r1.status).toBe(200); + expect(r1.headers['content-type']).toContain('image/png'); + await request(app).get('/api/icons/gitea.png'); + expect(fetcher).toHaveBeenCalledTimes(1); + }); + it('404s when upstream has no icon', async () => { + icons._setFetcher(vi.fn().mockResolvedValue(null)); + expect((await request(app).get('/api/icons/nope.png')).status).toBe(404); + }); +});