From cd5ca8da5361acaddea3bdf3ca717e080670090f Mon Sep 17 00:00:00 2001 From: patrick Date: Tue, 27 Oct 2026 13:52:24 +0100 Subject: [PATCH] refactor: geteiltes Modul common/ (Pilot httpclient, apierror) - common/httpclient: bisher byte-identisch in dms, mail und archive - common/apierror: bisher byte-identisch in Root und archive - Importpfade, go.mod (require + replace), archtest-Ausnahmen und CI-Pfadfilter der mail-/archive-Pflichttest-Gates angepasst - Waechtertest fuer den Standard-Client prueft weiter dms, mail, archive - dms/internal/apierror bleibt eigenstaendig (5xx-Fix in eigenem PR) Co-Authored-By: Claude Sonnet 5.5 --- .gitea/workflows/archive-pflichttest-gate.yml | 1 + .gitea/workflows/mail-pflichttest-gate.yml | 1 + PRODUKTARCHITEKTUR.md | 2 + archive/go.mod | 4 + archive/internal/archtest/errorleak_test.go | 2 +- archive/internal/auditclient/auditclient.go | 2 +- .../internal/ediscoveryhttp/ediscoveryhttp.go | 2 +- archive/internal/httpharden/harden.go | 2 +- archive/internal/kekclient/kekclient.go | 2 +- archive/internal/notifyclient/notifyclient.go | 2 +- archive/internal/rbacclient/rbacclient.go | 2 +- .../tenantregistryclient.go | 2 +- .../internal => common}/apierror/apierror.go | 0 .../apierror/apierror_test.go | 0 common/go.mod | 3 + .../httpclient/httpclient.go | 0 .../httpclient/httpclient_test.go | 17 +- dms/cmd/scalebench/main.go | 2 +- dms/cmd/search-e2e-fixture/main.go | 2 +- dms/go.mod | 4 + dms/internal/crypto/kekprovider.go | 2 +- dms/internal/httpclient/httpclient.go | 29 --- dms/internal/httpclient/httpclient_test.go | 99 --------- .../moduletypeclient/moduletypeclient.go | 2 +- .../retentionclient/retentionclient.go | 2 +- dms/internal/storage/usagereport.go | 2 +- dms/internal/workflowevent/workflowevent.go | 6 +- go.mod | 4 + internal/apierror/apierror.go | 191 ------------------ internal/apierror/apierror_test.go | 184 ----------------- internal/archtest/errorleak_test.go | 2 +- internal/httpharden/harden.go | 2 +- internal/statuspage/statuspage.go | 2 +- .../tenantimportdirapi/tenantimportdirapi.go | 2 +- mail/go.mod | 4 + mail/internal/crypto/kekprovider.go | 2 +- mail/internal/httpclient/httpclient.go | 29 --- mail/internal/httpclient/httpclient_test.go | 99 --------- .../moduletypeclient/moduletypeclient.go | 2 +- mail/internal/storage/usagereport.go | 2 +- mail/internal/syncalert/dispatcher.go | 2 +- mail/internal/workflowevent/client.go | 2 +- 42 files changed, 61 insertions(+), 662 deletions(-) rename {archive/internal => common}/apierror/apierror.go (100%) rename {archive/internal => common}/apierror/apierror_test.go (100%) create mode 100644 common/go.mod rename {archive/internal => common}/httpclient/httpclient.go (100%) rename {archive/internal => common}/httpclient/httpclient_test.go (82%) delete mode 100644 dms/internal/httpclient/httpclient.go delete mode 100644 dms/internal/httpclient/httpclient_test.go delete mode 100644 internal/apierror/apierror.go delete mode 100644 internal/apierror/apierror_test.go delete mode 100644 mail/internal/httpclient/httpclient.go delete mode 100644 mail/internal/httpclient/httpclient_test.go diff --git a/.gitea/workflows/archive-pflichttest-gate.yml b/.gitea/workflows/archive-pflichttest-gate.yml index 29f8039a..0184417f 100644 --- a/.gitea/workflows/archive-pflichttest-gate.yml +++ b/.gitea/workflows/archive-pflichttest-gate.yml @@ -4,6 +4,7 @@ on: pull_request: paths: - "archive/**" + - "common/**" jobs: pflichttest-gate: diff --git a/.gitea/workflows/mail-pflichttest-gate.yml b/.gitea/workflows/mail-pflichttest-gate.yml index 2e5662c9..f15479d3 100644 --- a/.gitea/workflows/mail-pflichttest-gate.yml +++ b/.gitea/workflows/mail-pflichttest-gate.yml @@ -4,6 +4,7 @@ on: pull_request: paths: - "mail/**" + - "common/**" jobs: pflichttest-gate: diff --git a/PRODUKTARCHITEKTUR.md b/PRODUKTARCHITEKTUR.md index 631e0fe3..225c318e 100644 --- a/PRODUKTARCHITEKTUR.md +++ b/PRODUKTARCHITEKTUR.md @@ -64,6 +64,8 @@ Kein Modul kopiert die Daten eines anderen — stattdessen ein wiederkehrendes * Gemeinsamer Nenner aller drei: **Referenz statt Kopie, Adapter statt Direktkopplung.** +Go-`internal/`-Pakete sind zwischen den Modulen nicht sichtbar; identische Querschnittspakete werden deshalb im geteilten Modul `common/` gehalten (per `replace` in den `go.mod` eingebunden, keine eigenen Abhängigkeiten außer der Standardbibliothek). Pilot: `common/httpclient`, `common/apierror`. Fachlich verschiedene Pakete (`audittrail`, `cutoverstage`, `crypto`) bleiben modul-lokal. + Dasselbe Prinzip gilt auch innerhalb eines Moduls, auf Code-Ebene: Hauptdateien/Entry-Points bleiben klein, Fachlogik wandert in eigene Module. Vor einer neuen Funktions-/Modul-Datei wird geprüft, ob dieselbe Funktionalität nicht schon woanders existiert (auch modulübergreifend, z. B. in Core) — keine doppelten Funktionsdateien. Das eigene Kanban-Planungswerkzeug wurde nach genau diesem Muster korrigiert (sieben `kanban.html` enthielten identisches JS/CSS als Volltext-Duplikat, damals in `_shared/kanban.js`/`kanban.css` ausgelagert) — die Boards selbst sind inzwischen komplett aus dem Repo entfernt (Ticket-Historie liegt in der Gitea-PR-Historie), das Prinzip "keine doppelten Funktionsdateien" gilt unverändert für den eigentlichen NEXARCH-Code. ## 6. Technische Architektur (Kurzfassung, Details in eigenen Dokumenten) diff --git a/archive/go.mod b/archive/go.mod index c4ba8374..e1e31e1e 100644 --- a/archive/go.mod +++ b/archive/go.mod @@ -13,3 +13,7 @@ require ( golang.org/x/sync v0.22.0 // indirect golang.org/x/text v0.41.0 // indirect ) + +require gitea.perlbach24.de/scripte/nexarch/common v0.0.0 + +replace gitea.perlbach24.de/scripte/nexarch/common => ../common diff --git a/archive/internal/archtest/errorleak_test.go b/archive/internal/archtest/errorleak_test.go index c814bc57..feed706e 100644 --- a/archive/internal/archtest/errorleak_test.go +++ b/archive/internal/archtest/errorleak_test.go @@ -33,7 +33,7 @@ func moduleRoot(t *testing.T) string { // Antwort bzw. der rohe ListenAndServe-Aufruf genau die erlaubte // Implementierung der Regel selbst ist (API-20/API-21), kein Verstoss. // Das Archive-Modul hat keine weiteren, physisch verschachtelten Go-Module. -var exempt = []string{"internal/apierror", "internal/httpharden"} +var exempt = []string{"internal/httpharden"} // TestNoErrorTextLeaksIntoHTTPResponses ist Akzeptanzkriterium 1: kein // err.Error() direkt in einer HTTP-Antwort, ausser ueber apierror. diff --git a/archive/internal/auditclient/auditclient.go b/archive/internal/auditclient/auditclient.go index ad04469a..16f8c300 100644 --- a/archive/internal/auditclient/auditclient.go +++ b/archive/internal/auditclient/auditclient.go @@ -10,7 +10,7 @@ import ( "context" "encoding/json" "fmt" - "gitea.perlbach24.de/scripte/nexarch/archive/internal/httpclient" + "gitea.perlbach24.de/scripte/nexarch/common/httpclient" "net/http" ) diff --git a/archive/internal/ediscoveryhttp/ediscoveryhttp.go b/archive/internal/ediscoveryhttp/ediscoveryhttp.go index 1a1b32cb..14eb8c83 100644 --- a/archive/internal/ediscoveryhttp/ediscoveryhttp.go +++ b/archive/internal/ediscoveryhttp/ediscoveryhttp.go @@ -14,7 +14,7 @@ import ( "net/url" "gitea.perlbach24.de/scripte/nexarch/archive/internal/ediscovery" - "gitea.perlbach24.de/scripte/nexarch/archive/internal/httpclient" + "gitea.perlbach24.de/scripte/nexarch/common/httpclient" ) const ModuleTypeDMSDocument = "dms_document" diff --git a/archive/internal/httpharden/harden.go b/archive/internal/httpharden/harden.go index 32d8f8df..c8b5f9b3 100644 --- a/archive/internal/httpharden/harden.go +++ b/archive/internal/httpharden/harden.go @@ -11,7 +11,7 @@ import ( "strconv" "time" - "gitea.perlbach24.de/scripte/nexarch/archive/internal/apierror" + "gitea.perlbach24.de/scripte/nexarch/common/apierror" ) const ( diff --git a/archive/internal/kekclient/kekclient.go b/archive/internal/kekclient/kekclient.go index 275a18ab..6d7e644d 100644 --- a/archive/internal/kekclient/kekclient.go +++ b/archive/internal/kekclient/kekclient.go @@ -11,7 +11,7 @@ import ( "encoding/base64" "encoding/json" "fmt" - "gitea.perlbach24.de/scripte/nexarch/archive/internal/httpclient" + "gitea.perlbach24.de/scripte/nexarch/common/httpclient" "net/http" "net/url" ) diff --git a/archive/internal/notifyclient/notifyclient.go b/archive/internal/notifyclient/notifyclient.go index 18d94551..aa87c8b0 100644 --- a/archive/internal/notifyclient/notifyclient.go +++ b/archive/internal/notifyclient/notifyclient.go @@ -10,7 +10,7 @@ import ( "context" "encoding/json" "fmt" - "gitea.perlbach24.de/scripte/nexarch/archive/internal/httpclient" + "gitea.perlbach24.de/scripte/nexarch/common/httpclient" "net/http" ) diff --git a/archive/internal/rbacclient/rbacclient.go b/archive/internal/rbacclient/rbacclient.go index cdd51af2..5be075be 100644 --- a/archive/internal/rbacclient/rbacclient.go +++ b/archive/internal/rbacclient/rbacclient.go @@ -11,7 +11,7 @@ import ( "encoding/json" "errors" "fmt" - "gitea.perlbach24.de/scripte/nexarch/archive/internal/httpclient" + "gitea.perlbach24.de/scripte/nexarch/common/httpclient" "net/http" ) diff --git a/archive/internal/tenantregistryclient/tenantregistryclient.go b/archive/internal/tenantregistryclient/tenantregistryclient.go index e0875a17..7ff44eec 100644 --- a/archive/internal/tenantregistryclient/tenantregistryclient.go +++ b/archive/internal/tenantregistryclient/tenantregistryclient.go @@ -8,7 +8,7 @@ package tenantregistryclient import ( "context" "fmt" - "gitea.perlbach24.de/scripte/nexarch/archive/internal/httpclient" + "gitea.perlbach24.de/scripte/nexarch/common/httpclient" "net/http" "net/url" ) diff --git a/archive/internal/apierror/apierror.go b/common/apierror/apierror.go similarity index 100% rename from archive/internal/apierror/apierror.go rename to common/apierror/apierror.go diff --git a/archive/internal/apierror/apierror_test.go b/common/apierror/apierror_test.go similarity index 100% rename from archive/internal/apierror/apierror_test.go rename to common/apierror/apierror_test.go diff --git a/common/go.mod b/common/go.mod new file mode 100644 index 00000000..05270b63 --- /dev/null +++ b/common/go.mod @@ -0,0 +1,3 @@ +module gitea.perlbach24.de/scripte/nexarch/common + +go 1.25.0 diff --git a/archive/internal/httpclient/httpclient.go b/common/httpclient/httpclient.go similarity index 100% rename from archive/internal/httpclient/httpclient.go rename to common/httpclient/httpclient.go diff --git a/archive/internal/httpclient/httpclient_test.go b/common/httpclient/httpclient_test.go similarity index 82% rename from archive/internal/httpclient/httpclient_test.go rename to common/httpclient/httpclient_test.go index ea24c6b2..32855b05 100644 --- a/archive/internal/httpclient/httpclient_test.go +++ b/common/httpclient/httpclient_test.go @@ -57,15 +57,22 @@ func TestClientDoesNotFollowRedirects(t *testing.T) { } } -// Architekturtest: im Produktivcode dieses Moduls gibt es keine Aufrufe ueber -// den Standard-Client ohne Zeitlimit. +// Architekturtest: im Produktivcode der Module, die dieses Paket nutzen, gibt +// es keine Aufrufe ueber den Standard-Client ohne Zeitlimit. func TestNoUnboundedDefaultClientInProductionCode(t *testing.T) { - root, err := filepath.Abs(filepath.Join("..", "..")) + repoRoot, err := filepath.Abs(filepath.Join("..", "..")) if err != nil { t.Fatal(err) } + for _, mod := range []string{"common", "dms", "mail", "archive"} { + scanNoDefaultClient(t, repoRoot, filepath.Join(repoRoot, mod)) + } +} + +func scanNoDefaultClient(t *testing.T, repoRoot, root string) { + t.Helper() forbidden := regexp.MustCompile(`http\.DefaultClient|http\.(Get|Post|Head|PostForm)\(`) - err = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { if err != nil { return err } @@ -87,7 +94,7 @@ func TestNoUnboundedDefaultClientInProductionCode(t *testing.T) { continue } if forbidden.MatchString(line) { - rel, _ := filepath.Rel(root, path) + rel, _ := filepath.Rel(repoRoot, path) t.Errorf("%s:%d nutzt den Standard-Client ohne Zeitlimit: %s", rel, i+1, strings.TrimSpace(line)) } } diff --git a/dms/cmd/scalebench/main.go b/dms/cmd/scalebench/main.go index 15c5c352..a13f5e16 100644 --- a/dms/cmd/scalebench/main.go +++ b/dms/cmd/scalebench/main.go @@ -30,9 +30,9 @@ import ( "github.com/jackc/pgx/v5/pgxpool" + "gitea.perlbach24.de/scripte/nexarch/common/httpclient" dmscrypto "gitea.perlbach24.de/scripte/nexarch/dms/internal/crypto" "gitea.perlbach24.de/scripte/nexarch/dms/internal/folder" - "gitea.perlbach24.de/scripte/nexarch/dms/internal/httpclient" "gitea.perlbach24.de/scripte/nexarch/dms/internal/jobqueue" "gitea.perlbach24.de/scripte/nexarch/dms/internal/migrate" "gitea.perlbach24.de/scripte/nexarch/dms/internal/routeprefix" diff --git a/dms/cmd/search-e2e-fixture/main.go b/dms/cmd/search-e2e-fixture/main.go index caf14011..124a254f 100644 --- a/dms/cmd/search-e2e-fixture/main.go +++ b/dms/cmd/search-e2e-fixture/main.go @@ -23,8 +23,8 @@ import ( "github.com/jackc/pgx/v5/pgxpool" + "gitea.perlbach24.de/scripte/nexarch/common/httpclient" dmscrypto "gitea.perlbach24.de/scripte/nexarch/dms/internal/crypto" - "gitea.perlbach24.de/scripte/nexarch/dms/internal/httpclient" "gitea.perlbach24.de/scripte/nexarch/dms/internal/jobqueue" "gitea.perlbach24.de/scripte/nexarch/dms/internal/migrate" "gitea.perlbach24.de/scripte/nexarch/dms/internal/routeprefix" diff --git a/dms/go.mod b/dms/go.mod index a6a5c82b..5f833932 100644 --- a/dms/go.mod +++ b/dms/go.mod @@ -52,3 +52,7 @@ require ( golang.org/x/text v0.41.0 // indirect golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect ) + +require gitea.perlbach24.de/scripte/nexarch/common v0.0.0 + +replace gitea.perlbach24.de/scripte/nexarch/common => ../common diff --git a/dms/internal/crypto/kekprovider.go b/dms/internal/crypto/kekprovider.go index 624fae2a..239056b7 100644 --- a/dms/internal/crypto/kekprovider.go +++ b/dms/internal/crypto/kekprovider.go @@ -5,7 +5,7 @@ import ( "encoding/base64" "encoding/json" "fmt" - "gitea.perlbach24.de/scripte/nexarch/dms/internal/httpclient" + "gitea.perlbach24.de/scripte/nexarch/common/httpclient" "net/http" "net/url" ) diff --git a/dms/internal/httpclient/httpclient.go b/dms/internal/httpclient/httpclient.go deleted file mode 100644 index 53c9a9f4..00000000 --- a/dms/internal/httpclient/httpclient.go +++ /dev/null @@ -1,29 +0,0 @@ -// Package httpclient liefert den HTTP-Client fuer ausgehende Dienstaufrufe -// (QA-15 F-11): Zeitlimit statt endlosem Warten auf einen haengenden -// Nachbardienst, und keine Weiterleitungen (ein Service-Token wuerde sonst an -// das Weiterleitungsziel mitgesendet). -package httpclient - -import ( - "net/http" - "time" -) - -// DefaultTimeout gilt fuer einen kompletten Aufruf (Verbindung bis Antwortende). -const DefaultTimeout = 15 * time.Second - -var shared = New(DefaultTimeout) - -// Default ist der gemeinsame Client mit DefaultTimeout. -func Default() *http.Client { return shared } - -// New liefert einen Client mit dem angegebenen Zeitlimit, der Weiterleitungen -// nicht folgt (die 3xx-Antwort wird zurueckgegeben). -func New(timeout time.Duration) *http.Client { - return &http.Client{ - Timeout: timeout, - CheckRedirect: func(*http.Request, []*http.Request) error { - return http.ErrUseLastResponse - }, - } -} diff --git a/dms/internal/httpclient/httpclient_test.go b/dms/internal/httpclient/httpclient_test.go deleted file mode 100644 index ea24c6b2..00000000 --- a/dms/internal/httpclient/httpclient_test.go +++ /dev/null @@ -1,99 +0,0 @@ -package httpclient - -import ( - "io/fs" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "regexp" - "strings" - "testing" - "time" -) - -func TestDefaultHasTimeout(t *testing.T) { - if Default().Timeout != DefaultTimeout || DefaultTimeout <= 0 { - t.Fatalf("timeout = %s", Default().Timeout) - } -} - -func TestClientGivesUpOnHangingServer(t *testing.T) { - release := make(chan struct{}) - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { <-release })) - defer srv.Close() - defer close(release) - - start := time.Now() - resp, err := New(200 * time.Millisecond).Get(srv.URL) - if err == nil { - _ = resp.Body.Close() - t.Fatal("kein fehler bei haengendem server") - } - if elapsed := time.Since(start); elapsed > 5*time.Second { - t.Fatalf("aufruf dauerte %s", elapsed) - } -} - -func TestClientDoesNotFollowRedirects(t *testing.T) { - target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - t.Error("weiterleitungsziel wurde aufgerufen (Service-Token-Leck)") - })) - defer target.Close() - redirector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - http.Redirect(w, r, target.URL, http.StatusTemporaryRedirect) - })) - defer redirector.Close() - - req, _ := http.NewRequest(http.MethodPost, redirector.URL, strings.NewReader("x")) - req.Header.Set("X-Service-Token", "geheim") - resp, err := Default().Do(req) - if err != nil { - t.Fatalf("do: %v", err) - } - defer func() { _ = resp.Body.Close() }() - if resp.StatusCode != http.StatusTemporaryRedirect { - t.Fatalf("status = %d, erwartet 307", resp.StatusCode) - } -} - -// Architekturtest: im Produktivcode dieses Moduls gibt es keine Aufrufe ueber -// den Standard-Client ohne Zeitlimit. -func TestNoUnboundedDefaultClientInProductionCode(t *testing.T) { - root, err := filepath.Abs(filepath.Join("..", "..")) - if err != nil { - t.Fatal(err) - } - forbidden := regexp.MustCompile(`http\.DefaultClient|http\.(Get|Post|Head|PostForm)\(`) - err = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { - if err != nil { - return err - } - if d.IsDir() { - if n := d.Name(); n == "node_modules" || n == "testdata" || n == ".git" { - return filepath.SkipDir - } - return nil - } - if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { - return nil - } - data, err := os.ReadFile(path) - if err != nil { - return err - } - for i, line := range strings.Split(string(data), "\n") { - if t := strings.TrimSpace(line); strings.HasPrefix(t, "//") { - continue - } - if forbidden.MatchString(line) { - rel, _ := filepath.Rel(root, path) - t.Errorf("%s:%d nutzt den Standard-Client ohne Zeitlimit: %s", rel, i+1, strings.TrimSpace(line)) - } - } - return nil - }) - if err != nil { - t.Fatal(err) - } -} diff --git a/dms/internal/moduletypeclient/moduletypeclient.go b/dms/internal/moduletypeclient/moduletypeclient.go index 52b4a2a6..181e4984 100644 --- a/dms/internal/moduletypeclient/moduletypeclient.go +++ b/dms/internal/moduletypeclient/moduletypeclient.go @@ -12,7 +12,7 @@ import ( "fmt" "net/http" - "gitea.perlbach24.de/scripte/nexarch/dms/internal/httpclient" + "gitea.perlbach24.de/scripte/nexarch/common/httpclient" ) type Client struct { diff --git a/dms/internal/retentionclient/retentionclient.go b/dms/internal/retentionclient/retentionclient.go index 0fee1fec..c223c5cb 100644 --- a/dms/internal/retentionclient/retentionclient.go +++ b/dms/internal/retentionclient/retentionclient.go @@ -13,7 +13,7 @@ import ( "net/http" "net/url" - "gitea.perlbach24.de/scripte/nexarch/dms/internal/httpclient" + "gitea.perlbach24.de/scripte/nexarch/common/httpclient" "gitea.perlbach24.de/scripte/nexarch/dms/internal/modulecall" ) diff --git a/dms/internal/storage/usagereport.go b/dms/internal/storage/usagereport.go index 7709f549..ed83fa73 100644 --- a/dms/internal/storage/usagereport.go +++ b/dms/internal/storage/usagereport.go @@ -5,7 +5,7 @@ import ( "context" "encoding/json" "fmt" - "gitea.perlbach24.de/scripte/nexarch/dms/internal/httpclient" + "gitea.perlbach24.de/scripte/nexarch/common/httpclient" "net/http" ) diff --git a/dms/internal/workflowevent/workflowevent.go b/dms/internal/workflowevent/workflowevent.go index 652a7658..30939846 100644 --- a/dms/internal/workflowevent/workflowevent.go +++ b/dms/internal/workflowevent/workflowevent.go @@ -13,7 +13,7 @@ import ( "net/http" "time" - "gitea.perlbach24.de/scripte/nexarch/dms/internal/httpclient" + "gitea.perlbach24.de/scripte/nexarch/common/httpclient" "gitea.perlbach24.de/scripte/nexarch/dms/internal/jobqueue" ) @@ -21,8 +21,8 @@ const ( ModuleName = "dms" ObjectType = "document" - TriggerCreated = "document.created" - TriggerStatusChanged = "document.status_changed" + TriggerCreated = "document.created" + TriggerStatusChanged = "document.status_changed" TriggerDeadlineReached = "document.deadline_reached" JobType = "workflow_event_publish" diff --git a/go.mod b/go.mod index bcc954e4..6cad3c96 100644 --- a/go.mod +++ b/go.mod @@ -26,3 +26,7 @@ require ( golang.org/x/text v0.40.0 // indirect google.golang.org/protobuf v1.36.11 // indirect ) + +require gitea.perlbach24.de/scripte/nexarch/common v0.0.0 + +replace gitea.perlbach24.de/scripte/nexarch/common => ./common diff --git a/internal/apierror/apierror.go b/internal/apierror/apierror.go deleted file mode 100644 index 4872e81d..00000000 --- a/internal/apierror/apierror.go +++ /dev/null @@ -1,191 +0,0 @@ -// Package apierror erzwingt das Core-Fehlerformat (API-01) auf allen -// HTTP-Antworten und schliesst QA-15 F-14: 116 Stellen in Core, DMS, Archive -// und Mail betteten err.Error() direkt in Antworten ein, darunter 500er mit -// Datenbank- und Dateipfadtexten. Statt jede einzelne Aufrufstelle -// anzufassen, schreibt Normalize JEDE Fehlerantwort am aeussersten Rand -// (httpharden.ListenAndServe) um: 4xx-Meldungen bleiben inhaltlich -// erhalten (fachlich, keine Interna), 5xx-Antworten werden PRINZIPIELL -// durch eine allgemeine Meldung mit Korrelations-ID ersetzt — der -// urspruengliche Text (der die Interna enthalten haben KANN) geht nur ins -// Log, niemals in die Antwort. Erweitert dasselbe Grundmuster wie -// dms/internal/apierror (INT-01, dort das "Vorbild" laut API-20). -package apierror - -import ( - "bytes" - "crypto/rand" - "encoding/hex" - "encoding/json" - "log" - "net/http" - "strings" -) - -func CodeForStatus(status int) string { - switch status { - case http.StatusBadRequest: - return "invalid_request" - case http.StatusUnauthorized: - return "unauthenticated" - case http.StatusForbidden: - return "forbidden" - case http.StatusNotFound: - return "not_found" - case http.StatusMethodNotAllowed: - return "method_not_allowed" - case http.StatusConflict: - return "conflict" - case http.StatusGone: - return "gone" - case http.StatusRequestEntityTooLarge: - return "payload_too_large" - case http.StatusUnsupportedMediaType: - return "unsupported_media_type" - case http.StatusUnprocessableEntity: - return "unprocessable" - case http.StatusLocked: - return "locked" - case http.StatusTooManyRequests: - return "rate_limited" - case http.StatusServiceUnavailable: - return "unavailable" - } - if status >= 500 { - return "internal_error" - } - return "invalid_request" -} - -type body struct { - Error struct { - Code string `json:"code"` - Message string `json:"message"` - CorrelationID string `json:"correlation_id,omitempty"` - } `json:"error"` -} - -func Write(w http.ResponseWriter, status int, code, message string) { - var b body - b.Error.Code = code - b.Error.Message = message - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(status) - _ = json.NewEncoder(w).Encode(b) -} - -func newCorrelationID() string { - buf := make([]byte, 8) - if _, err := rand.Read(buf); err != nil { - // crypto/rand-Ausfall ist praktisch nie beobachtbar; eine feste - // Ersatz-ID ist besser als ein Absturz in einem Fehlerpfad, der - // selbst schon einen Fehler meldet. - return "korrelations-id-nicht-verfuegbar" - } - return hex.EncodeToString(buf) -} - -// Normalize schreibt jede Fehlerantwort (Status >= 400) im Core-Format um. -// 4xx: der vom Handler geschriebene Text bleibt als "message" erhalten (er -// ist fachlich, kein Interna-Leck). 5xx (Akzeptanzkriterium 1): der Text -// wird verworfen, NIE an den Aufrufer weitergegeben — stattdessen eine -// allgemeine Meldung mit Korrelations-ID; derselbe Text landet mitsamt -// Korrelations-ID im Server-Log (Prozess-stderr, wie ueberall sonst in -// diesem Projekt). -func Normalize(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - rec := &recorder{ResponseWriter: w} - next.ServeHTTP(rec, r) - rec.finish(r) - }) -} - -type recorder struct { - http.ResponseWriter - status int - buffered bool - buf bytes.Buffer -} - -func (r *recorder) WriteHeader(status int) { - if r.status != 0 { - return - } - r.status = status - if status >= 400 { - r.buffered = true - return - } - r.ResponseWriter.WriteHeader(status) -} - -func (r *recorder) Write(p []byte) (int, error) { - if r.status == 0 { - r.WriteHeader(http.StatusOK) - } - if r.buffered { - return r.buf.Write(p) - } - return r.ResponseWriter.Write(p) -} - -func (r *recorder) Flush() { - if f, ok := r.ResponseWriter.(http.Flusher); ok && !r.buffered { - f.Flush() - } -} - -func (r *recorder) Unwrap() http.ResponseWriter { return r.ResponseWriter } - -func (r *recorder) finish(req *http.Request) { - if !r.buffered { - return - } - code := CodeForStatus(r.status) - rawMessage := strings.TrimSpace(r.buf.String()) - - if r.status >= 500 { - corrID := newCorrelationID() - log.Printf("apierror: 5xx %s %s status=%d korrelations-id=%s ursprung=%q", req.Method, req.URL.Path, r.status, corrID, rawMessage) - out := body{} - out.Error.Code = code - out.Error.Message = "Interner Fehler" - out.Error.CorrelationID = corrID - encoded, _ := json.Marshal(out) - r.flush(append(encoded, '\n')) - return - } - - out := map[string]any{} - message := rawMessage - var parsed map[string]any - if json.Unmarshal(r.buf.Bytes(), &parsed) == nil && parsed != nil { - switch e := parsed["error"].(type) { - case string: - message = e - delete(parsed, "error") - out = parsed - case map[string]any: - if c, _ := e["code"].(string); c != "" { - if m, _ := e["message"].(string); m != "" { - r.flush(r.buf.Bytes()) - return - } - } - } - } - if message == "" { - message = http.StatusText(r.status) - } - out["error"] = map[string]string{"code": code, "message": message} - encoded, _ := json.Marshal(out) - r.flush(append(encoded, '\n')) -} - -func (r *recorder) flush(payload []byte) { - h := r.ResponseWriter.Header() - h.Set("Content-Type", "application/json") - h.Del("Content-Length") - h.Set("X-Content-Type-Options", "nosniff") - r.ResponseWriter.WriteHeader(r.status) - _, _ = r.ResponseWriter.Write(payload) -} diff --git a/internal/apierror/apierror_test.go b/internal/apierror/apierror_test.go deleted file mode 100644 index e26d1cdb..00000000 --- a/internal/apierror/apierror_test.go +++ /dev/null @@ -1,184 +0,0 @@ -package apierror - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "testing" -) - -func decode(t *testing.T, rec *httptest.ResponseRecorder) map[string]any { - t.Helper() - var m map[string]any - if err := json.Unmarshal(rec.Body.Bytes(), &m); err != nil { - t.Fatalf("keine json-antwort: %q (%v)", rec.Body.String(), err) - } - return m -} - -func errOf(t *testing.T, m map[string]any) map[string]any { - t.Helper() - e, ok := m["error"].(map[string]any) - if !ok { - t.Fatalf("error ist kein objekt: %v", m) - } - return e -} - -// Akzeptanzkriterium 2: fachliche 4xx-Meldungen bleiben verstaendlich. -func TestNormalize_4xxKeepsHandlerMessage(t *testing.T) { - cases := []struct { - name string - handler http.HandlerFunc - status int - wantCode string - wantMsg string - }{ - {"http.Error klartext", func(w http.ResponseWriter, r *http.Request) { http.Error(w, "dokument nicht gefunden", 404) }, 404, "not_found", "dokument nicht gefunden"}, - {"json string", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(422) - _, _ = w.Write([]byte(`{"error":"ungueltiger wert"}`)) - }, 422, "unprocessable", "ungueltiger wert"}, - {"leerer body", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(403) }, 403, "forbidden", "Forbidden"}, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - rec := httptest.NewRecorder() - Normalize(c.handler).ServeHTTP(rec, httptest.NewRequest("GET", "/x", nil)) - if rec.Code != c.status { - t.Fatalf("status = %d", rec.Code) - } - if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") { - t.Errorf("content-type = %q", ct) - } - e := errOf(t, decode(t, rec)) - if e["code"] != c.wantCode || e["message"] != c.wantMsg { - t.Errorf("code=%v msg=%v, erwartet %q/%q", e["code"], e["message"], c.wantCode, c.wantMsg) - } - if _, has := e["correlation_id"]; has { - t.Errorf("4xx sollte keine korrelations-id tragen: %v", e) - } - }) - } -} - -// Akzeptanzkriterium 1: 5xx-Antworten enthalten NIE den Handler-Text -// (der die Interna leaken kann), nur eine allgemeine Meldung mit -// Korrelations-ID — egal, was der Handler geschrieben hat. -func TestNormalize_5xxNeverLeaksHandlerText(t *testing.T) { - cases := []struct { - name string - handler http.HandlerFunc - }{ - {"http.Error mit db-fehler", func(w http.ResponseWriter, r *http.Request) { - http.Error(w, `pq: connection to server at "10.0.0.5", port 5432 failed`, 500) - }}, - {"http.Error mit dateipfad", func(w http.ResponseWriter, r *http.Request) { - http.Error(w, "open /var/lib/nexarch/secrets/tenant.key: permission denied", 502) - }}, - {"json mit interna", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(503) - _, _ = w.Write([]byte(`{"error":"dial tcp 127.0.0.1:5432: connect: connection refused"}`)) - }}, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - rec := httptest.NewRecorder() - Normalize(c.handler).ServeHTTP(rec, httptest.NewRequest("GET", "/x", nil)) - body := rec.Body.String() - for _, leak := range []string{"pq:", "10.0.0.5", "/var/lib/nexarch", "permission denied", "127.0.0.1", "dial tcp"} { - if strings.Contains(body, leak) { - t.Errorf("5xx-antwort enthaelt interna %q: %s", leak, body) - } - } - e := errOf(t, decode(t, rec)) - if e["message"] != "Interner Fehler" { - t.Errorf("message = %v, erwartet generische meldung", e["message"]) - } - corrID, _ := e["correlation_id"].(string) - if corrID == "" { - t.Fatalf("keine korrelations-id in der antwort: %v", e) - } - if len(corrID) < 8 { - t.Errorf("korrelations-id zu kurz/vorhersagbar: %q", corrID) - } - }) - } -} - -// Zwei Anfragen mit demselben 5xx-Fehler bekommen unterschiedliche -// Korrelations-IDs (sonst waere die ID im Log nicht eindeutig zuordenbar). -func TestNormalize_5xxCorrelationIDsAreUnique(t *testing.T) { - handler := func(w http.ResponseWriter, r *http.Request) { http.Error(w, "boom", 500) } - var ids []string - for i := 0; i < 5; i++ { - rec := httptest.NewRecorder() - Normalize(http.HandlerFunc(handler)).ServeHTTP(rec, httptest.NewRequest("GET", "/x", nil)) - e := errOf(t, decode(t, rec)) - id, _ := e["correlation_id"].(string) - for _, seen := range ids { - if seen == id { - t.Fatalf("korrelations-id doppelt vergeben: %q", id) - } - } - ids = append(ids, id) - } -} - -func TestNormalize_KeepsExtraFieldsOn4xx(t *testing.T) { - rec := httptest.NewRecorder() - Normalize(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(409) - _, _ = w.Write([]byte(`{"error":"duplikat","documentId":"d1"}`)) - })).ServeHTTP(rec, httptest.NewRequest("POST", "/x", nil)) - m := decode(t, rec) - if m["documentId"] != "d1" { - t.Errorf("zusatzfeld verloren: %v", m) - } - if errOf(t, m)["code"] != "conflict" { - t.Errorf("code = %v", errOf(t, m)["code"]) - } - - rec = httptest.NewRecorder() - Normalize(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - Write(w, 401, "unauthenticated", "nicht angemeldet") - })).ServeHTTP(rec, httptest.NewRequest("GET", "/x", nil)) - e := errOf(t, decode(t, rec)) - if e["code"] != "unauthenticated" || e["message"] != "nicht angemeldet" { - t.Errorf("core-format veraendert: %v", e) - } -} - -func TestNormalize_SuccessPassesThroughUnbuffered(t *testing.T) { - rec := httptest.NewRecorder() - Normalize(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/pdf") - _, _ = w.Write([]byte("%PDF-1.7")) - })).ServeHTTP(rec, httptest.NewRequest("GET", "/x", nil)) - if rec.Code != 200 || rec.Body.String() != "%PDF-1.7" || rec.Header().Get("Content-Type") != "application/pdf" { - t.Errorf("erfolgsantwort veraendert: %d %q %q", rec.Code, rec.Body.String(), rec.Header().Get("Content-Type")) - } -} - -func TestNormalize_MuxNotFoundAndMethodNotAllowed(t *testing.T) { - mux := http.NewServeMux() - mux.HandleFunc("GET /a", func(w http.ResponseWriter, r *http.Request) {}) - h := Normalize(mux) - for _, c := range []struct { - method, path string - status int - code string - }{{"GET", "/nope", 404, "not_found"}, {"POST", "/a", 405, "method_not_allowed"}} { - rec := httptest.NewRecorder() - h.ServeHTTP(rec, httptest.NewRequest(c.method, c.path, nil)) - if rec.Code != c.status { - t.Fatalf("%s %s: status %d", c.method, c.path, rec.Code) - } - if errOf(t, decode(t, rec))["code"] != c.code { - t.Errorf("%s %s: code %v", c.method, c.path, errOf(t, decode(t, rec))["code"]) - } - } -} diff --git a/internal/archtest/errorleak_test.go b/internal/archtest/errorleak_test.go index 5be28df9..f24dc2e9 100644 --- a/internal/archtest/errorleak_test.go +++ b/internal/archtest/errorleak_test.go @@ -36,7 +36,7 @@ func moduleRoot(t *testing.T) string { // aber eigenstaendigen Go-Module (eigene go.mod, eigene Regeln/Ausnahmen - // DMS hat mit INT-01/INT-15 bereits ein eigenes Muster, siehe API-21-Ticket // "Nicht Bestandteil dieser Kachel"). -var exempt = []string{"internal/apierror", "internal/httpharden", "archive", "dms", "mail"} +var exempt = []string{"common", "internal/httpharden", "archive", "dms", "mail"} // TestNoErrorTextLeaksIntoHTTPResponses ist Akzeptanzkriterium 1: kein // err.Error() direkt in einer HTTP-Antwort, ausser ueber apierror. diff --git a/internal/httpharden/harden.go b/internal/httpharden/harden.go index 5cf7d8a0..66af8005 100644 --- a/internal/httpharden/harden.go +++ b/internal/httpharden/harden.go @@ -11,7 +11,7 @@ import ( "strconv" "time" - "gitea.perlbach24.de/scripte/nexarch/internal/apierror" + "gitea.perlbach24.de/scripte/nexarch/common/apierror" ) const ( diff --git a/internal/statuspage/statuspage.go b/internal/statuspage/statuspage.go index 6dbb002a..e6e6802d 100644 --- a/internal/statuspage/statuspage.go +++ b/internal/statuspage/statuspage.go @@ -17,7 +17,7 @@ import ( "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" - "gitea.perlbach24.de/scripte/nexarch/internal/apierror" + "gitea.perlbach24.de/scripte/nexarch/common/apierror" "gitea.perlbach24.de/scripte/nexarch/internal/httpclient" ) diff --git a/internal/tenantimportdirapi/tenantimportdirapi.go b/internal/tenantimportdirapi/tenantimportdirapi.go index bdd5eb81..f4d55ea2 100644 --- a/internal/tenantimportdirapi/tenantimportdirapi.go +++ b/internal/tenantimportdirapi/tenantimportdirapi.go @@ -10,7 +10,7 @@ import ( "errors" "net/http" - "gitea.perlbach24.de/scripte/nexarch/internal/apierror" + "gitea.perlbach24.de/scripte/nexarch/common/apierror" "gitea.perlbach24.de/scripte/nexarch/internal/tenantimportdir" ) diff --git a/mail/go.mod b/mail/go.mod index 2e1682dd..f2b7dfab 100644 --- a/mail/go.mod +++ b/mail/go.mod @@ -48,3 +48,7 @@ require ( golang.org/x/sys v0.47.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) + +require gitea.perlbach24.de/scripte/nexarch/common v0.0.0 + +replace gitea.perlbach24.de/scripte/nexarch/common => ../common diff --git a/mail/internal/crypto/kekprovider.go b/mail/internal/crypto/kekprovider.go index 4a422c2d..2ed39da6 100644 --- a/mail/internal/crypto/kekprovider.go +++ b/mail/internal/crypto/kekprovider.go @@ -5,7 +5,7 @@ import ( "encoding/base64" "encoding/json" "fmt" - "gitea.perlbach24.de/scripte/nexarch/mail/internal/httpclient" + "gitea.perlbach24.de/scripte/nexarch/common/httpclient" "net/http" "net/url" ) diff --git a/mail/internal/httpclient/httpclient.go b/mail/internal/httpclient/httpclient.go deleted file mode 100644 index 53c9a9f4..00000000 --- a/mail/internal/httpclient/httpclient.go +++ /dev/null @@ -1,29 +0,0 @@ -// Package httpclient liefert den HTTP-Client fuer ausgehende Dienstaufrufe -// (QA-15 F-11): Zeitlimit statt endlosem Warten auf einen haengenden -// Nachbardienst, und keine Weiterleitungen (ein Service-Token wuerde sonst an -// das Weiterleitungsziel mitgesendet). -package httpclient - -import ( - "net/http" - "time" -) - -// DefaultTimeout gilt fuer einen kompletten Aufruf (Verbindung bis Antwortende). -const DefaultTimeout = 15 * time.Second - -var shared = New(DefaultTimeout) - -// Default ist der gemeinsame Client mit DefaultTimeout. -func Default() *http.Client { return shared } - -// New liefert einen Client mit dem angegebenen Zeitlimit, der Weiterleitungen -// nicht folgt (die 3xx-Antwort wird zurueckgegeben). -func New(timeout time.Duration) *http.Client { - return &http.Client{ - Timeout: timeout, - CheckRedirect: func(*http.Request, []*http.Request) error { - return http.ErrUseLastResponse - }, - } -} diff --git a/mail/internal/httpclient/httpclient_test.go b/mail/internal/httpclient/httpclient_test.go deleted file mode 100644 index ea24c6b2..00000000 --- a/mail/internal/httpclient/httpclient_test.go +++ /dev/null @@ -1,99 +0,0 @@ -package httpclient - -import ( - "io/fs" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "regexp" - "strings" - "testing" - "time" -) - -func TestDefaultHasTimeout(t *testing.T) { - if Default().Timeout != DefaultTimeout || DefaultTimeout <= 0 { - t.Fatalf("timeout = %s", Default().Timeout) - } -} - -func TestClientGivesUpOnHangingServer(t *testing.T) { - release := make(chan struct{}) - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { <-release })) - defer srv.Close() - defer close(release) - - start := time.Now() - resp, err := New(200 * time.Millisecond).Get(srv.URL) - if err == nil { - _ = resp.Body.Close() - t.Fatal("kein fehler bei haengendem server") - } - if elapsed := time.Since(start); elapsed > 5*time.Second { - t.Fatalf("aufruf dauerte %s", elapsed) - } -} - -func TestClientDoesNotFollowRedirects(t *testing.T) { - target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - t.Error("weiterleitungsziel wurde aufgerufen (Service-Token-Leck)") - })) - defer target.Close() - redirector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - http.Redirect(w, r, target.URL, http.StatusTemporaryRedirect) - })) - defer redirector.Close() - - req, _ := http.NewRequest(http.MethodPost, redirector.URL, strings.NewReader("x")) - req.Header.Set("X-Service-Token", "geheim") - resp, err := Default().Do(req) - if err != nil { - t.Fatalf("do: %v", err) - } - defer func() { _ = resp.Body.Close() }() - if resp.StatusCode != http.StatusTemporaryRedirect { - t.Fatalf("status = %d, erwartet 307", resp.StatusCode) - } -} - -// Architekturtest: im Produktivcode dieses Moduls gibt es keine Aufrufe ueber -// den Standard-Client ohne Zeitlimit. -func TestNoUnboundedDefaultClientInProductionCode(t *testing.T) { - root, err := filepath.Abs(filepath.Join("..", "..")) - if err != nil { - t.Fatal(err) - } - forbidden := regexp.MustCompile(`http\.DefaultClient|http\.(Get|Post|Head|PostForm)\(`) - err = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { - if err != nil { - return err - } - if d.IsDir() { - if n := d.Name(); n == "node_modules" || n == "testdata" || n == ".git" { - return filepath.SkipDir - } - return nil - } - if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { - return nil - } - data, err := os.ReadFile(path) - if err != nil { - return err - } - for i, line := range strings.Split(string(data), "\n") { - if t := strings.TrimSpace(line); strings.HasPrefix(t, "//") { - continue - } - if forbidden.MatchString(line) { - rel, _ := filepath.Rel(root, path) - t.Errorf("%s:%d nutzt den Standard-Client ohne Zeitlimit: %s", rel, i+1, strings.TrimSpace(line)) - } - } - return nil - }) - if err != nil { - t.Fatal(err) - } -} diff --git a/mail/internal/moduletypeclient/moduletypeclient.go b/mail/internal/moduletypeclient/moduletypeclient.go index 22eb7b0e..5a4e4d00 100644 --- a/mail/internal/moduletypeclient/moduletypeclient.go +++ b/mail/internal/moduletypeclient/moduletypeclient.go @@ -12,7 +12,7 @@ import ( "fmt" "net/http" - "gitea.perlbach24.de/scripte/nexarch/mail/internal/httpclient" + "gitea.perlbach24.de/scripte/nexarch/common/httpclient" ) const moduleName = "mail" diff --git a/mail/internal/storage/usagereport.go b/mail/internal/storage/usagereport.go index a8a5c14d..8deb3299 100644 --- a/mail/internal/storage/usagereport.go +++ b/mail/internal/storage/usagereport.go @@ -5,7 +5,7 @@ import ( "context" "encoding/json" "fmt" - "gitea.perlbach24.de/scripte/nexarch/mail/internal/httpclient" + "gitea.perlbach24.de/scripte/nexarch/common/httpclient" "net/http" ) diff --git a/mail/internal/syncalert/dispatcher.go b/mail/internal/syncalert/dispatcher.go index 30188a9b..800df3da 100644 --- a/mail/internal/syncalert/dispatcher.go +++ b/mail/internal/syncalert/dispatcher.go @@ -12,7 +12,7 @@ import ( "context" "encoding/json" "fmt" - "gitea.perlbach24.de/scripte/nexarch/mail/internal/httpclient" + "gitea.perlbach24.de/scripte/nexarch/common/httpclient" "net/http" ) diff --git a/mail/internal/workflowevent/client.go b/mail/internal/workflowevent/client.go index f93c6867..9b51fc01 100644 --- a/mail/internal/workflowevent/client.go +++ b/mail/internal/workflowevent/client.go @@ -8,7 +8,7 @@ import ( "io" "net/http" - "gitea.perlbach24.de/scripte/nexarch/mail/internal/httpclient" + "gitea.perlbach24.de/scripte/nexarch/common/httpclient" ) // Config steuert Ziel und Zugangsdaten des Workflow-Modul-Adapters -- 2.47.3