- pact: AdminClientAssets, AdminAction, AdminActionInput, AdminActionResult,
HasAdminActions and AdminPartialData contracts
- fields.yaml type: widget with widget, action and fill keys; boot checks the
plugin tag prefix, the registered action and writable scalar fill fields
- POST .../widgets/{field} behind requireAjax, controller and action
permissions, scoped non-locking record read and a server-side fill filter
- typed OpenAPI operation, inventories and an acme conformance case
200 lines
6.0 KiB
Go
200 lines
6.0 KiB
Go
package cabana
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"reflect"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
|
"git.golem15.com/golem15/summercms/modules/pact"
|
|
"git.golem15.com/golem15/summercms/modules/towel"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/clause"
|
|
)
|
|
|
|
// 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.
|
|
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
|
|
}
|
|
action, ok := cc.Actions[field.Action]
|
|
if !ok {
|
|
WriteError(w, http.StatusNotFound, "not_found", msgNotFound)
|
|
return
|
|
}
|
|
if !s.allowAction(w, r, action) {
|
|
return
|
|
}
|
|
in, err := decodeActionRequest(r)
|
|
if err != nil {
|
|
writeCRUDError(w, err)
|
|
return
|
|
}
|
|
input := pact.AdminActionInput{Field: field.Name, Values: onlyFillScalars(field.Fill, in.Values)}
|
|
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)
|
|
})
|
|
}
|
|
|
|
// allowAction applies the action's own permissions on top of the controller's
|
|
// (already checked by protect). A denial is logged and answered 403.
|
|
func (s *service) allowAction(w http.ResponseWriter, r *http.Request, action pact.AdminAction) bool {
|
|
principal, _ := bouncer.User(r.Context())
|
|
if Allows(principal, action.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; any other error is logged and answered with the
|
|
// generic 500 body, never the error text.
|
|
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 {
|
|
var invalid *ValidationError
|
|
if errors.As(err, &invalid) {
|
|
writeCRUDError(w, err)
|
|
return
|
|
}
|
|
slog.Error("cabana: admin action failed", "controller", controllerID(cc), "action", action.Name, "field", input.Field, "error", err)
|
|
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
|
return
|
|
}
|
|
WriteData(w, http.StatusOK, AdminActionResult{
|
|
Message: translateKey(ctx, tr, result.Message),
|
|
Fill: onlyFillScalars(fill, result.Fill),
|
|
}, 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} body: unknown
|
|
// keys, a malformed body or trailing tokens are a validation failure (422).
|
|
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
|
|
}
|
|
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.
|
|
func isJSONScalar(v any) bool {
|
|
if v == nil || nestedValue(v) {
|
|
return v == nil
|
|
}
|
|
rv := reflect.ValueOf(v)
|
|
for rv.Kind() == reflect.Pointer {
|
|
if rv.IsNil() {
|
|
return true
|
|
}
|
|
rv = rv.Elem()
|
|
}
|
|
switch rv.Kind() {
|
|
case reflect.Bool, reflect.String,
|
|
reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
|
|
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64,
|
|
reflect.Float32, reflect.Float64:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|