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

View File

@@ -13,6 +13,7 @@ import (
"time"
"git.golem15.com/golem15/summercms/backpack"
"git.golem15.com/golem15/summercms/boardwalk"
"git.golem15.com/golem15/summercms/bouncer"
"git.golem15.com/golem15/summercms/pact"
"git.golem15.com/golem15/summercms/party"
@@ -20,10 +21,12 @@ import (
"gorm.io/gorm"
)
// Routes is the raw admin API mounted by surf.BuildRouter.
// Routes is the raw admin API and SPA mounted by surf.BuildRouter. Prefix is
// the normalized backend.uri every admin route lives under.
type Routes struct {
Middleware pact.Middleware
Mount func(r pact.Router)
Prefix string
}
type service struct {
@@ -38,6 +41,21 @@ type service struct {
loginDecay int
issuer string
bl bouncer.BlacklistStore
prefix string
spa http.Handler
}
// adminPrefix returns the mount path; a zero service uses the default.
func (s *service) adminPrefix() string {
if s == nil || s.prefix == "" {
return DefaultAdminPrefix
}
return s.prefix
}
// apiBase is the admin API root: the prefix plus /api/v1 (D-03).
func (s *service) apiBase() string {
return s.adminPrefix() + adminAPIVersion
}
// Activate compiles admin controllers and, when any exist, requires
@@ -54,6 +72,10 @@ func Activate(app *backpack.App, plugins []party.Plugin) (*Routes, error) {
if err != nil {
return nil, err
}
prefix, err := AdminPrefix(app)
if err != nil {
return nil, err
}
reg, err := compileRegistry(items)
if err != nil {
return nil, err
@@ -72,7 +94,7 @@ func Activate(app *backpack.App, plugins []party.Plugin) (*Routes, error) {
}
}
bl := adminBlacklist(app)
guard := bouncer.NewBackendJWTGuard(secret, lazyBackendUsers{app: app, reg: reg}, bl, writeUnauthenticated)
guard := bouncer.NewBackendJWTGuard(secret, lazyBackendUsers{app: app, reg: reg}, bl, writeUnauthenticated, AdminCookieName)
if _, err := guards.Middleware("backend"); err != nil {
if err := guards.Register("summercms.cabana", "backend", guard); err != nil {
return nil, err
@@ -93,10 +115,31 @@ func Activate(app *backpack.App, plugins []party.Plugin) (*Routes, error) {
bcryptCost: adminBcryptCost(app),
loginMax: loginMax,
loginDecay: loginDecay,
issuer: adminIssuer(app),
issuer: adminIssuer(app, prefix),
bl: bl,
prefix: prefix,
}
return &Routes{Middleware: mw, Mount: svc.mount}, nil
spa, err := boardwalk.Handler(prefix, http.HandlerFunc(writeNotFound))
if err != nil {
return nil, fmt.Errorf("cabana: admin SPA: %w", err)
}
svc.spa = spa
return &Routes{Middleware: mw, Mount: svc.mount, Prefix: prefix}, nil
}
func writeNotFound(w http.ResponseWriter, _ *http.Request) {
WriteError(w, http.StatusNotFound, "not_found", msgNotFound)
}
// serveSPA answers GET {prefix} and GET {prefix}/{path...} from the embedded
// build. API paths that no route matched fall through to it and receive the
// D-10 not_found envelope, never index.html.
func (s *service) serveSPA(w http.ResponseWriter, r *http.Request) {
if s == nil || s.spa == nil {
writeNotFound(w, r)
return
}
s.spa.ServeHTTP(w, r)
}
func writeUnauthenticated(w http.ResponseWriter, _ error) {
@@ -121,12 +164,15 @@ func (p lazyBackendUsers) FindByID(ctx context.Context, id uint) (*bouncer.Princ
func (s *service) mount(r pact.Router) {
throttle := fmt.Sprintf("throttle:%d,%d", s.loginMax, s.loginDecay)
r.GroupRaw("/_admin/api/v1/auth", nil, func(g pact.Router) {
api := s.apiBase()
r.GroupRaw(api+"/auth", nil, func(g pact.Router) {
// Login is exempt from the CSRF header: without it the response is a
// Bearer body and no cookie is set, so a cross-site post gains nothing.
g.Post("/login", s.login, throttle)
g.Post("/refresh", s.refresh)
g.Post("/refresh", requireAjax(s.refresh))
})
r.GroupRaw("/_admin/api/v1", []string{"backend"}, func(g pact.Router) {
g.Post("/auth/logout", s.logout)
r.GroupRaw(api, []string{"backend"}, func(g pact.Router) {
g.Post("/auth/logout", requireAjax(s.logout))
g.Get("/auth/me", s.me)
g.Get("/navigation", s.navigation)
g.Get("/settings", s.settingsList)
@@ -134,7 +180,7 @@ func (s *service) mount(r pact.Router) {
constrainSetting(g)
g.Get("/settings/{code}", s.settingsGet)
constrainSetting(g)
g.Put("/settings/{code}", s.settingsPut)
g.Put("/settings/{code}", requireAjax(s.settingsPut))
constrainSetting(g)
g.Get("/{vendor}/{plugin}/{controller}/schema/list", s.listSchema)
constrainController(g)
@@ -144,25 +190,31 @@ func (s *service) mount(r pact.Router) {
constrainRelation(g)
g.Get("/{vendor}/{plugin}/{controller}", s.list)
constrainController(g)
g.Post("/{vendor}/{plugin}/{controller}", s.create)
g.Post("/{vendor}/{plugin}/{controller}", requireAjax(s.create))
constrainController(g)
g.Post("/{vendor}/{plugin}/{controller}/bulk-delete", s.bulkDelete)
g.Post("/{vendor}/{plugin}/{controller}/bulk-delete", requireAjax(s.bulkDelete))
constrainController(g)
g.Get("/{vendor}/{plugin}/{controller}/{id}", s.show)
constrainController(g)
g.Put("/{vendor}/{plugin}/{controller}/{id}", s.update)
g.Put("/{vendor}/{plugin}/{controller}/{id}", requireAjax(s.update))
constrainController(g)
g.Delete("/{vendor}/{plugin}/{controller}/{id}", s.deleteRecord)
g.Delete("/{vendor}/{plugin}/{controller}/{id}", requireAjax(s.deleteRecord))
constrainController(g)
g.Get("/{vendor}/{plugin}/{controller}/{id}/relations/{name}", s.relationLinked)
constrainRelation(g)
g.Get("/{vendor}/{plugin}/{controller}/{id}/relations/{name}/candidates", s.relationCandidates)
constrainRelation(g)
g.Post("/{vendor}/{plugin}/{controller}/{id}/relations/{name}/link", s.relationLink)
g.Post("/{vendor}/{plugin}/{controller}/{id}/relations/{name}/link", requireAjax(s.relationLink))
constrainRelation(g)
g.Post("/{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink", s.relationUnlink)
g.Post("/{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink", requireAjax(s.relationUnlink))
constrainRelation(g)
})
// The SPA shell: public, no guard. ServeMux prefers every API pattern
// above over the {path...} wildcard.
r.GroupRaw(s.adminPrefix(), nil, func(g pact.Router) {
g.Get("", s.serveSPA)
g.Get("/{path...}", s.serveSPA)
})
}
func constrainController(g pact.Router) {