package http import ( "context" "crypto/subtle" "encoding/json" "net/http" "strings" "gitea.nik4nao.com/nik/home-services/alert-bridge/internal/core/domain" "gitea.nik4nao.com/nik/home-services/alert-bridge/internal/logger" ) // AlertHandler processes one decoded, validated alert. internal/app.AlertApp // implements this. type AlertHandler interface { Handle(ctx context.Context, alert domain.Alert) error } // alertRequest is the wire shape callers POST to /alerts. type alertRequest struct { Source string `json:"source"` Message string `json:"message"` Level string `json:"level"` } // Handler is the HTTP entrypoint external callers (e.g. cronjobs) POST alerts to. type Handler struct { app AlertHandler apiKey string } // NewHandler constructs the HTTP handler for the /alerts endpoint. func NewHandler(app AlertHandler, apiKey string) *Handler { return &Handler{app: app, apiKey: apiKey} } func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { ctx := r.Context() log := logger.FromContext(ctx) if !h.authorized(r) { http.Error(w, "unauthorized", http.StatusUnauthorized) return } var body alertRequest if err := json.NewDecoder(r.Body).Decode(&body); err != nil { log.Warn("decode request body failed", "err", err) http.Error(w, "bad request", http.StatusBadRequest) return } if body.Source == "" || body.Message == "" { http.Error(w, "source and message are required", http.StatusBadRequest) return } level := domain.Level(body.Level) if level == "" { level = domain.LevelInfo } switch level { case domain.LevelInfo, domain.LevelWarn, domain.LevelError: default: http.Error(w, "level must be one of: info, warn, error", http.StatusBadRequest) return } alert := domain.Alert{Source: body.Source, Message: body.Message, Level: level} if err := h.app.Handle(ctx, alert); err != nil { log.Error("handle alert failed", "err", err, "source", body.Source) http.Error(w, "failed to deliver alert", http.StatusBadGateway) return } w.WriteHeader(http.StatusAccepted) } // authorized checks the Authorization: Bearer header against the // configured API key using a constant-time comparison so a mistyped key // doesn't leak how many leading bytes matched via response timing. func (h *Handler) authorized(r *http.Request) bool { const prefix = "Bearer " auth := r.Header.Get("Authorization") if !strings.HasPrefix(auth, prefix) { return false } token := strings.TrimPrefix(auth, prefix) return subtle.ConstantTimeCompare([]byte(token), []byte(h.apiKey)) == 1 }