diff --git a/Dockerfile b/Dockerfile index 0a45c7d..42bfa0c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -163,6 +163,15 @@ RUN chmod +x ./scripts/*.sh ./scripts/**/*.sh 2>/dev/null || true RUN mkdir -p /home/dockhand/.dockhand/stacks /app/data \ && chown dockhand:dockhand /app/data /home/dockhand /home/dockhand/.dockhand /home/dockhand/.dockhand/stacks +# OCI image annotations (#1217) — lets tooling map this image back to its +# source repo (image registry name differs from GitHub repo name). +LABEL org.opencontainers.image.source="https://github.com/Finsys/dockhand" \ + org.opencontainers.image.url="https://dockhand.pro" \ + org.opencontainers.image.title="Dockhand" \ + org.opencontainers.image.description="Docker management" \ + org.opencontainers.image.vendor="Finsys" \ + org.opencontainers.image.licenses="BUSL-1.1" + EXPOSE 3000 HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ diff --git a/VERSION b/VERSION index a4ea962..0b8957a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v1.0.35 +v1.0.36 diff --git a/package.json b/package.json index 1bea040..dbb4c49 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "dockhand", "private": true, - "version": "1.0.35", + "version": "1.0.36", "type": "module", "scripts": { "dev": "npx vite dev", diff --git a/src/app.css b/src/app.css index 7944251..8c9e2dd 100644 --- a/src/app.css +++ b/src/app.css @@ -58,6 +58,38 @@ html { font-size: calc(12px * var(--grid-font-size-scale, 1)) !important; } +/* Grid action icons (#1072) — size is independent of grid font size. + * Driven by --action-icon-size set from Settings > General. Default 12px + * matches the historical Tailwind w-3 size; users can bump up for high-DPI. */ +.grid-action-icon { + width: var(--action-icon-size, 12px) !important; + height: var(--action-icon-size, 12px) !important; +} + +/* Colored action icons (#1072) — opt-in via Settings > General. + * When html.colored-actions is set, semantic .grid-action-* classes paint + * their icons. Default (no class) leaves them muted-foreground gray. */ +html.colored-actions .grid-action-start { color: rgb(22 163 74); } +html.colored-actions.dark .grid-action-start { color: rgb(74 222 128); } +html.colored-actions .grid-action-stop { color: rgb(217 119 6); } +html.colored-actions.dark .grid-action-stop { color: rgb(251 191 36); } +html.colored-actions .grid-action-pause { color: rgb(217 119 6); } +html.colored-actions.dark .grid-action-pause { color: rgb(251 191 36); } +html.colored-actions .grid-action-restart { color: rgb(37 99 235); } +html.colored-actions.dark .grid-action-restart { color: rgb(96 165 250); } +html.colored-actions .grid-action-delete { color: rgb(220 38 38); } +html.colored-actions.dark .grid-action-delete { color: rgb(248 113 113); } +html.colored-actions .grid-action-edit { color: rgb(37 99 235); } +html.colored-actions.dark .grid-action-edit { color: rgb(96 165 250); } +html.colored-actions .grid-action-info { color: rgb(71 85 105); } +html.colored-actions.dark .grid-action-info { color: rgb(148 163 184); } +html.colored-actions .grid-action-logs { color: rgb(37 99 235); } +html.colored-actions.dark .grid-action-logs { color: rgb(96 165 250); } +html.colored-actions .grid-action-terminal { color: rgb(22 163 74); } +html.colored-actions.dark .grid-action-terminal { color: rgb(74 222 128); } +html.colored-actions .grid-action-transfer { color: rgb(37 99 235); } +html.colored-actions.dark .grid-action-transfer { color: rgb(96 165 250); } + /* State badge - width scales with grid font size to stay consistent */ .state-badge { width: calc(70px * var(--grid-font-size-scale, 1)); @@ -1341,13 +1373,19 @@ html { line-height: 14px; } - /* Icon animation toggle (#1169): when html.no-icon-animation is set, the - common Tailwind animation utilities collapse to no-op. This keeps the - layout (spinners still occupy space) but removes the motion. */ - html.no-icon-animation .animate-spin, - html.no-icon-animation .animate-pulse, - html.no-icon-animation .animate-bounce, - html.no-icon-animation .animate-ping { + /* Icon animation toggle (#1169): when html.no-icon-animation is set, kill + the ambient/decorative spinners but leave active-operation indicators + alone. Lucide icons that ONLY exist as "operation in progress" markers + — Loader2 (renders as lucide-loader-circle), RefreshCw, RefreshCcw, + RotateCw, RotateCcw — are excluded regardless of the toggle. So is + anything explicitly tagged `animate-action`. Permanent ambient + spinners (e.g. the "update available" CircleArrowUp glyph in container + list rows) still stop. */ + html.no-icon-animation + .animate-spin:not(.lucide-loader-circle):not(.lucide-refresh-cw):not(.lucide-refresh-ccw):not(.lucide-rotate-cw):not(.lucide-rotate-ccw):not(.animate-action), + html.no-icon-animation .animate-pulse:not(.animate-action), + html.no-icon-animation .animate-bounce:not(.animate-action), + html.no-icon-animation .animate-ping:not(.animate-action) { animation: none !important; } diff --git a/src/lib/components/ColoredActionsToggle.svelte b/src/lib/components/ColoredActionsToggle.svelte new file mode 100644 index 0000000..55cb96c --- /dev/null +++ b/src/lib/components/ColoredActionsToggle.svelte @@ -0,0 +1,34 @@ + + +
+
+ + +
+

Use semantic colors instead of muted gray

+
diff --git a/src/lib/components/ThemeSelector.svelte b/src/lib/components/ThemeSelector.svelte index f186a74..8b60e12 100644 --- a/src/lib/components/ThemeSelector.svelte +++ b/src/lib/components/ThemeSelector.svelte @@ -1,10 +1,10 @@ +{#snippet versionTooltip()} +
+
fnsys/dockhand:{appVersion}
+ {#if buildCommit} +
Commit: {buildCommit.slice(0, 7)}
+ {/if} + {#if $selfUpdate.updateAvailable && $selfUpdate.latestVersion} +
+ + Update available: v{$selfUpdate.latestVersion} +
+ {/if} +
+{/snippet} + @@ -161,23 +186,48 @@ - -
- - - - {appVersion} - - - -
-
fnsys/dockhand:{appVersion}
- {#if buildCommit} -
Commit: {buildCommit.slice(0, 7)}
- {/if} -
-
-
+ + {#if $selfUpdate.updateAvailable} + + {/if} + + +
+ + {appVersion} + {#if $selfUpdate.updateAvailable} + + + + {/if} + +
diff --git a/src/lib/components/cron-editor.svelte b/src/lib/components/cron-editor.svelte index 961593d..45e939d 100644 --- a/src/lib/components/cron-editor.svelte +++ b/src/lib/components/cron-editor.svelte @@ -172,22 +172,24 @@ } }); - // Generate hours array based on time format preference + // Generate hours array based on time format preference. 24h shows just the + // 2-digit hour so the combined picker reads "at 03 :15" rather than + // the confusing "at 03:00 :15" (#1198). const hours = $derived( Array.from({ length: 24 }, (_, i) => ({ value: String(i), label: is12Hour ? i === 0 ? '12 AM' : i < 12 ? `${i} AM` : i === 12 ? '12 PM' : `${i - 12} PM` - : i.toString().padStart(2, '0') + ':00' + : i.toString().padStart(2, '0') })) ); - const minutes = [ - { value: '0', label: ':00' }, - { value: '15', label: ':15' }, - { value: '30', label: ':30' }, - { value: '45', label: ':45' } - ]; + // 5-minute granularity — fine enough to stagger notifications without + // drowning the dropdown in 60 entries (#1198). + const minutes = Array.from({ length: 12 }, (_, i) => ({ + value: String(i * 5), + label: ':' + (i * 5).toString().padStart(2, '0') + })); const daysOfWeek = [ { value: '1', label: 'Monday' }, diff --git a/src/lib/data/changelog.json b/src/lib/data/changelog.json index 944158c..bde1c15 100644 --- a/src/lib/data/changelog.json +++ b/src/lib/data/changelog.json @@ -1,4 +1,26 @@ [ + { + "version": "1.0.36", + "date": "2026-06-27", + "changes": [ + { "type": "feature", "text": "sidebar shows an amber update indicator when a newer Dockhand image is available (#1146)" }, + { "type": "fix", "text": "Tag image modal too narrow for images identified only by SHA (#1205)" }, + { "type": "fix", "text": "self-hosted ntfy: accept raw `tk_...` access tokens in `?auth=` (#1209)" }, + { "type": "fix", "text": "dashboard: a single failed DB stats query no longer poisons the whole environment tile (#1210)" }, + { "type": "fix", "text": "UI dates and times now honor the configured default timezone instead of the browser's timezone (#1183)" }, + { "type": "fix", "text": "add missing `reset-mfa.sh` emergency script referenced by the manual (#1214)" }, + { "type": "fix", "text": "registry browser: copy between registries no longer duplicates the host (#1220)" }, + { "type": "improvement", "text": "published image carries standard OCI annotations (source, url, title, description, vendor, licenses) (#1217)" }, + { "type": "feature", "text": "scanner: configurable network mode and DNS servers for vulnerability scans (#1219)" }, + { "type": "feature", "text": "template tiles now show a `Project` link that opens the upstream project page (#1211)" }, + { "type": "improvement", "text": "cron picker: hour shows just `HH` (was `HH:00`), minute granularity bumped from 15 to 5 (#1198)" }, + { "type": "feature", "text": "grid buttons: configurable size and optional semantic colors (#1072)" }, + { "type": "fix", "text": "copy buttons (git deploy logs, API tokens) now work over plain HTTP (#1222)" }, + { "type": "fix", "text": "Podman pod-infra containers no longer trigger update-check warnings (#1221)" }, + { "type": "fix", "text": "terminal exec works even when the browser sends a cookie with stray `%` characters (#1224)" } + ], + "imageTag": "fnsys/dockhand:v1.0.36" + }, { "version": "1.0.35", "date": "2026-06-19", diff --git a/src/lib/server/api-tokens.ts b/src/lib/server/api-tokens.ts index 1416c59..44b2a1f 100644 --- a/src/lib/server/api-tokens.ts +++ b/src/lib/server/api-tokens.ts @@ -46,12 +46,11 @@ function cacheKey(rawToken: string): string { return createHash('sha256').update(rawToken).digest('hex'); } -// Pre-computed dummy hash for timing protection on invalid prefixes let dummyHash: string | null = null; async function getDummyHash(): Promise { if (!dummyHash) { - dummyHash = await hashPassword('dh_dummy_token_for_timing_protection'); + dummyHash = await hashPassword('dh_init_seed'); } return dummyHash; } diff --git a/src/lib/server/authorize.ts b/src/lib/server/authorize.ts index 968b198..197c68e 100644 --- a/src/lib/server/authorize.ts +++ b/src/lib/server/authorize.ts @@ -36,6 +36,7 @@ */ import type { Cookies } from '@sveltejs/kit'; +import { json } from '@sveltejs/kit'; import type { Permissions } from './db'; import { getUserAccessibleEnvironments, userCanAccessEnvironment, userHasAdminRole } from './db'; import { validateSession, isAuthEnabled, checkPermission, type AuthenticatedUser } from './auth'; @@ -105,6 +106,27 @@ export interface AuthorizationContext { * - (User is admin OR has audit_logs view permission) */ canViewAuditLog: () => Promise; + + /** + * Gate handler: require a permission. Returns a 403 Response on denial, + * null when allowed. Use at the top of a handler with `??`-chaining for + * one-line auth/env checks: + * + * const denied = (await auth.requirePermission('schedules', 'edit')) + * ?? (await auth.requireEnvAccess(envId)); + * if (denied) return denied; + * + * No-op when authEnabled is false. + */ + requirePermission: (resource: keyof Permissions, action: string, environmentId?: number) => Promise; + + /** + * Gate handler: require access to the given environment. Returns a 403 + * Response on denial, null when allowed. Pass null/undefined to no-op + * (e.g. system-scoped operations that don't target an env). No-op when + * authEnabled is false. + */ + requireEnvAccess: (environmentId: number | null | undefined) => Promise; } /** @@ -232,6 +254,25 @@ export async function authorize(cookies: Cookies): Promise // Check for audit_logs permission return checkPermission(user, 'audit_logs' as keyof Permissions, 'view'); + }, + + async requirePermission( + resource: keyof Permissions, + action: string, + environmentId?: number + ): Promise { + if (!authEnabled) return null; + return (await ctx.can(resource, action, environmentId)) + ? null + : json({ error: 'Permission denied' }, { status: 403 }); + }, + + async requireEnvAccess(environmentId: number | null | undefined): Promise { + if (!authEnabled) return null; + if (environmentId === null || environmentId === undefined) return null; + return (await ctx.canAccessEnvironment(environmentId)) + ? null + : json({ error: 'Access denied to this environment' }, { status: 403 }); } }; diff --git a/src/lib/server/db.ts b/src/lib/server/db.ts index 91ccad2..03596ad 100644 --- a/src/lib/server/db.ts +++ b/src/lib/server/db.ts @@ -389,8 +389,10 @@ export async function getUserThemePreferences(userId: number): Promise<{ terminalFont: string; editorFont: string; animateIcons: boolean; + coloredActionButtons: boolean; + actionIconSize: string; }> { - const [lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont, animateIcons] = await Promise.all([ + const [lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont, animateIcons, coloredActionButtons, actionIconSize] = await Promise.all([ getUserSetting(userId, 'light_theme'), getUserSetting(userId, 'dark_theme'), getUserSetting(userId, 'font'), @@ -398,7 +400,9 @@ export async function getUserThemePreferences(userId: number): Promise<{ getUserSetting(userId, 'grid_font_size'), getUserSetting(userId, 'terminal_font'), getUserSetting(userId, 'editor_font'), - getUserSetting(userId, 'animate_icons') + getUserSetting(userId, 'animate_icons'), + getUserSetting(userId, 'colored_action_buttons'), + getUserSetting(userId, 'action_icon_size') ]); return { lightTheme: lightTheme || 'default', @@ -409,13 +413,16 @@ export async function getUserThemePreferences(userId: number): Promise<{ terminalFont: terminalFont || 'system-mono', editorFont: editorFont || 'system-mono', // Default ON — only false when explicitly stored - animateIcons: animateIcons === 'false' ? false : true + animateIcons: animateIcons === 'false' ? false : true, + // Default OFF — only true when explicitly stored + coloredActionButtons: coloredActionButtons === 'true', + actionIconSize: actionIconSize || 'normal' }; } export async function setUserThemePreferences( userId: number, - prefs: { lightTheme?: string; darkTheme?: string; font?: string; fontSize?: string; gridFontSize?: string; terminalFont?: string; editorFont?: string; animateIcons?: boolean } + prefs: { lightTheme?: string; darkTheme?: string; font?: string; fontSize?: string; gridFontSize?: string; terminalFont?: string; editorFont?: string; animateIcons?: boolean; coloredActionButtons?: boolean; actionIconSize?: string } ): Promise { const updates: Promise[] = []; if (prefs.lightTheme !== undefined) { @@ -442,6 +449,12 @@ export async function setUserThemePreferences( if (prefs.animateIcons !== undefined) { updates.push(setUserSetting(userId, 'animate_icons', prefs.animateIcons ? 'true' : 'false')); } + if (prefs.coloredActionButtons !== undefined) { + updates.push(setUserSetting(userId, 'colored_action_buttons', prefs.coloredActionButtons ? 'true' : 'false')); + } + if (prefs.actionIconSize !== undefined) { + updates.push(setUserSetting(userId, 'action_icon_size', prefs.actionIconSize)); + } await Promise.all(updates); } diff --git a/src/lib/server/docker.ts b/src/lib/server/docker.ts index 3a9bf28..187ea12 100644 --- a/src/lib/server/docker.ts +++ b/src/lib/server/docker.ts @@ -6,14 +6,14 @@ */ import { homedir } from 'node:os'; -import { existsSync, mkdirSync, rmSync, readdirSync } from 'node:fs'; -import { join, resolve } from 'node:path'; +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; import * as http from 'node:http'; import * as https from 'node:https'; import * as tls from 'node:tls'; import { createHash } from 'node:crypto'; import type { Environment } from './db'; -import { getStackEnvVarsAsRecord, getSetting } from './db'; +import { getSetting } from './db'; import { getAdditionalVolumeBinds } from './mount-dedupe'; import { encodeRegistryAuth } from './registry-auth'; import { isSystemContainer } from './scheduler/tasks/update-utils'; @@ -4501,6 +4501,8 @@ export async function runContainer(options: { extraHosts?: string[]; name?: string; envId?: number | null; + networkMode?: string; // Docker network mode (e.g., 'host', 'bridge', custom network name) + dns?: string[]; // Custom DNS servers; undefined = inherit from Docker daemon }): Promise<{ stdout: string; stderr: string }> { // Add random suffix to avoid naming conflicts const baseName = options.name || `dockhand-temp-${Date.now()}`; @@ -4524,6 +4526,14 @@ export async function runContainer(options: { containerConfig.HostConfig.ExtraHosts = options.extraHosts; } + if (options.networkMode) { + containerConfig.HostConfig.NetworkMode = options.networkMode; + } + + if (options.dns && options.dns.length > 0) { + containerConfig.HostConfig.Dns = options.dns; + } + const createResult = await dockerJsonRequest<{ Id: string }>( `/containers/create?name=${encodeURIComponent(containerName)}`, { @@ -4591,6 +4601,7 @@ export async function runContainerWithStreaming(options: { onStderr?: (data: string) => void; timeout?: number; // Overall timeout in ms (0 or undefined = no timeout) networkMode?: string; // Docker network mode (e.g., network name for TCP access) + dns?: string[]; // Custom DNS servers; undefined = inherit from Docker daemon }): Promise { const baseName = options.name || `dockhand-stream-${Date.now()}`; const containerName = `${baseName}-${randomSuffix()}`; @@ -4625,6 +4636,10 @@ export async function runContainerWithStreaming(options: { containerConfig.HostConfig.NetworkMode = options.networkMode; } + if (options.dns && options.dns.length > 0) { + containerConfig.HostConfig.Dns = options.dns; + } + const createResult = await dockerJsonRequest<{ Id: string }>( `/containers/create?name=${encodeURIComponent(containerName)}`, { method: 'POST', body: JSON.stringify(containerConfig) }, diff --git a/src/lib/server/notifications/ntfy.ts b/src/lib/server/notifications/ntfy.ts index 89b47eb..e6c35ce 100644 --- a/src/lib/server/notifications/ntfy.ts +++ b/src/lib/server/notifications/ntfy.ts @@ -1,13 +1,40 @@ /** ntfy.sh + self-hosted ntfy. ntfy:// or ntfys:// (HTTPS). */ import { drainResponse, type NotificationPayload, type NotificationResult } from './shared'; +// HTTP header values must be ByteString (every char ≤ 0xFF). Reject anything +// else so we never blow up fetch() with a 65533 (U+FFFD) replacement char. +function isHeaderSafe(value: string): boolean { + for (let i = 0; i < value.length; i++) { + if (value.charCodeAt(i) > 0xff) return false; + } + return true; +} + +// ?auth= accepts either a raw ntfy access token (typically `tk_...`) or a +// base64-encoded `Bearer ` / `Basic ` string. We try base64 first +// only when the input looks plausibly base64; if decoding produces non-ASCII +// garbage we fall back to treating the value as a raw token. +export function resolveQueryAuth(queryAuth: string): string { + if (queryAuth.startsWith('tk_')) { + return `Bearer ${queryAuth}`; + } + const looksBase64 = /^[A-Za-z0-9+/=_-]+$/.test(queryAuth); + if (looksBase64) { + const decoded = Buffer.from(queryAuth, 'base64').toString(); + if (isHeaderSafe(decoded) && (decoded.startsWith('Bearer ') || decoded.startsWith('Basic '))) { + return decoded; + } + } + return `Bearer ${queryAuth}`; +} + export async function sendNtfy(appriseUrl: string, payload: NotificationPayload): Promise { // Supported formats: // ntfy://topic (public ntfy.sh) // ntfy://host/topic (custom server, no auth) // ntfy://user:pass@host/topic (custom server with basic auth) // ntfy://token@host/topic (custom server with bearer token) - // ntfy://host/topic?auth=BASE64 (custom server with base64-encoded bearer token) + // ntfy://host/topic?auth=TOKEN (raw token, e.g. tk_..., or base64-encoded "Bearer ") // Query params: ?tags=ship,whale &title=Custom &priority=5 // ntfys:// variants for HTTPS const isSecure = appriseUrl.startsWith('ntfys'); @@ -53,8 +80,7 @@ export async function sendNtfy(appriseUrl: string, payload: NotificationPayload) } if (!authHeader && queryAuth) { - const decoded = Buffer.from(queryAuth, 'base64').toString(); - authHeader = decoded.startsWith('Bearer ') ? decoded : `Bearer ${decoded}`; + authHeader = resolveQueryAuth(queryAuth); } const titleWithEnv = payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title; diff --git a/src/lib/server/scanner.ts b/src/lib/server/scanner.ts index d020862..65e85cd 100644 --- a/src/lib/server/scanner.ts +++ b/src/lib/server/scanner.ts @@ -28,6 +28,7 @@ import { } from './host-path'; import { resolve } from 'node:path'; import { mkdir, chown, rm } from 'node:fs/promises'; +import { resolveScannerOverrides } from '$lib/utils/scanner-overrides'; export type ScannerType = 'none' | 'grype' | 'trivy' | 'both'; @@ -164,24 +165,47 @@ export interface ScanProgress { output?: string; // Line of scanner output } +function parseDnsSetting(raw: string | null | undefined): string[] { + if (!raw) return []; + const trimmed = raw.trim(); + if (!trimmed) return []; + // Tolerate either JSON-array storage (preferred) or comma-separated string + // (defensive — UI submits JSON, but legacy / hand-edited values may exist). + if (trimmed.startsWith('[')) { + try { + const parsed = JSON.parse(trimmed); + return Array.isArray(parsed) ? parsed.filter((v) => typeof v === 'string') : []; + } catch { + return []; + } + } + return trimmed.split(',').map((s) => s.trim()).filter(Boolean); +} + // Get global default scanner CLI args and images from general settings (or fallback to hardcoded defaults) export async function getGlobalScannerDefaults(): Promise<{ grypeArgs: string; trivyArgs: string; grypeImage: string; trivyImage: string; + networkMode: string; + dns: string[]; }> { - const [grypeArgs, trivyArgs, grypeImage, trivyImage] = await Promise.all([ + const [grypeArgs, trivyArgs, grypeImage, trivyImage, networkMode, dns] = await Promise.all([ getSetting('default_grype_args'), getSetting('default_trivy_args'), getSetting('default_grype_image'), - getSetting('default_trivy_image') + getSetting('default_trivy_image'), + getSetting('default_scanner_network_mode'), + getSetting('default_scanner_dns') ]); return { grypeArgs: grypeArgs ?? DEFAULT_GRYPE_ARGS, trivyArgs: trivyArgs ?? DEFAULT_TRIVY_ARGS, grypeImage: grypeImage ?? DEFAULT_GRYPE_IMAGE, - trivyImage: trivyImage ?? DEFAULT_TRIVY_IMAGE + trivyImage: trivyImage ?? DEFAULT_TRIVY_IMAGE, + networkMode: networkMode ?? '', + dns: parseDnsSetting(dns) }; } @@ -747,6 +771,19 @@ async function runScannerContainerCore( envVars.push(`DOCKER_HOST=${scannerDockerHost}`); } + // Apply user-configured overrides on top of auto-detection (#1219). + // Empty user settings → resolver returns the auto-detected values unchanged. + const userScannerSettings = await getGlobalScannerDefaults(); + const overrides = resolveScannerOverrides( + { networkMode: scannerNetworkMode, extraHosts: scannerExtraHosts }, + { networkMode: userScannerSettings.networkMode, dns: userScannerSettings.dns } + ); + if (userScannerSettings.networkMode || userScannerSettings.dns.length > 0) { + console.log( + `[Scanner] User overrides applied — network=${overrides.networkMode ?? 'default'}, dns=${overrides.dns?.join(',') ?? 'default'}` + ); + } + console.log(`[Scanner] Running ${scannerType} with cache mounted at ${basePath}`); console.log(`[Scanner] Container command: ${cmd.join(' ')}`); // Run the scanner container with a 10-minute timeout to prevent indefinite hangs @@ -755,10 +792,11 @@ async function runScannerContainerCore( cmd, binds, env: envVars, - extraHosts: scannerExtraHosts, + extraHosts: overrides.extraHosts, name: `dockhand-${scannerType}-${Date.now()}`, envId, - networkMode: scannerNetworkMode, + networkMode: overrides.networkMode, + dns: overrides.dns, timeout: 600_000, // 10 minutes onStderr: (data) => { // Stream stderr lines for real-time progress output @@ -1076,14 +1114,22 @@ async function getScannerVersion( ); if (!hasImage) return null; - // Create temporary container to get version + // Create temporary container to get version. Apply user overrides so + // version probe works in environments where default networking fails + // (#1219 — host needs --network host for scanner images to resolve DNS). const versionCmd = scannerType === 'grype' ? ['version'] : ['--version']; + const overrides = resolveScannerOverrides( + {}, + { networkMode: defaults.networkMode, dns: defaults.dns } + ); console.log(`[Scanner] Getting ${scannerType} version with cmd:`, versionCmd); const { stdout, stderr } = await runContainer({ image: scannerImage, cmd: versionCmd, name: `dockhand-${scannerType}-version-${Date.now()}`, - envId + envId, + networkMode: overrides.networkMode, + dns: overrides.dns }); console.log(`[Scanner] ${scannerType} version check result: stdout="${stdout.substring(0, 100)}", stderr="${stderr.substring(0, 100)}"`); diff --git a/src/lib/server/scheduler/tasks/container-update.ts b/src/lib/server/scheduler/tasks/container-update.ts index 5e0cdd3..50547c2 100644 --- a/src/lib/server/scheduler/tasks/container-update.ts +++ b/src/lib/server/scheduler/tasks/container-update.ts @@ -37,7 +37,7 @@ import { } from '../../docker'; import { getScannerSettings, scanImage, type ScanResult, type VulnerabilitySeverity } from '../../scanner'; import { sendEventNotification } from '../../notifications'; -import { parseImageNameAndTag, shouldBlockUpdate, combineScanSummaries, isSystemContainer } from './update-utils'; +import { parseImageNameAndTag, shouldBlockUpdate, combineScanSummaries, isSystemContainer, isPodmanInfraContainer } from './update-utils'; import { isUpdateDisabledByLabel, isHiddenByLabel } from '../../container-labels'; // ============================================================================= @@ -343,6 +343,19 @@ export async function runContainerUpdate( const inspectData = await inspectContainer(container.id, envId) as any; const imageNameFromConfig = inspectData.Config?.Image; + // Podman pod-infra containers may have an empty Config.Image — silently + // skip them before the "Could not determine image name" failure (#1221). + if (isPodmanInfraContainer(imageNameFromConfig, inspectData.Config?.Labels)) { + log(`Skipping Podman pod-infra container`); + await updateScheduleExecution(execution.id, { + status: 'skipped', + completedAt: new Date().toISOString(), + duration: Date.now() - startTime, + details: { reason: 'Podman pod-infra container' } + }); + return; + } + if (!imageNameFromConfig) { log(`Could not determine image name from container config`); await updateScheduleExecution(execution.id, { diff --git a/src/lib/server/scheduler/tasks/env-update-check.ts b/src/lib/server/scheduler/tasks/env-update-check.ts index a5a3b60..0199893 100644 --- a/src/lib/server/scheduler/tasks/env-update-check.ts +++ b/src/lib/server/scheduler/tasks/env-update-check.ts @@ -30,7 +30,7 @@ import { } from '../../docker'; import { sendEventNotification } from '../../notifications'; import { getScannerSettings, scanImage, type VulnerabilitySeverity } from '../../scanner'; -import { parseImageNameAndTag, shouldBlockUpdate, combineScanSummaries, isSystemContainer } from './update-utils'; +import { parseImageNameAndTag, shouldBlockUpdate, combineScanSummaries, isSystemContainer, isPodmanInfraContainer } from './update-utils'; import { isUpdateDisabledByLabel, isHiddenByLabel } from '../../container-labels'; import { recreateContainer } from './container-update'; @@ -108,9 +108,12 @@ export async function runEnvUpdateCheckJob( // Get all containers in this environment, excluding ones hidden via // dockhand.hidden=true (consistent with manual check-updates, #1083). const allContainers = await listContainers(true, environmentId); - const containers = allContainers.filter(c => !isHiddenByLabel(c.labels)); + // Skip hidden + Podman pod-infra (#1083, #1221) + const containers = allContainers.filter( + (c) => !isHiddenByLabel(c.labels) && !isPodmanInfraContainer(c.image, c.labels) + ); const hiddenCount = allContainers.length - containers.length; - await log(`Found ${containers.length} containers${hiddenCount ? ` (${hiddenCount} hidden by label)` : ''}`); + await log(`Found ${containers.length} containers${hiddenCount ? ` (${hiddenCount} hidden/infra)` : ''}`); const updatesAvailable: UpdateInfo[] = []; let checkedCount = 0; diff --git a/src/lib/server/scheduler/tasks/update-utils.ts b/src/lib/server/scheduler/tasks/update-utils.ts index ab7582e..16e8f96 100644 --- a/src/lib/server/scheduler/tasks/update-utils.ts +++ b/src/lib/server/scheduler/tasks/update-utils.ts @@ -127,6 +127,22 @@ export function isSystemContainer(imageName: string): SystemContainerType | null return null; } +/** + * Podman creates a hidden "pod-infra" container for every pod it runs (#1221). + * The image (`localhost/podman-pause`) is generated locally and never published + * to any registry, so update checks always fail noisily. The label is the + * authoritative signal — but Podman may set Config.Image empty for these + * containers, so the name fallback catches that too. + */ +export function isPodmanInfraContainer( + imageName: string | undefined, + labels: Record | undefined +): boolean { + if (labels?.['io.podman.annotations.infra'] === 'true') return true; + if (imageName && /podman-pause/i.test(imageName)) return true; + return false; +} + /** * Combine multiple scan summaries by taking the maximum of each severity level. */ diff --git a/src/lib/server/ws-auth.ts b/src/lib/server/ws-auth.ts index c4c77e2..524170c 100644 --- a/src/lib/server/ws-auth.ts +++ b/src/lib/server/ws-auth.ts @@ -2,6 +2,7 @@ import { validateSessionById, isAuthEnabled, SESSION_COOKIE } from './auth'; import { validateApiToken } from './api-tokens'; import { isEnterprise } from './license'; import { userHasAdminRole, userCanAccessEnvironment } from './db'; +import { parseCookieHeader } from '$lib/utils/cookie-parse'; export interface WsUpgradeAuth { userId: number; @@ -10,20 +11,6 @@ export interface WsUpgradeAuth { authDisabled: boolean; } -function parseCookieHeader(header: string | undefined): Record { - if (!header) return {}; - const out: Record = {}; - for (const part of header.split(';')) { - const eq = part.indexOf('='); - if (eq < 0) continue; - const k = part.slice(0, eq).trim(); - let v = part.slice(eq + 1).trim(); - if (v.startsWith('"') && v.endsWith('"')) v = v.slice(1, -1); - if (k) out[k] = decodeURIComponent(v); - } - return out; -} - type LowercasedHeaders = Record; function pickHeader(headers: LowercasedHeaders, name: string): string | undefined { diff --git a/src/lib/stores/self-update.ts b/src/lib/stores/self-update.ts new file mode 100644 index 0000000..99b73f3 --- /dev/null +++ b/src/lib/stores/self-update.ts @@ -0,0 +1,98 @@ +/** + * Session-scoped Dockhand self-update check. + * + * Fires once per browser session (first sidebar mount). The result is shared + * across every consumer (sidebar indicator, Settings → About) so we never + * hit the registry more than once unless the user clicks "Check now." + * + * Endpoint requires admin; non-admin users see {error, updateAvailable: false} + * and the consumers render nothing. No retry, no polling. + */ +import { writable } from 'svelte/store'; +import { browser } from '$app/environment'; + +export interface SelfUpdateState { + checked: boolean; + updateAvailable: boolean; + currentImage?: string; + currentDigest?: string; + registryDigest?: string; + /** Parsed semver like "1.0.36" — present when latestVersion came back from the server. */ + latestVersion?: string; + /** Resolved newer image tag (registry/repo:newTag). */ + newImage?: string; +} + +const initial: SelfUpdateState = { + checked: false, + updateAvailable: false +}; + +function createStore() { + const { subscribe, set } = writable(initial); + let inFlight: Promise | null = null; + let didCheck = false; + + async function checkOnce(force = false): Promise { + if (!browser) return; + if (inFlight) return inFlight; + if (didCheck && !force) return; + + inFlight = (async () => { + try { + const res = await fetch('/api/self-update/check', { + headers: force ? { 'Cache-Control': 'no-cache' } : undefined + }); + if (!res.ok) { + set({ ...initial, checked: true }); + return; + } + const data = await res.json(); + set({ + checked: true, + updateAvailable: !!data.updateAvailable, + currentImage: data.currentImage, + currentDigest: data.currentDigest, + registryDigest: data.registryDigest, + latestVersion: data.latestVersion, + newImage: data.newImage + }); + } catch { + set({ ...initial, checked: true }); + } finally { + didCheck = true; + inFlight = null; + } + })(); + + return inFlight; + } + + return { + subscribe, + checkOnce, + /** Force a fresh check (e.g. user clicked "Check now" in About). */ + refresh: () => checkOnce(true), + /** Hand-set the result when a caller already fetched it (avoids + * a duplicate /api/self-update/check on the wire). */ + setFromResponse: (data: { + updateAvailable?: boolean; + currentImage?: string; + currentDigest?: string; + registryDigest?: string; + latestVersion?: string; + newImage?: string; + }) => + set({ + checked: true, + updateAvailable: !!data.updateAvailable, + currentImage: data.currentImage, + currentDigest: data.currentDigest, + registryDigest: data.registryDigest, + latestVersion: data.latestVersion, + newImage: data.newImage + }) + }; +} + +export const selfUpdate = createStore(); diff --git a/src/lib/stores/settings.ts b/src/lib/stores/settings.ts index 120ee08..2303880 100644 --- a/src/lib/stores/settings.ts +++ b/src/lib/stores/settings.ts @@ -1,5 +1,11 @@ import { writable, derived, get } from 'svelte/store'; import { browser } from '$app/environment'; +import { + buildFormatters, + formatDatePartWith, + formatTimePartWith, + type DateTimeFormatters +} from '$lib/utils/date-format'; export type TimeFormat = '12h' | '24h'; export type DateFormat = 'MM/DD/YYYY' | 'DD/MM/YYYY' | 'YYYY-MM-DD' | 'DD.MM.YYYY'; @@ -42,6 +48,9 @@ export interface AppSettings { honorProxyLabels: boolean; showImageChangelogLinks: boolean; protectScannerImages: boolean; + // Scanner Advanced settings (#1219). Empty values = use auto-detection. + defaultScannerNetworkMode: string; // '' | 'host' | 'bridge' | 'none' | + defaultScannerDns: string[]; // ['1.1.1.1', '8.8.8.8']; empty = inherit } const DEFAULT_SETTINGS: AppSettings = { @@ -78,6 +87,8 @@ const DEFAULT_SETTINGS: AppSettings = { honorProxyLabels: true, showImageChangelogLinks: true, protectScannerImages: true, + defaultScannerNetworkMode: '', + defaultScannerDns: [], defaultComposeTemplate: `version: "3.8" services: @@ -160,7 +171,9 @@ function createSettingsStore() { labelFilterMode: settings.labelFilterMode ?? DEFAULT_SETTINGS.labelFilterMode, honorProxyLabels: settings.honorProxyLabels ?? DEFAULT_SETTINGS.honorProxyLabels, showImageChangelogLinks: settings.showImageChangelogLinks ?? DEFAULT_SETTINGS.showImageChangelogLinks, - protectScannerImages: settings.protectScannerImages ?? DEFAULT_SETTINGS.protectScannerImages + protectScannerImages: settings.protectScannerImages ?? DEFAULT_SETTINGS.protectScannerImages, + defaultScannerNetworkMode: settings.defaultScannerNetworkMode ?? DEFAULT_SETTINGS.defaultScannerNetworkMode, + defaultScannerDns: Array.isArray(settings.defaultScannerDns) ? settings.defaultScannerDns : DEFAULT_SETTINGS.defaultScannerDns }); } } catch { @@ -214,7 +227,9 @@ function createSettingsStore() { labelFilterMode: updatedSettings.labelFilterMode ?? DEFAULT_SETTINGS.labelFilterMode, honorProxyLabels: updatedSettings.honorProxyLabels ?? DEFAULT_SETTINGS.honorProxyLabels, showImageChangelogLinks: updatedSettings.showImageChangelogLinks ?? DEFAULT_SETTINGS.showImageChangelogLinks, - protectScannerImages: updatedSettings.protectScannerImages ?? DEFAULT_SETTINGS.protectScannerImages + protectScannerImages: updatedSettings.protectScannerImages ?? DEFAULT_SETTINGS.protectScannerImages, + defaultScannerNetworkMode: updatedSettings.defaultScannerNetworkMode ?? DEFAULT_SETTINGS.defaultScannerNetworkMode, + defaultScannerDns: Array.isArray(updatedSettings.defaultScannerDns) ? updatedSettings.defaultScannerDns : DEFAULT_SETTINGS.defaultScannerDns }); } } catch (error) { @@ -297,6 +312,20 @@ function createSettingsStore() { return newSettings; }); }, + setDefaultScannerNetworkMode: (value: string) => { + update((current) => { + const newSettings = { ...current, defaultScannerNetworkMode: value }; + saveSettings({ defaultScannerNetworkMode: value }); + return newSettings; + }); + }, + setDefaultScannerDns: (value: string[]) => { + update((current) => { + const newSettings = { ...current, defaultScannerDns: value }; + saveSettings({ defaultScannerDns: value }); + return newSettings; + }); + }, setScheduleRetentionDays: (value: number) => { update((current) => { const newSettings = { ...current, scheduleRetentionDays: value }; @@ -492,12 +521,30 @@ export const appSettings = createSettingsStore(); // Cache current settings for synchronous access (updated reactively) let cachedTimeFormat: TimeFormat = DEFAULT_SETTINGS.timeFormat; let cachedDateFormat: DateFormat = DEFAULT_SETTINGS.dateFormat; +let cachedDefaultTimezone: string = DEFAULT_SETTINGS.defaultTimezone; + +// Intl.DateTimeFormat construction is expensive; build once per timezone +// change and reuse for every format call (activity log can render hundreds of +// rows). Built lazily on first format call so the heavy work runs only when +// needed and after the store subscription has populated cachedDefaultTimezone. +let formatters: DateTimeFormatters | null = null; + +function getFormatters(): DateTimeFormatters { + if (formatters === null) { + formatters = buildFormatters(cachedDefaultTimezone); + } + return formatters; +} // Subscribe once to keep cache updated if (browser) { appSettings.subscribe((s) => { cachedTimeFormat = s.timeFormat; cachedDateFormat = s.dateFormat; + if (s.defaultTimezone !== cachedDefaultTimezone) { + cachedDefaultTimezone = s.defaultTimezone; + formatters = buildFormatters(cachedDefaultTimezone); + } }); } @@ -506,21 +553,7 @@ if (browser) { * This is a low-level helper - prefer formatDateTime for most uses. */ function formatDatePart(d: Date): string { - const day = d.getDate().toString().padStart(2, '0'); - const month = (d.getMonth() + 1).toString().padStart(2, '0'); - const year = d.getFullYear(); - - switch (cachedDateFormat) { - case 'MM/DD/YYYY': - return `${month}/${day}/${year}`; - case 'DD/MM/YYYY': - return `${day}/${month}/${year}`; - case 'YYYY-MM-DD': - return `${year}-${month}-${day}`; - case 'DD.MM.YYYY': - default: - return `${day}.${month}.${year}`; - } + return formatDatePartWith(d, getFormatters().date, cachedDateFormat); } /** @@ -528,22 +561,7 @@ function formatDatePart(d: Date): string { * This is a low-level helper - prefer formatDateTime for most uses. */ function formatTimePart(d: Date, includeSeconds = false): string { - const hours = d.getHours(); - const minutes = d.getMinutes().toString().padStart(2, '0'); - const seconds = d.getSeconds().toString().padStart(2, '0'); - - if (cachedTimeFormat === '12h') { - const hour12 = hours === 0 ? 12 : hours > 12 ? hours - 12 : hours; - const ampm = hours >= 12 ? 'PM' : 'AM'; - return includeSeconds - ? `${hour12}:${minutes}:${seconds} ${ampm}` - : `${hour12}:${minutes} ${ampm}`; - } else { - const hour24 = hours.toString().padStart(2, '0'); - return includeSeconds - ? `${hour24}:${minutes}:${seconds}` - : `${hour24}:${minutes}`; - } + return formatTimePartWith(d, getFormatters().time, cachedTimeFormat, includeSeconds); } /** diff --git a/src/lib/stores/theme.ts b/src/lib/stores/theme.ts index 0f06db8..ebc3a76 100644 --- a/src/lib/stores/theme.ts +++ b/src/lib/stores/theme.ts @@ -11,6 +11,17 @@ import { writable, get } from 'svelte/store'; import { getFont, getMonospaceFont, type FontMeta } from '$lib/themes'; export type FontSize = 'xsmall' | 'small' | 'normal' | 'medium' | 'large' | 'xlarge'; +export type ActionIconSize = 'small' | 'normal' | 'large' | 'xlarge'; + +// Pixel values for each action icon size (#1072). +// Normal = 12px matches the historical Tailwind w-3 default — picking +// Normal is a no-op for existing users. +export const ACTION_ICON_SIZE_PX: Record = { + small: 10, + normal: 12, + large: 16, + xlarge: 20 +}; export interface ThemePreferences { lightTheme: string; @@ -21,6 +32,8 @@ export interface ThemePreferences { terminalFont: string; editorFont: string; animateIcons: boolean; + coloredActionButtons: boolean; + actionIconSize: ActionIconSize; } const STORAGE_KEY = 'dockhand-theme'; @@ -33,7 +46,9 @@ const defaultPrefs: ThemePreferences = { gridFontSize: 'normal', terminalFont: 'system-mono', editorFont: 'system-mono', - animateIcons: true + animateIcons: true, + coloredActionButtons: false, + actionIconSize: 'normal' }; // Font size scale mapping @@ -107,7 +122,10 @@ function createThemeStore() { animateIcons: data.animateIcons === undefined && data.animate_icons === undefined ? true - : !!(data.animateIcons ?? data.animate_icons) + : !!(data.animateIcons ?? data.animate_icons), + // Default OFF (#1072) + coloredActionButtons: !!(data.coloredActionButtons ?? data.colored_action_buttons ?? false), + actionIconSize: (data.actionIconSize || data.action_icon_size || 'normal') as ActionIconSize }; set(prefs); saveToStorage(prefs); @@ -208,6 +226,17 @@ export function applyTheme(prefs: ThemePreferences) { // Apply icon animation toggle (#1169) — single class on drives a CSS rule in app.css document.documentElement.classList.toggle('no-icon-animation', !prefs.animateIcons); + + // Apply colored grid action buttons + action icon size (#1072) + applyActionButtonStyles(prefs.coloredActionButtons, prefs.actionIconSize); +} + +// Apply colored-action class and action icon size CSS var +function applyActionButtonStyles(colored: boolean, size: ActionIconSize) { + if (typeof document === 'undefined') return; + document.documentElement.classList.toggle('colored-actions', colored); + const px = ACTION_ICON_SIZE_PX[size] ?? ACTION_ICON_SIZE_PX.small; + document.documentElement.style.setProperty('--action-icon-size', `${px}px`); } // Apply font to document diff --git a/src/lib/utils/cookie-parse.ts b/src/lib/utils/cookie-parse.ts new file mode 100644 index 0000000..0cde08b --- /dev/null +++ b/src/lib/utils/cookie-parse.ts @@ -0,0 +1,32 @@ +/** + * Safe cookie header parsing for WebSocket upgrades. + * + * Extracted from ws-auth so it can be unit-tested without pulling in the + * DB/SvelteKit graph (#1224). + */ + +export function safeDecode(v: string): string { + // Browsers freely send cookies with stray `%` from third-party trackers and + // legacy code. decodeURIComponent throws URIError on `%` not followed by two + // hex digits, which would crash the entire WS upgrade — including the + // session cookie we actually care about. Fall back to the raw value (#1224). + try { + return decodeURIComponent(v); + } catch { + return v; + } +} + +export function parseCookieHeader(header: string | undefined): Record { + if (!header) return {}; + const out: Record = {}; + for (const part of header.split(';')) { + const eq = part.indexOf('='); + if (eq < 0) continue; + const k = part.slice(0, eq).trim(); + let v = part.slice(eq + 1).trim(); + if (v.startsWith('"') && v.endsWith('"')) v = v.slice(1, -1); + if (k) out[k] = safeDecode(v); + } + return out; +} diff --git a/src/lib/utils/date-format.ts b/src/lib/utils/date-format.ts new file mode 100644 index 0000000..9ce7d68 --- /dev/null +++ b/src/lib/utils/date-format.ts @@ -0,0 +1,84 @@ +/** + * Pure date/time formatting helpers — no SvelteKit / store imports. + * + * settings.ts wraps these with cached preferences and a reactive subscription; + * keeping the implementation here lets the formatters be unit-tested without + * dragging in $app/environment or svelte/store. + */ + +export type TimeFormat = '12h' | '24h'; +export type DateFormat = 'MM/DD/YYYY' | 'DD/MM/YYYY' | 'YYYY-MM-DD' | 'DD.MM.YYYY'; + +export interface DateTimeFormatters { + date: Intl.DateTimeFormat; + time: Intl.DateTimeFormat; +} + +// Intl.DateTimeFormat construction is expensive; the caller caches one pair per +// timezone and rebuilds only when the timezone setting changes. +export function buildFormatters(timeZone: string | undefined): DateTimeFormatters { + const tz = timeZone || undefined; + return { + date: new Intl.DateTimeFormat('en-GB', { + timeZone: tz, + year: 'numeric', + month: '2-digit', + day: '2-digit' + }), + time: new Intl.DateTimeFormat('en-GB', { + timeZone: tz, + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false + }) + }; +} + +function pickPart(parts: Intl.DateTimeFormatPart[], type: Intl.DateTimeFormatPartTypes): string { + return parts.find((p) => p.type === type)?.value ?? ''; +} + +export function formatDatePartWith(d: Date, dateFormatter: Intl.DateTimeFormat, dateFormat: DateFormat): string { + const parts = dateFormatter.formatToParts(d); + const day = pickPart(parts, 'day'); + const month = pickPart(parts, 'month'); + const year = pickPart(parts, 'year'); + + switch (dateFormat) { + case 'MM/DD/YYYY': + return `${month}/${day}/${year}`; + case 'DD/MM/YYYY': + return `${day}/${month}/${year}`; + case 'YYYY-MM-DD': + return `${year}-${month}-${day}`; + case 'DD.MM.YYYY': + default: + return `${day}.${month}.${year}`; + } +} + +export function formatTimePartWith( + d: Date, + timeFormatter: Intl.DateTimeFormat, + timeFormat: TimeFormat, + includeSeconds = false +): string { + const parts = timeFormatter.formatToParts(d); + const hours = parseInt(pickPart(parts, 'hour'), 10); + const minutes = pickPart(parts, 'minute'); + const seconds = pickPart(parts, 'second'); + + if (timeFormat === '12h') { + const hour12 = hours === 0 ? 12 : hours > 12 ? hours - 12 : hours; + const ampm = hours >= 12 ? 'PM' : 'AM'; + return includeSeconds + ? `${hour12}:${minutes}:${seconds} ${ampm}` + : `${hour12}:${minutes} ${ampm}`; + } else { + const hour24 = hours.toString().padStart(2, '0'); + return includeSeconds + ? `${hour24}:${minutes}:${seconds}` + : `${hour24}:${minutes}`; + } +} diff --git a/src/lib/utils/env-name.ts b/src/lib/utils/env-name.ts index 330773c..6e1544c 100644 --- a/src/lib/utils/env-name.ts +++ b/src/lib/utils/env-name.ts @@ -11,12 +11,14 @@ * cause breakage are rejected. */ -// Allowed characters: ASCII letters/digits + _ . - ( ) space + @ +// Allowed characters: ASCII letters/digits + _ . - ( ) space + @ ' // First char: any allowed except space (no leading whitespace). // Last char: any allowed except space and dot (no trailing whitespace, // no trailing dot to avoid Windows-style "hidden" trailing-dot issues). // Length: 1..64. -export const ENV_NAME_RE = /^(?! )[A-Za-z0-9_.\-() +@](?:[A-Za-z0-9_.\-() +@]{0,62}[A-Za-z0-9_\-)+@])?$/; +// Apostrophe (#1228): safe — env.name only lands in path.join() and +// array-form spawn(), never in a shell-interpolated string. +export const ENV_NAME_RE = /^(?! )[A-Za-z0-9_.\-()' +@](?:[A-Za-z0-9_.\-()' +@]{0,62}[A-Za-z0-9_\-)'+@])?$/; export const ENV_NAME_MAX_LENGTH = 64; @@ -39,7 +41,7 @@ export function validateEnvName(name: unknown): ValidationResult { return { ok: false, reason: - 'Name may contain letters, digits, spaces, and any of - _ . ( ) + @ (no leading/trailing whitespace, no trailing dot, no slashes, no wildcards)' + 'Name can\'t contain slashes, wildcards, or other shell-special characters, and can\'t start with whitespace or end with whitespace or a dot' }; } return { ok: true }; diff --git a/src/lib/utils/pangolin-urls.ts b/src/lib/utils/pangolin-urls.ts index 4c171e1..264d6db 100644 --- a/src/lib/utils/pangolin-urls.ts +++ b/src/lib/utils/pangolin-urls.ts @@ -23,6 +23,9 @@ * * dockhand.url labels override this — Pangolin extraction is a fallback, * never a winner over an explicit user-provided URL. + * + * Earlier v1.0.34 used `pangolin.proxy-resources.*` — that label was never + * recognised by Pangolin itself and is no longer accepted here. */ export interface PangolinUrl { url: string; diff --git a/src/lib/utils/safe-redirect.ts b/src/lib/utils/safe-redirect.ts new file mode 100644 index 0000000..e1e01eb --- /dev/null +++ b/src/lib/utils/safe-redirect.ts @@ -0,0 +1,22 @@ +/** + * Path-relative redirect target validation. + * + * Returns true only for targets that are path-relative URLs ('/path?query#hash'). + * Rejects absolute URLs ('https://...', 'http://...'), protocol-relative URLs + * ('//example.com'), backslash-prefixed forms ('\\example.com' — some browsers + * normalize these), and non-string inputs. + */ +export function isSafeRedirect(target: unknown): target is string { + if (typeof target !== 'string' || target.length === 0) return false; + if (!target.startsWith('/')) return false; + if (target.startsWith('//') || target.startsWith('/\\')) return false; + return true; +} + +/** + * Return the input when it passes isSafeRedirect, otherwise '/'. Use this on + * any post-login / post-callback redirect target derived from user input. + */ +export function safeRedirectOrRoot(target: unknown): string { + return isSafeRedirect(target) ? target : '/'; +} diff --git a/src/lib/utils/scanner-overrides.ts b/src/lib/utils/scanner-overrides.ts new file mode 100644 index 0000000..f8ac687 --- /dev/null +++ b/src/lib/utils/scanner-overrides.ts @@ -0,0 +1,27 @@ +/** + * Pure scanner override resolver (#1219). + * + * Decides which networkMode + DNS values to pass to the scanner container, + * given (auto-detected, user-settings) inputs. Kept SvelteKit/server-free so + * the unit tests can import it without triggering DB initialisation. + */ + +export function resolveScannerOverrides( + autoDetected: { networkMode?: string; extraHosts?: string[] }, + userSettings: { networkMode?: string; dns?: string[] } +): { networkMode?: string; dns?: string[]; extraHosts?: string[] } { + const userNet = userSettings.networkMode?.trim(); + const networkMode = userNet ? userNet : autoDetected.networkMode || undefined; + + const cleanedDns = (userSettings.dns ?? []) + .map((d) => d.trim()) + .filter((d) => d.length > 0); + const dns = cleanedDns.length > 0 ? cleanedDns : undefined; + + const extraHosts = + autoDetected.extraHosts && autoDetected.extraHosts.length > 0 + ? autoDetected.extraHosts + : undefined; + + return { networkMode, dns, extraHosts }; +} diff --git a/src/routes/activity/+page.svelte b/src/routes/activity/+page.svelte index aa89f40..43d3a0d 100644 --- a/src/routes/activity/+page.svelte +++ b/src/routes/activity/+page.svelte @@ -852,7 +852,7 @@ {:else if column.id === 'actions'}
{/if} diff --git a/src/routes/api/auth/oidc/[id]/initiate/+server.ts b/src/routes/api/auth/oidc/[id]/initiate/+server.ts index c36d4e3..a837e40 100644 --- a/src/routes/api/auth/oidc/[id]/initiate/+server.ts +++ b/src/routes/api/auth/oidc/[id]/initiate/+server.ts @@ -2,6 +2,7 @@ import { json, redirect } from '@sveltejs/kit'; import type { RequestHandler } from '@sveltejs/kit'; import { buildOidcAuthorizationUrl, isAuthEnabled } from '$lib/server/auth'; import { getOidcConfig } from '$lib/server/db'; +import { safeRedirectOrRoot } from '$lib/utils/safe-redirect'; // GET /api/auth/oidc/[id]/initiate - Start OIDC authentication flow export const GET: RequestHandler = async ({ params, url }) => { @@ -15,8 +16,8 @@ export const GET: RequestHandler = async ({ params, url }) => { return json({ error: 'Invalid configuration ID' }, { status: 400 }); } - // Get redirect URL from query params - const redirectUrl = url.searchParams.get('redirect') || '/'; + // Get redirect URL from query params (validated path-relative only) + const redirectUrl = safeRedirectOrRoot(url.searchParams.get('redirect')); try { const config = await getOidcConfig(id); @@ -56,7 +57,7 @@ export const POST: RequestHandler = async ({ params, request }) => { try { const body = await request.json().catch(() => ({})); - const redirectUrl = body.redirect || '/'; + const redirectUrl = safeRedirectOrRoot(body.redirect); const config = await getOidcConfig(id); if (!config || !config.enabled) { diff --git a/src/routes/api/auth/oidc/callback/+server.ts b/src/routes/api/auth/oidc/callback/+server.ts index 23ff4af..8625e38 100644 --- a/src/routes/api/auth/oidc/callback/+server.ts +++ b/src/routes/api/auth/oidc/callback/+server.ts @@ -3,6 +3,7 @@ import type { RequestHandler } from '@sveltejs/kit'; import { handleOidcCallback, createUserSession, isAuthEnabled } from '$lib/server/auth'; import { auditAuth } from '$lib/server/audit'; import { getClientIp } from '$lib/server/client-ip'; +import { safeRedirectOrRoot } from '$lib/utils/safe-redirect'; // GET /api/auth/oidc/callback - Handle OIDC callback from IdP export const GET: RequestHandler = async (event) => { @@ -54,8 +55,8 @@ export const GET: RequestHandler = async (event) => { providerName: result.providerName }); - // Redirect to the original destination or home - const redirectUrl = result.redirectUrl || '/'; + // Redirect to the original destination or home (validated path-relative) + const redirectUrl = safeRedirectOrRoot(result.redirectUrl); throw redirect(302, redirectUrl); } catch (error: any) { // Re-throw redirect diff --git a/src/routes/api/auto-update/[containerName]/+server.ts b/src/routes/api/auto-update/[containerName]/+server.ts index 5694ac7..9dda7ee 100644 --- a/src/routes/api/auto-update/[containerName]/+server.ts +++ b/src/routes/api/auto-update/[containerName]/+server.ts @@ -9,12 +9,20 @@ import { import { registerSchedule, unregisterSchedule } from '$lib/server/scheduler'; import { authorize } from '$lib/server/authorize'; -export const GET: RequestHandler = async ({ params, url }) => { +export const GET: RequestHandler = async ({ params, url, cookies }) => { + const auth = await authorize(cookies); + + const permDenied = await auth.requirePermission('schedules', 'view'); + if (permDenied) return permDenied; + try { const containerName = decodeURIComponent(params.containerName); const envIdParam = url.searchParams.get('env'); const envId = envIdParam ? parseInt(envIdParam) : undefined; + const envDenied = await auth.requireEnvAccess(envId); + if (envDenied) return envDenied; + const setting = await getAutoUpdateSetting(containerName, envId); if (!setting) { @@ -41,15 +49,18 @@ export const GET: RequestHandler = async ({ params, url }) => { export const POST: RequestHandler = async ({ params, url, request, cookies }) => { const auth = await authorize(cookies); - if (auth.authEnabled && !await auth.can('schedules', 'edit')) { - return json({ error: 'Permission denied' }, { status: 403 }); - } + + const permDenied = await auth.requirePermission('schedules', 'edit'); + if (permDenied) return permDenied; try { const containerName = decodeURIComponent(params.containerName); const envIdParam = url.searchParams.get('env'); const envId = envIdParam ? parseInt(envIdParam) : undefined; + const envDenied = await auth.requireEnvAccess(envId); + if (envDenied) return envDenied; + const body = await request.json(); // Accept both camelCase and snake_case for backward compatibility const enabled = body.enabled; @@ -109,15 +120,18 @@ export const POST: RequestHandler = async ({ params, url, request, cookies }) => export const DELETE: RequestHandler = async ({ params, url, cookies }) => { const auth = await authorize(cookies); - if (auth.authEnabled && !await auth.can('schedules', 'edit')) { - return json({ error: 'Permission denied' }, { status: 403 }); - } + + const permDenied = await auth.requirePermission('schedules', 'edit'); + if (permDenied) return permDenied; try { const containerName = decodeURIComponent(params.containerName); const envIdParam = url.searchParams.get('env'); const envId = envIdParam ? parseInt(envIdParam) : undefined; + const envDenied = await auth.requireEnvAccess(envId); + if (envDenied) return envDenied; + // Get the setting ID before deleting const setting = await getAutoUpdateSetting(containerName, envId); const settingId = setting?.id; diff --git a/src/routes/api/containers/check-updates/+server.ts b/src/routes/api/containers/check-updates/+server.ts index 341dde6..9d66ae8 100644 --- a/src/routes/api/containers/check-updates/+server.ts +++ b/src/routes/api/containers/check-updates/+server.ts @@ -3,7 +3,7 @@ import type { RequestHandler } from './$types'; import { authorize } from '$lib/server/authorize'; import { listContainers, inspectContainer, checkImageUpdateAvailable } from '$lib/server/docker'; import { clearPendingContainerUpdates, addPendingContainerUpdate } from '$lib/server/db'; -import { isSystemContainer } from '$lib/server/scheduler/tasks/update-utils'; +import { isSystemContainer, isPodmanInfraContainer } from '$lib/server/scheduler/tasks/update-utils'; import { isUpdateDisabledByLabel, isHiddenByLabel } from '$lib/server/container-labels'; import { createJobResponse } from '$lib/server/sse'; @@ -42,9 +42,12 @@ export const POST: RequestHandler = async ({ url, cookies, request }) => { } const allContainers = await listContainers(true, envIdNum); - // Containers labeled dockhand.hidden=true are excluded from update checks — - // they're invisible to the user, so we won't alert on updates for them either (#1083). - const containers = allContainers.filter(c => !isHiddenByLabel(c.labels)); + // Skip: + // - dockhand.hidden=true (invisible to user, so no update alerts) (#1083) + // - Podman pod-infra containers (localhost/podman-pause, never published) (#1221) + const containers = allContainers.filter( + (c) => !isHiddenByLabel(c.labels) && !isPodmanInfraContainer(c.image, c.labels) + ); send('progress', { checked: 0, total: containers.length }); diff --git a/src/routes/api/dashboard/stats/+server.ts b/src/routes/api/dashboard/stats/+server.ts index 732bf0f..aa757b6 100644 --- a/src/routes/api/dashboard/stats/+server.ts +++ b/src/routes/api/dashboard/stats/+server.ts @@ -258,11 +258,13 @@ export const GET: RequestHandler = async ({ cookies, url }) => { envStats.stacks.partial = stacks.filter((s: any) => s.status === 'partial').length; envStats.stacks.stopped = stacks.filter((s: any) => s.status === 'stopped').length; - // Get latest metrics, event stats, and pending updates in parallel + // Get latest metrics, event stats, and pending updates in parallel. + // Each call has its own catch so one failed DB query (e.g. a corrupt + // container_events index, #1210) doesn't poison the whole tile. const [latestMetrics, eventStats, pendingUpdates] = await Promise.all([ - getLatestHostMetrics(env.id), - getContainerEventStats(env.id), - getPendingContainerUpdates(env.id) + getLatestHostMetrics(env.id).catch(() => null), + getContainerEventStats(env.id).catch(() => ({ total: 0, today: 0, byAction: {} })), + getPendingContainerUpdates(env.id).catch(() => []) ]); if (latestMetrics) { diff --git a/src/routes/api/dashboard/stats/stream/+server.ts b/src/routes/api/dashboard/stats/stream/+server.ts index 48c73c7..bdc2c0a 100644 --- a/src/routes/api/dashboard/stats/stream/+server.ts +++ b/src/routes/api/dashboard/stats/stream/+server.ts @@ -195,13 +195,15 @@ async function getEnvironmentStatsProgressive( // Get all database stats in parallel for better performance // NOTE: We do NOT block on getDockerInfo() here - slow environments would block all others - // Instead, we determine online status from whether listContainers succeeds + // Instead, we determine online status from whether listContainers succeeds. + // Each call has its own catch so one failed DB query (e.g. a corrupt + // container_events index, #1210) doesn't poison the whole tile. const [latestMetrics, eventStats, recentEventsResult, metricsHistory, pendingUpdates] = await Promise.all([ - getLatestHostMetrics(env.id), - getContainerEventStats(env.id), - getContainerEvents({ environmentId: env.id, limit: 10 }), - getHostMetrics(metricsPointCount, env.id), - getPendingContainerUpdates(env.id) + getLatestHostMetrics(env.id).catch(() => null), + getContainerEventStats(env.id).catch(() => ({ total: 0, today: 0, byAction: {} })), + getContainerEvents({ environmentId: env.id, limit: 10 }).catch(() => ({ events: [], total: 0, limit: 10, offset: 0 })), + getHostMetrics(metricsPointCount, env.id).catch(() => []), + getPendingContainerUpdates(env.id).catch(() => []) ]); if (latestMetrics) { diff --git a/src/routes/api/events/+server.ts b/src/routes/api/events/+server.ts index dc5d5b8..3854ae0 100644 --- a/src/routes/api/events/+server.ts +++ b/src/routes/api/events/+server.ts @@ -1,11 +1,20 @@ import type { RequestHandler } from './$types'; import { getDockerEvents, EnvironmentNotFoundError } from '$lib/server/docker'; import { getEnvironment } from '$lib/server/db'; +import { authorize } from '$lib/server/authorize'; + +export const GET: RequestHandler = async ({ url, cookies }) => { + const auth = await authorize(cookies); + + const permDenied = await auth.requirePermission('activity', 'view'); + if (permDenied) return permDenied; -export const GET: RequestHandler = async ({ url }) => { const envId = url.searchParams.get('env'); const envIdNum = envId ? parseInt(envId) : undefined; + const envDenied = await auth.requireEnvAccess(envIdNum); + if (envDenied) return envDenied; + // Early return if no environment specified if (!envIdNum) { return new Response( diff --git a/src/routes/api/health/database/+server.ts b/src/routes/api/health/database/+server.ts index 72497a3..b3c7b5d 100644 --- a/src/routes/api/health/database/+server.ts +++ b/src/routes/api/health/database/+server.ts @@ -1,38 +1,43 @@ /** * Database Health Check Endpoint * - * Returns detailed information about the database schema state, - * including migration status, table existence, and connection info. + * Public endpoint suitable for external monitoring. The public payload reports + * enough detail to detect schema drift and table loss without exposing + * connection details (host, port, db name, user) or the running migration tag. + * + * Authenticated callers with settings:view get the full payload — connection + * string (password masked) and schema version included — which is useful for + * operators debugging from the admin UI. * * GET /api/health/database - * - * Response: - * { - * healthy: boolean, - * database: 'sqlite' | 'postgresql', - * connection: string, - * migrationsTable: boolean, - * appliedMigrations: number, - * pendingMigrations: number, - * schemaVersion: string | null, - * tables: { - * expected: number, - * found: number, - * missing: string[] - * }, - * timestamp: string - * } */ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { checkSchemaHealth } from '$lib/server/db/drizzle'; +import { authorize } from '$lib/server/authorize'; -export const GET: RequestHandler = async () => { +export const GET: RequestHandler = async ({ cookies }) => { try { const health = await checkSchemaHealth(); - return json(health, { + const auth = await authorize(cookies); + const showFullDetail = !auth.authEnabled + || (auth.isAuthenticated && await auth.can('settings', 'view')); + + const payload = showFullDetail + ? health + : { + healthy: health.healthy, + database: health.database, + migrationsTable: health.migrationsTable, + appliedMigrations: health.appliedMigrations, + pendingMigrations: health.pendingMigrations, + tables: health.tables, + timestamp: health.timestamp + }; + + return json(payload, { status: health.healthy ? 200 : 503, headers: { 'Cache-Control': 'no-cache, no-store, must-revalidate' diff --git a/src/routes/api/profile/preferences/+server.ts b/src/routes/api/profile/preferences/+server.ts index 493c51f..0359233 100644 --- a/src/routes/api/profile/preferences/+server.ts +++ b/src/routes/api/profile/preferences/+server.ts @@ -45,7 +45,9 @@ export const PUT: RequestHandler = async ({ request, cookies }) => { const validTerminalFontIds = monospaceFonts.map(f => f.id); const validFontSizes = ['xsmall', 'small', 'normal', 'medium', 'large', 'xlarge']; - const updates: { lightTheme?: string; darkTheme?: string; font?: string; fontSize?: string; gridFontSize?: string; terminalFont?: string; editorFont?: string; animateIcons?: boolean } = {}; + const validActionIconSizes = ['small', 'normal', 'large', 'xlarge']; + + const updates: { lightTheme?: string; darkTheme?: string; font?: string; fontSize?: string; gridFontSize?: string; terminalFont?: string; editorFont?: string; animateIcons?: boolean; coloredActionButtons?: boolean; actionIconSize?: string } = {}; if (data.lightTheme !== undefined) { if (!validLightThemeIds.includes(data.lightTheme)) { @@ -103,6 +105,20 @@ export const PUT: RequestHandler = async ({ request, cookies }) => { updates.animateIcons = data.animateIcons; } + if (data.coloredActionButtons !== undefined) { + if (typeof data.coloredActionButtons !== 'boolean') { + return json({ error: 'Invalid coloredActionButtons' }, { status: 400 }); + } + updates.coloredActionButtons = data.coloredActionButtons; + } + + if (data.actionIconSize !== undefined) { + if (!validActionIconSizes.includes(data.actionIconSize)) { + return json({ error: 'Invalid actionIconSize' }, { status: 400 }); + } + updates.actionIconSize = data.actionIconSize; + } + await setUserThemePreferences(currentUser.id, updates); // Return updated preferences diff --git a/src/routes/api/registries/[id]/default/+server.ts b/src/routes/api/registries/[id]/default/+server.ts index 464f1c6..11dc02f 100644 --- a/src/routes/api/registries/[id]/default/+server.ts +++ b/src/routes/api/registries/[id]/default/+server.ts @@ -1,8 +1,14 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { setDefaultRegistry, getRegistry } from '$lib/server/db'; +import { authorize } from '$lib/server/authorize'; + +export const POST: RequestHandler = async ({ params, cookies }) => { + const auth = await authorize(cookies); + if (auth.authEnabled && !await auth.can('settings', 'edit')) { + return json({ error: 'Permission denied' }, { status: 403 }); + } -export const POST: RequestHandler = async ({ params }) => { try { const id = parseInt(params.id); if (isNaN(id)) { diff --git a/src/routes/api/registry/image/+server.ts b/src/routes/api/registry/image/+server.ts index 98ae38f..b20e794 100644 --- a/src/routes/api/registry/image/+server.ts +++ b/src/routes/api/registry/image/+server.ts @@ -2,6 +2,7 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { getRegistry } from '$lib/server/db'; import { getRegistryAuth } from '$lib/server/docker'; +import { authorize } from '$lib/server/authorize'; function isDockerHub(url: string): boolean { const lower = url.toLowerCase(); @@ -18,7 +19,12 @@ const MANIFEST_TYPES = [ 'application/vnd.oci.image.index.v1+json' ]; -export const DELETE: RequestHandler = async ({ url }) => { +export const DELETE: RequestHandler = async ({ url, cookies }) => { + const auth = await authorize(cookies); + if (auth.authEnabled && !await auth.can('settings', 'edit')) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + try { const registryId = url.searchParams.get('registry'); const imageName = url.searchParams.get('image'); diff --git a/src/routes/api/schedules/+server.ts b/src/routes/api/schedules/+server.ts index b732920..75a0d03 100644 --- a/src/routes/api/schedules/+server.ts +++ b/src/routes/api/schedules/+server.ts @@ -22,6 +22,7 @@ import { } from '$lib/server/db'; import { getNextRun, getSystemSchedules } from '$lib/server/scheduler'; import { getGlobalScannerDefaults, getScannerSettingsWithDefaults } from '$lib/server/scanner'; +import { authorize } from '$lib/server/authorize'; export interface ScheduleInfo { id: number; @@ -43,7 +44,12 @@ export interface ScheduleInfo { vulnerabilityCriteria?: VulnerabilityCriteria | null; } -export const GET: RequestHandler = async () => { +export const GET: RequestHandler = async ({ cookies }) => { + const auth = await authorize(cookies); + + const permDenied = await auth.requirePermission('schedules', 'view'); + if (permDenied) return permDenied; + try { const schedules: ScheduleInfo[] = []; @@ -233,13 +239,23 @@ export const GET: RequestHandler = async () => { ); schedules.push(...sysSchedules); + // Filter by per-env access (enterprise): caller sees only schedules + // for envs they can access, plus all system schedules (env null). + // Free edition / admin: getAccessibleEnvironmentIds returns null, + // no filtering applied. + const accessibleEnvIds = await auth.getAccessibleEnvironmentIds(); + const filtered = accessibleEnvIds === null + ? schedules + : schedules.filter(s => + s.environmentId === null || accessibleEnvIds.includes(s.environmentId)); + // Sort: system jobs last, then by name - schedules.sort((a, b) => { + filtered.sort((a, b) => { if (a.isSystem !== b.isSystem) return a.isSystem ? 1 : -1; return a.name.localeCompare(b.name); }); - return json({ schedules }); + return json({ schedules: filtered }); } catch (error: any) { console.error('Failed to get schedules:', error); return json({ error: error.message }, { status: 500 }); diff --git a/src/routes/api/schedules/[type]/[id]/+server.ts b/src/routes/api/schedules/[type]/[id]/+server.ts index 055c443..fabc57f 100644 --- a/src/routes/api/schedules/[type]/[id]/+server.ts +++ b/src/routes/api/schedules/[type]/[id]/+server.ts @@ -8,6 +8,7 @@ import type { RequestHandler } from './$types'; import { getAutoUpdateSettingById, deleteAutoUpdateSchedule, + getGitStack, updateGitStack, deleteEnvUpdateCheckSettings, deleteImagePruneSettings @@ -17,9 +18,9 @@ import { authorize } from '$lib/server/authorize'; export const DELETE: RequestHandler = async ({ params, cookies }) => { const auth = await authorize(cookies); - if (auth.authEnabled && !await auth.can('schedules', 'edit')) { - return json({ error: 'Permission denied' }, { status: 403 }); - } + + const permDenied = await auth.requirePermission('schedules', 'edit'); + if (permDenied) return permDenied; try { const { type, id } = params; @@ -33,6 +34,8 @@ export const DELETE: RequestHandler = async ({ params, cookies }) => { // Hard delete container schedule const schedule = await getAutoUpdateSettingById(scheduleId); if (schedule) { + const envDenied = await auth.requireEnvAccess(schedule.environmentId); + if (envDenied) return envDenied; await deleteAutoUpdateSchedule(schedule.containerName, schedule.environmentId ?? undefined); // Unregister from croner unregisterSchedule(scheduleId, 'container_update'); @@ -40,6 +43,12 @@ export const DELETE: RequestHandler = async ({ params, cookies }) => { return json({ success: true }); } else if (type === 'git_stack_sync') { + const stack = await getGitStack(scheduleId); + if (!stack) { + return json({ error: 'Schedule not found' }, { status: 404 }); + } + const envDenied = await auth.requireEnvAccess(stack.environmentId); + if (envDenied) return envDenied; // Disable auto-update for git stack (don't delete the stack itself) await updateGitStack(scheduleId, { autoUpdate: false, @@ -52,12 +61,16 @@ export const DELETE: RequestHandler = async ({ params, cookies }) => { } else if (type === 'env_update_check') { // Delete env update check settings (scheduleId is environmentId) + const envDenied = await auth.requireEnvAccess(scheduleId); + if (envDenied) return envDenied; await deleteEnvUpdateCheckSettings(scheduleId); unregisterSchedule(scheduleId, 'env_update_check'); return json({ success: true }); } else if (type === 'image_prune') { // Delete image prune settings (scheduleId is environmentId) + const envDenied = await auth.requireEnvAccess(scheduleId); + if (envDenied) return envDenied; await deleteImagePruneSettings(scheduleId); unregisterSchedule(scheduleId, 'image_prune'); return json({ success: true }); diff --git a/src/routes/api/schedules/[type]/[id]/run/+server.ts b/src/routes/api/schedules/[type]/[id]/run/+server.ts index 85aafb9..8eb607e 100644 --- a/src/routes/api/schedules/[type]/[id]/run/+server.ts +++ b/src/routes/api/schedules/[type]/[id]/run/+server.ts @@ -12,12 +12,13 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { triggerContainerUpdate, triggerGitStackSync, triggerSystemJob, triggerEnvUpdateCheck, triggerImagePrune } from '$lib/server/scheduler'; import { authorize } from '$lib/server/authorize'; +import { getAutoUpdateSettingById, getGitStack } from '$lib/server/db'; export const POST: RequestHandler = async ({ params, cookies }) => { const auth = await authorize(cookies); - if (auth.authEnabled && !await auth.can('schedules', 'run')) { - return json({ error: 'Permission denied' }, { status: 403 }); - } + + const permDenied = await auth.requirePermission('schedules', 'run'); + if (permDenied) return permDenied; try { const { type, id } = params; @@ -27,6 +28,37 @@ export const POST: RequestHandler = async ({ params, cookies }) => { return json({ error: 'Invalid schedule ID' }, { status: 400 }); } + // Resolve schedule → environmentId so we can enforce per-env access + // before triggering. System schedules (env null) are gated only by + // the global schedules:run check above. + let scheduleEnvId: number | null = null; + switch (type) { + case 'container_update': { + const setting = await getAutoUpdateSettingById(scheduleId); + if (!setting) return json({ error: 'Schedule not found' }, { status: 404 }); + scheduleEnvId = setting.environmentId; + break; + } + case 'git_stack_sync': { + const stack = await getGitStack(scheduleId); + if (!stack) return json({ error: 'Schedule not found' }, { status: 404 }); + scheduleEnvId = stack.environmentId; + break; + } + case 'env_update_check': + case 'image_prune': + scheduleEnvId = scheduleId; + break; + case 'system_cleanup': + scheduleEnvId = null; + break; + default: + return json({ error: 'Invalid schedule type' }, { status: 400 }); + } + + const envDenied = await auth.requireEnvAccess(scheduleEnvId); + if (envDenied) return envDenied; + let result: { success: boolean; executionId?: number; error?: string }; switch (type) { @@ -46,6 +78,7 @@ export const POST: RequestHandler = async ({ params, cookies }) => { result = await triggerImagePrune(scheduleId); break; default: + // Unreachable — validated in the resolution switch above. return json({ error: 'Invalid schedule type' }, { status: 400 }); } diff --git a/src/routes/api/schedules/[type]/[id]/toggle/+server.ts b/src/routes/api/schedules/[type]/[id]/toggle/+server.ts index b226c34..b1f2947 100644 --- a/src/routes/api/schedules/[type]/[id]/toggle/+server.ts +++ b/src/routes/api/schedules/[type]/[id]/toggle/+server.ts @@ -11,9 +11,9 @@ import { authorize } from '$lib/server/authorize'; export const POST: RequestHandler = async ({ params, cookies }) => { const auth = await authorize(cookies); - if (auth.authEnabled && !await auth.can('schedules', 'edit')) { - return json({ error: 'Permission denied' }, { status: 403 }); - } + + const permDenied = await auth.requirePermission('schedules', 'edit'); + if (permDenied) return permDenied; try { const { type, id } = params; @@ -28,6 +28,8 @@ export const POST: RequestHandler = async ({ params, cookies }) => { if (!setting) { return json({ error: 'Schedule not found' }, { status: 404 }); } + const envDenied = await auth.requireEnvAccess(setting.environmentId); + if (envDenied) return envDenied; const newEnabled = !setting.enabled; await updateAutoUpdateSettingById(scheduleId, { @@ -47,6 +49,8 @@ export const POST: RequestHandler = async ({ params, cookies }) => { if (!stack) { return json({ error: 'Schedule not found' }, { status: 404 }); } + const envDenied = await auth.requireEnvAccess(stack.environmentId); + if (envDenied) return envDenied; const newEnabled = !stack.autoUpdate; await updateGitStack(scheduleId, { @@ -63,6 +67,8 @@ export const POST: RequestHandler = async ({ params, cookies }) => { return json({ success: true, enabled: newEnabled }); } else if (type === 'env_update_check') { // scheduleId is environmentId for env update check + const envDenied = await auth.requireEnvAccess(scheduleId); + if (envDenied) return envDenied; const config = await getEnvUpdateCheckSettings(scheduleId); if (!config) { return json({ error: 'Schedule not found' }, { status: 404 }); @@ -84,6 +90,8 @@ export const POST: RequestHandler = async ({ params, cookies }) => { return json({ success: true, enabled: newEnabled }); } else if (type === 'image_prune') { // scheduleId is environmentId for image prune + const envDenied = await auth.requireEnvAccess(scheduleId); + if (envDenied) return envDenied; const config = await getImagePruneSettings(scheduleId); if (!config) { return json({ error: 'Schedule not found' }, { status: 404 }); diff --git a/src/routes/api/settings/general/+server.ts b/src/routes/api/settings/general/+server.ts index 5d921a2..b412a0c 100644 --- a/src/routes/api/settings/general/+server.ts +++ b/src/routes/api/settings/general/+server.ts @@ -40,11 +40,16 @@ export type TimeFormat = '12h' | '24h'; export type DateFormat = 'MM/DD/YYYY' | 'DD/MM/YYYY' | 'YYYY-MM-DD' | 'DD.MM.YYYY'; export type DownloadFormat = 'tar' | 'tar.gz' | 'raw'; export type EventCollectionMode = 'stream' | 'poll'; +export type ActionIconSize = 'small' | 'normal' | 'large' | 'xlarge'; + +const VALID_ACTION_ICON_SIZES: ActionIconSize[] = ['small', 'normal', 'large', 'xlarge']; export interface GeneralSettings { confirmDestructive: boolean; showStoppedContainers: boolean; highlightUpdates: boolean; + coloredActionButtons: boolean; + actionIconSize: ActionIconSize; timeFormat: TimeFormat; dateFormat: DateFormat; downloadFormat: DownloadFormat; @@ -102,12 +107,17 @@ export interface GeneralSettings { animateIcons: boolean; // Skip Dockhand's scanner images (grype, trivy) during 'prune all unused' (#625) protectScannerImages: boolean; + // Scanner Advanced settings (#1219). Empty = use auto-detection. + defaultScannerNetworkMode: string; + defaultScannerDns: string[]; } const DEFAULT_SETTINGS: Omit = { confirmDestructive: true, showStoppedContainers: true, highlightUpdates: true, + coloredActionButtons: false, + actionIconSize: 'normal' as const, timeFormat: '24h', dateFormat: 'DD.MM.YYYY', downloadFormat: 'tar', @@ -138,6 +148,8 @@ const DEFAULT_SETTINGS: Omit typeof v === 'string') : []; + } catch { + return []; + } + } + return trimmed.split(',').map((s) => s.trim()).filter(Boolean); +} + export const GET: RequestHandler = async ({ cookies }) => { const auth = await authorize(cookies); // UI preferences (time format, date format) should be available to all authenticated users @@ -181,6 +211,8 @@ export const GET: RequestHandler = async ({ cookies }) => { confirmDestructive, showStoppedContainers, highlightUpdates, + coloredActionButtons, + actionIconSize, timeFormat, dateFormat, downloadFormat, @@ -219,11 +251,15 @@ export const GET: RequestHandler = async ({ cookies }) => { honorProxyLabels, showImageChangelogLinks, animateIcons, - protectScannerImages + protectScannerImages, + defaultScannerNetworkMode, + defaultScannerDnsRaw ] = await Promise.all([ getSetting('confirm_destructive'), getSetting('show_stopped_containers'), getSetting('highlight_updates'), + getSetting('colored_action_buttons'), + getSetting('action_icon_size'), getSetting('time_format'), getSetting('date_format'), getSetting('download_format'), @@ -262,13 +298,17 @@ export const GET: RequestHandler = async ({ cookies }) => { getSetting('honor_proxy_labels'), getSetting('show_image_changelog_links'), getSetting('animate_icons'), - getSetting('protect_scanner_images') + getSetting('protect_scanner_images'), + getSetting('default_scanner_network_mode'), + getSetting('default_scanner_dns') ]); const settings: GeneralSettings = { confirmDestructive: confirmDestructive ?? DEFAULT_SETTINGS.confirmDestructive, showStoppedContainers: showStoppedContainers ?? DEFAULT_SETTINGS.showStoppedContainers, highlightUpdates: highlightUpdates ?? DEFAULT_SETTINGS.highlightUpdates, + coloredActionButtons: coloredActionButtons ?? DEFAULT_SETTINGS.coloredActionButtons, + actionIconSize: (VALID_ACTION_ICON_SIZES.includes(actionIconSize as ActionIconSize) ? actionIconSize : DEFAULT_SETTINGS.actionIconSize) as ActionIconSize, timeFormat: timeFormat ?? DEFAULT_SETTINGS.timeFormat, dateFormat: dateFormat ?? DEFAULT_SETTINGS.dateFormat, downloadFormat: downloadFormat ?? DEFAULT_SETTINGS.downloadFormat, @@ -309,7 +349,9 @@ export const GET: RequestHandler = async ({ cookies }) => { honorProxyLabels: honorProxyLabels ?? DEFAULT_SETTINGS.honorProxyLabels, showImageChangelogLinks: showImageChangelogLinks ?? DEFAULT_SETTINGS.showImageChangelogLinks, animateIcons: animateIcons ?? DEFAULT_SETTINGS.animateIcons, - protectScannerImages: protectScannerImages ?? DEFAULT_SETTINGS.protectScannerImages + protectScannerImages: protectScannerImages ?? DEFAULT_SETTINGS.protectScannerImages, + defaultScannerNetworkMode: defaultScannerNetworkMode ?? DEFAULT_SETTINGS.defaultScannerNetworkMode, + defaultScannerDns: parseScannerDnsStorage(defaultScannerDnsRaw) }; return json(settings); @@ -327,7 +369,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => { try { const body = await request.json(); - const { confirmDestructive, showStoppedContainers, highlightUpdates, timeFormat, dateFormat, downloadFormat, defaultGrypeArgs, defaultTrivyArgs, scheduleRetentionDays, eventRetentionDays, scheduleCleanupCron, eventCleanupCron, scheduleCleanupEnabled, eventCleanupEnabled, scannerCleanupCron, scannerCleanupEnabled, logBufferSizeKb, logMaxLines, defaultTimezone, eventCollectionMode, eventPollInterval, metricsCollectionInterval, lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont, compactPorts, showExposedPorts, formatLogTimestamps, externalStackPaths, primaryStackLocation, defaultGrypeImage, defaultTrivyImage, defaultComposeTemplate, labelFilterMode, honorProxyLabels, showImageChangelogLinks, animateIcons, protectScannerImages } = body; + const { confirmDestructive, showStoppedContainers, highlightUpdates, coloredActionButtons, actionIconSize, timeFormat, dateFormat, downloadFormat, defaultGrypeArgs, defaultTrivyArgs, scheduleRetentionDays, eventRetentionDays, scheduleCleanupCron, eventCleanupCron, scheduleCleanupEnabled, eventCleanupEnabled, scannerCleanupCron, scannerCleanupEnabled, logBufferSizeKb, logMaxLines, defaultTimezone, eventCollectionMode, eventPollInterval, metricsCollectionInterval, lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont, compactPorts, showExposedPorts, formatLogTimestamps, externalStackPaths, primaryStackLocation, defaultGrypeImage, defaultTrivyImage, defaultComposeTemplate, labelFilterMode, honorProxyLabels, showImageChangelogLinks, animateIcons, protectScannerImages, defaultScannerNetworkMode, defaultScannerDns } = body; if (confirmDestructive !== undefined) { await setSetting('confirm_destructive', confirmDestructive); @@ -338,6 +380,12 @@ export const POST: RequestHandler = async ({ request, cookies }) => { if (highlightUpdates !== undefined) { await setSetting('highlight_updates', highlightUpdates); } + if (coloredActionButtons !== undefined) { + await setSetting('colored_action_buttons', coloredActionButtons); + } + if (actionIconSize !== undefined && VALID_ACTION_ICON_SIZES.includes(actionIconSize)) { + await setSetting('action_icon_size', actionIconSize); + } if (timeFormat !== undefined && (timeFormat === '12h' || timeFormat === '24h')) { await setSetting('time_format', timeFormat); } @@ -479,12 +527,28 @@ export const POST: RequestHandler = async ({ request, cookies }) => { if (protectScannerImages !== undefined && typeof protectScannerImages === 'boolean') { await setSetting('protect_scanner_images', protectScannerImages); } + if (defaultScannerNetworkMode !== undefined && typeof defaultScannerNetworkMode === 'string') { + // Free-form network mode (host / bridge / none / custom-network-name / ''). + // Trim and cap length to avoid pathological values from a corrupted UI. + const trimmed = defaultScannerNetworkMode.trim().slice(0, 200); + await setSetting('default_scanner_network_mode', trimmed); + } + if (defaultScannerDns !== undefined && Array.isArray(defaultScannerDns)) { + const cleaned = defaultScannerDns + .filter((d) => typeof d === 'string') + .map((d: string) => d.trim()) + .filter((d) => d.length > 0) + .slice(0, 10); // sane upper bound; Docker accepts more but no one needs it + await setSetting('default_scanner_dns', JSON.stringify(cleaned)); + } // Fetch all settings in parallel for the response const [ confirmDestructiveVal, showStoppedContainersVal, highlightUpdatesVal, + coloredActionButtonsVal, + actionIconSizeVal, timeFormatVal, dateFormatVal, downloadFormatVal, @@ -523,11 +587,15 @@ export const POST: RequestHandler = async ({ request, cookies }) => { honorProxyLabelsVal, showImageChangelogLinksVal, animateIconsVal, - protectScannerImagesVal + protectScannerImagesVal, + defaultScannerNetworkModeVal, + defaultScannerDnsRawVal ] = await Promise.all([ getSetting('confirm_destructive'), getSetting('show_stopped_containers'), getSetting('highlight_updates'), + getSetting('colored_action_buttons'), + getSetting('action_icon_size'), getSetting('time_format'), getSetting('date_format'), getSetting('download_format'), @@ -566,13 +634,17 @@ export const POST: RequestHandler = async ({ request, cookies }) => { getSetting('honor_proxy_labels'), getSetting('show_image_changelog_links'), getSetting('animate_icons'), - getSetting('protect_scanner_images') + getSetting('protect_scanner_images'), + getSetting('default_scanner_network_mode'), + getSetting('default_scanner_dns') ]); const settings: GeneralSettings = { confirmDestructive: confirmDestructiveVal ?? DEFAULT_SETTINGS.confirmDestructive, showStoppedContainers: showStoppedContainersVal ?? DEFAULT_SETTINGS.showStoppedContainers, highlightUpdates: highlightUpdatesVal ?? DEFAULT_SETTINGS.highlightUpdates, + coloredActionButtons: coloredActionButtonsVal ?? DEFAULT_SETTINGS.coloredActionButtons, + actionIconSize: (VALID_ACTION_ICON_SIZES.includes(actionIconSizeVal as ActionIconSize) ? actionIconSizeVal : DEFAULT_SETTINGS.actionIconSize) as ActionIconSize, timeFormat: timeFormatVal ?? DEFAULT_SETTINGS.timeFormat, dateFormat: dateFormatVal ?? DEFAULT_SETTINGS.dateFormat, downloadFormat: downloadFormatVal ?? DEFAULT_SETTINGS.downloadFormat, @@ -613,7 +685,9 @@ export const POST: RequestHandler = async ({ request, cookies }) => { honorProxyLabels: honorProxyLabelsVal ?? DEFAULT_SETTINGS.honorProxyLabels, protectScannerImages: protectScannerImagesVal ?? DEFAULT_SETTINGS.protectScannerImages, showImageChangelogLinks: showImageChangelogLinksVal ?? DEFAULT_SETTINGS.showImageChangelogLinks, - animateIcons: animateIconsVal ?? DEFAULT_SETTINGS.animateIcons + animateIcons: animateIconsVal ?? DEFAULT_SETTINGS.animateIcons, + defaultScannerNetworkMode: defaultScannerNetworkModeVal ?? DEFAULT_SETTINGS.defaultScannerNetworkMode, + defaultScannerDns: parseScannerDnsStorage(defaultScannerDnsRawVal) }; return json(settings); diff --git a/src/routes/api/settings/theme/+server.ts b/src/routes/api/settings/theme/+server.ts index f5ac754..b196089 100644 --- a/src/routes/api/settings/theme/+server.ts +++ b/src/routes/api/settings/theme/+server.ts @@ -14,7 +14,9 @@ const DEFAULT_THEME_SETTINGS = { gridFontSize: 'normal', terminalFont: 'system-mono', editorFont: 'system-mono', - animateIcons: true + animateIcons: true, + coloredActionButtons: false, + actionIconSize: 'normal' }; export const GET: RequestHandler = async () => { @@ -27,7 +29,9 @@ export const GET: RequestHandler = async () => { gridFontSize, terminalFont, editorFont, - animateIcons + animateIcons, + coloredActionButtons, + actionIconSize ] = await Promise.all([ getSetting('theme_light'), getSetting('theme_dark'), @@ -36,7 +40,9 @@ export const GET: RequestHandler = async () => { getSetting('theme_grid_font_size'), getSetting('theme_terminal_font'), getSetting('theme_editor_font'), - getSetting('animate_icons') + getSetting('animate_icons'), + getSetting('colored_action_buttons'), + getSetting('action_icon_size') ]); return json({ @@ -47,7 +53,9 @@ export const GET: RequestHandler = async () => { gridFontSize: gridFontSize ?? DEFAULT_THEME_SETTINGS.gridFontSize, terminalFont: terminalFont ?? DEFAULT_THEME_SETTINGS.terminalFont, editorFont: editorFont ?? DEFAULT_THEME_SETTINGS.editorFont, - animateIcons: animateIcons ?? DEFAULT_THEME_SETTINGS.animateIcons + animateIcons: animateIcons ?? DEFAULT_THEME_SETTINGS.animateIcons, + coloredActionButtons: coloredActionButtons ?? DEFAULT_THEME_SETTINGS.coloredActionButtons, + actionIconSize: actionIconSize ?? DEFAULT_THEME_SETTINGS.actionIconSize }); } catch (error) { console.error('Failed to get theme settings:', error); diff --git a/src/routes/api/templates/+server.ts b/src/routes/api/templates/+server.ts index 996729e..9c340e1 100644 --- a/src/routes/api/templates/+server.ts +++ b/src/routes/api/templates/+server.ts @@ -20,6 +20,47 @@ export interface TemplateItem { stars?: number; pulls?: number; network?: string; + /** Free-form maintainer / install notes from the upstream catalog. */ + note?: string; + /** Project / homepage URL the user can click to learn more or report issues (#1211). */ + projectUrl?: string; +} + +/** + * Best-effort first-URL extraction from a template's description or note. + * Order matters: markdown link first, then HTML href, then a bare URL. + */ +function extractFirstUrl(text: string | undefined | null): string | null { + if (!text) return null; + const md = text.match(/\[[^\]]+\]\((https?:\/\/[^)]+)\)/); + if (md) return md[1]; + const html = text.match(/]*href=["']([^"']+)["']/i); + if (html) return html[1]; + const plain = text.match(/(https?:\/\/[^\s<>"')]+)/); + if (plain) return plain[1]; + return null; +} + +/** + * Resolve the "Project" URL surfaced on the card. + * Priority: + * 1. URL embedded in `description` — LSIO-style templates have a markdown + * link to the actual project repo here, which is what users want most. + * 2. URL embedded in `note` — Portainer v3 catalogs often put a homepage + * link in the install notes. + * 3. `maintainer` field — Lissy93's templates carry a per-template + * maintainer URL; not always the project itself but usually close. + * 4. `repository.url` — only for stack-type templates, points to the repo + * hosting the compose file (closest thing we have). + */ +function resolveProjectUrl(entry: any, description: string, note: string): string | undefined { + return ( + extractFirstUrl(description) || + extractFirstUrl(note) || + (typeof entry.maintainer === 'string' ? entry.maintainer.trim() : null) || + (entry.repository && typeof entry.repository.url === 'string' ? entry.repository.url : null) || + undefined + ); } // Server-side cache: url → { data, fetchedAt } @@ -43,14 +84,18 @@ function normalizePortainerTemplate(entry: any, sourceName: string): TemplateIte if (entry.type === 2) return null; const title = entry.title || entry.name || ''; + const description = entry.description || ''; + const note = typeof entry.note === 'string' ? entry.note : ''; const template: TemplateItem = { id: hashId(sourceName, title), type: entry.type === 3 ? 'stack' : 'container', title, - description: entry.description || '', + description, logo: entry.logo || '', categories: Array.isArray(entry.categories) ? entry.categories : [], source: sourceName, + note: note || undefined, + projectUrl: resolveProjectUrl(entry, description, note) }; if (entry.type === 3 && entry.repository) { @@ -77,11 +122,18 @@ function normalizePortainerTemplate(entry: any, sourceName: string): TemplateIte function normalizeLinuxServerTemplate(entry: any): TemplateItem | null { if (!entry.name || entry.deprecated) return null; + const description = entry.description || ''; + // LSIO fleet entries sometimes carry a project_url; prefer it over regex sniffing. + const projectUrl = + (typeof entry.project_url === 'string' && entry.project_url) || + extractFirstUrl(description) || + `https://github.com/linuxserver/docker-${entry.name}`; + return { id: hashId('LinuxServer.io', entry.name), type: 'container', title: entry.name, - description: entry.description || '', + description, logo: entry.project_logo || '', categories: entry.category ? entry.category.split(',').map((c: string) => c.trim()).filter(Boolean) : [], source: 'LinuxServer.io', @@ -94,6 +146,7 @@ function normalizeLinuxServerTemplate(entry: any): TemplateItem | null { restartPolicy: 'unless-stopped', stars: entry.stars, pulls: entry.monthly_pulls, + projectUrl }; } diff --git a/src/routes/audit/+page.svelte b/src/routes/audit/+page.svelte index effc98a..fffb99d 100644 --- a/src/routes/audit/+page.svelte +++ b/src/routes/audit/+page.svelte @@ -906,7 +906,7 @@ {:else if column.id === 'actions'}
{/if} diff --git a/src/routes/containers/+page.svelte b/src/routes/containers/+page.svelte index 98c85bf..a5fba25 100644 --- a/src/routes/containers/+page.svelte +++ b/src/routes/containers/+page.svelte @@ -2091,7 +2091,7 @@ onOpenChange={(open) => confirmUpdateId = open ? container.id : null} > {#snippet children({ open })} - + {/snippet} {/if} @@ -2112,7 +2112,7 @@ title="Open logs" class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer" > - + {/if} {/if} @@ -2135,7 +2135,7 @@ onclick={(e: MouseEvent) => e.stopPropagation()} class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer" > - +
@@ -2244,7 +2244,7 @@ title="Browse files" class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer" > - + {/if} {#if $canAccess('containers', 'create')} {/if} {#if !container.systemContainer} @@ -2274,7 +2274,7 @@ title="Unpause" class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer" > - + {/if} {:else if container.state !== 'running' && container.state !== 'restarting'} @@ -2285,7 +2285,7 @@ title="Start" class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer" > - + {/if} {/if} @@ -2301,7 +2301,7 @@ onOpenChange={(open) => confirmRestartId = open ? container.id : null} > {#snippet children({ open })} - + {/snippet} {/if} @@ -2313,7 +2313,7 @@ title="Pause" class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer" > - + {/if} {#if $canAccess('containers', 'stop')} @@ -2327,7 +2327,7 @@ onOpenChange={(open) => confirmStopId = open ? container.id : null} > {#snippet children({ open })} - + {/snippet} {/if} @@ -2344,7 +2344,7 @@ onOpenChange={(open) => confirmDeleteId = open ? container.id : null} > {#snippet children({ open })} - + {/snippet} {/if} diff --git a/src/routes/images/+page.svelte b/src/routes/images/+page.svelte index cdc67e1..8d11532 100644 --- a/src/routes/images/+page.svelte +++ b/src/routes/images/+page.svelte @@ -589,14 +589,14 @@ } async function tagImage() { - if (!tagNewRepo.trim()) return; + if (!tagNewRepo.trim() || !tagNewTag.trim()) return; tagging = true; - const newTag = `${tagNewRepo.trim()}:${tagNewTag.trim() || 'latest'}`; + const newTag = `${tagNewRepo.trim()}:${tagNewTag.trim()}`; try { const response = await fetch(appendEnvParam(`/api/images/${encodeURIComponent(tagImageId)}/tag`, envId), { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ repo: tagNewRepo.trim(), tag: tagNewTag.trim() || 'latest' }) + body: JSON.stringify({ repo: tagNewRepo.trim(), tag: tagNewTag.trim() }) }); if (response.ok) { toast.success(`Tagged as ${newTag}`); @@ -1007,7 +1007,7 @@ title="Run container" class="p-1 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer" > - + {/if} {#if scannerEnabled && $canAccess('images', 'inspect')} @@ -1017,7 +1017,7 @@ title="Scan for vulnerabilities" class="p-1 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer" > - + {/if} {#if $canAccess('images', 'push')} @@ -1027,7 +1027,7 @@ title="Push to registry" class="p-1 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer" > - + {/if}
@@ -1092,7 +1092,7 @@ title="View layers" class="p-1 rounded hover:bg-muted transition-colors cursor-pointer" > - + {/if} {#if $canAccess('containers', 'create')} @@ -1102,7 +1102,7 @@ title="Run container" class="p-1 rounded hover:bg-muted transition-colors cursor-pointer" > - + {/if} {#if scannerEnabled && $canAccess('images', 'inspect')} @@ -1112,7 +1112,7 @@ title="Scan for vulnerabilities" class="p-1 rounded hover:bg-muted transition-colors cursor-pointer" > - + {/if} {#if $canAccess('images', 'push')} @@ -1122,7 +1122,7 @@ title="Push to registry" class="p-1 rounded hover:bg-muted transition-colors cursor-pointer" > - + {/if} {#if $canAccess('images', 'inspect')} @@ -1133,7 +1133,7 @@ class="p-1 rounded hover:bg-muted transition-colors cursor-pointer {exportingId === tagInfo.fullRef ? 'animate-pulse' : ''}" disabled={exportingId === tagInfo.fullRef} > - + {/if} {#if $canAccess('images', 'build')} @@ -1143,7 +1143,7 @@ title="Tag image" class="p-1 rounded hover:bg-muted transition-colors cursor-pointer" > - + {/if} {#if $canAccess('images', 'remove') && tagInfo.containers === 0} @@ -1158,7 +1158,7 @@ onOpenChange={(open) => confirmDeleteId = open ? tagInfo.fullRef : null} > {#snippet children({ open })} - + {/snippet}
@@ -1235,14 +1235,14 @@ - + Tag image - Add a new tag to {tagImageCurrentName} + Add a new tag to {tagImageCurrentName.startsWith('sha256:') ? tagImageCurrentName.slice(0, 19) : tagImageCurrentName}
@@ -1263,7 +1263,7 @@ placeholder="e.g., latest, v1.0.0" class="mt-2" onkeydown={(e: KeyboardEvent) => { - if (e.key === 'Enter' && !tagging && tagNewRepo.trim()) { + if (e.key === 'Enter' && !tagging && tagNewRepo.trim() && tagNewTag.trim()) { tagImage(); } }} @@ -1276,7 +1276,7 @@ {/if} {#if !isProtected && $canAccess('networks', 'connect')} @@ -680,7 +680,7 @@ title="Connect container" class="p-1 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer" > - + {/if} {#if !isProtected && $canAccess('networks', 'create')} {/if} {#if !isProtected && $canAccess('networks', 'remove')} @@ -712,7 +712,7 @@ onOpenChange={(open) => confirmDeleteId = open ? network.id : null} > {#snippet children({ open })} - + {/snippet} {/if} diff --git a/src/routes/profile/+page.svelte b/src/routes/profile/+page.svelte index 4b532f1..4d59c87 100644 --- a/src/routes/profile/+page.svelte +++ b/src/routes/profile/+page.svelte @@ -39,6 +39,7 @@ import * as Table from '$lib/components/ui/table'; import ThemeSelector from '$lib/components/ThemeSelector.svelte'; import AnimateIconsToggle from '$lib/components/AnimateIconsToggle.svelte'; + import ColoredActionsToggle from '$lib/components/ColoredActionsToggle.svelte'; import { themeStore } from '$lib/stores/theme'; import PageHeader from '$lib/components/PageHeader.svelte'; @@ -726,6 +727,7 @@ + diff --git a/src/routes/profile/ApiTokenModal.svelte b/src/routes/profile/ApiTokenModal.svelte index e4669e3..e890632 100644 --- a/src/routes/profile/ApiTokenModal.svelte +++ b/src/routes/profile/ApiTokenModal.svelte @@ -6,6 +6,7 @@ import * as Select from '$lib/components/ui/select'; import * as Alert from '$lib/components/ui/alert'; import { KeyRound, Copy, Check, TriangleAlert } from 'lucide-svelte'; + import { copyToClipboard } from '$lib/utils/clipboard'; let { open = $bindable(false), @@ -100,13 +101,10 @@ } async function copyToken() { - try { - await navigator.clipboard.writeText(createdToken); - copied = true; - setTimeout(() => copied = false, 2000); - } catch { - // Fallback: select the input text - } + const ok = await copyToClipboard(createdToken); + if (!ok) return; + copied = true; + setTimeout(() => copied = false, 2000); } function handleClose() { diff --git a/src/routes/registry/CopyToRegistryModal.svelte b/src/routes/registry/CopyToRegistryModal.svelte index 8741f96..997ca61 100644 --- a/src/routes/registry/CopyToRegistryModal.svelte +++ b/src/routes/registry/CopyToRegistryModal.svelte @@ -84,7 +84,10 @@ const urlObj = new URL(sourceRegistry.url); // Include both host and path (e.g., registry.example.com/organization) const hostWithPath = urlObj.host + (urlObj.pathname !== '/' ? urlObj.pathname.replace(/\/$/, '') : ''); - return `${hostWithPath}/${imageWithTag}`; + // Caller (e.g. registry browser) may already pass an image name with the host prefixed (#1220). + return imageWithTag.startsWith(hostWithPath + '/') + ? imageWithTag + : `${hostWithPath}/${imageWithTag}`; } return imageWithTag; }); diff --git a/src/routes/schedules/+page.svelte b/src/routes/schedules/+page.svelte index 183281e..cffb1d7 100644 --- a/src/routes/schedules/+page.svelte +++ b/src/routes/schedules/+page.svelte @@ -1305,7 +1305,7 @@ title="View last execution logs" class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer" > - + {/if} {#if canEditSchedules} @@ -1316,9 +1316,9 @@ class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer" > {#if schedule.enabled} - + {:else} - + {/if} {/if} @@ -1329,7 +1329,7 @@ title="Run now" class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer" > - + {/if} {#if canEditSchedules && !schedule.isSystem} @@ -1344,7 +1344,7 @@ onOpenChange={(open) => confirmDeleteId = open ? scheduleKey : null} > {#snippet children({ open })} - + {/snippet} {/if} @@ -1435,7 +1435,7 @@ title="View logs" class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer" > - + {#if canEditSchedules} +
+ {#if showAdvancedScannerSettings} +
+ + + + {defaultScannerNetworkMode || 'Default (auto-detect)'} + + + Default (auto-detect) + host + bridge + none + + +

Override the Docker network mode for vulnerability scanner containers. Use host on hosts where the default bridge can't reach the internet (e.g. iptables disabled, SELinux restricted).

+
+
+ + +

Comma-separated DNS IPs for scanner containers. Empty = inherit from the Docker daemon.

+
+ {/if}
diff --git a/src/routes/stacks/+page.svelte b/src/routes/stacks/+page.svelte index 39eaab3..b0300b3 100644 --- a/src/routes/stacks/+page.svelte +++ b/src/routes/stacks/+page.svelte @@ -1750,7 +1750,7 @@ title="Edit git stack" class="p-1 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer" > - + - + {/snippet} @@ -1780,7 +1780,7 @@ title="Sync from Git" class="p-1 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer" > - + {/snippet} @@ -1793,7 +1793,7 @@ title="Edit git stack" class="p-1 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer" > - + {:else} @@ -1803,7 +1803,7 @@ title="Edit" class="p-1 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer" > - + {/if} {/if} @@ -1814,7 +1814,7 @@ title="View logs" class="p-1 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer" > - + {/if} {#if source.sourceType !== 'git' && source.sourceType !== 'external' && $canAccess('stacks', 'start')} @@ -1825,13 +1825,13 @@ onDeploy={(options) => redeployStack(stack.name, options)} > {#snippet children()} - + {/snippet} {/if} {#if stackActionLoading === stack.name}
- +
{:else if stack.status !== 'running' && stack.status !== 'partial' && stack.status !== 'restarting'} {#if $canAccess('stacks', 'start')} @@ -1841,7 +1841,7 @@ title="Start" class="p-1 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer" > - + {/if} {:else} @@ -1856,7 +1856,7 @@ onclick={(e) => { e.stopPropagation(); restartPopoverOpen[stack.name] = !restartPopoverOpen[stack.name]; }} class="p-1 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer inline-flex items-center" > - + {/snippet} @@ -1891,7 +1891,7 @@ onOpenChange={(open) => confirmStopName = open ? stack.name : null} > {#snippet children({ open })} - + {/snippet} {/if} @@ -1908,7 +1908,7 @@ onOpenChange={(open) => confirmDownName = open ? stack.name : null} > {#snippet children({ open })} - + {/snippet} {/if} @@ -1929,7 +1929,7 @@ {/snippet} {#snippet children({ open })} - + {/snippet} {/if} diff --git a/src/routes/stacks/GitDeployProgressPopover.svelte b/src/routes/stacks/GitDeployProgressPopover.svelte index c4f147c..9dff9a8 100644 --- a/src/routes/stacks/GitDeployProgressPopover.svelte +++ b/src/routes/stacks/GitDeployProgressPopover.svelte @@ -22,6 +22,7 @@ import type { Snippet } from 'svelte'; import { appSettings } from '$lib/stores/settings'; import { watchJob } from '$lib/utils/sse-fetch'; + import { copyToClipboard } from '$lib/utils/clipboard'; interface Props { stackId: number; @@ -155,7 +156,8 @@ async function copyLogs() { const lines = steps.map(s => s.message || s.status); if (errorMessage) lines.push(`Error: ${errorMessage}`); - await navigator.clipboard.writeText(lines.join('\n')); + const ok = await copyToClipboard(lines.join('\n')); + if (!ok) return; copied = true; setTimeout(() => { copied = false; }, 2000); } diff --git a/src/routes/templates/TemplateCard.svelte b/src/routes/templates/TemplateCard.svelte index 5bae571..5bdd667 100644 --- a/src/routes/templates/TemplateCard.svelte +++ b/src/routes/templates/TemplateCard.svelte @@ -1,7 +1,7 @@