- FieldRelationContract/FieldRelationProvider bind every type: relation
field to a belongsTo foreign key or a belongsToMany pivot; activation
fails naming plugin, controller and field on a missing or broken contract
- GET /{vendor}/{plugin}/{controller}/fields/{field}/options serves
{value, label} pages scoped by pact.RelationExtendOptionsQuery, behind
the controller permission; read-only and non-relation fields are 404
- Saves apply present relation keys after the Before hook: ids are
revalidated through the same scoped query (422 and full rollback
otherwise), belongsTo sets the foreign key, belongsToMany replaces pivot
rows in submitted order with the order column set to the index
- Show, create and update return relation values in data and meta.labels
- A belongsTo on a protected fill key is read-only (D-26)
- One six-segment GET pattern dispatches relation lists and field options,
which ServeMux cannot register side by side
- Admin OpenAPI documents the options route and RecordEnvelope
763 lines
22 KiB
Go
763 lines
22 KiB
Go
package cabana
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"reflect"
|
|
"strconv"
|
|
"strings"
|
|
"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"
|
|
"git.golem15.com/golem15/summercms/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
|
|
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")
|
|
}
|
|
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)
|
|
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
|
|
}
|
|
}
|
|
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,
|
|
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))
|
|
})
|
|
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)
|
|
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) {
|
|
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
|
|
//
|
|
// 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 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
|
|
}
|
|
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
|
|
}
|
|
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)
|
|
}
|