Files
yovision/Bell/web/web.go
T
QiuSW 8208118904
Harness governance / validate (pull_request) Has been cancelled
feat(bell): add alert acknowledgement vertical slice
2026-08-11 17:01:53 +08:00

207 lines
7.0 KiB
Go

// Package web exposes the loopback-only Bell engineering console.
package web
import (
"crypto/sha256"
"crypto/subtle"
"embed"
"encoding/json"
"errors"
"io"
"net/http"
"regexp"
"strconv"
"strings"
"yovision/bell/internal/alert"
)
//go:embed assets/*
var assets embed.FS
type Config struct {
Token string
TenantID int64
SiteID int64
ActorRef string
}
type Handler struct {
repository alert.Repository
config Config
tokenHash [sha256.Size]byte
}
var alertIDPattern = regexp.MustCompile(`^alt_[0-9A-HJKMNP-TV-Z]{26}$`)
func NewHandler(repository alert.Repository, config Config) (*Handler, error) {
if repository == nil || len(config.Token) < 32 || len(config.Token) > 256 || config.TenantID < 1 || config.SiteID < 1 || !alert.ValidActorRef(config.ActorRef) {
return nil, errors.New("invalid Bell alert console configuration")
}
return &Handler{repository: repository, config: config, tokenHash: sha256.Sum256([]byte(config.Token))}, nil
}
func (h *Handler) Register(mux *http.ServeMux) {
mux.HandleFunc("GET /bell-console/", h.page)
mux.HandleFunc("GET /bell-console/assets/{name}", h.asset)
mux.HandleFunc("GET /bell-console/api/v1/alerts", h.list)
mux.HandleFunc("GET /bell-console/api/v1/alerts/{id}", h.detail)
mux.HandleFunc("POST /bell-console/api/v1/alerts/{action}", h.command)
}
func secureHeaders(writer http.ResponseWriter) {
writer.Header().Set("Cache-Control", "no-store")
writer.Header().Set("X-Content-Type-Options", "nosniff")
writer.Header().Set("X-Frame-Options", "DENY")
writer.Header().Set("Referrer-Policy", "no-referrer")
writer.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'")
}
func (h *Handler) page(writer http.ResponseWriter, _ *http.Request) {
secureHeaders(writer)
writer.Header().Set("Content-Type", "text/html; charset=utf-8")
value, _ := assets.ReadFile("assets/index.html")
_, _ = writer.Write(value)
}
func (h *Handler) asset(writer http.ResponseWriter, request *http.Request) {
secureHeaders(writer)
name := request.PathValue("name")
if name != "app.js" && name != "style.css" {
http.NotFound(writer, request)
return
}
value, err := assets.ReadFile("assets/" + name)
if err != nil {
http.NotFound(writer, request)
return
}
if strings.HasSuffix(name, ".js") {
writer.Header().Set("Content-Type", "text/javascript; charset=utf-8")
} else {
writer.Header().Set("Content-Type", "text/css; charset=utf-8")
}
_, _ = writer.Write(value)
}
func (h *Handler) authorize(writer http.ResponseWriter, request *http.Request) bool {
secureHeaders(writer)
prefix := "Bearer "
value := request.Header.Get("Authorization")
if !strings.HasPrefix(value, prefix) {
writeError(writer, http.StatusUnauthorized, "unauthorized", "需要控制台令牌")
return false
}
digest := sha256.Sum256([]byte(strings.TrimPrefix(value, prefix)))
if subtle.ConstantTimeCompare(digest[:], h.tokenHash[:]) != 1 {
writeError(writer, http.StatusUnauthorized, "unauthorized", "控制台令牌无效")
return false
}
return true
}
func (h *Handler) list(writer http.ResponseWriter, request *http.Request) {
if !h.authorize(writer, request) {
return
}
limit := alert.DefaultPageSize
state := request.URL.Query().Get("state")
if state != "" && state != "open" && state != "acknowledged" && state != "closed" {
writeError(writer, http.StatusBadRequest, "invalid_state", "state 筛选无效")
return
}
cursor := request.URL.Query().Get("cursor")
if cursor != "" && !alertIDPattern.MatchString(cursor) {
writeError(writer, http.StatusBadRequest, "invalid_cursor", "cursor 无效")
return
}
if raw := request.URL.Query().Get("limit"); raw != "" {
value, err := strconv.Atoi(raw)
if err != nil || value < 1 || value > alert.MaxPageSize {
writeError(writer, http.StatusBadRequest, "invalid_limit", "limit 必须在 1 到 100 之间")
return
}
limit = value
}
page, err := h.repository.ListAlerts(request.Context(), h.config.TenantID, h.config.SiteID, state, limit, cursor)
if err != nil {
writeError(writer, http.StatusInternalServerError, "internal_error", "读取 Alert 列表失败,可重试")
return
}
writeJSON(writer, http.StatusOK, page)
}
func (h *Handler) detail(writer http.ResponseWriter, request *http.Request) {
if !h.authorize(writer, request) {
return
}
id := request.PathValue("id")
if !alertIDPattern.MatchString(id) {
writeError(writer, http.StatusBadRequest, "invalid_alert_id", "Alert ID 无效")
return
}
value, err := h.repository.GetAlert(request.Context(), h.config.TenantID, h.config.SiteID, id)
if errors.Is(err, alert.ErrNotFound) {
writeError(writer, http.StatusNotFound, "not_found", "Alert 不存在")
return
}
if err != nil {
writeError(writer, http.StatusInternalServerError, "internal_error", "读取 Alert 失败,可重试")
return
}
writeJSON(writer, http.StatusOK, value)
}
func (h *Handler) command(writer http.ResponseWriter, request *http.Request) {
if !h.authorize(writer, request) {
return
}
action := request.PathValue("action")
id, command, found := strings.Cut(action, ":")
if !found || !alertIDPattern.MatchString(id) || (command != "ack" && command != "close") {
writeError(writer, http.StatusBadRequest, "invalid_command", "Alert 命令无效")
return
}
key := request.Header.Get("Idempotency-Key")
if !alert.ValidIdempotencyKey(key) {
writeError(writer, http.StatusBadRequest, "invalid_idempotency_key", "Idempotency-Key 必须为 8 到 128 个安全字符")
return
}
request.Body = http.MaxBytesReader(writer, request.Body, 2048)
decoder := json.NewDecoder(request.Body)
decoder.DisallowUnknownFields()
var body struct {
Note *string `json:"note"`
}
if err := decoder.Decode(&body); err != nil {
writeError(writer, http.StatusBadRequest, "invalid_json", "请求体必须是 JSON 对象")
return
}
var trailing any
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
writeError(writer, http.StatusBadRequest, "invalid_json", "请求体只能包含一个 JSON 对象")
return
}
status, response, err := h.repository.Command(request.Context(), h.config.TenantID, h.config.SiteID, id, command, key, h.config.ActorRef, body.Note)
switch {
case errors.Is(err, alert.ErrNotFound):
writeError(writer, http.StatusNotFound, "not_found", "Alert 不存在")
case errors.Is(err, alert.ErrIdempotencyConflict):
writeError(writer, http.StatusConflict, "idempotency_conflict", "该幂等键已用于另一条命令")
case err != nil:
writeError(writer, http.StatusInternalServerError, "internal_error", "写入处置状态失败,可使用同一幂等键重试")
default:
writeJSON(writer, status, response)
}
}
func writeJSON(writer http.ResponseWriter, status int, value any) {
writer.Header().Set("Content-Type", "application/json; charset=utf-8")
writer.WriteHeader(status)
_ = json.NewEncoder(writer).Encode(value)
}
func writeError(writer http.ResponseWriter, status int, code, message string) {
writeJSON(writer, status, map[string]string{"code": code, "message": message})
}