feat(health): local icon cache /api/icons/:slug.png (no CDN leak)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
14
lib/api/routes/icons.js
Normal file
14
lib/api/routes/icons.js
Normal file
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
31
lib/health/icons.js
Normal file
31
lib/health/icons.js
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { mountApi } from './lib/api/index.js';
|
|||||||
import * as queue from './lib/jobs/queue.js';
|
import * as queue from './lib/jobs/queue.js';
|
||||||
import { registerWorkers } from './lib/jobs/index.js';
|
import { registerWorkers } from './lib/jobs/index.js';
|
||||||
import { router as ingestRouter } from './lib/api/routes/ingest.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';
|
import { startCron } from './lib/cron/index.js';
|
||||||
|
|
||||||
const VERSION = '2.0.0-alpha.7';
|
const VERSION = '2.0.0-alpha.7';
|
||||||
@@ -22,6 +23,10 @@ export function createApp() {
|
|||||||
// and need access to req.rawBody captured above.
|
// and need access to req.rawBody captured above.
|
||||||
app.use('/api/ingest', ingestRouter);
|
app.use('/api/ingest', ingestRouter);
|
||||||
|
|
||||||
|
// /api/icons/* bypasses agentOrOwner — <img> 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) => {
|
app.get('/health', async (_req, res) => {
|
||||||
let db_ok = false;
|
let db_ok = false;
|
||||||
try {
|
try {
|
||||||
|
|||||||
31
tests/api/icons.test.js
Normal file
31
tests/api/icons.test.js
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user