chore: establish local platform baseline
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
package mockalertreceiver
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const maxBodyBytes = 64 * 1024
|
||||
|
||||
type Options struct {
|
||||
Secret string
|
||||
ClockSkew time.Duration
|
||||
}
|
||||
|
||||
type Event struct {
|
||||
EventID string `json:"event_id"`
|
||||
EventType string `json:"event_type"`
|
||||
TestRef string `json:"test_ref,omitempty"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
ReceivedAt time.Time `json:"received_at"`
|
||||
}
|
||||
|
||||
type envelope struct {
|
||||
EventID string `json:"event_id"`
|
||||
EventType string `json:"event_type"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
secret []byte
|
||||
clockSkew time.Duration
|
||||
logger *slog.Logger
|
||||
now func() time.Time
|
||||
mu sync.RWMutex
|
||||
events []Event
|
||||
}
|
||||
|
||||
func New(options Options, logger *slog.Logger) (*Server, error) {
|
||||
options.Secret = strings.TrimSpace(options.Secret)
|
||||
if len(options.Secret) < 32 {
|
||||
return nil, errors.New("mock alert receiver secret must contain at least 32 characters")
|
||||
}
|
||||
if options.ClockSkew <= 0 {
|
||||
return nil, errors.New("mock alert receiver clock skew must be positive")
|
||||
}
|
||||
if logger == nil {
|
||||
return nil, errors.New("mock alert receiver logger is required")
|
||||
}
|
||||
return &Server{secret: []byte(options.Secret), clockSkew: options.ClockSkew, logger: logger, now: time.Now, events: []Event{}}, nil
|
||||
}
|
||||
|
||||
func (s *Server) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /health/live", func(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "alive", "service": "mock-alert-receiver"})
|
||||
})
|
||||
mux.HandleFunc("POST /webhooks/alerts", s.receive)
|
||||
mux.HandleFunc("GET /events", s.list)
|
||||
return mux
|
||||
}
|
||||
|
||||
func (s *Server) receive(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, maxBodyBytes+1))
|
||||
if err != nil || len(body) > maxBodyBytes {
|
||||
http.Error(w, "invalid body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
timestamp := strings.TrimSpace(r.Header.Get("X-Voice-Timestamp"))
|
||||
eventID := strings.TrimSpace(r.Header.Get("X-Voice-Event-ID"))
|
||||
unixSeconds, err := strconv.ParseInt(timestamp, 10, 64)
|
||||
if err != nil || eventID == "" || s.now().UTC().Sub(time.Unix(unixSeconds, 0).UTC()).Abs() > s.clockSkew || !validSignature(s.secret, timestamp, body, r.Header.Get("X-Voice-Signature")) {
|
||||
http.Error(w, "invalid webhook authentication", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
var incoming envelope
|
||||
if err = json.Unmarshal(body, &incoming); err != nil || incoming.EventID != eventID || incoming.EventType == "" || !json.Valid(incoming.Data) {
|
||||
http.Error(w, "invalid webhook envelope", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
var metadata struct {
|
||||
TestRef string `json:"test_ref"`
|
||||
}
|
||||
_ = json.Unmarshal(incoming.Data, &metadata)
|
||||
received := Event{EventID: incoming.EventID, EventType: incoming.EventType, TestRef: metadata.TestRef, Data: incoming.Data, ReceivedAt: s.now().UTC()}
|
||||
s.mu.Lock()
|
||||
found := false
|
||||
for index := range s.events {
|
||||
if s.events[index].EventID == received.EventID {
|
||||
s.events[index] = received
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
s.events = append([]Event{received}, s.events...)
|
||||
if len(s.events) > 100 {
|
||||
s.events = s.events[:100]
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
s.logger.InfoContext(r.Context(), "mock alert webhook received", "event_id", received.EventID, "event_type", received.EventType, "test_ref", received.TestRef)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) list(w http.ResponseWriter, r *http.Request) {
|
||||
testRef := strings.TrimSpace(r.URL.Query().Get("test_ref"))
|
||||
s.mu.RLock()
|
||||
items := make([]Event, 0, len(s.events))
|
||||
for _, item := range s.events {
|
||||
if testRef == "" || item.TestRef == testRef {
|
||||
items = append(items, item)
|
||||
}
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||
}
|
||||
|
||||
func validSignature(secret []byte, timestamp string, body []byte, provided string) bool {
|
||||
mac := hmac.New(sha256.New, secret)
|
||||
_, _ = mac.Write([]byte(timestamp))
|
||||
_, _ = mac.Write([]byte("."))
|
||||
_, _ = mac.Write(body)
|
||||
want := mac.Sum(nil)
|
||||
provided = strings.TrimSpace(provided)
|
||||
if !strings.HasPrefix(provided, "v1=") {
|
||||
return false
|
||||
}
|
||||
provided = strings.TrimPrefix(provided, "v1=")
|
||||
got, err := hex.DecodeString(provided)
|
||||
return err == nil && hmac.Equal(want, got)
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, value any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(value)
|
||||
}
|
||||
Reference in New Issue
Block a user