This commit is contained in:
jarek committed 2026-03-21 14:29:30 +01:00
1 parent aefa5e7925
commit b55e1e5aad
107 files changed
+2193 -443

No files matched your search

@@ -6,7 +6,7 @@ import { getOidcConfig } from '$lib/server/db';
// GET /api/auth/oidc/[id]/initiate - Start OIDC authentication flow
export const GET: RequestHandler = async ({ params, url }) => {
// Check if auth is enabled
if (!isAuthEnabled()) {
if (!await isAuthEnabled()) {
return json({ error: 'Authentication is not enabled' }, { status: 400 });
}
@@ -45,7 +45,7 @@ export const GET: RequestHandler = async ({ params, url }) => {
// POST /api/auth/oidc/[id]/initiate - Get authorization URL without redirect
export const POST: RequestHandler = async ({ params, request }) => {
// Check if auth is enabled
if (!isAuthEnabled()) {
if (!await isAuthEnabled()) {
return json({ error: 'Authentication is not enabled' }, { status: 400 });
}
@@ -6,7 +6,7 @@ import { validateSession, testOidcConnection, isAuthEnabled } from '$lib/server/
export const POST: RequestHandler = async ({ params, cookies }) => {
// When auth is disabled, allow access (for initial setup)
// When auth is enabled, require admin
if (isAuthEnabled()) {
if (await isAuthEnabled()) {
const user = await validateSession(cookies);
if (!user || !user.isAdmin) {
return json({ error: 'Admin access required' }, { status: 403 });
+1 -1
View File
@@ -7,7 +7,7 @@ import { auditAuth } from '$lib/server/audit';
export const GET: RequestHandler = async (event) => {
const { url, cookies } = event;
// Check if auth is enabled
if (!isAuthEnabled()) {
if (!await isAuthEnabled()) {
throw redirect(302, '/login?error=auth_disabled');
}
@@ -8,9 +8,13 @@ import { deleteAutoUpdateSchedule, getAutoUpdateSetting, removePendingContainerU
import { authorize } from '$lib/server/authorize';
import { auditContainer } from '$lib/server/audit';
import { unregisterSchedule } from '$lib/server/scheduler';
import { validateDockerIdParam } from '$lib/server/docker-validation';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const invalid = validateDockerIdParam(params.id, 'container');
if (invalid) return invalid;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
@@ -41,6 +45,9 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
export const DELETE: RequestHandler = async (event) => {
const { params, url, cookies } = event;
const invalid = validateDockerIdParam(params.id, 'container');
if (invalid) return invalid;
const auth = await authorize(cookies);
const force = url.searchParams.get('force') === 'true';
@@ -9,8 +9,12 @@ import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { createExec, getDockerConnectionInfo } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { validateDockerIdParam } from '$lib/server/docker-validation';
export const POST: RequestHandler = async ({ params, request, cookies, url }) => {
const invalid = validateDockerIdParam(params.id, 'container');
if (invalid) return invalid;
const auth = await authorize(cookies);
if (auth.authEnabled && !auth.isAuthenticated) {
return json({ error: 'Unauthorized' }, { status: 401 });
@@ -1,9 +1,13 @@
import { json } from '@sveltejs/kit';
import { listContainerDirectory } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { validateDockerIdParam } from '$lib/server/docker-validation';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const invalid = validateDockerIdParam(params.id, 'container');
if (invalid) return invalid;
const auth = await authorize(cookies);
const path = url.searchParams.get('path') || '/';
@@ -1,9 +1,13 @@
import { json } from '@sveltejs/kit';
import { chmodContainerPath } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { validateDockerIdParam } from '$lib/server/docker-validation';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ params, url, cookies, request }) => {
const invalid = validateDockerIdParam(params.id, 'container');
if (invalid) return invalid;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
@@ -1,12 +1,16 @@
import { json } from '@sveltejs/kit';
import { readContainerFile, writeContainerFile } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { validateDockerIdParam } from '$lib/server/docker-validation';
import type { RequestHandler } from './$types';
// Max file size for reading (1MB)
const MAX_FILE_SIZE = 1024 * 1024;
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const invalid = validateDockerIdParam(params.id, 'container');
if (invalid) return invalid;
const auth = await authorize(cookies);
const path = url.searchParams.get('path');
@@ -60,6 +64,9 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
};
export const PUT: RequestHandler = async ({ params, url, cookies, request }) => {
const invalid = validateDockerIdParam(params.id, 'container');
if (invalid) return invalid;
const auth = await authorize(cookies);
const path = url.searchParams.get('path');
@@ -1,9 +1,13 @@
import { json } from '@sveltejs/kit';
import { createContainerFile, createContainerDirectory } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { validateDockerIdParam } from '$lib/server/docker-validation';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ params, url, cookies, request }) => {
const invalid = validateDockerIdParam(params.id, 'container');
if (invalid) return invalid;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
@@ -1,9 +1,13 @@
import { json } from '@sveltejs/kit';
import { deleteContainerPath } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { validateDockerIdParam } from '$lib/server/docker-validation';
import type { RequestHandler } from './$types';
export const DELETE: RequestHandler = async ({ params, url, cookies }) => {
const invalid = validateDockerIdParam(params.id, 'container');
if (invalid) return invalid;
const auth = await authorize(cookies);
const path = url.searchParams.get('path');
@@ -1,9 +1,13 @@
import { gzipSync } from 'node:zlib';
import { getContainerArchive, statContainerPath } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { validateDockerIdParam } from '$lib/server/docker-validation';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const invalid = validateDockerIdParam(params.id, 'container');
if (invalid) return invalid;
const auth = await authorize(cookies);
const path = url.searchParams.get('path');
@@ -1,9 +1,13 @@
import { json } from '@sveltejs/kit';
import { renameContainerPath } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { validateDockerIdParam } from '$lib/server/docker-validation';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ params, url, cookies, request }) => {
const invalid = validateDockerIdParam(params.id, 'container');
if (invalid) return invalid;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
@@ -1,6 +1,7 @@
import { json } from '@sveltejs/kit';
import { putContainerArchive } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { validateDockerIdParam } from '$lib/server/docker-validation';
import type { RequestHandler } from './$types';
/**
@@ -84,6 +85,9 @@ function createTarArchive(filename: string, content: Uint8Array): Uint8Array {
}
export const POST: RequestHandler = async ({ params, url, request, cookies }) => {
const invalid = validateDockerIdParam(params.id, 'container');
if (invalid) return invalid;
const auth = await authorize(cookies);
const path = url.searchParams.get('path');
@@ -2,8 +2,12 @@ import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { inspectContainer } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { validateDockerIdParam } from '$lib/server/docker-validation';
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const invalid = validateDockerIdParam(params.id, 'container');
if (invalid) return invalid;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
@@ -1,9 +1,13 @@
import { json } from '@sveltejs/kit';
import { getContainerLogs } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { validateDockerIdParam } from '$lib/server/docker-validation';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const invalid = validateDockerIdParam(params.id, 'container');
if (invalid) return invalid;
const auth = await authorize(cookies);
const tail = parseInt(url.searchParams.get('tail') || '100');
@@ -1,6 +1,7 @@
import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize';
import { getEnvironment } from '$lib/server/db';
import { validateDockerIdParam } from '$lib/server/docker-validation';
import { unixSocketRequest, unixSocketStreamRequest, httpsAgentRequest } from '$lib/server/docker';
import type { DockerClientConfig as BaseDockerClientConfig } from '$lib/server/docker';
import { sendEdgeRequest, sendEdgeStreamRequest, isEdgeConnected } from '$lib/server/hawser';
@@ -254,6 +255,9 @@ async function handleEdgeLogsStream(containerId: string, tail: string, environme
}
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const invalid = validateDockerIdParam(params.id, 'container');
if (invalid) return invalid;
const auth = await authorize(cookies);
const containerId = params.id;
@@ -3,9 +3,13 @@ import type { RequestHandler } from './$types';
import { pauseContainer, inspectContainer } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { auditContainer } from '$lib/server/audit';
import { validateDockerIdParam } from '$lib/server/docker-validation';
export const POST: RequestHandler = async (event) => {
const { params, url, cookies } = event;
const invalid = validateDockerIdParam(params.id, 'container');
if (invalid) return invalid;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
@@ -3,10 +3,14 @@ import { renameContainer, inspectContainer } from '$lib/server/docker';
import { renameAutoUpdateSchedule } from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
import { auditContainer } from '$lib/server/audit';
import { validateDockerIdParam } from '$lib/server/docker-validation';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async (event) => {
const { params, request, url, cookies } = event;
const invalid = validateDockerIdParam(params.id, 'container');
if (invalid) return invalid;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
@@ -2,10 +2,14 @@ import { json } from '@sveltejs/kit';
import { restartContainer, inspectContainer } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { auditContainer } from '$lib/server/audit';
import { validateDockerIdParam } from '$lib/server/docker-validation';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async (event) => {
const { params, url, cookies } = event;
const invalid = validateDockerIdParam(params.id, 'container');
if (invalid) return invalid;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
@@ -1,6 +1,7 @@
import { json } from '@sveltejs/kit';
import { execInContainer } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { validateDockerIdParam } from '$lib/server/docker-validation';
import type { RequestHandler } from './$types';
// Shell paths to check
@@ -12,6 +13,9 @@ const SHELLS_TO_CHECK = [
];
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const invalid = validateDockerIdParam(params.id, 'container');
if (invalid) return invalid;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
@@ -2,10 +2,14 @@ import { json } from '@sveltejs/kit';
import { startContainer, inspectContainer } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { auditContainer } from '$lib/server/audit';
import { validateDockerIdParam } from '$lib/server/docker-validation';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async (event) => {
const { params, url, cookies } = event;
const invalid = validateDockerIdParam(params.id, 'container');
if (invalid) return invalid;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
@@ -3,6 +3,7 @@ import type { RequestHandler } from './$types';
import { getContainerStats, EnvironmentNotFoundError } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { hasEnvironments } from '$lib/server/db';
import { validateDockerIdParam } from '$lib/server/docker-validation';
function calculateCpuPercent(stats: any): number {
const cpuDelta = stats.cpu_stats.cpu_usage.total_usage - stats.precpu_stats.cpu_usage.total_usage;
@@ -70,6 +71,9 @@ function calculateMemoryUsage(memoryStats: any): { usage: number; raw: number; c
}
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const invalid = validateDockerIdParam(params.id, 'container');
if (invalid) return invalid;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
@@ -2,10 +2,14 @@ import { json } from '@sveltejs/kit';
import { stopContainer, inspectContainer } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { auditContainer } from '$lib/server/audit';
import { validateDockerIdParam } from '$lib/server/docker-validation';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async (event) => {
const { params, url, cookies } = event;
const invalid = validateDockerIdParam(params.id, 'container');
if (invalid) return invalid;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
@@ -2,6 +2,7 @@ import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { execInContainer, getContainerTop } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { validateDockerIdParam } from '$lib/server/docker-validation';
function parsePsOutput(output: string): { Titles: string[]; Processes: string[][] } | null {
const lines = output.trim().split('\n').filter(line => line.trim());
@@ -28,6 +29,9 @@ function parsePsOutput(output: string): { Titles: string[]; Processes: string[][
}
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const invalid = validateDockerIdParam(params.id, 'container');
if (invalid) return invalid;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
@@ -3,9 +3,13 @@ import type { RequestHandler } from './$types';
import { unpauseContainer, inspectContainer } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { auditContainer } from '$lib/server/audit';
import { validateDockerIdParam } from '$lib/server/docker-validation';
export const POST: RequestHandler = async (event) => {
const { params, url, cookies } = event;
const invalid = validateDockerIdParam(params.id, 'container');
if (invalid) return invalid;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
@@ -3,10 +3,14 @@ import { pullImage, updateContainer, type CreateContainerOptions } from '$lib/se
import { authorize } from '$lib/server/authorize';
import { auditContainer } from '$lib/server/audit';
import { removePendingContainerUpdate } from '$lib/server/db';
import { validateDockerIdParam } from '$lib/server/docker-validation';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async (event) => {
const { params, request, url, cookies } = event;
const invalid = validateDockerIdParam(params.id, 'container');
if (invalid) return invalid;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
@@ -273,7 +273,6 @@ export const POST: RequestHandler = async (event) => {
let scanBlocked = false;
let blockReason = '';
let finalScanResult: ScanResult | undefined;
let individualScannerResults: ScannerResult[] = [];
try {
@@ -290,17 +289,7 @@ export const POST: RequestHandler = async (event) => {
});
if (scanResults.length > 0) {
const scanSummary = combineScanSummaries(scanResults);
finalScanResult = {
critical: scanSummary.critical,
high: scanSummary.high,
medium: scanSummary.medium,
low: scanSummary.low,
negligible: scanSummary.negligible,
unknown: scanSummary.unknown
};
// Build individual scanner results
// Build individual scanner results (used by frontend)
individualScannerResults = scanResults.map(result => ({
scanner: result.scanner as 'grype' | 'trivy',
critical: result.summary.critical,
@@ -333,8 +322,9 @@ export const POST: RequestHandler = async (event) => {
} catch { /* ignore save errors */ }
}
// Check if blocked
const { blocked, reason } = shouldBlockUpdate(vulnerabilityCriteria, scanSummary, undefined);
// Check if blocked (combineScanSummaries uses Math.max for security check)
const combinedForBlockCheck = combineScanSummaries(scanResults);
const { blocked, reason } = shouldBlockUpdate(vulnerabilityCriteria, combinedForBlockCheck, undefined);
if (blocked) {
scanBlocked = true;
blockReason = reason;
@@ -355,15 +345,21 @@ export const POST: RequestHandler = async (event) => {
scanner: v.scanner
}));
// Build scan message from individual results
const totalCritical = individualScannerResults.reduce((s, r) => s + r.critical, 0);
const totalHigh = individualScannerResults.reduce((s, r) => s + r.high, 0);
const totalMedium = individualScannerResults.reduce((s, r) => s + r.medium, 0);
const totalLow = individualScannerResults.reduce((s, r) => s + r.low, 0);
const hasVulns = totalCritical + totalHigh + totalMedium + totalLow > 0;
sendData({
type: 'scan_complete',
containerId,
containerName,
scanResult: finalScanResult,
scannerResults: individualScannerResults.length > 0 ? individualScannerResults : undefined,
vulnerabilities: vulnerabilities.length > 0 ? vulnerabilities : undefined,
message: finalScanResult
? `Scan complete: ${finalScanResult.critical} critical, ${finalScanResult.high} high, ${finalScanResult.medium} medium, ${finalScanResult.low} low`
message: hasVulns
? `Scan complete: ${totalCritical} critical, ${totalHigh} high, ${totalMedium} medium, ${totalLow} low`
: 'Scan complete: no vulnerabilities found'
});
@@ -398,7 +394,6 @@ export const POST: RequestHandler = async (event) => {
current: i + 1,
total: containerIds.length,
success: false,
scanResult: finalScanResult,
scannerResults: individualScannerResults.length > 0 ? individualScannerResults : undefined,
blockReason,
message: `Update blocked: ${blockReason}`
+62 -25
View File
@@ -1,26 +1,59 @@
import { json, type RequestHandler } from '@sveltejs/kit';
import { getDashboardPreferences, saveDashboardPreferences } from '$lib/server/db';
import { getUserPreference, setUserPreference } from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
// Store all dashboard prefs as a single JSON blob to avoid the chained .where() bug
// in getUserPreference/setUserPreference (chained .where() replaces instead of ANDing)
const DASHBOARD_PREFS_KEY = 'dashboard_prefs';
interface StoredDashboardPrefs {
gridLayout: any[];
locked: boolean;
viewMode: 'grid' | 'list';
}
async function getPrefs(userId: number | null): Promise<StoredDashboardPrefs> {
const stored = await getUserPreference<StoredDashboardPrefs>({
userId,
environmentId: null,
key: DASHBOARD_PREFS_KEY
});
if (stored && typeof stored === 'object' && Array.isArray(stored.gridLayout)) {
return {
gridLayout: stored.gridLayout,
locked: stored.locked ?? false,
viewMode: stored.viewMode ?? 'grid'
};
}
// Migration: try reading from old dashboard_layout key
const oldLayout = await getUserPreference<any[]>({
userId,
environmentId: null,
key: 'dashboard_layout'
});
return {
gridLayout: Array.isArray(oldLayout) ? oldLayout : [],
locked: false,
viewMode: 'grid'
};
}
async function savePrefs(userId: number | null, prefs: StoredDashboardPrefs): Promise<void> {
await setUserPreference(
{ userId, environmentId: null, key: DASHBOARD_PREFS_KEY },
prefs
);
}
export const GET: RequestHandler = async ({ cookies }) => {
const auth = await authorize(cookies);
try {
// Get user-specific preferences, or fall back to global preferences
const userId = auth.user?.id ?? null;
const prefs = await getDashboardPreferences(userId);
// If no preferences exist, return empty gridLayout
if (!prefs) {
return json({
id: 0,
userId: null,
gridLayout: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
});
}
const prefs = await getPrefs(userId);
return json(prefs);
} catch (error) {
console.error('Failed to get dashboard preferences:', error);
@@ -33,19 +66,23 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
try {
const body = await request.json();
const { gridLayout } = body;
const userId = auth.user?.id ?? null;
if (!gridLayout || !Array.isArray(gridLayout)) {
return json({ error: 'gridLayout is required and must be an array' }, { status: 400 });
// Load current prefs and merge changes
const current = await getPrefs(userId);
if (body.gridLayout && Array.isArray(body.gridLayout)) {
current.gridLayout = body.gridLayout;
}
if (body.locked !== undefined) {
current.locked = body.locked;
}
if (body.viewMode !== undefined) {
current.viewMode = body.viewMode;
}
const userId = auth.user?.id ?? null;
const prefs = await saveDashboardPreferences({
userId,
gridLayout
});
return json(prefs);
await savePrefs(userId, current);
return json(current);
} catch (error) {
console.error('Failed to save dashboard preferences:', error);
return json({ error: 'Failed to save dashboard preferences' }, { status: 500 });
+8 -2
View File
@@ -1,17 +1,23 @@
import { json, type RequestHandler } from '@sveltejs/kit';
import dependencies from '$lib/data/dependencies.json';
import { DEFAULT_GRYPE_IMAGE, DEFAULT_TRIVY_IMAGE } from '$lib/server/scanner';
// Extract version tag from image string (e.g., "anchore/grype:v0.110.0" -> "v0.110.0")
function imageTag(image: string): string {
return image.split(':')[1] || 'latest';
}
// External tools used by Dockhand (Docker images)
const externalTools = [
{
name: 'anchore/grype',
version: 'latest',
version: imageTag(DEFAULT_GRYPE_IMAGE),
license: 'Apache-2.0',
repository: 'https://github.com/anchore/grype'
},
{
name: 'aquasec/trivy',
version: 'latest',
version: imageTag(DEFAULT_TRIVY_IMAGE),
license: 'Apache-2.0',
repository: 'https://github.com/aquasecurity/trivy'
}
@@ -11,6 +11,7 @@ import { cleanPem } from '$lib/utils/pem';
import { unregisterSchedule } from '$lib/server/scheduler';
import { closeEdgeConnection } from '$lib/server/hawser';
import { computeAuditDiff } from '$lib/utils/diff';
import { deleteEnvironmentIcon } from '$lib/server/env-icons';
export const GET: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
@@ -167,6 +168,9 @@ export const DELETE: RequestHandler = async (event) => {
return json({ error: 'Cannot delete this environment' }, { status: 400 });
}
// Clean up custom icon file if exists
deleteEnvironmentIcon(id);
// Clean up public IP entry for this environment
await deleteEnvironmentPublicIp(id);
@@ -0,0 +1,68 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getEnvironment, updateEnvironment } from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
import { saveEnvironmentIcon, deleteEnvironmentIcon, getEnvironmentIconBuffer } from '$lib/server/env-icons';
export const GET: RequestHandler = async ({ params }) => {
const id = parseInt(params.id);
const buffer = getEnvironmentIconBuffer(id);
if (!buffer) {
return json({ error: 'No custom icon' }, { status: 404 });
}
return new Response(new Uint8Array(buffer), {
headers: {
'Content-Type': 'image/webp',
'Cache-Control': 'public, max-age=3600'
}
});
};
export const POST: RequestHandler = async ({ params, request, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('environments', 'edit')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const id = parseInt(params.id);
const env = await getEnvironment(id);
if (!env) {
return json({ error: 'Environment not found' }, { status: 404 });
}
const data = await request.json();
if (!data.image || typeof data.image !== 'string') {
return json({ error: 'Missing image data' }, { status: 400 });
}
// Validate size (~200KB base64 limit)
if (data.image.length > 300_000) {
return json({ error: 'Image too large' }, { status: 400 });
}
saveEnvironmentIcon(id, data.image);
const iconValue = `custom:env-${id}.webp`;
await updateEnvironment(id, { icon: iconValue });
return json({ success: true, icon: iconValue });
};
export const DELETE: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('environments', 'edit')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const id = parseInt(params.id);
const env = await getEnvironment(id);
if (!env) {
return json({ error: 'Environment not found' }, { status: 404 });
}
deleteEnvironmentIcon(id);
await updateEnvironment(id, { icon: 'globe' });
return json({ success: true, icon: 'globe' });
};
@@ -13,7 +13,23 @@ const TIMEZONE_ALIASES: Record<string, string> = {
'Europe/Kyiv': 'Europe/Kiev',
'Asia/Ho_Chi_Minh': 'Asia/Saigon',
'America/Nuuk': 'America/Godthab',
'Pacific/Kanton': 'Pacific/Enderbury'
'Pacific/Kanton': 'Pacific/Enderbury',
// Modern IANA names that Node.js ICU maps to legacy names
'Asia/Kolkata': 'Asia/Calcutta',
'Asia/Kathmandu': 'Asia/Katmandu',
'Asia/Yangon': 'Asia/Rangoon',
'Asia/Kashgar': 'Asia/Urumqi',
'Atlantic/Faroe': 'Atlantic/Faeroe',
'Europe/Uzhgorod': 'Europe/Kiev',
'Europe/Zaporozhye': 'Europe/Kiev',
'America/Atikokan': 'America/Coral_Harbour',
'America/Argentina/Buenos_Aires': 'America/Buenos_Aires',
'America/Argentina/Catamarca': 'America/Catamarca',
'America/Argentina/Cordoba': 'America/Cordoba',
'America/Argentina/Jujuy': 'America/Jujuy',
'America/Argentina/Mendoza': 'America/Mendoza',
'Pacific/Pohnpei': 'Pacific/Ponape',
'Pacific/Chuuk': 'Pacific/Truk'
};
function normalizeTimezone(tz: string): string {
@@ -4,9 +4,10 @@ import {
getGitRepository,
updateGitRepository,
deleteGitRepository,
getGitCredentials
getGitCredentials,
getGitStacksByRepositoryId
} from '$lib/server/db';
import { deleteRepositoryFiles } from '$lib/server/git';
import { deleteRepositoryFiles, deleteGitStackFiles } from '$lib/server/git';
import { authorize } from '$lib/server/authorize';
import { auditGitRepository } from '$lib/server/audit';
import { computeAuditDiff } from '$lib/utils/diff';
@@ -112,7 +113,13 @@ export const DELETE: RequestHandler = async (event) => {
return json({ error: 'Repository not found' }, { status: 404 });
}
// Delete repository files first
// Delete git stack clone directories before cascade deletes the DB rows
const stacks = await getGitStacksByRepositoryId(id);
for (const stack of stacks) {
await deleteGitStackFiles(stack.id, stack.stackName, stack.environmentId);
}
// Delete repository clone directory
deleteRepositoryFiles(id);
const deleted = await deleteGitRepository(id);
@@ -17,10 +17,10 @@ function verifySignature(payload: string, signature: string | null, secret: stri
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
);
const sigBuf = Buffer.from(signature);
const expectedBuf = Buffer.from(expectedSignature);
if (sigBuf.length !== expectedBuf.length) return false;
return crypto.timingSafeEqual(sigBuf, expectedBuf);
}
// GitLab uses X-Gitlab-Token which should match exactly
+4 -4
View File
@@ -17,10 +17,10 @@ function verifySignature(payload: string, signature: string | null, secret: stri
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
);
const sigBuf = Buffer.from(signature);
const expectedBuf = Buffer.from(expectedSignature);
if (sigBuf.length !== expectedBuf.length) return false;
return crypto.timingSafeEqual(sigBuf, expectedBuf);
}
// GitLab uses X-Gitlab-Token which should match exactly
+4
View File
@@ -2,10 +2,14 @@ import { json } from '@sveltejs/kit';
import { removeImage, inspectImage } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { auditImage } from '$lib/server/audit';
import { validateDockerIdParam } from '$lib/server/docker-validation';
import type { RequestHandler } from './$types';
export const DELETE: RequestHandler = async (event) => {
const { params, url, cookies } = event;
const invalid = validateDockerIdParam(params.id, 'image');
if (invalid) return invalid;
const auth = await authorize(cookies);
const force = url.searchParams.get('force') === 'true';
@@ -3,9 +3,13 @@ import { exportImage, inspectImage } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { createGzip } from 'zlib';
import { Readable } from 'stream';
import { validateDockerIdParam } from '$lib/server/docker-validation';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const invalid = validateDockerIdParam(params.id, 'image');
if (invalid) return invalid;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
@@ -2,8 +2,12 @@ import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getImageHistory } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { validateDockerIdParam } from '$lib/server/docker-validation';
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const invalid = validateDockerIdParam(params.id, 'image');
if (invalid) return invalid;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
@@ -1,9 +1,13 @@
import { json } from '@sveltejs/kit';
import { tagImage } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { validateDockerIdParam } from '$lib/server/docker-validation';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ params, request, url, cookies }) => {
const invalid = validateDockerIdParam(params.id, 'image');
if (invalid) return invalid;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
+3 -1
View File
@@ -10,7 +10,9 @@ export const GET: RequestHandler = async ({ url }) => {
// Return as plain text if requested
if (url.searchParams.get('format') === 'text') {
return text(content);
return text(content, {
headers: { 'content-type': 'text/plain; charset=utf-8' }
});
}
return json({ content });
+3 -1
View File
@@ -10,7 +10,9 @@ export const GET: RequestHandler = async ({ url }) => {
// Return as plain text if requested
if (url.searchParams.get('format') === 'text') {
return text(content);
return text(content, {
headers: { 'content-type': 'text/plain; charset=utf-8' }
});
}
return json({ content });
+7
View File
@@ -3,8 +3,12 @@ import type { RequestHandler } from './$types';
import { removeNetwork, inspectNetwork } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { auditNetwork } from '$lib/server/audit';
import { validateDockerIdParam } from '$lib/server/docker-validation';
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const invalid = validateDockerIdParam(params.id, 'network');
if (invalid) return invalid;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
@@ -32,6 +36,9 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
export const DELETE: RequestHandler = async (event) => {
const { params, url, cookies } = event;
const invalid = validateDockerIdParam(params.id, 'network');
if (invalid) return invalid;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
@@ -3,9 +3,13 @@ import type { RequestHandler } from './$types';
import { connectContainerToNetwork, inspectNetwork } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { auditNetwork } from '$lib/server/audit';
import { validateDockerIdParam } from '$lib/server/docker-validation';
export const POST: RequestHandler = async (event) => {
const { params, url, request, cookies } = event;
const invalid = validateDockerIdParam(params.id, 'network');
if (invalid) return invalid;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
@@ -25,6 +29,9 @@ export const POST: RequestHandler = async (event) => {
return json({ error: 'Container ID is required' }, { status: 400 });
}
const invalidContainer = validateDockerIdParam(containerId, 'container');
if (invalidContainer) return invalidContainer;
// Get network name for audit
let networkName = params.id;
try {
@@ -3,9 +3,13 @@ import type { RequestHandler } from './$types';
import { disconnectContainerFromNetwork, inspectNetwork } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { auditNetwork } from '$lib/server/audit';
import { validateDockerIdParam } from '$lib/server/docker-validation';
export const POST: RequestHandler = async (event) => {
const { params, url, request, cookies } = event;
const invalid = validateDockerIdParam(params.id, 'network');
if (invalid) return invalid;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
@@ -25,6 +29,9 @@ export const POST: RequestHandler = async (event) => {
return json({ error: 'Container ID is required' }, { status: 400 });
}
const invalidContainer = validateDockerIdParam(containerId, 'container');
if (invalidContainer) return invalidContainer;
// Get network name for audit
let networkName = params.id;
try {
@@ -2,8 +2,12 @@ import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { inspectNetwork } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { validateDockerIdParam } from '$lib/server/docker-validation';
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const invalid = validateDockerIdParam(params.id, 'network');
if (invalid) return invalid;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
+16 -11
View File
@@ -371,19 +371,24 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
...networkEnvVars
];
// Pass Docker API version so the updater CLI speaks a compatible version.
// Without this, newer CLI versions (e.g. API 1.53) fail against older
// daemons (e.g. Synology DSM shipping API 1.43).
const dockerApiVersion = process.env.DOCKER_API_VERSION;
if (dockerApiVersion) {
updaterEnv.push(`DOCKER_API_VERSION=${dockerApiVersion}`);
// Pin Docker API version so the updater's bundled Docker CLI
// doesn't request a version newer than the host daemon supports
// (e.g. Synology DSM with Docker 24.x / API 1.43)
if (process.env.DOCKER_API_VERSION) {
updaterEnv.push(`DOCKER_API_VERSION=${process.env.DOCKER_API_VERSION}`);
console.log(`[SelfUpdate] Forwarding explicit DOCKER_API_VERSION: ${process.env.DOCKER_API_VERSION}`);
} else {
const versionRes = await localDockerFetch('/version');
if (versionRes.ok) {
const vInfo = await versionRes.json() as { ApiVersion?: string };
if (vInfo.ApiVersion) {
updaterEnv.push(`DOCKER_API_VERSION=${vInfo.ApiVersion}`);
try {
const versionResp = await localDockerFetch('/version');
if (versionResp.ok) {
const versionInfo = await versionResp.json() as { ApiVersion?: string };
if (versionInfo.ApiVersion) {
updaterEnv.push(`DOCKER_API_VERSION=${versionInfo.ApiVersion}`);
console.log(`[SelfUpdate] Using negotiated Docker API version: ${versionInfo.ApiVersion}`);
}
}
} catch {
console.warn('[SelfUpdate] Could not detect Docker API version, updater will negotiate on its own');
}
}
@@ -35,6 +35,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
const containerId = getOwnContainerId();
if (!containerId) {
console.log('[SelfUpdate] Not running in Docker, skipping update check');
return json({
updateAvailable: false,
error: 'Not running in Docker'
@@ -45,6 +46,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
// Inspect own container to get current image info
const inspectResponse = await localDockerFetch(`/containers/${containerId}/json`);
if (!inspectResponse.ok) {
console.log(`[SelfUpdate] Failed to inspect container ${containerId.substring(0, 12)}: ${inspectResponse.status}`);
return json({
updateAvailable: false,
error: 'Failed to inspect own container'
@@ -61,7 +63,10 @@ export const GET: RequestHandler = async ({ cookies }) => {
const currentImageId = inspectData.Image || '';
const containerName = inspectData.Name?.replace(/^\//, '') || '';
console.log(`[SelfUpdate] Container: ${containerId.substring(0, 12)}, image: ${currentImage}, tag: ${currentImage.split(':').pop() || 'latest'}`);
if (!currentImage) {
console.log('[SelfUpdate] Could not determine current image from inspect data');
return json({
updateAvailable: false,
error: 'Could not determine current image'
@@ -73,6 +78,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
// Digest-based images (e.g. image@sha256:...) can't be checked for updates
if (currentImage.includes('@sha256:')) {
console.log('[SelfUpdate] Image pinned by digest, cannot check for updates');
return json({
updateAvailable: false,
currentImage,
@@ -94,6 +100,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
// Version-based check: compare against latest released version from changelog
const currentTagVersion = versionMatch[1];
const suffix = versionMatch[2] || ''; // '-baseline' or ''
console.log(`[SelfUpdate] Version-based check: current=${currentTagVersion}${suffix}`);
try {
const changelogResponse = await fetch(
@@ -102,6 +109,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
);
if (!changelogResponse.ok) {
console.log(`[SelfUpdate] Failed to fetch changelog from GitHub: ${changelogResponse.status}`);
return json({
updateAvailable: false,
currentImage,
@@ -122,6 +130,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
const latestRelease = changelog.find(entry => !entry.comingSoon);
if (!latestRelease) {
console.log('[SelfUpdate] No released version found in changelog');
return json({
updateAvailable: false,
currentImage,
@@ -133,12 +142,14 @@ export const GET: RequestHandler = async ({ cookies }) => {
const latestVersion = latestRelease.version;
const hasNewer = compareVersions(latestVersion, currentTagVersion) > 0;
console.log(`[SelfUpdate] Latest changelog version: ${latestVersion}, current: ${currentTagVersion}, hasNewer: ${hasNewer}`);
if (hasNewer) {
// Build new image tag preserving registry prefix and suffix
const newTag = `v${latestVersion.replace(/^v/, '')}${suffix}`;
const newImage = `${imageWithoutTag}:${newTag}`;
console.log(`[SelfUpdate] Update available: ${currentImage} → ${newImage}`);
return json({
updateAvailable: true,
currentImage,
@@ -149,6 +160,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
});
}
console.log(`[SelfUpdate] Up to date (version ${currentTagVersion})`);
return json({
updateAvailable: false,
currentImage,
@@ -156,6 +168,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
isComposeManaged
});
} catch (err) {
console.log(`[SelfUpdate] Version check failed: ${err}`);
return json({
updateAvailable: false,
currentImage,
@@ -167,10 +180,12 @@ export const GET: RequestHandler = async ({ cookies }) => {
}
// Digest-based check for mutable tags (:latest, :baseline, etc.)
console.log(`[SelfUpdate] Digest-based check for mutable tag: ${tag}`);
// Inspect image via local Docker socket to get RepoDigests
const imageResponse = await localDockerFetch(`/images/${encodeURIComponent(currentImageId)}/json`);
if (!imageResponse.ok) {
console.log(`[SelfUpdate] Failed to inspect image ${currentImageId}: ${imageResponse.status}`);
return json({
updateAvailable: false,
currentImage,
@@ -192,6 +207,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
.filter(Boolean) as string[];
if (localDigests.length === 0) {
console.log('[SelfUpdate] No RepoDigests found — local/untagged image, cannot check registry');
return json({
updateAvailable: false,
currentImage,
@@ -202,9 +218,12 @@ export const GET: RequestHandler = async ({ cookies }) => {
});
}
console.log(`[SelfUpdate] Local digests: ${localDigests.map(d => d.substring(0, 19)).join(', ')}`);
// Query registry for latest digest
const registryDigest = await getRegistryManifestDigest(currentImage);
if (!registryDigest) {
console.log(`[SelfUpdate] Could not query registry for ${currentImage}`);
return json({
updateAvailable: false,
currentImage,
@@ -216,6 +235,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
}
const hasUpdate = !localDigests.includes(registryDigest);
console.log(`[SelfUpdate] Registry digest: ${registryDigest.substring(0, 19)}, match: ${!hasUpdate}, updateAvailable: ${hasUpdate}`);
return json({
updateAvailable: hasUpdate,
@@ -227,6 +247,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
isComposeManaged
});
} catch (err) {
console.log(`[SelfUpdate] Check failed with error: ${err}`);
return json({
updateAvailable: false,
error: 'Check failed: ' + String(err)
+46 -8
View File
@@ -30,6 +30,7 @@ import {
import { authorize } from '$lib/server/authorize';
import { refreshSystemJobs } from '$lib/server/scheduler';
import { sendToEventSubprocess, sendToMetricsSubprocess } from '$lib/server/subprocess-manager';
import { DEFAULT_GRYPE_IMAGE, DEFAULT_TRIVY_IMAGE } from '$lib/server/scanner';
export type TimeFormat = '12h' | '24h';
export type DateFormat = 'MM/DD/YYYY' | 'DD/MM/YYYY' | 'YYYY-MM-DD' | 'DD.MM.YYYY';
@@ -67,10 +68,15 @@ export interface GeneralSettings {
editorFont: string;
// Compact ports
compactPorts: boolean;
// Log timestamp formatting
formatLogTimestamps: boolean;
// External stack paths
externalStackPaths: string[];
// Primary stack location
primaryStackLocation: string | null;
// Scanner images
defaultGrypeImage: string;
defaultTrivyImage: string;
}
const DEFAULT_SETTINGS: Omit<GeneralSettings, 'scheduleRetentionDays' | 'eventRetentionDays' | 'scheduleCleanupCron' | 'eventCleanupCron' | 'scheduleCleanupEnabled' | 'eventCleanupEnabled'> = {
@@ -88,13 +94,18 @@ const DEFAULT_SETTINGS: Omit<GeneralSettings, 'scheduleRetentionDays' | 'eventRe
eventPollInterval: 60000,
metricsCollectionInterval: 30000,
compactPorts: false,
formatLogTimestamps: false,
lightTheme: 'default',
darkTheme: 'default',
font: 'system',
fontSize: 'normal',
gridFontSize: 'normal',
terminalFont: 'system-mono',
editorFont: 'system-mono'
editorFont: 'system-mono',
externalStackPaths: [],
primaryStackLocation: null,
defaultGrypeImage: DEFAULT_GRYPE_IMAGE,
defaultTrivyImage: DEFAULT_TRIVY_IMAGE
};
const VALID_LIGHT_THEMES = ['default', 'catppuccin', 'rose-pine', 'nord', 'solarized', 'gruvbox', 'alucard', 'github', 'material', 'atom-one'];
@@ -144,8 +155,11 @@ export const GET: RequestHandler = async ({ cookies }) => {
terminalFont,
editorFont,
compactPorts,
formatLogTimestamps,
externalStackPaths,
primaryStackLocation
primaryStackLocation,
defaultGrypeImage,
defaultTrivyImage
] = await Promise.all([
getSetting('confirm_destructive'),
getSetting('show_stopped_containers'),
@@ -174,8 +188,11 @@ export const GET: RequestHandler = async ({ cookies }) => {
getSetting('theme_terminal_font'),
getSetting('theme_editor_font'),
getSetting('compact_ports'),
getSetting('format_log_timestamps'),
getExternalStackPaths(),
getPrimaryStackLocation()
getPrimaryStackLocation(),
getSetting('default_grype_image'),
getSetting('default_trivy_image')
]);
const settings: GeneralSettings = {
@@ -206,8 +223,11 @@ export const GET: RequestHandler = async ({ cookies }) => {
terminalFont: terminalFont ?? DEFAULT_SETTINGS.terminalFont,
editorFont: editorFont ?? DEFAULT_SETTINGS.editorFont,
compactPorts: compactPorts ?? DEFAULT_SETTINGS.compactPorts,
formatLogTimestamps: formatLogTimestamps ?? DEFAULT_SETTINGS.formatLogTimestamps,
externalStackPaths,
primaryStackLocation
primaryStackLocation,
defaultGrypeImage: defaultGrypeImage ?? DEFAULT_GRYPE_IMAGE,
defaultTrivyImage: defaultTrivyImage ?? DEFAULT_TRIVY_IMAGE
};
return json(settings);
@@ -225,7 +245,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, logBufferSizeKb, defaultTimezone, eventCollectionMode, eventPollInterval, metricsCollectionInterval, lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont, compactPorts, externalStackPaths, primaryStackLocation } = body;
const { confirmDestructive, showStoppedContainers, highlightUpdates, timeFormat, dateFormat, downloadFormat, defaultGrypeArgs, defaultTrivyArgs, scheduleRetentionDays, eventRetentionDays, scheduleCleanupCron, eventCleanupCron, scheduleCleanupEnabled, eventCleanupEnabled, logBufferSizeKb, defaultTimezone, eventCollectionMode, eventPollInterval, metricsCollectionInterval, lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont, compactPorts, formatLogTimestamps, externalStackPaths, primaryStackLocation, defaultGrypeImage, defaultTrivyImage } = body;
if (confirmDestructive !== undefined) {
await setSetting('confirm_destructive', confirmDestructive);
@@ -321,6 +341,9 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
if (compactPorts !== undefined) {
await setSetting('compact_ports', compactPorts);
}
if (formatLogTimestamps !== undefined) {
await setSetting('format_log_timestamps', formatLogTimestamps);
}
if (externalStackPaths !== undefined && Array.isArray(externalStackPaths)) {
// Filter to valid non-empty strings
const validPaths = externalStackPaths.filter((p: unknown) => typeof p === 'string' && p.trim());
@@ -335,6 +358,12 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
await setPrimaryStackLocation(null);
}
}
if (defaultGrypeImage !== undefined && typeof defaultGrypeImage === 'string') {
await setSetting('default_grype_image', defaultGrypeImage);
}
if (defaultTrivyImage !== undefined && typeof defaultTrivyImage === 'string') {
await setSetting('default_trivy_image', defaultTrivyImage);
}
// Fetch all settings in parallel for the response
const [
@@ -365,8 +394,11 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
terminalFontVal,
editorFontVal,
compactPortsVal,
formatLogTimestampsVal,
externalStackPathsVal,
primaryStackLocationVal
primaryStackLocationVal,
defaultGrypeImageVal,
defaultTrivyImageVal
] = await Promise.all([
getSetting('confirm_destructive'),
getSetting('show_stopped_containers'),
@@ -395,8 +427,11 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
getSetting('theme_terminal_font'),
getSetting('theme_editor_font'),
getSetting('compact_ports'),
getSetting('format_log_timestamps'),
getExternalStackPaths(),
getPrimaryStackLocation()
getPrimaryStackLocation(),
getSetting('default_grype_image'),
getSetting('default_trivy_image')
]);
const settings: GeneralSettings = {
@@ -427,8 +462,11 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
terminalFont: terminalFontVal ?? DEFAULT_SETTINGS.terminalFont,
editorFont: editorFontVal ?? DEFAULT_SETTINGS.editorFont,
compactPorts: compactPortsVal ?? DEFAULT_SETTINGS.compactPorts,
formatLogTimestamps: formatLogTimestampsVal ?? DEFAULT_SETTINGS.formatLogTimestamps,
externalStackPaths: externalStackPathsVal,
primaryStackLocation: primaryStackLocationVal
primaryStackLocation: primaryStackLocationVal,
defaultGrypeImage: defaultGrypeImageVal ?? DEFAULT_GRYPE_IMAGE,
defaultTrivyImage: defaultTrivyImageVal ?? DEFAULT_TRIVY_IMAGE
};
return json(settings);
+20 -5
View File
@@ -1,5 +1,5 @@
import { json, type RequestHandler } from '@sveltejs/kit';
import { getEnvSetting, setEnvSetting, getEnvironment } from '$lib/server/db';
import { getEnvSetting, setEnvSetting, getEnvironment, setSetting } from '$lib/server/db';
import {
checkScannerAvailability,
getScannerVersions,
@@ -15,6 +15,8 @@ export interface ScannerSettings {
scanner: ScannerType;
grypeArgs: string;
trivyArgs: string;
grypeImage: string;
trivyImage: string;
}
export const GET: RequestHandler = async ({ url, cookies }) => {
@@ -39,7 +41,9 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
const settings: ScannerSettings = {
scanner: await getEnvSetting('vulnerability_scanner', parsedEnvId) || 'none',
grypeArgs: await getEnvSetting('grype_cli_args', parsedEnvId) || globalDefaults.grypeArgs,
trivyArgs: await getEnvSetting('trivy_cli_args', parsedEnvId) || globalDefaults.trivyArgs
trivyArgs: await getEnvSetting('trivy_cli_args', parsedEnvId) || globalDefaults.trivyArgs,
grypeImage: globalDefaults.grypeImage,
trivyImage: globalDefaults.trivyImage
};
// Fast path: return just settings without Docker checks
@@ -80,7 +84,7 @@ export const POST: RequestHandler = async ({ request, url, cookies }) => {
try {
const body = await request.json();
const { scanner, grypeArgs, trivyArgs, envId } = body;
const { scanner, grypeArgs, trivyArgs, grypeImage, trivyImage, envId } = body;
const parsedEnvId = envId ? parseInt(envId) : undefined;
// Permission check with environment context
@@ -104,6 +108,12 @@ export const POST: RequestHandler = async ({ request, url, cookies }) => {
if (trivyArgs !== undefined) {
await setEnvSetting('trivy_cli_args', trivyArgs, parsedEnvId);
}
if (grypeImage !== undefined && typeof grypeImage === 'string') {
await setSetting('default_grype_image', grypeImage);
}
if (trivyImage !== undefined && typeof trivyImage === 'string') {
await setSetting('default_trivy_image', trivyImage);
}
// Get global defaults for fallback
const globalDefaults = await getGlobalScannerDefaults();
@@ -113,7 +123,9 @@ export const POST: RequestHandler = async ({ request, url, cookies }) => {
settings: {
scanner: await getEnvSetting('vulnerability_scanner', parsedEnvId) || 'none',
grypeArgs: await getEnvSetting('grype_cli_args', parsedEnvId) || globalDefaults.grypeArgs,
trivyArgs: await getEnvSetting('trivy_cli_args', parsedEnvId) || globalDefaults.trivyArgs
trivyArgs: await getEnvSetting('trivy_cli_args', parsedEnvId) || globalDefaults.trivyArgs,
grypeImage: globalDefaults.grypeImage,
trivyImage: globalDefaults.trivyImage
}
});
} catch (error) {
@@ -154,6 +166,9 @@ export const DELETE: RequestHandler = async ({ url, cookies }) => {
const removed: string[] = [];
const errors: string[] = [];
// Get configured scanner images
const globalDefaults = await getGlobalScannerDefaults();
// Determine which images to remove
const scannersToRemove: ('grype' | 'trivy')[] =
scanner === 'grype' ? ['grype'] :
@@ -161,7 +176,7 @@ export const DELETE: RequestHandler = async ({ url, cookies }) => {
['grype', 'trivy'];
for (const scannerType of scannersToRemove) {
const imageName = scannerType === 'grype' ? 'anchore/grype' : 'aquasec/trivy';
const imageName = scannerType === 'grype' ? globalDefaults.grypeImage.split(':')[0] : globalDefaults.trivyImage.split(':')[0];
// Find the image
const image = images.find((img) =>
+45 -2
View File
@@ -1,7 +1,7 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { readdirSync, statSync, existsSync } from 'node:fs';
import { join, basename } from 'node:path';
import { readdirSync, statSync, existsSync, mkdirSync } from 'node:fs';
import { join, basename, isAbsolute } from 'node:path';
import { authorize } from '$lib/server/authorize';
export interface FileEntry {
@@ -13,6 +13,49 @@ export interface FileEntry {
mode: string;
}
/**
* POST /api/system/files
* Create a directory
*
* Body: { path: string }
*/
export const POST: RequestHandler = async ({ request, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('stacks', 'edit')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const body = await request.json();
const path = body.path;
if (!path || typeof path !== 'string') {
return json({ error: 'Path is required' }, { status: 400 });
}
if (!isAbsolute(path)) {
return json({ error: 'Path must be absolute' }, { status: 400 });
}
if (path.includes('..')) {
return json({ error: 'Path must not contain ..' }, { status: 400 });
}
if (existsSync(path)) {
return json({ error: 'Path already exists' }, { status: 409 });
}
mkdirSync(path, { recursive: true });
return json({ success: true, path });
} catch (error) {
console.error('Error creating directory:', error);
const message = error instanceof Error ? error.message : 'Unknown error';
return json({ error: `Failed to create directory: ${message}` }, { status: 500 });
}
};
/**
* GET /api/system/files
* Browse Dockhand's local filesystem (for mount browsing)
+7
View File
@@ -3,8 +3,12 @@ import type { RequestHandler } from './$types';
import { removeVolume, inspectVolume } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { auditVolume } from '$lib/server/audit';
import { validateDockerIdParam } from '$lib/server/docker-validation';
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const invalid = validateDockerIdParam(params.name, 'volume');
if (invalid) return invalid;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
@@ -33,6 +37,9 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
export const DELETE: RequestHandler = async (event) => {
const { params, url, cookies } = event;
const invalid = validateDockerIdParam(params.name, 'volume');
if (invalid) return invalid;
const auth = await authorize(cookies);
const force = url.searchParams.get('force') === 'true';
@@ -2,8 +2,12 @@ import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { listVolumeDirectory, getVolumeUsage } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { validateDockerIdParam } from '$lib/server/docker-validation';
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const invalid = validateDockerIdParam(params.name, 'volume');
if (invalid) return invalid;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
@@ -2,11 +2,15 @@ import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { readVolumeFile } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { validateDockerIdParam } from '$lib/server/docker-validation';
// Max file size for reading (1MB)
const MAX_FILE_SIZE = 1024 * 1024;
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const invalid = validateDockerIdParam(params.name, 'volume');
if (invalid) return invalid;
const auth = await authorize(cookies);
const path = url.searchParams.get('path');
@@ -2,12 +2,16 @@ import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { releaseVolumeHelperContainer } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { validateDockerIdParam } from '$lib/server/docker-validation';
/**
* Release the cached volume helper container when done browsing.
* This is called when the volume browser modal is closed.
*/
export const POST: RequestHandler = async ({ params, url, cookies }) => {
const invalid = validateDockerIdParam(params.name, 'volume');
if (invalid) return invalid;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
+48 -1
View File
@@ -1,11 +1,15 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { inspectVolume, createVolume, type CreateVolumeOptions } from '$lib/server/docker';
import { inspectVolume, createVolume, type CreateVolumeOptions, ensureVolumeHelperImage, dockerFetch, dockerJsonRequest, drainResponse } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { auditVolume } from '$lib/server/audit';
import { validateDockerIdParam } from '$lib/server/docker-validation';
export const POST: RequestHandler = async (event) => {
const { params, url, request, cookies } = event;
const invalid = validateDockerIdParam(params.name, 'volume');
if (invalid) return invalid;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
@@ -38,6 +42,49 @@ export const POST: RequestHandler = async (event) => {
const newVolume = await createVolume(options, envIdNum);
// Copy data from source to destination using a temporary busybox container
// Mount source read-only at /src and destination read-write at /dst
await ensureVolumeHelperImage(envIdNum);
const containerName = `dockhand-clone-${Date.now().toString(36)}`;
const containerConfig = {
Image: 'busybox:latest',
Cmd: ['cp', '-a', '/src/.', '/dst/'],
HostConfig: {
Binds: [
`${params.name}:/src:ro`,
`${newName}:/dst`
],
AutoRemove: false
},
Labels: { 'dockhand.volume.helper': 'true' }
};
let copyCtrId: string | undefined;
try {
const createRes = await dockerJsonRequest<{ Id: string }>(
`/containers/create?name=${encodeURIComponent(containerName)}`,
{ method: 'POST', body: JSON.stringify(containerConfig) },
envIdNum
);
copyCtrId = createRes.Id;
await drainResponse(await dockerFetch(`/containers/${copyCtrId}/start`, { method: 'POST' }, envIdNum));
// Wait for the copy to finish (must drain response to ensure wait completes)
const waitRes = await dockerFetch(`/containers/${copyCtrId}/wait`, { method: 'POST' }, envIdNum);
const waitBody = await waitRes.json().catch(() => ({ StatusCode: -1 }));
if (waitBody.StatusCode !== 0) {
throw new Error(`Volume copy failed with exit code ${waitBody.StatusCode}`);
}
} finally {
if (copyCtrId) {
await drainResponse(
await dockerFetch(`/containers/${copyCtrId}?force=true`, { method: 'DELETE' }, envIdNum)
).catch(() => { /* best effort cleanup */ });
}
}
// Audit log
await auditVolume(event, 'clone', newVolume.Name, `${params.name} → ${newName}`, envIdNum, {
source: params.name,
@@ -3,8 +3,12 @@ import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getVolumeArchive } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { validateDockerIdParam } from '$lib/server/docker-validation';
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const invalid = validateDockerIdParam(params.name, 'volume');
if (invalid) return invalid;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
@@ -2,8 +2,12 @@ import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { inspectVolume } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { validateDockerIdParam } from '$lib/server/docker-validation';
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const invalid = validateDockerIdParam(params.name, 'volume');
if (invalid) return invalid;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');