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, "")[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(`
{{.}}
{{end}}`)) func renderLogin(w http.ResponseWriter, msg string) { w.Header().Set("Content-Type", "text/html; charset=utf-8") _ = loginTpl.Execute(w, msg) }