+ App logos by
+ selfh.st,
+ licensed CC BY 4.0. Fetched once and cached locally.
+
+
+ Product names, logos and trademarks are the property of their respective owners and are shown for identification only; their use does not imply endorsement.
+
+
+
+
+
+
+
+
+
+
+ {#if value}
+
+ {/if}
+
+
+ (showIconCropper = false)}
+ onSave={handleIconCropSave}
+ />
+
+
diff --git a/src/routes/stacks/StackModal.svelte b/src/routes/stacks/StackModal.svelte
index 36c59ef..47f4159 100644
--- a/src/routes/stacks/StackModal.svelte
+++ b/src/routes/stacks/StackModal.svelte
@@ -18,6 +18,8 @@
import { fetchBackupExecutions } from '$lib/utils/backup';
import type { Component } from 'svelte';
import FilesystemBrowser from './FilesystemBrowser.svelte';
+ import IconPickerModal from './IconPickerModal.svelte';
+ import StackIcon from '$lib/components/StackIcon.svelte';
import PathBarItem from './PathBarItem.svelte';
import * as Tooltip from '$lib/components/ui/tooltip';
import { Badge } from '$lib/components/ui/badge';
@@ -56,6 +58,61 @@
// Local effective state - can transition from create → edit after failed deploy
let mode = $state(propMode);
let stackName = $state(propStackName);
+ let formIcon = $state(null);
+ let showIconPicker = $state(false);
+ // Create mode has no stack to POST to yet - stash the pending upload data URL and
+ // send it once the stack is created (see persistPendingIcon after handleCreate).
+ let pendingUploadImage = $state(null);
+
+ // The picker value is one of: '' (clear), 'upload:' (custom upload), or a
+ // lucide name / 'selfhst:'. In edit mode it persists immediately via the /icon
+ // endpoint; in create mode it is held locally until the stack exists.
+ async function onIconSelect(value: string) {
+ if (mode !== 'edit' || !stackName) {
+ // Create mode: hold locally, persist after the stack is created.
+ if (!value) {
+ formIcon = null;
+ pendingUploadImage = null;
+ } else if (value.startsWith('upload:')) {
+ pendingUploadImage = value.slice('upload:'.length);
+ formIcon = 'custom:stack';
+ } else {
+ pendingUploadImage = null;
+ formIcon = value;
+ }
+ return;
+ }
+ const envId = $currentEnvironment?.id ?? null;
+ const target = appendEnvParam(`/api/stacks/${encodeURIComponent(stackName)}/icon`, envId);
+ try {
+ if (!value) {
+ await fetch(target, { method: 'DELETE' });
+ formIcon = null;
+ } else if (value.startsWith('upload:')) {
+ const image = value.slice('upload:'.length);
+ const res = await fetch(target, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ image }) });
+ if (res.ok) formIcon = (await res.json()).icon;
+ } else {
+ const res = await fetch(target, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ icon: value }) });
+ if (res.ok) formIcon = (await res.json()).icon;
+ }
+ onSuccess?.();
+ } catch (e) {
+ console.error('Failed to set stack icon:', e);
+ }
+ }
+
+ // After a stack is created, persist the icon picked in create mode to the new stack.
+ async function persistPendingIcon(name: string, envId: number | null) {
+ if (!formIcon) return;
+ const target = appendEnvParam(`/api/stacks/${encodeURIComponent(name)}/icon`, envId);
+ const body = pendingUploadImage ? { image: pendingUploadImage } : { icon: formIcon };
+ try {
+ await fetch(target, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
+ } catch (e) {
+ console.error('Failed to set stack icon:', e);
+ }
+ }
// Form state
let newStackName = $state('');
@@ -1107,6 +1164,7 @@
const sourceMap = await sourcesRes.json();
const source = sourceMap?.[stackName];
formSecretProviderId = source?.secretProviderId ?? null;
+ formIcon = source?.icon ?? null;
}
} catch (e) {
console.warn('Failed to load stack source for secret provider binding:', e);
@@ -1290,6 +1348,8 @@
throw new Error(data.error || 'Failed to create stack');
}
+ await persistPendingIcon(newStackName.trim(), envId);
+
toast.success(`Created stack "${newStackName.trim()}"`);
onSuccess();
handleClose();
@@ -1528,6 +1588,8 @@
loadError = null;
rawEnvContent = '';
errors = {};
+ formIcon = null;
+ pendingUploadImage = null;
composeContent = '';
envVars = [];
envValidation = null;
@@ -1714,9 +1776,26 @@
-
-
-
+ {#if !readonly}
+
+ {:else}
+
+
+
+ {/if}
{#if mode === 'create'}
@@ -2190,6 +2269,8 @@
+
+
diff --git a/src/routes/terminal/+page.svelte b/src/routes/terminal/+page.svelte
index 046b02b..adb537e 100644
--- a/src/routes/terminal/+page.svelte
+++ b/src/routes/terminal/+page.svelte
@@ -11,6 +11,7 @@
import * as Select from '$lib/components/ui/select';
import { Search, ChevronDown, Terminal as TerminalIcon, Unplug, RefreshCw, Trash2, Copy, Shell, User, Loader2, AlertCircle } from 'lucide-svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
+ import ContainerIcon from '$lib/components/ContainerIcon.svelte';
import type { ContainerInfo } from '$lib/types';
import { currentEnvironment, environments, appendEnvParam } from '$lib/stores/environment';
import Terminal from './Terminal.svelte';
@@ -294,6 +295,7 @@
onclick={() => selectContainer(container)}
class="w-full px-3 py-2 text-left text-sm hover:bg-muted transition-colors flex items-center gap-2 {selectedContainer?.id === container.id ? 'bg-muted' : ''}"
>
+ {container.name}({container.image})
{#if selectedContainer?.id === container.id}
diff --git a/src/routes/volumes/+page.svelte b/src/routes/volumes/+page.svelte
index 7da79f6..6d2ed64 100644
--- a/src/routes/volumes/+page.svelte
+++ b/src/routes/volumes/+page.svelte
@@ -19,6 +19,7 @@
import CloneVolumeModal from './CloneVolumeModal.svelte';
import ContainerInspectModal from '../containers/ContainerInspectModal.svelte';
import { appSettings } from '$lib/stores/settings';
+ import ContainerIcon from '$lib/components/ContainerIcon.svelte';
import type { VolumeInfo } from '$lib/types';
import { currentEnvironment, environments, appendEnvParam, clearStaleEnvironment } from '$lib/stores/environment';
import MultiSelectFilter from '$lib/components/MultiSelectFilter.svelte';
@@ -575,10 +576,11 @@
{/each}
{#if volume.usedBy.length > 3}
diff --git a/tests/selfhst-icons.test.ts b/tests/selfhst-icons.test.ts
new file mode 100644
index 0000000..2b75fbc
--- /dev/null
+++ b/tests/selfhst-icons.test.ts
@@ -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 ');
+ expect(out).not.toContain('