package cabana import ( "context" "errors" "fmt" "net/http" "net/url" "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. A // section whose Status fails is sent with Error set and no status, and an // action whose Applies fails is left out and sets Error on its section (the // admin sees why a button is missing), both logged: one plugin's error never // answers 500 for the list. 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 { // One plugin's failure never hides the other sections: the // section is sent with an empty status and the error flag. 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) view.Error = true } else { view.Status = localizeDetail(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 { // The action is left out and the section says so // (RB-IN-02); the other sections are still answered. moduleLog.ErrorContext(ctx, "cabana: account contribution applies failed", "section", item.section.Name, "action", action.Name, "plugin", item.pluginID, "error", err) view.Error = true continue } 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) if result.URL != "" && !safeAccountActionURL(result.URL) { // Never log the URL itself: it may carry a secret or a script. moduleLog.ErrorContext(ctx, "cabana: account contribution action answered a non-http URL", "section", item.section.Name, "action", action.Name, "plugin", item.pluginID) result.URL = "" } WriteData(w, http.StatusOK, AccountActionResultView{ Message: translateKey(ctx, tr, result.Message), URL: result.URL, }, nil) } // safeAccountActionURL reports whether an account action's URL may be handed // to the admin's browser, which renders it as a link and opens it: an // absolute http or https URL with a host, or a same-origin relative path // (exactly one leading "/", no scheme). Anything else, javascript: and data: // included, is refused. func safeAccountActionURL(raw string) bool { u, err := url.Parse(raw) if err != nil { return false } if u.Scheme == "" { return strings.HasPrefix(raw, "/") && !strings.HasPrefix(raw, "//") && !strings.HasPrefix(raw, "/\\") && u.Host == "" } return (u.Scheme == "https" || u.Scheme == "http") && u.Host != "" }