| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717 |
- // Command admind is the admin HTTP API + (later) UI host. Tenant CRUD,
- // DLQ inspection, replay, audit log.
- //
- // M0: /health, /metrics, /v1/ping.
- // M8: + DLQ surface —
- // GET /v1/dlq — list/filter DLQ rows
- // GET /v1/dlq/{id} — single row (with payload)
- // POST /v1/dlq/{id}/replay — re-INSERT into the deliverd NATS subject
- // POST /v1/dlq/{id}/discard — mark discarded (hidden from default list)
- // GET /dlq — minimal HTML UI
- //
- // Auth: M8 ships without auth (LAN-only). The M11 work
- // gates /v1/dlq* behind an operator JWT.
- package main
- import (
- "context"
- "embed"
- "encoding/json"
- "errors"
- "fmt"
- "html"
- "html/template"
- "io/fs"
- "log/slog"
- "net/http"
- "os"
- "os/signal"
- "strconv"
- "strings"
- "syscall"
- "time"
- "git3.techno-world.net/lrosales/broad-announce/internal/authd"
- "git3.techno-world.net/lrosales/broad-announce/internal/broker"
- "git3.techno-world.net/lrosales/broad-announce/internal/config"
- "git3.techno-world.net/lrosales/broad-announce/internal/httpserver"
- "git3.techno-world.net/lrosales/broad-announce/internal/observability"
- "git3.techno-world.net/lrosales/broad-announce/internal/postgres"
- "github.com/jackc/pgx/v5"
- "github.com/nats-io/nats.go"
- )
- //go:embed ui/*
- var uiFS embed.FS
- // webFS embeds the React SPA built by web/ (M13b W0+). The
- // directory must exist at build time; `make web-build` produces
- // web/dist/ and the Go embed picks it up automatically. When
- // web/dist/ is missing (dev build before `make web-build`),
- // the embed is empty and webHandlers() returns a 503 stub so
- // operators still see a useful message instead of a 404.
- //
- //go:embed web-dist
- var webFS embed.FS
- // dlqRow is the wire shape returned by /v1/dlq and
- // rendered by the HTML UI. The payload field is omitted
- // from the list endpoint (operators can fetch the full
- // row via /v1/dlq/{id}).
- type dlqRow struct {
- ID int64 `json:"id"`
- AlertID string `json:"alert_id"`
- CompanyID string `json:"company_id"`
- IndividualID string `json:"individual_id"`
- Channel string `json:"channel"`
- Target string `json:"target"`
- OriginalSubject string `json:"original_subject"`
- Attempts int `json:"attempts"`
- LastError string `json:"last_error"`
- Discarded bool `json:"discarded"`
- DiscardedAt *time.Time `json:"discarded_at,omitempty"`
- DiscardedBy string `json:"discarded_by,omitempty"`
- CreatedAt time.Time `json:"created_at"`
- Payload json.RawMessage `json:"payload,omitempty"`
- }
- func main() {
- cfg, err := config.LoadCommon("admind")
- if err != nil {
- os.Stderr.WriteString("config: " + err.Error() + "\n")
- os.Exit(1)
- }
- logger := observability.Init(cfg.Env, cfg.LogLevel, "admind")
- logger.Info("starting", "env", cfg.Env, "addr", cfg.HTTPAddr)
- ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
- defer stop()
- br, err := broker.Connect(ctx, cfg.NATSURL)
- if err != nil {
- logger.Error("nats connect", "err", err)
- os.Exit(1)
- }
- defer br.Close()
- pool, err := postgres.Connect(ctx, cfg.PostgresDSN)
- if err != nil {
- logger.Error("postgres connect", "err", err)
- os.Exit(1)
- }
- defer pool.Close()
- reg, _ := observability.NewRegistry("admind")
- srv := httpserver.New(httpserver.Config{
- Addr: cfg.HTTPAddr,
- ServiceName: "admind",
- ShutdownGrace: cfg.ShutdownGrace,
- }, logger, observability.MetricsHandler(reg))
- mux := srv.Mux()
- mux.HandleFunc("GET /v1/ping", handlePing)
- mux.HandleFunc("GET /dlq", handleDLQUI(pool, logger))
- // M13b W0: serve the React SPA from /, with SPA fallback to
- // /index.html for client-side routes. /dlq keeps the M8 HTML
- // UI (it's a server-rendered template, not part of the SPA).
- // The /v1/* routes take precedence (Go ServeMux matches the
- // longest prefix).
- wireSPA(mux, logger)
- // M13a W3: JWT-gate the /v1/dlq* routes when BA_AUTHD_JWT_SECRET
- // is set. When unset, the routes stay unauthenticated (the
- // pre-M13 behavior) so the LAN-only deploy path keeps working.
- wireDLQRoutes(mux, br, pool, logger)
- errCh := make(chan error, 1)
- go func() { errCh <- srv.Start() }()
- select {
- case <-ctx.Done():
- logger.Info("shutdown signal received")
- case err := <-errCh:
- if err != nil {
- logger.Error("http server", "err", err)
- os.Exit(1)
- }
- }
- if err := srv.Shutdown(ctx); err != nil {
- logger.Warn("graceful shutdown", "err", err)
- }
- logger.Info("bye")
- }
- func handlePing(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("Content-Type", "application/json")
- _ = json.NewEncoder(w).Encode(map[string]any{
- "pong": true,
- "service": "admind",
- "timestamp": time.Now().UTC().Format(time.RFC3339Nano),
- })
- }
- // listFilters is the parsed query string for /v1/dlq.
- type listFilters struct {
- CompanyID string
- Channel string
- AlertID string
- Include string // "all" to show discarded; default hides them
- Limit int
- Offset int
- }
- func parseListFilters(r *http.Request) listFilters {
- q := r.URL.Query()
- limit, _ := strconv.Atoi(q.Get("limit"))
- if limit <= 0 || limit > 500 {
- limit = 100
- }
- offset, _ := strconv.Atoi(q.Get("offset"))
- if offset < 0 {
- offset = 0
- }
- return listFilters{
- CompanyID: strings.TrimSpace(q.Get("company_id")),
- Channel: strings.TrimSpace(q.Get("channel")),
- AlertID: strings.TrimSpace(q.Get("alert_id")),
- Include: strings.TrimSpace(q.Get("include")),
- Limit: limit,
- Offset: offset,
- }
- }
- // handleListDLQ returns a paginated list of DLQ rows.
- // The list excludes discarded rows by default; pass
- // include=all to see them.
- func handleListDLQ(pool *postgres.Pool, logger *slog.Logger) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- f := parseListFilters(r)
- rows, err := queryDLQ(r.Context(), pool, dlqQuery{
- CompanyID: f.CompanyID,
- Channel: f.Channel,
- AlertID: f.AlertID,
- IncludeDiscarded: f.Include == "all",
- Limit: f.Limit,
- Offset: f.Offset,
- })
- if err != nil {
- logger.Error("dlq list", "err", err)
- http.Error(w, "dlq list: "+err.Error(), http.StatusInternalServerError)
- return
- }
- w.Header().Set("Content-Type", "application/json")
- _ = json.NewEncoder(w).Encode(map[string]any{
- "rows": rows,
- "limit": f.Limit,
- "offset": f.Offset,
- })
- }
- }
- // handleGetDLQ returns one row by id, with the payload
- // included. Used by the UI's "view payload" expand.
- func handleGetDLQ(pool *postgres.Pool, logger *slog.Logger) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- id, ok := parseID(r)
- if !ok {
- http.Error(w, "bad id", http.StatusBadRequest)
- return
- }
- row, err := getDLQ(r.Context(), pool, id)
- if err != nil {
- logger.Error("dlq get", "err", err, "id", id)
- http.Error(w, "dlq get: "+err.Error(), http.StatusInternalServerError)
- return
- }
- if row == nil {
- http.Error(w, "not found", http.StatusNotFound)
- return
- }
- w.Header().Set("Content-Type", "application/json")
- _ = json.NewEncoder(w).Encode(row)
- }
- }
- // handleReplayDLQ re-publishes the original NATS envelope
- // (stored in deliveries_dlq.payload) onto the original
- // subject, then marks the DLQ row discarded=true (with
- // discarded_by='replay') and writes a fresh audit row in
- // the live deliveries table.
- //
- // Idempotency: a single replay succeeds or fails. If it
- // fails, the DLQ row is NOT marked discarded so the
- // operator can retry. There is no race because the
- // discarded=true UPDATE happens in the same handler call
- // as the Publish; a second concurrent replay would
- // publish twice (the second one will hit dedupe at the
- // ingestd layer if the alert_id has been seen recently,
- // but for the DLQ replay path we don't dedupe — the
- // operator explicitly asked for a re-send).
- func handleReplayDLQ(br *broker.Client, pool *postgres.Pool, logger *slog.Logger) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- id, ok := parseID(r)
- if !ok {
- http.Error(w, "bad id", http.StatusBadRequest)
- return
- }
- row, err := getDLQ(r.Context(), pool, id)
- if err != nil {
- logger.Error("dlq replay get", "err", err, "id", id)
- http.Error(w, "dlq replay: "+err.Error(), http.StatusInternalServerError)
- return
- }
- if row == nil {
- http.Error(w, "not found", http.StatusNotFound)
- return
- }
- if row.Discarded {
- http.Error(w, "already discarded", http.StatusConflict)
- return
- }
- // Re-publish the original payload (json.RawMessage
- // holds the NATS envelope bytes verbatim).
- nc := br.NC()
- if err := nc.Publish(row.OriginalSubject, row.Payload); err != nil {
- logger.Error("dlq replay publish", "err", err, "id", id, "subject", row.OriginalSubject)
- http.Error(w, "publish: "+err.Error(), http.StatusBadGateway)
- return
- }
- // Flush so the message is on the wire before we
- // mark the row discarded. If the worker can't
- // deliver it, it will go back into the DLQ with
- // a fresh row, and the operator will see both.
- if err := nc.Flush(); err != nil {
- logger.Warn("dlq replay flush", "err", err, "id", id)
- }
- // Mark the DLQ row discarded. We do this AFTER
- // the publish so a publish failure leaves the
- // DLQ row in place for retry.
- now := time.Now().UTC()
- _, dbErr := pool.Exec(r.Context(), `
- UPDATE deliveries_dlq
- SET discarded = true,
- discarded_at = $1,
- discarded_by = $2
- WHERE id = $3 AND created_at IN (
- SELECT created_at FROM deliveries_dlq WHERE id = $3 LIMIT 1
- )
- `, now, "replay", id)
- if dbErr != nil {
- // Publish succeeded but the DB update
- // didn't. The replay still happened —
- // log loud, return 200, and let the
- // operator handle the duplicate UI row
- // if they re-replay.
- logger.Error("dlq replay: publish ok but update failed",
- "err", dbErr, "id", id)
- }
- logger.Info("dlq replay ok", "id", id, "subject", row.OriginalSubject, "alert_id", row.AlertID)
- w.Header().Set("Content-Type", "application/json")
- _ = json.NewEncoder(w).Encode(map[string]any{
- "replayed": true,
- "id": id,
- "subject": row.OriginalSubject,
- "alert_id": row.AlertID,
- "company_id": row.CompanyID,
- "original_attempts": row.Attempts,
- })
- }
- }
- // handleDiscardDLQ marks a DLQ row as discarded. After
- // discard, the row is hidden from /v1/dlq by default and
- // from the HTML UI.
- func handleDiscardDLQ(pool *postgres.Pool, logger *slog.Logger) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- id, ok := parseID(r)
- if !ok {
- http.Error(w, "bad id", http.StatusBadRequest)
- return
- }
- now := time.Now().UTC()
- // The (id, created_at) PK means the UPDATE
- // has to match the row's created_at too. We
- // fetch first, then UPDATE with both keys.
- row, err := getDLQ(r.Context(), pool, id)
- if err != nil {
- logger.Error("dlq discard get", "err", err, "id", id)
- http.Error(w, "dlq discard: "+err.Error(), http.StatusInternalServerError)
- return
- }
- if row == nil {
- http.Error(w, "not found", http.StatusNotFound)
- return
- }
- if row.Discarded {
- // Idempotent: a second discard is a no-op.
- w.Header().Set("Content-Type", "application/json")
- _ = json.NewEncoder(w).Encode(map[string]any{"discarded": true, "id": id, "already": true})
- return
- }
- _, dbErr := pool.Exec(r.Context(), `
- UPDATE deliveries_dlq
- SET discarded = true,
- discarded_at = $1,
- discarded_by = $2
- WHERE id = $3 AND created_at = $4
- `, now, "operator", id, row.CreatedAt)
- if dbErr != nil {
- logger.Error("dlq discard", "err", dbErr, "id", id)
- http.Error(w, "discard: "+dbErr.Error(), http.StatusInternalServerError)
- return
- }
- logger.Info("dlq discard ok", "id", id, "alert_id", row.AlertID)
- w.Header().Set("Content-Type", "application/json")
- _ = json.NewEncoder(w).Encode(map[string]any{
- "discarded": true,
- "id": id,
- "discarded_at": now,
- })
- }
- }
- // dlqQuery is the structured query for the list endpoint.
- type dlqQuery struct {
- CompanyID string
- Channel string
- AlertID string
- IncludeDiscarded bool
- Limit int
- Offset int
- }
- // queryDLQ returns the rows that match the filters.
- // Excludes the payload from the list view (operators
- // fetch /v1/dlq/{id} for the full row).
- func queryDLQ(ctx context.Context, pool *postgres.Pool, q dlqQuery) ([]dlqRow, error) {
- // Build the WHERE clause dynamically. We always
- // filter on created_at > now - 30d for the list
- // view (operators rarely need ancient rows; the
- // archiver ships them to CH).
- conds := []string{"created_at > now() - INTERVAL '30 days'"}
- args := []any{}
- if q.CompanyID != "" {
- args = append(args, q.CompanyID)
- conds = append(conds, fmt.Sprintf("company_id = $%d", len(args)))
- }
- if q.Channel != "" {
- args = append(args, q.Channel)
- conds = append(conds, fmt.Sprintf("channel = $%d", len(args)))
- }
- if q.AlertID != "" {
- args = append(args, q.AlertID)
- conds = append(conds, fmt.Sprintf("alert_id = $%d", len(args)))
- }
- if !q.IncludeDiscarded {
- conds = append(conds, "discarded = false")
- }
- where := strings.Join(conds, " AND ")
- args = append(args, q.Limit, q.Offset)
- q1 := fmt.Sprintf(`
- SELECT id, alert_id, company_id, individual_id, channel, target,
- original_subject, attempts, last_error, discarded,
- discarded_at, discarded_by, created_at
- FROM deliveries_dlq
- WHERE %s
- ORDER BY created_at DESC
- LIMIT $%d OFFSET $%d
- `, where, len(args)-1, len(args))
- rows, err := pool.Query(ctx, q1, args...)
- if err != nil {
- return nil, err
- }
- defer rows.Close()
- var out []dlqRow
- for rows.Next() {
- var r dlqRow
- var discardedAt *time.Time
- var discardedBy *string
- if err := rows.Scan(
- &r.ID, &r.AlertID, &r.CompanyID, &r.IndividualID, &r.Channel, &r.Target,
- &r.OriginalSubject, &r.Attempts, &r.LastError, &r.Discarded,
- &discardedAt, &discardedBy, &r.CreatedAt,
- ); err != nil {
- return nil, err
- }
- r.DiscardedAt = discardedAt
- if discardedBy != nil {
- r.DiscardedBy = *discardedBy
- }
- out = append(out, r)
- }
- return out, rows.Err()
- }
- // getDLQ returns one row by id (with payload). Returns
- // nil, nil if the row doesn't exist.
- func getDLQ(ctx context.Context, pool *postgres.Pool, id int64) (*dlqRow, error) {
- row := pool.QueryRow(ctx, `
- SELECT id, alert_id, company_id, individual_id, channel, target,
- original_subject, attempts, last_error, payload, discarded,
- discarded_at, discarded_by, created_at
- FROM deliveries_dlq
- WHERE id = $1
- LIMIT 1
- `, id)
- var r dlqRow
- var payload []byte
- var discardedAt *time.Time
- var discardedBy *string
- if err := row.Scan(
- &r.ID, &r.AlertID, &r.CompanyID, &r.IndividualID, &r.Channel, &r.Target,
- &r.OriginalSubject, &r.Attempts, &r.LastError, &payload, &r.Discarded,
- &discardedAt, &discardedBy, &r.CreatedAt,
- ); err != nil {
- if errors.Is(err, pgx.ErrNoRows) {
- return nil, nil
- }
- return nil, err
- }
- r.Payload = json.RawMessage(payload)
- r.DiscardedAt = discardedAt
- if discardedBy != nil {
- r.DiscardedBy = *discardedBy
- }
- return &r, nil
- }
- func parseID(r *http.Request) (int64, bool) {
- raw := r.PathValue("id")
- id, err := strconv.ParseInt(raw, 10, 64)
- if err != nil || id <= 0 {
- return 0, false
- }
- return id, true
- }
- // ── HTML UI ──────────────────────────────────────────────────────
- // uiData is the template data for the /dlq HTML page.
- type uiData struct {
- Rows []dlqRow
- Filters listFilters
- ShowAll bool
- HasFilters bool
- }
- // handleDLQUI serves a minimal HTML page with the DLQ
- // list and inline replay/discard buttons. POSTs go to
- // the same /v1/dlq/{id}/{replay,discard} endpoints; the
- // JS does a fetch and reloads the page.
- func handleDLQUI(pool *postgres.Pool, logger *slog.Logger) http.HandlerFunc {
- tpl := template.Must(template.New("dlq").Funcs(template.FuncMap{
- "safeHTML": func(s string) template.HTML { return template.HTML(html.EscapeString(s)) },
- "shortErr": func(s string) string {
- if len(s) > 80 {
- return s[:80] + "…"
- }
- return s
- },
- "ago": func(t time.Time) string {
- d := time.Since(t)
- switch {
- case d < time.Minute:
- return fmt.Sprintf("%ds ago", int(d.Seconds()))
- case d < time.Hour:
- return fmt.Sprintf("%dm ago", int(d.Minutes()))
- case d < 24*time.Hour:
- return fmt.Sprintf("%dh ago", int(d.Hours()))
- default:
- return fmt.Sprintf("%dd ago", int(d.Hours()/24))
- }
- },
- }).ParseFS(uiFS, "ui/*.html"))
- return func(w http.ResponseWriter, r *http.Request) {
- f := parseListFilters(r)
- filters := dlqQuery{
- CompanyID: f.CompanyID,
- Channel: f.Channel,
- AlertID: f.AlertID,
- IncludeDiscarded: f.Include == "all",
- Limit: f.Limit,
- Offset: f.Offset,
- }
- rows, err := queryDLQ(r.Context(), pool, filters)
- if err != nil {
- logger.Error("dlq ui list", "err", err)
- http.Error(w, "dlq ui: "+err.Error(), http.StatusInternalServerError)
- return
- }
- data := uiData{
- Rows: rows,
- Filters: f,
- ShowAll: f.Include == "all",
- HasFilters: f.CompanyID != "" || f.Channel != "" || f.AlertID != "",
- }
- w.Header().Set("Content-Type", "text/html; charset=utf-8")
- if err := tpl.ExecuteTemplate(w, "dlq.html", data); err != nil {
- logger.Error("dlq ui render", "err", err)
- }
- }
- }
- // broker re-export so the handler signatures stay clean.
- // We import broker just for the Connect + Conn() pair.
- var _ = nats.Conn{}
- // wireSPA serves the React SPA at / and SPA-fallback to /index.html
- // for client-side routes (/companies, /sources, /login, etc.).
- //
- // If web/dist/ is empty (dev build before `make web-build`), the
- // handler returns a small 503 page that explains how to build the
- // SPA. This is intentional: the embed.FS at compile time is fixed,
- // and we'd rather show a useful error than 404 every route.
- func wireSPA(mux *http.ServeMux, logger *slog.Logger) {
- sub, err := fs.Sub(webFS, "web-dist")
- if err != nil {
- // web/dist missing entirely. The Go embed would have
- // errored at build time if a non-existent prefix was used
- // with go:embed; if we get here, web/dist exists but
- // contains nothing.
- logger.Warn("SPA not built: web/dist is empty — run `make web-build`")
- mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("Content-Type", "text/html; charset=utf-8")
- w.WriteHeader(http.StatusServiceUnavailable)
- _, _ = w.Write([]byte(spaNotBuilt))
- })
- return
- }
- indexBytes, err := fs.ReadFile(sub, "index.html")
- if err != nil {
- // web/dist exists but doesn't have index.html — wrong
- // build output (maybe a partial vite build).
- logger.Warn("SPA build looks incomplete: web/dist has no index.html — re-run `make web-build`")
- mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("Content-Type", "text/html; charset=utf-8")
- w.WriteHeader(http.StatusServiceUnavailable)
- _, _ = w.Write([]byte(spaNotBuilt))
- })
- return
- }
- logger.Info("SPA mounted", "index_bytes", len(indexBytes))
- // Static files (JS, CSS, fonts, icons) — served from /assets/.
- // We use a custom handler so /assets/* serves real files and
- // everything else falls back to /index.html (SPA history mode).
- mux.HandleFunc("GET /assets/", spaAssetsHandler(sub))
- mux.HandleFunc("GET /favicon.svg", spaAssetAt(sub, "favicon.svg"))
- mux.HandleFunc("GET /{$}", spaIndex(sub, indexBytes))
- // SPA history fallback for client-side routes. Each top-level
- // route returns index.html; React Router takes over.
- for _, path := range []string{
- "/login", "/forbidden",
- "/companies", "/companies/",
- "/sources", "/sources/",
- "/telegram", "/telegram/",
- "/tail",
- "/dlq",
- "/audit",
- } {
- p := path
- mux.HandleFunc("GET "+p, spaIndex(sub, indexBytes))
- }
- }
- func spaAssetsHandler(sub fs.FS) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- // URL path is /assets/<path>; strip the prefix.
- p := strings.TrimPrefix(r.URL.Path, "/assets/")
- f, err := sub.Open("assets/" + p)
- if err != nil {
- http.NotFound(w, r)
- return
- }
- defer f.Close()
- stat, err := f.Stat()
- if err != nil {
- http.NotFound(w, r)
- return
- }
- // Cache aggressively — assets/ files have hashed names so
- // they never change. The HTML at / is NEVER cached (always
- // re-fetched so a deploy picks up new bundles).
- w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
- http.ServeContent(w, r, stat.Name(), stat.ModTime(), f.(interface {
- Read([]byte) (int, error)
- Seek(int64, int) (int64, error)
- }))
- }
- }
- func spaAssetAt(sub fs.FS, name string) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- f, err := sub.Open(name)
- if err != nil {
- http.NotFound(w, r)
- return
- }
- defer f.Close()
- stat, _ := f.Stat()
- w.Header().Set("Cache-Control", "public, max-age=3600")
- http.ServeContent(w, r, stat.Name(), stat.ModTime(), f.(interface {
- Read([]byte) (int, error)
- Seek(int64, int) (int64, error)
- }))
- }
- }
- func spaIndex(sub fs.FS, indexBytes []byte) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("Content-Type", "text/html; charset=utf-8")
- w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
- _, _ = w.Write(indexBytes)
- }
- }
- const spaNotBuilt = `<!doctype html>
- <html><head><meta charset="utf-8"><title>broad-announce admin</title>
- <style>
- body { font-family: ui-sans-serif, system-ui, sans-serif; max-width: 640px; margin: 80px auto; padding: 0 24px; color: #111; background: #fafafa; }
- .dark body { background: #0d0f12; color: #e4e4e4; }
- h1 { font-size: 22px; }
- pre { background: #161a20; color: #e4e4e4; padding: 12px 16px; border-radius: 6px; }
- code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
- </style>
- </head><body>
- <h1>SPA not built</h1>
- <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>
- <p>Run from the repo root:</p>
- <pre>make web-build && make build</pre>
- <p>The <a href="/dlq">M8 DLQ HTML UI</a> still works at <a href="/dlq">/dlq</a>.</p>
- <p>The <a href="/v1/ping">/v1/ping</a> JSON endpoint is also available.</p>
- </body></html>`
- // wireDLQRoutes decides whether the /v1/dlq* routes go behind the
- // JWT gate or stay open, based on BA_AUTHD_JWT_SECRET. Extracted so
- // main() stays linear (no goto, no early returns from main).
- func wireDLQRoutes(mux *http.ServeMux, br *broker.Client, pool *postgres.Pool, logger *slog.Logger) {
- if !authd.EnvEnabled() {
- logger.Warn("dlq routes are UNAUTHENTICATED (set BA_AUTHD_JWT_SECRET to enable JWT gate)")
- mux.HandleFunc("GET /v1/dlq", handleListDLQ(pool, logger))
- mux.HandleFunc("GET /v1/dlq/{id}", handleGetDLQ(pool, logger))
- mux.HandleFunc("POST /v1/dlq/{id}/replay", handleReplayDLQ(br, pool, logger))
- mux.HandleFunc("POST /v1/dlq/{id}/discard", handleDiscardDLQ(pool, logger))
- return
- }
- ad, err := authd.NewFromEnv()
- if err != nil {
- logger.Error("BA_AUTHD_JWT_SECRET set but authd init failed; falling back to UNAUTHENTICATED routes", "err", err)
- mux.HandleFunc("GET /v1/dlq", handleListDLQ(pool, logger))
- mux.HandleFunc("GET /v1/dlq/{id}", handleGetDLQ(pool, logger))
- mux.HandleFunc("POST /v1/dlq/{id}/replay", handleReplayDLQ(br, pool, logger))
- mux.HandleFunc("POST /v1/dlq/{id}/discard", handleDiscardDLQ(pool, logger))
- return
- }
- logger.Info("dlq routes enabled with JWT gate")
- // replay and discard are destructive — require admin role.
- // list and get are read-only — any authenticated user.
- mux.Handle("GET /v1/dlq", ad.RequireAuth(http.HandlerFunc(handleListDLQ(pool, logger))))
- mux.Handle("GET /v1/dlq/{id}", ad.RequireAuth(http.HandlerFunc(handleGetDLQ(pool, logger))))
- mux.Handle("POST /v1/dlq/{id}/replay", ad.RequireRole("super_admin", "tenant_admin")(http.HandlerFunc(handleReplayDLQ(br, pool, logger))))
- mux.Handle("POST /v1/dlq/{id}/discard", ad.RequireRole("super_admin", "tenant_admin")(http.HandlerFunc(handleDiscardDLQ(pool, logger))))
- }
|