Files
GraceSolutions 329825ce86 fix(auth): sanitize redirect, read token from storage, 301 legacy /auth/* paths
- Add sanitizeRedirect/safeRedirectTarget helper; reject anything that isn't
  a safe local path (must start with a single /, no scheme, no whitespace,
  not /login itself). Apply in RequireAuth when encoding the current pathname
  and in AuthLogin when consuming ?redirect=.
- Make configureApi's getToken read localStorage directly via readCurrentToken
  so the first request after login cannot race the AuthContext re-render that
  previously owned the token via a React closure.
- Redirect legacy /auth/* (including the pre-flatten /auth/auth1/login) with
  HTTP 301 to /login in the webui handler so stale bookmarks can't seed the
  SPA router with a malformed URL.
- Regression test TestHandler_LegacyAuthPathRedirectsToLogin covering four
  legacy path shapes.
2026-04-23 17:12:41 -04:00

233 lines
7.1 KiB
Go

// Package webui embeds the compiled Next.js static export so the single
// backend binary can serve the full product UI. The build pipeline is
// responsible for populating dist/ (see scripts/build.ps1) by copying
// frontend/out/ into this directory before running go build.
package webui
import (
"embed"
"errors"
"io"
"io/fs"
"mime"
"net/http"
"path"
"path/filepath"
"strconv"
"strings"
)
//go:embed all:dist
var distFS embed.FS
// placeholderHTML is served whenever a real UI has not been staged into
// dist/ before `go build`. It lets the backend start and respond on the
// UI routes during pure-Go development without tracking any generated
// HTML in source control.
const placeholderHTML = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>OrchestrAD</title>
<style>
body{margin:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;background:#0f172a;color:#e2e8f0;display:flex;align-items:center;justify-content:center;min-height:100vh}
.card{max-width:520px;padding:32px;border:1px solid #1e293b;border-radius:12px;background:#111827}
h1{margin:0 0 8px;font-size:22px}
p{margin:0 0 8px;color:#94a3b8;line-height:1.5}
code{background:#1e293b;padding:2px 6px;border-radius:4px;color:#e2e8f0;font-size:90%}
</style>
</head>
<body>
<div class="card">
<h1>OrchestrAD</h1>
<p>The web UI has not been staged into this binary. Run <code>scripts/build.ps1</code> (or its <code>-SkipFrontend</code> companion after a separate frontend build) to produce a release binary that bundles the UI.</p>
<p>The API is fully functional at <code>/api/v1</code>.</p>
</div>
</body>
</html>
`
// FS returns the embedded UI filesystem rooted at dist/.
func FS() fs.FS {
sub, err := fs.Sub(distFS, "dist")
if err != nil {
return distFS
}
return sub
}
// IsBuilt reports whether a real built UI is present. When only the
// .gitignore stub has been embedded, the frontend build was skipped and
// callers should fall back to the placeholder document.
func IsBuilt() bool {
f, err := FS().Open("index.html")
if err != nil {
return false
}
_ = f.Close()
return true
}
// Handler returns an http.Handler that serves the embedded UI with SPA
// fallback: any request that does not resolve to a real embedded file is
// served index.html with HTTP 200 so client-side routing can handle it.
// Hashed asset paths under _next/static are served with a long-lived cache
// header; everything else uses a short revalidating cache.
//
// Files are streamed directly from the embedded fs.FS rather than through
// http.FileServer to avoid its built-in "/foo/index.html -> ./" redirect
// behavior, which creates infinite loops when index.html is the resolved
// file (e.g. for /, /rules, /auth/auth1/login, etc.).
func Handler() http.Handler {
return handlerForFS(FS())
}
// handlerForFS is the testable core of Handler, parameterized over the
// filesystem so tests can build synthetic UI trees with fstest.MapFS
// without depending on whether a real frontend has been staged into dist/.
func handlerForFS(root fs.FS) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if target, ok := legacyRedirect(r.URL.Path); ok {
http.Redirect(w, r, target, http.StatusMovedPermanently)
return
}
reqPath := strings.TrimPrefix(path.Clean(r.URL.Path), "/")
if reqPath == "" {
reqPath = "index.html"
}
resolved, ok := resolve(root, reqPath)
if !ok {
serveIndex(w, r, root)
return
}
if strings.HasPrefix(resolved, "_next/static/") {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
} else {
w.Header().Set("Cache-Control", "no-cache")
}
serveFile(w, r, root, resolved)
})
}
// serveFile streams name from root with a content-type derived from the file
// extension and an explicit Content-Length header so HEAD requests and range
// behavior match http.ServeContent's output.
func serveFile(w http.ResponseWriter, r *http.Request, root fs.FS, name string) {
f, err := root.Open(name)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
serveIndex(w, r, root)
return
}
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer f.Close()
info, err := fs.Stat(root, name)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if ct := mime.TypeByExtension(filepath.Ext(name)); ct != "" {
w.Header().Set("Content-Type", ct)
} else {
w.Header().Set("Content-Type", "application/octet-stream")
}
w.Header().Set("Content-Length", strconv.FormatInt(info.Size(), 10))
if r.Method == http.MethodHead {
w.WriteHeader(http.StatusOK)
return
}
w.WriteHeader(http.StatusOK)
_, _ = io.Copy(w, f)
}
// legacyRedirect maps pre-flatten template paths (e.g. the Spike template's
// /auth/auth1/login) to their current locations with HTTP 301. Returning ok
// short-circuits asset resolution so stale bookmarks never feed back into
// the SPA router, where they have historically produced malformed redirect
// URLs like /auth/http://host/loginauth1/login.
func legacyRedirect(reqPath string) (string, bool) {
trimmed := strings.TrimRight(reqPath, "/")
switch {
case trimmed == "/auth/auth1/login":
return "/login", true
case strings.HasPrefix(trimmed, "/auth/auth1/"),
trimmed == "/auth/auth1",
strings.HasPrefix(trimmed, "/auth/"):
return "/login", true
}
return "", false
}
// resolve mirrors Next.js static export's trailing-slash behavior: a request
// for /rules first tries rules (file), then rules/index.html, then rules.html.
// Returns the resolved path (without leading slash) and whether a real file
// was found.
func resolve(root fs.FS, reqPath string) (string, bool) {
if exists(root, reqPath) && !isDir(root, reqPath) {
return reqPath, true
}
if strings.HasSuffix(reqPath, "/") {
candidate := reqPath + "index.html"
if exists(root, candidate) {
return candidate, true
}
} else {
candidate := reqPath + "/index.html"
if exists(root, candidate) {
return candidate, true
}
candidate = reqPath + ".html"
if exists(root, candidate) {
return candidate, true
}
}
return "", false
}
func exists(root fs.FS, name string) bool {
_, err := fs.Stat(root, name)
return err == nil
}
func isDir(root fs.FS, name string) bool {
info, err := fs.Stat(root, name)
if err != nil {
return false
}
return info.IsDir()
}
// serveIndex writes the SPA entry document with HTTP 200 so deep links work.
// Falls back to placeholderHTML when the real index.html has not been staged
// into the embedded filesystem (dev builds without a prior frontend build).
func serveIndex(w http.ResponseWriter, r *http.Request, root fs.FS) {
f, err := root.Open("index.html")
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
w.WriteHeader(http.StatusOK)
_, _ = io.WriteString(w, placeholderHTML)
return
}
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer f.Close()
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
w.WriteHeader(http.StatusOK)
_, _ = io.Copy(w, f)
}