diff --git a/README.md b/README.md index e4742fc..ebbde09 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ AnchorDB is a lightweight Go service for automated database backups. - Supported databases: PostgreSQL, MySQL, Convex, and Cloudflare D1 - Automated backup scheduling - API and web interface for connection and backup management -- Notifications *(in progress)* +- Notifications: Discord webhooks and SMTP destinations with per-schedule success/failure routing, test send, and SMTP security modes (STARTTLS, SSL/TLS, none) - Encryption and restore *(in progress)* - Open source (OSS) diff --git a/internal/api/handler.go b/internal/api/handler.go index 106ecc4..6cf3a58 100644 --- a/internal/api/handler.go +++ b/internal/api/handler.go @@ -10,6 +10,7 @@ import ( "time" "anchordb/internal/models" + "anchordb/internal/notifications" "anchordb/internal/repository" "anchordb/internal/scheduler" @@ -21,10 +22,11 @@ import ( type Handler struct { repo *repository.Repository scheduler *scheduler.Scheduler + notifier *notifications.Dispatcher } func NewHandler(repo *repository.Repository, scheduler *scheduler.Scheduler) *Handler { - return &Handler{repo: repo, scheduler: scheduler} + return &Handler{repo: repo, scheduler: scheduler, notifier: notifications.NewDispatcher(repo)} } func (h *Handler) Router() http.Handler { @@ -50,6 +52,15 @@ func (h *Handler) Router() http.Handler { r.Delete("/{id}", h.deleteRemote) }) + r.Route("/notifications", func(r chi.Router) { + r.Post("/", h.createNotification) + r.Get("/", h.listNotifications) + r.Get("/{id}", h.getNotification) + r.Patch("/{id}", h.updateNotification) + r.Post("/{id}/test", h.testNotification) + r.Delete("/{id}", h.deleteNotification) + }) + r.Route("/backups", func(r chi.Router) { r.Post("/", h.createBackup) r.Get("/", h.listBackups) @@ -59,6 +70,8 @@ func (h *Handler) Router() http.Handler { r.Post("/{id}/run", h.runBackupNow) r.Patch("/{id}/enabled", h.setBackupEnabled) r.Get("/{id}/runs", h.listBackupRuns) + r.Get("/{id}/notifications", h.listBackupNotifications) + r.Put("/{id}/notifications", h.setBackupNotifications) }) return r @@ -257,6 +270,174 @@ func (h *Handler) deleteRemote(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) } +func (h *Handler) createNotification(w http.ResponseWriter, r *http.Request) { + type createRequest struct { + Name string `json:"name"` + Type string `json:"type"` + Enabled *bool `json:"enabled"` + DiscordWebhookURL string `json:"discord_webhook_url"` + SMTPHost string `json:"smtp_host"` + SMTPPort int `json:"smtp_port"` + SMTPUsername string `json:"smtp_username"` + SMTPPassword string `json:"smtp_password"` + SMTPFrom string `json:"smtp_from"` + SMTPTo string `json:"smtp_to"` + SMTPSecurity string `json:"smtp_security"` + } + + var req createRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON") + return + } + + enabled := true + if req.Enabled != nil { + enabled = *req.Enabled + } + + item := models.NotificationDestination{ + Name: strings.TrimSpace(req.Name), + Type: strings.ToLower(strings.TrimSpace(req.Type)), + Enabled: enabled, + DiscordWebhookURL: strings.TrimSpace(req.DiscordWebhookURL), + SMTPHost: strings.TrimSpace(req.SMTPHost), + SMTPPort: req.SMTPPort, + SMTPUsername: strings.TrimSpace(req.SMTPUsername), + SMTPPassword: req.SMTPPassword, + SMTPFrom: strings.TrimSpace(req.SMTPFrom), + SMTPTo: strings.TrimSpace(req.SMTPTo), + SMTPSecurity: strings.ToLower(strings.TrimSpace(req.SMTPSecurity)), + } + + if err := h.repo.CreateNotification(r.Context(), &item); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + redactNotification(&item) + writeJSON(w, http.StatusCreated, item) +} + +func (h *Handler) listNotifications(w http.ResponseWriter, r *http.Request) { + items, err := h.repo.ListNotifications(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + for i := range items { + redactNotification(&items[i]) + } + writeJSON(w, http.StatusOK, items) +} + +func (h *Handler) getNotification(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + item, err := h.repo.GetNotification(r.Context(), id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + writeError(w, http.StatusNotFound, "notification not found") + return + } + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + redactNotification(&item) + writeJSON(w, http.StatusOK, item) +} + +func (h *Handler) updateNotification(w http.ResponseWriter, r *http.Request) { + type patchRequest struct { + Name *string `json:"name"` + Type *string `json:"type"` + Enabled *bool `json:"enabled"` + DiscordWebhookURL *string `json:"discord_webhook_url"` + SMTPHost *string `json:"smtp_host"` + SMTPPort *int `json:"smtp_port"` + SMTPUsername *string `json:"smtp_username"` + SMTPPassword *string `json:"smtp_password"` + SMTPFrom *string `json:"smtp_from"` + SMTPTo *string `json:"smtp_to"` + SMTPSecurity *string `json:"smtp_security"` + } + + id := chi.URLParam(r, "id") + var req patchRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON") + return + } + if req.Type != nil { + trimmed := strings.ToLower(strings.TrimSpace(*req.Type)) + req.Type = &trimmed + } + if req.SMTPSecurity != nil { + trimmed := strings.ToLower(strings.TrimSpace(*req.SMTPSecurity)) + req.SMTPSecurity = &trimmed + } + + item, err := h.repo.UpdateNotification(r.Context(), id, repository.NotificationPatch{ + Name: req.Name, + Type: req.Type, + Enabled: req.Enabled, + DiscordWebhookURL: req.DiscordWebhookURL, + SMTPHost: req.SMTPHost, + SMTPPort: req.SMTPPort, + SMTPUsername: req.SMTPUsername, + SMTPPassword: req.SMTPPassword, + SMTPFrom: req.SMTPFrom, + SMTPTo: req.SMTPTo, + SMTPSecurity: req.SMTPSecurity, + }) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + writeError(w, http.StatusNotFound, "notification not found") + return + } + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + redactNotification(&item) + writeJSON(w, http.StatusOK, item) +} + +func (h *Handler) testNotification(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + item, err := h.repo.GetNotification(r.Context(), id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + writeError(w, http.StatusNotFound, "notification not found") + return + } + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + if !item.Enabled { + writeError(w, http.StatusBadRequest, "notification is disabled") + return + } + if err := h.notifier.SendTestNotification(r.Context(), item); err != nil { + writeError(w, http.StatusBadGateway, err.Error()) + return + } + + writeJSON(w, http.StatusOK, map[string]string{"status": "sent"}) +} + +func (h *Handler) deleteNotification(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + if err := h.repo.DeleteNotification(r.Context(), id); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + writeError(w, http.StatusNotFound, "notification not found") + return + } + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) +} + func (h *Handler) createBackup(w http.ResponseWriter, r *http.Request) { var req models.Backup if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -461,6 +642,93 @@ func (h *Handler) listBackupRuns(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, items) } +func (h *Handler) listBackupNotifications(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + if _, err := h.repo.GetBackup(r.Context(), id); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + writeError(w, http.StatusNotFound, "backup not found") + return + } + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + items, err := h.repo.ListBackupNotifications(r.Context(), id) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + for i := range items { + redactNotification(&items[i].Notification) + } + writeJSON(w, http.StatusOK, items) +} + +func (h *Handler) setBackupNotifications(w http.ResponseWriter, r *http.Request) { + type notificationBindingRequest struct { + NotificationID string `json:"notification_id"` + OnSuccess *bool `json:"on_success"` + OnFailure *bool `json:"on_failure"` + Enabled *bool `json:"enabled"` + } + type bindingsRequest struct { + Notifications []notificationBindingRequest `json:"notifications"` + } + + id := chi.URLParam(r, "id") + if _, err := h.repo.GetBackup(r.Context(), id); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + writeError(w, http.StatusNotFound, "backup not found") + return + } + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + var req bindingsRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON") + return + } + + bindings := make([]models.BackupNotification, 0, len(req.Notifications)) + for _, item := range req.Notifications { + onSuccess := true + if item.OnSuccess != nil { + onSuccess = *item.OnSuccess + } + onFailure := true + if item.OnFailure != nil { + onFailure = *item.OnFailure + } + enabled := true + if item.Enabled != nil { + enabled = *item.Enabled + } + + bindings = append(bindings, models.BackupNotification{ + NotificationID: strings.TrimSpace(item.NotificationID), + OnSuccess: onSuccess, + OnFailure: onFailure, + Enabled: enabled, + }) + } + + items, err := h.repo.SetBackupNotifications(r.Context(), id, bindings) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + writeError(w, http.StatusNotFound, "backup not found") + return + } + writeError(w, http.StatusBadRequest, err.Error()) + return + } + for i := range items { + redactNotification(&items[i].Notification) + } + writeJSON(w, http.StatusOK, items) +} + func (h *Handler) validateBackupRequest(r *http.Request, b models.Backup, create bool) error { if create && (b.Name == "" || b.ConnectionID == "" || b.CronExpr == "" || b.TargetType == "") { return errors.New("name, connection_id, cron_expr, target_type are required") @@ -586,6 +854,11 @@ func redactRemote(r *models.Remote) { r.SecretKey = "" } +func redactNotification(n *models.NotificationDestination) { + n.DiscordWebhookURL = "" + n.SMTPPassword = "" +} + func redactBackupSecrets(b *models.Backup) { redactConnection(&b.Connection) if b.Remote != nil { diff --git a/internal/api/handler_integration_test.go b/internal/api/handler_integration_test.go index bb6f8de..4d014b7 100644 --- a/internal/api/handler_integration_test.go +++ b/internal/api/handler_integration_test.go @@ -292,6 +292,101 @@ func TestCreateD1ConnectionWithMinimalFields(t *testing.T) { } } +func TestNotificationsEndpointsAndBackupBindings(t *testing.T) { + stack := testutil.NewStack(t) + server := httptest.NewServer(api.NewHandler(stack.Repo, stack.Scheduler).Router()) + t.Cleanup(server.Close) + + createNotificationRes := doJSON(t, http.MethodPost, server.URL+"/notifications", map[string]any{ + "name": "discord-ops", + "type": "discord", + "discord_webhook_url": "https://discord.com/api/webhooks/test-id/test-token", + }) + defer func() { _ = createNotificationRes.Body.Close() }() + if createNotificationRes.StatusCode != http.StatusCreated { + t.Fatalf("expected 201 from create notification, got %d", createNotificationRes.StatusCode) + } + + var destination models.NotificationDestination + decodeJSON(t, createNotificationRes.Body, &destination) + if destination.ID == "" { + t.Fatal("expected created notification id") + } + if destination.DiscordWebhookURL != "" { + t.Fatalf("expected redacted webhook URL, got %q", destination.DiscordWebhookURL) + } + + conn := testutil.MustCreateConnection(t, stack.Repo, "api-notify-source") + backup := testutil.MustCreateLocalBackup(t, stack.Repo, "api-notify-backup", conn.ID, t.TempDir(), true) + + setBindingsRes := doJSON(t, http.MethodPut, server.URL+"/backups/"+backup.ID+"/notifications", map[string]any{ + "notifications": []map[string]any{{ + "notification_id": destination.ID, + "on_success": true, + "on_failure": true, + "enabled": true, + }}, + }) + defer func() { _ = setBindingsRes.Body.Close() }() + if setBindingsRes.StatusCode != http.StatusOK { + t.Fatalf("expected 200 from set backup notifications, got %d", setBindingsRes.StatusCode) + } + + listBindingsRes := doJSON(t, http.MethodGet, server.URL+"/backups/"+backup.ID+"/notifications", nil) + defer func() { _ = listBindingsRes.Body.Close() }() + if listBindingsRes.StatusCode != http.StatusOK { + t.Fatalf("expected 200 from list backup notifications, got %d", listBindingsRes.StatusCode) + } + + var bindings []models.BackupNotification + decodeJSON(t, listBindingsRes.Body, &bindings) + if len(bindings) != 1 { + t.Fatalf("expected 1 backup notification binding, got %d", len(bindings)) + } + if bindings[0].Notification.DiscordWebhookURL != "" { + t.Fatalf("expected redacted webhook URL in bindings, got %q", bindings[0].Notification.DiscordWebhookURL) + } +} + +func TestNotificationTestEndpointSendsDiscordWebhook(t *testing.T) { + stack := testutil.NewStack(t) + apiServer := httptest.NewServer(api.NewHandler(stack.Repo, stack.Scheduler).Router()) + t.Cleanup(apiServer.Close) + + called := false + webhookServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Fatalf("expected POST webhook request, got %s", r.Method) + } + called = true + w.WriteHeader(http.StatusNoContent) + })) + t.Cleanup(webhookServer.Close) + + createRes := doJSON(t, http.MethodPost, apiServer.URL+"/notifications", map[string]any{ + "name": "discord-test", + "type": "discord", + "discord_webhook_url": webhookServer.URL, + }) + defer func() { _ = createRes.Body.Close() }() + if createRes.StatusCode != http.StatusCreated { + t.Fatalf("expected 201 from create notification, got %d", createRes.StatusCode) + } + + var destination models.NotificationDestination + decodeJSON(t, createRes.Body, &destination) + + testRes := doJSON(t, http.MethodPost, apiServer.URL+"/notifications/"+destination.ID+"/test", nil) + defer func() { _ = testRes.Body.Close() }() + if testRes.StatusCode != http.StatusOK { + t.Fatalf("expected 200 from test notification endpoint, got %d", testRes.StatusCode) + } + + if !called { + t.Fatal("expected webhook endpoint to be called") + } +} + func doJSON(t *testing.T, method, url string, payload any) *http.Response { t.Helper() diff --git a/internal/metadata/db.go b/internal/metadata/db.go index 8841dd4..3168d6c 100644 --- a/internal/metadata/db.go +++ b/internal/metadata/db.go @@ -23,5 +23,12 @@ func Open(cfg config.Config) (*gorm.DB, error) { } func Migrate(db *gorm.DB) error { - return db.AutoMigrate(&models.Connection{}, &models.Remote{}, &models.Backup{}, &models.BackupRun{}) + return db.AutoMigrate( + &models.Connection{}, + &models.Remote{}, + &models.Backup{}, + &models.BackupRun{}, + &models.NotificationDestination{}, + &models.BackupNotification{}, + ) } diff --git a/internal/models/models.go b/internal/models/models.go index f299622..4a8bb2e 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -63,3 +63,33 @@ type BackupRun struct { CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } + +type NotificationDestination struct { + ID string `gorm:"primaryKey;size:36" json:"id"` + Name string `gorm:"size:255;not null" json:"name"` + Type string `gorm:"size:32;not null" json:"type"` + Enabled bool `gorm:"not null;default:true" json:"enabled"` + DiscordWebhookURL string `gorm:"size:2048" json:"discord_webhook_url"` + SMTPHost string `gorm:"size:255" json:"smtp_host"` + SMTPPort int `gorm:"not null;default:587" json:"smtp_port"` + SMTPUsername string `gorm:"size:255" json:"smtp_username"` + SMTPPassword string `gorm:"size:1024" json:"smtp_password"` + SMTPFrom string `gorm:"size:255" json:"smtp_from"` + SMTPTo string `gorm:"size:1024" json:"smtp_to"` + SMTPSecurity string `gorm:"size:32;not null;default:starttls" json:"smtp_security"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type BackupNotification struct { + ID string `gorm:"primaryKey;size:36" json:"id"` + BackupID string `gorm:"size:36;not null;index:idx_backup_notification_unique,unique" json:"backup_id"` + NotificationID string `gorm:"size:36;not null;index:idx_backup_notification_unique,unique;index" json:"notification_id"` + OnSuccess bool `gorm:"not null;default:true" json:"on_success"` + OnFailure bool `gorm:"not null;default:true" json:"on_failure"` + Enabled bool `gorm:"not null;default:true" json:"enabled"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Backup Backup `gorm:"foreignKey:BackupID" json:"backup,omitempty"` + Notification NotificationDestination `gorm:"foreignKey:NotificationID" json:"notification,omitempty"` +} diff --git a/internal/notifications/dispatcher.go b/internal/notifications/dispatcher.go new file mode 100644 index 0000000..c073299 --- /dev/null +++ b/internal/notifications/dispatcher.go @@ -0,0 +1,317 @@ +package notifications + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/smtp" + "strconv" + "strings" + "time" + + "anchordb/internal/models" + "anchordb/internal/repository" +) + +type Dispatcher struct { + repo *repository.Repository + httpClient *http.Client +} + +func NewDispatcher(repo *repository.Repository) *Dispatcher { + return &Dispatcher{ + repo: repo, + httpClient: &http.Client{Timeout: 8 * time.Second}, + } +} + +func (d *Dispatcher) NotifyBackupRun(ctx context.Context, backup models.Backup, run models.BackupRun) error { + event := "failed" + if strings.EqualFold(strings.TrimSpace(run.Status), "success") { + event = "success" + } + + destinations, err := d.repo.ListNotificationDestinationsForEvent(ctx, backup.ID, event) + if err != nil { + return err + } + if len(destinations) == 0 { + return nil + } + + errs := make([]string, 0) + for _, destination := range destinations { + if strings.TrimSpace(destination.Type) == "" { + continue + } + + var sendErr error + switch strings.ToLower(strings.TrimSpace(destination.Type)) { + case "discord": + sendErr = d.sendDiscordMessage(ctx, destination, formatDiscordMessage(backup, run)) + case "smtp": + sendErr = d.sendSMTPMessage(ctx, destination, buildSMTPSubject(backup, run), formatDiscordMessage(backup, run)) + default: + sendErr = fmt.Errorf("unsupported notification type: %s", destination.Type) + } + + if sendErr != nil { + errs = append(errs, fmt.Sprintf("%s: %v", destination.Name, sendErr)) + } + } + + if len(errs) > 0 { + return fmt.Errorf(strings.Join(errs, "; ")) + } + + return nil +} + +func (d *Dispatcher) SendTestNotification(ctx context.Context, destination models.NotificationDestination) error { + kind := strings.ToLower(strings.TrimSpace(destination.Type)) + message := "AnchorDB test notification\n- Result: Delivery channel is configured correctly" + switch kind { + case "discord": + return d.sendDiscordMessage(ctx, destination, message) + case "smtp": + return d.sendSMTPMessage(ctx, destination, "[AnchorDB] Test notification", message) + default: + return fmt.Errorf("unsupported notification type: %s", destination.Type) + } +} + +func (d *Dispatcher) sendDiscordMessage(ctx context.Context, destination models.NotificationDestination, content string) error { + webhookURL := strings.TrimSpace(destination.DiscordWebhookURL) + if webhookURL == "" { + return fmt.Errorf("discord_webhook_url is empty") + } + + body, err := json.Marshal(map[string]string{ + "content": content, + }) + if err != nil { + return err + } + + requestCtx, cancel := context.WithTimeout(ctx, 8*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(requestCtx, http.MethodPost, webhookURL, bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + + res, err := d.httpClient.Do(req) + if err != nil { + return err + } + defer func() { _ = res.Body.Close() }() + + if res.StatusCode >= http.StatusBadRequest { + responseBody, _ := io.ReadAll(res.Body) + if trimmed := strings.TrimSpace(string(responseBody)); trimmed != "" { + return fmt.Errorf("discord webhook status %d: %s", res.StatusCode, trimmed) + } + return fmt.Errorf("discord webhook status %d", res.StatusCode) + } + + return nil +} + +func (d *Dispatcher) sendSMTPMessage(ctx context.Context, destination models.NotificationDestination, subject, body string) error { + host := strings.TrimSpace(destination.SMTPHost) + port := destination.SMTPPort + if host == "" || port <= 0 { + return fmt.Errorf("smtp_host and smtp_port are required") + } + security, err := normalizeSMTPSecurity(destination.SMTPSecurity) + if err != nil { + return err + } + + from := strings.TrimSpace(destination.SMTPFrom) + if from == "" { + return fmt.Errorf("smtp_from is required") + } + recipients := parseRecipients(destination.SMTPTo) + if len(recipients) == 0 { + return fmt.Errorf("smtp_to must include at least one recipient") + } + + client, err := dialSMTPClient(ctx, host, port, security) + if err != nil { + return err + } + defer func() { _ = client.Close() }() + + if security == "starttls" { + if ok, _ := client.Extension("STARTTLS"); !ok { + return fmt.Errorf("smtp server does not support STARTTLS") + } + tlsCfg := &tls.Config{ServerName: host, MinVersion: tls.VersionTLS12} + if err := client.StartTLS(tlsCfg); err != nil { + return fmt.Errorf("starttls failed: %w", err) + } + } + + username := strings.TrimSpace(destination.SMTPUsername) + if username != "" { + auth := smtp.PlainAuth("", username, destination.SMTPPassword, host) + if err := client.Auth(auth); err != nil { + return fmt.Errorf("smtp auth failed: %w", err) + } + } + + if err := client.Mail(from); err != nil { + return err + } + for _, recipient := range recipients { + if err := client.Rcpt(recipient); err != nil { + return err + } + } + + bodyWriter, err := client.Data() + if err != nil { + return err + } + + message := buildSMTPMessage(from, recipients, subject, body) + if _, err := bodyWriter.Write([]byte(message)); err != nil { + _ = bodyWriter.Close() + return err + } + if err := bodyWriter.Close(); err != nil { + return err + } + + return client.Quit() +} + +func formatDiscordMessage(backup models.Backup, run models.BackupRun) string { + status := strings.ToUpper(strings.TrimSpace(run.Status)) + if status == "" { + status = "UNKNOWN" + } + startedAt := run.StartedAt.UTC() + if startedAt.IsZero() { + startedAt = time.Now().UTC() + } + + builder := strings.Builder{} + builder.WriteString("AnchorDB backup ") + builder.WriteString(status) + builder.WriteString("\n") + builder.WriteString("- Schedule: ") + builder.WriteString(backup.Name) + builder.WriteString("\n") + builder.WriteString("- Connection: ") + builder.WriteString(backup.Connection.Name) + builder.WriteString("\n") + builder.WriteString("- Run ID: ") + builder.WriteString(run.ID) + builder.WriteString("\n") + builder.WriteString("- Started: ") + builder.WriteString(startedAt.Format(time.RFC3339)) + + if run.FinishedAt != nil { + builder.WriteString("\n- Finished: ") + builder.WriteString(run.FinishedAt.UTC().Format(time.RFC3339)) + } + if strings.TrimSpace(run.OutputKey) != "" { + builder.WriteString("\n- Artifact: ") + builder.WriteString(run.OutputKey) + } + if strings.TrimSpace(run.ErrorText) != "" { + builder.WriteString("\n- Error: ") + builder.WriteString(strings.TrimSpace(run.ErrorText)) + } + + return builder.String() +} + +func buildSMTPSubject(backup models.Backup, run models.BackupRun) string { + status := strings.ToUpper(strings.TrimSpace(run.Status)) + if status == "" { + status = "UNKNOWN" + } + return fmt.Sprintf("[AnchorDB] %s - %s", status, backup.Name) +} + +func buildSMTPMessage(from string, to []string, subject, body string) string { + headers := []string{ + "From: " + from, + "To: " + strings.Join(to, ", "), + "Subject: " + subject, + "Date: " + time.Now().UTC().Format(time.RFC1123Z), + "MIME-Version: 1.0", + "Content-Type: text/plain; charset=UTF-8", + } + + return strings.Join(headers, "\r\n") + "\r\n\r\n" + body + "\r\n" +} + +func normalizeSMTPSecurity(raw string) (string, error) { + security := strings.ToLower(strings.TrimSpace(raw)) + switch security { + case "", "starttls": + return "starttls", nil + case "ssl", "tls", "ssl_tls", "smtps", "implicit_tls", "implicit-tls": + return "ssl_tls", nil + case "none", "plain", "insecure": + return "none", nil + default: + return "", fmt.Errorf("unsupported smtp_security: %s", strings.TrimSpace(raw)) + } +} + +func parseRecipients(input string) []string { + replacer := strings.NewReplacer("\n", ",", ";", ",") + raw := replacer.Replace(input) + parts := strings.Split(raw, ",") + items := make([]string, 0, len(parts)) + for _, part := range parts { + trimmed := strings.TrimSpace(part) + if trimmed == "" { + continue + } + items = append(items, trimmed) + } + return items +} + +func dialSMTPClient(ctx context.Context, host string, port int, security string) (*smtp.Client, error) { + address := host + ":" + strconv.Itoa(port) + if security == "ssl_tls" { + tlsConfig := &tls.Config{ServerName: host, MinVersion: tls.VersionTLS12} + dialer := &net.Dialer{Timeout: 8 * time.Second} + conn, err := tls.DialWithDialer(dialer, "tcp", address, tlsConfig) + if err != nil { + return nil, err + } + client, err := smtp.NewClient(conn, host) + if err != nil { + _ = conn.Close() + return nil, err + } + return client, nil + } + + dialer := &net.Dialer{Timeout: 8 * time.Second} + conn, err := dialer.DialContext(ctx, "tcp", address) + if err != nil { + return nil, err + } + client, err := smtp.NewClient(conn, host) + if err != nil { + _ = conn.Close() + return nil, err + } + return client, nil +} diff --git a/internal/notifications/dispatcher_test.go b/internal/notifications/dispatcher_test.go new file mode 100644 index 0000000..aa82f2c --- /dev/null +++ b/internal/notifications/dispatcher_test.go @@ -0,0 +1,28 @@ +package notifications + +import ( + "context" + "strings" + "testing" + + "anchordb/internal/models" +) + +func TestSendTestNotificationRejectsUnknownSMTPSecurity(t *testing.T) { + dispatcher := &Dispatcher{} + + err := dispatcher.SendTestNotification(context.Background(), models.NotificationDestination{ + Type: "smtp", + SMTPHost: "smtp.example.com", + SMTPPort: 587, + SMTPFrom: "alerts@example.com", + SMTPTo: "ops@example.com", + SMTPSecurity: "starttl", + }) + if err == nil { + t.Fatal("expected unsupported smtp_security error") + } + if !strings.Contains(err.Error(), "unsupported smtp_security") { + t.Fatalf("expected unsupported smtp_security in error, got %v", err) + } +} diff --git a/internal/repository/repository.go b/internal/repository/repository.go index 60b3f34..20b46aa 100644 --- a/internal/repository/repository.go +++ b/internal/repository/repository.go @@ -3,6 +3,7 @@ package repository import ( "context" "errors" + "fmt" "strings" "time" @@ -18,6 +19,20 @@ type Repository struct { crypto *crypto.Service } +type NotificationPatch struct { + Name *string + Type *string + Enabled *bool + DiscordWebhookURL *string + SMTPHost *string + SMTPPort *int + SMTPUsername *string + SMTPPassword *string + SMTPFrom *string + SMTPTo *string + SMTPSecurity *string +} + func New(db *gorm.DB, cryptoSvc *crypto.Service) *Repository { return &Repository{db: db, crypto: cryptoSvc} } @@ -245,6 +260,295 @@ func (r *Repository) ListRemotes(ctx context.Context) ([]models.Remote, error) { return items, nil } +func (r *Repository) CreateNotification(ctx context.Context, n *models.NotificationDestination) error { + if n.ID == "" { + n.ID = uuid.NewString() + } + n.Type = strings.ToLower(strings.TrimSpace(n.Type)) + if n.SMTPPort == 0 { + n.SMTPPort = 587 + } + n.SMTPSecurity = normalizeSMTPSecurityValue(n.SMTPSecurity) + if err := validateNotificationDestination(*n); err != nil { + return err + } + + copy := *n + if err := r.encryptNotificationSecrets(©); err != nil { + return err + } + if err := r.db.WithContext(ctx).Create(©).Error; err != nil { + return err + } + *n = copy + return r.decryptNotification(n) +} + +func (r *Repository) UpdateNotification(ctx context.Context, id string, patch NotificationPatch) (models.NotificationDestination, error) { + existing, err := r.getNotificationRaw(ctx, id) + if err != nil { + return models.NotificationDestination{}, err + } + if err := r.decryptNotification(&existing); err != nil { + return models.NotificationDestination{}, err + } + + if patch.Name != nil { + existing.Name = strings.TrimSpace(*patch.Name) + } + if patch.Type != nil { + existing.Type = strings.ToLower(strings.TrimSpace(*patch.Type)) + } + if patch.Enabled != nil { + existing.Enabled = *patch.Enabled + } + if patch.DiscordWebhookURL != nil { + existing.DiscordWebhookURL = strings.TrimSpace(*patch.DiscordWebhookURL) + } + if patch.SMTPHost != nil { + existing.SMTPHost = strings.TrimSpace(*patch.SMTPHost) + } + if patch.SMTPPort != nil { + existing.SMTPPort = *patch.SMTPPort + } + if patch.SMTPUsername != nil { + existing.SMTPUsername = strings.TrimSpace(*patch.SMTPUsername) + } + if patch.SMTPPassword != nil { + existing.SMTPPassword = *patch.SMTPPassword + } + if patch.SMTPFrom != nil { + existing.SMTPFrom = strings.TrimSpace(*patch.SMTPFrom) + } + if patch.SMTPTo != nil { + existing.SMTPTo = strings.TrimSpace(*patch.SMTPTo) + } + if patch.SMTPSecurity != nil { + existing.SMTPSecurity = normalizeSMTPSecurityValue(*patch.SMTPSecurity) + } + + if existing.SMTPPort == 0 { + existing.SMTPPort = 587 + } + existing.SMTPSecurity = normalizeSMTPSecurityValue(existing.SMTPSecurity) + if err := validateNotificationDestination(existing); err != nil { + return models.NotificationDestination{}, err + } + + saved := existing + if err := r.encryptNotificationSecrets(&saved); err != nil { + return models.NotificationDestination{}, err + } + if err := r.db.WithContext(ctx).Save(&saved).Error; err != nil { + return models.NotificationDestination{}, err + } + if err := r.decryptNotification(&saved); err != nil { + return models.NotificationDestination{}, err + } + return saved, nil +} + +func (r *Repository) DeleteNotification(ctx context.Context, id string) error { + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Delete(&models.BackupNotification{}, "notification_id = ?", id).Error; err != nil { + return err + } + res := tx.Delete(&models.NotificationDestination{}, "id = ?", id) + if res.Error != nil { + return res.Error + } + if res.RowsAffected == 0 { + return gorm.ErrRecordNotFound + } + return nil + }) +} + +func (r *Repository) GetNotification(ctx context.Context, id string) (models.NotificationDestination, error) { + n, err := r.getNotificationRaw(ctx, id) + if err != nil { + return models.NotificationDestination{}, err + } + if err := r.decryptNotification(&n); err != nil { + return models.NotificationDestination{}, err + } + return n, nil +} + +func (r *Repository) ListNotifications(ctx context.Context) ([]models.NotificationDestination, error) { + var items []models.NotificationDestination + err := r.db.WithContext(ctx).Order("created_at desc").Find(&items).Error + if err != nil { + return nil, err + } + for i := range items { + if decErr := r.decryptNotification(&items[i]); decErr != nil { + return nil, decErr + } + } + return items, nil +} + +func (r *Repository) getNotificationRaw(ctx context.Context, id string) (models.NotificationDestination, error) { + var n models.NotificationDestination + err := r.db.WithContext(ctx).First(&n, "id = ?", id).Error + return n, err +} + +func (r *Repository) ListBackupNotifications(ctx context.Context, backupID string) ([]models.BackupNotification, error) { + var items []models.BackupNotification + err := r.db.WithContext(ctx). + Where("backup_id = ?", backupID). + Preload("Notification"). + Order("created_at asc"). + Find(&items).Error + if err != nil { + return nil, err + } + for i := range items { + if items[i].Notification.ID != "" { + if decErr := r.decryptNotification(&items[i].Notification); decErr != nil { + return nil, decErr + } + } + } + return items, nil +} + +func (r *Repository) SetBackupNotifications(ctx context.Context, backupID string, bindings []models.BackupNotification) ([]models.BackupNotification, error) { + err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var backup models.Backup + if err := tx.First(&backup, "id = ?", backupID).Error; err != nil { + return err + } + + if err := tx.Delete(&models.BackupNotification{}, "backup_id = ?", backupID).Error; err != nil { + return err + } + + seen := make(map[string]struct{}, len(bindings)) + for _, binding := range bindings { + notificationID := strings.TrimSpace(binding.NotificationID) + if notificationID == "" { + return errors.New("notification_id is required") + } + if _, ok := seen[notificationID]; ok { + return fmt.Errorf("duplicate notification_id: %s", notificationID) + } + seen[notificationID] = struct{}{} + if !binding.OnSuccess && !binding.OnFailure { + return fmt.Errorf("notification %s must enable on_success or on_failure", notificationID) + } + + var destination models.NotificationDestination + if err := tx.First(&destination, "id = ?", notificationID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fmt.Errorf("notification_id not found: %s", notificationID) + } + return err + } + + now := time.Now().UTC() + item := map[string]any{ + "id": uuid.NewString(), + "backup_id": backupID, + "notification_id": notificationID, + "on_success": binding.OnSuccess, + "on_failure": binding.OnFailure, + "enabled": binding.Enabled, + "created_at": now, + "updated_at": now, + } + + if err := tx.Table("backup_notifications").Create(item).Error; err != nil { + return err + } + } + + return nil + }) + if err != nil { + return nil, err + } + + return r.ListBackupNotifications(ctx, backupID) +} + +func (r *Repository) ListNotificationDestinationsForEvent(ctx context.Context, backupID, event string) ([]models.NotificationDestination, error) { + column := "on_failure" + if strings.EqualFold(strings.TrimSpace(event), "success") { + column = "on_success" + } + + var bindings []models.BackupNotification + err := r.db.WithContext(ctx). + Where("backup_id = ?", backupID). + Where("enabled = ?", true). + Where(column+" = ?", true). + Preload("Notification", "enabled = ?", true). + Find(&bindings).Error + if err != nil { + return nil, err + } + + items := make([]models.NotificationDestination, 0, len(bindings)) + for _, binding := range bindings { + if binding.Notification.ID == "" { + continue + } + if decErr := r.decryptNotification(&binding.Notification); decErr != nil { + return nil, decErr + } + items = append(items, binding.Notification) + } + return items, nil +} + +func validateNotificationDestination(n models.NotificationDestination) error { + if strings.TrimSpace(n.Name) == "" { + return errors.New("name is required") + } + kind := strings.ToLower(strings.TrimSpace(n.Type)) + if kind != "discord" && kind != "smtp" { + return errors.New("type must be discord or smtp") + } + if kind == "discord" { + if strings.TrimSpace(n.DiscordWebhookURL) == "" { + return errors.New("discord_webhook_url is required for discord notifications") + } + return nil + } + + if strings.TrimSpace(n.SMTPHost) == "" || strings.TrimSpace(n.SMTPFrom) == "" || strings.TrimSpace(n.SMTPTo) == "" { + return errors.New("smtp_host, smtp_from, and smtp_to are required for smtp notifications") + } + if n.SMTPPort <= 0 || n.SMTPPort > 65535 { + return errors.New("smtp_port must be between 1 and 65535") + } + security := strings.ToLower(strings.TrimSpace(n.SMTPSecurity)) + if security != "starttls" && security != "ssl_tls" && security != "none" { + return errors.New("smtp_security must be starttls, ssl_tls, or none") + } + if strings.TrimSpace(n.SMTPUsername) != "" && strings.TrimSpace(n.SMTPPassword) == "" { + return errors.New("smtp_password is required when smtp_username is set") + } + return nil +} + +func normalizeSMTPSecurityValue(raw string) string { + security := strings.ToLower(strings.TrimSpace(raw)) + switch security { + case "", "starttls": + return "starttls" + case "ssl", "tls", "ssl_tls", "smtps", "implicit_tls", "implicit-tls": + return "ssl_tls" + case "none", "plain", "insecure": + return "none" + default: + return security + } +} + func (r *Repository) CreateBackup(ctx context.Context, b *models.Backup) error { if b.ID == "" { b.ID = uuid.NewString() @@ -521,3 +825,39 @@ func (r *Repository) decryptRemote(rem *models.Remote) error { rem.SecretKey = secret return nil } + +func (r *Repository) encryptNotificationSecrets(n *models.NotificationDestination) error { + if strings.TrimSpace(n.DiscordWebhookURL) != "" { + enc, err := r.crypto.EncryptString(n.DiscordWebhookURL) + if err != nil { + return err + } + n.DiscordWebhookURL = enc + } + if strings.TrimSpace(n.SMTPPassword) != "" { + enc, err := r.crypto.EncryptString(n.SMTPPassword) + if err != nil { + return err + } + n.SMTPPassword = enc + } + return nil +} + +func (r *Repository) decryptNotification(n *models.NotificationDestination) error { + if strings.TrimSpace(n.DiscordWebhookURL) != "" { + plain, err := r.crypto.DecryptString(n.DiscordWebhookURL) + if err != nil { + return err + } + n.DiscordWebhookURL = plain + } + if strings.TrimSpace(n.SMTPPassword) != "" { + plain, err := r.crypto.DecryptString(n.SMTPPassword) + if err != nil { + return err + } + n.SMTPPassword = plain + } + return nil +} diff --git a/internal/repository/repository_integration_test.go b/internal/repository/repository_integration_test.go index 3f92e71..d839ee4 100644 --- a/internal/repository/repository_integration_test.go +++ b/internal/repository/repository_integration_test.go @@ -6,6 +6,7 @@ import ( "testing" "anchordb/internal/models" + "anchordb/internal/repository" "anchordb/internal/testutil" ) @@ -149,3 +150,88 @@ func TestBackupRunLifecycle(t *testing.T) { t.Fatalf("expected 1 run, got %d", len(runs)) } } + +func TestNotificationLifecycleAndBindings(t *testing.T) { + stack := testutil.NewStack(t) + ctx := context.Background() + + notification := models.NotificationDestination{ + Name: "discord-alerts", + Type: "discord", + Enabled: true, + DiscordWebhookURL: "https://discord.com/api/webhooks/test-id/test-token", + } + + if err := stack.Repo.CreateNotification(ctx, ¬ification); err != nil { + t.Fatalf("create notification: %v", err) + } + + if notification.DiscordWebhookURL == "" { + t.Fatal("expected decrypted webhook URL in create response") + } + + var stored models.NotificationDestination + if err := stack.DB.WithContext(ctx).First(&stored, "id = ?", notification.ID).Error; err != nil { + t.Fatalf("read stored notification: %v", err) + } + if !strings.HasPrefix(stored.DiscordWebhookURL, "enc:v1:") { + t.Fatalf("expected encrypted webhook URL at rest, got %q", stored.DiscordWebhookURL) + } + + updated, err := stack.Repo.UpdateNotification(ctx, notification.ID, repository.NotificationPatch{ + Name: ptrString("discord-alerts-renamed"), + }) + if err != nil { + t.Fatalf("update notification: %v", err) + } + if updated.DiscordWebhookURL != notification.DiscordWebhookURL { + t.Fatalf("expected webhook URL to remain decryptable after non-secret update, got %q", updated.DiscordWebhookURL) + } + + var storedAfterUpdate models.NotificationDestination + if err := stack.DB.WithContext(ctx).First(&storedAfterUpdate, "id = ?", notification.ID).Error; err != nil { + t.Fatalf("read stored notification after update: %v", err) + } + if strings.Count(storedAfterUpdate.DiscordWebhookURL, "enc:v1:") != 1 { + t.Fatalf("expected single encryption prefix, got %q", storedAfterUpdate.DiscordWebhookURL) + } + + conn := testutil.MustCreateConnection(t, stack.Repo, "notif-source") + backup := testutil.MustCreateLocalBackup(t, stack.Repo, "notif-backup", conn.ID, t.TempDir(), true) + + bindings, err := stack.Repo.SetBackupNotifications(ctx, backup.ID, []models.BackupNotification{{ + NotificationID: notification.ID, + Enabled: true, + OnSuccess: true, + OnFailure: false, + }}) + if err != nil { + t.Fatalf("set backup notifications: %v", err) + } + if len(bindings) != 1 { + t.Fatalf("expected 1 binding, got %d", len(bindings)) + } + if bindings[0].CreatedAt.IsZero() || bindings[0].UpdatedAt.IsZero() { + t.Fatalf("expected binding timestamps to be set, got created_at=%v updated_at=%v", bindings[0].CreatedAt, bindings[0].UpdatedAt) + } + + successDestinations, err := stack.Repo.ListNotificationDestinationsForEvent(ctx, backup.ID, "success") + if err != nil { + t.Fatalf("list success destinations: %v", err) + } + if len(successDestinations) != 1 { + t.Fatalf("expected 1 success destination, got %d", len(successDestinations)) + } + + failureDestinations, err := stack.Repo.ListNotificationDestinationsForEvent(ctx, backup.ID, "failed") + if err != nil { + t.Fatalf("list failure destinations: %v", err) + } + if len(failureDestinations) != 0 { + t.Fatalf("expected 0 failure destinations, got %d", len(failureDestinations)) + } +} + +func ptrString(v string) *string { + return &v +} diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go index 10c6ceb..c845031 100644 --- a/internal/scheduler/scheduler.go +++ b/internal/scheduler/scheduler.go @@ -7,6 +7,7 @@ import ( "time" "anchordb/internal/config" + "anchordb/internal/notifications" "anchordb/internal/repository" "github.com/robfig/cron/v3" @@ -15,6 +16,7 @@ import ( type Scheduler struct { repo *repository.Repository executor *Executor + notifier *notifications.Dispatcher cron *cron.Cron sem chan struct{} @@ -26,6 +28,7 @@ func New(repo *repository.Repository, executor *Executor, cfg config.Config) *Sc return &Scheduler{ repo: repo, executor: executor, + notifier: notifications.NewDispatcher(repo), cron: cron.New(), sem: make(chan struct{}, cfg.MaxConcurrentBackups), entries: make(map[string]cron.EntryID), @@ -128,9 +131,17 @@ func (s *Scheduler) executeBackup(backupID string) { outputKey, err := s.executor.Run(ctx, b) if err != nil { log.Printf("backup %s failed: %v", backupID, err) + finished := time.Now().UTC() + run.Status = "failed" + run.ErrorText = err.Error() + run.OutputKey = "" + run.FinishedAt = &finished if runErr == nil { _ = s.repo.FinishBackupRun(ctx, run.ID, "failed", err.Error(), "") } + if notifyErr := s.notifier.NotifyBackupRun(ctx, b, run); notifyErr != nil { + log.Printf("backup %s notification failed: %v", backupID, notifyErr) + } return } @@ -143,12 +154,21 @@ func (s *Scheduler) executeBackup(backupID string) { if err := s.repo.TouchBackupRun(ctx, backupID, last, next); err != nil { log.Printf("backup %s update run metadata failed: %v", backupID, err) } + finished := time.Now().UTC() + run.Status = "success" + run.ErrorText = "" + run.OutputKey = outputKey + run.FinishedAt = &finished if runErr == nil { if err := s.repo.FinishBackupRun(ctx, run.ID, "success", "", outputKey); err != nil { log.Printf("backup %s finish run record failed: %v", backupID, err) } } + if notifyErr := s.notifier.NotifyBackupRun(ctx, b, run); notifyErr != nil { + log.Printf("backup %s notification failed: %v", backupID, notifyErr) + } + log.Printf("backup %s completed", backupID) } diff --git a/internal/ui/handler.go b/internal/ui/handler.go index b012553..c4c8aab 100644 --- a/internal/ui/handler.go +++ b/internal/ui/handler.go @@ -20,6 +20,7 @@ import ( "anchordb/internal/config" "anchordb/internal/models" + "anchordb/internal/notifications" "anchordb/internal/repository" "anchordb/internal/scheduler" @@ -38,31 +39,37 @@ var templateFS embed.FS type Handler struct { repo *repository.Repository scheduler *scheduler.Scheduler + notifier *notifications.Dispatcher cfg config.Config tmpl *template.Template } type pageData struct { - Connections []models.Connection - Remotes []models.Remote - Backups []models.Backup - Runs []models.BackupRun - RunItems []runListItem - RunLog []runLogLine - SelectedRunID string - SelectedRun runListItem - HasSelectedRun bool - SelectedBackupID string - CurrentPage string - Dashboard dashboardStats - - ConnectionsMsg string - ConnectionsErr string - RemotesMsg string - RemotesErr string - BackupsMsg string - BackupsErr string - RunsErr string + Connections []models.Connection + Remotes []models.Remote + Backups []models.Backup + Notifications []models.NotificationDestination + BackupBindings []models.BackupNotification + Runs []models.BackupRun + RunItems []runListItem + RunLog []runLogLine + SelectedRunID string + SelectedRun runListItem + HasSelectedRun bool + SelectedBackupID string + BackupNotificationCounts map[string]int + CurrentPage string + Dashboard dashboardStats + + ConnectionsMsg string + ConnectionsErr string + RemotesMsg string + RemotesErr string + BackupsMsg string + BackupsErr string + NotificationsMsg string + NotificationsErr string + RunsErr string } type dashboardStats struct { @@ -125,9 +132,43 @@ func NewHandler(repo *repository.Repository, scheduler *scheduler.Scheduler, cfg return "muted" } }, + "notificationTypeLabel": func(kind string) string { + switch strings.ToLower(strings.TrimSpace(kind)) { + case "discord": + return "Discord" + case "smtp": + return "SMTP" + default: + return strings.ToUpper(strings.TrimSpace(kind)) + } + }, + "bindingEnabled": func(bindings []models.BackupNotification, notificationID string) bool { + for _, item := range bindings { + if item.NotificationID == notificationID && item.Enabled { + return true + } + } + return false + }, + "bindingOnSuccess": func(bindings []models.BackupNotification, notificationID string) bool { + for _, item := range bindings { + if item.NotificationID == notificationID && item.Enabled { + return item.OnSuccess + } + } + return false + }, + "bindingOnFailure": func(bindings []models.BackupNotification, notificationID string) bool { + for _, item := range bindings { + if item.NotificationID == notificationID && item.Enabled { + return item.OnFailure + } + } + return false + }, }).ParseFS(templateFS, "templates/*.html")) - return &Handler{repo: repo, scheduler: scheduler, cfg: cfg, tmpl: tmpl} + return &Handler{repo: repo, scheduler: scheduler, notifier: notifications.NewDispatcher(repo), cfg: cfg, tmpl: tmpl} } func (h *Handler) Router() http.Handler { @@ -144,6 +185,12 @@ func (h *Handler) Router() http.Handler { r.Get("/remotes", h.remotesPage) r.Get("/remotes/section", h.remotesSection) r.Post("/remotes", h.createRemote) + r.Get("/notifications", h.notificationsPage) + r.Get("/notifications/section", h.notificationsSection) + r.Post("/notifications", h.createNotification) + r.Post("/notifications/{id}/test", h.testNotification) + r.Post("/notifications/{id}/delete", h.deleteNotification) + r.Post("/notifications/bindings", h.saveNotificationBindings) r.Get("/backups", h.backupsPage) r.Get("/schedules", h.backupsPage) r.Get("/backups/section", h.backupsSection) @@ -214,6 +261,27 @@ func (h *Handler) remotesPage(w http.ResponseWriter, r *http.Request) { h.render(w, "page", pageData{CurrentPage: "remotes", Remotes: items}) } +func (h *Handler) notificationsPage(w http.ResponseWriter, r *http.Request) { + selectedBackupID := strings.TrimSpace(r.URL.Query().Get("backup_id")) + data, err := h.loadNotificationsData(r.Context(), selectedBackupID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + data.CurrentPage = "notifications" + h.render(w, "page", data) +} + +func (h *Handler) notificationsSection(w http.ResponseWriter, r *http.Request) { + selectedBackupID := strings.TrimSpace(r.URL.Query().Get("backup_id")) + data, err := h.loadNotificationsData(r.Context(), selectedBackupID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + h.render(w, "notifications_section", data) +} + func (h *Handler) backupsSection(w http.ResponseWriter, r *http.Request) { data, err := h.loadBackupsData(r.Context()) if err != nil { @@ -833,6 +901,135 @@ func (h *Handler) createRemote(w http.ResponseWriter, r *http.Request) { h.renderRemotes(w, "Remote created", "") } +func (h *Handler) createNotification(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + h.renderNotifications(w, "", "Invalid form data", strings.TrimSpace(r.FormValue("backup_id"))) + return + } + + selectedBackupID := strings.TrimSpace(r.FormValue("backup_id")) + notificationType := strings.ToLower(strings.TrimSpace(r.FormValue("type"))) + enabled := r.FormValue("enabled") == "on" || r.FormValue("enabled") == "true" + + item := models.NotificationDestination{ + Name: strings.TrimSpace(r.FormValue("name")), + Type: notificationType, + Enabled: enabled, + DiscordWebhookURL: strings.TrimSpace(r.FormValue("discord_webhook_url")), + SMTPHost: strings.TrimSpace(r.FormValue("smtp_host")), + SMTPPort: 587, + SMTPUsername: strings.TrimSpace(r.FormValue("smtp_username")), + SMTPPassword: r.FormValue("smtp_password"), + SMTPFrom: strings.TrimSpace(r.FormValue("smtp_from")), + SMTPTo: strings.TrimSpace(r.FormValue("smtp_to")), + SMTPSecurity: strings.ToLower(strings.TrimSpace(r.FormValue("smtp_security"))), + } + + portRaw := strings.TrimSpace(r.FormValue("smtp_port")) + portMode := strings.TrimSpace(r.FormValue("smtp_port_mode")) + if portMode != "" && portMode != "other" { + portRaw = portMode + } + if portMode == "other" { + portRaw = strings.TrimSpace(r.FormValue("smtp_port_custom")) + } + if portRaw != "" { + port, err := strconv.Atoi(portRaw) + if err != nil || port <= 0 || port > 65535 { + h.renderNotifications(w, "", "smtp_port must be between 1 and 65535", selectedBackupID) + return + } + item.SMTPPort = port + } + + if err := h.repo.CreateNotification(r.Context(), &item); err != nil { + h.renderNotifications(w, "", err.Error(), selectedBackupID) + return + } + + h.renderNotifications(w, "Notification destination created", "", selectedBackupID) +} + +func (h *Handler) testNotification(w http.ResponseWriter, r *http.Request) { + id := strings.TrimSpace(chi.URLParam(r, "id")) + selectedBackupID := strings.TrimSpace(r.URL.Query().Get("backup_id")) + + item, err := h.repo.GetNotification(r.Context(), id) + if err != nil { + h.renderNotifications(w, "", "notification not found", selectedBackupID) + return + } + if !item.Enabled { + h.renderNotifications(w, "", "notification is disabled", selectedBackupID) + return + } + if err := h.notifier.SendTestNotification(r.Context(), item); err != nil { + h.renderNotifications(w, "", "Test notification failed: "+err.Error(), selectedBackupID) + return + } + + h.renderNotifications(w, "Test notification sent", "", selectedBackupID) +} + +func (h *Handler) deleteNotification(w http.ResponseWriter, r *http.Request) { + id := strings.TrimSpace(chi.URLParam(r, "id")) + selectedBackupID := strings.TrimSpace(r.URL.Query().Get("backup_id")) + if err := h.repo.DeleteNotification(r.Context(), id); err != nil { + h.renderNotifications(w, "", "notification not found", selectedBackupID) + return + } + h.renderNotifications(w, "Notification destination deleted", "", selectedBackupID) +} + +func (h *Handler) saveNotificationBindings(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + h.renderNotifications(w, "", "Invalid form data", strings.TrimSpace(r.FormValue("backup_id"))) + return + } + + selectedBackupID := strings.TrimSpace(r.FormValue("backup_id")) + if selectedBackupID == "" { + h.renderNotifications(w, "", "backup_id is required", selectedBackupID) + return + } + + notifications, err := h.repo.ListNotifications(r.Context()) + if err != nil { + h.renderNotifications(w, "", err.Error(), selectedBackupID) + return + } + + bindings := make([]models.BackupNotification, 0, len(notifications)) + for _, notification := range notifications { + key := notification.ID + enabled := r.FormValue("binding_enabled_"+key) == "on" + if !enabled { + continue + } + + onSuccess := r.FormValue("binding_success_"+key) == "on" + onFailure := r.FormValue("binding_failure_"+key) == "on" + if !onSuccess && !onFailure { + h.renderNotifications(w, "", "each enabled notification must have success or failure selected", selectedBackupID) + return + } + + bindings = append(bindings, models.BackupNotification{ + NotificationID: notification.ID, + Enabled: true, + OnSuccess: onSuccess, + OnFailure: onFailure, + }) + } + + if _, err := h.repo.SetBackupNotifications(r.Context(), selectedBackupID, bindings); err != nil { + h.renderNotifications(w, "", err.Error(), selectedBackupID) + return + } + + h.renderNotifications(w, "Schedule notifications updated", "", selectedBackupID) +} + func (h *Handler) createBackup(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { h.renderBackups(w, "", "Invalid form data") @@ -992,6 +1189,14 @@ func (h *Handler) renderBackups(w http.ResponseWriter, msg, errMsg string) { h.render(w, "backups_section", data) } +func (h *Handler) renderNotifications(w http.ResponseWriter, msg, errMsg, selectedBackupID string) { + data, _ := h.loadNotificationsData(context.Background(), selectedBackupID) + data.CurrentPage = "notifications" + data.NotificationsMsg = msg + data.NotificationsErr = errMsg + h.render(w, "notifications_section", data) +} + func (h *Handler) render(w http.ResponseWriter, name string, data pageData) { w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := h.tmpl.ExecuteTemplate(w, name, data); err != nil { @@ -1026,7 +1231,78 @@ func (h *Handler) loadBackupsData(ctx context.Context) (pageData, error) { if err != nil { return pageData{}, err } - return pageData{Connections: connections, Remotes: remotes, Backups: backups}, nil + counts := make(map[string]int, len(backups)) + for _, backup := range backups { + bindings, bindErr := h.repo.ListBackupNotifications(ctx, backup.ID) + if bindErr != nil { + return pageData{}, bindErr + } + active := 0 + for _, binding := range bindings { + if binding.Enabled { + active++ + } + } + counts[backup.ID] = active + } + + return pageData{ + Connections: connections, + Remotes: remotes, + Backups: backups, + BackupNotificationCounts: counts, + }, nil +} + +func (h *Handler) loadNotificationsData(ctx context.Context, selectedBackupID string) (pageData, error) { + connections, remotes, backups, err := h.loadCoreData(ctx) + if err != nil { + return pageData{}, err + } + + notifications, err := h.repo.ListNotifications(ctx) + if err != nil { + return pageData{}, err + } + redactNotifications(notifications) + + counts := make(map[string]int, len(backups)) + for _, backup := range backups { + bindings, bindErr := h.repo.ListBackupNotifications(ctx, backup.ID) + if bindErr != nil { + return pageData{}, bindErr + } + active := 0 + for _, binding := range bindings { + if binding.Enabled { + active++ + } + } + counts[backup.ID] = active + } + + if strings.TrimSpace(selectedBackupID) == "" && len(backups) > 0 { + selectedBackupID = backups[0].ID + } + + bindings := make([]models.BackupNotification, 0) + if strings.TrimSpace(selectedBackupID) != "" { + bindings, err = h.repo.ListBackupNotifications(ctx, selectedBackupID) + if err != nil { + return pageData{}, err + } + redactBackupNotifications(bindings) + } + + return pageData{ + Connections: connections, + Remotes: remotes, + Backups: backups, + Notifications: notifications, + BackupBindings: bindings, + SelectedBackupID: selectedBackupID, + BackupNotificationCounts: counts, + }, nil } func (h *Handler) loadDashboardData(ctx context.Context) (pageData, error) { @@ -1174,6 +1450,20 @@ func redactBackups(items []models.Backup) { } } +func redactNotifications(items []models.NotificationDestination) { + for i := range items { + items[i].DiscordWebhookURL = "" + items[i].SMTPPassword = "" + } +} + +func redactBackupNotifications(items []models.BackupNotification) { + for i := range items { + items[i].Notification.DiscordWebhookURL = "" + items[i].Notification.SMTPPassword = "" + } +} + func connectionTypeLabel(v string) string { switch strings.ToLower(strings.TrimSpace(v)) { case "postgres", "postgresql": diff --git a/internal/ui/handler_integration_test.go b/internal/ui/handler_integration_test.go index 3fe61bc..a55a961 100644 --- a/internal/ui/handler_integration_test.go +++ b/internal/ui/handler_integration_test.go @@ -1,6 +1,7 @@ package ui_test import ( + "context" "io" "net/http" "net/http/httptest" @@ -10,6 +11,7 @@ import ( "strings" "testing" + "anchordb/internal/models" "anchordb/internal/testutil" "anchordb/internal/ui" ) @@ -150,3 +152,119 @@ func TestCreateD1ConnectionFromUI(t *testing.T) { t.Fatalf("expected success message, got %q", string(body)) } } + +func TestCreateNotificationAndBindToScheduleFromUI(t *testing.T) { + stack := testutil.NewStack(t) + h := ui.NewHandler(stack.Repo, stack.Scheduler, stack.Config) + server := httptest.NewServer(h.Router()) + t.Cleanup(server.Close) + + conn := testutil.MustCreateConnection(t, stack.Repo, "ui-notify-source") + backup := testutil.MustCreateLocalBackup(t, stack.Repo, "ui-notify-backup", conn.ID, t.TempDir(), true) + + createForm := url.Values{} + createForm.Set("backup_id", backup.ID) + createForm.Set("name", "ops-discord") + createForm.Set("type", "discord") + createForm.Set("discord_webhook_url", "https://discord.com/api/webhooks/test-id/test-token") + + createRes, err := http.PostForm(server.URL+"/notifications", createForm) + if err != nil { + t.Fatalf("post notification form: %v", err) + } + defer func() { _ = createRes.Body.Close() }() + if createRes.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", createRes.StatusCode) + } + body, err := io.ReadAll(createRes.Body) + if err != nil { + t.Fatalf("read create notification response body: %v", err) + } + if !strings.Contains(string(body), "Notification destination created") { + t.Fatalf("expected create notification message, got %q", string(body)) + } + + notifications, err := stack.Repo.ListNotifications(context.Background()) + if err != nil { + t.Fatalf("list notifications: %v", err) + } + if len(notifications) != 1 { + t.Fatalf("expected 1 notification, got %d", len(notifications)) + } + + bindingsForm := url.Values{} + bindingsForm.Set("backup_id", backup.ID) + bindingsForm.Set("binding_enabled_"+notifications[0].ID, "on") + bindingsForm.Set("binding_success_"+notifications[0].ID, "on") + bindingsForm.Set("binding_failure_"+notifications[0].ID, "on") + + bindRes, err := http.PostForm(server.URL+"/notifications/bindings", bindingsForm) + if err != nil { + t.Fatalf("post bindings form: %v", err) + } + defer func() { _ = bindRes.Body.Close() }() + if bindRes.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", bindRes.StatusCode) + } + + bindBody, err := io.ReadAll(bindRes.Body) + if err != nil { + t.Fatalf("read bind response body: %v", err) + } + if !strings.Contains(string(bindBody), "Schedule notifications updated") { + t.Fatalf("expected schedule bindings updated message, got %q", string(bindBody)) + } + + bindings, err := stack.Repo.ListBackupNotifications(context.Background(), backup.ID) + if err != nil { + t.Fatalf("list backup bindings: %v", err) + } + if len(bindings) != 1 { + t.Fatalf("expected 1 backup binding, got %d", len(bindings)) + } +} + +func TestNotificationTestFromUI(t *testing.T) { + stack := testutil.NewStack(t) + h := ui.NewHandler(stack.Repo, stack.Scheduler, stack.Config) + server := httptest.NewServer(h.Router()) + t.Cleanup(server.Close) + + called := false + webhookServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + w.WriteHeader(http.StatusNoContent) + })) + t.Cleanup(webhookServer.Close) + + notification := models.NotificationDestination{ + Name: "ui-discord-test", + Type: "discord", + Enabled: true, + DiscordWebhookURL: webhookServer.URL, + } + if err := stack.Repo.CreateNotification(context.Background(), ¬ification); err != nil { + t.Fatalf("create notification destination: %v", err) + } + + res, err := http.Post(server.URL+"/notifications/"+notification.ID+"/test", "application/x-www-form-urlencoded", strings.NewReader("")) + if err != nil { + t.Fatalf("post notification test endpoint: %v", err) + } + defer func() { _ = res.Body.Close() }() + + if res.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", res.StatusCode) + } + + body, err := io.ReadAll(res.Body) + if err != nil { + t.Fatalf("read response body: %v", err) + } + if !strings.Contains(string(body), "Test notification sent") { + t.Fatalf("expected test notification success message, got %q", string(body)) + } + if !called { + t.Fatal("expected webhook to be called") + } +} diff --git a/internal/ui/templates/app.html b/internal/ui/templates/app.html index 3cbfa33..aa3e9c4 100644 --- a/internal/ui/templates/app.html +++ b/internal/ui/templates/app.html @@ -677,6 +677,10 @@ Remotes + + + Notifications +
@@ -693,11 +697,15 @@ {{if eq .CurrentPage "dashboard"}}{{template "dashboard_section" .}}{{end}} {{if eq .CurrentPage "connections"}}{{template "connections_section" .}}{{end}} {{if eq .CurrentPage "remotes"}}{{template "remotes_section" .}}{{end}} + {{if eq .CurrentPage "notifications"}}{{template "notifications_section" .}}{{end}} {{if eq .CurrentPage "schedules"}}{{template "backups_section" .}}{{end}} {{if eq .CurrentPage "runs"}}{{template "runs_page" .}}{{end}} + {{if eq .CurrentPage "notifications"}}{{template "notifications_dialog" .}}{{end}} + {{if eq .CurrentPage "schedules"}}{{template "schedule_dialog" .}}{{end}} +