This commit is contained in:
jarek committed 2026-06-27 15:15:54 +02:00
1 parent ce12d7ae9f
commit 685132ff89
63 files changed
+1324 -281

No files matched your search

@@ -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) {
+3 -2
View File
@@ -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
@@ -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;
@@ -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 });
+6 -4
View File
@@ -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) {
@@ -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) {
+10 -1
View File
@@ -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(
+26 -21
View File
@@ -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'
+17 -1
View File
@@ -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
@@ -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)) {
+7 -1
View File
@@ -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');
+19 -3
View File
@@ -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 });
@@ -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 });
@@ -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 });
}
@@ -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 });
+81 -7
View File
@@ -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<GeneralSettings, 'scheduleRetentionDays' | 'eventRetentionDays' | 'scheduleCleanupCron' | 'eventCleanupCron' | 'scheduleCleanupEnabled' | 'eventCleanupEnabled' | 'scannerCleanupCron' | 'scannerCleanupEnabled'> = {
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<GeneralSettings, 'scheduleRetentionDays' | 'eventRe
showImageChangelogLinks: true,
animateIcons: true,
protectScannerImages: true,
defaultScannerNetworkMode: '',
defaultScannerDns: [],
defaultComposeTemplate: `version: "3.8"
services:
@@ -167,6 +179,24 @@ const VALID_EDITOR_FONTS = VALID_TERMINAL_FONTS;
const VALID_DATE_FORMATS: DateFormat[] = ['MM/DD/YYYY', 'DD/MM/YYYY', 'YYYY-MM-DD', 'DD.MM.YYYY'];
// Scanner DNS is stored as a JSON-stringified array in the settings KV store
// (consistent with externalStackPaths). Tolerate the legacy/hand-edited
// comma-separated form too.
function parseScannerDnsStorage(raw: string | null | undefined): string[] {
if (!raw) return [];
const trimmed = raw.trim();
if (!trimmed) return [];
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);
}
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);
+12 -4
View File
@@ -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);
+55 -2
View File
@@ -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(/<a\s+[^>]*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
};
}