All checks were successful
CI / changes (push) Successful in 19s
CI / test (push) Successful in 31s
CI / build-ai-gateway (push) Successful in 1m8s
CI / build-ha-gateway (push) Successful in 1m19s
CI / build-discord-bot (push) Successful in 59s
CI / build-alexa-bridge (push) Successful in 1m8s
CI / build-alert-bridge (push) Successful in 1m17s
CI / build-tts-gateway (push) Successful in 1m10s
CI / build-tts-sidecar (push) Has been skipped
CI / build-tts-model (push) Has been skipped
New standalone service that receives authenticated HTTP POSTs (e.g. from a cronjob elsewhere on the home network) and posts them to a Discord channel via an Incoming Webhook. Fire-and-forget, no dependency on discord-bot's bot session at all - mirrors alexa-bridge's precedent of a small bridge service with its own HTTP listener and auth rather than adding an inbound edge to an existing internal service. Wires alert-bridge into go.work, CI (changes filter, test job, new build-alert-bridge job), and the other five Dockerfiles' COPY lines. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
92 lines
2.5 KiB
Go
92 lines
2.5 KiB
Go
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 <token> 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
|
|
}
|