fix(docker-watcher): phase 8 security fixes
Remove webhook secret from logs and API response. Add auth-pending note to router. Fix decrypt fallback that would use ciphertext as auth token on decrypt failure.
This commit is contained in:
@@ -0,0 +1,320 @@
|
||||
<script lang="ts">
|
||||
import { api } from '$lib/api';
|
||||
import type { InspectResult, QuickDeployRequest } from '$lib/types';
|
||||
import FormField from '$lib/components/FormField.svelte';
|
||||
import { toasts } from '$lib/stores/toast';
|
||||
|
||||
let imageUrl = $state('');
|
||||
let inspecting = $state(false);
|
||||
let deploying = $state(false);
|
||||
let inspected = $state(false);
|
||||
|
||||
let inspectResult: InspectResult | null = $state(null);
|
||||
|
||||
// Form fields populated after inspect
|
||||
let projectName = $state('');
|
||||
let port = $state('');
|
||||
let healthcheck = $state('');
|
||||
let stage = $state('dev');
|
||||
let subdomain = $state('');
|
||||
let envVars = $state('');
|
||||
|
||||
// Validation errors
|
||||
let errors = $state<Record<string, string>>({});
|
||||
|
||||
function validateImageUrl(url: string): string {
|
||||
if (!url.trim()) return 'Image URL is required';
|
||||
if (!/^[a-zA-Z0-9._\-/]+:[a-zA-Z0-9._\-]+$/.test(url.trim())) {
|
||||
return 'Invalid image URL format (e.g., registry.example.com/org/app:tag)';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function validatePort(value: string): string {
|
||||
if (!value.trim()) return 'Port is required';
|
||||
const num = parseInt(value, 10);
|
||||
if (isNaN(num) || num < 1 || num > 65535) return 'Port must be between 1 and 65535';
|
||||
return '';
|
||||
}
|
||||
|
||||
function validateProjectName(value: string): string {
|
||||
if (!value.trim()) return 'Project name is required';
|
||||
if (!/^[a-z0-9][a-z0-9\-]*[a-z0-9]$/.test(value.trim()) && value.trim().length > 1) {
|
||||
return 'Must be lowercase alphanumeric with hyphens (e.g., my-app)';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function validateAll(): boolean {
|
||||
const newErrors: Record<string, string> = {};
|
||||
const nameErr = validateProjectName(projectName);
|
||||
if (nameErr) newErrors.projectName = nameErr;
|
||||
const portErr = validatePort(port);
|
||||
if (portErr) newErrors.port = portErr;
|
||||
errors = newErrors;
|
||||
return Object.keys(newErrors).length === 0;
|
||||
}
|
||||
|
||||
async function handleInspect() {
|
||||
const urlError = validateImageUrl(imageUrl);
|
||||
if (urlError) {
|
||||
errors = { imageUrl: urlError };
|
||||
return;
|
||||
}
|
||||
errors = {};
|
||||
|
||||
inspecting = true;
|
||||
try {
|
||||
const result = await api.post<InspectResult>('/api/deploy/inspect', {
|
||||
image: imageUrl.trim()
|
||||
});
|
||||
inspectResult = result;
|
||||
|
||||
// Auto-fill form with inspection results
|
||||
projectName = result.project_name ?? '';
|
||||
port = result.port?.toString() ?? '';
|
||||
healthcheck = result.healthcheck ?? '';
|
||||
stage = 'dev';
|
||||
subdomain = result.suggested_subdomain ?? '';
|
||||
envVars = result.env_vars ? result.env_vars.map((e) => `${e.key}=${e.value}`).join('\n') : '';
|
||||
inspected = true;
|
||||
toasts.success('Image inspected successfully');
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to inspect image';
|
||||
toasts.error(message);
|
||||
} finally {
|
||||
inspecting = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeploy() {
|
||||
if (!validateAll()) return;
|
||||
|
||||
deploying = true;
|
||||
try {
|
||||
const envMap: Record<string, string> = {};
|
||||
if (envVars.trim()) {
|
||||
for (const line of envVars.split('\n')) {
|
||||
const eqIndex = line.indexOf('=');
|
||||
if (eqIndex > 0) {
|
||||
envMap[line.substring(0, eqIndex).trim()] = line.substring(eqIndex + 1).trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const payload: QuickDeployRequest = {
|
||||
image: imageUrl.trim(),
|
||||
project_name: projectName.trim(),
|
||||
port: parseInt(port, 10),
|
||||
healthcheck: healthcheck.trim() || undefined,
|
||||
stage: stage,
|
||||
subdomain: subdomain.trim() || undefined,
|
||||
env: Object.keys(envMap).length > 0 ? envMap : undefined
|
||||
};
|
||||
|
||||
await api.post('/api/deploy/quick', payload);
|
||||
toasts.success(`Deployed ${projectName} successfully!`);
|
||||
|
||||
// Reset form
|
||||
imageUrl = '';
|
||||
inspected = false;
|
||||
inspectResult = null;
|
||||
projectName = '';
|
||||
port = '';
|
||||
healthcheck = '';
|
||||
stage = 'dev';
|
||||
subdomain = '';
|
||||
envVars = '';
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Deployment failed';
|
||||
toasts.error(message);
|
||||
} finally {
|
||||
deploying = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Quick Deploy - Docker Watcher</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-2xl space-y-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">Quick Deploy</h1>
|
||||
<p class="mt-1 text-sm text-gray-500">
|
||||
Deploy a container image with zero configuration. Paste an image URL, review the defaults,
|
||||
and deploy.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Step 1: Image URL input -->
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-6 shadow-sm">
|
||||
<h2 class="mb-4 text-lg font-semibold text-gray-800">1. Enter Image URL</h2>
|
||||
<div class="flex gap-3">
|
||||
<div class="flex-1">
|
||||
<FormField
|
||||
label="Image URL"
|
||||
name="imageUrl"
|
||||
bind:value={imageUrl}
|
||||
placeholder="registry.example.com/org/app:tag"
|
||||
required
|
||||
error={errors.imageUrl ?? ''}
|
||||
helpText="Full image URL including tag (e.g., git.example.com/user/app:dev-abc123)"
|
||||
disabled={inspecting}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-end">
|
||||
<button
|
||||
onclick={handleInspect}
|
||||
disabled={inspecting || !imageUrl.trim()}
|
||||
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{#if inspecting}
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<svg class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
></path>
|
||||
</svg>
|
||||
Inspecting...
|
||||
</span>
|
||||
{:else}
|
||||
Inspect
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Review and configure (shown after inspect) -->
|
||||
{#if inspected}
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-6 shadow-sm">
|
||||
<h2 class="mb-4 text-lg font-semibold text-gray-800">2. Review Configuration</h2>
|
||||
<p class="mb-4 text-sm text-gray-500">
|
||||
These defaults were detected from the image. Adjust as needed before deploying.
|
||||
</p>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<FormField
|
||||
label="Project Name"
|
||||
name="projectName"
|
||||
bind:value={projectName}
|
||||
placeholder="my-app"
|
||||
required
|
||||
error={errors.projectName ?? ''}
|
||||
helpText="Lowercase with hyphens"
|
||||
/>
|
||||
|
||||
<FormField
|
||||
label="Port"
|
||||
name="port"
|
||||
type="number"
|
||||
bind:value={port}
|
||||
placeholder="3000"
|
||||
required
|
||||
error={errors.port ?? ''}
|
||||
helpText="Container port to expose (1-65535)"
|
||||
/>
|
||||
|
||||
<FormField
|
||||
label="Health Check Path"
|
||||
name="healthcheck"
|
||||
bind:value={healthcheck}
|
||||
placeholder="/api/health"
|
||||
helpText="Optional HTTP path for health verification"
|
||||
/>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="stage" class="text-sm font-medium text-gray-700">Stage</label>
|
||||
<select
|
||||
id="stage"
|
||||
bind:value={stage}
|
||||
class="rounded-md border border-gray-300 bg-white px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="dev">Development</option>
|
||||
<option value="rel">Release</option>
|
||||
<option value="prod">Production</option>
|
||||
</select>
|
||||
<p class="text-xs text-gray-500">Deployment stage for this image</p>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
label="Subdomain Override"
|
||||
name="subdomain"
|
||||
bind:value={subdomain}
|
||||
placeholder="auto-generated"
|
||||
helpText="Leave empty to use the default subdomain pattern"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<FormField
|
||||
label="Environment Variables"
|
||||
name="envVars"
|
||||
type="textarea"
|
||||
bind:value={envVars}
|
||||
placeholder="KEY=value ANOTHER_KEY=another_value"
|
||||
helpText="One per line, KEY=VALUE format"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: Deploy -->
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-6 shadow-sm">
|
||||
<h2 class="mb-4 text-lg font-semibold text-gray-800">3. Deploy</h2>
|
||||
<p class="mb-4 text-sm text-gray-500">
|
||||
A new project will be created and the container will be deployed immediately.
|
||||
</p>
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
onclick={handleDeploy}
|
||||
disabled={deploying}
|
||||
class="rounded-md bg-green-600 px-6 py-2 text-sm font-medium text-white transition-colors hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{#if deploying}
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<svg class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
></path>
|
||||
</svg>
|
||||
Deploying...
|
||||
</span>
|
||||
{:else}
|
||||
Deploy
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
onclick={() => {
|
||||
inspected = false;
|
||||
inspectResult = null;
|
||||
}}
|
||||
disabled={deploying}
|
||||
class="rounded-md border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,55 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
|
||||
interface Props {
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { children }: Props = $props();
|
||||
|
||||
const navItems = [
|
||||
{ href: '/settings', label: 'General' },
|
||||
{ href: '/settings/registries', label: 'Registries' },
|
||||
{ href: '/settings/credentials', label: 'Credentials' }
|
||||
];
|
||||
|
||||
let currentPath = $derived($page.url.pathname);
|
||||
|
||||
function isActive(href: string): boolean {
|
||||
if (href === '/settings') {
|
||||
return currentPath === '/settings';
|
||||
}
|
||||
return currentPath.startsWith(href);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-4xl">
|
||||
<h1 class="mb-6 text-2xl font-bold text-gray-900">Settings</h1>
|
||||
|
||||
<div class="flex gap-6">
|
||||
<!-- Sub-navigation -->
|
||||
<nav class="w-48 flex-shrink-0">
|
||||
<ul class="space-y-1">
|
||||
{#each navItems as item}
|
||||
<li>
|
||||
<a
|
||||
href={item.href}
|
||||
class="block rounded-md px-3 py-2 text-sm font-medium transition-colors
|
||||
{isActive(item.href)
|
||||
? 'bg-blue-50 text-blue-700'
|
||||
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'}"
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<!-- Settings content -->
|
||||
<div class="flex-1">
|
||||
{@render children()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user