init
This commit is contained in:
@@ -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})
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/example/notify-gateway/internal/config"
|
||||
"github.com/example/notify-gateway/internal/divera"
|
||||
)
|
||||
|
||||
func TestExtractCatalogFromKeyedPullData(t *testing.T) {
|
||||
raw := map[string]any{
|
||||
"data": map[string]any{
|
||||
"cluster": map[string]any{"101": map[string]any{"title": "Löschzug 1"}},
|
||||
"group": map[string]any{"202": map[string]any{"title": "Atemschutz"}},
|
||||
"user_cluster_relation": map[string]any{"303": map[string]any{"firstname": "Max", "lastname": "Muster"}},
|
||||
"vehicle": map[string]any{"404": map[string]any{"name": "HLF 20", "ric": "1234567"}},
|
||||
},
|
||||
}
|
||||
got := extractCatalog(raw)
|
||||
if len(got.Units) != 1 || got.Units[0].ID != 101 || got.Units[0].Label != "Löschzug 1" {
|
||||
t.Fatalf("units = %#v", got.Units)
|
||||
}
|
||||
if len(got.Groups) != 1 || got.Groups[0].ID != 202 {
|
||||
t.Fatalf("groups = %#v", got.Groups)
|
||||
}
|
||||
if len(got.Persons) != 1 || got.Persons[0].ID != 303 || got.Persons[0].Label != "Max Muster" {
|
||||
t.Fatalf("persons = %#v", got.Persons)
|
||||
}
|
||||
if len(got.Vehicles) != 1 || got.Vehicles[0].ID != 404 {
|
||||
t.Fatalf("vehicles = %#v", got.Vehicles)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractCatalogPrefersUCRCollectionKeyOverGlobalUserID(t *testing.T) {
|
||||
// Divera247 documents cluster.consumer as the user master-data collection.
|
||||
// It can be keyed by UCR while a nested id refers to the global User-ID.
|
||||
// Alarm recipients need the UCR key (9876).
|
||||
raw := map[string]any{
|
||||
"data": map[string]any{
|
||||
"cluster": map[string]any{
|
||||
"id": 42,
|
||||
"consumer": map[string]any{
|
||||
"9876": map[string]any{
|
||||
"id": 1234,
|
||||
"firstname": "Erika",
|
||||
"lastname": "Mustermann",
|
||||
"email": "erika@example.invalid",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
got := extractCatalog(raw)
|
||||
if len(got.Persons) != 1 {
|
||||
t.Fatalf("persons = %#v", got.Persons)
|
||||
}
|
||||
if got.Persons[0].ID != 9876 {
|
||||
t.Fatalf("expected UCR id 9876, got %#v", got.Persons[0])
|
||||
}
|
||||
if got.Persons[0].Label != "Erika Mustermann" {
|
||||
t.Fatalf("unexpected label: %#v", got.Persons[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseV3PersonsUsesUserClusterRelationID(t *testing.T) {
|
||||
body := []byte(`[
|
||||
{"id":303,"cluster_id":42,"foreign_id":"ext-1","user":{"id":999,"firstname":"Max","lastname":"Muster","email":"max@example.invalid"}}
|
||||
]`)
|
||||
got, err := parseV3Persons(body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 1 || got[0].ID != 303 || got[0].Label != "Max Muster" {
|
||||
t.Fatalf("persons = %#v", got)
|
||||
}
|
||||
if !strings.Contains(got[0].Subtitle, "Einheit 42") {
|
||||
t.Fatalf("subtitle = %q", got[0].Subtitle)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractUCRRefsFromPullAll(t *testing.T) {
|
||||
raw := map[string]any{
|
||||
"data": map[string]any{
|
||||
"ucr_active": float64(555),
|
||||
"ucr": []any{
|
||||
map[string]any{"id": float64(555), "cluster_id": float64(42), "name": "Einheit Nord", "shortname": "N"},
|
||||
map[string]any{"id": float64(666), "cluster_id": float64(43), "name": "Einheit Süd"},
|
||||
},
|
||||
},
|
||||
}
|
||||
refs := extractUCRRefs(raw)
|
||||
if len(refs) != 2 {
|
||||
t.Fatalf("refs=%#v", refs)
|
||||
}
|
||||
if extractActiveUCR(raw) != 555 {
|
||||
t.Fatalf("active=%d", extractActiveUCR(raw))
|
||||
}
|
||||
byID := map[int64]ucrRef{}
|
||||
for _, ref := range refs {
|
||||
byID[ref.UCRID] = ref
|
||||
}
|
||||
if byID[555].ClusterID != 42 || byID[666].ClusterID != 43 {
|
||||
t.Fatalf("refs=%#v", refs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiveraCatalogLoadsConsumersAcrossV2UCRsWithoutV3(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
pullCalls := []string{}
|
||||
v3Calls := 0
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/v2/pull/all":
|
||||
mu.Lock()
|
||||
pullCalls = append(pullCalls, r.URL.Query().Get("ucr"))
|
||||
mu.Unlock()
|
||||
if r.URL.Query().Get("accesskey") != "test-key" {
|
||||
t.Fatalf("missing accesskey: %s", r.URL.RawQuery)
|
||||
}
|
||||
switch r.URL.Query().Get("ucr") {
|
||||
case "555":
|
||||
_, _ = io.WriteString(w, `{"data":{"ucr_active":555,"ucr":[{"id":555,"cluster_id":42,"name":"Einheit 42"},{"id":666,"cluster_id":43,"name":"Einheit 43"}],"cluster":{"id":42,"consumer":{"777":{"id":12,"firstname":"Lisa","lastname":"Beispiel","email":"lisa@example.invalid"}},"group":{"11":{"title":"Gruppe Nord"}},"vehicle":{}}}}`)
|
||||
case "666":
|
||||
_, _ = io.WriteString(w, `{"data":{"ucr_active":666,"ucr":[{"id":555,"cluster_id":42,"name":"Einheit 42"},{"id":666,"cluster_id":43,"name":"Einheit 43"}],"cluster":{"id":43,"consumer":{"888":{"id":13,"firstname":"Max","lastname":"Süd"}},"group":{"22":{"title":"Gruppe Süd"}},"vehicle":{}}}}`)
|
||||
default:
|
||||
http.Error(w, "unexpected ucr", http.StatusBadRequest)
|
||||
}
|
||||
case "/api/v3/user-cluster-relations":
|
||||
v3Calls++
|
||||
http.Error(w, `{"message":"Nicht autorisiert"}`, http.StatusForbidden)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
store, err := config.Open(filepath.Join(t.TempDir(), "config.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.Update(func(c *config.Config) error {
|
||||
c.Divera.BaseURL = upstream.URL
|
||||
c.Divera.AccessKey = "test-key"
|
||||
c.Divera.UCR = 555
|
||||
c.Divera.DryRun = true
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
client := divera.New(func() config.DiveraConfig { return store.Get().Divera })
|
||||
srv := New(store, nil, client, log.New(io.Discard, "", 0))
|
||||
req := httptest.NewRequest(http.MethodGet, "/ui/api/divera247/catalog", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
srv.apiDiveraCatalog(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var out struct {
|
||||
Catalog diveraCatalog `json:"catalog"`
|
||||
Warnings []string `json:"warnings"`
|
||||
Diagnostics struct {
|
||||
V2Calls int `json:"v2_ucr_calls"`
|
||||
V3Calls int `json:"v3_user_calls"`
|
||||
Source string `json:"person_source"`
|
||||
} `json:"diagnostics"`
|
||||
}
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(out.Catalog.Persons) != 2 {
|
||||
t.Fatalf("persons=%#v warnings=%#v", out.Catalog.Persons, out.Warnings)
|
||||
}
|
||||
ids := map[int64]bool{}
|
||||
for _, person := range out.Catalog.Persons {
|
||||
ids[person.ID] = true
|
||||
if !strings.Contains(person.Subtitle, "Cluster-ID") {
|
||||
t.Fatalf("missing unit subtitle: %#v", person)
|
||||
}
|
||||
}
|
||||
if !ids[777] || !ids[888] {
|
||||
t.Fatalf("persons=%#v", out.Catalog.Persons)
|
||||
}
|
||||
if v3Calls != 0 || out.Diagnostics.V3Calls != 0 {
|
||||
t.Fatalf("v3 must not be called when v2 consumers exist: upstream=%d diagnostics=%d", v3Calls, out.Diagnostics.V3Calls)
|
||||
}
|
||||
if out.Diagnostics.Source != "v2 pull/all · cluster.consumer" {
|
||||
t.Fatalf("source=%q", out.Diagnostics.Source)
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(pullCalls) != 2 {
|
||||
t.Fatalf("pullCalls=%#v", pullCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiveraCatalogStopsV3FallbackAfterForbidden(t *testing.T) {
|
||||
v3Calls := 0
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/v2/pull/all":
|
||||
ucr := r.URL.Query().Get("ucr")
|
||||
if ucr == "555" {
|
||||
_, _ = io.WriteString(w, `{"data":{"ucr_active":555,"ucr":[{"id":555,"cluster_id":42,"name":"Einheit 42"},{"id":666,"cluster_id":43,"name":"Einheit 43"}],"cluster":{"id":42,"consumer":{}}}}`)
|
||||
} else {
|
||||
_, _ = io.WriteString(w, `{"data":{"ucr_active":666,"ucr":[{"id":555,"cluster_id":42,"name":"Einheit 42"},{"id":666,"cluster_id":43,"name":"Einheit 43"}],"cluster":{"id":43,"consumer":{}}}}`)
|
||||
}
|
||||
case "/api/v3/user-cluster-relations":
|
||||
v3Calls++
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
_, _ = io.WriteString(w, `{"name":"Forbidden","message":"Nicht autorisiert","code":0,"status":403}`)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
store, err := config.Open(filepath.Join(t.TempDir(), "config.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.Update(func(c *config.Config) error {
|
||||
c.Divera.BaseURL = upstream.URL
|
||||
c.Divera.AccessKey = "test-key"
|
||||
c.Divera.UCR = 555
|
||||
c.Divera.DryRun = true
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
client := divera.New(func() config.DiveraConfig { return store.Get().Divera })
|
||||
srv := New(store, nil, client, log.New(io.Discard, "", 0))
|
||||
rr := httptest.NewRecorder()
|
||||
srv.apiDiveraCatalog(rr, httptest.NewRequest(http.MethodGet, "/ui/api/divera247/catalog", nil))
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var out struct {
|
||||
Warnings []string `json:"warnings"`
|
||||
}
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v3Calls != 1 {
|
||||
t.Fatalf("expected one v3 probe, got %d", v3Calls)
|
||||
}
|
||||
if len(out.Warnings) != 1 || !strings.Contains(out.Warnings[0], "HTTP 403") {
|
||||
t.Fatalf("warnings=%#v", out.Warnings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditableConfigDoesNotExposeSessionSecrets(t *testing.T) {
|
||||
c := config.Default()
|
||||
c.Server.AdminPasswordHash = "secret-hash"
|
||||
c.Server.SessionSecret = "session-secret"
|
||||
b, err := json.Marshal(makeEditable(c))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := string(b)
|
||||
if strings.Contains(s, "secret-hash") || strings.Contains(s, "session-secret") || strings.Contains(s, "admin_password_hash") || strings.Contains(s, "session_secret") {
|
||||
t.Fatalf("editable config leaked internal auth data: %s", s)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/example/notify-gateway/internal/model"
|
||||
)
|
||||
|
||||
func (s *Server) discord(w http.ResponseWriter, r *http.Request) {
|
||||
c := s.store.Get().Ingress.Discord
|
||||
if !c.Enabled {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, (1<<20)+1))
|
||||
if err != nil || len(body) > 1<<20 {
|
||||
http.Error(w, "invalid request", 400)
|
||||
return
|
||||
}
|
||||
stamp := r.Header.Get("X-Signature-Timestamp")
|
||||
ts, err := strconv.ParseInt(stamp, 10, 64)
|
||||
key, e1 := hex.DecodeString(c.PublicKey)
|
||||
sig, e2 := hex.DecodeString(r.Header.Get("X-Signature-Ed25519"))
|
||||
if err != nil || e1 != nil || e2 != nil || len(key) != ed25519.PublicKeySize || ts < time.Now().Add(-5*time.Minute).Unix() || ts > time.Now().Add(5*time.Minute).Unix() || !ed25519.Verify(key, append([]byte(stamp), body...), sig) {
|
||||
http.Error(w, "invalid signature", 401)
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
ID string `json:"id"`
|
||||
ApplicationID string `json:"application_id"`
|
||||
Type int `json:"type"`
|
||||
GuildID string `json:"guild_id"`
|
||||
ChannelID string `json:"channel_id"`
|
||||
Data struct {
|
||||
Name string `json:"name"`
|
||||
Type int `json:"type"`
|
||||
Options []struct {
|
||||
Name string `json:"name"`
|
||||
Type int `json:"type"`
|
||||
Value json.RawMessage `json:"value"`
|
||||
} `json:"options"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if json.Unmarshal(body, &in) != nil || in.ApplicationID != c.ApplicationID {
|
||||
http.Error(w, "invalid interaction", 400)
|
||||
return
|
||||
}
|
||||
if in.Type == 1 {
|
||||
writeJSON(w, 200, map[string]int{"type": 1})
|
||||
return
|
||||
}
|
||||
if in.Type != 2 || in.Data.Type != 1 || in.Data.Name != c.Command || in.ID == "" || !slices.Contains(c.GuildIDs, in.GuildID) || !slices.Contains(c.ChannelIDs, in.ChannelID) {
|
||||
http.Error(w, "interaction not allowed", 403)
|
||||
return
|
||||
}
|
||||
msg := model.InboundMessage{Source: "discord", Channel: in.ChannelID, ReceivedAt: time.Now().UTC()}
|
||||
for _, o := range in.Data.Options {
|
||||
switch o.Name {
|
||||
case "title":
|
||||
err = json.Unmarshal(o.Value, &msg.Title)
|
||||
case "message":
|
||||
err = json.Unmarshal(o.Value, &msg.Message)
|
||||
case "priority":
|
||||
err = json.Unmarshal(o.Value, &msg.Priority)
|
||||
default:
|
||||
http.Error(w, "unsupported option", 400)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
http.Error(w, "invalid option", 400)
|
||||
return
|
||||
}
|
||||
}
|
||||
if msg.Message == "" {
|
||||
http.Error(w, "message required", 400)
|
||||
return
|
||||
}
|
||||
if s.queue == nil {
|
||||
http.Error(w, "outbox unavailable", 503)
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
|
||||
defer cancel()
|
||||
receipt, err := s.queue.Accept(ctx, msg, "discord:"+in.ID)
|
||||
if err != nil {
|
||||
http.Error(w, "interaction could not be queued; check routing", 503)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"type": 4, "data": map[string]any{"content": "Meldung angenommen. Referenz: " + receipt.ID, "flags": 64, "allowed_mentions": map[string]any{"parse": []string{}}}})
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/example/notify-gateway/internal/gateway"
|
||||
"github.com/example/notify-gateway/internal/mailingress"
|
||||
"github.com/example/notify-gateway/internal/outbox"
|
||||
)
|
||||
|
||||
func (s *Server) UseQueue(q *gateway.Queue, p *mailingress.Poller) { s.queue = q; s.mail = p }
|
||||
func (s *Server) ready(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
|
||||
defer cancel()
|
||||
if s.queue == nil || s.queue.Store.Ping(ctx) != nil {
|
||||
writeJSON(w, 503, map[string]any{"ok": false})
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"ok": true})
|
||||
}
|
||||
func (s *Server) deliveries(w http.ResponseWriter, r *http.Request) {
|
||||
if s.queue == nil {
|
||||
http.Error(w, "outbox unavailable", 503)
|
||||
return
|
||||
}
|
||||
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
|
||||
jobs, err := s.queue.Store.List(r.Context(), 50, offset)
|
||||
if err != nil {
|
||||
http.Error(w, "outbox unavailable", 503)
|
||||
return
|
||||
}
|
||||
counts, err := s.queue.Store.Counts(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, "outbox unavailable", 503)
|
||||
return
|
||||
}
|
||||
statuses := []mailingress.Status{}
|
||||
if s.mail != nil {
|
||||
statuses = s.mail.Statuses()
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"deliveries": jobs, "counts": counts, "mail": statuses})
|
||||
}
|
||||
func (s *Server) deliveryHistory(w http.ResponseWriter, r *http.Request) {
|
||||
if s.queue == nil {
|
||||
http.Error(w, "outbox unavailable", 503)
|
||||
return
|
||||
}
|
||||
rows, err := s.queue.Store.History(r.Context(), r.PathValue("id"))
|
||||
if err != nil {
|
||||
http.Error(w, "outbox unavailable", 503)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, rows)
|
||||
}
|
||||
func (s *Server) retryDelivery(w http.ResponseWriter, r *http.Request) {
|
||||
if s.queue == nil {
|
||||
http.Error(w, "outbox unavailable", 503)
|
||||
return
|
||||
}
|
||||
err := s.queue.Store.Retry(r.Context(), r.PathValue("id"))
|
||||
if errors.Is(err, outbox.ErrNotRetryable) {
|
||||
http.Error(w, err.Error(), 409)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
http.Error(w, "outbox unavailable", 503)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"ok": true})
|
||||
}
|
||||
func (s *Server) metrics(w http.ResponseWriter, r *http.Request) {
|
||||
token := os.Getenv("GATEWAY_METRICS_TOKEN")
|
||||
if token == "" {
|
||||
s.requireAdmin(s.writeMetrics)(w, r)
|
||||
return
|
||||
}
|
||||
got := r.Header.Get("Authorization")
|
||||
if subtle.ConstantTimeCompare([]byte(got), []byte("Bearer "+token)) != 1 {
|
||||
http.Error(w, "unauthorized", 401)
|
||||
return
|
||||
}
|
||||
s.writeMetrics(w, r)
|
||||
}
|
||||
func (s *Server) writeMetrics(w http.ResponseWriter, r *http.Request) {
|
||||
if s.queue == nil {
|
||||
http.Error(w, "outbox unavailable", 503)
|
||||
return
|
||||
}
|
||||
counts, err := s.queue.Store.Counts(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, "outbox unavailable", 503)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/plain; version=0.0.4")
|
||||
fmt.Fprintln(w, "# HELP notify_gateway_deliveries Persisted deliveries by current state.\n# TYPE notify_gateway_deliveries gauge")
|
||||
for _, state := range []string{"pending", "sending", "succeeded", "dry_run", "dead"} {
|
||||
fmt.Fprintf(w, "notify_gateway_deliveries{state=%q} %d\n", state, counts[state])
|
||||
}
|
||||
}
|
||||
func csrfToken(secret, session string) string {
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write([]byte("csrf:" + session))
|
||||
return hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
func (s *Server) csrf(w http.ResponseWriter, r *http.Request) {
|
||||
c, _ := r.Cookie("ng_session")
|
||||
writeJSON(w, 200, map[string]string{"token": csrfToken(s.store.Get().Server.SessionSecret, c.Value)})
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/example/notify-gateway/internal/auth"
|
||||
"github.com/example/notify-gateway/internal/config"
|
||||
"github.com/example/notify-gateway/internal/divera"
|
||||
"github.com/example/notify-gateway/internal/gateway"
|
||||
"github.com/example/notify-gateway/internal/outbox"
|
||||
)
|
||||
|
||||
func queuedServer(t *testing.T) (*Server, *config.Store, *outbox.Store) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
store, err := config.Open(filepath.Join(dir, "config.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db, err := outbox.Open(filepath.Join(dir, "outbox.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
client := divera.New(func() config.DiveraConfig { return store.Get().Divera })
|
||||
dispatcher := gateway.New(store, client)
|
||||
s := New(store, dispatcher, client, log.New(io.Discard, "", 0))
|
||||
s.UseQueue(&gateway.Queue{Store: db, Dispatcher: dispatcher}, nil)
|
||||
return s, store, db
|
||||
}
|
||||
func TestAsyncIngressIdempotencyReadinessAndMetrics(t *testing.T) {
|
||||
s, store, db := queuedServer(t)
|
||||
c := store.Get()
|
||||
c.Ingress.WebhookTokens = map[string]string{"ops": "secret"}
|
||||
c.Mappings = []config.Mapping{{ID: "route", Enabled: true, Target: "news", TextTemplate: "{{.Message}}"}}
|
||||
store.Replace(c)
|
||||
h := s.Handler()
|
||||
send := func(body string) *httptest.ResponseRecorder {
|
||||
r := httptest.NewRequest("POST", "/in/webhook/ops", strings.NewReader(body))
|
||||
r.Header.Set("Authorization", "Bearer secret")
|
||||
r.Header.Set("Idempotency-Key", "same-request")
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
return w
|
||||
}
|
||||
for i := 0; i < 2; i++ {
|
||||
if w := send("hello"); w.Code != 202 {
|
||||
t.Fatalf("accepted=%d %s", w.Code, w.Body)
|
||||
}
|
||||
}
|
||||
if w := send("different"); w.Code != 409 {
|
||||
t.Fatal(w.Code)
|
||||
}
|
||||
rows, _ := db.List(context.Background(), 50, 0)
|
||||
if len(rows) != 1 || rows[0].State != "pending" {
|
||||
t.Fatal(rows)
|
||||
}
|
||||
for _, path := range []string{"/readyz", "/metrics"} {
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, httptest.NewRequest("GET", path, nil))
|
||||
if path == "/readyz" && w.Code != 200 {
|
||||
t.Fatal(w.Code)
|
||||
}
|
||||
if path == "/metrics" && w.Code != 303 {
|
||||
t.Fatal("public metrics")
|
||||
}
|
||||
}
|
||||
t.Setenv("GATEWAY_METRICS_TOKEN", "metric-token")
|
||||
r := httptest.NewRequest("GET", "/metrics", nil)
|
||||
r.Header.Set("Authorization", "Bearer metric-token")
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
if w.Code != 200 || !strings.Contains(w.Body.String(), `state="pending"} 1`) {
|
||||
t.Fatalf("metrics %d %s", w.Code, w.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSRFRequestLimitsAndLoginRateLimit(t *testing.T) {
|
||||
s, store, _ := queuedServer(t)
|
||||
c := store.Get()
|
||||
h := s.Handler()
|
||||
session := auth.SignSession(c.Server.SessionSecret, c.Server.AdminUsername, time.Now().Add(time.Hour))
|
||||
for _, valid := range []bool{false, true} {
|
||||
body, _ := json.Marshal(makeEditable(c))
|
||||
r := httptest.NewRequest("PUT", "/ui/api/config", bytes.NewReader(body))
|
||||
r.AddCookie(&http.Cookie{Name: "ng_session", Value: session})
|
||||
if valid {
|
||||
r.Header.Set("X-CSRF-Token", csrfToken(c.Server.SessionSecret, session))
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
want := 403
|
||||
if valid {
|
||||
want = 200
|
||||
}
|
||||
if w.Code != want {
|
||||
t.Fatalf("CSRF valid=%v status=%d %s", valid, w.Code, w.Body)
|
||||
}
|
||||
}
|
||||
r := httptest.NewRequest("POST", "/login", nil)
|
||||
r.Header.Set("Origin", "https://evil.example")
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
if w.Code != 403 {
|
||||
t.Fatal("cross-site login accepted")
|
||||
}
|
||||
w = httptest.NewRecorder()
|
||||
h.ServeHTTP(w, httptest.NewRequest("POST", "/ntfy/ops", strings.NewReader(strings.Repeat("x", (1<<20)+1))))
|
||||
if w.Code != 413 {
|
||||
t.Fatal("large body accepted")
|
||||
}
|
||||
for i := 0; i < 11; i++ {
|
||||
w = httptest.NewRecorder()
|
||||
h.ServeHTTP(w, httptest.NewRequest("POST", "/login", nil))
|
||||
}
|
||||
if w.Code != 429 {
|
||||
t.Fatalf("rate limit %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscordSignatureAllowlistAndReplay(t *testing.T) {
|
||||
s, store, db := queuedServer(t)
|
||||
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
|
||||
c := store.Get()
|
||||
c.Ingress.Discord = config.DiscordIngress{Enabled: true, PublicKey: hex.EncodeToString(pub), ApplicationID: "app", GuildIDs: []string{"guild"}, ChannelIDs: []string{"channel"}, Command: "notify"}
|
||||
c.Mappings = []config.Mapping{{ID: "discord", Enabled: true, Source: "discord", Target: "news", TextTemplate: "{{.Message}}"}}
|
||||
if err := store.Replace(c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := s.Handler()
|
||||
invoke := func(body string, stamp time.Time, valid bool) *httptest.ResponseRecorder {
|
||||
r := httptest.NewRequest("POST", "/in/discord", strings.NewReader(body))
|
||||
ts := strconv.FormatInt(stamp.Unix(), 10)
|
||||
sig := ed25519.Sign(priv, []byte(ts+body))
|
||||
if !valid {
|
||||
sig[0] ^= 1
|
||||
}
|
||||
r.Header.Set("X-Signature-Timestamp", ts)
|
||||
r.Header.Set("X-Signature-Ed25519", hex.EncodeToString(sig))
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
return w
|
||||
}
|
||||
ping := `{"type":1,"application_id":"app"}`
|
||||
if w := invoke(ping, time.Now(), true); w.Code != 200 || !strings.Contains(w.Body.String(), `"type":1`) {
|
||||
t.Fatalf("ping %d %s", w.Code, w.Body)
|
||||
}
|
||||
if w := invoke(ping, time.Now(), false); w.Code != 401 {
|
||||
t.Fatal("bad signature accepted")
|
||||
}
|
||||
if w := invoke(ping, time.Now().Add(-6*time.Minute), true); w.Code != 401 {
|
||||
t.Fatal("stale request accepted")
|
||||
}
|
||||
body := `{"id":"interaction","application_id":"app","type":2,"guild_id":"guild","channel_id":"channel","data":{"name":"notify","type":1,"options":[{"name":"message","type":3,"value":"hello"}]}}`
|
||||
for i := 0; i < 2; i++ {
|
||||
if w := invoke(body, time.Now(), true); w.Code != 200 {
|
||||
t.Fatalf("command %d %s", w.Code, w.Body)
|
||||
}
|
||||
}
|
||||
if w := invoke(strings.Replace(body, `"guild"`, `"other"`, 1), time.Now(), true); w.Code != 403 {
|
||||
t.Fatal("guild allowlist bypass")
|
||||
}
|
||||
rows, _ := db.List(context.Background(), 50, 0)
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("duplicate interaction: %+v", rows)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/example/notify-gateway/internal/auth"
|
||||
"github.com/example/notify-gateway/internal/config"
|
||||
"github.com/example/notify-gateway/internal/divera"
|
||||
"github.com/example/notify-gateway/internal/gateway"
|
||||
)
|
||||
|
||||
func TestOutboundAdminRoundTripPreviewAndIngress(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
store, err := config.Open(configPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
client := divera.New(func() config.DiveraConfig { return store.Get().Divera })
|
||||
h := New(store, gateway.New(store, client), client, log.New(io.Discard, "", 0)).Handler()
|
||||
c := store.Get()
|
||||
cookie := &http.Cookie{Name: "ng_session", Value: auth.SignSession(c.Server.SessionSecret, c.Server.AdminUsername, time.Now().Add(time.Hour))}
|
||||
edit := makeEditable(c)
|
||||
edit.Outbounds = []config.OutboundConfig{{ID: "discord-ops", Name: "Ops", Provider: "discord", URL: "https://discord.com/api/webhooks/123/secret"}}
|
||||
edit.Ingress.NtfyTokens = map[string]string{"ops": "ingress-token"}
|
||||
edit.Mappings = []config.Mapping{{ID: "route", Name: "Route", Enabled: true, Source: "ntfy", Target: "discord", OutboundID: "discord-ops", TitleTemplate: "{{.Title}}", TextTemplate: "{{.Message}}"}}
|
||||
body, _ := json.Marshal(edit)
|
||||
request := func(method, path string, body []byte, admin bool) *httptest.ResponseRecorder {
|
||||
r := httptest.NewRequest(method, path, bytes.NewReader(body))
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
if admin {
|
||||
r.AddCookie(cookie)
|
||||
r.Header.Set("X-CSRF-Token", csrfToken(c.Server.SessionSecret, cookie.Value))
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
return w
|
||||
}
|
||||
if w := request("PUT", "/ui/api/config", body, false); w.Code != 303 {
|
||||
t.Fatalf("unprotected admin API: %d", w.Code)
|
||||
}
|
||||
if w := request("PUT", "/ui/api/config", body, true); w.Code != 200 {
|
||||
t.Fatalf("save: %d %s", w.Code, w.Body)
|
||||
}
|
||||
if store.Get().Server.SessionSecret != c.Server.SessionSecret {
|
||||
t.Fatal("admin secret changed")
|
||||
}
|
||||
reopened, err := config.Open(configPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(reopened.Get().Outbounds) != 1 || reopened.Get().Outbounds[0].ID != "discord-ops" {
|
||||
t.Fatal("outbound was not persisted")
|
||||
}
|
||||
w := request("GET", "/ui/api/config", nil, true)
|
||||
var loaded editableConfig
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &loaded); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(loaded.Outbounds) != 1 || loaded.Outbounds[0].Live || loaded.Mappings[0].OutboundID != "discord-ops" {
|
||||
t.Fatalf("round trip: %+v", loaded)
|
||||
}
|
||||
preview, _ := json.Marshal(map[string]any{"mapping": edit.Mappings[0], "message": map[string]any{"source": "ntfy", "title": "Hello", "message": "World"}})
|
||||
w = request("POST", "/ui/api/preview", preview, true)
|
||||
if w.Code != 200 || !strings.Contains(w.Body.String(), `"content":"Hello\nWorld"`) {
|
||||
t.Fatalf("preview: %d %s", w.Code, w.Body)
|
||||
}
|
||||
for _, authorized := range []bool{false, true} {
|
||||
r := httptest.NewRequest("POST", "/ntfy/ops", strings.NewReader("World"))
|
||||
r.Header.Set("Title", "Hello")
|
||||
if authorized {
|
||||
r.Header.Set("Authorization", "Bearer ingress-token")
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
if !authorized {
|
||||
if w.Code != 401 {
|
||||
t.Fatalf("ingress auth: %d", w.Code)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if w.Code != 200 || !strings.Contains(w.Body.String(), `"outbound_id":"discord-ops"`) || !strings.Contains(w.Body.String(), `dry_run`) {
|
||||
t.Fatalf("delivery: %d %s", w.Code, w.Body)
|
||||
}
|
||||
if strings.Contains(w.Body.String(), "secret") {
|
||||
t.Fatal("destination token leaked")
|
||||
}
|
||||
}
|
||||
// A destination still referenced by a mapping cannot be removed.
|
||||
edit.Outbounds = nil
|
||||
body, _ = json.Marshal(edit)
|
||||
if w := request("PUT", "/ui/api/config", body, true); w.Code != 400 {
|
||||
t.Fatalf("invalid reference: %d %s", w.Code, w.Body)
|
||||
}
|
||||
if len(store.Get().Outbounds) != 1 {
|
||||
t.Fatal("invalid config was persisted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type loginBucket struct {
|
||||
Start time.Time
|
||||
Count int
|
||||
}
|
||||
|
||||
func (s *Server) allowLogin(address string) bool {
|
||||
ip, _, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
ip = address
|
||||
}
|
||||
s.loginMu.Lock()
|
||||
defer s.loginMu.Unlock()
|
||||
if s.logins == nil {
|
||||
s.logins = map[string]loginBucket{}
|
||||
}
|
||||
now := time.Now()
|
||||
for k, v := range s.logins {
|
||||
if now.Sub(v.Start) > time.Minute {
|
||||
delete(s.logins, k)
|
||||
}
|
||||
}
|
||||
b := s.logins[ip]
|
||||
if b.Start.IsZero() {
|
||||
if len(s.logins) >= 1024 {
|
||||
return false
|
||||
}
|
||||
b.Start = now
|
||||
}
|
||||
b.Count++
|
||||
s.logins[ip] = b
|
||||
return b.Count <= 10
|
||||
}
|
||||
|
||||
func requestProtection(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// No proxy headers are trusted. Configure the proxy to preserve the Host.
|
||||
admin := strings.HasPrefix(r.URL.Path, "/ui/") || r.URL.Path == "/login" || r.URL.Path == "/logout"
|
||||
if admin {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
}
|
||||
if admin && r.Method != "GET" && r.Method != "HEAD" {
|
||||
if origin := r.Header.Get("Origin"); origin != "" {
|
||||
u, err := url.Parse(origin)
|
||||
if err != nil || u.Host != r.Host || (u.Scheme != "http" && u.Scheme != "https") {
|
||||
http.Error(w, "cross-origin request rejected", 403)
|
||||
return
|
||||
}
|
||||
}
|
||||
if r.Header.Get("Sec-Fetch-Site") == "cross-site" {
|
||||
http.Error(w, "cross-site request rejected", 403)
|
||||
return
|
||||
}
|
||||
}
|
||||
if r.Body != nil {
|
||||
limit := int64(1 << 20)
|
||||
if admin {
|
||||
limit = 2 << 20
|
||||
}
|
||||
b, err := io.ReadAll(io.LimitReader(r.Body, limit+1))
|
||||
r.Body.Close()
|
||||
if err != nil {
|
||||
http.Error(w, "invalid body", 400)
|
||||
return
|
||||
}
|
||||
if int64(len(b)) > limit {
|
||||
http.Error(w, "request too large", 413)
|
||||
return
|
||||
}
|
||||
r.Body = io.NopCloser(bytes.NewReader(b))
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,513 @@
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
_ "embed"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"html/template"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/example/notify-gateway/internal/auth"
|
||||
"github.com/example/notify-gateway/internal/config"
|
||||
"github.com/example/notify-gateway/internal/divera"
|
||||
"github.com/example/notify-gateway/internal/gateway"
|
||||
"github.com/example/notify-gateway/internal/mailingress"
|
||||
"github.com/example/notify-gateway/internal/model"
|
||||
"github.com/example/notify-gateway/internal/outbox"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
queue *gateway.Queue
|
||||
mail *mailingress.Poller
|
||||
loginMu sync.Mutex
|
||||
logins map[string]loginBucket
|
||||
store *config.Store
|
||||
dispatcher *gateway.Dispatcher
|
||||
divera *divera.Client
|
||||
logger *log.Logger
|
||||
}
|
||||
|
||||
func New(store *config.Store, dispatcher *gateway.Dispatcher, client *divera.Client, logger *log.Logger) *Server {
|
||||
return &Server{store: store, dispatcher: dispatcher, divera: client, logger: logger}
|
||||
}
|
||||
|
||||
func (s *Server) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /healthz", s.health)
|
||||
mux.HandleFunc("GET /readyz", s.ready)
|
||||
mux.HandleFunc("GET /metrics", s.metrics)
|
||||
mux.HandleFunc("GET /ui/api/csrf", s.requireAdmin(s.csrf))
|
||||
mux.HandleFunc("GET /ui/api/deliveries", s.requireAdmin(s.deliveries))
|
||||
mux.HandleFunc("GET /ui/api/deliveries/{id}/attempts", s.requireAdmin(s.deliveryHistory))
|
||||
mux.HandleFunc("POST /ui/api/deliveries/{id}/retry", s.requireAdmin(s.retryDelivery))
|
||||
mux.HandleFunc("POST /in/discord", s.discord)
|
||||
mux.HandleFunc("POST /login", s.login)
|
||||
mux.HandleFunc("GET /login", s.loginPage)
|
||||
mux.HandleFunc("POST /logout", s.requireAdmin(s.logout))
|
||||
mux.HandleFunc("GET /ui/", s.requireAdmin(s.ui))
|
||||
mux.HandleFunc("GET /ui/api/config", s.requireAdmin(s.apiConfig))
|
||||
mux.HandleFunc("PUT /ui/api/config", s.requireAdmin(s.apiConfig))
|
||||
mux.HandleFunc("GET /ui/api/divera247/catalog", s.requireAdmin(s.apiDiveraCatalog))
|
||||
mux.HandleFunc("POST /ui/api/preview", s.requireAdmin(s.apiPreview))
|
||||
mux.HandleFunc("POST /ui/password", s.requireAdmin(s.changePassword))
|
||||
mux.HandleFunc("POST /ui/test-divera", s.requireAdmin(s.testDivera))
|
||||
mux.HandleFunc("POST /in/webhook/{channel}", s.webhook)
|
||||
mux.HandleFunc("PUT /in/webhook/{channel}", s.webhook)
|
||||
mux.HandleFunc("POST /in/ntfy", s.ntfyJSON)
|
||||
mux.HandleFunc("PUT /in/ntfy", s.ntfyJSON)
|
||||
mux.HandleFunc("POST /ntfy", s.ntfyJSON)
|
||||
mux.HandleFunc("PUT /ntfy", s.ntfyJSON)
|
||||
mux.HandleFunc("POST /in/ntfy/{topic}", s.ntfyTopic)
|
||||
mux.HandleFunc("PUT /in/ntfy/{topic}", s.ntfyTopic)
|
||||
mux.HandleFunc("GET /in/ntfy/{topic}/trigger", s.ntfyTrigger)
|
||||
mux.HandleFunc("GET /in/ntfy/{topic}/send", s.ntfyTrigger)
|
||||
mux.HandleFunc("GET /in/ntfy/{topic}/publish", s.ntfyTrigger)
|
||||
mux.HandleFunc("POST /ntfy/{topic}", s.ntfyTopic)
|
||||
mux.HandleFunc("PUT /ntfy/{topic}", s.ntfyTopic)
|
||||
mux.HandleFunc("GET /ntfy/{topic}/trigger", s.ntfyTrigger)
|
||||
mux.HandleFunc("GET /ntfy/{topic}/send", s.ntfyTrigger)
|
||||
mux.HandleFunc("GET /ntfy/{topic}/publish", s.ntfyTrigger)
|
||||
mux.HandleFunc("POST /in/gotify/message", s.gotify)
|
||||
mux.HandleFunc("POST /message", s.gotify)
|
||||
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/ui/", http.StatusFound) })
|
||||
return securityHeaders(requestProtection(mux))
|
||||
}
|
||||
|
||||
func securityHeaders(next http.Handler) http.Handler {
|
||||
script := strings.Split(strings.Split(uiHTML, "<script>")[1], "</script>")[0]
|
||||
// HTML parsing normalizes CRLF before CSP hashes are checked.
|
||||
sum := sha256.Sum256([]byte(strings.ReplaceAll(script, "\r\n", "\n")))
|
||||
scriptHash := base64.StdEncoding.EncodeToString(sum[:])
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'sha256-"+scriptHash+"'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; base-uri 'none'; form-action 'self'; frame-ancestors 'none'")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("X-Frame-Options", "DENY")
|
||||
w.Header().Set("Referrer-Policy", "same-origin")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
func (s *Server) health(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, 200, map[string]any{"ok": true, "time": time.Now().UTC()})
|
||||
}
|
||||
|
||||
func (s *Server) loginPage(w http.ResponseWriter, r *http.Request) { renderLogin(w, "") }
|
||||
func (s *Server) login(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.allowLogin(r.RemoteAddr) {
|
||||
w.Header().Set("Retry-After", "60")
|
||||
http.Error(w, "too many login attempts", 429)
|
||||
return
|
||||
}
|
||||
_ = r.ParseForm()
|
||||
cfg := s.store.Get()
|
||||
if r.Form.Get("username") != cfg.Server.AdminUsername || !auth.CheckPassword(cfg.Server.AdminPasswordHash, r.Form.Get("password")) {
|
||||
renderLogin(w, "Login fehlgeschlagen")
|
||||
return
|
||||
}
|
||||
if strings.HasPrefix(cfg.Server.AdminPasswordHash, "sha256$") && len(r.Form.Get("password")) <= 72 {
|
||||
h, err := auth.HashPassword(r.Form.Get("password"))
|
||||
if err != nil {
|
||||
http.Error(w, "password migration failed", 500)
|
||||
return
|
||||
}
|
||||
if err = s.store.Update(func(c *config.Config) error { c.Server.AdminPasswordHash = h; return nil }); err != nil {
|
||||
http.Error(w, "password migration failed", 500)
|
||||
return
|
||||
}
|
||||
}
|
||||
token := auth.SignSession(cfg.Server.SessionSecret, cfg.Server.AdminUsername, time.Now().Add(12*time.Hour))
|
||||
http.SetCookie(w, &http.Cookie{Name: "ng_session", Value: token, Path: "/", HttpOnly: true, SameSite: http.SameSiteStrictMode, Secure: r.TLS != nil, MaxAge: 43200})
|
||||
http.Redirect(w, r, "/ui/", http.StatusSeeOther)
|
||||
}
|
||||
func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
|
||||
http.SetCookie(w, &http.Cookie{Name: "ng_session", Value: "", Path: "/", HttpOnly: true, MaxAge: -1, SameSite: http.SameSiteStrictMode})
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
}
|
||||
func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
cfg := s.store.Get()
|
||||
c, err := r.Cookie("ng_session")
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
u, ok := auth.VerifySession(cfg.Server.SessionSecret, c.Value, time.Now())
|
||||
if !ok || u != cfg.Server.AdminUsername {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if r.Method != "GET" && r.Method != "HEAD" {
|
||||
token := r.Header.Get("X-CSRF-Token")
|
||||
if token == "" && strings.HasPrefix(r.Header.Get("Content-Type"), "application/x-www-form-urlencoded") {
|
||||
_ = r.ParseForm()
|
||||
token = r.Form.Get("csrf_token")
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(token), []byte(csrfToken(cfg.Server.SessionSecret, c.Value))) != 1 {
|
||||
http.Error(w, "invalid CSRF token", 403)
|
||||
return
|
||||
}
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
//go:embed ui.html
|
||||
var uiHTML string
|
||||
|
||||
func (s *Server) ui(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = io.WriteString(w, uiHTML)
|
||||
}
|
||||
func (s *Server) changePassword(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
p := r.Form.Get("password")
|
||||
if len(p) < 10 || len(p) > 72 {
|
||||
http.Error(w, "Passwort muss 10 bis 72 UTF-8-Bytes enthalten", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
h, err := auth.HashPassword(p)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := s.store.Update(func(c *config.Config) error {
|
||||
c.Server.AdminPasswordHash = h
|
||||
c.Server.SessionSecret = auth.RandomSecret()
|
||||
return nil
|
||||
}); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/ui/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) testDivera(w http.ResponseWriter, r *http.Request) {
|
||||
resp, err := s.divera.PullAll(r.Context(), nil)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 502)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
_, _ = w.Write(resp.Body)
|
||||
}
|
||||
|
||||
func (s *Server) authorized(source, channel string, r *http.Request) bool {
|
||||
cfg := s.store.Get()
|
||||
if cfg.Ingress.AllowUnauthenticated {
|
||||
return true
|
||||
}
|
||||
token := bearerOrToken(r)
|
||||
var allowed []string
|
||||
switch source {
|
||||
case "ntfy":
|
||||
if t := cfg.Ingress.NtfyTokens[channel]; t != "" {
|
||||
allowed = append(allowed, t)
|
||||
}
|
||||
if t := cfg.Ingress.NtfyTokens["*"]; t != "" {
|
||||
allowed = append(allowed, t)
|
||||
}
|
||||
case "webhook":
|
||||
if t := cfg.Ingress.WebhookTokens[channel]; t != "" {
|
||||
allowed = append(allowed, t)
|
||||
}
|
||||
if t := cfg.Ingress.WebhookTokens["*"]; t != "" {
|
||||
allowed = append(allowed, t)
|
||||
}
|
||||
case "gotify":
|
||||
allowed = append(allowed, cfg.Ingress.GotifyTokens...)
|
||||
}
|
||||
for _, a := range allowed {
|
||||
if len(a) == len(token) && subtle.ConstantTimeCompare([]byte(a), []byte(token)) == 1 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
func bearerOrToken(r *http.Request) string {
|
||||
if t := r.URL.Query().Get("token"); t != "" {
|
||||
return t
|
||||
}
|
||||
if t := r.Header.Get("X-Gotify-Key"); t != "" {
|
||||
return t
|
||||
}
|
||||
a := r.Header.Get("Authorization")
|
||||
if strings.HasPrefix(strings.ToLower(a), "bearer ") {
|
||||
return strings.TrimSpace(a[7:])
|
||||
}
|
||||
if u, p, ok := r.BasicAuth(); ok {
|
||||
if p != "" {
|
||||
return p
|
||||
}
|
||||
return u
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *Server) webhook(w http.ResponseWriter, r *http.Request) {
|
||||
channel := r.PathValue("channel")
|
||||
if !s.authorized("webhook", channel, r) {
|
||||
writeJSON(w, 401, map[string]any{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
raw, body, err := decodeFlexible(r)
|
||||
if err != nil {
|
||||
writeJSON(w, 400, map[string]any{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
msg := model.InboundMessage{Source: "webhook", Channel: channel, Title: str(raw, "title", "subject"), Message: firstNonEmpty(str(raw, "message", "text", "body"), body), Address: str(raw, "address", "location"), Priority: intval(raw, "priority"), Raw: raw, ReceivedAt: time.Now().UTC()}
|
||||
s.deliver(w, r, msg)
|
||||
}
|
||||
func (s *Server) ntfyJSON(w http.ResponseWriter, r *http.Request) {
|
||||
var raw map[string]any
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&raw); err != nil {
|
||||
writeJSON(w, 400, map[string]any{"error": "invalid ntfy json"})
|
||||
return
|
||||
}
|
||||
topic := str(raw, "topic")
|
||||
if topic == "" {
|
||||
writeJSON(w, 400, map[string]any{"error": "topic required"})
|
||||
return
|
||||
}
|
||||
if !s.authorized("ntfy", topic, r) {
|
||||
writeJSON(w, 401, map[string]any{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
msg := model.InboundMessage{Source: "ntfy", Channel: topic, Title: str(raw, "title"), Message: str(raw, "message"), Priority: intval(raw, "priority"), Raw: raw, ReceivedAt: time.Now().UTC()}
|
||||
s.deliver(w, r, msg)
|
||||
}
|
||||
func (s *Server) ntfyTopic(w http.ResponseWriter, r *http.Request) {
|
||||
topic := r.PathValue("topic")
|
||||
if !s.authorized("ntfy", topic, r) {
|
||||
writeJSON(w, 401, map[string]any{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
raw, body, err := decodeNtfy(r)
|
||||
if err != nil {
|
||||
writeJSON(w, 400, map[string]any{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
title := firstNonEmpty(r.Header.Get("Title"), r.Header.Get("X-Title"), str(raw, "title"))
|
||||
message := firstNonEmpty(str(raw, "message"), body)
|
||||
priority := intval(raw, "priority")
|
||||
if priority == 0 {
|
||||
priority = parsePriority(firstNonEmpty(r.Header.Get("Priority"), r.Header.Get("X-Priority")))
|
||||
}
|
||||
msg := model.InboundMessage{Source: "ntfy", Channel: topic, Title: title, Message: message, Priority: priority, Raw: raw, ReceivedAt: time.Now().UTC()}
|
||||
s.deliver(w, r, msg)
|
||||
}
|
||||
func (s *Server) ntfyTrigger(w http.ResponseWriter, r *http.Request) {
|
||||
topic := r.PathValue("topic")
|
||||
if !s.authorized("ntfy", topic, r) {
|
||||
writeJSON(w, 401, map[string]any{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
msg := model.InboundMessage{Source: "ntfy", Channel: topic, Title: r.URL.Query().Get("title"), Message: firstNonEmpty(r.URL.Query().Get("message"), "triggered"), Priority: parsePriority(r.URL.Query().Get("priority")), Raw: map[string]any{"query": r.URL.Query()}, ReceivedAt: time.Now().UTC()}
|
||||
s.deliver(w, r, msg)
|
||||
}
|
||||
func (s *Server) gotify(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.authorized("gotify", "", r) {
|
||||
writeJSON(w, 401, map[string]any{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
raw, body, err := decodeFlexible(r)
|
||||
if err != nil {
|
||||
writeJSON(w, 400, map[string]any{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if len(raw) == 0 {
|
||||
_ = r.ParseForm()
|
||||
raw = map[string]any{"title": r.Form.Get("title"), "message": r.Form.Get("message"), "priority": r.Form.Get("priority")}
|
||||
}
|
||||
msg := model.InboundMessage{Source: "gotify", Channel: firstNonEmpty(str(raw, "channel"), "default"), Title: str(raw, "title"), Message: firstNonEmpty(str(raw, "message"), body), Priority: intval(raw, "priority"), Raw: raw, ReceivedAt: time.Now().UTC()}
|
||||
s.deliver(w, r, msg)
|
||||
}
|
||||
func (s *Server) deliver(w http.ResponseWriter, r *http.Request, msg model.InboundMessage) {
|
||||
if s.queue != nil {
|
||||
// Never accept a gateway-generated HTTP notification back into the gateway.
|
||||
if r.Header.Get("X-Notify-Gateway-Delivery") != "" {
|
||||
writeJSON(w, 409, map[string]any{"error": "gateway loop detected"})
|
||||
return
|
||||
}
|
||||
receipt, err := s.queue.Accept(r.Context(), msg, r.Header.Get("Idempotency-Key"))
|
||||
if err != nil {
|
||||
status := 503
|
||||
var input *gateway.InputError
|
||||
if errors.As(err, &input) {
|
||||
status = 400
|
||||
}
|
||||
if errors.Is(err, outbox.ErrConflict) {
|
||||
status = 409
|
||||
}
|
||||
message := "outbox unavailable"
|
||||
if status != 503 {
|
||||
message = err.Error()
|
||||
}
|
||||
writeJSON(w, status, map[string]any{"error": message})
|
||||
return
|
||||
}
|
||||
writeJSON(w, 202, map[string]any{"ok": true, "receipt": receipt})
|
||||
return
|
||||
}
|
||||
results, err := s.dispatcher.Dispatch(r.Context(), msg)
|
||||
if err != nil {
|
||||
s.logger.Printf("delivery failed: %v", err)
|
||||
writeJSON(w, 502, map[string]any{"error": err.Error(), "results": results})
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"ok": true, "results": results})
|
||||
}
|
||||
|
||||
func decodeNtfy(r *http.Request) (map[string]any, string, error) {
|
||||
b, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
raw := map[string]any{}
|
||||
if strings.Contains(r.Header.Get("Content-Type"), "application/json") || (len(b) > 0 && b[0] == '{') {
|
||||
if err := json.Unmarshal(b, &raw); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return raw, "", nil
|
||||
}
|
||||
return raw, string(b), nil
|
||||
}
|
||||
|
||||
func decodeFlexible(r *http.Request) (map[string]any, string, error) {
|
||||
b, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
raw := map[string]any{}
|
||||
ct := r.Header.Get("Content-Type")
|
||||
if strings.Contains(ct, "application/json") || (len(b) > 0 && b[0] == '{') {
|
||||
if err := json.Unmarshal(b, &raw); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return raw, "", nil
|
||||
}
|
||||
if strings.Contains(ct, "application/x-www-form-urlencoded") {
|
||||
vals, err := urlParse(string(b))
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
for k, v := range vals {
|
||||
if len(v) > 0 {
|
||||
raw[k] = v[0]
|
||||
}
|
||||
}
|
||||
return raw, "", nil
|
||||
}
|
||||
return raw, string(b), nil
|
||||
}
|
||||
func urlParse(s string) (map[string][]string, error) {
|
||||
vals, err := netURLParseQuery(s)
|
||||
return map[string][]string(vals), err
|
||||
}
|
||||
|
||||
var netURLParseQuery = func(s string) (map[string][]string, error) {
|
||||
out := map[string][]string{}
|
||||
for _, p := range strings.Split(s, "&") {
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
kv := strings.SplitN(p, "=", 2)
|
||||
k := strings.ReplaceAll(kv[0], "+", " ")
|
||||
v := ""
|
||||
if len(kv) > 1 {
|
||||
v = strings.ReplaceAll(kv[1], "+", " ")
|
||||
}
|
||||
ku, err := urlQueryUnescape(k)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
vu, err := urlQueryUnescape(v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[ku] = append(out[ku], vu)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
var urlQueryUnescape = func(s string) (string, error) { return queryUnescape(s) }
|
||||
|
||||
func queryUnescape(s string) (string, error) { // minimal wrapper avoids exposing net/url in helpers
|
||||
var b strings.Builder
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] == '%' && i+2 < len(s) {
|
||||
n, err := strconv.ParseUint(s[i+1:i+3], 16, 8)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
b.WriteByte(byte(n))
|
||||
i += 2
|
||||
} else {
|
||||
b.WriteByte(s[i])
|
||||
}
|
||||
}
|
||||
return b.String(), nil
|
||||
}
|
||||
func str(m map[string]any, keys ...string) string {
|
||||
for _, k := range keys {
|
||||
if v, ok := m[k]; ok {
|
||||
switch x := v.(type) {
|
||||
case string:
|
||||
return x
|
||||
case json.Number:
|
||||
return x.String()
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
func intval(m map[string]any, k string) int {
|
||||
v, ok := m[k]
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
switch x := v.(type) {
|
||||
case float64:
|
||||
return int(x)
|
||||
case int:
|
||||
return x
|
||||
case string:
|
||||
n, _ := strconv.Atoi(x)
|
||||
return n
|
||||
case json.Number:
|
||||
n, _ := strconv.Atoi(x.String())
|
||||
return n
|
||||
}
|
||||
return 0
|
||||
}
|
||||
func parsePriority(s string) int { n, _ := strconv.Atoi(s); return n }
|
||||
func firstNonEmpty(v ...string) string {
|
||||
for _, s := range v {
|
||||
if s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
var loginTpl = template.Must(template.New("login").Parse(`<!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>Notify Gateway Login</title><style>body{font:16px system-ui;max-width:420px;margin:10vh auto;padding:1rem}input,button{width:100%;padding:.7rem;margin:.35rem 0;box-sizing:border-box}.err{color:#a00}</style></head><body><h1>Notify Gateway</h1>{{if .}}<p class="err">{{.}}</p>{{end}}<form method="post" action="/login"><input name="username" placeholder="Benutzer" autocomplete="username"><input type="password" name="password" placeholder="Passwort" autocomplete="current-password"><button>Anmelden</button></form></body></html>`))
|
||||
|
||||
func renderLogin(w http.ResponseWriter, msg string) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_ = loginTpl.Execute(w, msg)
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user