fix(docker-watcher): address final review findings

Security:
- Move config export behind auth middleware
- Validate OIDC callback token before storing in localStorage
- Use constant-time comparison for webhook secret
- Encrypt OIDC client secret at rest (like registry tokens)

Performance:
- Make TriggerDeploy async from HTTP handlers (return deploy ID
  immediately, run pipeline in background goroutine)

Robustness:
- Wrap api.ts res.json() in try/catch for non-JSON responses

i18n:
- Replace ~20 hardcoded English validation messages with $t() calls
- Localize ConfirmDialog cancel button, InstanceCard confirm titles,
  ProjectCard instance/instances pluralization
- Add validation keys to both en.json and ru.json
This commit is contained in:
2026-03-28 00:14:53 +03:00
parent a3aa5912d9
commit 1f81ca9eb0
17 changed files with 178 additions and 40 deletions
+9 -1
View File
@@ -46,7 +46,15 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
headers
});
const envelope: ApiEnvelope<T> = await res.json();
let envelope: ApiEnvelope<T>;
try {
envelope = await res.json();
} catch {
throw new ApiError(
`Server returned non-JSON response (HTTP ${res.status})`,
res.status
);
}
if (!envelope.success) {
throw new ApiError(envelope.error ?? 'Unknown API error', res.status);
+2 -1
View File
@@ -3,6 +3,7 @@
-->
<script lang="ts">
import { IconAlert } from '$lib/components/icons';
import { t } from '$lib/i18n';
interface Props {
open: boolean;
@@ -66,7 +67,7 @@
class="rounded-lg px-4 py-2 text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors active:animate-press"
onclick={oncancel}
>
Cancel
{$t('common.cancel')}
</button>
<button
type="button"
+2 -1
View File
@@ -7,6 +7,7 @@
import ConfirmDialog from './ConfirmDialog.svelte';
import { IconPlay, IconStop, IconRestart, IconTrash, IconExternalLink } from '$lib/components/icons';
import { t } from '$lib/i18n';
import { t } from '$lib/i18n';
import * as api from '$lib/api';
interface Props {
@@ -148,7 +149,7 @@
<ConfirmDialog
open={confirmAction !== null}
title="{confirmAction ? confirmAction.charAt(0).toUpperCase() + confirmAction.slice(1) : ''} Instance"
title={confirmAction ? $t(`confirm.${confirmAction}Instance`) : ''}
message={confirmAction ? $t(`instance.${confirmAction}Confirm`) : ''}
confirmLabel={confirmAction ? confirmAction.charAt(0).toUpperCase() + confirmAction.slice(1) : ''}
confirmVariant={confirmAction === 'remove' ? 'danger' : 'primary'}
+1 -1
View File
@@ -64,7 +64,7 @@
</span>
{/if}
<span class="ml-auto rounded-full bg-[var(--surface-card-hover)] px-2 py-0.5 text-xs font-medium text-[var(--text-tertiary)]">
{totalCount} {totalCount === 1 ? 'instance' : 'instances'}
{totalCount} {totalCount === 1 ? $t('common.instance') : $t('common.instances')}
</span>
{:else}
<span class="text-xs text-[var(--text-tertiary)]">{$t('projectDetail.noInstancesRunning')}</span>
+21 -1
View File
@@ -316,7 +316,9 @@
"stop": "Stop",
"start": "Start",
"restart": "Restart",
"remove": "Remove"
"remove": "Remove",
"instance": "instance",
"instances": "instances"
},
"instance": {
"stopConfirm": "This will stop the running container. The instance can be started again later.",
@@ -339,6 +341,24 @@
"noUsers": "No users",
"noUsersDesc": "Add local users to manage access."
},
"validation": {
"required": "{field} is required",
"invalidUrl": "Invalid URL format",
"invalidDomain": "Invalid domain format",
"invalidIp": "Invalid IP format",
"invalidEmail": "Invalid email format",
"invalidPort": "Port must be between 1 and 65535",
"invalidPollingInterval": "Polling interval must be between 10 and 86400 seconds",
"invalidProjectName": "Only lowercase letters, numbers, and hyphens allowed",
"requiredWhenUpdating": "{field} is required when updating credentials",
"requiredForNew": "{field} is required for new registries"
},
"confirm": {
"stopInstance": "Stop Instance",
"startInstance": "Start Instance",
"restartInstance": "Restart Instance",
"removeInstance": "Remove Instance"
},
"theme": {
"light": "Light",
"dark": "Dark",
+21 -1
View File
@@ -316,7 +316,9 @@
"stop": "Остановить",
"start": "Запустить",
"restart": "Перезапустить",
"remove": "Удалить"
"remove": "Удалить",
"instance": "экземпляр",
"instances": "экземпляров"
},
"instance": {
"stopConfirm": "Контейнер будет остановлен. Экземпляр можно будет запустить снова позже.",
@@ -339,6 +341,24 @@
"noUsers": "Нет пользователей",
"noUsersDesc": "Добавьте локальных пользователей для управления доступом."
},
"validation": {
"required": "Поле {field} обязательно",
"invalidUrl": "Неверный формат URL",
"invalidDomain": "Неверный формат домена",
"invalidIp": "Неверный формат IP",
"invalidEmail": "Неверный формат email",
"invalidPort": "Порт должен быть от 1 до 65535",
"invalidPollingInterval": "Интервал опроса должен быть от 10 до 86400 секунд",
"invalidProjectName": "Допускаются только строчные буквы, цифры и дефисы",
"requiredWhenUpdating": "Поле {field} обязательно при обновлении учётных данных",
"requiredForNew": "Поле {field} обязательно для новых реестров"
},
"confirm": {
"stopInstance": "Остановить экземпляр",
"startInstance": "Запустить экземпляр",
"restartInstance": "Перезапустить экземпляр",
"removeInstance": "Удалить экземпляр"
},
"theme": {
"light": "Светлая",
"dark": "Тёмная",
+6 -6
View File
@@ -22,24 +22,24 @@
let errors = $state<Record<string, string>>({});
function validateImageUrl(url: string): string {
if (!url.trim()) return 'Image URL is required';
if (!url.trim()) return $t('validation.required', { field: 'Image URL' });
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 $t('validation.invalidUrl');
}
return '';
}
function validatePort(value: string): string {
if (!value.trim()) return 'Port is required';
if (!value.trim()) return $t('validation.required', { field: 'Port' });
const num = parseInt(value, 10);
if (isNaN(num) || num < 1 || num > 65535) return 'Port must be between 1 and 65535';
if (isNaN(num) || num < 1 || num > 65535) return $t('validation.invalidPort');
return '';
}
function validateProjectName(value: string): string {
if (!value.trim()) return 'Project name is required';
if (!value.trim()) return $t('validation.required', { field: 'Project name' });
if (value.trim().length > 1 && !/^[a-z0-9][a-z0-9\-]*[a-z0-9]$/.test(value.trim())) {
return 'Must be lowercase alphanumeric with hyphens (e.g., my-app)';
return $t('validation.invalidProjectName');
}
return '';
}
+18 -3
View File
@@ -16,11 +16,26 @@
applyTheme($resolvedTheme);
});
onMount(() => {
onMount(async () => {
const urlToken = $page.url.searchParams.get('token');
if (urlToken) {
localStorage.setItem('auth_token', urlToken);
goto('/');
// Validate the token against the backend before trusting it.
try {
const res = await fetch('/api/auth/me', {
headers: { 'Authorization': `Bearer ${urlToken}` }
});
if (res.ok) {
localStorage.setItem('auth_token', urlToken);
// Remove token from URL to prevent leakage via history/referrer.
history.replaceState(null, '', '/login');
goto('/');
return;
}
} catch {
// Token validation failed — fall through to login form.
}
// Remove invalid token from URL.
history.replaceState(null, '', '/login');
}
const existingToken = localStorage.getItem('auth_token');
if (existingToken) {
+5 -5
View File
@@ -22,27 +22,27 @@
let errors = $state<Record<string, string>>({});
function validateDomain(value: string): string {
if (!value.trim()) return 'Domain is required';
if (!/^[a-zA-Z0-9][a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$/.test(value.trim())) return 'Invalid domain format';
if (!value.trim()) return $t('validation.required', { field: 'Domain' });
if (!/^[a-zA-Z0-9][a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$/.test(value.trim())) return $t('validation.invalidDomain');
return '';
}
function validateIp(value: string): string {
if (!value.trim()) return '';
if (!/^(\d{1,3}\.){3}\d{1,3}$/.test(value.trim())) return 'Invalid IP address format';
if (!/^(\d{1,3}\.){3}\d{1,3}$/.test(value.trim())) return $t('validation.invalidIp');
return '';
}
function validatePollingInterval(value: string): string {
if (!value.trim()) return '';
const num = parseInt(value, 10);
if (isNaN(num) || num < 10 || num > 86400) return 'Polling interval must be between 10 and 86400 seconds';
if (isNaN(num) || num < 10 || num > 86400) return $t('validation.invalidPollingInterval');
return '';
}
function validateUrl(value: string): string {
if (!value.trim()) return '';
try { new URL(value.trim()); return ''; } catch { return 'Invalid URL format'; }
try { new URL(value.trim()); return ''; } catch { return $t('validation.invalidUrl'); }
}
function validateAll(): boolean {
@@ -17,9 +17,9 @@
function validateNpmForm(): boolean {
const newErrors: Record<string, string> = {};
if (!npmUrl.trim()) { newErrors.npmUrl = 'NPM URL is required'; } else { try { new URL(npmUrl.trim()); } catch { newErrors.npmUrl = 'Invalid URL format'; } }
if (!npmEmail.trim()) { newErrors.npmEmail = 'Email is required'; } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(npmEmail.trim())) { newErrors.npmEmail = 'Invalid email format'; }
if (editingNpm && !npmPassword.trim()) { newErrors.npmPassword = 'Password is required when updating credentials'; }
if (!npmUrl.trim()) { newErrors.npmUrl = $t('validation.required', { field: 'NPM URL' }); } else { try { new URL(npmUrl.trim()); } catch { newErrors.npmUrl = $t('validation.invalidUrl'); } }
if (!npmEmail.trim()) { newErrors.npmEmail = $t('validation.required', { field: 'Email' }); } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(npmEmail.trim())) { newErrors.npmEmail = $t('validation.invalidEmail'); }
if (editingNpm && !npmPassword.trim()) { newErrors.npmPassword = $t('validation.requiredWhenUpdating', { field: 'Password' }); }
errors = newErrors;
return Object.keys(newErrors).length === 0;
}
@@ -24,9 +24,9 @@
function validateForm(): boolean {
const newErrors: Record<string, string> = {};
if (!formName.trim()) newErrors.name = 'Name is required';
if (!formUrl.trim()) { newErrors.url = 'URL is required'; } else { try { new URL(formUrl.trim()); } catch { newErrors.url = 'Invalid URL format'; } }
if (!formToken.trim() && !editingId) newErrors.token = 'Token is required for new registries';
if (!formName.trim()) newErrors.name = $t('validation.required', { field: 'Name' });
if (!formUrl.trim()) { newErrors.url = $t('validation.required', { field: 'URL' }); } else { try { new URL(formUrl.trim()); } catch { newErrors.url = $t('validation.invalidUrl'); } }
if (!formToken.trim() && !editingId) newErrors.token = $t('validation.requiredForNew', { field: 'Token' });
errors = newErrors;
return Object.keys(newErrors).length === 0;
}