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:
167
boardwalk/boardwalk.go
Normal file
167
boardwalk/boardwalk.go
Normal 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")
|
||||
}
|
||||
BIN
boardwalk/dist/assets/dm-mono-latin-400-normal--0xN8mdc.woff
vendored
Normal file
BIN
boardwalk/dist/assets/dm-mono-latin-400-normal--0xN8mdc.woff
vendored
Normal file
Binary file not shown.
BIN
boardwalk/dist/assets/dm-mono-latin-400-normal-4GdczIuU.woff2
vendored
Normal file
BIN
boardwalk/dist/assets/dm-mono-latin-400-normal-4GdczIuU.woff2
vendored
Normal file
Binary file not shown.
BIN
boardwalk/dist/assets/dm-mono-latin-500-normal-CN8Miw6E.woff
vendored
Normal file
BIN
boardwalk/dist/assets/dm-mono-latin-500-normal-CN8Miw6E.woff
vendored
Normal file
Binary file not shown.
BIN
boardwalk/dist/assets/dm-mono-latin-500-normal-DRMDZjhP.woff2
vendored
Normal file
BIN
boardwalk/dist/assets/dm-mono-latin-500-normal-DRMDZjhP.woff2
vendored
Normal file
Binary file not shown.
BIN
boardwalk/dist/assets/dm-mono-latin-ext-400-normal-1aZr6b2b.woff
vendored
Normal file
BIN
boardwalk/dist/assets/dm-mono-latin-ext-400-normal-1aZr6b2b.woff
vendored
Normal file
Binary file not shown.
BIN
boardwalk/dist/assets/dm-mono-latin-ext-400-normal-C2zvOubV.woff2
vendored
Normal file
BIN
boardwalk/dist/assets/dm-mono-latin-ext-400-normal-C2zvOubV.woff2
vendored
Normal file
Binary file not shown.
BIN
boardwalk/dist/assets/dm-mono-latin-ext-500-normal-BtRyHRi6.woff2
vendored
Normal file
BIN
boardwalk/dist/assets/dm-mono-latin-ext-500-normal-BtRyHRi6.woff2
vendored
Normal file
Binary file not shown.
BIN
boardwalk/dist/assets/dm-mono-latin-ext-500-normal-Dw3M13d8.woff
vendored
Normal file
BIN
boardwalk/dist/assets/dm-mono-latin-ext-500-normal-Dw3M13d8.woff
vendored
Normal file
Binary file not shown.
BIN
boardwalk/dist/assets/dm-sans-latin-400-normal-BwCSEQnW.woff
vendored
Normal file
BIN
boardwalk/dist/assets/dm-sans-latin-400-normal-BwCSEQnW.woff
vendored
Normal file
Binary file not shown.
BIN
boardwalk/dist/assets/dm-sans-latin-400-normal-CW0RaeGs.woff2
vendored
Normal file
BIN
boardwalk/dist/assets/dm-sans-latin-400-normal-CW0RaeGs.woff2
vendored
Normal file
Binary file not shown.
BIN
boardwalk/dist/assets/dm-sans-latin-500-normal-B9HHJjqV.woff2
vendored
Normal file
BIN
boardwalk/dist/assets/dm-sans-latin-500-normal-B9HHJjqV.woff2
vendored
Normal file
Binary file not shown.
BIN
boardwalk/dist/assets/dm-sans-latin-500-normal-Dr3UlScf.woff
vendored
Normal file
BIN
boardwalk/dist/assets/dm-sans-latin-500-normal-Dr3UlScf.woff
vendored
Normal file
Binary file not shown.
BIN
boardwalk/dist/assets/dm-sans-latin-600-normal-Aqo67rzb.woff2
vendored
Normal file
BIN
boardwalk/dist/assets/dm-sans-latin-600-normal-Aqo67rzb.woff2
vendored
Normal file
Binary file not shown.
BIN
boardwalk/dist/assets/dm-sans-latin-600-normal-BmdmIIQ2.woff
vendored
Normal file
BIN
boardwalk/dist/assets/dm-sans-latin-600-normal-BmdmIIQ2.woff
vendored
Normal file
Binary file not shown.
BIN
boardwalk/dist/assets/dm-sans-latin-700-normal-CUSSCpQX.woff
vendored
Normal file
BIN
boardwalk/dist/assets/dm-sans-latin-700-normal-CUSSCpQX.woff
vendored
Normal file
Binary file not shown.
BIN
boardwalk/dist/assets/dm-sans-latin-700-normal-DvUfVpUG.woff2
vendored
Normal file
BIN
boardwalk/dist/assets/dm-sans-latin-700-normal-DvUfVpUG.woff2
vendored
Normal file
Binary file not shown.
BIN
boardwalk/dist/assets/dm-sans-latin-ext-400-normal-BjWJ59Pq.woff
vendored
Normal file
BIN
boardwalk/dist/assets/dm-sans-latin-ext-400-normal-BjWJ59Pq.woff
vendored
Normal file
Binary file not shown.
BIN
boardwalk/dist/assets/dm-sans-latin-ext-400-normal-BtiwyxMk.woff2
vendored
Normal file
BIN
boardwalk/dist/assets/dm-sans-latin-ext-400-normal-BtiwyxMk.woff2
vendored
Normal file
Binary file not shown.
BIN
boardwalk/dist/assets/dm-sans-latin-ext-500-normal-BJfUCQsA.woff2
vendored
Normal file
BIN
boardwalk/dist/assets/dm-sans-latin-ext-500-normal-BJfUCQsA.woff2
vendored
Normal file
Binary file not shown.
BIN
boardwalk/dist/assets/dm-sans-latin-ext-500-normal-DR84L5F-.woff
vendored
Normal file
BIN
boardwalk/dist/assets/dm-sans-latin-ext-500-normal-DR84L5F-.woff
vendored
Normal file
Binary file not shown.
BIN
boardwalk/dist/assets/dm-sans-latin-ext-600-normal-4vooXBpG.woff2
vendored
Normal file
BIN
boardwalk/dist/assets/dm-sans-latin-ext-600-normal-4vooXBpG.woff2
vendored
Normal file
Binary file not shown.
BIN
boardwalk/dist/assets/dm-sans-latin-ext-600-normal-DRtaDpgU.woff
vendored
Normal file
BIN
boardwalk/dist/assets/dm-sans-latin-ext-600-normal-DRtaDpgU.woff
vendored
Normal file
Binary file not shown.
BIN
boardwalk/dist/assets/dm-sans-latin-ext-700-normal-BLI3TTWz.woff
vendored
Normal file
BIN
boardwalk/dist/assets/dm-sans-latin-ext-700-normal-BLI3TTWz.woff
vendored
Normal file
Binary file not shown.
BIN
boardwalk/dist/assets/dm-sans-latin-ext-700-normal-CJIcxD6K.woff2
vendored
Normal file
BIN
boardwalk/dist/assets/dm-sans-latin-ext-700-normal-CJIcxD6K.woff2
vendored
Normal file
Binary file not shown.
1
boardwalk/dist/assets/index-UTAit0wB.css
vendored
Normal file
1
boardwalk/dist/assets/index-UTAit0wB.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
boardwalk/dist/assets/index-ZNCn30hM.js
vendored
Normal file
1
boardwalk/dist/assets/index-ZNCn30hM.js
vendored
Normal file
File diff suppressed because one or more lines are too long
15
boardwalk/dist/index.html
vendored
Normal file
15
boardwalk/dist/index.html
vendored
Normal 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>
|
||||
Reference in New Issue
Block a user