formSchema now filters type: widget fields by the action's permissions, the same D-12 filtering listSchema applies to toolbarActions, so an admin without the action permission no longer gets a button that always answers 403, and the action name is not revealed. The filtered fields are a new slice, so the cached schema is never modified.
820 lines
24 KiB
Go
820 lines
24 KiB
Go
package cabana
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"reflect"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
|
"git.golem15.com/golem15/summercms/modules/boardwalk"
|
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
|
"git.golem15.com/golem15/summercms/modules/pact"
|
|
"git.golem15.com/golem15/summercms/modules/party"
|
|
"git.golem15.com/golem15/summercms/modules/phrasebook"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// 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 {
|
|
app *backpack.App
|
|
reg *Registry
|
|
secret string
|
|
ttl time.Duration
|
|
refreshTTL time.Duration
|
|
grace time.Duration
|
|
bcryptCost int
|
|
loginMax int
|
|
loginDecay int
|
|
issuer string
|
|
bl bouncer.BlacklistStore
|
|
users bouncer.UserProvider // the backend guard's provider, reused by refresh
|
|
prefix string
|
|
spa http.Handler
|
|
// insecureCookie drops Secure from the admin cookie (backend.cookie_secure
|
|
// false, development only); the zero value keeps the cookie Secure.
|
|
insecureCookie bool
|
|
}
|
|
|
|
// 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
|
|
// admin.jwt.secret. No controllers means no admin routes and no secret check.
|
|
func Activate(app *backpack.App, plugins []party.Plugin) (*Routes, error) {
|
|
items, err := collectControllers(plugins)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(items) == 0 {
|
|
return nil, nil
|
|
}
|
|
if err := checkReservedSegments(items); err != nil {
|
|
return nil, err
|
|
}
|
|
secret, err := adminSecret(app)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
prefix, err := AdminPrefix(app)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
secureCookie, err := adminCookieSecure(app)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
reg, err := compileRegistry(items)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := compileContributions(reg, plugins); err != nil {
|
|
return nil, err
|
|
}
|
|
if app == nil {
|
|
return nil, errors.New("cabana: app is nil")
|
|
}
|
|
if tr, ok := app.Lookup[*phrasebook.Translator](); ok && tr != nil {
|
|
if err := validateMessageKeys(reg, tr); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
guards, ok := app.Lookup[*bouncer.Registry]()
|
|
if !ok || guards == nil {
|
|
guards = bouncer.NewRegistry()
|
|
if err := app.Publish(guards); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
bl := adminBlacklist(app)
|
|
users := lazyBackendUsers{app: app, reg: reg}
|
|
guard := bouncer.NewBackendJWTGuard(secret, users, bl, writeUnauthenticated, AdminCookieName)
|
|
if _, err := guards.Middleware("backend"); err != nil {
|
|
if err := guards.Register("summercms.cabana", "backend", guard); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
mw, err := guards.Middleware("backend")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
loginMax, loginDecay := adminLoginWindow(app)
|
|
svc := &service{
|
|
app: app,
|
|
reg: reg,
|
|
secret: secret,
|
|
ttl: adminTTL(app),
|
|
refreshTTL: adminRefreshTTL(app),
|
|
grace: adminGrace(app),
|
|
bcryptCost: adminBcryptCost(app),
|
|
loginMax: loginMax,
|
|
loginDecay: loginDecay,
|
|
issuer: adminIssuer(app, prefix),
|
|
bl: bl,
|
|
users: users,
|
|
prefix: prefix,
|
|
|
|
insecureCookie: !secureCookie,
|
|
}
|
|
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) {
|
|
WriteError(w, http.StatusUnauthorized, "unauthenticated", msgUnauthenticated)
|
|
}
|
|
|
|
type lazyBackendUsers struct {
|
|
app *backpack.App
|
|
reg *Registry
|
|
}
|
|
|
|
func (p lazyBackendUsers) FindByID(ctx context.Context, id uint) (*bouncer.Principal, error) {
|
|
if p.app == nil {
|
|
return nil, errors.New("cabana: database is not configured")
|
|
}
|
|
db, ok := p.app.Lookup[*gorm.DB]()
|
|
if !ok || db == nil {
|
|
return nil, errors.New("cabana: database is not configured")
|
|
}
|
|
return (BackendUsers{DB: db, Registry: p.reg}).FindByID(ctx, id)
|
|
}
|
|
|
|
func (s *service) mount(r pact.Router) {
|
|
throttle := fmt.Sprintf("throttle:%d,%d", s.loginMax, s.loginDecay)
|
|
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", requireAjax(s.refresh))
|
|
})
|
|
// The string bundle is public: the login screen needs it before auth.
|
|
r.GroupRaw(api, nil, func(g pact.Router) {
|
|
g.Get("/lang", s.langBundle)
|
|
})
|
|
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)
|
|
g.Get("/settings/{code}/schema", s.settingsSchema)
|
|
constrainSetting(g)
|
|
g.Get("/settings/{code}", s.settingsGet)
|
|
constrainSetting(g)
|
|
g.Put("/settings/{code}", requireAjax(s.settingsPut))
|
|
constrainSetting(g)
|
|
g.Get("/{vendor}/{plugin}/{controller}/schema/list", s.listSchema)
|
|
constrainController(g)
|
|
g.Get("/{vendor}/{plugin}/{controller}/schema/form", s.formSchema)
|
|
constrainController(g)
|
|
g.Get("/{vendor}/{plugin}/{controller}/schema/relation/{name}", s.relationSchema)
|
|
constrainRelation(g)
|
|
g.Get("/{vendor}/{plugin}/{controller}", s.list)
|
|
constrainController(g)
|
|
g.Post("/{vendor}/{plugin}/{controller}", requireAjax(s.create))
|
|
constrainController(g)
|
|
g.Post("/{vendor}/{plugin}/{controller}/bulk-delete", requireAjax(s.bulkDelete))
|
|
constrainController(g)
|
|
// Runtime extension actions (Phase 10.1): cabana owns these routes, so
|
|
// CSRF, auth and record scoping never depend on plugin code.
|
|
g.Post("/{vendor}/{plugin}/{controller}/widgets/{field}", requireAjax(s.widgetAction))
|
|
constrainController(g)
|
|
g.Where("field", "[A-Za-z_][A-Za-z0-9_]*")
|
|
g.Post("/{vendor}/{plugin}/{controller}/toolbar/{action}", requireAjax(s.toolbarAction))
|
|
constrainController(g)
|
|
g.Where("action", "[A-Za-z_][A-Za-z0-9_]*")
|
|
g.Get("/{vendor}/{plugin}/{controller}/partials/{name}", s.partial)
|
|
constrainRelation(g)
|
|
g.Get("/{vendor}/{plugin}/{controller}/{id}", s.show)
|
|
constrainController(g)
|
|
g.Put("/{vendor}/{plugin}/{controller}/{id}", requireAjax(s.update))
|
|
constrainController(g)
|
|
g.Delete("/{vendor}/{plugin}/{controller}/{id}", requireAjax(s.deleteRecord))
|
|
constrainController(g)
|
|
// Six-segment GET routes share one pattern: ServeMux rejects the
|
|
// relation list next to the field options route (neither is more
|
|
// specific), so nestedGet dispatches on the literal segments.
|
|
g.Get("/{vendor}/{plugin}/{controller}/{id}/{segment}/{name}", s.nestedGet)
|
|
constrainNested(g)
|
|
g.Get("/{vendor}/{plugin}/{controller}/{id}/relations/{name}/candidates", s.relationCandidates)
|
|
constrainRelation(g)
|
|
g.Post("/{vendor}/{plugin}/{controller}/{id}/relations/{name}/link", requireAjax(s.relationLink))
|
|
constrainRelation(g)
|
|
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) {
|
|
// Declared plugin JS and CSS (D-16); a miss falls through to the SPA,
|
|
// which serves its own dist assets under the same /assets/ path.
|
|
g.Get("/assets/{vendor}/{plugin}/{file...}", s.pluginAsset)
|
|
g.Where("vendor", "[A-Za-z0-9_-]+")
|
|
g.Where("plugin", "[A-Za-z0-9_-]+")
|
|
g.Get("", s.serveSPA)
|
|
g.Get("/{path...}", s.serveSPA)
|
|
})
|
|
}
|
|
|
|
func constrainController(g pact.Router) {
|
|
g.Where("vendor", "[A-Za-z0-9_-]+")
|
|
g.Where("plugin", "[A-Za-z0-9_-]+")
|
|
g.Where("controller", "[A-Za-z0-9_-]+")
|
|
}
|
|
|
|
func constrainRelation(g pact.Router) {
|
|
constrainController(g)
|
|
g.Where("name", "[A-Za-z_][A-Za-z0-9_]*")
|
|
}
|
|
|
|
func constrainNested(g pact.Router) {
|
|
constrainController(g)
|
|
g.Where("segment", "[A-Za-z_][A-Za-z0-9_]*")
|
|
g.Where("name", "[A-Za-z_][A-Za-z0-9_]*")
|
|
}
|
|
|
|
// nestedGet serves the logical routes
|
|
//
|
|
// GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}
|
|
// GET /{vendor}/{plugin}/{controller}/fields/{field}/options
|
|
// GET /{vendor}/{plugin}/{controller}/filters/{scope}/options
|
|
//
|
|
// A numeric id never equals a literal segment, so the dispatch is unambiguous.
|
|
// Anything else is the D-10 not_found envelope, as an unmatched API path.
|
|
func (s *service) nestedGet(w http.ResponseWriter, r *http.Request) {
|
|
id, segment, name := r.PathValue("id"), r.PathValue("segment"), r.PathValue("name")
|
|
switch {
|
|
case id == "fields" && name == "options":
|
|
r.SetPathValue("field", segment)
|
|
s.fieldOptions(w, r)
|
|
case id == "filters" && name == "options":
|
|
r.SetPathValue("scope", segment)
|
|
s.filterOptions(w, r)
|
|
case segment == "relations":
|
|
s.relationLinked(w, r)
|
|
default:
|
|
writeNotFound(w, r)
|
|
}
|
|
}
|
|
|
|
func constrainSetting(g pact.Router) {
|
|
g.Where("code", "[A-Za-z_][A-Za-z0-9_-]*")
|
|
}
|
|
|
|
func (s *service) navigation(w http.ResponseWriter, r *http.Request) {
|
|
principal, ok := bouncer.User(r.Context())
|
|
if !ok || principal == nil || !principal.Backend {
|
|
WriteError(w, http.StatusUnauthorized, "unauthenticated", msgUnauthenticated)
|
|
return
|
|
}
|
|
navigation, _ := s.reg.Metadata(r.Context(), principal, s.translator())
|
|
WriteData(w, http.StatusOK, navigation, map[string]any{"locale": schemaLocale(r.Context(), s.translator())})
|
|
}
|
|
|
|
func (s *service) settingsList(w http.ResponseWriter, r *http.Request) {
|
|
principal, ok := bouncer.User(r.Context())
|
|
if !ok || principal == nil || !principal.Backend {
|
|
WriteError(w, http.StatusUnauthorized, "unauthenticated", msgUnauthenticated)
|
|
return
|
|
}
|
|
_, settings := s.reg.Metadata(r.Context(), principal, s.translator())
|
|
WriteData(w, http.StatusOK, settings, map[string]any{"locale": schemaLocale(r.Context(), s.translator())})
|
|
}
|
|
|
|
func (s *service) settingsSchema(w http.ResponseWriter, r *http.Request) {
|
|
s.protectSetting(w, r, func(setting *CompiledSetting) {
|
|
view, err := setting.Form.Localize(r.Context(), s.translator(), nil)
|
|
if err != nil {
|
|
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
|
return
|
|
}
|
|
WriteData(w, http.StatusOK, view, map[string]any{"locale": view.Meta.Locale})
|
|
})
|
|
}
|
|
|
|
func (s *service) settingsGet(w http.ResponseWriter, r *http.Request) {
|
|
s.protectSetting(w, r, func(setting *CompiledSetting) {
|
|
db, err := s.db()
|
|
if err != nil {
|
|
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
|
return
|
|
}
|
|
result, err := (SettingsService{DB: db}).Get(r.Context(), setting)
|
|
if err != nil {
|
|
writeCRUDError(w, err)
|
|
return
|
|
}
|
|
WriteData(w, http.StatusOK, result, nil)
|
|
})
|
|
}
|
|
|
|
func (s *service) settingsPut(w http.ResponseWriter, r *http.Request) {
|
|
s.protectSetting(w, r, func(setting *CompiledSetting) {
|
|
body, err := decodeObject(r)
|
|
if err != nil {
|
|
writeCRUDError(w, err)
|
|
return
|
|
}
|
|
db, err := s.db()
|
|
if err != nil {
|
|
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
|
return
|
|
}
|
|
result, err := (SettingsService{DB: db}).Put(r.Context(), setting, body)
|
|
if err != nil {
|
|
writeCRUDError(w, err)
|
|
return
|
|
}
|
|
WriteData(w, http.StatusOK, result, nil)
|
|
})
|
|
}
|
|
|
|
func (s *service) protectSetting(w http.ResponseWriter, r *http.Request, fn func(*CompiledSetting)) {
|
|
principal, ok := bouncer.User(r.Context())
|
|
if !ok || principal == nil || !principal.Backend {
|
|
WriteError(w, http.StatusUnauthorized, "unauthenticated", msgUnauthenticated)
|
|
return
|
|
}
|
|
setting, exists := s.reg.Setting(r.PathValue("code"))
|
|
if !exists {
|
|
WriteError(w, http.StatusNotFound, "not_found", msgNotFound)
|
|
return
|
|
}
|
|
if !Allows(principal, setting.Item.Permissions) {
|
|
WriteError(w, http.StatusForbidden, "forbidden", msgForbidden)
|
|
return
|
|
}
|
|
fn(setting)
|
|
}
|
|
|
|
func (s *service) relationSchema(w http.ResponseWriter, r *http.Request) {
|
|
s.protect(w, r, func(cc *CompiledController) {
|
|
cr, err := relationOf(cc, r.PathValue("name"))
|
|
if err != nil {
|
|
writeCRUDError(w, err)
|
|
return
|
|
}
|
|
tr := s.translator()
|
|
view := cr.Schema.Localize(r.Context(), tr)
|
|
meta := map[string]any{}
|
|
if locale := schemaLocale(r.Context(), tr); locale != "" {
|
|
meta["locale"] = locale
|
|
}
|
|
WriteData(w, http.StatusOK, view, meta)
|
|
})
|
|
}
|
|
|
|
func relationQueryFromRequest(r *http.Request) RelationQuery {
|
|
q := r.URL.Query()
|
|
return RelationQuery{Search: q.Get("search"), Sort: q.Get("sort"), Dir: q.Get("dir"), Page: q.Get("page"), PerPage: q.Get("per_page")}
|
|
}
|
|
|
|
func (s *service) relationLinked(w http.ResponseWriter, r *http.Request) {
|
|
s.relationList(w, r, false)
|
|
}
|
|
|
|
func (s *service) relationCandidates(w http.ResponseWriter, r *http.Request) {
|
|
s.relationList(w, r, true)
|
|
}
|
|
|
|
func (s *service) relationList(w http.ResponseWriter, r *http.Request, candidates bool) {
|
|
s.protect(w, r, func(cc *CompiledController) {
|
|
id, err := pathID(r)
|
|
if err != nil {
|
|
writeCRUDError(w, err)
|
|
return
|
|
}
|
|
svc, err := s.relations()
|
|
if err != nil {
|
|
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
|
return
|
|
}
|
|
var result *RelationResult
|
|
if candidates {
|
|
result, err = svc.Candidates(r.Context(), cc, r.PathValue("name"), id, relationQueryFromRequest(r))
|
|
} else {
|
|
result, err = svc.Linked(r.Context(), cc, r.PathValue("name"), id, relationQueryFromRequest(r))
|
|
}
|
|
if err != nil {
|
|
writeCRUDError(w, err)
|
|
return
|
|
}
|
|
WriteData(w, http.StatusOK, result.Data, map[string]any{"page": result.Meta.Page, "per_page": result.Meta.PerPage, "total": result.Meta.Total, "last_page": result.Meta.LastPage})
|
|
})
|
|
}
|
|
|
|
func (s *service) relationLink(w http.ResponseWriter, r *http.Request) {
|
|
s.relationMutation(w, r, true)
|
|
}
|
|
|
|
func (s *service) relationUnlink(w http.ResponseWriter, r *http.Request) {
|
|
s.relationMutation(w, r, false)
|
|
}
|
|
|
|
func (s *service) relationMutation(w http.ResponseWriter, r *http.Request, link bool) {
|
|
s.protect(w, r, func(cc *CompiledController) {
|
|
id, err := pathID(r)
|
|
if err != nil {
|
|
writeCRUDError(w, err)
|
|
return
|
|
}
|
|
in, err := decodeRelationMutation(r)
|
|
if err != nil {
|
|
writeCRUDError(w, err)
|
|
return
|
|
}
|
|
svc, err := s.relations()
|
|
if err != nil {
|
|
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
|
return
|
|
}
|
|
var result RelationMutationResult
|
|
if link {
|
|
result, err = svc.Link(r.Context(), cc, r.PathValue("name"), id, in)
|
|
} else {
|
|
result, err = svc.Unlink(r.Context(), cc, r.PathValue("name"), id, in)
|
|
}
|
|
if err != nil {
|
|
writeCRUDError(w, err)
|
|
return
|
|
}
|
|
WriteData(w, http.StatusOK, result, nil)
|
|
})
|
|
}
|
|
|
|
func decodeRelationMutation(r *http.Request) (RelationMutationInput, error) {
|
|
dec := json.NewDecoder(r.Body)
|
|
dec.UseNumber()
|
|
dec.DisallowUnknownFields()
|
|
var in RelationMutationInput
|
|
if err := dec.Decode(&in); err != nil {
|
|
return RelationMutationInput{}, relationInvalid("body", "The request body is invalid.")
|
|
}
|
|
var trailing any
|
|
if err := dec.Decode(&trailing); err != io.EOF {
|
|
return RelationMutationInput{}, relationInvalid("body", "The request body is invalid.")
|
|
}
|
|
return in, nil
|
|
}
|
|
|
|
func (s *service) relations() (RelationService, error) {
|
|
db, err := s.db()
|
|
if err != nil {
|
|
return RelationService{}, err
|
|
}
|
|
return RelationService{DB: db}, nil
|
|
}
|
|
|
|
func (s *service) formSchema(w http.ResponseWriter, r *http.Request) {
|
|
s.protect(w, r, func(cc *CompiledController) {
|
|
if cc.Form == nil {
|
|
WriteError(w, http.StatusNotFound, "not_found", msgNotFound)
|
|
return
|
|
}
|
|
view, err := cc.Form.Localize(r.Context(), s.translator(), dropdownProvider(cc.Controller))
|
|
if err != nil {
|
|
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
|
return
|
|
}
|
|
// Like toolbarActions in the list schema (D-12), a widget whose
|
|
// action this admin may not run is not offered. A new slice: the
|
|
// localized view must never share its backing array with the cache.
|
|
principal, _ := bouncer.User(r.Context())
|
|
kept := make([]FormField, 0, len(view.Fields))
|
|
for _, field := range view.Fields {
|
|
if field.Type == "widget" {
|
|
action, ok := cc.Actions[field.Action]
|
|
if !ok || !Allows(principal, action.Permissions) {
|
|
continue
|
|
}
|
|
}
|
|
kept = append(kept, field)
|
|
}
|
|
view.Fields = kept
|
|
view.Assets = s.controllerAssets(cc)
|
|
meta := map[string]any{}
|
|
if view.Meta.Locale != "" {
|
|
meta["locale"] = view.Meta.Locale
|
|
}
|
|
WriteData(w, http.StatusOK, view, meta)
|
|
})
|
|
}
|
|
|
|
func (s *service) listSchema(w http.ResponseWriter, r *http.Request) {
|
|
s.protect(w, r, func(cc *CompiledController) {
|
|
schema := cc.List
|
|
if schema == nil {
|
|
schema = &ListSchema{}
|
|
}
|
|
view, err := schema.Localize(r.Context(), s.translator())
|
|
if err != nil {
|
|
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
|
return
|
|
}
|
|
// Only the registered toolbar actions this admin may run are offered.
|
|
principal, _ := bouncer.User(r.Context())
|
|
allowed := make([]ToolbarAction, 0, len(view.ToolbarActions))
|
|
for _, action := range view.ToolbarActions {
|
|
if registered, ok := cc.Actions[action.Name]; ok && Allows(principal, registered.Permissions) {
|
|
allowed = append(allowed, action)
|
|
}
|
|
}
|
|
view.ToolbarActions = allowed
|
|
view.Assets = s.controllerAssets(cc)
|
|
meta := map[string]any{}
|
|
if view.Meta != nil {
|
|
meta["locale"] = view.Meta.Locale
|
|
}
|
|
WriteData(w, http.StatusOK, view, meta)
|
|
})
|
|
}
|
|
|
|
func (s *service) translator() *phrasebook.Translator {
|
|
if s == nil || s.app == nil {
|
|
return nil
|
|
}
|
|
tr, ok := s.app.Lookup[*phrasebook.Translator]()
|
|
if !ok {
|
|
return nil
|
|
}
|
|
return tr
|
|
}
|
|
|
|
func (s *service) show(w http.ResponseWriter, r *http.Request) {
|
|
s.protect(w, r, func(cc *CompiledController) {
|
|
id, err := pathID(r)
|
|
if err != nil {
|
|
writeCRUDError(w, err)
|
|
return
|
|
}
|
|
svc, err := s.crud()
|
|
if err != nil {
|
|
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
|
return
|
|
}
|
|
rec, err := svc.ShowRecord(r.Context(), cc, id)
|
|
if err != nil {
|
|
writeCRUDError(w, err)
|
|
return
|
|
}
|
|
WriteData(w, http.StatusOK, rec.Data, rec.Meta)
|
|
})
|
|
}
|
|
|
|
func (s *service) create(w http.ResponseWriter, r *http.Request) {
|
|
s.protect(w, r, func(cc *CompiledController) {
|
|
body, err := decodeObject(r)
|
|
if err != nil {
|
|
writeCRUDError(w, err)
|
|
return
|
|
}
|
|
svc, err := s.crud()
|
|
if err != nil {
|
|
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
|
return
|
|
}
|
|
rec, err := svc.CreateRecord(r.Context(), cc, RecordInput{Body: body})
|
|
if err != nil {
|
|
writeCRUDError(w, err)
|
|
return
|
|
}
|
|
WriteData(w, http.StatusCreated, rec.Data, rec.Meta)
|
|
})
|
|
}
|
|
|
|
func (s *service) update(w http.ResponseWriter, r *http.Request) {
|
|
s.protect(w, r, func(cc *CompiledController) {
|
|
id, err := pathID(r)
|
|
if err != nil {
|
|
writeCRUDError(w, err)
|
|
return
|
|
}
|
|
body, err := decodeObject(r)
|
|
if err != nil {
|
|
writeCRUDError(w, err)
|
|
return
|
|
}
|
|
svc, err := s.crud()
|
|
if err != nil {
|
|
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
|
return
|
|
}
|
|
rec, err := svc.UpdateRecord(r.Context(), cc, id, RecordInput{Body: body})
|
|
if err != nil {
|
|
writeCRUDError(w, err)
|
|
return
|
|
}
|
|
WriteData(w, http.StatusOK, rec.Data, rec.Meta)
|
|
})
|
|
}
|
|
|
|
func (s *service) bulkDelete(w http.ResponseWriter, r *http.Request) {
|
|
s.protect(w, r, func(cc *CompiledController) {
|
|
in, err := decodeBulk(r)
|
|
if err != nil {
|
|
writeCRUDError(w, err)
|
|
return
|
|
}
|
|
svc, err := s.crud()
|
|
if err != nil {
|
|
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
|
return
|
|
}
|
|
result, err := svc.BulkDelete(r.Context(), cc, in)
|
|
if err != nil {
|
|
writeCRUDError(w, err)
|
|
return
|
|
}
|
|
WriteData(w, http.StatusOK, result, nil)
|
|
})
|
|
}
|
|
|
|
func decodeBulk(r *http.Request) (BulkDeleteInput, error) {
|
|
dec := json.NewDecoder(r.Body)
|
|
dec.UseNumber()
|
|
var in BulkDeleteInput
|
|
if err := dec.Decode(&in); err != nil {
|
|
return BulkDeleteInput{}, &ValidationError{Details: map[string]any{"body": []string{"The request body is invalid."}}}
|
|
}
|
|
return in, nil
|
|
}
|
|
|
|
func (s *service) deleteRecord(w http.ResponseWriter, r *http.Request) {
|
|
s.protect(w, r, func(cc *CompiledController) {
|
|
id, err := pathID(r)
|
|
if err != nil {
|
|
writeCRUDError(w, err)
|
|
return
|
|
}
|
|
svc, err := s.crud()
|
|
if err != nil {
|
|
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
|
return
|
|
}
|
|
result, err := svc.Delete(r.Context(), cc, id)
|
|
if err != nil {
|
|
writeCRUDError(w, err)
|
|
return
|
|
}
|
|
WriteData(w, http.StatusOK, result, nil)
|
|
})
|
|
}
|
|
|
|
func (s *service) crud() (CRUDService, error) {
|
|
db, err := s.db()
|
|
if err != nil {
|
|
return CRUDService{}, err
|
|
}
|
|
return CRUDService{DB: db}, nil
|
|
}
|
|
|
|
func (s *service) list(w http.ResponseWriter, r *http.Request) {
|
|
s.protect(w, r, func(cc *CompiledController) {
|
|
db, err := s.db()
|
|
if err != nil {
|
|
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
|
return
|
|
}
|
|
result, err := ExecuteList(r.Context(), db, cc, listQueryFromRequest(r))
|
|
var invalid *ListValidationError
|
|
if errors.As(err, &invalid) {
|
|
WriteErrorDetails(w, http.StatusUnprocessableEntity, "validation_failed", "Validation failed", invalid.Details)
|
|
return
|
|
}
|
|
if err != nil {
|
|
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
|
return
|
|
}
|
|
WriteData(w, http.StatusOK, result.Data, map[string]any{
|
|
"page": result.Meta.Page,
|
|
"per_page": result.Meta.PerPage,
|
|
"total": result.Meta.Total,
|
|
"last_page": result.Meta.LastPage,
|
|
})
|
|
})
|
|
}
|
|
|
|
// protect runs after the backend guard. Controller lookup precedes permission
|
|
// evaluation, and schema/SQL run only inside fn.
|
|
func (s *service) protect(w http.ResponseWriter, r *http.Request, fn func(*CompiledController)) {
|
|
id := r.PathValue("vendor") + "." + r.PathValue("plugin") + "." + r.PathValue("controller")
|
|
cc, ok := s.reg.Get(id)
|
|
if !ok {
|
|
WriteError(w, http.StatusNotFound, "not_found", msgNotFound)
|
|
return
|
|
}
|
|
principal, _ := bouncer.User(r.Context())
|
|
if principal == nil || !principal.Backend {
|
|
WriteError(w, http.StatusUnauthorized, "unauthenticated", msgUnauthenticated)
|
|
return
|
|
}
|
|
if !Allows(principal, requiredOf(cc.Controller)) {
|
|
s.logAuth(r, "denied", principal.ID)
|
|
WriteError(w, http.StatusForbidden, "forbidden", msgForbidden)
|
|
return
|
|
}
|
|
fn(cc)
|
|
}
|
|
|
|
func projectRow(row any, controller pact.AdminController, cols []ListColumn) map[string]any {
|
|
v := reflect.ValueOf(row)
|
|
for v.Kind() == reflect.Pointer {
|
|
if v.IsNil() {
|
|
return map[string]any{}
|
|
}
|
|
v = v.Elem()
|
|
}
|
|
out := make(map[string]any, len(cols)+1)
|
|
if id := fieldByColumn(v, "id"); id.IsValid() && id.CanInterface() {
|
|
out["id"] = id.Interface()
|
|
}
|
|
for _, col := range cols {
|
|
if col.Relation != "" {
|
|
if value, ok := relatedSelect(v, controller, col); ok {
|
|
out[col.Key] = value
|
|
}
|
|
continue
|
|
}
|
|
field := fieldByColumn(v, col.Key)
|
|
if !field.IsValid() || !field.CanInterface() {
|
|
continue
|
|
}
|
|
out[col.Key] = field.Interface()
|
|
}
|
|
return out
|
|
}
|
|
|
|
func fieldByColumn(v reflect.Value, column string) reflect.Value {
|
|
if v.Kind() != reflect.Struct {
|
|
return reflect.Value{}
|
|
}
|
|
t := v.Type()
|
|
for i := 0; i < t.NumField(); i++ {
|
|
field := t.Field(i)
|
|
if field.PkgPath != "" {
|
|
continue
|
|
}
|
|
if gormColumn(field) == column || strings.EqualFold(field.Name, column) {
|
|
return v.Field(i)
|
|
}
|
|
}
|
|
return reflect.Value{}
|
|
}
|
|
|
|
func gormColumn(field reflect.StructField) string {
|
|
for _, part := range strings.Split(field.Tag.Get("gorm"), ";") {
|
|
part = strings.TrimSpace(part)
|
|
if name, ok := strings.CutPrefix(part, "column:"); ok {
|
|
return name
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func uitoa(id uint) string {
|
|
return strconv.FormatUint(uint64(id), 10)
|
|
}
|