- placement: tabs before/after tab anchors, anchor cursor for plugin order, own-field anchors sorted with cycle detection, Requires() rule, anchor tab inheritance, Hide with reference checks, zone refusal (D-101) - boot errors name plugin, controller and offending name; fileupload is refused (D-100); localized tab label collisions checked in Activate - extension widgets dispatch to AdminFormExtension.Actions, partials render from the contributor's Dir through PartialData, plugin field types decode for Save and load after Load, translatable values reach Save as locale maps and never the owner's translations - Permissions and Visible apply to schema, show, save, widgets and partials; AdminFormErrors answer 422, Save notices join meta.notices
462 lines
16 KiB
Go
462 lines
16 KiB
Go
package cabana
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"unicode/utf8"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
|
"git.golem15.com/golem15/summercms/modules/pact"
|
|
"git.golem15.com/golem15/summercms/modules/phrasebook"
|
|
"git.golem15.com/golem15/summercms/modules/towel"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/clause"
|
|
)
|
|
|
|
// maxActionPayloadBytes caps the widget's own payload (the body key payload):
|
|
// a larger value is a 422 on body and the action never runs.
|
|
const maxActionPayloadBytes = 64 << 10
|
|
|
|
// maxActionDataBytes caps an action's encoded Data: a larger or unencodable
|
|
// value is logged and answered with the opaque 500 body.
|
|
const maxActionDataBytes = 256 << 10
|
|
|
|
// widgetAction serves POST .../{controller}/widgets/{field} (D-05, D-07): the
|
|
// SPA posts on behalf of a `type: widget` field, cabana checks the controller
|
|
// and action permissions, loads record_id through the controller's form scope
|
|
// and runs the registered action. Only the field's declared fill keys with
|
|
// scalar values reach the action and the response; the optional payload
|
|
// passes through to the action exactly as sent, never inspected and never
|
|
// used to select the record.
|
|
func (s *service) widgetAction(w http.ResponseWriter, r *http.Request) {
|
|
s.protect(w, r, func(cc *CompiledController) {
|
|
field, ok := widgetField(cc, r.PathValue("field"))
|
|
if !ok {
|
|
WriteError(w, http.StatusNotFound, "not_found", msgNotFound)
|
|
return
|
|
}
|
|
// D-91: a form extension's widget runs one of the extension's
|
|
// Actions, and exists only for an administrator who sees the
|
|
// extension (its Permissions and Visible).
|
|
actions := cc.Actions
|
|
if ext := cc.extensionOf(field.Name); ext != nil {
|
|
principal, _ := bouncer.User(r.Context())
|
|
if !extensionVisible(r.Context(), principal, ext) {
|
|
WriteError(w, http.StatusNotFound, "not_found", msgNotFound)
|
|
return
|
|
}
|
|
actions = ext.actions
|
|
}
|
|
action, ok := actions[field.Action]
|
|
if !ok {
|
|
WriteError(w, http.StatusNotFound, "not_found", msgNotFound)
|
|
return
|
|
}
|
|
if !s.allowAction(w, r, action.Permissions) {
|
|
return
|
|
}
|
|
in, err := decodeActionRequest(r)
|
|
if err != nil {
|
|
writeCRUDError(w, err)
|
|
return
|
|
}
|
|
// The field's context applies here as on save: without record_id
|
|
// the request comes from the create form, with one from the update
|
|
// form. A widget its context hides on that form does not exist.
|
|
op := "create"
|
|
if in.RecordID != nil {
|
|
op = "update"
|
|
}
|
|
if !contextAllows(cc, field.Name, op) {
|
|
WriteError(w, http.StatusNotFound, "not_found", msgNotFound)
|
|
return
|
|
}
|
|
input := pact.AdminActionInput{Field: field.Name, Values: onlyFillScalars(field.Fill, in.Values)}
|
|
input.Payload = in.Payload
|
|
if in.RecordID != nil {
|
|
db, err := s.db()
|
|
if err != nil {
|
|
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
|
return
|
|
}
|
|
record, err := readScopedRecord(r.Context(), db, cc, *in.RecordID)
|
|
if err != nil {
|
|
writeCRUDError(w, err)
|
|
return
|
|
}
|
|
id := *in.RecordID
|
|
input.RecordID = &id
|
|
input.Record = record
|
|
}
|
|
s.runAction(w, r, cc, action, input, field.Fill)
|
|
})
|
|
}
|
|
|
|
// toolbarAction serves POST .../{controller}/toolbar/{action} (D-12): a
|
|
// registered action the list's toolbar.buttons declares. A toolbar action
|
|
// carries no record id and no values, so it can never become an unscoped
|
|
// record lookup; its answer's fill is always empty.
|
|
func (s *service) toolbarAction(w http.ResponseWriter, r *http.Request) {
|
|
s.protect(w, r, func(cc *CompiledController) {
|
|
action, ok := toolbarActionOf(cc, r.PathValue("action"))
|
|
if !ok {
|
|
WriteError(w, http.StatusNotFound, "not_found", msgNotFound)
|
|
return
|
|
}
|
|
if !s.allowAction(w, r, action.Permissions) {
|
|
return
|
|
}
|
|
in, err := decodeActionRequest(r)
|
|
if err != nil {
|
|
writeCRUDError(w, err)
|
|
return
|
|
}
|
|
if in.RecordID != nil || in.Values != nil || len(in.Payload) > 0 {
|
|
writeCRUDError(w, &ValidationError{Details: map[string]any{"body": []string{"A toolbar action takes no record_id, values or payload."}}})
|
|
return
|
|
}
|
|
s.runAction(w, r, cc, action, pact.AdminActionInput{}, nil)
|
|
})
|
|
}
|
|
|
|
// toolbarActionOf returns the registered action a list toolbar declares under
|
|
// name; the built-in create and delete are not actions.
|
|
func toolbarActionOf(cc *CompiledController, name string) (pact.AdminAction, bool) {
|
|
if cc == nil || cc.List == nil || builtinToolbarActions[name] {
|
|
return pact.AdminAction{}, false
|
|
}
|
|
declared := false
|
|
for _, button := range cc.List.ToolbarButtons {
|
|
declared = declared || button == name
|
|
}
|
|
if !declared {
|
|
return pact.AdminAction{}, false
|
|
}
|
|
action, ok := cc.Actions[name]
|
|
return action, ok
|
|
}
|
|
|
|
// bulkAction serves POST .../{controller}/bulk/{action} (D-09): a registered
|
|
// bulk action the list's bulkActions declares. The posted ids are resolved
|
|
// through the controller's list scope inside the action's transaction, so the
|
|
// plugin receives loaded records and never an id.
|
|
func (s *service) bulkAction(w http.ResponseWriter, r *http.Request) {
|
|
s.protect(w, r, func(cc *CompiledController) {
|
|
if singletonRefuses(w, r, cc) {
|
|
return
|
|
}
|
|
name := r.PathValue("action")
|
|
action, ok := bulkActionOf(cc, name)
|
|
if !ok {
|
|
WriteError(w, http.StatusNotFound, "not_found", msgNotFound)
|
|
return
|
|
}
|
|
if !s.allowAction(w, r, action.Permissions) {
|
|
return
|
|
}
|
|
in, err := s.decodeCappedBulk(w, 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.BulkAction(r.Context(), cc, name, in)
|
|
if err != nil {
|
|
writeCRUDError(w, err)
|
|
return
|
|
}
|
|
var adminID uint
|
|
if principal, _ := bouncer.User(r.Context()); principal != nil {
|
|
adminID = principal.ID
|
|
}
|
|
moduleLog.Info("cabana: admin bulk action", "controller", controllerID(cc), "action", name, "admin_id", adminID, "affected", result.Affected)
|
|
WriteData(w, http.StatusOK, result, nil)
|
|
})
|
|
}
|
|
|
|
// bulkActionOf returns the registered bulk action a list declares under name
|
|
// in bulkActions; the built-in delete is not a declared action.
|
|
func bulkActionOf(cc *CompiledController, name string) (pact.AdminBulkAction, bool) {
|
|
if cc == nil || cc.List == nil || builtinToolbarActions[name] {
|
|
return pact.AdminBulkAction{}, false
|
|
}
|
|
declared := false
|
|
for _, entry := range cc.List.BulkActions {
|
|
declared = declared || entry.Name == name
|
|
}
|
|
if !declared {
|
|
return pact.AdminBulkAction{}, false
|
|
}
|
|
action, ok := cc.BulkActions[name]
|
|
return action, ok
|
|
}
|
|
|
|
// recordAction serves POST .../{controller}/{id}/actions/{action} (D-10): a
|
|
// registered record action the form's recordActions declares. The record is
|
|
// loaded through the controller's form scope with a row lock inside the
|
|
// action's transaction, so a missing or out-of-scope id is one 404, and an
|
|
// action that no longer applies to the record is a 409.
|
|
func (s *service) recordAction(w http.ResponseWriter, r *http.Request) {
|
|
s.protect(w, r, func(cc *CompiledController) {
|
|
name := r.PathValue("action")
|
|
action, ok := recordActionOf(cc, name)
|
|
if !ok {
|
|
WriteError(w, http.StatusNotFound, "not_found", msgNotFound)
|
|
return
|
|
}
|
|
if !s.allowAction(w, r, action.Permissions) {
|
|
return
|
|
}
|
|
id, err := pathID(r)
|
|
if err != nil {
|
|
writeCRUDError(w, err)
|
|
return
|
|
}
|
|
in, err := decodeActionRequest(r)
|
|
if err != nil {
|
|
writeCRUDError(w, err)
|
|
return
|
|
}
|
|
if in.RecordID != nil || in.Values != nil || (action.Prompt == nil && len(in.Payload) > 0) {
|
|
writeCRUDError(w, &ValidationError{Details: map[string]any{"body": []string{"A record action takes no record_id, values or payload."}}})
|
|
return
|
|
}
|
|
value, err := promptValue(r.Context(), s.translator(), action.Prompt, in.Payload)
|
|
if err != nil {
|
|
writeCRUDError(w, err)
|
|
return
|
|
}
|
|
svc, err := s.crud()
|
|
if err != nil {
|
|
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
|
return
|
|
}
|
|
result, err := svc.RecordActionWithValue(r.Context(), cc, id, name, value)
|
|
if err != nil {
|
|
writeCRUDError(w, err)
|
|
return
|
|
}
|
|
var adminID uint
|
|
if principal, _ := bouncer.User(r.Context()); principal != nil {
|
|
adminID = principal.ID
|
|
}
|
|
moduleLog.Info("cabana: admin record action", "controller", controllerID(cc), "action", name, "admin_id", adminID, "record_id", id)
|
|
WriteData(w, http.StatusOK, result, nil)
|
|
})
|
|
}
|
|
|
|
// Phrase keys of the prompt value refusals (backend::lang.form).
|
|
const (
|
|
promptRequiredKey = "backend::lang.form.action_prompt_required"
|
|
promptTooLongKey = "backend::lang.form.action_prompt_too_long"
|
|
promptInvalidKey = "backend::lang.form.action_prompt_invalid"
|
|
)
|
|
|
|
// promptValue decodes the prompt answer of a prompted record action from
|
|
// the posted payload: a JSON string, trimmed, not empty and at most the
|
|
// prompt's MaxLength runes, else a 422 on payload. An action without a
|
|
// Prompt (nil) answers "" without reading the payload, which the route has
|
|
// already refused.
|
|
func promptValue(ctx context.Context, tr *phrasebook.Translator, prompt *pact.AdminActionPrompt, payload json.RawMessage) (string, error) {
|
|
if prompt == nil {
|
|
return "", nil
|
|
}
|
|
refuse := func(message string) error {
|
|
return &ValidationError{Details: map[string]any{"payload": []string{message}}}
|
|
}
|
|
if len(payload) == 0 {
|
|
return "", refuse(translateKey(ctx, tr, promptRequiredKey))
|
|
}
|
|
var raw string
|
|
if err := json.Unmarshal(payload, &raw); err != nil {
|
|
return "", refuse(translateKey(ctx, tr, promptInvalidKey))
|
|
}
|
|
value := strings.TrimSpace(raw)
|
|
if value == "" {
|
|
return "", refuse(translateKey(ctx, tr, promptRequiredKey))
|
|
}
|
|
if max := promptMaxLength(prompt); utf8.RuneCountInString(value) > max {
|
|
return "", refuse(strings.ReplaceAll(translateKey(ctx, tr, promptTooLongKey), ":max", strconv.Itoa(max)))
|
|
}
|
|
return value, nil
|
|
}
|
|
|
|
// recordActionOf returns the registered record action a form declares under
|
|
// name in recordActions.
|
|
func recordActionOf(cc *CompiledController, name string) (pact.AdminRecordAction, bool) {
|
|
if cc == nil || cc.Form == nil || builtinToolbarActions[name] {
|
|
return pact.AdminRecordAction{}, false
|
|
}
|
|
declared := false
|
|
for _, entry := range cc.Form.recordActions {
|
|
declared = declared || entry == name
|
|
}
|
|
if !declared {
|
|
return pact.AdminRecordAction{}, false
|
|
}
|
|
action, ok := cc.RecordActions[name]
|
|
return action, ok
|
|
}
|
|
|
|
// allowAction applies an action's own permissions on top of the controller's
|
|
// (already checked by protect). Every action kind shares it: a denial is
|
|
// logged and answered 403.
|
|
func (s *service) allowAction(w http.ResponseWriter, r *http.Request, permissions []string) bool {
|
|
principal, _ := bouncer.User(r.Context())
|
|
if Allows(principal, permissions) {
|
|
return true
|
|
}
|
|
var adminID uint
|
|
if principal != nil {
|
|
adminID = principal.ID
|
|
}
|
|
s.logAuth(r, "denied", adminID)
|
|
WriteError(w, http.StatusForbidden, "forbidden", msgForbidden)
|
|
return false
|
|
}
|
|
|
|
// runAction calls the plugin's Run and writes the D-10 envelope. A
|
|
// *ValidationError is a 422 and a *ForbiddenError a 403, both with their
|
|
// messages localized in the request locale; any other error is
|
|
// logged and answered with the generic 500 body, never the error text. The
|
|
// result's Fill passes the fill filter; its Data bypasses that filter but not
|
|
// the size cap: it is encoded once and embedded as-is, and a value above
|
|
// maxActionDataBytes or one that cannot be encoded is an opaque 500.
|
|
func (s *service) runAction(w http.ResponseWriter, r *http.Request, cc *CompiledController, action pact.AdminAction, input pact.AdminActionInput, fill []string) {
|
|
tr := s.translator()
|
|
ctx := towel.WithLocale(r.Context(), schemaLocale(r.Context(), tr))
|
|
result, err := action.Run(ctx, input)
|
|
if err != nil {
|
|
// A validation failure is a 422 and a refusal (D-27) a 403, both
|
|
// with the action's messages localized.
|
|
var invalid *ValidationError
|
|
var refused *ForbiddenError
|
|
if errors.As(err, &invalid) || errors.As(err, &refused) {
|
|
writeCRUDError(w, localizeControllerError(ctx, tr, err))
|
|
return
|
|
}
|
|
moduleLog.Error("cabana: admin action failed", "controller", controllerID(cc), "action", action.Name, "field", input.Field, "error", err)
|
|
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
|
return
|
|
}
|
|
out := AdminActionResult{
|
|
Message: translateKey(ctx, tr, result.Message),
|
|
Fill: onlyFillScalars(fill, result.Fill),
|
|
}
|
|
if result.Data != nil {
|
|
raw, err := json.Marshal(result.Data)
|
|
if err != nil || len(raw) > maxActionDataBytes {
|
|
moduleLog.Error("cabana: admin action data rejected", "controller", controllerID(cc), "action", action.Name, "field", input.Field, "bytes", len(raw), "error", err)
|
|
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
|
return
|
|
}
|
|
out.Data = json.RawMessage(raw)
|
|
}
|
|
WriteData(w, http.StatusOK, out, nil)
|
|
}
|
|
|
|
// widgetField returns the form's `type: widget` field with the given name.
|
|
func widgetField(cc *CompiledController, name string) (FormField, bool) {
|
|
if cc == nil || cc.Form == nil || name == "" {
|
|
return FormField{}, false
|
|
}
|
|
for _, field := range cc.Form.Fields {
|
|
if field.Name == name && field.Type == "widget" {
|
|
return field, true
|
|
}
|
|
}
|
|
return FormField{}, false
|
|
}
|
|
|
|
// decodeActionRequest decodes the strict {record_id, values, payload} body:
|
|
// unknown keys, a malformed body or trailing tokens are a validation failure
|
|
// (422), and so is a payload above maxActionPayloadBytes. The payload bytes
|
|
// are kept exactly as sent.
|
|
func decodeActionRequest(r *http.Request) (AdminActionRequest, error) {
|
|
invalid := &ValidationError{Details: map[string]any{"body": []string{"The request body is invalid."}}}
|
|
dec := json.NewDecoder(r.Body)
|
|
dec.UseNumber()
|
|
dec.DisallowUnknownFields()
|
|
var in AdminActionRequest
|
|
if err := dec.Decode(&in); err != nil {
|
|
return AdminActionRequest{}, invalid
|
|
}
|
|
var trailing any
|
|
if err := dec.Decode(&trailing); err != io.EOF {
|
|
return AdminActionRequest{}, invalid
|
|
}
|
|
if len(in.Payload) > maxActionPayloadBytes {
|
|
return AdminActionRequest{}, &ValidationError{Details: map[string]any{"body": []string{"The payload may not exceed 64 KiB."}}}
|
|
}
|
|
return in, nil
|
|
}
|
|
|
|
// readScopedRecord loads one record through the controller's FormExtendQuery
|
|
// scope, exactly as show and update do, but without a row lock: it is a read
|
|
// outside any write transaction. Missing and out-of-scope ids are both
|
|
// recordNotFound (404).
|
|
func readScopedRecord(ctx context.Context, db *gorm.DB, cc *CompiledController, id uint64) (any, error) {
|
|
model, err := newWritableModel(cc)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
pk, err := coercePK(model, id)
|
|
if err != nil {
|
|
return nil, recordNotFound{}
|
|
}
|
|
q := db.WithContext(ctx)
|
|
if ext, ok := cc.Controller.(pact.FormExtendQuery); ok && ext != nil {
|
|
if next := ext.FormExtendQuery(ctx, q); next != nil {
|
|
q = next
|
|
}
|
|
}
|
|
err = q.Where(clause.Eq{Column: clause.Column{Name: primaryColumn(model)}, Value: pk}).Take(model).Error
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, recordNotFound{}
|
|
}
|
|
if err != nil {
|
|
return nil, lifecycleFailure(cc, err)
|
|
}
|
|
return model, nil
|
|
}
|
|
|
|
// onlyFillScalars keeps the keys named in fill whose values are JSON scalars
|
|
// or null. The result is never nil.
|
|
func onlyFillScalars(fill []string, values map[string]any) map[string]any {
|
|
out := map[string]any{}
|
|
for _, key := range fill {
|
|
value, ok := values[key]
|
|
if !ok || !isJSONScalar(value) {
|
|
continue
|
|
}
|
|
out[key] = value
|
|
}
|
|
return out
|
|
}
|
|
|
|
// isJSONScalar reports whether v encodes as a JSON string, number, boolean or
|
|
// null. It decides by encoding the value, not by its reflect.Kind: a named
|
|
// scalar whose MarshalJSON writes an array or object is not a scalar, and a
|
|
// value that cannot be encoded (NaN, an infinity) is dropped rather than
|
|
// failing the whole response.
|
|
func isJSONScalar(v any) bool {
|
|
raw, err := json.Marshal(v)
|
|
if err != nil || len(raw) == 0 {
|
|
return false
|
|
}
|
|
switch raw[0] {
|
|
case '{', '[':
|
|
return false
|
|
}
|
|
return true
|
|
}
|