package httpserver import ( "encoding/json" "fmt" "io" "net/http" "net/url" "sort" "strconv" "strings" "sync" "github.com/example/notify-gateway/internal/config" "github.com/example/notify-gateway/internal/gateway" "github.com/example/notify-gateway/internal/model" ) type editableConfig struct { Version int `json:"version"` Server editableServerConfig `json:"server"` Divera config.DiveraConfig `json:"divera"` Ingress config.IngressConfig `json:"ingress"` Mappings []config.Mapping `json:"mappings"` Outbounds []config.OutboundConfig `json:"outbounds,omitempty"` } type editableServerConfig struct { Listen string `json:"listen"` AdminUsername string `json:"admin_username"` } func makeEditable(c config.Config) editableConfig { return editableConfig{ Version: c.Version, Server: editableServerConfig{Listen: c.Server.Listen, AdminUsername: c.Server.AdminUsername}, Divera: c.Divera, Ingress: c.Ingress, Mappings: c.Mappings, Outbounds: c.Outbounds, } } func (s *Server) apiConfig(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: writeJSON(w, http.StatusOK, makeEditable(s.store.Get())) case http.MethodPut: var in editableConfig dec := json.NewDecoder(io.LimitReader(r.Body, 2<<20)) dec.DisallowUnknownFields() if err := dec.Decode(&in); err != nil { writeJSON(w, http.StatusBadRequest, map[string]any{"error": "Ungültige Konfiguration: " + err.Error()}) return } old := s.store.Get() next := old next.Version = config.CurrentVersion next.Server.Listen = strings.TrimSpace(in.Server.Listen) next.Server.AdminUsername = strings.TrimSpace(in.Server.AdminUsername) next.Divera = in.Divera next.Ingress = in.Ingress next.Mappings = in.Mappings next.Outbounds = in.Outbounds if err := config.Validate(next); err != nil { writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()}) return } if err := s.store.Replace(next); err != nil { writeJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()}) return } writeJSON(w, http.StatusOK, map[string]any{"ok": true, "config": makeEditable(s.store.Get())}) default: w.Header().Set("Allow", "GET, PUT") http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } } func (s *Server) apiDiveraCatalog(w http.ResponseWriter, r *http.Request) { // Divera247 v2 pull/all is the primary catalog source. Besides the active // unit it returns data.ucr, i.e. every UserClusterRelation the current // account can switch to. We use those UCR ids to load each reachable unit // and merge its cluster.consumer/group/vehicle catalog. This works with // normal v2 access even when the v3 synchronization API is not enabled. resp, err := s.divera.PullAll(r.Context(), nil) if err != nil { status := http.StatusBadGateway if strings.Contains(strings.ToLower(err.Error()), "access_key") { status = http.StatusBadRequest } writeJSON(w, status, map[string]any{"error": err.Error(), "status_code": resp.StatusCode, "response": string(resp.Body)}) return } var raw any if err := json.Unmarshal(resp.Body, &raw); err != nil { writeJSON(w, http.StatusBadGateway, map[string]any{"error": "Divera247-Antwort ist kein JSON: " + err.Error()}) return } catalog := extractCatalog(raw) refs := extractUCRRefs(raw) activeUCR := extractActiveUCR(raw) for _, ref := range refs { catalog.Units = mergeCatalogItems(catalog.Units, []catalogItem{{ID: ref.ClusterID, Label: ref.Label}}) } if ref, ok := findUCRRef(refs, activeUCR); ok { annotateCatalogWithUnit(&catalog, ref) } warnings := []string{} v2Calls, v2Successes := 1, 1 failedV2 := []string{} // pull/all requests are independent. Limit concurrency so a PRO account with // many units does not create a burst of dozens of simultaneous requests. type pullResult struct { ref ucrRef catalog diveraCatalog err error } resultCh := make(chan pullResult, len(refs)) sem := make(chan struct{}, 6) var wg sync.WaitGroup for _, ref := range refs { if ref.UCRID <= 0 || ref.UCRID == activeUCR { continue } ref := ref v2Calls++ wg.Add(1) go func() { defer wg.Done() select { case sem <- struct{}{}: defer func() { <-sem }() case <-r.Context().Done(): resultCh <- pullResult{ref: ref, err: r.Context().Err()} return } q := url.Values{} q.Set("ucr", strconv.FormatInt(ref.UCRID, 10)) unitResp, unitErr := s.divera.PullAll(r.Context(), q) if unitErr != nil { resultCh <- pullResult{ref: ref, err: unitErr} return } var unitRaw any if err := json.Unmarshal(unitResp.Body, &unitRaw); err != nil { resultCh <- pullResult{ref: ref, err: fmt.Errorf("ungültiges JSON: %w", err)} return } unitCatalog := extractCatalog(unitRaw) annotateCatalogWithUnit(&unitCatalog, ref) resultCh <- pullResult{ref: ref, catalog: unitCatalog} }() } go func() { wg.Wait() close(resultCh) }() for result := range resultCh { if result.err != nil { failedV2 = append(failedV2, fmt.Sprintf("%s: %v", result.ref.Label, result.err)) continue } v2Successes++ mergeDiveraCatalog(&catalog, result.catalog) } if len(failedV2) > 0 { examples := failedV2 if len(examples) > 3 { examples = examples[:3] } warnings = append(warnings, fmt.Sprintf("Stammdaten für %d von %d Divera247-Einheiten konnten über v2 pull/all nicht geladen werden. Beispiele: %s", len(failedV2), len(refs), strings.Join(examples, " | "))) } personSource := "v2 pull/all · cluster.consumer" v3Calls, v3Successes := 0, 0 // v3 is a beta/synchronization API and can legitimately be forbidden for an // otherwise valid access key. Only use it as a fallback when v2 did not yield // any consumers. On an explicit 403 stop immediately instead of producing one // warning per unit. if len(catalog.Persons) == 0 { personSource = "keine Personen verfügbar" probeRefs := refs if len(probeRefs) == 0 { probeRefs = []ucrRef{{}} } v3Persons := []catalogItem{} for _, ref := range probeRefs { var clusterID *int64 if ref.ClusterID > 0 { id := ref.ClusterID clusterID = &id } v3Calls++ userResp, userErr := s.divera.ListUserClusterRelations(r.Context(), clusterID) if userErr != nil { if userResp.StatusCode == http.StatusForbidden { warnings = append(warnings, "Divera247 v3 Benutzer-Synchronisation ist für diesen Accesskey nicht freigeschaltet (HTTP 403). Personen werden ausschließlich aus v2 pull/all gelesen.") break } warnings = append(warnings, "Divera247-v3-Fallback für Personen fehlgeschlagen: "+userErr.Error()) break } persons, parseErr := parseV3Persons(userResp.Body) if parseErr != nil { warnings = append(warnings, "Divera247-v3-Benutzerantwort konnte nicht gelesen werden: "+parseErr.Error()) break } v3Successes++ v3Persons = mergeCatalogItems(v3Persons, persons) } if len(v3Persons) > 0 { catalog.Persons = v3Persons personSource = "v3 user-cluster-relations" } } sortCatalog(&catalog) writeJSON(w, http.StatusOK, map[string]any{ "ok": true, "catalog": catalog, "warnings": warnings, "diagnostics": map[string]any{ "v2_ucr_calls": v2Calls, "v2_ucr_successes": v2Successes, "v3_user_calls": v3Calls, "v3_user_successes": v3Successes, "person_count": len(catalog.Persons), "person_source": personSource, "available_ucr_count": len(refs), }, }) } type ucrRef struct { UCRID int64 ClusterID int64 Label string } func extractUCRRefs(raw any) []ucrRef { root, ok := raw.(map[string]any) if !ok { return nil } data, ok := root["data"].(map[string]any) if !ok { return nil } var refs []ucrRef add := func(m map[string]any, implicit int64) { ucrID, _ := positiveInt64(m["id"]) if ucrID <= 0 { ucrID = implicit } clusterID, _ := positiveInt64(m["cluster_id"]) if ucrID <= 0 || clusterID <= 0 { return } label := anyString(m["name"]) short := anyString(m["shortname"]) if label == "" { label = short } else if short != "" && short != label { label += " (" + short + ")" } if label == "" { label = fmt.Sprintf("Einheit %d", clusterID) } refs = append(refs, ucrRef{UCRID: ucrID, ClusterID: clusterID, Label: label}) } switch u := data["ucr"].(type) { case []any: for _, row := range u { if m, ok := row.(map[string]any); ok { add(m, 0) } } case map[string]any: for key, row := range u { if m, ok := row.(map[string]any); ok { implicit, _ := strconv.ParseInt(key, 10, 64) add(m, implicit) } } } byUCR := map[int64]ucrRef{} for _, ref := range refs { byUCR[ref.UCRID] = ref } refs = refs[:0] for _, ref := range byUCR { refs = append(refs, ref) } sort.Slice(refs, func(i, j int) bool { li, lj := strings.ToLower(refs[i].Label), strings.ToLower(refs[j].Label) if li == lj { return refs[i].UCRID < refs[j].UCRID } return li < lj }) return refs } func extractActiveUCR(raw any) int64 { root, ok := raw.(map[string]any) if !ok { return 0 } data, ok := root["data"].(map[string]any) if !ok { return 0 } id, _ := positiveInt64(data["ucr_active"]) return id } func findUCRRef(refs []ucrRef, id int64) (ucrRef, bool) { for _, ref := range refs { if ref.UCRID == id { return ref, true } } return ucrRef{}, false } func annotateCatalogWithUnit(c *diveraCatalog, ref ucrRef) { if ref.ClusterID <= 0 { return } unit := ref.Label if unit == "" { unit = fmt.Sprintf("Einheit %d", ref.ClusterID) } suffix := fmt.Sprintf("Einheit: %s · Cluster-ID %d", unit, ref.ClusterID) annotate := func(items []catalogItem) { for i := range items { if !strings.Contains(items[i].Subtitle, "Cluster-ID ") { if items[i].Subtitle == "" { items[i].Subtitle = suffix } else { items[i].Subtitle += " · " + suffix } } } } annotate(c.Groups) annotate(c.Persons) annotate(c.Vehicles) } func mergeDiveraCatalog(dst *diveraCatalog, src diveraCatalog) { dst.Units = mergeCatalogItems(dst.Units, src.Units) dst.Groups = mergeCatalogItems(dst.Groups, src.Groups) dst.Persons = mergeCatalogItems(dst.Persons, src.Persons) dst.Vehicles = mergeCatalogItems(dst.Vehicles, src.Vehicles) } type catalogItem struct { ID int64 `json:"id"` Label string `json:"label"` Subtitle string `json:"subtitle,omitempty"` } type diveraCatalog struct { Units []catalogItem `json:"units"` Groups []catalogItem `json:"groups"` Persons []catalogItem `json:"persons"` Vehicles []catalogItem `json:"vehicles"` } // extractCatalog tolerates different pull/all response layouts. Numeric keys in // collections are preserved separately because in cluster/consumer Divera247 can use // that key as UCR id while the nested object's "id" is the global user id. func extractCatalog(raw any) diveraCatalog { out := diveraCatalog{} seen := map[string]map[int64]bool{ "units": {}, "groups": {}, "persons": {}, "vehicles": {}, } var walk func(v any, path string) walk = func(v any, path string) { switch x := v.(type) { case map[string]any: kind := classifyCatalogPath(path) if id, ok := objectIDForKind(x, kind); ok && kind != "" && !seen[kind][id] { seen[kind][id] = true item := catalogItem{ID: id, Label: objectLabel(x, id), Subtitle: objectSubtitle(x)} switch kind { case "units": out.Units = append(out.Units, item) case "groups": out.Groups = append(out.Groups, item) case "persons": out.Persons = append(out.Persons, item) case "vehicles": out.Vehicles = append(out.Vehicles, item) } } for k, child := range x { p := strings.ToLower(k) if path != "" { p = path + "/" + p } // pull/all commonly returns collections as objects keyed by numeric ID. // Preserve the key even if the child has its own "id" field: for users, // the child id may be the global User-ID while the collection key is UCR. if cm, ok := child.(map[string]any); ok { if implicit, err := strconv.ParseInt(k, 10, 64); err == nil && implicit > 0 { copyMap := make(map[string]any, len(cm)+1) for ck, cv := range cm { copyMap[ck] = cv } copyMap["__collection_key_id"] = implicit child = copyMap } } walk(child, p) } case []any: for _, child := range x { walk(child, path) } } } walk(raw, "") sortCatalog(&out) return out } func classifyCatalogPath(path string) string { p := strings.ToLower(path) switch { case strings.Contains(p, "vehicle") || strings.Contains(p, "fahrzeug"): return "vehicles" case strings.Contains(p, "user_cluster_relation") || strings.Contains(p, "user-cluster-relation") || strings.Contains(p, "userclusterrelation") || strings.Contains(p, "consumer") || strings.Contains(p, "member") || strings.Contains(p, "person") || strings.Contains(p, "/user") || strings.HasSuffix(p, "users"): return "persons" case strings.Contains(p, "group") || strings.Contains(p, "gruppen"): return "groups" case strings.Contains(p, "cluster") || strings.Contains(p, "unit") || strings.Contains(p, "standort") || strings.Contains(p, "einheit"): return "units" default: return "" } } func objectIDForKind(m map[string]any, kind string) (int64, bool) { if kind == "persons" { // Prefer explicit UCR fields. If pull/all uses cluster/consumer keyed by UCR, // prefer the collection key before the nested global user id. for _, k := range []string{"user_cluster_relation_id", "userClusterRelationId", "ucr_id", "ucr"} { if id, ok := positiveInt64(m[k]); ok { return id, true } } if id, ok := positiveInt64(m["__collection_key_id"]); ok { return id, true } } for _, k := range []string{"id", "cluster_id", "group_id", "vehicle_id", "__collection_key_id"} { if id, ok := positiveInt64(m[k]); ok { return id, true } } return 0, false } func positiveInt64(v any) (int64, bool) { switch n := v.(type) { case int64: return n, n > 0 case int: return int64(n), n > 0 case float64: return int64(n), n > 0 case json.Number: i, err := n.Int64() return i, err == nil && i > 0 case string: i, err := strconv.ParseInt(strings.TrimSpace(n), 10, 64) return i, err == nil && i > 0 default: return 0, false } } func objectLabel(m map[string]any, id int64) string { for _, k := range []string{"title", "name", "display_name", "fullname", "full_name", "label", "shortname", "callname", "number"} { if s := anyString(m[k]); s != "" { return s } } first := anyString(m["firstname"]) last := anyString(m["lastname"]) if strings.TrimSpace(first+" "+last) != "" { return strings.TrimSpace(first + " " + last) } if nested, ok := m["user"].(map[string]any); ok { first = anyString(nested["firstname"]) last = anyString(nested["lastname"]) if strings.TrimSpace(first+" "+last) != "" { return strings.TrimSpace(first + " " + last) } if email := anyString(nested["email"]); email != "" { return email } } if email := anyString(m["email"]); email != "" { return email } return fmt.Sprintf("ID %d", id) } func objectSubtitle(m map[string]any) string { vals := []string{} appendUnique := func(s string) { s = strings.TrimSpace(s) if s == "" { return } for _, old := range vals { if old == s { return } } vals = append(vals, s) } for _, k := range []string{"foreign_id", "ric", "issi", "opta", "number", "email"} { appendUnique(anyString(m[k])) } if nested, ok := m["user"].(map[string]any); ok { appendUnique(anyString(nested["email"])) } if clusterID, ok := positiveInt64(m["cluster_id"]); ok { appendUnique(fmt.Sprintf("Einheit %d", clusterID)) } return strings.Join(vals, " · ") } func anyString(v any) string { switch x := v.(type) { case string: return strings.TrimSpace(x) case int: return strconv.Itoa(x) case int64: return strconv.FormatInt(x, 10) case float64: return strconv.FormatInt(int64(x), 10) case json.Number: return x.String() default: return "" } } func parseV3Persons(body []byte) ([]catalogItem, error) { var rows []struct { ID int64 `json:"id"` ClusterID int64 `json:"cluster_id"` ForeignID string `json:"foreign_id"` User struct { ID int64 `json:"id"` Firstname string `json:"firstname"` Lastname string `json:"lastname"` Email string `json:"email"` } `json:"user"` } if err := json.Unmarshal(body, &rows); err != nil { return nil, err } items := make([]catalogItem, 0, len(rows)) for _, row := range rows { if row.ID <= 0 { continue } label := strings.TrimSpace(row.User.Firstname + " " + row.User.Lastname) if label == "" { label = strings.TrimSpace(row.User.Email) } if label == "" { label = fmt.Sprintf("UCR %d", row.ID) } parts := []string{} if row.User.Email != "" { parts = append(parts, row.User.Email) } if row.ClusterID > 0 { parts = append(parts, fmt.Sprintf("Einheit %d", row.ClusterID)) } if row.ForeignID != "" { parts = append(parts, row.ForeignID) } items = append(items, catalogItem{ID: row.ID, Label: label, Subtitle: strings.Join(parts, " · ")}) } return mergeCatalogItems(nil, items), nil } func mergeCatalogItems(base, add []catalogItem) []catalogItem { byID := make(map[int64]catalogItem, len(base)+len(add)) for _, item := range base { if item.ID > 0 { byID[item.ID] = item } } for _, item := range add { if item.ID <= 0 { continue } if old, ok := byID[item.ID]; ok { // Prefer the richer v3/person label over generic fallback labels. if strings.HasPrefix(old.Label, "ID ") || strings.HasPrefix(old.Label, "UCR ") || len(item.Label) > len(old.Label) { old.Label = item.Label } if item.Subtitle != "" { old.Subtitle = item.Subtitle } byID[item.ID] = old } else { byID[item.ID] = item } } out := make([]catalogItem, 0, len(byID)) for _, item := range byID { out = append(out, item) } sort.Slice(out, func(i, j int) bool { li, lj := strings.ToLower(out[i].Label), strings.ToLower(out[j].Label) if li == lj { return out[i].ID < out[j].ID } return li < lj }) return out } func sortCatalog(c *diveraCatalog) { sorter := func(items []catalogItem) { sort.Slice(items, func(i, j int) bool { li, lj := strings.ToLower(items[i].Label), strings.ToLower(items[j].Label) if li == lj { return items[i].ID < items[j].ID } return li < lj }) } sorter(c.Units) sorter(c.Groups) sorter(c.Persons) sorter(c.Vehicles) } type previewRequest struct { Mapping config.Mapping `json:"mapping"` Message model.InboundMessage `json:"message"` } func (s *Server) apiPreview(w http.ResponseWriter, r *http.Request) { var in previewRequest if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&in); err != nil { writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()}) return } payload, kind, matched, err := gateway.Preview(in.Mapping, in.Message) if err != nil { writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error(), "matched": matched}) return } writeJSON(w, http.StatusOK, map[string]any{"matched": matched, "kind": kind, "payload": payload}) }