Files
meta-core/internal/api/reports.go
T
2026-11-04 11:43:01 +00:00

205 lines
6.0 KiB
Go

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