|
|
@@ -21,6 +21,7 @@ import (
|
|
|
"fmt"
|
|
|
"html"
|
|
|
"html/template"
|
|
|
+ "io/fs"
|
|
|
"log/slog"
|
|
|
"net/http"
|
|
|
"os"
|
|
|
@@ -44,6 +45,16 @@ import (
|
|
|
//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
|
|
|
@@ -103,6 +114,13 @@ func main() {
|
|
|
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.
|
|
|
@@ -541,6 +559,133 @@ func handleDLQUI(pool *postgres.Pool, logger *slog.Logger) http.HandlerFunc {
|
|
|
// 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).
|