Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/lib/components/ui/input/input.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
data-slot={dataSlot}
class={cn(
"selection:bg-primary dark:bg-input/30 selection:text-primary-foreground border-input ring-offset-background placeholder:text-muted-foreground shadow-xs flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 pt-1.5 text-sm font-medium outline-none transition-[color,box-shadow] disabled:cursor-not-allowed disabled:opacity-50",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"focus-visible:border-ring",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
Expand All @@ -41,7 +41,7 @@
data-slot={dataSlot}
class={cn(
"border-input bg-background selection:bg-primary dark:bg-input/30 selection:text-primary-foreground ring-offset-background placeholder:text-muted-foreground shadow-xs flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base outline-none transition-[color,box-shadow] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"focus-visible:border-ring",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
Expand Down
4 changes: 4 additions & 0 deletions src/lib/data/changelog.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@
"comingSoon": true,
"changes": [
{ "type": "feature", "text": "tag containers and stacks, then filter and group the grid by tag (#781, #870)" },
{ "type": "fix", "text": "removing all of a stack's environment variables no longer leaves stale entries (#1598)" },
{ "type": "fix", "text": "templates with duplicate names no longer stop the templates page from rendering" },
{ "type": "fix", "text": "the container toolbar no longer clips its buttons on mobile (#1586)" },
{ "type": "fix", "text": "use webp or png for selfh.st icons that have no svg (#1596)" },
{ "type": "fix", "text": "don't report a newer version tag that resolves to the image you already run (#1572)" },
{ "type": "fix", "text": "fix vulnerability scans failing on the containerd image store (#1569, #1350)" },
{ "type": "fix", "text": "show CPU and memory for containers on Windows Docker hosts (#1574)" },
Expand Down
10 changes: 5 additions & 5 deletions src/lib/openapi.generated.json
Original file line number Diff line number Diff line change
Expand Up @@ -16149,7 +16149,7 @@
"tags": [
"icons"
],
"summary": "Get a selfh.st app icon (raw SVG), fetched once from the CDN and cached on disk",
"summary": "Get a selfh.st app icon, fetched once from the CDN and cached on disk",
"parameters": [
{
"name": "ref",
Expand All @@ -16163,7 +16163,7 @@
],
"responses": {
"200": {
"description": "Raw image/svg+xml body (the icon, or a neutral placeholder if it can't be resolved), Cache-Control public max-age=604800"
"description": "Raw image body (image/svg+xml, image/webp, or image/png; a neutral SVG placeholder if it can't be resolved), Cache-Control public max-age=604800"
},
"400": {
"description": "The ref is not a valid selfh.st reference"
Expand All @@ -16177,7 +16177,7 @@
"bearerAuth": []
}
],
"description": "Proxies an icon from the selfh.st collection so the browser never contacts an external CDN directly. The first request for a given ref fetches the SVG from jsdelivr and caches it under DATA_DIR; later requests are served locally. Icons are CC BY 4.0 (selfh.st). When the icon can't be resolved it returns a neutral placeholder SVG (200) rather than a 404, so a grid of icons doesn't read as probing to a WAF."
"description": "Proxies an icon from the selfh.st collection so the browser never contacts an external CDN directly. The first request for a given ref fetches it from jsdelivr in the first available format (SVG, else WebP, else PNG - about 16% of the collection has no SVG) and caches it under DATA_DIR; later requests are served locally. Icons are CC BY 4.0 (selfh.st). When the icon can't be resolved in any format it returns a neutral placeholder SVG (200) rather than a 404, so a grid of icons doesn't read as probing to a WAF."
}
},
"/api/icons/selfhst/batch": {
Expand All @@ -16190,7 +16190,7 @@
"parameters": [],
"responses": {
"200": {
"description": "icons maps each resolved reference to a data:image/svg+xml base64 URI; unresolvable refs are omitted.",
"description": "icons maps each resolved reference to a data: base64 URI in the format available (svg/webp/png); unresolvable refs are omitted.",
"content": {
"application/json": {
"schema": {
Expand Down Expand Up @@ -16225,7 +16225,7 @@
"bearerAuth": []
}
],
"description": "Takes a list of selfh.st icon references and returns each resolved icon as a data:image/svg+xml URI, keyed by reference. Unresolvable or invalid refs are omitted rather than returned as errors. Each icon is fetched from the CDN once and cached on disk, same as the single-icon endpoint.",
"description": "Takes a list of selfh.st icon references and returns each resolved icon as a data: URI (image/svg+xml, image/webp, or image/png depending on the format available), keyed by reference. Unresolvable or invalid refs are omitted rather than returned as errors. Each icon is fetched from the CDN once and cached on disk, same as the single-icon endpoint.",
"requestBody": {
"required": true,
"content": {
Expand Down
154 changes: 96 additions & 58 deletions src/lib/server/selfhst-icons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,31 @@ const MANIFEST_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
// transiently-failed fetch, is retried instead of stuck on the fallback glyph forever.
const NEG_CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 1 day

// selfh.st serves each icon in up to three formats. About 16% of the collection has no
// SVG (e.g. `unpackerr`), so we fall back to raster. SVG first (scalable, sanitizable),
// then WebP (smaller than PNG at equal quality), then PNG as the last resort. All render
// via <img>, so a raster icon is as safe as the sanitized SVG.
const ICON_FORMATS = [
{ ext: 'svg', dir: 'svg', contentType: 'image/svg+xml' },
{ ext: 'webp', dir: 'webp', contentType: 'image/webp' },
{ ext: 'png', dir: 'png', contentType: 'image/png' }
] as const;
type IconFormat = (typeof ICON_FORMATS)[number];

/** Cached icon bytes plus the media type to serve them with. */
export interface SelfhstIcon {
buffer: Buffer;
contentType: string;
}

/** True when the buffer's magic bytes match the format (rejects mislabelled/garbage bytes). */
export function magicOk(ext: 'svg' | 'webp' | 'png', buf: Buffer): boolean {
if (ext === 'svg') return buf[0] === 0x3c; // '<'
if (ext === 'png') return buf.length >= 8 && buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47;
// webp: "RIFF"...."WEBP"
return buf.length >= 12 && buf.toString('ascii', 0, 4) === 'RIFF' && buf.toString('ascii', 8, 12) === 'WEBP';
}

/** Write bytes atomically (temp + rename) so a crash mid-write never leaves a partial file. */
function atomicWrite(path: string, data: Buffer): void {
const tmp = `${path}.${process.pid}.tmp`;
Expand Down Expand Up @@ -50,85 +75,98 @@ function cacheDir(): string {
return dir;
}

/** Absolute cache path for a ref's svg. Throws on an invalid ref (never builds a path from junk). */
export function selfhstCachePath(ref: string): string {
/**
* Absolute cache path for a ref's icon in a given format (default svg). Throws on an
* invalid ref (never builds a path from junk). The ext is from a fixed internal set.
*/
export function selfhstCachePath(ref: string, ext: IconFormat['ext'] = 'svg'): string {
if (!isValidSelfhstRef(ref)) throw new Error('Invalid selfh.st icon reference');
return join(cacheDir(), `${ref}.svg`);
return join(cacheDir(), `${ref}.${ext}`);
}

async function fetchWithTimeout(url: string): Promise<Response> {
const ac = new AbortController();
const t = setTimeout(() => ac.abort(), FETCH_TIMEOUT_MS);
try {
return await fetch(url, { signal: ac.signal, headers: { Accept: 'image/svg+xml,*/*' } });
return await fetch(url, { signal: ac.signal, headers: { Accept: 'image/*,*/*' } });
} finally {
clearTimeout(t);
}
}

/**
* Return the cached SVG bytes for a ref, fetching+caching from the CDN on a miss.
* Returns null when the ref is invalid, not found upstream, or the fetch fails -
* the caller then serves a 404 and the UI falls back to a generic icon. Never throws.
* A valid cached file for this format, or null. A NON-EMPTY file that fails the magic
* check is corrupt and dropped so it refetches. A 0-byte file is left alone: for the svg
* format that is the negative-cache tombstone, honoured by the dedicated check in the
* caller (deleting it here would defeat the negative cache).
*/
function readCachedFormat(ref: string, fmt: IconFormat): Buffer | null {
const path = selfhstCachePath(ref, fmt.ext);
if (!existsSync(path)) return null;
try {
const cached = readFileSync(path);
if (cached.length === 0) return null; // tombstone / empty - leave it in place
if (magicOk(fmt.ext, cached)) return cached;
try { unlinkSync(path); } catch { /* best-effort */ }
} catch {
/* fall through */
}
return null;
}

/**
* Return the cached icon bytes + content type for a ref, fetching+caching from the CDN
* on a miss. Tries SVG, then WebP, then PNG (about 16% of the collection has no SVG).
* Returns null when the ref is invalid, exists in no format upstream, or every fetch
* fails - the caller then serves a placeholder. Never throws.
*/
export async function getSelfhstIcon(ref: string): Promise<Buffer | null> {
export async function getSelfhstIcon(ref: string): Promise<SelfhstIcon | null> {
if (!isValidSelfhstRef(ref)) return null;
const path = selfhstCachePath(ref);
if (existsSync(path)) {

// Serve from cache if any format is already stored.
for (const fmt of ICON_FORMATS) {
const cached = readCachedFormat(ref, fmt);
if (cached) return { buffer: cached, contentType: fmt.contentType };
}

// Negative-cache tombstone (keyed on the .svg path) means "no format exists"; honour
// it until it expires, then fall through to refetch every format.
const tombPath = selfhstCachePath(ref, 'svg');
if (existsSync(tombPath)) {
try {
const cached = readFileSync(path);
// A zero-byte file is a negative-cache tombstone; honour it until it expires,
// then fall through to refetch (a ref added upstream later, or a transient
// failure, must not be stuck on the fallback forever).
if (cached.length === 0) {
if (Date.now() - statSync(path).mtimeMs < NEG_CACHE_TTL_MS) return null;
} else if (cached[0] === 0x3c) {
return cached; // still looks like an SVG - serve it
}
// zero-byte-but-stale, or a nonzero-but-corrupt file (e.g. a truncated write):
// drop it and refetch.
try {
unlinkSync(path);
} catch {
/* best-effort */
}
} catch {
// fall through to refetch
}
const st = statSync(tombPath);
if (st.size === 0 && Date.now() - st.mtimeMs < NEG_CACHE_TTL_MS) return null;
} catch { /* fall through */ }
}
const tombstone = () => {

// Try each format in order. A 404 for one format just moves to the next; only when
// ALL formats miss (or error) do we tombstone below.
for (const fmt of ICON_FORMATS) {
try {
atomicWrite(path, Buffer.alloc(0));
const res = await fetchWithTimeout(`${CDN_BASE}/${fmt.dir}/${ref}.${fmt.ext}`);
if (!res.ok) continue; // not in this format - try the next
const buf = Buffer.from(await res.arrayBuffer());
if (buf.length === 0 || buf.length > MAX_ICON_BYTES || !magicOk(fmt.ext, buf)) continue;
if (fmt.ext === 'svg') {
// Strip active content before caching. An SVG from a third-party repo could
// carry <script>/on*/<foreignObject>; we render via <img> and a locked-down
// CSP, but sanitizing at rest is defense in depth against a stored payload.
const clean = Buffer.from(sanitizeSvg(buf.toString('utf-8')), 'utf-8');
atomicWrite(selfhstCachePath(ref, 'svg'), clean);
return { buffer: clean, contentType: fmt.contentType };
}
// Raster formats are inert bytes; cache verbatim after the magic-byte check.
atomicWrite(selfhstCachePath(ref, fmt.ext), buf);
return { buffer: buf, contentType: fmt.contentType };
} catch {
/* best-effort */
}
};
try {
const res = await fetchWithTimeout(`${CDN_BASE}/svg/${ref}.svg`);
if (!res.ok) {
// Remember the miss (with a TTL) so a matched-but-missing ref isn't re-fetched
// every render - covers 404 and transient 5xx/timeouts alike.
tombstone();
return null;
// timeout / network for this format - try the next, then tombstone if all fail
}
const buf = Buffer.from(await res.arrayBuffer());
if (buf.length === 0 || buf.length > MAX_ICON_BYTES) return null;
// Sanity check: SVG starts with '<' (xml/svg), never binary junk.
if (buf[0] !== 0x3c) return null;
// Strip active content before caching. The icons come from a third-party
// community repo; an SVG is an active-content format, so a poisoned/scripted
// logo could carry <script>/on*/<foreignObject>. We render only via <img>
// (script-inert) AND serve with a locked-down CSP, but sanitizing at rest is
// defense in depth so a cached file can never be a stored-XSS payload.
const clean = Buffer.from(sanitizeSvg(buf.toString('utf-8')), 'utf-8');
atomicWrite(path, clean);
return clean;
} catch {
// Network error / timeout: tombstone with a TTL so we back off instead of
// re-running an 8s-timeout fetch on every remount of the row.
tombstone();
return null;
}

// Every format missed. Tombstone (with a TTL) so a matched-but-missing ref isn't
// re-fetched on every render; a transient network error backs off the same way.
try { atomicWrite(tombPath, Buffer.alloc(0)); } catch { /* best-effort */ }
return null;
}

/**
Expand Down
5 changes: 3 additions & 2 deletions src/lib/stores/selfhst-refs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,10 @@ export async function ensureSelfhstMatcher(): Promise<void> {
try {
const res = await fetch('/api/icons/selfhst-manifest');
if (!res.ok) return;
const entries = (await res.json()) as { Reference?: string; SVG?: string }[];
const entries = (await res.json()) as { Reference?: string; SVG?: string; WebP?: string; PNG?: string }[];
const refs = new Set<string>();
for (const e of entries) if (e.SVG === 'Yes' && e.Reference) refs.add(e.Reference.toLowerCase());
// Keep any icon available in a format we can serve (SVG, else WebP, else PNG).
for (const e of entries) if (e.Reference && (e.SVG === 'Yes' || e.WebP === 'Yes' || e.PNG === 'Yes')) refs.add(e.Reference.toLowerCase());
matcher.set(createSelfhstMatcher(refs));
loaded = true;
} catch {
Expand Down
17 changes: 17 additions & 0 deletions src/lib/utils/template-dedupe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/**
* Give every template a unique id (mutates + returns the list). Template ids are derived
* from source+title, so two entries with the same title in one source collide - which makes
* the client's keyed {#each} throw (each_key_duplicate) and render nothing. Any repeat is
* suffixed so the ids stay stable-ish but never duplicate. Pure, import-light, unit-tested.
*/
export function dedupeTemplateIds<T extends { id: string }>(templates: T[]): T[] {
const seen = new Set<string>();
for (const t of templates) {
let id = t.id;
let n = 1;
while (seen.has(id)) id = `${t.id}-${n++}`;
seen.add(id);
t.id = id;
}
return templates;
}
25 changes: 13 additions & 12 deletions src/routes/api/icons/selfhst/[ref]/+server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@ import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getSelfhstIcon, isValidSelfhstRef } from '$lib/server/selfhst-icons';

const SVG_HEADERS = {
'Content-Type': 'image/svg+xml',
// The SVG is sanitized at cache time; these headers are belt-and-braces so even a
// direct navigation to this URL cannot execute embedded content.
// Security headers applied to every icon response regardless of format. An SVG is
// sanitized at cache time; raster formats are inert. These are belt-and-braces so even
// a direct navigation to this URL cannot execute embedded content. Content-Type is set
// per response from the resolved format.
const SECURITY_HEADERS = {
'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'; sandbox",
'X-Content-Type-Options': 'nosniff'
} as const;
Expand All @@ -19,26 +20,26 @@ const PLACEHOLDER_SVG =

/**
* @openapi
* summary: Get a selfh.st app icon (raw SVG), fetched once from the CDN and cached on disk
* description: Proxies an icon from the selfh.st collection so the browser never contacts an external CDN directly. The first request for a given ref fetches the SVG from jsdelivr and caches it under DATA_DIR; later requests are served locally. Icons are CC BY 4.0 (selfh.st). When the icon can't be resolved it returns a neutral placeholder SVG (200) rather than a 404, so a grid of icons doesn't read as probing to a WAF.
* summary: Get a selfh.st app icon, fetched once from the CDN and cached on disk
* description: Proxies an icon from the selfh.st collection so the browser never contacts an external CDN directly. The first request for a given ref fetches it from jsdelivr in the first available format (SVG, else WebP, else PNG - about 16% of the collection has no SVG) and caches it under DATA_DIR; later requests are served locally. Icons are CC BY 4.0 (selfh.st). When the icon can't be resolved in any format it returns a neutral placeholder SVG (200) rather than a 404, so a grid of icons doesn't read as probing to a WAF.
* path: ref:string! selfh.st icon Reference (lowercase letters, digits, hyphens), e.g. "plex"
* resp-200: Raw image/svg+xml body (the icon, or a neutral placeholder if it can't be resolved), Cache-Control public max-age=604800
* resp-200: Raw image body (image/svg+xml, image/webp, or image/png; a neutral SVG placeholder if it can't be resolved), Cache-Control public max-age=604800
* resp-400: The ref is not a valid selfh.st reference
*/
export const GET: RequestHandler = async ({ params }) => {
const ref = params.ref;
if (!isValidSelfhstRef(ref)) {
return json({ error: 'Invalid icon reference' }, { status: 400 });
}
const buf = await getSelfhstIcon(ref);
if (!buf) {
const icon = await getSelfhstIcon(ref);
if (!icon) {
// A short cache so a ref that resolves later (transient CDN failure) is retried
// soon, but repeated views in the meantime don't re-hit the server.
return new Response(PLACEHOLDER_SVG, {
headers: { ...SVG_HEADERS, 'Cache-Control': 'public, max-age=300' }
headers: { ...SECURITY_HEADERS, 'Content-Type': 'image/svg+xml', 'Cache-Control': 'public, max-age=300' }
});
}
return new Response(new Uint8Array(buf), {
headers: { ...SVG_HEADERS, 'Cache-Control': 'public, max-age=604800' }
return new Response(new Uint8Array(icon.buffer), {
headers: { ...SECURITY_HEADERS, 'Content-Type': icon.contentType, 'Cache-Control': 'public, max-age=604800' }
});
};
Loading
Loading