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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user