This commit is contained in:
2026-09-16 06:26:16 +02:00
parent 95b0779db0
commit 4f8e0bbb04
51 changed files with 6290 additions and 0 deletions
+673
View File
@@ -0,0 +1,673 @@
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})
}