feat(10-01): serve the embedded admin SPA at backend.uri with cookie login

- backend.uri prefix (default /backend) mounts the admin API at {prefix}/api/v1
  and the embedded SPA shell at {prefix} with an api/ JSON 404 fallback
- cookie transport: an X-Requested-With login sets the HttpOnly summer_admin
  cookie and returns no token; the backend guard reads the cookie after Bearer
- CSRF wrapper refuses cookie-only POST/PUT/DELETE without X-Requested-With
- boardwalk package embeds boardwalk/dist, rewrites index.html once per prefix
  and sets cache and security headers
- framework admin OpenAPI pipeline (swag, swagger2openapi, openapi-typescript)
  with prefix-relative paths and typed envelopes for the tracer routes
- admin/ Vite SPA: login, plugin rail, section panel and read-only list
  through the openapi-fetch client typed by the generated schema
This commit is contained in:
Jakub Zych
2026-09-27 15:21:48 +02:00
parent 8c3e131111
commit 5f9353841b
79 changed files with 10745 additions and 147 deletions

167
boardwalk/boardwalk.go Normal file
View File

@@ -0,0 +1,167 @@
// Package boardwalk serves the embedded admin SPA build (D-01, D-02).
//
// The committed dist/ is path-agnostic: Vite builds it with a relative base
// and index.html carries the __SUMMER_ADMIN_BASE__ token. Handler rewrites
// index.html once for the configured backend.uri, serves hashed assets with
// long-lived caching, falls back to index.html for client-side routes and
// hands every unmatched api/ path back to the caller so API misses stay JSON.
package boardwalk
import (
"bytes"
"embed"
"errors"
"fmt"
"html"
"io/fs"
"mime"
"net/http"
"path"
"strings"
"time"
)
//go:embed all:dist
var distFS embed.FS
// BaseToken is replaced in dist/index.html by the configured admin prefix.
const BaseToken = "__SUMMER_ADMIN_BASE__"
// contentSecurityPolicy keeps the admin out of frames and allows scripts
// only from its own origin; the build contains no inline script.
const contentSecurityPolicy = "frame-ancestors 'none'; base-uri 'none'; object-src 'none'; script-src 'self'"
var contentTypes = map[string]string{
".js": "text/javascript; charset=utf-8",
".mjs": "text/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".html": "text/html; charset=utf-8",
".woff2": "font/woff2",
".woff": "font/woff",
".svg": "image/svg+xml",
".json": "application/json",
}
// Dist returns the embedded build tree rooted at dist/.
func Dist() (fs.FS, error) {
return fs.Sub(distFS, "dist")
}
// Handler serves the embedded SPA under prefix (for example /backend).
// notFoundAPI answers any request whose path under the prefix is api or
// starts with api/; it must write the admin API's JSON 404 envelope.
func Handler(prefix string, notFoundAPI http.Handler) (http.Handler, error) {
root, err := Dist()
if err != nil {
return nil, err
}
return newHandler(root, prefix, notFoundAPI)
}
func newHandler(root fs.FS, prefix string, notFoundAPI http.Handler) (http.Handler, error) {
if notFoundAPI == nil {
return nil, errors.New("boardwalk: notFoundAPI handler is nil")
}
prefix = strings.TrimRight(prefix, "/")
if !strings.HasPrefix(prefix, "/") {
return nil, fmt.Errorf("boardwalk: prefix %q must start with /", prefix)
}
raw, err := fs.ReadFile(root, "index.html")
if err != nil {
return nil, fmt.Errorf("boardwalk: dist/index.html: %w", err)
}
index, err := RewriteIndex(raw, prefix)
if err != nil {
return nil, err
}
return &handler{root: root, prefix: prefix, index: index, notFoundAPI: notFoundAPI}, nil
}
// RewriteIndex points relative asset URLs at prefix and injects the prefix
// into the summer-admin-base meta. It fails when the token is absent, which
// catches a stale or hand-edited dist at boot.
func RewriteIndex(raw []byte, prefix string) ([]byte, error) {
if !bytes.Contains(raw, []byte(BaseToken)) {
return nil, errors.New("boardwalk: dist/index.html has no " + BaseToken + " token; rebuild the admin SPA")
}
escaped := html.EscapeString(prefix)
out := bytes.ReplaceAll(raw, []byte(`="./`), []byte(`="`+escaped+`/`))
out = bytes.ReplaceAll(out, []byte(BaseToken), []byte(escaped))
return out, nil
}
type handler struct {
root fs.FS
prefix string
index []byte
notFoundAPI http.Handler
}
func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
setSecurityHeaders(w.Header())
rel := strings.TrimPrefix(r.URL.Path, h.prefix)
rel = strings.TrimPrefix(rel, "/")
if rel == "api" || strings.HasPrefix(rel, "api/") {
h.notFoundAPI.ServeHTTP(w, r)
return
}
name := strings.TrimPrefix(path.Clean("/"+rel), "/")
if name == "" || name == "index.html" {
h.serveIndex(w, r)
return
}
if info, err := fs.Stat(h.root, name); err == nil {
if info.Mode().IsRegular() {
h.serveFile(w, r, name)
return
}
// A directory is never listed; it is treated as a client route.
h.serveIndex(w, r)
return
}
if path.Ext(name) != "" {
http.NotFound(w, r)
return
}
h.serveIndex(w, r)
}
func (h *handler) serveIndex(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
http.ServeContent(w, r, "index.html", time.Time{}, bytes.NewReader(h.index))
}
func (h *handler) serveFile(w http.ResponseWriter, r *http.Request, name string) {
body, err := fs.ReadFile(h.root, name)
if err != nil {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", contentType(name))
if strings.HasPrefix(name, "assets/") {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
} else {
w.Header().Set("Cache-Control", "no-cache")
}
http.ServeContent(w, r, path.Base(name), time.Time{}, bytes.NewReader(body))
}
func contentType(name string) string {
ext := strings.ToLower(path.Ext(name))
if ct, ok := contentTypes[ext]; ok {
return ct
}
if ct := mime.TypeByExtension(ext); ct != "" {
return ct
}
return "application/octet-stream"
}
func setSecurityHeaders(h http.Header) {
h.Set("X-Content-Type-Options", "nosniff")
h.Set("Referrer-Policy", "same-origin")
h.Set("X-Frame-Options", "DENY")
h.Set("Content-Security-Policy", contentSecurityPolicy)
h.Set("X-Robots-Tag", "noindex, nofollow")
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

15
boardwalk/dist/index.html vendored Normal file
View File

@@ -0,0 +1,15 @@
<!doctype html>
<html lang="pl">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="robots" content="noindex, nofollow" />
<meta name="summer-admin-base" content="__SUMMER_ADMIN_BASE__" />
<title>SummerCMS</title>
<script type="module" crossorigin src="./assets/index-ZNCn30hM.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-UTAit0wB.css">
</head>
<body>
<div id="app"></div>
</body>
</html>