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

+43
View File
@@ -0,0 +1,43 @@
import { resolve } from 'path';
import { existsSync, mkdirSync, writeFileSync, unlinkSync, readFileSync } from 'fs';
import { createHash } from 'crypto';
// Re-export so the icon endpoints share ONE magic-byte check with stacks.
export { looksLikeImage } from './stack-icons';
/**
* Custom uploaded container icons, mirroring stack-icons.ts. A container has no
* stable numeric id (it is keyed by name+env, the name being stable across
* recreation), so the on-disk filename is a hash of name+env - collision-free and
* filesystem-safe regardless of the container name.
*/
function getIconsDir(): string {
const dataDir = process.env.DATA_DIR || './data';
const dir = resolve(dataDir, 'icons', 'containers');
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
return dir;
}
/** Stable, path-safe basename for a container's custom icon. */
export function containerIconKey(containerName: string, envId: number | null): string {
return createHash('sha256').update(`${containerName} ${envId ?? ''}`).digest('hex').slice(0, 32);
}
function iconPath(containerName: string, envId: number | null): string {
return resolve(getIconsDir(), `${containerIconKey(containerName, envId)}.webp`);
}
export function saveContainerIcon(containerName: string, envId: number | null, base64Data: string): void {
const base64 = base64Data.replace(/^data:image\/\w+;base64,/, '');
writeFileSync(iconPath(containerName, envId), Buffer.from(base64, 'base64'));
}
export function deleteContainerIcon(containerName: string, envId: number | null): void {
const path = iconPath(containerName, envId);
if (existsSync(path)) unlinkSync(path);
}
export function getContainerIconBuffer(containerName: string, envId: number | null): Buffer | null {
const path = iconPath(containerName, envId);
return existsSync(path) ? readFileSync(path) : null;
}
+74 -6
View File
@@ -43,6 +43,7 @@ import {
gitStacks,
secretProviders,
stackSources,
containerIconOverrides,
vulnerabilityScans,
auditLogs,
containerEvents,
@@ -2946,6 +2947,7 @@ export interface StackSourceData {
composePath: string | null;
envPath: string | null;
secretProviderId: number | null;
icon: string | null;
createdAt: string;
updatedAt: string;
}
@@ -3054,6 +3056,7 @@ export async function upsertStackSource(data: {
composePath?: string | null;
envPath?: string | null;
secretProviderId?: number | null;
icon?: string | null;
}): Promise<StackSourceData> {
const existing = await getStackSource(data.stackName, data.environmentId);
@@ -3077,7 +3080,9 @@ export async function upsertStackSource(data: {
envPath: data.envPath ?? null,
updatedAt: new Date().toISOString(),
// Preserve existing binding when caller (like git) omits it
...(data.secretProviderId !== undefined && { secretProviderId: data.secretProviderId })
...(data.secretProviderId !== undefined && { secretProviderId: data.secretProviderId }),
// Same preserve-on-omit for the icon, so a git sync doesn't wipe a user's choice
...(data.icon !== undefined && { icon: data.icon })
})
.where(eq(stackSources.id, existing.id));
return getStackSource(data.stackName, data.environmentId) as Promise<StackSourceData>;
@@ -3091,7 +3096,8 @@ export async function upsertStackSource(data: {
gitStackId: data.gitStackId || null,
composePath: data.composePath ?? null,
envPath: data.envPath ?? null,
secretProviderId: data.secretProviderId ?? null
secretProviderId: data.secretProviderId ?? null,
icon: data.icon ?? null
});
return getStackSource(data.stackName, data.environmentId) as Promise<StackSourceData>;
}
@@ -3100,7 +3106,7 @@ export async function upsertStackSource(data: {
export async function updateStackSource(
stackName: string,
environmentId: number | null,
updates: { composePath?: string | null; envPath?: string | null; secretProviderId?: number | null }
updates: { composePath?: string | null; envPath?: string | null; secretProviderId?: number | null; icon?: string | null }
): Promise<boolean> {
const existing = await getStackSource(stackName, environmentId);
if (!existing) return false;
@@ -3110,6 +3116,7 @@ export async function updateStackSource(
composePath: updates.composePath !== undefined ? updates.composePath : existing.composePath,
envPath: updates.envPath !== undefined ? updates.envPath : existing.envPath,
secretProviderId: updates.secretProviderId !== undefined ? updates.secretProviderId : existing.secretProviderId,
icon: updates.icon !== undefined ? updates.icon : existing.icon,
updatedAt: new Date().toISOString()
})
.where(eq(stackSources.id, existing.id));
@@ -3193,6 +3200,53 @@ export async function updateStackSourceName(
return true;
}
// =============================================================================
// CONTAINER ICON OVERRIDES
// =============================================================================
// A user-picked icon for a container, keyed by (name, env). Absent -> the UI's
// automatic image/name matching applies. The value is a lucide name,
// 'selfhst:<ref>', or 'custom:container' (bytes on disk via container-icons.ts).
function containerEnvClause(environmentId: number | null) {
return environmentId !== null
? eq(containerIconOverrides.environmentId, environmentId)
: isNull(containerIconOverrides.environmentId);
}
/** The icon override for one container, or null if none is set. */
export async function getContainerIconOverride(containerName: string, environmentId: number | null): Promise<string | null> {
const rows = await db.select().from(containerIconOverrides)
.where(and(eq(containerIconOverrides.containerName, containerName), containerEnvClause(environmentId)))
.limit(1);
return rows[0]?.icon ?? null;
}
/** All overrides for an environment as a name->icon map, for the list page (no N+1). */
export async function getContainerIconOverrides(environmentId: number | null): Promise<Record<string, string>> {
const rows = await db.select().from(containerIconOverrides).where(containerEnvClause(environmentId));
const map: Record<string, string> = {};
for (const row of rows) map[row.containerName] = row.icon;
return map;
}
/** Upsert the icon override for a container. */
export async function setContainerIconOverride(containerName: string, environmentId: number | null, icon: string): Promise<void> {
const existing = await getContainerIconOverride(containerName, environmentId);
if (existing !== null) {
await db.update(containerIconOverrides)
.set({ icon, updatedAt: new Date().toISOString() })
.where(and(eq(containerIconOverrides.containerName, containerName), containerEnvClause(environmentId)));
} else {
await db.insert(containerIconOverrides).values({ containerName, environmentId, icon });
}
}
/** Remove a container's icon override (fall back to automatic matching). */
export async function deleteContainerIconOverride(containerName: string, environmentId: number | null): Promise<void> {
await db.delete(containerIconOverrides)
.where(and(eq(containerIconOverrides.containerName, containerName), containerEnvClause(environmentId)));
}
// =============================================================================
// VULNERABILITY SCAN RESULTS
// =============================================================================
@@ -5569,6 +5623,8 @@ export async function createBackupDestination(data: {
flags?: string | null;
hostPath?: string | null;
policies?: string | null;
cacert?: string | null;
tlsClientCert?: string | null;
}): Promise<BackupDestination> {
const result = await db.insert(backupDestinations).values({
name: data.name,
@@ -5577,7 +5633,9 @@ export async function createBackupDestination(data: {
envVars: data.envVars ? encrypt(data.envVars) : null,
flags: data.flags ?? null,
hostPath: data.hostPath ?? null,
policies: data.policies ?? null
policies: data.policies ?? null,
cacert: data.cacert ? encrypt(data.cacert) : null,
tlsClientCert: data.tlsClientCert ? encrypt(data.tlsClientCert) : null
}).returning();
return result[0];
}
@@ -5590,6 +5648,8 @@ export async function updateBackupDestination(id: number, data: {
flags?: string | null;
hostPath?: string | null;
policies?: string | null;
cacert?: string | null;
tlsClientCert?: string | null;
lastTestAt?: string | null;
lastTestStatus?: string | null;
lastTestError?: string | null;
@@ -5604,6 +5664,9 @@ export async function updateBackupDestination(id: number, data: {
// Same guard for envVars: an empty string would null out stored cloud creds.
// (An explicit '{}' JSON string is truthy and still clears them intentionally.)
if (data.envVars) updateData.envVars = encrypt(data.envVars);
// Certs (optional): undefined = keep, '' = clear (user removed it), value = encrypt.
if (data.cacert !== undefined) updateData.cacert = data.cacert ? encrypt(data.cacert) : null;
if (data.tlsClientCert !== undefined) updateData.tlsClientCert = data.tlsClientCert ? encrypt(data.tlsClientCert) : null;
if (data.flags !== undefined) updateData.flags = data.flags;
if (data.hostPath !== undefined) updateData.hostPath = data.hostPath;
if (data.policies !== undefined) updateData.policies = data.policies;
@@ -5631,7 +5694,7 @@ export async function updateBackupDestinationTestStatus(id: number, status: 'suc
/**
* Decrypt sensitive fields from a backup destination for runtime use.
*/
export function decryptBackupDestination(dest: BackupDestination): BackupDestination & { decryptedPassword: string; decryptedEnvVars: Record<string, string> } {
export function decryptBackupDestination(dest: BackupDestination): BackupDestination & { decryptedPassword: string; decryptedEnvVars: Record<string, string>; decryptedCacert: string | null; decryptedTlsClientCert: string | null } {
// (audit low #55) Fail closed: if the stored password is a genuine ciphertext
// blob that can't be decrypted (wrong/rotated key), decryptStrict throws rather
// than forwarding the literal `enc:v1:...` string as RESTIC_PASSWORD.
@@ -5647,7 +5710,12 @@ export function decryptBackupDestination(dest: BackupDestination): BackupDestina
}
}
}
return { ...dest, decryptedPassword, decryptedEnvVars };
// PEM certs for a self-signed TLS backend (#1451). Same fail-closed rule as the
// password: a genuine ciphertext that can't be decrypted throws rather than passing
// the literal enc:v1 string to restic.
const decryptedCacert = dest.cacert ? (decryptStrict(dest.cacert) || null) : null;
const decryptedTlsClientCert = dest.tlsClientCert ? (decryptStrict(dest.tlsClientCert) || null) : null;
return { ...dest, decryptedPassword, decryptedEnvVars, decryptedCacert, decryptedTlsClientCert };
}
// =============================================================================
+1
View File
@@ -925,6 +925,7 @@ export const gitRepositories = schemaProxy.gitRepositories;
export const gitStacks = schemaProxy.gitStacks;
export const secretProviders = schemaProxy.secretProviders;
export const stackSources = schemaProxy.stackSources;
export const containerIconOverrides = schemaProxy.containerIconOverrides;
export const vulnerabilityScans = schemaProxy.vulnerabilityScans;
export const auditLogs = schemaProxy.auditLogs;
export const containerEvents = schemaProxy.containerEvents;
+21
View File
@@ -358,12 +358,31 @@ export const stackSources = sqliteTable('stack_sources', {
// Names (no values) of secret keys injected from the bound provider on the last
// deploy, so container inspect can mask them without a live provider call.
injectedSecretKeys: text('injected_secret_keys'),
// Per-stack icon: a lucide name ('server'), 'selfhst:<ref>', or 'custom:<file>'.
// Null -> UI falls back to a generic icon.
icon: text('icon'),
createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`),
updatedAt: text('updated_at').default(sql`CURRENT_TIMESTAMP`)
}, (table) => ({
stackSourceEnvUnique: unique().on(table.stackName, table.environmentId)
}));
// Per-container icon override. Containers are ephemeral Docker objects (no DB row of
// their own), so the override is keyed by (containerName, environmentId) - the name is
// stable across recreation (auto-update, compose up), unlike the container id. Absent
// row -> the UI's automatic image/name icon matching applies.
export const containerIconOverrides = sqliteTable('container_icon_overrides', {
id: integer('id').primaryKey({ autoIncrement: true }),
containerName: text('container_name').notNull(),
environmentId: integer('environment_id').references(() => environments.id, { onDelete: 'cascade' }),
// A lucide name ('server'), 'selfhst:<ref>', or 'custom:<file>'. Same shape as stack icons.
icon: text('icon').notNull(),
createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`),
updatedAt: text('updated_at').default(sql`CURRENT_TIMESTAMP`)
}, (table) => ({
containerIconEnvUnique: unique().on(table.containerName, table.environmentId)
}));
export const stackEnvironmentVariables = sqliteTable('stack_environment_variables', {
id: integer('id').primaryKey({ autoIncrement: true }),
stackName: text('stack_name').notNull(),
@@ -506,6 +525,8 @@ export const backupDestinations = sqliteTable('backup_destinations', {
envVars: text('env_vars'), // JSON: { key: value } — values encrypted
flags: text('flags'), // extra restic CLI flags
hostPath: text('host_path'), // bind-mount source for local repos
cacert: text('cacert'), // AES-256-GCM encrypted PEM: self-signed CA for a TLS backend (RESTIC_CACERT)
tlsClientCert: text('tls_client_cert'), // AES-256-GCM encrypted PEM: client cert+key for mTLS (RESTIC_TLS_CLIENT_CERT)
policies: text('policies'), // JSON: { pruneSchedule, checkSchedule, autoUnlock, maxUnused }
lastTestAt: text('last_test_at'),
lastTestStatus: text('last_test_status'), // 'success' | 'failed'
+20
View File
@@ -361,12 +361,30 @@ export const stackSources = pgTable('stack_sources', {
// Names (no values) of secret keys injected from the bound provider on the last
// deploy, so container inspect can mask them without a live provider call.
injectedSecretKeys: text('injected_secret_keys'),
// Per-stack icon: a lucide name ('server'), 'selfhst:<ref>', or 'custom:<file>'.
// Null -> UI falls back to a generic icon.
icon: text('icon'),
createdAt: timestamp('created_at', { mode: 'string' }).defaultNow(),
updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow()
}, (table) => ({
stackSourceEnvUnique: unique().on(table.stackName, table.environmentId)
}));
// Per-container icon override, keyed by (containerName, environmentId). Containers have
// no DB row of their own and are recreated with a new id but a stable name, so the name
// is the durable key. Absent row -> automatic image/name icon matching applies.
export const containerIconOverrides = pgTable('container_icon_overrides', {
id: serial('id').primaryKey(),
containerName: text('container_name').notNull(),
environmentId: integer('environment_id').references(() => environments.id, { onDelete: 'cascade' }),
// A lucide name ('server'), 'selfhst:<ref>', or 'custom:<file>'. Same shape as stack icons.
icon: text('icon').notNull(),
createdAt: timestamp('created_at', { mode: 'string' }).defaultNow(),
updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow()
}, (table) => ({
containerIconEnvUnique: unique().on(table.containerName, table.environmentId)
}));
export const stackEnvironmentVariables = pgTable('stack_environment_variables', {
id: serial('id').primaryKey(),
stackName: text('stack_name').notNull(),
@@ -509,6 +527,8 @@ export const backupDestinations = pgTable('backup_destinations', {
envVars: text('env_vars'),
flags: text('flags'),
hostPath: text('host_path'),
cacert: text('cacert'),
tlsClientCert: text('tls_client_cert'),
policies: text('policies'),
lastTestAt: timestamp('last_test_at', { mode: 'string' }),
lastTestStatus: text('last_test_status'),
+183
View File
@@ -0,0 +1,183 @@
/**
* selfh.st icon proxy + on-disk cache.
*
* The browser NEVER hits an external CDN: it asks our /api/icons/selfhst/<ref>
* endpoint, we fetch the SVG from jsdelivr ONCE, cache it under DATA_DIR, and
* serve it locally forever after. This keeps what a user self-hosts private (no
* per-view CDN request that would reveal their app list) and works offline once
* cached. Icons are CC-BY-4.0 (attribution shown in the picker + manual).
*/
import { resolve, join } from 'path';
import { existsSync, mkdirSync, writeFileSync, readFileSync, statSync, renameSync, unlinkSync } from 'fs';
// Only these hosts/paths are ever fetched - the ref is the sole user-controlled
// input and is strictly validated below, so there is no SSRF surface.
const CDN_BASE = 'https://cdn.jsdelivr.net/gh/selfhst/icons';
const MANIFEST_URL = `${CDN_BASE}/index.json`;
const FETCH_TIMEOUT_MS = 8000;
const MAX_ICON_BYTES = 512 * 1024; // an SVG logo is a few KB; cap defensively
const MANIFEST_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
// A negative-cache tombstone (0-byte file) expires so a ref added upstream later, or a
// transiently-failed fetch, is retried instead of stuck on the fallback glyph forever.
const NEG_CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 1 day
/** 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`;
writeFileSync(tmp, data);
renameSync(tmp, path);
}
/** A selfh.st Reference: lowercase letters, digits, hyphens only. Anti-traversal. */
export function isValidSelfhstRef(ref: string): boolean {
return /^[a-z0-9-]{1,64}$/.test(ref);
}
function cacheDir(): string {
const dataDir = process.env.DATA_DIR || './data';
const dir = resolve(dataDir, 'icon-cache', 'selfhst');
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
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 {
if (!isValidSelfhstRef(ref)) throw new Error('Invalid selfh.st icon reference');
return join(cacheDir(), `${ref}.svg`);
}
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,*/*' } });
} 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.
*/
export async function getSelfhstIcon(ref: string): Promise<Buffer | null> {
if (!isValidSelfhstRef(ref)) return null;
const path = selfhstCachePath(ref);
if (existsSync(path)) {
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 tombstone = () => {
try {
atomicWrite(path, Buffer.alloc(0));
} 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;
}
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;
}
}
/**
* Remove active content from an SVG string: <script> elements, on* event-handler
* attributes, <foreignObject> (can embed HTML), and javascript: URLs. Conservative
* regex strip - the SVGs are simple logos, not documents.
*/
export function sanitizeSvg(svg: string): string {
return svg
.replace(/<script[\s\S]*?<\/script\s*>/gi, '')
.replace(/<foreignObject[\s\S]*?<\/foreignObject\s*>/gi, '')
.replace(/\son\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, '')
.replace(/(href|xlink:href)\s*=\s*("|')?\s*javascript:[^"'>]*("|')?/gi, '');
}
// --- manifest (index.json, ~2880 entries) cached for the search picker ---
function manifestPath(): string {
return join(cacheDir(), 'index.json');
}
function manifestFresh(): boolean {
try {
const st = statSync(manifestPath());
return Date.now() - st.mtimeMs < MANIFEST_TTL_MS;
} catch {
return false;
}
}
/**
* Return the selfh.st manifest JSON (as a string), cached on disk with a 7-day TTL.
* Returns null if it has never been fetched and the fetch fails.
*/
export async function getSelfhstManifest(): Promise<string | null> {
const path = manifestPath();
if (manifestFresh()) {
try {
return readFileSync(path, 'utf-8');
} catch {
// fall through to refetch
}
}
try {
const res = await fetchWithTimeout(MANIFEST_URL);
if (!res.ok) throw new Error(`manifest ${res.status}`);
const text = await res.text();
JSON.parse(text); // validate it parses before caching
atomicWrite(path, Buffer.from(text, 'utf-8'));
return text;
} catch {
// Serve a stale cached copy if we have one; otherwise null.
if (existsSync(path)) {
try {
return readFileSync(path, 'utf-8');
} catch {
return null;
}
}
return null;
}
}
Binary file not shown.