- an account action URL is kept only when it is http(s) with a host or a same-origin path; anything else is dropped and logged without the URL - a failing Status marks its section error: true and a failing Applies drops only that action, instead of a 500 for the whole list - status text goes through localizeDetail (B-IN-03); the non-string payload refusal is the phrase key action_prompt_invalid (B-IN-02) - the record action annotations and pact's prompt doc say what happens (B-IN-05 text); admin.json and schema.d.ts regenerated
243 lines
9.0 KiB
Go
243 lines
9.0 KiB
Go
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, 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; the section and 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)
|
|
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 != ""
|
|
}
|