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:
40 files changed
+9520
-38
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user