Support configuration variables on Gitea Actions (#24724)

Co-Author: @silverwind @wxiaoguang 
Replace: #24404

See:
- [defining configuration variables for multiple
workflows](https://docs.github.com/en/actions/learn-github-actions/variables#defining-configuration-variables-for-multiple-workflows)
- [vars
context](https://docs.github.com/en/actions/learn-github-actions/contexts#vars-context)

Related to:
- [x] protocol: https://gitea.com/gitea/actions-proto-def/pulls/7
- [x] act_runner: https://gitea.com/gitea/act_runner/pulls/157
- [x] act: https://gitea.com/gitea/act/pulls/43

#### Screenshoot
Create Variable:

![image](https://user-images.githubusercontent.com/33891828/236758288-032b7f64-44e7-48ea-b07d-de8b8b0e3729.png)


![image](https://user-images.githubusercontent.com/33891828/236758174-5203f64c-1d0e-4737-a5b0-62061dee86f8.png)

Workflow:
```yaml
  test_vars:
    runs-on: ubuntu-latest
    steps:
      - name: Print Custom Variables
        run: echo "${{ vars.test_key }}"
      - name: Try to print a non-exist var
        run: echo "${{ vars.NON_EXIST_VAR }}"
```

Actions Log:

![image](https://user-images.githubusercontent.com/33891828/236759075-af0c5950-368d-4758-a8ac-47a96e43b6e2.png)

---
This PR just implement the org / user (depends on the owner of the
current repository) and repo level variables, The Environment level
variables have not been implemented.
Because
[Environment](https://docs.github.com/en/actions/deployment/targeting-different-environments/using-environments-for-deployment#about-environments)
is a module separate from `Actions`. Maybe it would be better to create
a new PR to do it.

---------

Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: Giteabot <teabot@gitea.io>
This commit is contained in:
sillyguodong 2023-06-21 06:54:15 +08:00 committed by GitHub
parent 8220e50b56
commit 35a653d7ed
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 683 additions and 138 deletions

View File

@ -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
}

View File

@ -503,6 +503,9 @@ var migrations = []Migration{
// v260 -> v261 // v260 -> v261
NewMigration("Drop custom_labels column of action_runner table", v1_21.DropCustomLabelsColumnOfActionRunner), 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 // GetCurrentDBVersion returns the current db version

View File

@ -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))
}

View File

@ -5,38 +5,17 @@ package secret
import ( import (
"context" "context"
"fmt" "errors"
"regexp"
"strings" "strings"
"code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/db"
secret_module "code.gitea.io/gitea/modules/secret" secret_module "code.gitea.io/gitea/modules/secret"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/timeutil"
"code.gitea.io/gitea/modules/util"
"xorm.io/builder" "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 // Secret represents a secret
type Secret struct { type Secret struct {
ID int64 ID int64
@ -74,24 +53,11 @@ func init() {
db.RegisterModel(new(Secret)) 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 { func (s *Secret) Validate() error {
switch { if s.OwnerID == 0 && s.RepoID == 0 {
case len(s.Name) == 0 || len(s.Name) > 50: return errors.New("the secret is not bound to any scope")
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
} }
return nil
} }
type FindSecretsOptions struct { type FindSecretsOptions struct {

View File

@ -132,6 +132,9 @@ show_full_screen = Show full screen
confirm_delete_selected = Confirm to delete all selected items? confirm_delete_selected = Confirm to delete all selected items?
name = Name
value = Value
[aria] [aria]
navbar = Navigation Bar navbar = Navigation Bar
footer = Footer footer = Footer
@ -3391,8 +3394,6 @@ owner.settings.chef.keypair.description = Generate a key pair used to authentica
secrets = Secrets secrets = Secrets
description = Secrets will be passed to certain actions and cannot be read otherwise. description = Secrets will be passed to certain actions and cannot be read otherwise.
none = There are no secrets yet. none = There are no secrets yet.
value = Value
name = Name
creation = Add Secret creation = Add Secret
creation.name_placeholder = case-insensitive, alphanumeric characters or underscores only, cannot start with GITEA_ or GITHUB_ 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. 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. 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] [projects]
type-1.display_name = Individual Project type-1.display_name = Individual Project
type-2.display_name = Repository Project type-2.display_name = Repository Project

View File

@ -36,6 +36,7 @@ func pickTask(ctx context.Context, runner *actions_model.ActionRunner) (*runnerv
WorkflowPayload: t.Job.WorkflowPayload, WorkflowPayload: t.Job.WorkflowPayload,
Context: generateTaskContext(t), Context: generateTaskContext(t),
Secrets: getSecretsOfTask(ctx, t), Secrets: getSecretsOfTask(ctx, t),
Vars: getVariablesOfTask(ctx, t),
} }
if needs, err := findTaskNeeds(ctx, t); err != nil { 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 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 { func generateTaskContext(t *actions_model.ActionTask) *structpb.Struct {
event := map[string]interface{}{} event := map[string]interface{}{}
_ = json.Unmarshal([]byte(t.Job.Run.EventPayload), &event) _ = json.Unmarshal([]byte(t.Job.Run.EventPayload), &event)

View File

@ -92,6 +92,12 @@ func SecretsPost(ctx *context.Context) {
ctx.ServerError("getSecretsCtx", err) ctx.ServerError("getSecretsCtx", err)
return return
} }
if ctx.HasError() {
ctx.JSONError(ctx.GetErrMsg())
return
}
shared.PerformSecretsPost( shared.PerformSecretsPost(
ctx, ctx,
sCtx.OwnerID, sCtx.OwnerID,

View File

@ -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)
}

View File

@ -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")
}

View File

@ -4,14 +4,12 @@
package secrets package secrets
import ( import (
"net/http"
"strings"
"code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/db"
secret_model "code.gitea.io/gitea/models/secret" secret_model "code.gitea.io/gitea/models/secret"
"code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/context"
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/modules/web"
"code.gitea.io/gitea/routers/web/shared/actions"
"code.gitea.io/gitea/services/forms" "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) { func PerformSecretsPost(ctx *context.Context, ownerID, repoID int64, redirectURL string) {
form := web.GetForm(ctx).(*forms.AddSecretForm) form := web.GetForm(ctx).(*forms.AddSecretForm)
content := form.Content if err := actions.NameRegexMatch(form.Name); err != nil {
// Since the content is from a form which is a textarea, the line endings are \r\n. ctx.JSONError(ctx.Tr("secrets.creation.failed"))
// It's a standard behavior of HTML. return
// 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")
s, err := secret_model.InsertEncryptedSecret(ctx, ownerID, repoID, form.Title, content)
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.Redirect(redirectURL) s, err := secret_model.InsertEncryptedSecret(ctx, ownerID, repoID, form.Name, actions.ReserveLineBreakForTextarea(form.Data))
if err != nil {
log.Error("InsertEncryptedSecret: %v", err)
ctx.JSONError(ctx.Tr("secrets.creation.failed"))
return
}
ctx.Flash.Success(ctx.Tr("secrets.creation.success", s.Name))
ctx.JSONRedirect(redirectURL)
} }
func PerformSecretsDelete(ctx *context.Context, ownerID, repoID int64, redirectURL string) { 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 { 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) log.Error("Delete secret %d failed: %v", id, err)
ctx.Flash.Error(ctx.Tr("secrets.deletion.failed")) ctx.JSONError(ctx.Tr("secrets.deletion.failed"))
} else { return
}
ctx.Flash.Success(ctx.Tr("secrets.deletion.success")) ctx.Flash.Success(ctx.Tr("secrets.deletion.success"))
} ctx.JSONRedirect(redirectURL)
ctx.JSON(http.StatusOK, map[string]interface{}{
"redirect": redirectURL,
})
} }

View File

@ -307,6 +307,15 @@ func registerRoutes(m *web.Route) {
m.Post("/packagist/{id}", web.Bind(forms.NewPackagistHookForm{}), repo.PackagistHooksEditPost) 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() { addSettingsSecretsRoutes := func() {
m.Group("/secrets", func() { m.Group("/secrets", func() {
m.Get("", repo_setting.Secrets) m.Get("", repo_setting.Secrets)
@ -494,6 +503,7 @@ func registerRoutes(m *web.Route) {
m.Get("", user_setting.RedirectToDefaultSetting) m.Get("", user_setting.RedirectToDefaultSetting)
addSettingsRunnersRoutes() addSettingsRunnersRoutes()
addSettingsSecretsRoutes() addSettingsSecretsRoutes()
addSettingVariablesRoutes()
}, actions.MustEnableActions) }, actions.MustEnableActions)
m.Get("/organization", user_setting.Organization) m.Get("/organization", user_setting.Organization)
@ -760,6 +770,7 @@ func registerRoutes(m *web.Route) {
m.Get("", org_setting.RedirectToDefaultSetting) m.Get("", org_setting.RedirectToDefaultSetting)
addSettingsRunnersRoutes() addSettingsRunnersRoutes()
addSettingsSecretsRoutes() addSettingsSecretsRoutes()
addSettingVariablesRoutes()
}, actions.MustEnableActions) }, actions.MustEnableActions)
m.RouteMethods("/delete", "GET,POST", org.SettingsDelete) m.RouteMethods("/delete", "GET,POST", org.SettingsDelete)
@ -941,6 +952,7 @@ func registerRoutes(m *web.Route) {
m.Get("", repo_setting.RedirectToDefaultSetting) m.Get("", repo_setting.RedirectToDefaultSetting)
addSettingsRunnersRoutes() addSettingsRunnersRoutes()
addSettingsSecretsRoutes() addSettingsSecretsRoutes()
addSettingVariablesRoutes()
}, actions.MustEnableActions) }, actions.MustEnableActions)
m.Post("/migrate/cancel", repo.MigrateCancelPost) // this handler must be under "settings", otherwise this incomplete repo can't be accessed 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)) }, ctxDataSet("PageIsRepoSettings", true, "LFSStartServer", setting.LFS.StartServer))

View File

@ -367,8 +367,8 @@ func (f *AddKeyForm) Validate(req *http.Request, errs binding.Errors) binding.Er
// AddSecretForm for adding secrets // AddSecretForm for adding secrets
type AddSecretForm struct { type AddSecretForm struct {
Title string `binding:"Required;MaxSize(50)"` Name string `binding:"Required;MaxSize(255)"`
Content string `binding:"Required"` Data string `binding:"Required;MaxSize(65535)"`
} }
// Validate validates the fields // 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) 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 // NewAccessTokenForm form for creating access token
type NewAccessTokenForm struct { type NewAccessTokenForm struct {
Name string `binding:"Required;MaxSize(255)"` Name string `binding:"Required;MaxSize(255)"`

View File

@ -4,6 +4,8 @@
{{template "shared/actions/runner_list" .}} {{template "shared/actions/runner_list" .}}
{{else if eq .PageType "secrets"}} {{else if eq .PageType "secrets"}}
{{template "shared/secrets/add_list" .}} {{template "shared/secrets/add_list" .}}
{{else if eq .PageType "variables"}}
{{template "shared/variables/variable_list" .}}
{{end}} {{end}}
</div> </div>
{{template "org/settings/layout_footer" .}} {{template "org/settings/layout_footer" .}}

View File

@ -23,7 +23,7 @@
</a> </a>
{{end}} {{end}}
{{if .EnableActions}} {{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> <summary>{{.locale.Tr "actions.actions"}}</summary>
<div class="menu"> <div class="menu">
<a class="{{if .PageIsSharedSettingsRunners}}active {{end}}item" href="{{.OrgLink}}/settings/actions/runners"> <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"> <a class="{{if .PageIsSharedSettingsSecrets}}active {{end}}item" href="{{.OrgLink}}/settings/actions/secrets">
{{.locale.Tr "secrets.secrets"}} {{.locale.Tr "secrets.secrets"}}
</a> </a>
<a class="{{if .PageIsSharedSettingsVariables}}active {{end}}item" href="{{.OrgLink}}/settings/actions/variables">
{{.locale.Tr "actions.variables"}}
</a>
</div> </div>
</details> </details>
{{end}} {{end}}

View File

@ -4,6 +4,8 @@
{{template "shared/actions/runner_list" .}} {{template "shared/actions/runner_list" .}}
{{else if eq .PageType "secrets"}} {{else if eq .PageType "secrets"}}
{{template "shared/secrets/add_list" .}} {{template "shared/secrets/add_list" .}}
{{else if eq .PageType "variables"}}
{{template "shared/variables/variable_list" .}}
{{end}} {{end}}
</div> </div>
{{template "repo/settings/layout_footer" .}} {{template "repo/settings/layout_footer" .}}

View File

@ -34,7 +34,7 @@
</a> </a>
{{end}} {{end}}
{{if and .EnableActions (not .UnitActionsGlobalDisabled) (.Permission.CanRead $.UnitTypeActions)}} {{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> <summary>{{.locale.Tr "actions.actions"}}</summary>
<div class="menu"> <div class="menu">
<a class="{{if .PageIsSharedSettingsRunners}}active {{end}}item" href="{{.RepoLink}}/settings/actions/runners"> <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"> <a class="{{if .PageIsSharedSettingsSecrets}}active {{end}}item" href="{{.RepoLink}}/settings/actions/secrets">
{{.locale.Tr "secrets.secrets"}} {{.locale.Tr "secrets.secrets"}}
</a> </a>
<a class="{{if .PageIsSharedSettingsVariables}}active {{end}}item" href="{{.RepoLink}}/settings/actions/variables">
{{.locale.Tr "actions.variables"}}
</a>
</div> </div>
</details> </details>
{{end}} {{end}}

View File

@ -1,53 +1,41 @@
<h4 class="ui top attached header"> <h4 class="ui top attached header">
{{.locale.Tr "secrets.management"}} {{.locale.Tr "secrets.management"}}
<div class="ui right"> <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> </div>
</h4> </h4>
<div class="ui attached segment"> <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}} {{if .Secrets}}
<div class="ui key list"> <div class="ui key list">
{{range .Secrets}} {{range $i, $v := .Secrets}}
<div class="item"> <div class="item gt-df gt-ac gt-fw {{if gt $i 0}} gt-py-4{{end}}">
<div class="right floated content"> <div class="content gt-f1 gt-df gt-js">
<button class="ui red tiny button delete-button" data-url="{{$.Link}}/delete" data-id="{{.ID}}"> <div class="content">
{{$.locale.Tr "settings.delete_key"}}
</button>
</div>
<div class="left floated content">
<i>{{svg "octicon-key" 32}}</i> <i>{{svg "octicon-key" 32}}</i>
</div> </div>
<div class="content"> <div class="content gt-ml-3 gt-ellipsis">
<strong>{{.Name}}</strong> <strong>{{$v.Name}}</strong>
<div class="print meta">******</div> <div class="print meta">******</div>
<div class="activity meta">
<i>
{{$.locale.Tr "settings.added_on" (DateTime "short" .CreatedUnix) | Safe}}
</i>
</div> </div>
</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="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> </div>
{{end}} {{end}}
</div> </div>
@ -55,13 +43,37 @@
{{.locale.Tr "secrets.none"}} {{.locale.Tr "secrets.none"}}
{{end}} {{end}}
</div> </div>
<div class="ui g-modal-confirm delete modal">
{{/* Add secret dialog */}}
<div class="ui small modal" id="add-secret-modal">
<div class="header"> <div class="header">
{{svg "octicon-trash"}} <span id="actions-modal-header"></span>
{{.locale.Tr "secrets.deletion"}}
</div> </div>
<form class="ui form form-fetch-action" method="post">
<div class="content"> <div class="content">
<p>{{.locale.Tr "secrets.deletion.description"}}</p> {{.CsrfTokenHtml}}
<div class="field">
{{.locale.Tr "secrets.description"}}
</div> </div>
{{template "base/modal_actions_confirm" .}} <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> </div>

View File

@ -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>

View File

@ -4,6 +4,8 @@
{{template "shared/secrets/add_list" .}} {{template "shared/secrets/add_list" .}}
{{else if eq .PageType "runners"}} {{else if eq .PageType "runners"}}
{{template "shared/actions/runner_list" .}} {{template "shared/actions/runner_list" .}}
{{else if eq .PageType "variables"}}
{{template "shared/variables/variable_list" .}}
{{end}} {{end}}
</div> </div>

View File

@ -20,7 +20,7 @@
{{.locale.Tr "settings.ssh_gpg_keys"}} {{.locale.Tr "settings.ssh_gpg_keys"}}
</a> </a>
{{if .EnableActions}} {{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> <summary>{{.locale.Tr "actions.actions"}}</summary>
<div class="menu"> <div class="menu">
<a class="{{if .PageIsSharedSettingsRunners}}active {{end}}item" href="{{AppSubUrl}}/user/settings/actions/runners"> <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"> <a class="{{if .PageIsSharedSettingsSecrets}}active {{end}}item" href="{{AppSubUrl}}/user/settings/actions/secrets">
{{.locale.Tr "secrets.secrets"}} {{.locale.Tr "secrets.secrets"}}
</a> </a>
<a class="{{if .PageIsSharedSettingsVariables}}active {{end}}item" href="{{AppSubUrl}}/user/settings/actions/variables">
{{.locale.Tr "actions.variables"}}
</a>
</div> </div>
</details> </details>
{{end}} {{end}}

View File

@ -25,11 +25,15 @@
display: inline-block; 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 */ /* Gitea sometimes use a form in a modal dialog, then the "positive" button could submit the form directly */
.ui.modal > .content, .ui.modal > .content,
.ui.modal > form > .content { .ui.modal > form > .content {
background: var(--color-body);
padding: 1.5em; padding: 1.5em;
} }

View File

@ -354,6 +354,57 @@ export function initGlobalLinkActions() {
$('.link-action').on('click', linkAction); $('.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() { 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. // 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. // 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'); alert('Nothing to hide');
}); });
$('.show-modal').on('click', function (e) { initGlobalShowModal();
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();
}
});
} }
/** /**