- pact.HasAccountContributions compiled at Activate; GET /account/contributions
and POST /account/contributions/{name}/actions/{action} behind interactiveOnly
(AccountContributionView, AccountContributionActionView, AccountActionResultView)
- RecordAction.prompt (RecordActionPrompt) for a pact.AdminRecordAction with a
Prompt; the record action route accepts payload only for such an action
(trimmed JSON string, non-empty, capped at max_length) and hands it to Run as
Value through CRUDService.RecordActionWithValue; a prompt without a label
fails boot
- fireSubjectChanged: pact.MCPSubjectChanged for revoked tokens, Administrators
saves that changed role, permissions or superuser, Roles saves that changed
grants, deactivation and deletion
- phrases backend::lang.form.action_prompt_required/_too_long (en, pl)
- smoke tests TestAccountContributionsSmoke and TestRecordActionPromptSmoke;
TestRecordActionPromptRun on Postgres; route inventories, CSRF walk and
OpenAPI conformance cases extended for the two routes
- regenerated admin/openapi/admin.json and admin/src/api/schema.d.ts; cabana
README, docs/backend/{admin-controllers,users-and-permissions}.md
215 lines
7.7 KiB
Go
215 lines
7.7 KiB
Go
package cabana
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/lagoon"
|
|
"git.golem15.com/golem15/summercms/modules/pact"
|
|
"git.golem15.com/golem15/summercms/modules/party"
|
|
"git.golem15.com/golem15/summercms/modules/towel"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// accountContributionCatalog holds the My account sections plugins
|
|
// contribute (pact.HasAccountContributions), in activation order.
|
|
type accountContributionCatalog struct {
|
|
ordered []compiledAccountContribution
|
|
byName map[string]*compiledAccountContribution
|
|
}
|
|
|
|
// compiledAccountContribution is one contributed section with its owner.
|
|
type compiledAccountContribution struct {
|
|
pluginID string
|
|
section pact.AccountContribution
|
|
actions map[string]pact.AccountAction
|
|
}
|
|
|
|
// compileAccountContributions collects pact.HasAccountContributions from
|
|
// plugins into reg, in activation order. Every failure stops boot with a
|
|
// message naming the plugin and the section: a name that is not an
|
|
// identifier, a name another plugin already contributed, an empty label, an
|
|
// action whose name is not an identifier or repeats within the section, a
|
|
// nil Run or an empty action label.
|
|
func compileAccountContributions(reg *Registry, plugins []party.Plugin) error {
|
|
if reg == nil {
|
|
return fmt.Errorf("cabana: registry is nil")
|
|
}
|
|
cat := &accountContributionCatalog{byName: map[string]*compiledAccountContribution{}}
|
|
for _, plugin := range plugins {
|
|
if plugin == nil {
|
|
continue
|
|
}
|
|
src, ok := plugin.(pact.HasAccountContributions)
|
|
if !ok || src == nil {
|
|
continue
|
|
}
|
|
pluginID := plugin.ID()
|
|
for _, section := range src.AccountContributions() {
|
|
prefix := fmt.Sprintf("cabana: plugin %s contributes account section %q", pluginID, section.Name)
|
|
if !identifier(section.Name) {
|
|
return fmt.Errorf("%s: the name is not an identifier", prefix)
|
|
}
|
|
if taken, ok := cat.byName[section.Name]; ok {
|
|
return fmt.Errorf("%s: plugin %s already contributes a section with that name", prefix, taken.pluginID)
|
|
}
|
|
if strings.TrimSpace(section.Label) == "" {
|
|
return fmt.Errorf("%s: it needs a label", prefix)
|
|
}
|
|
compiled := &compiledAccountContribution{pluginID: pluginID, section: section, actions: map[string]pact.AccountAction{}}
|
|
for _, action := range section.Actions {
|
|
if !identifier(action.Name) {
|
|
return fmt.Errorf("%s: action name %q is not an identifier", prefix, action.Name)
|
|
}
|
|
if _, dup := compiled.actions[action.Name]; dup {
|
|
return fmt.Errorf("%s: duplicate action %q", prefix, action.Name)
|
|
}
|
|
if action.Run == nil {
|
|
return fmt.Errorf("%s: action %q has no Run function", prefix, action.Name)
|
|
}
|
|
if strings.TrimSpace(action.Label) == "" {
|
|
return fmt.Errorf("%s: action %q needs a label", prefix, action.Name)
|
|
}
|
|
compiled.actions[action.Name] = action
|
|
}
|
|
cat.ordered = append(cat.ordered, *compiled)
|
|
cat.byName[section.Name] = &cat.ordered[len(cat.ordered)-1]
|
|
}
|
|
}
|
|
// The slice may have grown: point the map at the final backing array.
|
|
for i := range cat.ordered {
|
|
cat.byName[cat.ordered[i].section.Name] = &cat.ordered[i]
|
|
}
|
|
reg.accountContributions = cat
|
|
return nil
|
|
}
|
|
|
|
// accountContributionsList serves GET /account/contributions: every
|
|
// contributed section with its status for the signed-in administrator and
|
|
// the actions they may run that apply to them, labels translated.
|
|
func (s *service) accountContributionsList(w http.ResponseWriter, r *http.Request) {
|
|
principal := accountPrincipal(w, r)
|
|
if principal == nil {
|
|
return
|
|
}
|
|
ctx := r.Context()
|
|
tr := s.translator()
|
|
out := []AccountContributionView{}
|
|
if s.reg != nil && s.reg.accountContributions != nil {
|
|
for _, item := range s.reg.accountContributions.ordered {
|
|
view := AccountContributionView{
|
|
Name: item.section.Name,
|
|
Label: translateKey(ctx, tr, item.section.Label),
|
|
Description: translateKey(ctx, tr, item.section.Description),
|
|
Actions: []AccountContributionActionView{},
|
|
}
|
|
if item.section.Status != nil {
|
|
status, err := item.section.Status(ctx, principal.ID)
|
|
if err != nil {
|
|
moduleLog.ErrorContext(ctx, "cabana: account contribution status failed", "section", item.section.Name, "plugin", item.pluginID, "error", err)
|
|
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
|
return
|
|
}
|
|
view.Status = translateKey(ctx, tr, status)
|
|
}
|
|
for _, action := range item.section.Actions {
|
|
if !Allows(principal, action.Permissions) {
|
|
continue
|
|
}
|
|
if action.Applies != nil {
|
|
applies, err := action.Applies(ctx, principal.ID)
|
|
if err != nil {
|
|
moduleLog.ErrorContext(ctx, "cabana: account contribution applies failed", "section", item.section.Name, "action", action.Name, "plugin", item.pluginID, "error", err)
|
|
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
|
return
|
|
}
|
|
if !applies {
|
|
continue
|
|
}
|
|
}
|
|
view.Actions = append(view.Actions, AccountContributionActionView{
|
|
Name: action.Name,
|
|
Label: translateKey(ctx, tr, action.Label),
|
|
Confirm: translateKey(ctx, tr, action.Confirm),
|
|
Danger: action.Danger,
|
|
})
|
|
}
|
|
out = append(out, view)
|
|
}
|
|
}
|
|
WriteData(w, http.StatusOK, out, map[string]any{})
|
|
}
|
|
|
|
// accountContributionAction serves POST /account/contributions/{name}/actions/{action}:
|
|
// an unknown section or action is 404, a missing permission 403, an action
|
|
// that no longer applies 409; then Run runs for the signed-in administrator
|
|
// inside a transaction when the admin has a database (the context carries
|
|
// it, as for record actions) and its message and URL are answered.
|
|
func (s *service) accountContributionAction(w http.ResponseWriter, r *http.Request) {
|
|
principal := accountPrincipal(w, r)
|
|
if principal == nil {
|
|
return
|
|
}
|
|
var item *compiledAccountContribution
|
|
if s.reg != nil && s.reg.accountContributions != nil {
|
|
item = s.reg.accountContributions.byName[r.PathValue("name")]
|
|
}
|
|
if item == nil {
|
|
WriteError(w, http.StatusNotFound, "not_found", msgNotFound)
|
|
return
|
|
}
|
|
action, ok := item.actions[r.PathValue("action")]
|
|
if !ok {
|
|
WriteError(w, http.StatusNotFound, "not_found", msgNotFound)
|
|
return
|
|
}
|
|
if !s.allowAction(w, r, action.Permissions) {
|
|
return
|
|
}
|
|
tr := s.translator()
|
|
ctx := towel.WithLocale(r.Context(), schemaLocale(r.Context(), tr))
|
|
run := func(ctx context.Context) (pact.AccountActionResult, error) {
|
|
if action.Applies != nil {
|
|
applies, err := action.Applies(ctx, principal.ID)
|
|
if err != nil {
|
|
return pact.AccountActionResult{}, err
|
|
}
|
|
if !applies {
|
|
return pact.AccountActionResult{}, actionConflict{}
|
|
}
|
|
}
|
|
return action.Run(ctx, pact.AccountActionInput{AdminID: principal.ID})
|
|
}
|
|
var result pact.AccountActionResult
|
|
var err error
|
|
if db, dbErr := s.db(); dbErr == nil {
|
|
err = lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error {
|
|
result, err = run(withTx(ctx, tx))
|
|
return err
|
|
})
|
|
} else {
|
|
result, err = run(ctx)
|
|
}
|
|
if err != nil {
|
|
// A refusal the plugin authored (403, 422) or a stale action (409)
|
|
// is answered as is; anything else is the plugin's failure, logged
|
|
// here and answered as an opaque 500.
|
|
var stale actionConflict
|
|
var refused *ForbiddenError
|
|
var invalid *ValidationError
|
|
if !errors.As(err, &stale) && !errors.As(err, &refused) && !errors.As(err, &invalid) {
|
|
moduleLog.ErrorContext(ctx, "cabana: account contribution action failed", "section", item.section.Name, "action", action.Name, "plugin", item.pluginID, "error", err)
|
|
}
|
|
writeCRUDError(w, localizeControllerError(ctx, tr, err))
|
|
return
|
|
}
|
|
moduleLog.InfoContext(ctx, "cabana: account contribution action", "admin_id", principal.ID, "section", item.section.Name, "action", action.Name)
|
|
WriteData(w, http.StatusOK, AccountActionResultView{
|
|
Message: translateKey(ctx, tr, result.Message),
|
|
URL: result.URL,
|
|
}, nil)
|
|
}
|