package api // Report inbox (/api/reports): bug reports and feedback filed from the client // apps. An app's browser builds the report, the app's server forwards it here // server-to-server, and the dashboard's Reports page reads it back. Storage // and bounds live in internal/reports. // // Auth: like the rest of /api, gated at the perimeter — do NOT add to the // nginx-hash-lock unauth bypass. Apps reach meta-core on the internal network. import ( "encoding/json" "errors" "log" "net/http" "strconv" "strings" "time" "github.com/gorilla/mux" "github.com/metazla/meta-core/internal/reports" ) // maxReportBody bounds one POST. A report is at most a few blocks of 32 KiB // plus a 200-line log tail; anything larger is not a report. const maxReportBody = 512 << 10 // SetReports attaches the report inbox. Separate from NewServer so its // signature (and its tests) stay untouched; nil leaves the routes answering // 503. func (s *Server) SetReports(r *reports.Store) { s.reports = r } func (s *Server) setupReportRoutes() { s.router.HandleFunc("/api/reports", s.handlePostReport).Methods("POST") s.router.HandleFunc("/api/reports", s.handleListReports).Methods("GET") s.router.HandleFunc("/api/reports/{id:[0-9]+}", s.handleGetReport).Methods("GET") s.router.HandleFunc("/api/reports/{id:[0-9]+}", s.handlePatchReport).Methods("PATCH") s.router.HandleFunc("/api/reports/{id:[0-9]+}", s.handleDeleteReport).Methods("DELETE") } func (s *Server) reportsReady(w http.ResponseWriter) bool { if s.reports == nil { writeError(w, http.StatusServiceUnavailable, "report inbox not available") return false } return true } func reportID(r *http.Request) int64 { id, _ := strconv.ParseInt(mux.Vars(r)["id"], 10, 64) return id } // handlePostReport handles POST /api/reports → 201 {id}. func (s *Server) handlePostReport(w http.ResponseWriter, r *http.Request) { if !s.reportsReady(w) { return } var in map[string]json.RawMessage if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxReportBody)).Decode(&in); err != nil { var tooBig *http.MaxBytesError if errors.As(err, &tooBig) { writeError(w, http.StatusRequestEntityTooLarge, "report larger than 512 KiB") return } writeError(w, http.StatusBadRequest, "body must be a JSON object") return } rep, err := reports.Normalize(in, time.Now()) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } id, err := s.reports.Insert(rep) if err != nil { log.Printf("[reports] insert failed: %v", err) writeError(w, http.StatusInternalServerError, "could not store the report") return } log.Printf("[reports] #%d filed by %s (%s)", id, rep.App, firstLine(rep.Note)) writeJSON(w, http.StatusCreated, map[string]int64{"id": id}) } // handleListReports handles GET /api/reports?app=&status=&q=&before=&limit=. func (s *Server) handleListReports(w http.ResponseWriter, r *http.Request) { if !s.reportsReady(w) { return } q := r.URL.Query() f := reports.Filter{App: q.Get("app"), Status: q.Get("status"), Q: q.Get("q")} f.Before, _ = strconv.ParseInt(q.Get("before"), 10, 64) f.Limit, _ = strconv.Atoi(q.Get("limit")) if f.Status != "" && !reports.ValidStatus(f.Status) { writeError(w, http.StatusBadRequest, "status must be open, triaged or closed") return } page, err := s.reports.List(f) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, page) } // handleGetReport handles GET /api/reports/{id}. func (s *Server) handleGetReport(w http.ResponseWriter, r *http.Request) { if !s.reportsReady(w) { return } rep, err := s.reports.Get(reportID(r)) if errors.Is(err, reports.ErrNotFound) { writeError(w, http.StatusNotFound, "no such report") return } if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, rep) } // handlePatchReport handles PATCH /api/reports/{id} {status?, triage_note?}. func (s *Server) handlePatchReport(w http.ResponseWriter, r *http.Request) { if !s.reportsReady(w) { return } var in struct { Status *string `json:"status"` TriageNote *string `json:"triage_note"` } if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&in); err != nil { writeError(w, http.StatusBadRequest, "body must be a JSON object") return } if in.Status == nil && in.TriageNote == nil { writeError(w, http.StatusBadRequest, "nothing to change: send status and/or triage_note") return } if in.Status != nil && !reports.ValidStatus(*in.Status) { writeError(w, http.StatusBadRequest, "status must be open, triaged or closed") return } rep, err := s.reports.Update(reportID(r), in.Status, in.TriageNote) if errors.Is(err, reports.ErrNotFound) { writeError(w, http.StatusNotFound, "no such report") return } if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, rep) } // handleDeleteReport handles DELETE /api/reports/{id}. func (s *Server) handleDeleteReport(w http.ResponseWriter, r *http.Request) { if !s.reportsReady(w) { return } err := s.reports.Delete(reportID(r)) if errors.Is(err, reports.ErrNotFound) { writeError(w, http.StatusNotFound, "no such report") return } if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } w.WriteHeader(http.StatusNoContent) } // pruneReports re-applies the inbox bounds hourly. Insert already prunes, so // this only matters when a bound was lowered between restarts. func (s *Server) pruneReports() { run := func() { if n, err := s.reports.Prune(); err != nil { log.Printf("[reports] prune failed: %v", err) } else if n > 0 { log.Printf("[reports] pruned %d report(s) over the inbox bounds", n) } } run() ticker := time.NewTicker(time.Hour) defer ticker.Stop() for range ticker.C { run() } } func firstLine(s string) string { if i := strings.IndexByte(s, '\n'); i >= 0 { s = s[:i] } if r := []rune(s); len(r) > 80 { s = string(r[:80]) + "…" } if s == "" { return "no note" } return strconv.Quote(s) }