diff options
22 files changed, 680 insertions, 135 deletions
diff --git a/models/actions/variable.go b/models/actions/variable.go new file mode 100644 index 0000000000..e0bb59ccbe --- /dev/null +++ b/models/actions/variable.go @@ -0,0 +1,97 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "context" + "errors" + "fmt" + "strings" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/timeutil" + "code.gitea.io/gitea/modules/util" + + "xorm.io/builder" +) + +type ActionVariable struct { + ID int64 `xorm:"pk autoincr"` + OwnerID int64 `xorm:"UNIQUE(owner_repo_name)"` + RepoID int64 `xorm:"INDEX UNIQUE(owner_repo_name)"` + Name string `xorm:"UNIQUE(owner_repo_name) NOT NULL"` + Data string `xorm:"LONGTEXT NOT NULL"` + CreatedUnix timeutil.TimeStamp `xorm:"created NOT NULL"` + UpdatedUnix timeutil.TimeStamp `xorm:"updated"` +} + +func init() { + db.RegisterModel(new(ActionVariable)) +} + +func (v *ActionVariable) Validate() error { + if v.OwnerID == 0 && v.RepoID == 0 { + return errors.New("the variable is not bound to any scope") + } + return nil +} + +func InsertVariable(ctx context.Context, ownerID, repoID int64, name, data string) (*ActionVariable, error) { + variable := &ActionVariable{ + OwnerID: ownerID, + RepoID: repoID, + Name: strings.ToUpper(name), + Data: data, + } + if err := variable.Validate(); err != nil { + return variable, err + } + return variable, db.Insert(ctx, variable) +} + +type FindVariablesOpts struct { + db.ListOptions + OwnerID int64 + RepoID int64 +} + +func (opts *FindVariablesOpts) toConds() builder.Cond { + cond := builder.NewCond() + if opts.OwnerID > 0 { + cond = cond.And(builder.Eq{"owner_id": opts.OwnerID}) + } + if opts.RepoID > 0 { + cond = cond.And(builder.Eq{"repo_id": opts.RepoID}) + } + return cond +} + +func FindVariables(ctx context.Context, opts FindVariablesOpts) ([]*ActionVariable, error) { + var variables []*ActionVariable + sess := db.GetEngine(ctx) + if opts.PageSize != 0 { + sess = db.SetSessionPagination(sess, &opts.ListOptions) + } + return variables, sess.Where(opts.toConds()).Find(&variables) +} + +func GetVariableByID(ctx context.Context, variableID int64) (*ActionVariable, error) { + var variable ActionVariable + has, err := db.GetEngine(ctx).Where("id=?", variableID).Get(&variable) + if err != nil { + return nil, err + } else if !has { + return nil, fmt.Errorf("variable with id %d: %w", variableID, util.ErrNotExist) + } + return &variable, nil +} + +func UpdateVariable(ctx context.Context, variable *ActionVariable) (bool, error) { + count, err := db.GetEngine(ctx).ID(variable.ID).Cols("name", "data"). + Update(&ActionVariable{ + Name: variable.Name, + Data: variable.Data, + }) + return count != 0, err +} diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index 4eb512ab49..1d443b3d15 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -503,6 +503,9 @@ var migrations = []Migration{ // v260 -> v261 NewMigration("Drop custom_labels column of action_runner table", v1_21.DropCustomLabelsColumnOfActionRunner), + + // v261 -> v262 + NewMigration("Add variable table", v1_21.CreateVariableTable), } // GetCurrentDBVersion returns the current db version diff --git a/models/migrations/v1_21/v261.go b/models/migrations/v1_21/v261.go new file mode 100644 index 0000000000..4ec1160d0b --- /dev/null +++ b/models/migrations/v1_21/v261.go @@ -0,0 +1,24 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_21 //nolint + +import ( + "code.gitea.io/gitea/modules/timeutil" + + "xorm.io/xorm" +) + +func CreateVariableTable(x *xorm.Engine) error { + type ActionVariable struct { + ID int64 `xorm:"pk autoincr"` + OwnerID int64 `xorm:"UNIQUE(owner_repo_name)"` + RepoID int64 `xorm:"INDEX UNIQUE(owner_repo_name)"` + Name string `xorm:"UNIQUE(owner_repo_name) NOT NULL"` + Data string `xorm:"LONGTEXT NOT NULL"` + CreatedUnix timeutil.TimeStamp `xorm:"created NOT NULL"` + UpdatedUnix timeutil.TimeStamp `xorm:"updated"` + } + + return x.Sync(new(ActionVariable)) +} diff --git a/models/secret/secret.go b/models/secret/secret.go index 8b23b6c35c..5a17cc37a5 100644 --- a/models/secret/secret.go +++ b/models/secret/secret.go @@ -5,38 +5,17 @@ package secret import ( "context" - "fmt" - "regexp" + "errors" "strings" "code.gitea.io/gitea/models/db" secret_module "code.gitea.io/gitea/modules/secret" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/timeutil" - "code.gitea.io/gitea/modules/util" "xorm.io/builder" ) -type ErrSecretInvalidValue struct { - Name *string - Data *string -} - -func (err ErrSecretInvalidValue) Error() string { - if err.Name != nil { - return fmt.Sprintf("secret name %q is invalid", *err.Name) - } - if err.Data != nil { - return fmt.Sprintf("secret data %q is invalid", *err.Data) - } - return util.ErrInvalidArgument.Error() -} - -func (err ErrSecretInvalidValue) Unwrap() error { - return util.ErrInvalidArgument -} - // Secret represents a secret type Secret struct { ID int64 @@ -74,24 +53,11 @@ func init() { db.RegisterModel(new(Secret)) } -var ( - secretNameReg = regexp.MustCompile("^[A-Z_][A-Z0-9_]*$") - forbiddenSecretPrefixReg = regexp.MustCompile("^GIT(EA|HUB)_") -) - -// Validate validates the required fields and formats. func (s *Secret) Validate() error { - switch { - case len(s.Name) == 0 || len(s.Name) > 50: - return ErrSecretInvalidValue{Name: &s.Name} - case len(s.Data) == 0: - return ErrSecretInvalidValue{Data: &s.Data} - case !secretNameReg.MatchString(s.Name) || - forbiddenSecretPrefixReg.MatchString(s.Name): - return ErrSecretInvalidValue{Name: &s.Name} - default: - return nil + if s.OwnerID == 0 && s.RepoID == 0 { + return errors.New("the secret is not bound to any scope") } + return nil } type FindSecretsOptions struct { diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 6cab7c0cbb..0cf8810702 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -132,6 +132,9 @@ show_full_screen = Show full screen confirm_delete_selected = Confirm to delete all selected items? +name = Name +value = Value + [aria] navbar = Navigation Bar footer = Footer @@ -3391,8 +3394,6 @@ owner.settings.chef.keypair.description = Generate a key pair used to authentica secrets = Secrets description = Secrets will be passed to certain actions and cannot be read otherwise. none = There are no secrets yet. -value = Value -name = Name creation = Add Secret creation.name_placeholder = case-insensitive, alphanumeric characters or underscores only, cannot start with GITEA_ or GITHUB_ creation.value_placeholder = Input any content. Whitespace at the start and end will be omitted. @@ -3462,6 +3463,22 @@ runs.no_matching_runner_helper = No matching runner: %s need_approval_desc = Need approval to run workflows for fork pull request. +variables = Variables +variables.management = Variables Management +variables.creation = Add Variable +variables.none = There are no variables yet. +variables.deletion = Remove variable +variables.deletion.description = Removing a variable is permanent and cannot be undone. Continue? +variables.description = Variables will be passed to certain actions and cannot be read otherwise. +variables.id_not_exist = Variable with id %d not exists. +variables.edit = Edit Variable +variables.deletion.failed = Failed to remove variable. +variables.deletion.success = The variable has been removed. +variables.creation.failed = Failed to add variable. +variables.creation.success = The variable "%s" has been added. +variables.update.failed = Failed to edit variable. +variables.update.success = The variable has been edited. + [projects] type-1.display_name = Individual Project type-2.display_name = Repository Project diff --git a/routers/api/actions/runner/utils.go b/routers/api/actions/runner/utils.go index 9af51f2d7e..cc9c06ab45 100644 --- a/routers/api/actions/runner/utils.go +++ b/routers/api/actions/runner/utils.go @@ -36,6 +36,7 @@ func pickTask(ctx context.Context, runner *actions_model.ActionRunner) (*runnerv WorkflowPayload: t.Job.WorkflowPayload, Context: generateTaskContext(t), Secrets: getSecretsOfTask(ctx, t), + Vars: getVariablesOfTask(ctx, t), } if needs, err := findTaskNeeds(ctx, t); err != nil { @@ -88,6 +89,29 @@ func getSecretsOfTask(ctx context.Context, task *actions_model.ActionTask) map[s return secrets } +func getVariablesOfTask(ctx context.Context, task *actions_model.ActionTask) map[string]string { + variables := map[string]string{} + + // Org / User level + ownerVariables, err := actions_model.FindVariables(ctx, actions_model.FindVariablesOpts{OwnerID: task.Job.Run.Repo.OwnerID}) + if err != nil { + log.Error("find variables of org: %d, error: %v", task.Job.Run.Repo.OwnerID, err) + } + + // Repo level + repoVariables, err := actions_model.FindVariables(ctx, actions_model.FindVariablesOpts{RepoID: task.Job.Run.RepoID}) + if err != nil { + log.Error("find variables of repo: %d, error: %v", task.Job.Run.RepoID, err) + } + + // Level precedence: Repo > Org / User + for _, v := range append(ownerVariables, repoVariables...) { + variables[v.Name] = v.Data + } + + return variables +} + func generateTaskContext(t *actions_model.ActionTask) *structpb.Struct { event := map[string]interface{}{} _ = json.Unmarshal([]byte(t.Job.Run.EventPayload), &event) diff --git a/routers/web/repo/setting/secrets.go b/routers/web/repo/setting/secrets.go index 444f16f86c..3d7a057602 100644 --- a/routers/web/repo/setting/secrets.go +++ b/routers/web/repo/setting/secrets.go @@ -92,6 +92,12 @@ func SecretsPost(ctx *context.Context) { ctx.ServerError("getSecretsCtx", err) return } + + if ctx.HasError() { + ctx.JSONError(ctx.GetErrMsg()) + return + } + shared.PerformSecretsPost( ctx, sCtx.OwnerID, diff --git a/routers/web/repo/setting/variables.go b/routers/web/repo/setting/variables.go new file mode 100644 index 0000000000..1005d1d9c6 --- /dev/null +++ b/routers/web/repo/setting/variables.go @@ -0,0 +1,119 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "errors" + "net/http" + + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/setting" + shared "code.gitea.io/gitea/routers/web/shared/actions" +) + +const ( + tplRepoVariables base.TplName = "repo/settings/actions" + tplOrgVariables base.TplName = "org/settings/actions" + tplUserVariables base.TplName = "user/settings/actions" +) + +type variablesCtx struct { + OwnerID int64 + RepoID int64 + IsRepo bool + IsOrg bool + IsUser bool + VariablesTemplate base.TplName + RedirectLink string +} + +func getVariablesCtx(ctx *context.Context) (*variablesCtx, error) { + if ctx.Data["PageIsRepoSettings"] == true { + return &variablesCtx{ + RepoID: ctx.Repo.Repository.ID, + IsRepo: true, + VariablesTemplate: tplRepoVariables, + RedirectLink: ctx.Repo.RepoLink + "/settings/actions/variables", + }, nil + } + + if ctx.Data["PageIsOrgSettings"] == true { + return &variablesCtx{ + OwnerID: ctx.ContextUser.ID, + IsOrg: true, + VariablesTemplate: tplOrgVariables, + RedirectLink: ctx.Org.OrgLink + "/settings/actions/variables", + }, nil + } + + if ctx.Data["PageIsUserSettings"] == true { + return &variablesCtx{ + OwnerID: ctx.Doer.ID, + IsUser: true, + VariablesTemplate: tplUserVariables, + RedirectLink: setting.AppSubURL + "/user/settings/actions/variables", + }, nil + } + + return nil, errors.New("unable to set Variables context") +} + +func Variables(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("actions.variables") + ctx.Data["PageType"] = "variables" + ctx.Data["PageIsSharedSettingsVariables"] = true + + vCtx, err := getVariablesCtx(ctx) + if err != nil { + ctx.ServerError("getVariablesCtx", err) + return + } + + shared.SetVariablesContext(ctx, vCtx.OwnerID, vCtx.RepoID) + if ctx.Written() { + return + } + + ctx.HTML(http.StatusOK, vCtx.VariablesTemplate) +} + +func VariableCreate(ctx *context.Context) { + vCtx, err := getVariablesCtx(ctx) + if err != nil { + ctx.ServerError("getVariablesCtx", err) + return + } + + if ctx.HasError() { // form binding validation error + ctx.JSONError(ctx.GetErrMsg()) + return + } + + shared.CreateVariable(ctx, vCtx.OwnerID, vCtx.RepoID, vCtx.RedirectLink) +} + +func VariableUpdate(ctx *context.Context) { + vCtx, err := getVariablesCtx(ctx) + if err != nil { + ctx.ServerError("getVariablesCtx", err) + return + } + + if ctx.HasError() { // form binding validation error + ctx.JSONError(ctx.GetErrMsg()) + return + } + + shared.UpdateVariable(ctx, vCtx.RedirectLink) +} + +func VariableDelete(ctx *context.Context) { + vCtx, err := getVariablesCtx(ctx) + if err != nil { + ctx.ServerError("getVariablesCtx", err) + return + } + shared.DeleteVariable(ctx, vCtx.RedirectLink) +} diff --git a/routers/web/shared/actions/variables.go b/routers/web/shared/actions/variables.go new file mode 100644 index 0000000000..8d1516c91c --- /dev/null +++ b/routers/web/shared/actions/variables.go @@ -0,0 +1,128 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "errors" + "regexp" + "strings" + + actions_model "code.gitea.io/gitea/models/actions" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/forms" +) + +func SetVariablesContext(ctx *context.Context, ownerID, repoID int64) { + variables, err := actions_model.FindVariables(ctx, actions_model.FindVariablesOpts{ + OwnerID: ownerID, + RepoID: repoID, + }) + if err != nil { + ctx.ServerError("FindVariables", err) + return + } + ctx.Data["Variables"] = variables +} + +// some regular expression of `variables` and `secrets` +// reference to: +// https://docs.github.com/en/actions/learn-github-actions/variables#naming-conventions-for-configuration-variables +// https://docs.github.com/en/actions/security-guides/encrypted-secrets#naming-your-secrets +var ( + nameRx = regexp.MustCompile("(?i)^[A-Z_][A-Z0-9_]*$") + forbiddenPrefixRx = regexp.MustCompile("(?i)^GIT(EA|HUB)_") + + forbiddenEnvNameCIRx = regexp.MustCompile("(?i)^CI") +) + +func NameRegexMatch(name string) error { + if !nameRx.MatchString(name) || forbiddenPrefixRx.MatchString(name) { + log.Error("Name %s, regex match error", name) + return errors.New("name has invalid character") + } + return nil +} + +func envNameCIRegexMatch(name string) error { + if forbiddenEnvNameCIRx.MatchString(name) { + log.Error("Env Name cannot be ci") + return errors.New("env name cannot be ci") + } + return nil +} + +func CreateVariable(ctx *context.Context, ownerID, repoID int64, redirectURL string) { + form := web.GetForm(ctx).(*forms.EditVariableForm) + + if err := NameRegexMatch(form.Name); err != nil { + ctx.JSONError(err.Error()) + return + } + + if err := envNameCIRegexMatch(form.Name); err != nil { + ctx.JSONError(err.Error()) + return + } + + v, err := actions_model.InsertVariable(ctx, ownerID, repoID, form.Name, ReserveLineBreakForTextarea(form.Data)) + if err != nil { + log.Error("InsertVariable error: %v", err) + ctx.JSONError(ctx.Tr("actions.variables.creation.failed")) + return + } + ctx.Flash.Success(ctx.Tr("actions.variables.creation.success", v.Name)) + ctx.JSONRedirect(redirectURL) +} + +func UpdateVariable(ctx *context.Context, redirectURL string) { + id := ctx.ParamsInt64(":variable_id") + form := web.GetForm(ctx).(*forms.EditVariableForm) + + if err := NameRegexMatch(form.Name); err != nil { + ctx.JSONError(err.Error()) + return + } + + if err := envNameCIRegexMatch(form.Name); err != nil { + ctx.JSONError(err.Error()) + return + } + + ok, err := actions_model.UpdateVariable(ctx, &actions_model.ActionVariable{ + ID: id, + Name: strings.ToUpper(form.Name), + Data: ReserveLineBreakForTextarea(form.Data), + }) + if err != nil || !ok { + log.Error("UpdateVariable error: %v", err) + ctx.JSONError(ctx.Tr("actions.variables.update.failed")) + return + } + ctx.Flash.Success(ctx.Tr("actions.variables.update.success")) + ctx.JSONRedirect(redirectURL) +} + +func DeleteVariable(ctx *context.Context, redirectURL string) { + id := ctx.ParamsInt64(":variable_id") + + if _, err := db.DeleteByBean(ctx, &actions_model.ActionVariable{ID: id}); err != nil { + log.Error("Delete variable [%d] failed: %v", id, err) + ctx.JSONError(ctx.Tr("actions.variables.deletion.failed")) + return + } + ctx.Flash.Success(ctx.Tr("actions.variables.deletion.success")) + ctx.JSONRedirect(redirectURL) +} + +func ReserveLineBreakForTextarea(input string) string { + // Since the content is from a form which is a textarea, the line endings are \r\n. + // It's a standard behavior of HTML. + // But we want to store them as \n like what GitHub does. + // And users are unlikely to really need to keep the \r. + // Other than this, we should respect the original content, even leading or trailing spaces. + return strings.ReplaceAll(input, "\r\n", "\n") +} diff --git a/routers/web/shared/secrets/secrets.go b/routers/web/shared/secrets/secrets.go index a0d648f908..c09ce51499 100644 --- a/routers/web/shared/secrets/secrets.go +++ b/routers/web/shared/secrets/secrets.go @@ -4,14 +4,12 @@ package secrets import ( - "net/http" - "strings" - "code.gitea.io/gitea/models/db" secret_model "code.gitea.io/gitea/models/secret" "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/routers/web/shared/actions" "code.gitea.io/gitea/services/forms" ) @@ -28,23 +26,20 @@ func SetSecretsContext(ctx *context.Context, ownerID, repoID int64) { func PerformSecretsPost(ctx *context.Context, ownerID, repoID int64, redirectURL string) { form := web.GetForm(ctx).(*forms.AddSecretForm) - content := form.Content - // Since the content is from a form which is a textarea, the line endings are \r\n. - // It's a standard behavior of HTML. - // But we want to store them as \n like what GitHub does. - // And users are unlikely to really need to keep the \r. - // Other than this, we should respect the original content, even leading or trailing spaces. - content = strings.ReplaceAll(content, "\r\n", "\n") + if err := actions.NameRegexMatch(form.Name); err != nil { + ctx.JSONError(ctx.Tr("secrets.creation.failed")) + return + } - s, err := secret_model.InsertEncryptedSecret(ctx, ownerID, repoID, form.Title, content) + s, err := secret_model.InsertEncryptedSecret(ctx, ownerID, repoID, form.Name, actions.ReserveLineBreakForTextarea(form.Data)) if err != nil { log.Error("InsertEncryptedSecret: %v", err) - ctx.Flash.Error(ctx.Tr("secrets.creation.failed")) - } else { - ctx.Flash.Success(ctx.Tr("secrets.creation.success", s.Name)) + ctx.JSONError(ctx.Tr("secrets.creation.failed")) + return } - ctx.Redirect(redirectURL) + ctx.Flash.Success(ctx.Tr("secrets.creation.success", s.Name)) + ctx.JSONRedirect(redirectURL) } func PerformSecretsDelete(ctx *context.Context, ownerID, repoID int64, redirectURL string) { @@ -52,12 +47,9 @@ func PerformSecretsDelete(ctx *context.Context, ownerID, repoID int64, redirectU if _, err := db.DeleteByBean(ctx, &secret_model.Secret{ID: id, OwnerID: ownerID, RepoID: repoID}); err != nil { log.Error("Delete secret %d failed: %v", id, err) - ctx.Flash.Error(ctx.Tr("secrets.deletion.failed")) - } else { - ctx.Flash.Success(ctx.Tr("secrets.deletion.success")) + ctx.JSONError(ctx.Tr("secrets.deletion.failed")) + return } - - ctx.JSON(http.StatusOK, map[string]interface{}{ - "redirect": redirectURL, - }) + ctx.Flash.Success(ctx.Tr("secrets.deletion.success")) + ctx.JSONRedirect(redirectURL) } diff --git a/routers/web/web.go b/routers/web/web.go index 8ac01f1742..a7573b38f5 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -307,6 +307,15 @@ func registerRoutes(m *web.Route) { m.Post("/packagist/{id}", web.Bind(forms.NewPackagistHookForm{}), repo.PackagistHooksEditPost) } + addSettingVariablesRoutes := func() { + m.Group("/variables", func() { + m.Get("", repo_setting.Variables) + m.Post("/new", web.Bind(forms.EditVariableForm{}), repo_setting.VariableCreate) + m.Post("/{variable_id}/edit", web.Bind(forms.EditVariableForm{}), repo_setting.VariableUpdate) + m.Post("/{variable_id}/delete", repo_setting.VariableDelete) + }) + } + addSettingsSecretsRoutes := func() { m.Group("/secrets", func() { m.Get("", repo_setting.Secrets) @@ -494,6 +503,7 @@ func registerRoutes(m *web.Route) { m.Get("", user_setting.RedirectToDefaultSetting) addSettingsRunnersRoutes() addSettingsSecretsRoutes() + addSettingVariablesRoutes() }, actions.MustEnableActions) m.Get("/organization", user_setting.Organization) @@ -760,6 +770,7 @@ func registerRoutes(m *web.Route) { m.Get("", org_setting.RedirectToDefaultSetting) addSettingsRunnersRoutes() addSettingsSecretsRoutes() + addSettingVariablesRoutes() }, actions.MustEnableActions) m.RouteMethods("/delete", "GET,POST", org.SettingsDelete) @@ -941,6 +952,7 @@ func registerRoutes(m *web.Route) { m.Get("", repo_setting.RedirectToDefaultSetting) addSettingsRunnersRoutes() addSettingsSecretsRoutes() + addSettingVariablesRoutes() }, actions.MustEnableActions) m.Post("/migrate/cancel", repo.MigrateCancelPost) // this handler must be under "settings", otherwise this incomplete repo can't be accessed }, ctxDataSet("PageIsRepoSettings", true, "LFSStartServer", setting.LFS.StartServer)) diff --git a/services/forms/user_form.go b/services/forms/user_form.go index 1315fb237b..0a4e2729e7 100644 --- a/services/forms/user_form.go +++ b/services/forms/user_form.go @@ -367,8 +367,8 @@ func (f *AddKeyForm) Validate(req *http.Request, errs binding.Errors) binding.Er // AddSecretForm for adding secrets type AddSecretForm struct { - Title string `binding:"Required;MaxSize(50)"` - Content string `binding:"Required"` + Name string `binding:"Required;MaxSize(255)"` + Data string `binding:"Required;MaxSize(65535)"` } // Validate validates the fields @@ -377,6 +377,16 @@ func (f *AddSecretForm) Validate(req *http.Request, errs binding.Errors) binding return middleware.Validate(errs, ctx.Data, f, ctx.Locale) } +type EditVariableForm struct { + Name string `binding:"Required;MaxSize(255)"` + Data string `binding:"Required;MaxSize(65535)"` +} + +func (f *EditVariableForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { + ctx := context.GetValidateContext(req) + return middleware.Validate(errs, ctx.Data, f, ctx.Locale) +} + // NewAccessTokenForm form for creating access token type NewAccessTokenForm struct { Name string `binding:"Required;MaxSize(255)"` diff --git a/templates/org/settings/actions.tmpl b/templates/org/settings/actions.tmpl index b3b24e0517..abb9c98435 100644 --- a/templates/org/settings/actions.tmpl +++ b/templates/org/settings/actions.tmpl @@ -4,6 +4,8 @@ {{template "shared/actions/runner_list" .}} {{else if eq .PageType "secrets"}} {{template "shared/secrets/add_list" .}} + {{else if eq .PageType "variables"}} + {{template "shared/variables/variable_list" .}} {{end}} </div> {{template "org/settings/layout_footer" .}} diff --git a/templates/org/settings/navbar.tmpl b/templates/org/settings/navbar.tmpl index 6bea9f5f60..e22d1b0f80 100644 --- a/templates/org/settings/navbar.tmpl +++ b/templates/org/settings/navbar.tmpl @@ -23,7 +23,7 @@ </a> {{end}} {{if .EnableActions}} - <details class="item toggleable-item" {{if or .PageIsSharedSettingsRunners .PageIsSharedSettingsSecrets}}open{{end}}> + <details class="item toggleable-item" {{if or .PageIsSharedSettingsRunners .PageIsSharedSettingsSecrets .PageIsSharedSettingsVariables}}open{{end}}> <summary>{{.locale.Tr "actions.actions"}}</summary> <div class="menu"> <a class="{{if .PageIsSharedSettingsRunners}}active {{end}}item" href="{{.OrgLink}}/settings/actions/runners"> @@ -32,6 +32,9 @@ <a class="{{if .PageIsSharedSettingsSecrets}}active {{end}}item" href="{{.OrgLink}}/settings/actions/secrets"> {{.locale.Tr "secrets.secrets"}} </a> + <a class="{{if .PageIsSharedSettingsVariables}}active {{end}}item" href="{{.OrgLink}}/settings/actions/variables"> + {{.locale.Tr "actions.variables"}} + </a> </div> </details> {{end}} diff --git a/templates/repo/settings/actions.tmpl b/templates/repo/settings/actions.tmpl index 72944234a3..f38ab5b658 100644 --- a/templates/repo/settings/actions.tmpl +++ b/templates/repo/settings/actions.tmpl @@ -4,6 +4,8 @@ {{template "shared/actions/runner_list" .}} {{else if eq .PageType "secrets"}} {{template "shared/secrets/add_list" .}} + {{else if eq .PageType "variables"}} + {{template "shared/variables/variable_list" .}} {{end}} </div> {{template "repo/settings/layout_footer" .}} diff --git a/templates/repo/settings/navbar.tmpl b/templates/repo/settings/navbar.tmpl index e21f23f6a0..5426a1b1fa 100644 --- a/templates/repo/settings/navbar.tmpl +++ b/templates/repo/settings/navbar.tmpl @@ -34,7 +34,7 @@ </a> {{end}} {{if and .EnableActions (not .UnitActionsGlobalDisabled) (.Permission.CanRead $.UnitTypeActions)}} - <details class="item toggleable-item" {{if or .PageIsSharedSettingsRunners .PageIsSharedSettingsSecrets}}open{{end}}> + <details class="item toggleable-item" {{if or .PageIsSharedSettingsRunners .PageIsSharedSettingsSecrets .PageIsSharedSettingsVariables}}open{{end}}> <summary>{{.locale.Tr "actions.actions"}}</summary> <div class="menu"> <a class="{{if .PageIsSharedSettingsRunners}}active {{end}}item" href="{{.RepoLink}}/settings/actions/runners"> @@ -43,6 +43,9 @@ <a class="{{if .PageIsSharedSettingsSecrets}}active {{end}}item" href="{{.RepoLink}}/settings/actions/secrets"> {{.locale.Tr "secrets.secrets"}} </a> + <a class="{{if .PageIsSharedSettingsVariables}}active {{end}}item" href="{{.RepoLink}}/settings/actions/variables"> + {{.locale.Tr "actions.variables"}} + </a> </div> </details> {{end}} diff --git a/templates/shared/secrets/add_list.tmpl b/templates/shared/secrets/add_list.tmpl index 8a6b7db907..ce5351d22b 100644 --- a/templates/shared/secrets/add_list.tmpl +++ b/templates/shared/secrets/add_list.tmpl @@ -1,52 +1,40 @@ <h4 class="ui top attached header"> {{.locale.Tr "secrets.management"}} <div class="ui right"> - <button class="ui primary tiny show-panel button" data-panel="#add-secret-panel">{{.locale.Tr "secrets.creation"}}</button> + <button class="ui primary tiny button show-modal" + data-modal="#add-secret-modal" + data-modal-form.action="{{.Link}}" + data-modal-header="{{.locale.Tr "secrets.creation"}}" + > + {{.locale.Tr "secrets.creation"}} + </button> </div> </h4> <div class="ui attached segment"> - <div class="{{if not .HasError}}gt-hidden {{end}}gt-mb-4" id="add-secret-panel"> - <form class="ui form" action="{{.Link}}" method="post"> - {{.CsrfTokenHtml}} - <div class="field"> - {{.locale.Tr "secrets.description"}} - </div> - <div class="field{{if .Err_Title}} error{{end}}"> - <label for="secret-title">{{.locale.Tr "secrets.name"}}</label> - <input id="secret-title" name="title" value="{{.title}}" autofocus required pattern="^[a-zA-Z_][a-zA-Z0-9_]*$" placeholder="{{.locale.Tr "secrets.creation.name_placeholder"}}"> - </div> - <div class="field{{if .Err_Content}} error{{end}}"> - <label for="secret-content">{{.locale.Tr "secrets.value"}}</label> - <textarea id="secret-content" name="content" required placeholder="{{.locale.Tr "secrets.creation.value_placeholder"}}">{{.content}}</textarea> - </div> - <button class="ui green button"> - {{.locale.Tr "secrets.creation"}} - </button> - <button class="ui hide-panel button" data-panel="#add-secret-panel"> - {{.locale.Tr "cancel"}} - </button> - </form> - </div> {{if .Secrets}} <div class="ui key list"> - {{range .Secrets}} - <div class="item"> - <div class="right floated content"> - <button class="ui red tiny button delete-button" data-url="{{$.Link}}/delete" data-id="{{.ID}}"> - {{$.locale.Tr "settings.delete_key"}} - </button> - </div> - <div class="left floated content"> - <i>{{svg "octicon-key" 32}}</i> + {{range $i, $v := .Secrets}} + <div class="item gt-df gt-ac gt-fw {{if gt $i 0}} gt-py-4{{end}}"> + <div class="content gt-f1 gt-df gt-js"> + <div class="content"> + <i>{{svg "octicon-key" 32}}</i> + </div> + <div class="content gt-ml-3 gt-ellipsis"> + <strong>{{$v.Name}}</strong> + <div class="print meta">******</div> + </div> </div> <div class="content"> - <strong>{{.Name}}</strong> - <div class="print meta">******</div> - <div class="activity meta"> - <i> - {{$.locale.Tr "settings.added_on" (DateTime "short" .CreatedUnix) | Safe}} - </i> - </div> + <span class="color-text-light-2 gt-mr-5"> + {{$.locale.Tr "settings.added_on" (DateTime "short" $v.CreatedUnix) | Safe}} + </span> + <button class="ui btn interact-bg link-action gt-p-3" + data-url="{{$.Link}}/delete?id={{.ID}}" + data-modal-confirm="{{$.locale.Tr "secrets.deletion.description"}}" + data-tooltip-content="{{$.locale.Tr "secrets.deletion"}}" + > + {{svg "octicon-trash"}} + </button> </div> </div> {{end}} @@ -55,13 +43,37 @@ {{.locale.Tr "secrets.none"}} {{end}} </div> -<div class="ui g-modal-confirm delete modal"> + +{{/* Add secret dialog */}} +<div class="ui small modal" id="add-secret-modal"> <div class="header"> - {{svg "octicon-trash"}} - {{.locale.Tr "secrets.deletion"}} - </div> - <div class="content"> - <p>{{.locale.Tr "secrets.deletion.description"}}</p> + <span id="actions-modal-header"></span> </div> - {{template "base/modal_actions_confirm" .}} + <form class="ui form form-fetch-action" method="post"> + <div class="content"> + {{.CsrfTokenHtml}} + <div class="field"> + {{.locale.Tr "secrets.description"}} + </div> + <div class="field"> + <label for="secret-name">{{.locale.Tr "name"}}</label> + <input autofocus required + id="secret-name" + name="name" + value="{{.name}}" + pattern="^[a-zA-Z_][a-zA-Z0-9_]*$" + placeholder="{{.locale.Tr "secrets.creation.name_placeholder"}}" + > + </div> + <div class="field"> + <label for="secret-data">{{.locale.Tr "value"}}</label> + <textarea required + id="secret-data" + name="data" + placeholder="{{.locale.Tr "secrets.creation.value_placeholder"}}" + ></textarea> + </div> + </div> + {{template "base/modal_actions_confirm" (dict "locale" $.locale "ModalButtonTypes" "confirm")}} + </form> </div> diff --git a/templates/shared/variables/variable_list.tmpl b/templates/shared/variables/variable_list.tmpl new file mode 100644 index 0000000000..5941902dbb --- /dev/null +++ b/templates/shared/variables/variable_list.tmpl @@ -0,0 +1,85 @@ +<h4 class="ui top attached header"> + {{.locale.Tr "actions.variables.management"}} + <div class="ui right"> + <button class="ui primary tiny button show-modal" + data-modal="#edit-variable-modal" + data-modal-form.action="{{.Link}}/new" + data-modal-header="{{.locale.Tr "actions.variables.creation"}}" + data-modal-dialog-variable-name="" + data-modal-dialog-variable-data="" + > + {{.locale.Tr "actions.variables.creation"}} + </button> + </div> +</h4> +<div class="ui attached segment"> + {{if .Variables}} + <div class="ui list"> + {{range $i, $v := .Variables}} + <div class="item gt-df gt-ac gt-fw {{if gt $i 0}} gt-py-4{{end}}"> + <div class="content gt-f1 gt-ellipsis"> + <strong>{{$v.Name}}</strong> + <div class="print meta gt-ellipsis">{{$v.Data}}</div> + </div> + <div class="content"> + <span class="color-text-light-2 gt-mr-5"> + {{$.locale.Tr "settings.added_on" (DateTime "short" $v.CreatedUnix) | Safe}} + </span> + <button class="btn interact-bg gt-p-3 show-modal" + data-tooltip-content="{{$.locale.Tr "variables.edit"}}" + data-modal="#edit-variable-modal" + data-modal-form.action="{{$.Link}}/{{$v.ID}}/edit" + data-modal-header="{{$.locale.Tr "actions.variables.edit"}}" + data-modal-dialog-variable-name="{{$v.Name}}" + data-modal-dialog-variable-data="{{$v.Data}}" + > + {{svg "octicon-pencil"}} + </button> + <button class="btn interact-bg gt-p-3 link-action" + data-tooltip-content="{{$.locale.Tr "actions.variables.deletion"}}" + data-url="{{$.Link}}/{{$v.ID}}/delete" + data-modal-confirm="{{$.locale.Tr "actions.variables.deletion.description"}}" + > + {{svg "octicon-trash"}} + </button> + </div> + </div> + {{end}} + </div> + {{else}} + {{.locale.Tr "actions.variables.none"}} + {{end}} +</div> + +{{/** Edit variable dialog */}} +<div class="ui small modal" id="edit-variable-modal"> + <div class="header"></div> + <form class="ui form form-fetch-action" method="post"> + <div class="content"> + {{.CsrfTokenHtml}} + <div class="field"> + {{.locale.Tr "actions.variables.description"}} + </div> + <div class="field"> + <label for="dialog-variable-name">{{.locale.Tr "name"}}</label> + <input autofocus required + name="name" + id="dialog-variable-name" + value="{{.name}}" + pattern="^[a-zA-Z_][a-zA-Z0-9_]*$" + placeholder="{{.locale.Tr "secrets.creation.name_placeholder"}}" + > + </div> + <div class="field"> + <label for="dialog-variable-data">{{.locale.Tr "value"}}</label> + <textarea required + name="data" + id="dialog-variable-data" + placeholder="{{.locale.Tr "secrets.creation.value_placeholder"}}" + ></textarea> + </div> + </div> + {{template "base/modal_actions_confirm" (dict "locale" $.locale "ModalButtonTypes" "confirm")}} + </form> +</div> + diff --git a/templates/user/settings/actions.tmpl b/templates/user/settings/actions.tmpl index 57cdae4469..abc5443383 100644 --- a/templates/user/settings/actions.tmpl +++ b/templates/user/settings/actions.tmpl @@ -4,6 +4,8 @@ {{template "shared/secrets/add_list" .}} {{else if eq .PageType "runners"}} {{template "shared/actions/runner_list" .}} + {{else if eq .PageType "variables"}} + {{template "shared/variables/variable_list" .}} {{end}} </div> diff --git a/templates/user/settings/navbar.tmpl b/templates/user/settings/navbar.tmpl index 4ef2abeaab..7612e41ba5 100644 --- a/templates/user/settings/navbar.tmpl +++ b/templates/user/settings/navbar.tmpl @@ -20,7 +20,7 @@ {{.locale.Tr "settings.ssh_gpg_keys"}} </a> {{if .EnableActions}} - <details class="item toggleable-item" {{if or .PageIsSharedSettingsRunners .PageIsSharedSettingsSecrets}}open{{end}}> + <details class="item toggleable-item" {{if or .PageIsSharedSettingsRunners .PageIsSharedSettingsSecrets .PageIsSharedSettingsVariables}}open{{end}}> <summary>{{.locale.Tr "actions.actions"}}</summary> <div class="menu"> <a class="{{if .PageIsSharedSettingsRunners}}active {{end}}item" href="{{AppSubUrl}}/user/settings/actions/runners"> @@ -29,6 +29,9 @@ <a class="{{if .PageIsSharedSettingsSecrets}}active {{end}}item" href="{{AppSubUrl}}/user/settings/actions/secrets"> {{.locale.Tr "secrets.secrets"}} </a> + <a class="{{if .PageIsSharedSettingsVariables}}active {{end}}item" href="{{AppSubUrl}}/user/settings/actions/variables"> + {{.locale.Tr "actions.variables"}} + </a> </div> </details> {{end}} diff --git a/web_src/css/modules/modal.css b/web_src/css/modules/modal.css index 3baaaf9ff2..a8d3521093 100644 --- a/web_src/css/modules/modal.css +++ b/web_src/css/modules/modal.css @@ -25,11 +25,15 @@ display: inline-block; } +.ui.modal { + background: var(--color-body); + box-shadow: 1px 3px 3px 0 var(--color-shadow), 1px 3px 15px 2px var(--color-shadow); +} + /* Gitea sometimes use a form in a modal dialog, then the "positive" button could submit the form directly */ .ui.modal > .content, .ui.modal > form > .content { - background: var(--color-body); padding: 1.5em; } diff --git a/web_src/js/features/common-global.js b/web_src/js/features/common-global.js index 5e418fa48b..e5fd7c29fc 100644 --- a/web_src/js/features/common-global.js +++ b/web_src/js/features/common-global.js @@ -354,6 +354,57 @@ export function initGlobalLinkActions() { $('.link-action').on('click', linkAction); } +function initGlobalShowModal() { + // A ".show-modal" button will show a modal dialog defined by its "data-modal" attribute. + // Each "data-modal-{target}" attribute will be filled to target element's value or text-content. + // * First, try to query '#target' + // * Then, try to query '.target' + // * Then, try to query 'target' as HTML tag + // If there is a ".{attr}" part like "data-modal-form.action", then the form's "action" attribute will be set. + $('.show-modal').on('click', function (e) { + e.preventDefault(); + const $el = $(this); + const modalSelector = $el.attr('data-modal'); + const $modal = $(modalSelector); + if (!$modal.length) { + throw new Error('no modal for this action'); + } + const modalAttrPrefix = 'data-modal-'; + for (const attrib of this.attributes) { + if (!attrib.name.startsWith(modalAttrPrefix)) { + continue; + } + + const attrTargetCombo = attrib.name.substring(modalAttrPrefix.length); + const [attrTargetName, attrTargetAttr] = attrTargetCombo.split('.'); + // try to find target by: "#target" -> ".target" -> "target tag" + let $attrTarget = $modal.find(`#${attrTargetName}`); + if (!$attrTarget.length) $attrTarget = $modal.find(`.${attrTargetName}`); + if (!$attrTarget.length) $attrTarget = $modal.find(`${attrTargetName}`); + if (!$attrTarget.length) continue; // TODO: show errors in dev mode to remind developers that there is a bug + + if (attrTargetAttr) { + $attrTarget[0][attrTargetAttr] = attrib.value; + } else if ($attrTarget.is('input') || $attrTarget.is('textarea')) { + $attrTarget.val(attrib.value); // FIXME: add more supports like checkbox + } else { + $attrTarget.text(attrib.value); // FIXME: it should be more strict here, only handle div/span/p + } + } + const colorPickers = $modal.find('.color-picker'); + if (colorPickers.length > 0) { + initCompColorPicker(); // FIXME: this might cause duplicate init + } + $modal.modal('setting', { + onApprove: () => { + // "form-fetch-action" can handle network errors gracefully, + // so keep the modal dialog to make users can re-submit the form if anything wrong happens. + if ($modal.find('.form-fetch-action').length) return false; + }, + }).modal('show'); + }); +} + export function initGlobalButtons() { // There are many "cancel button" elements in modal dialogs, Fomantic UI expects they are button-like elements but never submit a form. // However, Gitea misuses the modal dialog and put the cancel buttons inside forms, so we must prevent the form submission. @@ -391,27 +442,7 @@ export function initGlobalButtons() { alert('Nothing to hide'); }); - $('.show-modal').on('click', function (e) { - e.preventDefault(); - const modalDiv = $($(this).attr('data-modal')); - for (const attrib of this.attributes) { - if (!attrib.name.startsWith('data-modal-')) { - continue; - } - const id = attrib.name.substring(11); - const target = modalDiv.find(`#${id}`); - if (target.is('input')) { - target.val(attrib.value); - } else { - target.text(attrib.value); - } - } - modalDiv.modal('show'); - const colorPickers = $($(this).attr('data-modal')).find('.color-picker'); - if (colorPickers.length > 0) { - initCompColorPicker(); - } - }); + initGlobalShowModal(); } /** |