diff --git a/models/actions/variable.go b/models/actions/variable.go new file mode 100644 index 00000000000..e0bb59ccbe6 --- /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 4eb512ab49f..1d443b3d152 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 00000000000..4ec1160d0b3 --- /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 8b23b6c35cf..5a17cc37a5d 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 6cab7c0cbb0..0cf88107020 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 9af51f2d7e6..cc9c06ab455 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 444f16f86c1..3d7a0576027 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 00000000000..1005d1d9c61 --- /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 00000000000..8d1516c91ce --- /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 a0d648f908f..c09ce51499a 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 8ac01f17429..a7573b38f55 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 1315fb237b3..0a4e2729e7b 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 b3b24e05174..abb9c98435f 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}} {{template "org/settings/layout_footer" .}} diff --git a/templates/org/settings/navbar.tmpl b/templates/org/settings/navbar.tmpl index 6bea9f5f60e..e22d1b0f807 100644 --- a/templates/org/settings/navbar.tmpl +++ b/templates/org/settings/navbar.tmpl @@ -23,7 +23,7 @@ {{end}} {{if .EnableActions}} -
+
{{.locale.Tr "actions.actions"}}
{{end}} diff --git a/templates/repo/settings/actions.tmpl b/templates/repo/settings/actions.tmpl index 72944234a3e..f38ab5b6584 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}} {{template "repo/settings/layout_footer" .}} diff --git a/templates/repo/settings/navbar.tmpl b/templates/repo/settings/navbar.tmpl index e21f23f6a09..5426a1b1fa2 100644 --- a/templates/repo/settings/navbar.tmpl +++ b/templates/repo/settings/navbar.tmpl @@ -34,7 +34,7 @@ {{end}} {{if and .EnableActions (not .UnitActionsGlobalDisabled) (.Permission.CanRead $.UnitTypeActions)}} -
+
{{.locale.Tr "actions.actions"}}
{{end}} diff --git a/templates/shared/secrets/add_list.tmpl b/templates/shared/secrets/add_list.tmpl index 8a6b7db9079..ce5351d22b4 100644 --- a/templates/shared/secrets/add_list.tmpl +++ b/templates/shared/secrets/add_list.tmpl @@ -1,52 +1,40 @@

{{.locale.Tr "secrets.management"}}
- +

-
-
- {{.CsrfTokenHtml}} -
- {{.locale.Tr "secrets.description"}} -
-
- - -
-
- - -
- - -
-
{{if .Secrets}}
- {{range .Secrets}} -
-
- -
-
- {{svg "octicon-key" 32}} + {{range $i, $v := .Secrets}} +
+
+
+ {{svg "octicon-key" 32}} +
+
+ {{$v.Name}} +
******
+
- {{.Name}} -
******
-
- - {{$.locale.Tr "settings.added_on" (DateTime "short" .CreatedUnix) | Safe}} - -
+ + {{$.locale.Tr "settings.added_on" (DateTime "short" $v.CreatedUnix) | Safe}} + +
{{end}} @@ -55,13 +43,37 @@ {{.locale.Tr "secrets.none"}} {{end}}
- diff --git a/templates/user/settings/navbar.tmpl b/templates/user/settings/navbar.tmpl index 4ef2abeaab1..7612e41ba5a 100644 --- a/templates/user/settings/navbar.tmpl +++ b/templates/user/settings/navbar.tmpl @@ -20,7 +20,7 @@ {{.locale.Tr "settings.ssh_gpg_keys"}} {{if .EnableActions}} -
+
{{.locale.Tr "actions.actions"}}
{{end}} diff --git a/web_src/css/modules/modal.css b/web_src/css/modules/modal.css index 3baaaf9ff2b..a8d3521093e 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 5e418fa48b2..e5fd7c29fce 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(); } /**