main.go 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717
  1. // Command admind is the admin HTTP API + (later) UI host. Tenant CRUD,
  2. // DLQ inspection, replay, audit log.
  3. //
  4. // M0: /health, /metrics, /v1/ping.
  5. // M8: + DLQ surface —
  6. // GET /v1/dlq — list/filter DLQ rows
  7. // GET /v1/dlq/{id} — single row (with payload)
  8. // POST /v1/dlq/{id}/replay — re-INSERT into the deliverd NATS subject
  9. // POST /v1/dlq/{id}/discard — mark discarded (hidden from default list)
  10. // GET /dlq — minimal HTML UI
  11. //
  12. // Auth: M8 ships without auth (LAN-only). The M11 work
  13. // gates /v1/dlq* behind an operator JWT.
  14. package main
  15. import (
  16. "context"
  17. "embed"
  18. "encoding/json"
  19. "errors"
  20. "fmt"
  21. "html"
  22. "html/template"
  23. "io/fs"
  24. "log/slog"
  25. "net/http"
  26. "os"
  27. "os/signal"
  28. "strconv"
  29. "strings"
  30. "syscall"
  31. "time"
  32. "git3.techno-world.net/lrosales/broad-announce/internal/authd"
  33. "git3.techno-world.net/lrosales/broad-announce/internal/broker"
  34. "git3.techno-world.net/lrosales/broad-announce/internal/config"
  35. "git3.techno-world.net/lrosales/broad-announce/internal/httpserver"
  36. "git3.techno-world.net/lrosales/broad-announce/internal/observability"
  37. "git3.techno-world.net/lrosales/broad-announce/internal/postgres"
  38. "github.com/jackc/pgx/v5"
  39. "github.com/nats-io/nats.go"
  40. )
  41. //go:embed ui/*
  42. var uiFS embed.FS
  43. // webFS embeds the React SPA built by web/ (M13b W0+). The
  44. // directory must exist at build time; `make web-build` produces
  45. // web/dist/ and the Go embed picks it up automatically. When
  46. // web/dist/ is missing (dev build before `make web-build`),
  47. // the embed is empty and webHandlers() returns a 503 stub so
  48. // operators still see a useful message instead of a 404.
  49. //
  50. //go:embed web-dist
  51. var webFS embed.FS
  52. // dlqRow is the wire shape returned by /v1/dlq and
  53. // rendered by the HTML UI. The payload field is omitted
  54. // from the list endpoint (operators can fetch the full
  55. // row via /v1/dlq/{id}).
  56. type dlqRow struct {
  57. ID int64 `json:"id"`
  58. AlertID string `json:"alert_id"`
  59. CompanyID string `json:"company_id"`
  60. IndividualID string `json:"individual_id"`
  61. Channel string `json:"channel"`
  62. Target string `json:"target"`
  63. OriginalSubject string `json:"original_subject"`
  64. Attempts int `json:"attempts"`
  65. LastError string `json:"last_error"`
  66. Discarded bool `json:"discarded"`
  67. DiscardedAt *time.Time `json:"discarded_at,omitempty"`
  68. DiscardedBy string `json:"discarded_by,omitempty"`
  69. CreatedAt time.Time `json:"created_at"`
  70. Payload json.RawMessage `json:"payload,omitempty"`
  71. }
  72. func main() {
  73. cfg, err := config.LoadCommon("admind")
  74. if err != nil {
  75. os.Stderr.WriteString("config: " + err.Error() + "\n")
  76. os.Exit(1)
  77. }
  78. logger := observability.Init(cfg.Env, cfg.LogLevel, "admind")
  79. logger.Info("starting", "env", cfg.Env, "addr", cfg.HTTPAddr)
  80. ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
  81. defer stop()
  82. br, err := broker.Connect(ctx, cfg.NATSURL)
  83. if err != nil {
  84. logger.Error("nats connect", "err", err)
  85. os.Exit(1)
  86. }
  87. defer br.Close()
  88. pool, err := postgres.Connect(ctx, cfg.PostgresDSN)
  89. if err != nil {
  90. logger.Error("postgres connect", "err", err)
  91. os.Exit(1)
  92. }
  93. defer pool.Close()
  94. reg, _ := observability.NewRegistry("admind")
  95. srv := httpserver.New(httpserver.Config{
  96. Addr: cfg.HTTPAddr,
  97. ServiceName: "admind",
  98. ShutdownGrace: cfg.ShutdownGrace,
  99. }, logger, observability.MetricsHandler(reg))
  100. mux := srv.Mux()
  101. mux.HandleFunc("GET /v1/ping", handlePing)
  102. mux.HandleFunc("GET /dlq", handleDLQUI(pool, logger))
  103. // M13b W0: serve the React SPA from /, with SPA fallback to
  104. // /index.html for client-side routes. /dlq keeps the M8 HTML
  105. // UI (it's a server-rendered template, not part of the SPA).
  106. // The /v1/* routes take precedence (Go ServeMux matches the
  107. // longest prefix).
  108. wireSPA(mux, logger)
  109. // M13a W3: JWT-gate the /v1/dlq* routes when BA_AUTHD_JWT_SECRET
  110. // is set. When unset, the routes stay unauthenticated (the
  111. // pre-M13 behavior) so the LAN-only deploy path keeps working.
  112. wireDLQRoutes(mux, br, pool, logger)
  113. errCh := make(chan error, 1)
  114. go func() { errCh <- srv.Start() }()
  115. select {
  116. case <-ctx.Done():
  117. logger.Info("shutdown signal received")
  118. case err := <-errCh:
  119. if err != nil {
  120. logger.Error("http server", "err", err)
  121. os.Exit(1)
  122. }
  123. }
  124. if err := srv.Shutdown(ctx); err != nil {
  125. logger.Warn("graceful shutdown", "err", err)
  126. }
  127. logger.Info("bye")
  128. }
  129. func handlePing(w http.ResponseWriter, r *http.Request) {
  130. w.Header().Set("Content-Type", "application/json")
  131. _ = json.NewEncoder(w).Encode(map[string]any{
  132. "pong": true,
  133. "service": "admind",
  134. "timestamp": time.Now().UTC().Format(time.RFC3339Nano),
  135. })
  136. }
  137. // listFilters is the parsed query string for /v1/dlq.
  138. type listFilters struct {
  139. CompanyID string
  140. Channel string
  141. AlertID string
  142. Include string // "all" to show discarded; default hides them
  143. Limit int
  144. Offset int
  145. }
  146. func parseListFilters(r *http.Request) listFilters {
  147. q := r.URL.Query()
  148. limit, _ := strconv.Atoi(q.Get("limit"))
  149. if limit <= 0 || limit > 500 {
  150. limit = 100
  151. }
  152. offset, _ := strconv.Atoi(q.Get("offset"))
  153. if offset < 0 {
  154. offset = 0
  155. }
  156. return listFilters{
  157. CompanyID: strings.TrimSpace(q.Get("company_id")),
  158. Channel: strings.TrimSpace(q.Get("channel")),
  159. AlertID: strings.TrimSpace(q.Get("alert_id")),
  160. Include: strings.TrimSpace(q.Get("include")),
  161. Limit: limit,
  162. Offset: offset,
  163. }
  164. }
  165. // handleListDLQ returns a paginated list of DLQ rows.
  166. // The list excludes discarded rows by default; pass
  167. // include=all to see them.
  168. func handleListDLQ(pool *postgres.Pool, logger *slog.Logger) http.HandlerFunc {
  169. return func(w http.ResponseWriter, r *http.Request) {
  170. f := parseListFilters(r)
  171. rows, err := queryDLQ(r.Context(), pool, dlqQuery{
  172. CompanyID: f.CompanyID,
  173. Channel: f.Channel,
  174. AlertID: f.AlertID,
  175. IncludeDiscarded: f.Include == "all",
  176. Limit: f.Limit,
  177. Offset: f.Offset,
  178. })
  179. if err != nil {
  180. logger.Error("dlq list", "err", err)
  181. http.Error(w, "dlq list: "+err.Error(), http.StatusInternalServerError)
  182. return
  183. }
  184. w.Header().Set("Content-Type", "application/json")
  185. _ = json.NewEncoder(w).Encode(map[string]any{
  186. "rows": rows,
  187. "limit": f.Limit,
  188. "offset": f.Offset,
  189. })
  190. }
  191. }
  192. // handleGetDLQ returns one row by id, with the payload
  193. // included. Used by the UI's "view payload" expand.
  194. func handleGetDLQ(pool *postgres.Pool, logger *slog.Logger) http.HandlerFunc {
  195. return func(w http.ResponseWriter, r *http.Request) {
  196. id, ok := parseID(r)
  197. if !ok {
  198. http.Error(w, "bad id", http.StatusBadRequest)
  199. return
  200. }
  201. row, err := getDLQ(r.Context(), pool, id)
  202. if err != nil {
  203. logger.Error("dlq get", "err", err, "id", id)
  204. http.Error(w, "dlq get: "+err.Error(), http.StatusInternalServerError)
  205. return
  206. }
  207. if row == nil {
  208. http.Error(w, "not found", http.StatusNotFound)
  209. return
  210. }
  211. w.Header().Set("Content-Type", "application/json")
  212. _ = json.NewEncoder(w).Encode(row)
  213. }
  214. }
  215. // handleReplayDLQ re-publishes the original NATS envelope
  216. // (stored in deliveries_dlq.payload) onto the original
  217. // subject, then marks the DLQ row discarded=true (with
  218. // discarded_by='replay') and writes a fresh audit row in
  219. // the live deliveries table.
  220. //
  221. // Idempotency: a single replay succeeds or fails. If it
  222. // fails, the DLQ row is NOT marked discarded so the
  223. // operator can retry. There is no race because the
  224. // discarded=true UPDATE happens in the same handler call
  225. // as the Publish; a second concurrent replay would
  226. // publish twice (the second one will hit dedupe at the
  227. // ingestd layer if the alert_id has been seen recently,
  228. // but for the DLQ replay path we don't dedupe — the
  229. // operator explicitly asked for a re-send).
  230. func handleReplayDLQ(br *broker.Client, pool *postgres.Pool, logger *slog.Logger) http.HandlerFunc {
  231. return func(w http.ResponseWriter, r *http.Request) {
  232. id, ok := parseID(r)
  233. if !ok {
  234. http.Error(w, "bad id", http.StatusBadRequest)
  235. return
  236. }
  237. row, err := getDLQ(r.Context(), pool, id)
  238. if err != nil {
  239. logger.Error("dlq replay get", "err", err, "id", id)
  240. http.Error(w, "dlq replay: "+err.Error(), http.StatusInternalServerError)
  241. return
  242. }
  243. if row == nil {
  244. http.Error(w, "not found", http.StatusNotFound)
  245. return
  246. }
  247. if row.Discarded {
  248. http.Error(w, "already discarded", http.StatusConflict)
  249. return
  250. }
  251. // Re-publish the original payload (json.RawMessage
  252. // holds the NATS envelope bytes verbatim).
  253. nc := br.NC()
  254. if err := nc.Publish(row.OriginalSubject, row.Payload); err != nil {
  255. logger.Error("dlq replay publish", "err", err, "id", id, "subject", row.OriginalSubject)
  256. http.Error(w, "publish: "+err.Error(), http.StatusBadGateway)
  257. return
  258. }
  259. // Flush so the message is on the wire before we
  260. // mark the row discarded. If the worker can't
  261. // deliver it, it will go back into the DLQ with
  262. // a fresh row, and the operator will see both.
  263. if err := nc.Flush(); err != nil {
  264. logger.Warn("dlq replay flush", "err", err, "id", id)
  265. }
  266. // Mark the DLQ row discarded. We do this AFTER
  267. // the publish so a publish failure leaves the
  268. // DLQ row in place for retry.
  269. now := time.Now().UTC()
  270. _, dbErr := pool.Exec(r.Context(), `
  271. UPDATE deliveries_dlq
  272. SET discarded = true,
  273. discarded_at = $1,
  274. discarded_by = $2
  275. WHERE id = $3 AND created_at IN (
  276. SELECT created_at FROM deliveries_dlq WHERE id = $3 LIMIT 1
  277. )
  278. `, now, "replay", id)
  279. if dbErr != nil {
  280. // Publish succeeded but the DB update
  281. // didn't. The replay still happened —
  282. // log loud, return 200, and let the
  283. // operator handle the duplicate UI row
  284. // if they re-replay.
  285. logger.Error("dlq replay: publish ok but update failed",
  286. "err", dbErr, "id", id)
  287. }
  288. logger.Info("dlq replay ok", "id", id, "subject", row.OriginalSubject, "alert_id", row.AlertID)
  289. w.Header().Set("Content-Type", "application/json")
  290. _ = json.NewEncoder(w).Encode(map[string]any{
  291. "replayed": true,
  292. "id": id,
  293. "subject": row.OriginalSubject,
  294. "alert_id": row.AlertID,
  295. "company_id": row.CompanyID,
  296. "original_attempts": row.Attempts,
  297. })
  298. }
  299. }
  300. // handleDiscardDLQ marks a DLQ row as discarded. After
  301. // discard, the row is hidden from /v1/dlq by default and
  302. // from the HTML UI.
  303. func handleDiscardDLQ(pool *postgres.Pool, logger *slog.Logger) http.HandlerFunc {
  304. return func(w http.ResponseWriter, r *http.Request) {
  305. id, ok := parseID(r)
  306. if !ok {
  307. http.Error(w, "bad id", http.StatusBadRequest)
  308. return
  309. }
  310. now := time.Now().UTC()
  311. // The (id, created_at) PK means the UPDATE
  312. // has to match the row's created_at too. We
  313. // fetch first, then UPDATE with both keys.
  314. row, err := getDLQ(r.Context(), pool, id)
  315. if err != nil {
  316. logger.Error("dlq discard get", "err", err, "id", id)
  317. http.Error(w, "dlq discard: "+err.Error(), http.StatusInternalServerError)
  318. return
  319. }
  320. if row == nil {
  321. http.Error(w, "not found", http.StatusNotFound)
  322. return
  323. }
  324. if row.Discarded {
  325. // Idempotent: a second discard is a no-op.
  326. w.Header().Set("Content-Type", "application/json")
  327. _ = json.NewEncoder(w).Encode(map[string]any{"discarded": true, "id": id, "already": true})
  328. return
  329. }
  330. _, dbErr := pool.Exec(r.Context(), `
  331. UPDATE deliveries_dlq
  332. SET discarded = true,
  333. discarded_at = $1,
  334. discarded_by = $2
  335. WHERE id = $3 AND created_at = $4
  336. `, now, "operator", id, row.CreatedAt)
  337. if dbErr != nil {
  338. logger.Error("dlq discard", "err", dbErr, "id", id)
  339. http.Error(w, "discard: "+dbErr.Error(), http.StatusInternalServerError)
  340. return
  341. }
  342. logger.Info("dlq discard ok", "id", id, "alert_id", row.AlertID)
  343. w.Header().Set("Content-Type", "application/json")
  344. _ = json.NewEncoder(w).Encode(map[string]any{
  345. "discarded": true,
  346. "id": id,
  347. "discarded_at": now,
  348. })
  349. }
  350. }
  351. // dlqQuery is the structured query for the list endpoint.
  352. type dlqQuery struct {
  353. CompanyID string
  354. Channel string
  355. AlertID string
  356. IncludeDiscarded bool
  357. Limit int
  358. Offset int
  359. }
  360. // queryDLQ returns the rows that match the filters.
  361. // Excludes the payload from the list view (operators
  362. // fetch /v1/dlq/{id} for the full row).
  363. func queryDLQ(ctx context.Context, pool *postgres.Pool, q dlqQuery) ([]dlqRow, error) {
  364. // Build the WHERE clause dynamically. We always
  365. // filter on created_at > now - 30d for the list
  366. // view (operators rarely need ancient rows; the
  367. // archiver ships them to CH).
  368. conds := []string{"created_at > now() - INTERVAL '30 days'"}
  369. args := []any{}
  370. if q.CompanyID != "" {
  371. args = append(args, q.CompanyID)
  372. conds = append(conds, fmt.Sprintf("company_id = $%d", len(args)))
  373. }
  374. if q.Channel != "" {
  375. args = append(args, q.Channel)
  376. conds = append(conds, fmt.Sprintf("channel = $%d", len(args)))
  377. }
  378. if q.AlertID != "" {
  379. args = append(args, q.AlertID)
  380. conds = append(conds, fmt.Sprintf("alert_id = $%d", len(args)))
  381. }
  382. if !q.IncludeDiscarded {
  383. conds = append(conds, "discarded = false")
  384. }
  385. where := strings.Join(conds, " AND ")
  386. args = append(args, q.Limit, q.Offset)
  387. q1 := fmt.Sprintf(`
  388. SELECT id, alert_id, company_id, individual_id, channel, target,
  389. original_subject, attempts, last_error, discarded,
  390. discarded_at, discarded_by, created_at
  391. FROM deliveries_dlq
  392. WHERE %s
  393. ORDER BY created_at DESC
  394. LIMIT $%d OFFSET $%d
  395. `, where, len(args)-1, len(args))
  396. rows, err := pool.Query(ctx, q1, args...)
  397. if err != nil {
  398. return nil, err
  399. }
  400. defer rows.Close()
  401. var out []dlqRow
  402. for rows.Next() {
  403. var r dlqRow
  404. var discardedAt *time.Time
  405. var discardedBy *string
  406. if err := rows.Scan(
  407. &r.ID, &r.AlertID, &r.CompanyID, &r.IndividualID, &r.Channel, &r.Target,
  408. &r.OriginalSubject, &r.Attempts, &r.LastError, &r.Discarded,
  409. &discardedAt, &discardedBy, &r.CreatedAt,
  410. ); err != nil {
  411. return nil, err
  412. }
  413. r.DiscardedAt = discardedAt
  414. if discardedBy != nil {
  415. r.DiscardedBy = *discardedBy
  416. }
  417. out = append(out, r)
  418. }
  419. return out, rows.Err()
  420. }
  421. // getDLQ returns one row by id (with payload). Returns
  422. // nil, nil if the row doesn't exist.
  423. func getDLQ(ctx context.Context, pool *postgres.Pool, id int64) (*dlqRow, error) {
  424. row := pool.QueryRow(ctx, `
  425. SELECT id, alert_id, company_id, individual_id, channel, target,
  426. original_subject, attempts, last_error, payload, discarded,
  427. discarded_at, discarded_by, created_at
  428. FROM deliveries_dlq
  429. WHERE id = $1
  430. LIMIT 1
  431. `, id)
  432. var r dlqRow
  433. var payload []byte
  434. var discardedAt *time.Time
  435. var discardedBy *string
  436. if err := row.Scan(
  437. &r.ID, &r.AlertID, &r.CompanyID, &r.IndividualID, &r.Channel, &r.Target,
  438. &r.OriginalSubject, &r.Attempts, &r.LastError, &payload, &r.Discarded,
  439. &discardedAt, &discardedBy, &r.CreatedAt,
  440. ); err != nil {
  441. if errors.Is(err, pgx.ErrNoRows) {
  442. return nil, nil
  443. }
  444. return nil, err
  445. }
  446. r.Payload = json.RawMessage(payload)
  447. r.DiscardedAt = discardedAt
  448. if discardedBy != nil {
  449. r.DiscardedBy = *discardedBy
  450. }
  451. return &r, nil
  452. }
  453. func parseID(r *http.Request) (int64, bool) {
  454. raw := r.PathValue("id")
  455. id, err := strconv.ParseInt(raw, 10, 64)
  456. if err != nil || id <= 0 {
  457. return 0, false
  458. }
  459. return id, true
  460. }
  461. // ── HTML UI ──────────────────────────────────────────────────────
  462. // uiData is the template data for the /dlq HTML page.
  463. type uiData struct {
  464. Rows []dlqRow
  465. Filters listFilters
  466. ShowAll bool
  467. HasFilters bool
  468. }
  469. // handleDLQUI serves a minimal HTML page with the DLQ
  470. // list and inline replay/discard buttons. POSTs go to
  471. // the same /v1/dlq/{id}/{replay,discard} endpoints; the
  472. // JS does a fetch and reloads the page.
  473. func handleDLQUI(pool *postgres.Pool, logger *slog.Logger) http.HandlerFunc {
  474. tpl := template.Must(template.New("dlq").Funcs(template.FuncMap{
  475. "safeHTML": func(s string) template.HTML { return template.HTML(html.EscapeString(s)) },
  476. "shortErr": func(s string) string {
  477. if len(s) > 80 {
  478. return s[:80] + "…"
  479. }
  480. return s
  481. },
  482. "ago": func(t time.Time) string {
  483. d := time.Since(t)
  484. switch {
  485. case d < time.Minute:
  486. return fmt.Sprintf("%ds ago", int(d.Seconds()))
  487. case d < time.Hour:
  488. return fmt.Sprintf("%dm ago", int(d.Minutes()))
  489. case d < 24*time.Hour:
  490. return fmt.Sprintf("%dh ago", int(d.Hours()))
  491. default:
  492. return fmt.Sprintf("%dd ago", int(d.Hours()/24))
  493. }
  494. },
  495. }).ParseFS(uiFS, "ui/*.html"))
  496. return func(w http.ResponseWriter, r *http.Request) {
  497. f := parseListFilters(r)
  498. filters := dlqQuery{
  499. CompanyID: f.CompanyID,
  500. Channel: f.Channel,
  501. AlertID: f.AlertID,
  502. IncludeDiscarded: f.Include == "all",
  503. Limit: f.Limit,
  504. Offset: f.Offset,
  505. }
  506. rows, err := queryDLQ(r.Context(), pool, filters)
  507. if err != nil {
  508. logger.Error("dlq ui list", "err", err)
  509. http.Error(w, "dlq ui: "+err.Error(), http.StatusInternalServerError)
  510. return
  511. }
  512. data := uiData{
  513. Rows: rows,
  514. Filters: f,
  515. ShowAll: f.Include == "all",
  516. HasFilters: f.CompanyID != "" || f.Channel != "" || f.AlertID != "",
  517. }
  518. w.Header().Set("Content-Type", "text/html; charset=utf-8")
  519. if err := tpl.ExecuteTemplate(w, "dlq.html", data); err != nil {
  520. logger.Error("dlq ui render", "err", err)
  521. }
  522. }
  523. }
  524. // broker re-export so the handler signatures stay clean.
  525. // We import broker just for the Connect + Conn() pair.
  526. var _ = nats.Conn{}
  527. // wireSPA serves the React SPA at / and SPA-fallback to /index.html
  528. // for client-side routes (/companies, /sources, /login, etc.).
  529. //
  530. // If web/dist/ is empty (dev build before `make web-build`), the
  531. // handler returns a small 503 page that explains how to build the
  532. // SPA. This is intentional: the embed.FS at compile time is fixed,
  533. // and we'd rather show a useful error than 404 every route.
  534. func wireSPA(mux *http.ServeMux, logger *slog.Logger) {
  535. sub, err := fs.Sub(webFS, "web-dist")
  536. if err != nil {
  537. // web/dist missing entirely. The Go embed would have
  538. // errored at build time if a non-existent prefix was used
  539. // with go:embed; if we get here, web/dist exists but
  540. // contains nothing.
  541. logger.Warn("SPA not built: web/dist is empty — run `make web-build`")
  542. mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
  543. w.Header().Set("Content-Type", "text/html; charset=utf-8")
  544. w.WriteHeader(http.StatusServiceUnavailable)
  545. _, _ = w.Write([]byte(spaNotBuilt))
  546. })
  547. return
  548. }
  549. indexBytes, err := fs.ReadFile(sub, "index.html")
  550. if err != nil {
  551. // web/dist exists but doesn't have index.html — wrong
  552. // build output (maybe a partial vite build).
  553. logger.Warn("SPA build looks incomplete: web/dist has no index.html — re-run `make web-build`")
  554. mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
  555. w.Header().Set("Content-Type", "text/html; charset=utf-8")
  556. w.WriteHeader(http.StatusServiceUnavailable)
  557. _, _ = w.Write([]byte(spaNotBuilt))
  558. })
  559. return
  560. }
  561. logger.Info("SPA mounted", "index_bytes", len(indexBytes))
  562. // Static files (JS, CSS, fonts, icons) — served from /assets/.
  563. // We use a custom handler so /assets/* serves real files and
  564. // everything else falls back to /index.html (SPA history mode).
  565. mux.HandleFunc("GET /assets/", spaAssetsHandler(sub))
  566. mux.HandleFunc("GET /favicon.svg", spaAssetAt(sub, "favicon.svg"))
  567. mux.HandleFunc("GET /{$}", spaIndex(sub, indexBytes))
  568. // SPA history fallback for client-side routes. Each top-level
  569. // route returns index.html; React Router takes over.
  570. for _, path := range []string{
  571. "/login", "/forbidden",
  572. "/companies", "/companies/",
  573. "/sources", "/sources/",
  574. "/telegram", "/telegram/",
  575. "/tail",
  576. "/dlq",
  577. "/audit",
  578. } {
  579. p := path
  580. mux.HandleFunc("GET "+p, spaIndex(sub, indexBytes))
  581. }
  582. }
  583. func spaAssetsHandler(sub fs.FS) http.HandlerFunc {
  584. return func(w http.ResponseWriter, r *http.Request) {
  585. // URL path is /assets/<path>; strip the prefix.
  586. p := strings.TrimPrefix(r.URL.Path, "/assets/")
  587. f, err := sub.Open("assets/" + p)
  588. if err != nil {
  589. http.NotFound(w, r)
  590. return
  591. }
  592. defer f.Close()
  593. stat, err := f.Stat()
  594. if err != nil {
  595. http.NotFound(w, r)
  596. return
  597. }
  598. // Cache aggressively — assets/ files have hashed names so
  599. // they never change. The HTML at / is NEVER cached (always
  600. // re-fetched so a deploy picks up new bundles).
  601. w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
  602. http.ServeContent(w, r, stat.Name(), stat.ModTime(), f.(interface {
  603. Read([]byte) (int, error)
  604. Seek(int64, int) (int64, error)
  605. }))
  606. }
  607. }
  608. func spaAssetAt(sub fs.FS, name string) http.HandlerFunc {
  609. return func(w http.ResponseWriter, r *http.Request) {
  610. f, err := sub.Open(name)
  611. if err != nil {
  612. http.NotFound(w, r)
  613. return
  614. }
  615. defer f.Close()
  616. stat, _ := f.Stat()
  617. w.Header().Set("Cache-Control", "public, max-age=3600")
  618. http.ServeContent(w, r, stat.Name(), stat.ModTime(), f.(interface {
  619. Read([]byte) (int, error)
  620. Seek(int64, int) (int64, error)
  621. }))
  622. }
  623. }
  624. func spaIndex(sub fs.FS, indexBytes []byte) http.HandlerFunc {
  625. return func(w http.ResponseWriter, r *http.Request) {
  626. w.Header().Set("Content-Type", "text/html; charset=utf-8")
  627. w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
  628. _, _ = w.Write(indexBytes)
  629. }
  630. }
  631. const spaNotBuilt = `<!doctype html>
  632. <html><head><meta charset="utf-8"><title>broad-announce admin</title>
  633. <style>
  634. body { font-family: ui-sans-serif, system-ui, sans-serif; max-width: 640px; margin: 80px auto; padding: 0 24px; color: #111; background: #fafafa; }
  635. .dark body { background: #0d0f12; color: #e4e4e4; }
  636. h1 { font-size: 22px; }
  637. pre { background: #161a20; color: #e4e4e4; padding: 12px 16px; border-radius: 6px; }
  638. code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
  639. </style>
  640. </head><body>
  641. <h1>SPA not built</h1>
  642. <p>The React admin console (<code>web/dist/index.html</code>) is not present in the admind binary. This usually means the SPA was rebuilt without first running <code>make web-build</code>.</p>
  643. <p>Run from the repo root:</p>
  644. <pre>make web-build && make build</pre>
  645. <p>The <a href="/dlq">M8 DLQ HTML UI</a> still works at <a href="/dlq">/dlq</a>.</p>
  646. <p>The <a href="/v1/ping">/v1/ping</a> JSON endpoint is also available.</p>
  647. </body></html>`
  648. // wireDLQRoutes decides whether the /v1/dlq* routes go behind the
  649. // JWT gate or stay open, based on BA_AUTHD_JWT_SECRET. Extracted so
  650. // main() stays linear (no goto, no early returns from main).
  651. func wireDLQRoutes(mux *http.ServeMux, br *broker.Client, pool *postgres.Pool, logger *slog.Logger) {
  652. if !authd.EnvEnabled() {
  653. logger.Warn("dlq routes are UNAUTHENTICATED (set BA_AUTHD_JWT_SECRET to enable JWT gate)")
  654. mux.HandleFunc("GET /v1/dlq", handleListDLQ(pool, logger))
  655. mux.HandleFunc("GET /v1/dlq/{id}", handleGetDLQ(pool, logger))
  656. mux.HandleFunc("POST /v1/dlq/{id}/replay", handleReplayDLQ(br, pool, logger))
  657. mux.HandleFunc("POST /v1/dlq/{id}/discard", handleDiscardDLQ(pool, logger))
  658. return
  659. }
  660. ad, err := authd.NewFromEnv()
  661. if err != nil {
  662. logger.Error("BA_AUTHD_JWT_SECRET set but authd init failed; falling back to UNAUTHENTICATED routes", "err", err)
  663. mux.HandleFunc("GET /v1/dlq", handleListDLQ(pool, logger))
  664. mux.HandleFunc("GET /v1/dlq/{id}", handleGetDLQ(pool, logger))
  665. mux.HandleFunc("POST /v1/dlq/{id}/replay", handleReplayDLQ(br, pool, logger))
  666. mux.HandleFunc("POST /v1/dlq/{id}/discard", handleDiscardDLQ(pool, logger))
  667. return
  668. }
  669. logger.Info("dlq routes enabled with JWT gate")
  670. // replay and discard are destructive — require admin role.
  671. // list and get are read-only — any authenticated user.
  672. mux.Handle("GET /v1/dlq", ad.RequireAuth(http.HandlerFunc(handleListDLQ(pool, logger))))
  673. mux.Handle("GET /v1/dlq/{id}", ad.RequireAuth(http.HandlerFunc(handleGetDLQ(pool, logger))))
  674. mux.Handle("POST /v1/dlq/{id}/replay", ad.RequireRole("super_admin", "tenant_admin")(http.HandlerFunc(handleReplayDLQ(br, pool, logger))))
  675. mux.Handle("POST /v1/dlq/{id}/discard", ad.RequireRole("super_admin", "tenant_admin")(http.HandlerFunc(handleDiscardDLQ(pool, logger))))
  676. }