stack and container icons - pick an icon per stack or per container (survives recreation), plus automatic app logos on containers

This commit is contained in:
Jarek Krochmalski committed 2026-08-23 10:20:59 +02:00
1 parent 30fb770a9f
commit f6ffadbacb
40 files changed
+9520 -38

No files matched your search

+144
View File
@@ -0,0 +1,144 @@
// @ts-expect-error -- bun:test is a runtime built-in with no types installed
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { mkdtempSync, writeFileSync, readFileSync, existsSync, utimesSync, statSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import {
isValidSelfhstRef,
selfhstCachePath,
sanitizeSvg,
getSelfhstIcon
} from '../src/lib/server/selfhst-icons';
import { looksLikeImage } from '../src/lib/server/stack-icons';
describe('isValidSelfhstRef', () => {
test('accepts normal references', () => {
for (const r of ['plex', 'jellyfin', 'home-assistant', 'pihole', 'nextcloud', '2fauth']) {
expect(isValidSelfhstRef(r)).toBe(true);
}
});
test('rejects path traversal and junk', () => {
for (const r of ['../etc/passwd', 'a/b', 'foo.svg', 'Foo', 'a_b', 'a b', '', '..', './x', 'x/../y']) {
expect(isValidSelfhstRef(r)).toBe(false);
}
});
test('rejects an over-long ref', () => {
expect(isValidSelfhstRef('a'.repeat(65))).toBe(false);
});
test('selfhstCachePath throws on an invalid ref (never builds a path from junk)', () => {
expect(() => selfhstCachePath('../../etc/passwd')).toThrow();
expect(() => selfhstCachePath('ok-ref')).not.toThrow();
});
test('selfhstCachePath keeps the ref inside the cache dir', () => {
const p = selfhstCachePath('plex');
expect(p.endsWith('/icon-cache/selfhst/plex.svg')).toBe(true);
});
});
describe('sanitizeSvg (SVG-XSS defense)', () => {
test('strips <script> elements', () => {
const out = sanitizeSvg('<svg><script>alert(document.cookie)</script><rect/></svg>');
expect(out).not.toContain('<script');
expect(out).toContain('<rect');
});
test('strips on* event-handler attributes', () => {
const out = sanitizeSvg('<svg onload="evil()"><rect onclick=\'x\'/></svg>');
expect(out).not.toMatch(/onload/i);
expect(out).not.toMatch(/onclick/i);
});
test('strips <foreignObject> (can embed HTML)', () => {
const out = sanitizeSvg('<svg><foreignObject><body onload="x"/></foreignObject></svg>');
expect(out).not.toMatch(/foreignObject/i);
});
test('strips javascript: URLs', () => {
const out = sanitizeSvg('<svg><a href="javascript:alert(1)">x</a></svg>');
expect(out.toLowerCase()).not.toContain('javascript:');
});
test('leaves a clean logo SVG intact', () => {
const clean = '<svg viewBox="0 0 24 24"><path d="M1 1h2v2z" fill="#fff"/></svg>';
expect(sanitizeSvg(clean)).toBe(clean);
});
});
describe('looksLikeImage (upload magic-byte check)', () => {
test('accepts real image magic bytes', () => {
expect(looksLikeImage(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0, 0]))).toBe(true); // PNG
expect(looksLikeImage(Buffer.from([0xff, 0xd8, 0xff, 0xe0]))).toBe(true); // JPEG
expect(looksLikeImage(Buffer.from('GIF89a'))).toBe(true);
expect(looksLikeImage(Buffer.from('RIFF\0\0\0\0WEBP'))).toBe(true);
expect(looksLikeImage(Buffer.from('<svg></svg>'))).toBe(true);
});
test('rejects arbitrary / non-image bytes', () => {
expect(looksLikeImage(Buffer.from('#!/bin/sh\nrm -rf /'))).toBe(false);
expect(looksLikeImage(Buffer.from([0x00, 0x01, 0x02, 0x03]))).toBe(false);
expect(looksLikeImage(Buffer.from('MZ\x90\x00'))).toBe(false); // PE/exe
expect(looksLikeImage(Buffer.alloc(0))).toBe(false);
});
});
// getSelfhstIcon's cache-HIT paths are deterministic (no network). We seed the on-disk
// cache under a temp DATA_DIR and assert what the cache read returns without ever
// reaching the CDN. Stale/corrupt entries fall through to a fetch, which fails offline
// and returns null - that null still proves the bad file was NOT served.
describe('getSelfhstIcon cache-hit handling', () => {
let dir: string;
beforeAll(() => {
dir = mkdtempSync(join(tmpdir(), 'selfhst-cache-'));
process.env.DATA_DIR = dir;
});
afterAll(() => {
delete process.env.DATA_DIR;
rmSync(dir, { recursive: true, force: true });
});
test('serves a valid cached SVG without refetching', async () => {
const p = selfhstCachePath('plex');
writeFileSync(p, '<svg>ok</svg>');
const buf = await getSelfhstIcon('plex');
expect(buf?.toString()).toBe('<svg>ok</svg>');
});
test('a fresh zero-byte tombstone returns null (no refetch)', async () => {
const p = selfhstCachePath('jellyfin');
writeFileSync(p, Buffer.alloc(0)); // mtime = now -> within TTL
expect(await getSelfhstIcon('jellyfin')).toBeNull();
expect(existsSync(p)).toBe(true); // tombstone kept
});
// A ref that can NEVER exist upstream (valid syntactically, but no such icon) so the
// fall-through fetch deterministically returns a 404/miss regardless of network.
const MISSING = 'zzz-nonexistent-test-icon';
test('a corrupt (nonzero non-SVG) cache file is never served (dropped, refetched)', async () => {
const p = selfhstCachePath(MISSING);
writeFileSync(p, Buffer.from([0x00, 0x01, 0x02])); // binary, no leading '<'
const buf = await getSelfhstIcon(MISSING);
// Whatever happens, the corrupt bytes are NOT returned. Offline/404 -> null; the file
// is either gone or replaced by a 0-byte tombstone, never the original garbage.
expect(buf).toBeNull();
if (existsSync(p)) {
expect(readFileSync(p).length).toBe(0); // tombstone, not the corrupt content
}
});
test('a stale tombstone (older than the TTL) is not treated as a live miss', async () => {
const p = selfhstCachePath(MISSING);
writeFileSync(p, Buffer.alloc(0));
const old = Date.now() / 1000 - 3 * 24 * 60 * 60; // 3 days ago (TTL is 1 day)
utimesSync(p, old, old);
const before = statSync(p).mtimeMs;
await getSelfhstIcon(MISSING); // stale -> refetch (misses) -> fresh tombstone
// The stale tombstone was acted on (dropped then rewritten), i.e. its mtime advanced -
// proving it was NOT returned as a still-valid negative-cache hit.
if (existsSync(p)) expect(statSync(p).mtimeMs).toBeGreaterThan(before);
});
});
+114
View File
@@ -0,0 +1,114 @@
// @ts-expect-error -- bun:test is a runtime built-in with no types installed
import { describe, test, expect } from 'bun:test';
import {
imageBasename,
matchSelfhstRef,
createSelfhstMatcher,
containerNameBase,
matchSelfhstByName
} from '../src/lib/utils/selfhst-match';
describe('imageBasename', () => {
test('strips registry, namespace, tag and digest', () => {
expect(imageBasename('lscr.io/linuxserver/sonarr:latest')).toBe('sonarr');
expect(imageBasename('plexinc/pms-docker')).toBe('pms-docker');
expect(imageBasename('grafana/grafana:10.2.0')).toBe('grafana');
expect(imageBasename('redis@sha256:abcd')).toBe('redis');
expect(imageBasename('registry.example.com:5000/team/app:v1')).toBe('app');
expect(imageBasename('nginx')).toBe('nginx');
expect(imageBasename('')).toBe('');
});
});
describe('matchSelfhstRef', () => {
const refs = new Set(['plex', 'sonarr', 'grafana', 'home-assistant', 'pi-hole', 'redis', 'postgresql']);
test('alias table resolves known mismatches', () => {
expect(matchSelfhstRef('plexinc/pms-docker:latest', refs)).toBe('plex');
expect(matchSelfhstRef('homeassistant/home-assistant', refs)).toBe('home-assistant');
expect(matchSelfhstRef('pihole/pihole', refs)).toBe('pi-hole');
expect(matchSelfhstRef('postgres:16', refs)).toBe('postgresql');
});
test('exact basename matches a reference', () => {
expect(matchSelfhstRef('lscr.io/linuxserver/sonarr', refs)).toBe('sonarr');
expect(matchSelfhstRef('grafana/grafana', refs)).toBe('grafana');
});
test('NO fuzzy false positives (the whole point)', () => {
// 'redis-exporter' must NOT become 'redis'
expect(matchSelfhstRef('oliver006/redis-exporter', refs)).toBeNull();
// unknown app -> null, never a wrong logo
expect(matchSelfhstRef('mycompany/secret-internal-app', refs)).toBeNull();
expect(matchSelfhstRef('', refs)).toBeNull();
});
test('alias only wins when the aliased ref is actually in the manifest', () => {
const tiny = new Set(['sonarr']);
// pms-docker aliases to plex, but plex is not in this manifest -> null
expect(matchSelfhstRef('plexinc/pms-docker', tiny)).toBeNull();
});
});
describe('containerNameBase', () => {
test('strips leading slash and compose replica suffix, lowercases', () => {
expect(containerNameBase('/traefik')).toBe('traefik');
expect(containerNameBase('/traefik-1')).toBe('traefik');
expect(containerNameBase('immich_server_1')).toBe('immich_server');
expect(containerNameBase('Sonarr')).toBe('sonarr');
expect(containerNameBase('/web-2')).toBe('web');
expect(containerNameBase('')).toBe('');
});
test('does not strip a non-replica trailing number word', () => {
// only a -N / _N SUFFIX is a replica index; an embedded digit stays
expect(containerNameBase('mc-atm9')).toBe('mc-atm9');
});
});
describe('matchSelfhstByName (fallback)', () => {
const refs = new Set(['syncthing', 'traefik', 'grafana', 'pi-hole']);
test('exact container name matches a reference', () => {
expect(matchSelfhstByName('/syncthing', refs)).toBe('syncthing');
expect(matchSelfhstByName('traefik-1', refs)).toBe('traefik');
});
test('alias table applies to names too', () => {
expect(matchSelfhstByName('pihole', refs)).toBe('pi-hole');
});
test('generic names never false-match', () => {
expect(matchSelfhstByName('web', refs)).toBeNull();
expect(matchSelfhstByName('app-1', refs)).toBeNull();
expect(matchSelfhstByName('', refs)).toBeNull();
});
});
describe('createSelfhstMatcher (memoized, image-first with name fallback)', () => {
test('returns the same result and caches per (image, name) pair', () => {
const m = createSelfhstMatcher(new Set(['grafana']));
expect(m('grafana/grafana:1')).toBe('grafana');
expect(m('grafana/grafana:1')).toBe('grafana'); // cached
expect(m('unknown/thing')).toBeNull();
});
test('falls back to the container name when the image does not resolve', () => {
const m = createSelfhstMatcher(new Set(['syncthing', 'traefik']));
// image pinned by digest -> no readable basename match; name saves it
expect(m('sha256:3de5a32e11c1cc', '/syncthing')).toBe('syncthing');
expect(m('some/private-mirror@sha256:abcd', 'traefik-1')).toBe('traefik');
});
test('image wins over name when both could match', () => {
const m = createSelfhstMatcher(new Set(['grafana', 'traefik']));
// image resolves to grafana even though the name says traefik
expect(m('grafana/grafana', 'traefik')).toBe('grafana');
});
test('a matched image is not overridden by an unmatchable name, and vice versa', () => {
const m = createSelfhstMatcher(new Set(['traefik']));
expect(m('unknown/thing', 'traefik')).toBe('traefik'); // name saves it
expect(m('unknown/thing', 'web')).toBeNull(); // neither matches
});
});