package httpapi import ( "bytes" "errors" "log/slog" "net/http" "time" "github.com/syncova/syncova/packages/audit" "github.com/syncova/syncova/packages/auth" "github.com/syncova/syncova/packages/platform/logging" "github.com/syncova/syncova/packages/reports" ) // reportHandler bedient die Berichte (SYNCOVA_API.md §21). type reportHandler struct { // reportGenerator erzeugt die Berichte. reportGenerator *reports.Generator // auditRecorder protokolliert die Ausgabe eines Berichts. auditRecorder audit.Recorder // logger protokolliert technische Fehler. logger *slog.Logger } // reportCatalogEntry ist ein Eintrag des Berichtskatalogs. type reportCatalogEntry struct { // Type ist der maschinenlesbare Bezeichner. Type reports.ReportType `json:"type"` // Title ist die Bezeichnung. Title string `json:"title"` // Description erklaert, welche Frage der Bericht beantwortet. Description string `json:"description"` // PeriodKind beschreibt die Art des Zeitbezugs. PeriodKind reports.PeriodKind `json:"period_kind"` // DefaultPeriod beschreibt den Standardzeitraum. DefaultPeriod string `json:"default_period,omitempty"` // Formats sind die verfuegbaren Ausgabeformate. Formats []reports.Format `json:"formats"` } // handleListReports bedient GET /reports. func (handler *reportHandler) handleListReports(responseWriter http.ResponseWriter, request *http.Request) { catalogEntries := make([]reportCatalogEntry, 0, 9) for _, definition := range reports.Definitions() { catalogEntries = append(catalogEntries, reportCatalogEntry{ Type: definition.Type, Title: definition.Title, Description: definition.Description, PeriodKind: definition.PeriodKind, DefaultPeriod: definition.DefaultPeriodLabel, Formats: definition.Formats, }) } WriteSuccess(responseWriter, request, http.StatusOK, catalogEntries) } // generateReportRequest ist der Rumpf von POST /reports/generate. type generateReportRequest struct { // Type ist die gewuenschte Reportart. Type string `json:"type"` // From ist der Beginn des Zeitraums; ohne Angabe gilt der Standardzeitraum. From *time.Time `json:"from"` // To ist das Ende des Zeitraums; ohne Angabe gilt jetzt. To *time.Time `json:"to"` // Format ist das Ausgabeformat; ohne Angabe JSON. Format string `json:"format"` } // handleGenerateReport bedient POST /reports/generate. // // Der Bericht wird erzeugt und unmittelbar ausgeliefert; er wird **nicht** // gespeichert. Ein abgelegter Bericht veraltet mit jedem Tag, ohne dass sich an // ihm etwas aendert — dieselbe Ueberlegung wie bei der Recovery Assurance // (Phase 10) und der Sicherheitsbewertung (Phase 15). Wer ihn aufbewahren will, // laedt ihn herunter; die Datei traegt ihren Erzeugungszeitpunkt bei sich. func (handler *reportHandler) handleGenerateReport(responseWriter http.ResponseWriter, request *http.Request) { requestLogger := logging.WithContext(request.Context(), handler.logger) var reportRequest generateReportRequest if decodeError := decodeJSONBody(request, &reportRequest); decodeError != nil { WriteError(responseWriter, request, requestLogger, decodeError) return } reportType, typeError := reports.ParseType(reportRequest.Type) if typeError != nil { WriteError(responseWriter, request, requestLogger, NewBadRequestError(typeError.Error())) return } outputFormat, formatError := reports.ParseFormat(reportRequest.Format) if formatError != nil { WriteError(responseWriter, request, requestLogger, NewBadRequestError(formatError.Error())) return } definition, _ := reports.FindDefinition(reportType) periodFrom, periodTo, periodError := reports.ResolvePeriod(definition, reportRequest.From, reportRequest.To, time.Now().UTC()) if periodError != nil { WriteError(responseWriter, request, requestLogger, NewBadRequestError(periodError.Error())) return } currentUser, isAuthenticated := AuthenticatedUserFromContext(request.Context()) requestedBy := "" if isAuthenticated { requestedBy = currentUser.Username } generatedReport, generateError := handler.reportGenerator.Generate(request.Context(), reportType, periodFrom, periodTo, requestedBy) if generateError != nil { if errors.Is(generateError, reports.ErrSecurityInspectorMissing) { WriteError(responseWriter, request, requestLogger, NewServiceUnavailableError("Für diesen Bericht ist keine Sicherheitsprüfung "+ "eingerichtet. Ein Bericht mit leeren Abschnitten sähe aus wie eine Anlage "+ "ohne Befunde.")) return } requestLogger.Error("der bericht konnte nicht erzeugt werden", slog.String("art", string(reportType)), slog.String("grund", generateError.Error())) WriteError(responseWriter, request, requestLogger, NewInternalError(generateError)) return } // Erst vollstaendig in den Puffer, dann ausliefern. Schriebe der Erzeuger // direkt in die Antwort, stuenden bei einem Fehler auf halber Strecke schon // Kopfzeilen und ein halber Bericht beim Empfaenger — eine abgeschnittene // CSV-Datei sieht aus wie eine vollstaendige mit weniger Zeilen. outputBuffer := &bytes.Buffer{} if writeError := reports.Write(outputBuffer, generatedReport, outputFormat); writeError != nil { requestLogger.Error("der bericht konnte nicht ausgegeben werden", slog.String("format", string(outputFormat)), slog.String("grund", writeError.Error())) WriteError(responseWriter, request, requestLogger, NewInternalError(writeError)) return } if isAuthenticated { handler.recordGeneration(request, currentUser, generatedReport, outputFormat) } // JSON geht durch die uebliche Antworthuelle, damit der Frontend-Client // nicht zwei Arten von Antworten unterscheiden muss. CSV und PDF sind // Dateien und werden roh ausgeliefert. if outputFormat == reports.FormatJSON { WriteSuccess(responseWriter, request, http.StatusOK, generatedReport) return } fileName := reports.SanitizeFileName(reports.FileName(generatedReport, outputFormat)) responseWriter.Header().Set("Content-Type", outputFormat.ContentType()) responseWriter.Header().Set("Content-Disposition", `attachment; filename="`+fileName+`"`) responseWriter.WriteHeader(http.StatusOK) if _, writeError := responseWriter.Write(outputBuffer.Bytes()); writeError != nil { requestLogger.Warn("der bericht konnte nicht vollstaendig gesendet werden", slog.String("grund", writeError.Error())) } } // recordGeneration protokolliert die Ausgabe eines Berichts. // // Scheitert das Protokollieren, wird der Bericht trotzdem ausgeliefert und der // Fehler vermerkt: Ein Leserecht wegen eines Protokollfehlers zu verweigern // waere die falsche Abwaegung — anders als bei einer destruktiven Handlung. func (handler *reportHandler) recordGeneration(request *http.Request, currentUser auth.User, generatedReport *reports.Report, outputFormat reports.Format) { if handler.auditRecorder == nil { return } actingUserIdentifier := currentUser.ID correlationID, _ := logging.CorrelationIDFromContext(request.Context()) auditEvent := audit.Event{ UserID: &actingUserIdentifier, ActorUsername: currentUser.Username, Action: audit.ActionReportGenerated, EntityType: "report", Result: audit.ResultSuccess, IPAddress: clientIPAddress(request), UserAgent: request.UserAgent(), CorrelationID: correlationID, Details: map[string]any{ "report_type": string(generatedReport.Type), "format": string(outputFormat), "period_from": generatedReport.PeriodFrom, "period_to": generatedReport.PeriodTo, }, } if recordError := handler.auditRecorder.Record(request.Context(), auditEvent); recordError != nil { logging.WithContext(request.Context(), handler.logger).Warn( "die berichtsausgabe konnte nicht protokolliert werden", slog.String("grund", recordError.Error())) } }