aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--models/actions/variable.go97
-rw-r--r--models/migrations/migrations.go3
-rw-r--r--models/migrations/v1_21/v261.go24
-rw-r--r--models/secret/secret.go42
-rw-r--r--options/locale/locale_en-US.ini21
-rw-r--r--routers/api/actions/runner/utils.go24
-rw-r--r--routers/web/repo/setting/secrets.go6
-rw-r--r--routers/web/repo/setting/variables.go119
-rw-r--r--routers/web/shared/actions/variables.go128
-rw-r--r--routers/web/shared/secrets/secrets.go36
-rw-r--r--routers/web/web.go12
-rw-r--r--services/forms/user_form.go14
-rw-r--r--templates/org/settings/actions.tmpl2
-rw-r--r--templates/org/settings/navbar.tmpl5
-rw-r--r--templates/repo/settings/actions.tmpl2
-rw-r--r--templates/repo/settings/navbar.tmpl5
-rw-r--r--templates/shared/secrets/add_list.tmpl104
-rw-r--r--templates/shared/variables/variable_list.tmpl85
-rw-r--r--templates/user/settings/actions.tmpl2
-rw-r--r--templates/user/settings/navbar.tmpl5
-rw-r--r--web_src/css/modules/modal.css6
-rw-r--r--web_src/js/features/common-global.js73
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();
}
/**