package httpapi import ( "bufio" "crypto/rand" "encoding/hex" "encoding/json" "fmt" "io" "net/http" "os" "path/filepath" "strconv" "strings" "time" "safesight-agent/internal/files" ) func (s *Server) alarmsPath() string { return filepath.Join(s.baseDir, "logs", "alarms.jsonl") } // handleAlarmReport receives alarm notifications from media-server. // POST /v1/alarms/report func (s *Server) handleAlarmReport(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { errorJSON(w, http.StatusMethodNotAllowed, "method not allowed") return } // Allow localhost requests without token (media-server alarm callback) if !isLocalhost(r) && !s.authorize(r, true) { errorJSON(w, http.StatusUnauthorized, "unauthorized") return } maxBytes := int64(1 << 20) // 1MB max r.Body = http.MaxBytesReader(w, r.Body, maxBytes) var alarm map[string]any if err := json.NewDecoder(r.Body).Decode(&alarm); err != nil { errorJSON(w, http.StatusBadRequest, "invalid json: "+err.Error()) return } // Normalize: extract channel and rule_name from whatever format media-server sends id, _ := alarm["id"].(string) if id == "" { id = fmt.Sprintf("alarm_%s_%s", time.Now().Format("20060102_150405"), randomHex(6)) } if _, ok := alarm["timestamp"]; !ok { alarm["timestamp"] = time.Now().Format(time.RFC3339) } alarm["id"] = id alarm["received_at"] = time.Now().Format(time.RFC3339) path := s.alarmsPath() dir := filepath.Dir(path) if err := files.EnsureDir(dir, 0o755); err != nil { errorJSON(w, http.StatusInternalServerError, "internal error: "+err.Error()) return } line, err := json.Marshal(alarm) if err != nil { errorJSON(w, http.StatusInternalServerError, "internal error: "+err.Error()) return } f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) if err != nil { errorJSON(w, http.StatusInternalServerError, "internal error: "+err.Error()) return } defer f.Close() if _, err := fmt.Fprintf(f, "%s\n", string(line)); err != nil { errorJSON(w, http.StatusInternalServerError, "internal error: "+err.Error()) return } s.recordAudit(r, "alarm.report", true, id) writeJSON(w, http.StatusOK, map[string]any{"ok": true, "id": id}) } // handleAlarmsRecent returns recent alarm records. // GET /v1/alarms/recent?limit=50&since=2026-01-01T00:00:00Z func (s *Server) handleAlarmsRecent(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { errorJSON(w, http.StatusMethodNotAllowed, "method not allowed") return } if !s.authorize(r, false) { errorJSON(w, http.StatusUnauthorized, "unauthorized") return } limit := 50 if v := strings.TrimSpace(r.URL.Query().Get("limit")); v != "" { if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 500 { limit = n } } alarms, err := s.readRecentAlarms(limit) if err != nil { errorJSON(w, http.StatusInternalServerError, "internal error: "+err.Error()) return } if alarms == nil { alarms = make([]map[string]any, 0) } writeJSON(w, http.StatusOK, map[string]any{"alarms": alarms}) } func (s *Server) readRecentAlarms(limit int) ([]map[string]any, error) { path := s.alarmsPath() f, err := os.Open(path) if err != nil { if os.IsNotExist(err) { return nil, nil } return nil, err } defer f.Close() // Read all lines and keep last N var lines []string scanner := bufio.NewScanner(f) scanner.Buffer(make([]byte, 1<<20), 1<<20) // 1MB max line for scanner.Scan() { line := scanner.Text() if strings.TrimSpace(line) == "" { continue } lines = append(lines, line) // Keep only last 2*limit lines in memory if len(lines) > 2*limit { lines = lines[limit:] } } if err := scanner.Err(); err != nil && err != io.EOF { return nil, err } // Take last `limit` lines start := 0 if len(lines) > limit { start = len(lines) - limit } alarms := make([]map[string]any, 0, limit) for i := start; i < len(lines); i++ { var alarm map[string]any if err := json.Unmarshal([]byte(lines[i]), &alarm); err != nil { continue } alarms = append(alarms, alarm) } return alarms, nil } func randomHex(n int) string { b := make([]byte, n) rand.Read(b) return hex.EncodeToString(b)[:n] } func isLocalhost(r *http.Request) bool { ip := remoteIP(r) return ip == "127.0.0.1" || ip == "::1" || ip == "localhost" }