From 6667abf03cf2bd2f82933086a2a3f50c723a22d2 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Sat, 4 Apr 2026 14:40:59 +0300 Subject: [PATCH] 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 --- internal/api/deploys.go | 18 +++ internal/api/middleware.go | 2 +- internal/events/bus.go | 53 ++++++++ internal/store/projects.go | 22 ++++ internal/store/store.go | 1 + web/src/lib/api.ts | 1 + web/src/lib/i18n/en.json | 6 +- web/src/lib/i18n/ru.json | 6 +- web/src/routes/+layout.svelte | 41 ++++-- web/src/routes/deploy/+page.svelte | 136 +++++++++++++++++--- web/src/routes/settings/backup/+page.svelte | 8 +- 11 files changed, 259 insertions(+), 35 deletions(-) diff --git a/internal/api/deploys.go b/internal/api/deploys.go index a53969b..86cbd9d 100644 --- a/internal/api/deploys.go +++ b/internal/api/deploys.go @@ -101,6 +101,7 @@ type quickDeployRequest struct { Tag string `json:"tag"` Registry string `json:"registry"` Port int `json:"port"` + Force bool `json:"force"` // skip duplicate check } // 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] } + // 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. project, err := s.store.CreateProject(store.Project{ Name: req.Name, diff --git a/internal/api/middleware.go b/internal/api/middleware.go index e2692c6..f0f0604 100644 --- a/internal/api/middleware.go +++ b/internal/api/middleware.go @@ -45,7 +45,7 @@ func securityHeaders(next http.Handler) http.Handler { w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("X-Frame-Options", "DENY") 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) }) } diff --git a/internal/events/bus.go b/internal/events/bus.go index 3298d05..ecf92c8 100644 --- a/internal/events/bus.go +++ b/internal/events/bus.go @@ -2,6 +2,7 @@ package events import ( "encoding/json" + "log/slog" "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. func MarshalEvent(evt Event) (string, error) { data, err := json.Marshal(evt) diff --git a/internal/store/projects.go b/internal/store/projects.go index fbf2363..fb5a422 100644 --- a/internal/store/projects.go +++ b/internal/store/projects.go @@ -63,6 +63,28 @@ func (s *Store) GetAllProjects() ([]Project, error) { 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. func (s *Store) UpdateProject(p Project) error { p.UpdatedAt = Now() diff --git a/internal/store/store.go b/internal/store/store.go index 83e4255..c8d03b7 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -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_interval_hours INTEGER NOT NULL DEFAULT 24`, `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 { diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index d2e55c0..2cd4f9c 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -215,6 +215,7 @@ export function quickDeploy(data: { tag?: string; registry?: string; port?: number; + force?: boolean; }): Promise<{ project: Project; status: string }> { return post<{ project: Project; status: string }>('/api/deploy/quick', data); } diff --git a/web/src/lib/i18n/en.json b/web/src/lib/i18n/en.json index ae2d2fe..689b52d 100644 --- a/web/src/lib/i18n/en.json +++ b/web/src/lib/i18n/en.json @@ -223,7 +223,11 @@ "noImages": "No images found", "loadingImages": "Loading...", "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": { "title": "Settings", diff --git a/web/src/lib/i18n/ru.json b/web/src/lib/i18n/ru.json index 5b89677..137ba47 100644 --- a/web/src/lib/i18n/ru.json +++ b/web/src/lib/i18n/ru.json @@ -223,7 +223,11 @@ "noImages": "Образы не найдены", "loadingImages": "Загрузка...", "imageLoadFailed": "Не удалось загрузить образы", - "lowercaseHint": "Строчные буквы и дефисы" + "lowercaseHint": "Строчные буквы и дефисы", + "imageAlreadyExists": "Образ уже развёрнут", + "conflictDescription": "Проект с этим образом уже существует. Откройте существующий проект для развёртывания новой версии или создайте отдельный проект.", + "openProject": "Открыть проект \u2192", + "createNewAnyway": "Создать новый проект" }, "settings": { "title": "Настройки", diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index 92f63dc..33f1d97 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -6,12 +6,13 @@ import Toast from '$lib/components/Toast.svelte'; import ThemeToggle from '$lib/components/ThemeToggle.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 { connectGlobalEvents, type SSEConnection } from '$lib/sse'; import { instanceStatusStore } from '$lib/stores/instance-status'; 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'; interface Props { @@ -68,14 +69,17 @@ } } - sseConnection = connectGlobalEvents({ - onInstanceStatus(payload) { - instanceStatusStore.update(payload); - }, - onDeployStatus(payload) { - instanceStatusStore.notifyDeploy(payload); - } - }); + // Only connect SSE when authenticated (has a token). + if (isAuthenticated()) { + sseConnection = connectGlobalEvents({ + onInstanceStatus(payload) { + instanceStatusStore.update(payload); + }, + onDeployStatus(payload) { + instanceStatusStore.notifyDeploy(payload); + } + }); + } }); onDestroy(() => { @@ -112,9 +116,24 @@ {$t('app.name')} + + + + {/each} + + +
+ + +
+ + +{/if} diff --git a/web/src/routes/settings/backup/+page.svelte b/web/src/routes/settings/backup/+page.svelte index ec377d6..dc156cf 100644 --- a/web/src/routes/settings/backup/+page.svelte +++ b/web/src/routes/settings/backup/+page.svelte @@ -3,6 +3,7 @@ import type { BackupInfo } from '$lib/types'; import FormField from '$lib/components/FormField.svelte'; import ConfirmDialog from '$lib/components/ConfirmDialog.svelte'; + import ToggleSwitch from '$lib/components/ToggleSwitch.svelte'; import { toasts } from '$lib/stores/toast'; import { t } from '$lib/i18n'; import { IconLoader, IconTrash, IconRefresh } from '$lib/components/icons'; @@ -144,14 +145,13 @@
-