mirror of
				https://github.com/go-gitea/gitea.git
				synced 2025-10-31 21:28:11 +09:00 
			
		
		
		
	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:   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:  --- 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:
		
							
								
								
									
										97
									
								
								models/actions/variable.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										97
									
								
								models/actions/variable.go
									
									
									
									
									
										Normal 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 | ||||||
|  | } | ||||||
| @@ -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 | ||||||
|   | |||||||
							
								
								
									
										24
									
								
								models/migrations/v1_21/v261.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								models/migrations/v1_21/v261.go
									
									
									
									
									
										Normal 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)) | ||||||
|  | } | ||||||
| @@ -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 { | ||||||
|   | |||||||
| @@ -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 | ||||||
|   | |||||||
| @@ -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) | ||||||
|   | |||||||
| @@ -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, | ||||||
|   | |||||||
							
								
								
									
										119
									
								
								routers/web/repo/setting/variables.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										119
									
								
								routers/web/repo/setting/variables.go
									
									
									
									
									
										Normal 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) | ||||||
|  | } | ||||||
							
								
								
									
										128
									
								
								routers/web/shared/actions/variables.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										128
									
								
								routers/web/shared/actions/variables.go
									
									
									
									
									
										Normal 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") | ||||||
|  | } | ||||||
| @@ -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.JSON(http.StatusOK, map[string]interface{}{ | 	ctx.JSONRedirect(redirectURL) | ||||||
| 		"redirect": redirectURL, |  | ||||||
| 	}) |  | ||||||
| } | } | ||||||
|   | |||||||
| @@ -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)) | ||||||
|   | |||||||
| @@ -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)"` | ||||||
|   | |||||||
| @@ -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" .}} | ||||||
|   | |||||||
| @@ -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}} | ||||||
|   | |||||||
| @@ -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" .}} | ||||||
|   | |||||||
| @@ -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}} | ||||||
|   | |||||||
| @@ -1,52 +1,40 @@ | |||||||
| <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"}} | 					<i>{{svg "octicon-key" 32}}</i> | ||||||
| 				</button> | 				</div> | ||||||
| 			</div> | 				<div class="content gt-ml-3 gt-ellipsis"> | ||||||
| 			<div class="left floated content"> | 					<strong>{{$v.Name}}</strong> | ||||||
| 				<i>{{svg "octicon-key" 32}}</i> | 					<div class="print meta">******</div> | ||||||
|  | 				</div> | ||||||
| 			</div> | 			</div> | ||||||
| 			<div class="content"> | 			<div class="content"> | ||||||
| 				<strong>{{.Name}}</strong> | 				<span class="color-text-light-2 gt-mr-5"> | ||||||
| 				<div class="print meta">******</div> | 					{{$.locale.Tr "settings.added_on" (DateTime "short" $v.CreatedUnix) | Safe}} | ||||||
| 				<div class="activity meta"> | 				</span> | ||||||
| 					<i> | 				<button class="ui btn interact-bg link-action gt-p-3" | ||||||
| 						{{$.locale.Tr "settings.added_on" (DateTime "short" .CreatedUnix) | Safe}} | 					data-url="{{$.Link}}/delete?id={{.ID}}" | ||||||
| 					</i> | 					data-modal-confirm="{{$.locale.Tr "secrets.deletion.description"}}" | ||||||
| 				</div> | 					data-tooltip-content="{{$.locale.Tr "secrets.deletion"}}" | ||||||
|  | 				> | ||||||
|  | 					{{svg "octicon-trash"}} | ||||||
|  | 				</button> | ||||||
| 			</div> | 			</div> | ||||||
| 		</div> | 		</div> | ||||||
| 		{{end}} | 		{{end}} | ||||||
| @@ -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> | ||||||
| 	<div class="content"> | 	<form class="ui form form-fetch-action" method="post"> | ||||||
| 		<p>{{.locale.Tr "secrets.deletion.description"}}</p> | 		<div class="content"> | ||||||
| 	</div> | 			{{.CsrfTokenHtml}} | ||||||
| 	{{template "base/modal_actions_confirm" .}} | 			<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> | </div> | ||||||
|   | |||||||
							
								
								
									
										85
									
								
								templates/shared/variables/variable_list.tmpl
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										85
									
								
								templates/shared/variables/variable_list.tmpl
									
									
									
									
									
										Normal 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> | ||||||
|  |  | ||||||
| @@ -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> | ||||||
|  |  | ||||||
|   | |||||||
| @@ -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}} | ||||||
|   | |||||||
| @@ -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; | ||||||
| } | } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -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(); |  | ||||||
|     } |  | ||||||
|   }); |  | ||||||
| } | } | ||||||
|  |  | ||||||
| /** | /** | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user