// 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}) }