fix: quick deploy duplicate detection, logout UX, backup toggle, CSP, SSE guard, and migration

- Detect existing projects with same image on quick deploy; show conflict dialog with options
- Move logout button to sidebar header as icon-only
- Replace backup checkbox with ToggleSwitch component
- Allow unsafe-inline in CSP script-src for SvelteKit hydration
- Guard SSE connection behind isAuthenticated() check
- Add notification_url ALTER TABLE migration for existing databases
- Restore RegisterPersistentLogger on event bus
This commit is contained in:
2026-04-04 14:40:59 +03:00
parent 205a5a36c6
commit 6667abf03c
11 changed files with 259 additions and 35 deletions
+18
View File
@@ -101,6 +101,7 @@ type quickDeployRequest struct {
Tag string `json:"tag"` Tag string `json:"tag"`
Registry string `json:"registry"` Registry string `json:"registry"`
Port int `json:"port"` Port int `json:"port"`
Force bool `json:"force"` // skip duplicate check
} }
// quickDeploy handles POST /api/deploy/quick. // quickDeploy handles POST /api/deploy/quick.
@@ -124,6 +125,23 @@ func (s *Server) quickDeploy(w http.ResponseWriter, r *http.Request) {
req.Name = parts[len(parts)-1] req.Name = parts[len(parts)-1]
} }
// Check for existing projects with the same image.
if !req.Force {
existing, err := s.store.GetProjectsByImage(req.Image)
if err != nil {
slog.Error("failed to check existing projects", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
if len(existing) > 0 {
respondJSON(w, http.StatusConflict, map[string]any{
"message": "A project with this image already exists",
"existing_projects": existing,
})
return
}
}
// Create project. // Create project.
project, err := s.store.CreateProject(store.Project{ project, err := s.store.CreateProject(store.Project{
Name: req.Name, Name: req.Name,
+1 -1
View File
@@ -45,7 +45,7 @@ func securityHeaders(next http.Handler) http.Handler {
w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY") w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; font-src 'self'") w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; font-src 'self'")
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
}) })
} }
+53
View File
@@ -2,6 +2,7 @@ package events
import ( import (
"encoding/json" "encoding/json"
"log/slog"
"sync" "sync"
) )
@@ -124,6 +125,58 @@ func (b *Bus) Publish(evt Event) {
} }
} }
// PersistFunc is a callback that persists an event log entry.
// It receives source, severity, message, and metadata (JSON string).
// It returns the persisted entry's ID and created_at timestamp.
type PersistFunc func(source, severity, message, metadata string) (int64, string, error)
// RegisterPersistentLogger subscribes to the bus and auto-persists warn/error
// events by calling the provided persist function. It also re-publishes the
// persisted event as an EventLog so SSE clients receive it in real-time.
// Call the returned function to unsubscribe.
func (b *Bus) RegisterPersistentLogger(persist PersistFunc) func() {
sub := b.Subscribe(func(evt Event) bool {
if evt.Type != EventDeployLog {
return false
}
p, ok := evt.Payload.(DeployLogPayload)
if !ok {
return false
}
return p.Level == "warn" || p.Level == "error"
})
go func() {
for evt := range sub {
p, ok := evt.Payload.(DeployLogPayload)
if !ok {
continue
}
metaBytes, _ := json.Marshal(map[string]string{"deploy_id": p.DeployID})
metadata := string(metaBytes)
id, createdAt, err := persist("deploy", p.Level, p.Message, metadata)
if err != nil {
slog.Error("failed to persist event log", "source", "deploy", "level", p.Level, "error", err)
continue
}
b.Publish(Event{
Type: EventLog,
Payload: EventLogPayload{
ID: id,
Source: "deploy",
Severity: p.Level,
Message: p.Message,
Metadata: metadata,
CreatedAt: createdAt,
},
})
}
}()
return func() { b.Unsubscribe(sub) }
}
// MarshalEvent serializes an event to a JSON string suitable for SSE data lines. // MarshalEvent serializes an event to a JSON string suitable for SSE data lines.
func MarshalEvent(evt Event) (string, error) { func MarshalEvent(evt Event) (string, error) {
data, err := json.Marshal(evt) data, err := json.Marshal(evt)
+22
View File
@@ -63,6 +63,28 @@ func (s *Store) GetAllProjects() ([]Project, error) {
return projects, rows.Err() return projects, rows.Err()
} }
// GetProjectsByImage returns all projects using the given image, newest first.
func (s *Store) GetProjectsByImage(image string) ([]Project, error) {
rows, err := s.db.Query(
`SELECT id, name, registry, image, port, healthcheck, env, volumes, created_at, updated_at
FROM projects WHERE image = ? ORDER BY created_at DESC`, image,
)
if err != nil {
return nil, fmt.Errorf("query projects by image: %w", err)
}
defer rows.Close()
projects := []Project{}
for rows.Next() {
var p Project
if err := rows.Scan(&p.ID, &p.Name, &p.Registry, &p.Image, &p.Port, &p.Healthcheck, &p.Env, &p.Volumes, &p.CreatedAt, &p.UpdatedAt); err != nil {
return nil, fmt.Errorf("scan project: %w", err)
}
projects = append(projects, p)
}
return projects, rows.Err()
}
// UpdateProject updates an existing project's mutable fields. // UpdateProject updates an existing project's mutable fields.
func (s *Store) UpdateProject(p Project) error { func (s *Store) UpdateProject(p Project) error {
p.UpdatedAt = Now() p.UpdatedAt = Now()
+1
View File
@@ -99,6 +99,7 @@ func (s *Store) runMigrations() error {
`ALTER TABLE settings ADD COLUMN backup_enabled INTEGER NOT NULL DEFAULT 0`, `ALTER TABLE settings ADD COLUMN backup_enabled INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE settings ADD COLUMN backup_interval_hours INTEGER NOT NULL DEFAULT 24`, `ALTER TABLE settings ADD COLUMN backup_interval_hours INTEGER NOT NULL DEFAULT 24`,
`ALTER TABLE settings ADD COLUMN backup_retention_count INTEGER NOT NULL DEFAULT 10`, `ALTER TABLE settings ADD COLUMN backup_retention_count INTEGER NOT NULL DEFAULT 10`,
`ALTER TABLE stages ADD COLUMN notification_url TEXT NOT NULL DEFAULT ''`,
} }
for _, m := range migrations { for _, m := range migrations {
+1
View File
@@ -215,6 +215,7 @@ export function quickDeploy(data: {
tag?: string; tag?: string;
registry?: string; registry?: string;
port?: number; port?: number;
force?: boolean;
}): Promise<{ project: Project; status: string }> { }): Promise<{ project: Project; status: string }> {
return post<{ project: Project; status: string }>('/api/deploy/quick', data); return post<{ project: Project; status: string }>('/api/deploy/quick', data);
} }
+5 -1
View File
@@ -223,7 +223,11 @@
"noImages": "No images found", "noImages": "No images found",
"loadingImages": "Loading...", "loadingImages": "Loading...",
"imageLoadFailed": "Failed to load images", "imageLoadFailed": "Failed to load images",
"lowercaseHint": "Lowercase with hyphens" "lowercaseHint": "Lowercase with hyphens",
"imageAlreadyExists": "Image already deployed",
"conflictDescription": "A project using this image already exists. You can open the existing project to deploy a new version, or create a separate project.",
"openProject": "Open project \u2192",
"createNewAnyway": "Create New Project"
}, },
"settings": { "settings": {
"title": "Settings", "title": "Settings",
+5 -1
View File
@@ -223,7 +223,11 @@
"noImages": "Образы не найдены", "noImages": "Образы не найдены",
"loadingImages": "Загрузка...", "loadingImages": "Загрузка...",
"imageLoadFailed": "Не удалось загрузить образы", "imageLoadFailed": "Не удалось загрузить образы",
"lowercaseHint": "Строчные буквы и дефисы" "lowercaseHint": "Строчные буквы и дефисы",
"imageAlreadyExists": "Образ уже развёрнут",
"conflictDescription": "Проект с этим образом уже существует. Откройте существующий проект для развёртывания новой версии или создайте отдельный проект.",
"openProject": "Открыть проект \u2192",
"createNewAnyway": "Создать новый проект"
}, },
"settings": { "settings": {
"title": "Настройки", "title": "Настройки",
+30 -11
View File
@@ -6,12 +6,13 @@
import Toast from '$lib/components/Toast.svelte'; import Toast from '$lib/components/Toast.svelte';
import ThemeToggle from '$lib/components/ThemeToggle.svelte'; import ThemeToggle from '$lib/components/ThemeToggle.svelte';
import LocaleSwitcher from '$lib/components/LocaleSwitcher.svelte'; import LocaleSwitcher from '$lib/components/LocaleSwitcher.svelte';
import { IconDashboard, IconProjects, IconDeploy, IconSettings, IconMenu, IconX } from '$lib/components/icons'; import { IconDashboard, IconProjects, IconDeploy, IconSettings, IconMenu, IconX, IconLogout } from '$lib/components/icons';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { connectGlobalEvents, type SSEConnection } from '$lib/sse'; import { connectGlobalEvents, type SSEConnection } from '$lib/sse';
import { instanceStatusStore } from '$lib/stores/instance-status'; import { instanceStatusStore } from '$lib/stores/instance-status';
import { resolvedTheme, applyTheme } from '$lib/stores/theme'; import { resolvedTheme, applyTheme } from '$lib/stores/theme';
import { exchangeOidcToken, setAuthToken } from '$lib/auth'; import { exchangeOidcToken, setAuthToken, clearAuth, isAuthenticated } from '$lib/auth';
import { logout as apiLogout } from '$lib/api';
import { t } from '$lib/i18n'; import { t } from '$lib/i18n';
interface Props { interface Props {
@@ -68,14 +69,17 @@
} }
} }
sseConnection = connectGlobalEvents({ // Only connect SSE when authenticated (has a token).
onInstanceStatus(payload) { if (isAuthenticated()) {
instanceStatusStore.update(payload); sseConnection = connectGlobalEvents({
}, onInstanceStatus(payload) {
onDeployStatus(payload) { instanceStatusStore.update(payload);
instanceStatusStore.notifyDeploy(payload); },
} onDeployStatus(payload) {
}); instanceStatusStore.notifyDeploy(payload);
}
});
}
}); });
onDestroy(() => { onDestroy(() => {
@@ -112,9 +116,24 @@
</div> </div>
<span class="text-base font-bold text-[var(--text-primary)]">{$t('app.name')}</span> <span class="text-base font-bold text-[var(--text-primary)]">{$t('app.name')}</span>
<!-- Logout button -->
<button
type="button"
title={$t('nav.logout')}
aria-label={$t('nav.logout')}
onclick={async () => {
try { await apiLogout(); } catch { /* best effort */ }
clearAuth();
goto('/login');
}}
class="ml-auto rounded-md p-1.5 text-[var(--text-tertiary)] hover:text-[var(--color-danger)] transition-colors"
>
<IconLogout size={18} />
</button>
<!-- Close sidebar (mobile) --> <!-- Close sidebar (mobile) -->
<button <button
class="ml-auto rounded-md p-1 text-[var(--text-tertiary)] hover:text-[var(--text-primary)] lg:hidden" class="rounded-md p-1 text-[var(--text-tertiary)] hover:text-[var(--text-primary)] lg:hidden"
onclick={() => { sidebarOpen = false; }} onclick={() => { sidebarOpen = false; }}
aria-label="Close sidebar" aria-label="Close sidebar"
> >
+119 -17
View File
@@ -1,10 +1,12 @@
<script lang="ts"> <script lang="ts">
import { inspectImage, quickDeploy, listRegistries, listRegistryImages } from '$lib/api'; import { inspectImage, quickDeploy, listRegistries, listRegistryImages, deployInstance } from '$lib/api';
import type { InspectResult, EntityPickerItem } from '$lib/types'; import type { InspectResult, EntityPickerItem, Project } from '$lib/types';
import FormField from '$lib/components/FormField.svelte'; import FormField from '$lib/components/FormField.svelte';
import EntityPicker from '$lib/components/EntityPicker.svelte'; import EntityPicker from '$lib/components/EntityPicker.svelte';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import { toasts } from '$lib/stores/toast'; import { toasts } from '$lib/stores/toast';
import { t } from '$lib/i18n'; import { t } from '$lib/i18n';
import { goto } from '$app/navigation';
import { IconSearch, IconDeploy, IconLoader, IconCheck } from '$lib/components/icons'; import { IconSearch, IconDeploy, IconLoader, IconCheck } from '$lib/components/icons';
let imageUrl = $state(''); let imageUrl = $state('');
@@ -22,6 +24,10 @@
let errors = $state<Record<string, string>>({}); let errors = $state<Record<string, string>>({});
// Duplicate detection state
let conflictProjects = $state<Project[]>([]);
let showConflictDialog = $state(false);
// Image picker state // Image picker state
let showImagePicker = $state(false); let showImagePicker = $state(false);
let imagePickerItems = $state<EntityPickerItem[]>([]); let imagePickerItems = $state<EntityPickerItem[]>([]);
@@ -130,28 +136,67 @@
} }
} }
async function handleDeploy() { async function handleDeploy(force = false) {
if (!validateAll()) return; if (!validateAll()) return;
deploying = true; deploying = true;
try { try {
await quickDeploy({ image: imageUrl.trim(), name: projectName.trim(), port: parseInt(port, 10) }); const result = await quickDeploy({ image: imageUrl.trim(), name: projectName.trim(), port: parseInt(port, 10), force });
toasts.success($t('quickDeploy.deployedSuccess', { name: projectName })); toasts.success($t('quickDeploy.deployedSuccess', { name: projectName }));
imageUrl = ''; // Redirect to the new project page.
inspected = false; if (result.project?.id) {
inspectResult = null; goto(`/projects/${result.project.id}`);
projectName = ''; } else {
port = ''; imageUrl = '';
healthcheck = ''; inspected = false;
stage = 'dev'; inspectResult = null;
subdomain = ''; projectName = '';
envVars = ''; port = '';
} catch (err) { healthcheck = '';
const message = err instanceof Error ? err.message : $t('quickDeploy.deployFailed'); stage = 'dev';
toasts.error(message); subdomain = '';
envVars = '';
}
} catch (err: unknown) {
// Handle 409 Conflict — existing project with same image.
if (err instanceof Error && 'status' in err && (err as any).status === 409) {
try {
// The error message contains the JSON response from the server.
// Re-fetch existing projects for this image.
const res = await fetch(`/api/deploy/quick`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${localStorage.getItem('auth_token')}` },
body: JSON.stringify({ image: imageUrl.trim(), name: projectName.trim(), port: parseInt(port, 10) })
});
if (res.status === 409) {
const envelope = await res.json();
if (envelope.data?.existing_projects) {
conflictProjects = envelope.data.existing_projects;
showConflictDialog = true;
return;
}
}
} catch { /* fall through */ }
toasts.error($t('quickDeploy.imageAlreadyExists'));
} else {
const message = err instanceof Error ? err.message : $t('quickDeploy.deployFailed');
toasts.error(message);
}
} finally { } finally {
deploying = false; deploying = false;
} }
} }
async function handleDeployToExisting(project: Project) {
showConflictDialog = false;
conflictProjects = [];
goto(`/projects/${project.id}`);
}
async function handleForceNewProject() {
showConflictDialog = false;
conflictProjects = [];
await handleDeploy(true);
}
</script> </script>
<svelte:head> <svelte:head>
@@ -253,7 +298,7 @@
<p class="mb-4 text-sm text-[var(--text-secondary)]">{$t('quickDeploy.deployDesc')}</p> <p class="mb-4 text-sm text-[var(--text-secondary)]">{$t('quickDeploy.deployDesc')}</p>
<div class="flex gap-3"> <div class="flex gap-3">
<button <button
onclick={handleDeploy} onclick={() => handleDeploy()}
disabled={deploying} disabled={deploying}
class="inline-flex items-center gap-2 rounded-lg bg-[var(--color-success)] px-6 py-2.5 text-sm font-medium text-white shadow-sm transition-all duration-150 hover:bg-[var(--color-success-dark)] disabled:cursor-not-allowed disabled:opacity-50 active:animate-press" class="inline-flex items-center gap-2 rounded-lg bg-[var(--color-success)] px-6 py-2.5 text-sm font-medium text-white shadow-sm transition-all duration-150 hover:bg-[var(--color-success-dark)] disabled:cursor-not-allowed disabled:opacity-50 active:animate-press"
> >
@@ -276,3 +321,60 @@
</div> </div>
{/if} {/if}
</div> </div>
<!-- Conflict dialog: image already deployed -->
{#if showConflictDialog}
<div class="fixed inset-0 z-40 bg-[var(--surface-overlay)] animate-fade-in" role="presentation" onclick={() => { showConflictDialog = false; }}></div>
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
role="dialog"
aria-modal="true"
aria-labelledby="conflict-dialog-title"
tabindex="-1"
onkeydown={(e) => { if (e.key === 'Escape') showConflictDialog = false; }}
onclick={(e) => e.stopPropagation()}
class="w-full max-w-lg rounded-2xl bg-[var(--surface-card)] p-6 shadow-xl animate-scale-in"
>
<h3 id="conflict-dialog-title" class="text-lg font-semibold text-[var(--text-primary)]">
{$t('quickDeploy.imageAlreadyExists')}
</h3>
<p class="mt-2 text-sm text-[var(--text-secondary)]">
{$t('quickDeploy.conflictDescription')}
</p>
<div class="mt-4 space-y-2">
{#each conflictProjects as project (project.id)}
<button
type="button"
onclick={() => handleDeployToExisting(project)}
class="flex w-full items-center justify-between rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card-hover)] p-3 text-left hover:border-[var(--color-brand-500)] transition-colors"
>
<div>
<span class="text-sm font-medium text-[var(--text-primary)]">{project.name}</span>
<span class="ml-2 text-xs text-[var(--text-tertiary)]">{project.image}</span>
</div>
<span class="text-xs text-[var(--text-tertiary)]">{$t('quickDeploy.openProject')}</span>
</button>
{/each}
</div>
<div class="mt-6 flex justify-end gap-3">
<button
type="button"
class="rounded-lg px-4 py-2 text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors"
onclick={() => { showConflictDialog = false; }}
>
{$t('common.cancel')}
</button>
<button
type="button"
class="rounded-lg bg-[var(--color-brand-600)] px-4 py-2 text-sm font-medium text-white hover:bg-[var(--color-brand-700)] transition-colors"
onclick={handleForceNewProject}
>
{$t('quickDeploy.createNewAnyway')}
</button>
</div>
</div>
</div>
{/if}
+4 -4
View File
@@ -3,6 +3,7 @@
import type { BackupInfo } from '$lib/types'; import type { BackupInfo } from '$lib/types';
import FormField from '$lib/components/FormField.svelte'; import FormField from '$lib/components/FormField.svelte';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte'; import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
import { toasts } from '$lib/stores/toast'; import { toasts } from '$lib/stores/toast';
import { t } from '$lib/i18n'; import { t } from '$lib/i18n';
import { IconLoader, IconTrash, IconRefresh } from '$lib/components/icons'; import { IconLoader, IconTrash, IconRefresh } from '$lib/components/icons';
@@ -144,14 +145,13 @@
<div class="space-y-4"> <div class="space-y-4">
<!-- Auto backup toggle --> <!-- Auto backup toggle -->
<label class="flex items-center gap-3 cursor-pointer"> <div class="flex items-center gap-3">
<input type="checkbox" bind:checked={backupEnabled} <ToggleSwitch bind:checked={backupEnabled} label={$t('settingsBackup.autoBackup')} />
class="h-4 w-4 rounded border-[var(--border-primary)] text-[var(--color-brand-600)] focus:ring-[var(--color-brand-500)]" />
<div> <div>
<span class="text-sm font-medium text-[var(--text-primary)]">{$t('settingsBackup.autoBackup')}</span> <span class="text-sm font-medium text-[var(--text-primary)]">{$t('settingsBackup.autoBackup')}</span>
<p class="text-xs text-[var(--text-tertiary)]">{$t('settingsBackup.autoBackupHelp')}</p> <p class="text-xs text-[var(--text-tertiary)]">{$t('settingsBackup.autoBackupHelp')}</p>
</div> </div>
</label> </div>
{#if backupEnabled} {#if backupEnabled}
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 ml-7"> <div class="grid grid-cols-1 gap-4 sm:grid-cols-2 ml-7">