Nik Afiq 39b7d6d4b4
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
feat: add alert-bridge service for external cronjob -> Discord alerts
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>
2026-08-01 22:45:18 +09:00

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
}