package httpapi import ( "context" "errors" "log/slog" "net/http" "github.com/google/uuid" "github.com/syncova/syncova/packages/alerting" "github.com/syncova/syncova/packages/audit" "github.com/syncova/syncova/packages/auth" "github.com/syncova/syncova/packages/platform/crypto" "github.com/syncova/syncova/packages/platform/logging" "github.com/syncova/syncova/packages/platform/netguard" ) // alertHandler bedient Meldungen und Benachrichtigungen (Phase 14). type alertHandler struct { // alertStore ist die Datenzugriffsschicht der Meldungen. alertStore *alerting.Store // secretStore verschlüsselt die Zugangsgeheimnisse der Kanäle. secretStore crypto.SecretStore // auditRecorder protokolliert Änderungen an Kanälen. auditRecorder audit.Recorder // addressGuard wehrt interne Zieladressen ab (SSRF, Phase 19). addressGuard *netguard.Guard // logger protokolliert technische Fehler. logger *slog.Logger } // acknowledgeRequest ist der Rumpf von POST /alerts/{id}/acknowledge. type acknowledgeRequest struct { // Note ist eine Bemerkung des Bestätigenden. Note string `json:"note,omitempty"` } // resolveRequest ist der Rumpf von POST /alerts/{id}/resolve. type resolveRequest struct { // Note begründet die Auflösung. Note string `json:"note,omitempty"` } // channelRequest ist der Rumpf von POST /notification-channels. type channelRequest struct { // Name ist die sprechende Bezeichnung. Name string `json:"name"` // ChannelType ist die Art der Zustellung. ChannelType string `json:"channel_type"` // MinimumSeverity ist der niedrigste zugestellte Schweregrad. MinimumSeverity string `json:"minimum_severity,omitempty"` // Configuration trägt die Zustelldaten ohne Geheimnisse. Configuration map[string]any `json:"configuration"` // Secret ist das Zugangsgeheimnis. // // Es geht nur hinein, nie heraus: Keine Antwort dieses Handlers enthält es // jemals wieder (PROMPT.md §140). Secret string `json:"secret,omitempty"` } // handleListAlerts bedient GET /alerts. func (handler *alertHandler) handleListAlerts(responseWriter http.ResponseWriter, request *http.Request) { requestLogger := logging.WithContext(request.Context(), handler.logger) listFilter := alerting.AlertFilter{ Status: request.URL.Query().Get("status"), Severity: request.URL.Query().Get("severity"), OnlyActive: request.URL.Query().Get("only_active") == "true", Page: parsePositiveInteger(request.URL.Query().Get("page"), 1), PageSize: parsePositiveInteger(request.URL.Query().Get("page_size"), 50), } loadedAlerts, totalCount, listError := handler.alertStore.ListAlerts(request.Context(), listFilter) if listError != nil { WriteError(responseWriter, request, requestLogger, NewInternalError(listError)) return } WritePaginatedSuccess(responseWriter, request, loadedAlerts, PaginationMeta{ Page: listFilter.Page, PageSize: listFilter.PageSize, Total: int64(totalCount), }) } // handleAlertSummary bedient GET /alerts/summary. // // Getrennt von der Liste, weil die Übersicht sie bei jedem Aufruf braucht: Eine // Seite von fünfzig Meldungen zu laden, um drei Zahlen zu bilden, wäre Aufwand // ohne Nutzen. func (handler *alertHandler) handleAlertSummary(responseWriter http.ResponseWriter, request *http.Request) { requestLogger := logging.WithContext(request.Context(), handler.logger) summary, summaryError := handler.alertStore.Summary(request.Context()) if summaryError != nil { WriteError(responseWriter, request, requestLogger, NewInternalError(summaryError)) return } // Das Regelwerk kommt mit: Wer die Meldungslage beurteilt, muss wissen, // worauf überhaupt geachtet wird — und worauf nicht. WriteSuccess(responseWriter, request, http.StatusOK, map[string]any{ "summary": summary, "rules": alerting.Rules(), "available_rule_count": alerting.AvailableRuleCount(), }) } // handleGetAlert bedient GET /alerts/{id}. func (handler *alertHandler) handleGetAlert(responseWriter http.ResponseWriter, request *http.Request) { requestLogger := logging.WithContext(request.Context(), handler.logger) alertIdentifier, parseError := parsePathIdentifier(request, "Die Meldungskennung ist keine gültige UUID.") if parseError != nil { WriteError(responseWriter, request, requestLogger, parseError) return } loadedAlert, readError := handler.alertStore.GetAlert(request.Context(), alertIdentifier) if readError != nil { WriteError(responseWriter, request, requestLogger, translateAlertError(readError)) return } WriteSuccess(responseWriter, request, http.StatusOK, loadedAlert) } // handleAcknowledgeAlert bedient POST /alerts/{id}/acknowledge. func (handler *alertHandler) handleAcknowledgeAlert(responseWriter http.ResponseWriter, request *http.Request) { requestLogger := logging.WithContext(request.Context(), handler.logger) actingUser, _ := AuthenticatedUserFromContext(request.Context()) alertIdentifier, parseError := parsePathIdentifier(request, "Die Meldungskennung ist keine gültige UUID.") if parseError != nil { WriteError(responseWriter, request, requestLogger, parseError) return } var acknowledgePayload acknowledgeRequest if decodeError := decodeJSONBody(request, &acknowledgePayload); decodeError != nil { WriteError(responseWriter, request, requestLogger, decodeError) return } acknowledgedAlert, acknowledgeError := handler.alertStore.AcknowledgeAlert(request.Context(), alertIdentifier, actingUser.ID, acknowledgePayload.Note) if acknowledgeError != nil { WriteError(responseWriter, request, requestLogger, translateAlertError(acknowledgeError)) return } WriteSuccess(responseWriter, request, http.StatusOK, map[string]any{ "alert": acknowledgedAlert, "message": "Die Meldung ist zur Kenntnis genommen. Sie bleibt offen, bis ihre Ursache " + "verschwindet — Bestätigen heisst nicht Erledigen.", }) } // handleResolveAlert bedient POST /alerts/{id}/resolve. func (handler *alertHandler) handleResolveAlert(responseWriter http.ResponseWriter, request *http.Request) { requestLogger := logging.WithContext(request.Context(), handler.logger) actingUser, _ := AuthenticatedUserFromContext(request.Context()) alertIdentifier, parseError := parsePathIdentifier(request, "Die Meldungskennung ist keine gültige UUID.") if parseError != nil { WriteError(responseWriter, request, requestLogger, parseError) return } var resolvePayload resolveRequest if decodeError := decodeJSONBody(request, &resolvePayload); decodeError != nil { WriteError(responseWriter, request, requestLogger, decodeError) return } resolvedAlert, resolveError := handler.alertStore.ResolveAlert(request.Context(), alertIdentifier, actingUser.ID, resolvePayload.Note) if resolveError != nil { WriteError(responseWriter, request, requestLogger, translateAlertError(resolveError)) return } // Der Hinweis ist wichtig: Die Auswertung prüft im nächsten Durchgang // erneut. Besteht die Ursache fort, entsteht eine neue Meldung — von Hand // schliessen macht ein Problem nicht weg. WriteSuccess(responseWriter, request, http.StatusOK, map[string]any{ "alert": resolvedAlert, "message": "Die Meldung wurde geschlossen. Besteht die Ursache weiterhin, erzeugt die " + "nächste Auswertung eine neue Meldung.", }) } // handleListChannels bedient GET /notification-channels. func (handler *alertHandler) handleListChannels(responseWriter http.ResponseWriter, request *http.Request) { requestLogger := logging.WithContext(request.Context(), handler.logger) loadedChannels, listError := handler.alertStore.ListChannels(request.Context()) if listError != nil { WriteError(responseWriter, request, requestLogger, NewInternalError(listError)) return } WriteSuccess(responseWriter, request, http.StatusOK, loadedChannels) } // handleCreateChannel bedient POST /notification-channels. func (handler *alertHandler) handleCreateChannel(responseWriter http.ResponseWriter, request *http.Request) { requestLogger := logging.WithContext(request.Context(), handler.logger) actingUser, _ := AuthenticatedUserFromContext(request.Context()) var channelPayload channelRequest if decodeError := decodeJSONBody(request, &channelPayload); decodeError != nil { WriteError(responseWriter, request, requestLogger, decodeError) return } // Die Zieladresse wird geprüft, bevor der Kanal entsteht. // // Die Zustellung prüft ein zweites Mal — dort erst kurz vor dem // Verbindungsaufbau, weil ein Name zwischenzeitlich auf eine andere Adresse // zeigen kann. Diese Prüfung hier hat einen anderen Zweck: Der Betreiber // soll die Meldung sofort sehen und nicht erst, wenn eine Meldung // ausbleibt. if guardError := handler.checkChannelTarget(request.Context(), channelPayload); guardError != nil { WriteError(responseWriter, request, requestLogger, guardError) return } createdChannel, createError := handler.alertStore.CreateChannel(request.Context(), alerting.CreateChannelRequest{ Name: channelPayload.Name, ChannelType: alerting.ChannelType(channelPayload.ChannelType), MinimumSeverity: alerting.Severity(channelPayload.MinimumSeverity), Configuration: channelPayload.Configuration, Secret: channelPayload.Secret, CreatedBy: &actingUser.ID, }, handler.secretStore) if createError != nil { WriteError(responseWriter, request, requestLogger, translateAlertError(createError)) return } // Der Kanal wird auditiert: Wer Benachrichtigungen umleitet, kann damit // erreichen, dass niemand mehr von einem Ausfall erfährt. handler.recordAudit(request, actingUser, audit.ActionNotificationChannelCreated, &createdChannel.ID, map[string]any{ "name": createdChannel.Name, "channel_type": string(createdChannel.ChannelType), "minimum_severity": string(createdChannel.MinimumSeverity), }) WriteSuccess(responseWriter, request, http.StatusCreated, createdChannel) } // handleDeleteChannel bedient DELETE /notification-channels/{id}. func (handler *alertHandler) handleDeleteChannel(responseWriter http.ResponseWriter, request *http.Request) { requestLogger := logging.WithContext(request.Context(), handler.logger) actingUser, _ := AuthenticatedUserFromContext(request.Context()) channelIdentifier, parseError := parsePathIdentifier(request, "Die Kanalkennung ist keine gültige UUID.") if parseError != nil { WriteError(responseWriter, request, requestLogger, parseError) return } existingChannel, readError := handler.alertStore.GetChannel(request.Context(), channelIdentifier) if readError != nil { WriteError(responseWriter, request, requestLogger, translateAlertError(readError)) return } if deleteError := handler.alertStore.DeleteChannel(request.Context(), channelIdentifier); deleteError != nil { WriteError(responseWriter, request, requestLogger, translateAlertError(deleteError)) return } handler.recordAudit(request, actingUser, audit.ActionNotificationChannelDeleted, &channelIdentifier, map[string]any{"name": existingChannel.Name}) WriteSuccess(responseWriter, request, http.StatusOK, map[string]any{ "message": "Der Benachrichtigungskanal wurde gelöscht. Meldungen entstehen weiterhin, " + "werden aber nicht mehr über diesen Weg zugestellt.", }) } // recordAudit schreibt ein Auditereignis. func (handler *alertHandler) recordAudit(request *http.Request, actingUser auth.User, auditAction audit.Action, entityIdentifier *uuid.UUID, auditDetails map[string]any) { if handler.auditRecorder == nil { return } correlationIdentifier, _ := logging.CorrelationIDFromContext(request.Context()) recordError := handler.auditRecorder.Record(request.Context(), audit.Event{ UserID: &actingUser.ID, ActorUsername: actingUser.Username, Action: auditAction, EntityType: "notification_channel", EntityID: entityIdentifier, Result: audit.ResultSuccess, IPAddress: clientIPAddress(request), UserAgent: request.UserAgent(), CorrelationID: correlationIdentifier, Details: auditDetails, }) if recordError != nil { logging.WithContext(request.Context(), handler.logger).Error( "das auditereignis konnte nicht geschrieben werden", slog.String("aktion", string(auditAction)), slog.String("grund", recordError.Error())) } } // translateAlertError bildet Fehler der Fachschicht auf API-Fehler ab. func translateAlertError(occurredError error) *APIError { switch { case errors.Is(occurredError, alerting.ErrAlertNotFound): return NewNotFoundError("Die Meldung wurde nicht gefunden.") case errors.Is(occurredError, alerting.ErrChannelNotFound): return NewNotFoundError("Der Benachrichtigungskanal wurde nicht gefunden.") case errors.Is(occurredError, alerting.ErrAlertNotOpen): // 409 und nicht 404: Die Meldung gibt es, sie ist nur schon erledigt. conflictError := NewValidationError("Diese Meldung ist bereits erledigt.") conflictError.Code = ErrorCodeConflict conflictError.StatusCode = http.StatusConflict return conflictError default: return NewInternalError(occurredError) } } // checkChannelTarget prüft die Zieladresse eines Benachrichtigungswegs. // // Geprüft werden Webhook-Adresse und SMTP-Server. Fehlt die Angabe, greift // diese Prüfung nicht — die fachliche Vollständigkeit prüft der Store. func (handler *alertHandler) checkChannelTarget(checkContext context.Context, channelPayload channelRequest) *APIError { if handler.addressGuard == nil || channelPayload.Configuration == nil { return nil } if webhookURL, hasURL := channelPayload.Configuration["url"].(string); hasURL && webhookURL != "" { if guardError := handler.addressGuard.CheckURL(checkContext, webhookURL); guardError != nil { return NewValidationError(guardError.Error()) } } if smtpHost, hasHost := channelPayload.Configuration["host"].(string); hasHost && smtpHost != "" { if guardError := handler.addressGuard.CheckHost(checkContext, smtpHost); guardError != nil { return NewValidationError(guardError.Error()) } } return nil }