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:
@@ -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 '';
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user