package api import ( "encoding/json" "net/http" "strconv" "strings" "time" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" "git.fabledsword.com/bvandeusen/minstrel/internal/apierror" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" ) // validDiagnosticKinds is the COARSE category whitelist. Mirrors the // CHECK constraint in migration 0036 — keep both in sync (per the // enum-CHECK-whitelist rule). The finer event discriminator + all // event-specific fields live inside each event's payload, so new event // variants do NOT require a new category here. var validDiagnosticKinds = map[string]struct{}{ "connectivity": {}, "upnp_sync": {}, "power": {}, "lifecycle": {}, "heartbeat": {}, "http": {}, } // maxDiagnosticBatch caps a single ingest call. The client buffers and // flushes in batches of ~100; 500 leaves generous headroom while // bounding a misbehaving client's per-request work. const maxDiagnosticBatch = 500 // Diagnostics list paging. Larger than the generic parsePaging caps // because the admin timeline is meant to be exported as a slice and // handed off for analysis — a heartbeat-dense window has many rows. const ( defaultDiagnosticPageSize = 500 maxDiagnosticPageSize = 5000 ) // diagnosticEventIn is one event in a POST /api/diagnostics batch. // OccurredAt is the client device clock as epoch milliseconds (may be // skewed — the server also stamps received_at). Payload is opaque JSON // carrying the event's sub-type + fields. type diagnosticEventIn struct { Kind string `json:"kind"` OccurredAt int64 `json:"occurred_at"` Payload json.RawMessage `json:"payload"` } // reportDiagnosticsRequest is the body of POST /api/diagnostics — one // batch from one device. type reportDiagnosticsRequest struct { ClientID string `json:"client_id"` AppVersion *string `json:"app_version,omitempty"` OsVersion *string `json:"os_version,omitempty"` Events []diagnosticEventIn `json:"events"` } // handleReportDiagnostics implements POST /api/diagnostics. Any signed-in // user can call it, but events are only stored when the account's // debug_mode_enabled flag is on; otherwise the call is a 204 no-op so the // client can safely drop the batch (it gates locally on /api/me, this is // the race-safe backstop for "admin just disabled debug"). func (h *handlers) handleReportDiagnostics(w http.ResponseWriter, r *http.Request) { user, ok := requireUser(w, r) if !ok { return } if !user.DebugModeEnabled { w.WriteHeader(http.StatusNoContent) return } var req reportDiagnosticsRequest if !decodeBody(w, r, &req) { return } if strings.TrimSpace(req.ClientID) == "" { writeErr(w, apierror.BadRequest("bad_request", "client_id required")) return } if len(req.Events) == 0 { writeJSON(w, http.StatusAccepted, map[string]int{"accepted": 0}) return } if len(req.Events) > maxDiagnosticBatch { writeErr(w, apierror.BadRequest("batch_too_large", "too many events in one batch")) return } for i := range req.Events { if _, valid := validDiagnosticKinds[req.Events[i].Kind]; !valid { writeErr(w, apierror.BadRequest("invalid_kind", "unknown diagnostic kind")) return } } err := pgx.BeginFunc(r.Context(), h.pool, func(tx pgx.Tx) error { q := dbq.New(tx) for i := range req.Events { ev := req.Events[i] payload := ev.Payload if len(payload) == 0 { payload = json.RawMessage("{}") } if _, err := q.InsertDiagnosticEvent(r.Context(), dbq.InsertDiagnosticEventParams{ UserID: user.ID, ClientID: req.ClientID, AppVersion: req.AppVersion, OsVersion: req.OsVersion, Kind: ev.Kind, Payload: payload, OccurredAt: pgtype.Timestamptz{Time: time.UnixMilli(ev.OccurredAt).UTC(), Valid: true}, }); err != nil { return err } } return nil }) if err != nil { writeErrWithLog(w, h.logger, "diagnostics: insert batch failed", apierror.Internal(err)) return } writeJSON(w, http.StatusAccepted, map[string]int{"accepted": len(req.Events)}) } // adminDiagnosticView is one row in the admin timeline response. Payload // is passed through verbatim as raw JSON so the SPA can render / export // the structured event without the server re-shaping it. type adminDiagnosticView struct { ID string `json:"id"` UserID string `json:"user_id"` Username string `json:"username"` ClientID string `json:"client_id"` AppVersion *string `json:"app_version,omitempty"` OsVersion *string `json:"os_version,omitempty"` Kind string `json:"kind"` Payload json.RawMessage `json:"payload"` OccurredAt string `json:"occurred_at"` ReceivedAt string `json:"received_at"` } // handleListAdminDiagnostics implements GET /api/admin/diagnostics with // optional filters user_id, client_id, kind, from, to (RFC3339 or epoch // millis) plus limit/offset. Newest-first; the SPA reverses for a // chronological timeline + export. func (h *handlers) handleListAdminDiagnostics(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() params, ok := h.buildDiagnosticsFilter(w, q) if !ok { return } rows, err := dbq.New(h.pool).ListAdminDiagnostics(r.Context(), params) if err != nil { writeErrWithLog(w, h.logger, "admin: list diagnostics failed", apierror.Internal(err)) return } out := make([]adminDiagnosticView, 0, len(rows)) for _, row := range rows { out = append(out, adminDiagnosticView{ ID: uuidToString(row.ID), UserID: uuidToString(row.UserID), Username: row.Username, ClientID: row.ClientID, AppVersion: row.AppVersion, OsVersion: row.OsVersion, Kind: row.Kind, Payload: json.RawMessage(row.Payload), OccurredAt: formatTimestamp(row.OccurredAt), ReceivedAt: formatTimestamp(row.ReceivedAt), }) } writeJSON(w, http.StatusOK, out) } // buildDiagnosticsFilter parses the query string into a query param // struct, writing a 400 and returning ok=false on a malformed time. func (h *handlers) buildDiagnosticsFilter( w http.ResponseWriter, q map[string][]string, ) (dbq.ListAdminDiagnosticsParams, bool) { get := func(k string) string { if v, present := q[k]; present && len(v) > 0 { return v[0] } return "" } limit, offset, err := parseDiagPaging(get("limit"), get("offset")) if err != nil { writeErr(w, apierror.BadRequest("bad_request", "invalid limit or offset")) return dbq.ListAdminDiagnosticsParams{}, false } from, okFrom := parseDiagTime(get("from")) to, okTo := parseDiagTime(get("to")) if !okFrom || !okTo { writeErr(w, apierror.BadRequest("bad_request", "invalid from/to timestamp")) return dbq.ListAdminDiagnosticsParams{}, false } params := dbq.ListAdminDiagnosticsParams{ ClientID: optionalQuery(get("client_id")), Kind: optionalQuery(get("kind")), FromTs: from, ToTs: to, Off: int32(offset), Lim: int32(limit), } if uid, valid := parseUUID(get("user_id")); valid { params.UserID = uid } return params, true } // adminDiagnosticDeviceView is one device in the "who is reporting" // overview / device filter dropdown. type adminDiagnosticDeviceView struct { ClientID string `json:"client_id"` UserID string `json:"user_id"` Username string `json:"username"` AppVersion string `json:"app_version"` OsVersion string `json:"os_version"` LastSeen string `json:"last_seen"` EventCount int64 `json:"event_count"` } // handleListAdminDiagnosticDevices implements GET // /api/admin/diagnostics/devices?user_id=. Lists distinct reporting // devices so the admin UI can populate its device filter. func (h *handlers) handleListAdminDiagnosticDevices(w http.ResponseWriter, r *http.Request) { var userID pgtype.UUID if uid, valid := parseUUID(r.URL.Query().Get("user_id")); valid { userID = uid } rows, err := dbq.New(h.pool).ListDiagnosticDevices(r.Context(), userID) if err != nil { writeErrWithLog(w, h.logger, "admin: list diagnostic devices failed", apierror.Internal(err)) return } out := make([]adminDiagnosticDeviceView, 0, len(rows)) for _, row := range rows { out = append(out, adminDiagnosticDeviceView{ ClientID: row.ClientID, UserID: uuidToString(row.UserID), Username: row.Username, AppVersion: row.AppVersion, OsVersion: row.OsVersion, LastSeen: formatTimestamp(row.LastSeen), EventCount: row.EventCount, }) } writeJSON(w, http.StatusOK, out) } // adminDebugModeReq is the body of PUT /api/admin/users/{id}/debug-mode. type adminDebugModeReq struct { Enabled bool `json:"enabled"` } // handleAdminDebugModeToggle implements PUT // /api/admin/users/{id}/debug-mode — flip an account's diagnostics // opt-in remotely while a bug is live. Echoes the updated user. func (h *handlers) handleAdminDebugModeToggle(w http.ResponseWriter, r *http.Request) { targetID, ok := requireURLUUID(w, r, "id") if !ok { return } var req adminDebugModeReq if !decodeBody(w, r, &req) { return } updated, err := dbq.New(h.pool).SetDebugMode(r.Context(), dbq.SetDebugModeParams{ ID: targetID, DebugModeEnabled: req.Enabled, }) if err != nil { writeErrWithLog(w, h.logger, "admin: set debug mode failed", apierror.Internal(err)) return } h.logger.Info("admin: debug mode toggled", "target_user", uuidToString(targetID), "enabled", req.Enabled) writeJSON(w, http.StatusOK, adminUserView{ ID: uuidToString(updated.ID), Username: updated.Username, DisplayName: updated.DisplayName, IsAdmin: updated.IsAdmin, AutoApproveRequests: updated.AutoApproveRequests, DebugModeEnabled: updated.DebugModeEnabled, CreatedAt: updated.CreatedAt.Time.UTC().Format(time.RFC3339), }) } // parseDiagPaging reads limit/offset with diagnostics-specific caps. // Blank → defaults; out-of-range clamps; non-numeric → error. func parseDiagPaging(rawLimit, rawOffset string) (limit, offset int, err error) { limit = defaultDiagnosticPageSize if s := strings.TrimSpace(rawLimit); s != "" { n, perr := strconv.Atoi(s) if perr != nil { return 0, 0, perr } if n < 1 { n = 1 } if n > maxDiagnosticPageSize { n = maxDiagnosticPageSize } limit = n } if s := strings.TrimSpace(rawOffset); s != "" { n, perr := strconv.Atoi(s) if perr != nil { return 0, 0, perr } if n > 0 { offset = n } } return limit, offset, nil } // optionalQuery returns nil for an absent/blank query value so the sqlc // NULL-or-equals filter treats it as "no filter." func optionalQuery(raw string) *string { raw = strings.TrimSpace(raw) if raw == "" { return nil } return &raw } // parseDiagTime accepts RFC3339 or epoch-millis. Empty = absent (NULL // filter, ok=true). Returns ok=false only on a non-empty unparseable value. func parseDiagTime(raw string) (pgtype.Timestamptz, bool) { raw = strings.TrimSpace(raw) if raw == "" { return pgtype.Timestamptz{}, true } if t, err := time.Parse(time.RFC3339, raw); err == nil { return pgtype.Timestamptz{Time: t.UTC(), Valid: true}, true } if ms, err := strconv.ParseInt(raw, 10, 64); err == nil { return pgtype.Timestamptz{Time: time.UnixMilli(ms).UTC(), Valid: true}, true } return pgtype.Timestamptz{}, false }