329825ce86
- 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.
233 lines
7.1 KiB
Go
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)
|
|
}
|