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) }) } // 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) { return } in, err := decodeActionRequest(r) if err != nil { writeCRUDError(w, err) return } if in.RecordID != nil || in.Values != nil { writeCRUDError(w, &ValidationError{Details: map[string]any{"body": []string{"A toolbar action takes no record_id or values."}}}) 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 } // 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 } }