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