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,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
@@ -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 };
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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.
Reference in new issue
Block a user