207 lines
7.0 KiB
Go
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})
|
||
|
|
}
|