mirror of
				https://gitee.com/gitea/gitea
				synced 2025-11-04 08:30:25 +08: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
 | 
			
		||||
	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
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										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 (
 | 
			
		||||
	"context"
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"regexp"
 | 
			
		||||
	"errors"
 | 
			
		||||
	"strings"
 | 
			
		||||
 | 
			
		||||
	"code.gitea.io/gitea/models/db"
 | 
			
		||||
	secret_module "code.gitea.io/gitea/modules/secret"
 | 
			
		||||
	"code.gitea.io/gitea/modules/setting"
 | 
			
		||||
	"code.gitea.io/gitea/modules/timeutil"
 | 
			
		||||
	"code.gitea.io/gitea/modules/util"
 | 
			
		||||
 | 
			
		||||
	"xorm.io/builder"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
type ErrSecretInvalidValue struct {
 | 
			
		||||
	Name *string
 | 
			
		||||
	Data *string
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (err ErrSecretInvalidValue) Error() string {
 | 
			
		||||
	if err.Name != nil {
 | 
			
		||||
		return fmt.Sprintf("secret name %q is invalid", *err.Name)
 | 
			
		||||
	}
 | 
			
		||||
	if err.Data != nil {
 | 
			
		||||
		return fmt.Sprintf("secret data %q is invalid", *err.Data)
 | 
			
		||||
	}
 | 
			
		||||
	return util.ErrInvalidArgument.Error()
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (err ErrSecretInvalidValue) Unwrap() error {
 | 
			
		||||
	return util.ErrInvalidArgument
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Secret represents a secret
 | 
			
		||||
type Secret struct {
 | 
			
		||||
	ID          int64
 | 
			
		||||
@@ -74,24 +53,11 @@ func init() {
 | 
			
		||||
	db.RegisterModel(new(Secret))
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
var (
 | 
			
		||||
	secretNameReg            = regexp.MustCompile("^[A-Z_][A-Z0-9_]*$")
 | 
			
		||||
	forbiddenSecretPrefixReg = regexp.MustCompile("^GIT(EA|HUB)_")
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// Validate validates the required fields and formats.
 | 
			
		||||
func (s *Secret) Validate() error {
 | 
			
		||||
	switch {
 | 
			
		||||
	case len(s.Name) == 0 || len(s.Name) > 50:
 | 
			
		||||
		return ErrSecretInvalidValue{Name: &s.Name}
 | 
			
		||||
	case len(s.Data) == 0:
 | 
			
		||||
		return ErrSecretInvalidValue{Data: &s.Data}
 | 
			
		||||
	case !secretNameReg.MatchString(s.Name) ||
 | 
			
		||||
		forbiddenSecretPrefixReg.MatchString(s.Name):
 | 
			
		||||
		return ErrSecretInvalidValue{Name: &s.Name}
 | 
			
		||||
	default:
 | 
			
		||||
		return nil
 | 
			
		||||
	if s.OwnerID == 0 && s.RepoID == 0 {
 | 
			
		||||
		return errors.New("the secret is not bound to any scope")
 | 
			
		||||
	}
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type FindSecretsOptions struct {
 | 
			
		||||
 
 | 
			
		||||
@@ -132,6 +132,9 @@ show_full_screen = Show full screen
 | 
			
		||||
 | 
			
		||||
confirm_delete_selected = Confirm to delete all selected items?
 | 
			
		||||
 | 
			
		||||
name = Name
 | 
			
		||||
value = Value
 | 
			
		||||
 | 
			
		||||
[aria]
 | 
			
		||||
navbar = Navigation Bar
 | 
			
		||||
footer = Footer
 | 
			
		||||
@@ -3391,8 +3394,6 @@ owner.settings.chef.keypair.description = Generate a key pair used to authentica
 | 
			
		||||
secrets = Secrets
 | 
			
		||||
description = Secrets will be passed to certain actions and cannot be read otherwise.
 | 
			
		||||
none = There are no secrets yet.
 | 
			
		||||
value = Value
 | 
			
		||||
name = Name
 | 
			
		||||
creation = Add Secret
 | 
			
		||||
creation.name_placeholder = case-insensitive, alphanumeric characters or underscores only, cannot start with GITEA_ or GITHUB_
 | 
			
		||||
creation.value_placeholder = Input any content. Whitespace at the start and end will be omitted.
 | 
			
		||||
@@ -3462,6 +3463,22 @@ runs.no_matching_runner_helper = No matching runner: %s
 | 
			
		||||
 | 
			
		||||
need_approval_desc = Need approval to run workflows for fork pull request.
 | 
			
		||||
 | 
			
		||||
variables = Variables
 | 
			
		||||
variables.management = Variables Management
 | 
			
		||||
variables.creation = Add Variable
 | 
			
		||||
variables.none = There are no variables yet.
 | 
			
		||||
variables.deletion = Remove variable
 | 
			
		||||
variables.deletion.description = Removing a variable is permanent and cannot be undone. Continue?
 | 
			
		||||
variables.description = Variables will be passed to certain actions and cannot be read otherwise.
 | 
			
		||||
variables.id_not_exist = Variable with id %d not exists.
 | 
			
		||||
variables.edit = Edit Variable
 | 
			
		||||
variables.deletion.failed = Failed to remove variable.
 | 
			
		||||
variables.deletion.success = The variable has been removed.
 | 
			
		||||
variables.creation.failed = Failed to add variable.
 | 
			
		||||
variables.creation.success = The variable "%s" has been added.
 | 
			
		||||
variables.update.failed = Failed to edit variable.
 | 
			
		||||
variables.update.success = The variable has been edited.
 | 
			
		||||
 | 
			
		||||
[projects]
 | 
			
		||||
type-1.display_name = Individual Project
 | 
			
		||||
type-2.display_name = Repository Project
 | 
			
		||||
 
 | 
			
		||||
@@ -36,6 +36,7 @@ func pickTask(ctx context.Context, runner *actions_model.ActionRunner) (*runnerv
 | 
			
		||||
		WorkflowPayload: t.Job.WorkflowPayload,
 | 
			
		||||
		Context:         generateTaskContext(t),
 | 
			
		||||
		Secrets:         getSecretsOfTask(ctx, t),
 | 
			
		||||
		Vars:            getVariablesOfTask(ctx, t),
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if needs, err := findTaskNeeds(ctx, t); err != nil {
 | 
			
		||||
@@ -88,6 +89,29 @@ func getSecretsOfTask(ctx context.Context, task *actions_model.ActionTask) map[s
 | 
			
		||||
	return secrets
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func getVariablesOfTask(ctx context.Context, task *actions_model.ActionTask) map[string]string {
 | 
			
		||||
	variables := map[string]string{}
 | 
			
		||||
 | 
			
		||||
	// Org / User level
 | 
			
		||||
	ownerVariables, err := actions_model.FindVariables(ctx, actions_model.FindVariablesOpts{OwnerID: task.Job.Run.Repo.OwnerID})
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		log.Error("find variables of org: %d, error: %v", task.Job.Run.Repo.OwnerID, err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Repo level
 | 
			
		||||
	repoVariables, err := actions_model.FindVariables(ctx, actions_model.FindVariablesOpts{RepoID: task.Job.Run.RepoID})
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		log.Error("find variables of repo: %d, error: %v", task.Job.Run.RepoID, err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Level precedence: Repo > Org / User
 | 
			
		||||
	for _, v := range append(ownerVariables, repoVariables...) {
 | 
			
		||||
		variables[v.Name] = v.Data
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return variables
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func generateTaskContext(t *actions_model.ActionTask) *structpb.Struct {
 | 
			
		||||
	event := map[string]interface{}{}
 | 
			
		||||
	_ = json.Unmarshal([]byte(t.Job.Run.EventPayload), &event)
 | 
			
		||||
 
 | 
			
		||||
@@ -92,6 +92,12 @@ func SecretsPost(ctx *context.Context) {
 | 
			
		||||
		ctx.ServerError("getSecretsCtx", err)
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if ctx.HasError() {
 | 
			
		||||
		ctx.JSONError(ctx.GetErrMsg())
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	shared.PerformSecretsPost(
 | 
			
		||||
		ctx,
 | 
			
		||||
		sCtx.OwnerID,
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										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
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"net/http"
 | 
			
		||||
	"strings"
 | 
			
		||||
 | 
			
		||||
	"code.gitea.io/gitea/models/db"
 | 
			
		||||
	secret_model "code.gitea.io/gitea/models/secret"
 | 
			
		||||
	"code.gitea.io/gitea/modules/context"
 | 
			
		||||
	"code.gitea.io/gitea/modules/log"
 | 
			
		||||
	"code.gitea.io/gitea/modules/web"
 | 
			
		||||
	"code.gitea.io/gitea/routers/web/shared/actions"
 | 
			
		||||
	"code.gitea.io/gitea/services/forms"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
@@ -28,23 +26,20 @@ func SetSecretsContext(ctx *context.Context, ownerID, repoID int64) {
 | 
			
		||||
func PerformSecretsPost(ctx *context.Context, ownerID, repoID int64, redirectURL string) {
 | 
			
		||||
	form := web.GetForm(ctx).(*forms.AddSecretForm)
 | 
			
		||||
 | 
			
		||||
	content := form.Content
 | 
			
		||||
	// Since the content is from a form which is a textarea, the line endings are \r\n.
 | 
			
		||||
	// It's a standard behavior of HTML.
 | 
			
		||||
	// But we want to store them as \n like what GitHub does.
 | 
			
		||||
	// And users are unlikely to really need to keep the \r.
 | 
			
		||||
	// Other than this, we should respect the original content, even leading or trailing spaces.
 | 
			
		||||
	content = strings.ReplaceAll(content, "\r\n", "\n")
 | 
			
		||||
 | 
			
		||||
	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))
 | 
			
		||||
	if err := actions.NameRegexMatch(form.Name); err != nil {
 | 
			
		||||
		ctx.JSONError(ctx.Tr("secrets.creation.failed"))
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	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) {
 | 
			
		||||
@@ -52,12 +47,9 @@ func PerformSecretsDelete(ctx *context.Context, ownerID, repoID int64, redirectU
 | 
			
		||||
 | 
			
		||||
	if _, err := db.DeleteByBean(ctx, &secret_model.Secret{ID: id, OwnerID: ownerID, RepoID: repoID}); err != nil {
 | 
			
		||||
		log.Error("Delete secret %d failed: %v", id, err)
 | 
			
		||||
		ctx.Flash.Error(ctx.Tr("secrets.deletion.failed"))
 | 
			
		||||
	} else {
 | 
			
		||||
		ctx.Flash.Success(ctx.Tr("secrets.deletion.success"))
 | 
			
		||||
		ctx.JSONError(ctx.Tr("secrets.deletion.failed"))
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	ctx.JSON(http.StatusOK, map[string]interface{}{
 | 
			
		||||
		"redirect": redirectURL,
 | 
			
		||||
	})
 | 
			
		||||
	ctx.Flash.Success(ctx.Tr("secrets.deletion.success"))
 | 
			
		||||
	ctx.JSONRedirect(redirectURL)
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -307,6 +307,15 @@ func registerRoutes(m *web.Route) {
 | 
			
		||||
		m.Post("/packagist/{id}", web.Bind(forms.NewPackagistHookForm{}), repo.PackagistHooksEditPost)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	addSettingVariablesRoutes := func() {
 | 
			
		||||
		m.Group("/variables", func() {
 | 
			
		||||
			m.Get("", repo_setting.Variables)
 | 
			
		||||
			m.Post("/new", web.Bind(forms.EditVariableForm{}), repo_setting.VariableCreate)
 | 
			
		||||
			m.Post("/{variable_id}/edit", web.Bind(forms.EditVariableForm{}), repo_setting.VariableUpdate)
 | 
			
		||||
			m.Post("/{variable_id}/delete", repo_setting.VariableDelete)
 | 
			
		||||
		})
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	addSettingsSecretsRoutes := func() {
 | 
			
		||||
		m.Group("/secrets", func() {
 | 
			
		||||
			m.Get("", repo_setting.Secrets)
 | 
			
		||||
@@ -494,6 +503,7 @@ func registerRoutes(m *web.Route) {
 | 
			
		||||
			m.Get("", user_setting.RedirectToDefaultSetting)
 | 
			
		||||
			addSettingsRunnersRoutes()
 | 
			
		||||
			addSettingsSecretsRoutes()
 | 
			
		||||
			addSettingVariablesRoutes()
 | 
			
		||||
		}, actions.MustEnableActions)
 | 
			
		||||
 | 
			
		||||
		m.Get("/organization", user_setting.Organization)
 | 
			
		||||
@@ -760,6 +770,7 @@ func registerRoutes(m *web.Route) {
 | 
			
		||||
					m.Get("", org_setting.RedirectToDefaultSetting)
 | 
			
		||||
					addSettingsRunnersRoutes()
 | 
			
		||||
					addSettingsSecretsRoutes()
 | 
			
		||||
					addSettingVariablesRoutes()
 | 
			
		||||
				}, actions.MustEnableActions)
 | 
			
		||||
 | 
			
		||||
				m.RouteMethods("/delete", "GET,POST", org.SettingsDelete)
 | 
			
		||||
@@ -941,6 +952,7 @@ func registerRoutes(m *web.Route) {
 | 
			
		||||
				m.Get("", repo_setting.RedirectToDefaultSetting)
 | 
			
		||||
				addSettingsRunnersRoutes()
 | 
			
		||||
				addSettingsSecretsRoutes()
 | 
			
		||||
				addSettingVariablesRoutes()
 | 
			
		||||
			}, actions.MustEnableActions)
 | 
			
		||||
			m.Post("/migrate/cancel", repo.MigrateCancelPost) // this handler must be under "settings", otherwise this incomplete repo can't be accessed
 | 
			
		||||
		}, ctxDataSet("PageIsRepoSettings", true, "LFSStartServer", setting.LFS.StartServer))
 | 
			
		||||
 
 | 
			
		||||
@@ -367,8 +367,8 @@ func (f *AddKeyForm) Validate(req *http.Request, errs binding.Errors) binding.Er
 | 
			
		||||
 | 
			
		||||
// AddSecretForm for adding secrets
 | 
			
		||||
type AddSecretForm struct {
 | 
			
		||||
	Title   string `binding:"Required;MaxSize(50)"`
 | 
			
		||||
	Content string `binding:"Required"`
 | 
			
		||||
	Name string `binding:"Required;MaxSize(255)"`
 | 
			
		||||
	Data string `binding:"Required;MaxSize(65535)"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Validate validates the fields
 | 
			
		||||
@@ -377,6 +377,16 @@ func (f *AddSecretForm) Validate(req *http.Request, errs binding.Errors) binding
 | 
			
		||||
	return middleware.Validate(errs, ctx.Data, f, ctx.Locale)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type EditVariableForm struct {
 | 
			
		||||
	Name string `binding:"Required;MaxSize(255)"`
 | 
			
		||||
	Data string `binding:"Required;MaxSize(65535)"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (f *EditVariableForm) Validate(req *http.Request, errs binding.Errors) binding.Errors {
 | 
			
		||||
	ctx := context.GetValidateContext(req)
 | 
			
		||||
	return middleware.Validate(errs, ctx.Data, f, ctx.Locale)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// NewAccessTokenForm form for creating access token
 | 
			
		||||
type NewAccessTokenForm struct {
 | 
			
		||||
	Name  string `binding:"Required;MaxSize(255)"`
 | 
			
		||||
 
 | 
			
		||||
@@ -4,6 +4,8 @@
 | 
			
		||||
		{{template "shared/actions/runner_list" .}}
 | 
			
		||||
	{{else if eq .PageType "secrets"}}
 | 
			
		||||
		{{template "shared/secrets/add_list" .}}
 | 
			
		||||
	{{else if eq .PageType "variables"}}
 | 
			
		||||
		{{template "shared/variables/variable_list" .}}
 | 
			
		||||
	{{end}}
 | 
			
		||||
	</div>
 | 
			
		||||
{{template "org/settings/layout_footer" .}}
 | 
			
		||||
 
 | 
			
		||||
@@ -23,7 +23,7 @@
 | 
			
		||||
		</a>
 | 
			
		||||
		{{end}}
 | 
			
		||||
		{{if .EnableActions}}
 | 
			
		||||
		<details class="item toggleable-item" {{if or .PageIsSharedSettingsRunners .PageIsSharedSettingsSecrets}}open{{end}}>
 | 
			
		||||
		<details class="item toggleable-item" {{if or .PageIsSharedSettingsRunners .PageIsSharedSettingsSecrets .PageIsSharedSettingsVariables}}open{{end}}>
 | 
			
		||||
			<summary>{{.locale.Tr "actions.actions"}}</summary>
 | 
			
		||||
			<div class="menu">
 | 
			
		||||
				<a class="{{if .PageIsSharedSettingsRunners}}active {{end}}item" href="{{.OrgLink}}/settings/actions/runners">
 | 
			
		||||
@@ -32,6 +32,9 @@
 | 
			
		||||
				<a class="{{if .PageIsSharedSettingsSecrets}}active {{end}}item" href="{{.OrgLink}}/settings/actions/secrets">
 | 
			
		||||
					{{.locale.Tr "secrets.secrets"}}
 | 
			
		||||
				</a>
 | 
			
		||||
				<a class="{{if .PageIsSharedSettingsVariables}}active {{end}}item" href="{{.OrgLink}}/settings/actions/variables">
 | 
			
		||||
					{{.locale.Tr "actions.variables"}}
 | 
			
		||||
				</a>
 | 
			
		||||
			</div>
 | 
			
		||||
		</details>
 | 
			
		||||
		{{end}}
 | 
			
		||||
 
 | 
			
		||||
@@ -4,6 +4,8 @@
 | 
			
		||||
			{{template "shared/actions/runner_list" .}}
 | 
			
		||||
		{{else if eq .PageType "secrets"}}
 | 
			
		||||
			{{template "shared/secrets/add_list" .}}
 | 
			
		||||
		{{else if eq .PageType "variables"}}
 | 
			
		||||
			{{template "shared/variables/variable_list" .}}
 | 
			
		||||
		{{end}}
 | 
			
		||||
	</div>
 | 
			
		||||
{{template "repo/settings/layout_footer" .}}
 | 
			
		||||
 
 | 
			
		||||
@@ -34,7 +34,7 @@
 | 
			
		||||
			</a>
 | 
			
		||||
		{{end}}
 | 
			
		||||
		{{if and .EnableActions (not .UnitActionsGlobalDisabled) (.Permission.CanRead $.UnitTypeActions)}}
 | 
			
		||||
		<details class="item toggleable-item" {{if or .PageIsSharedSettingsRunners .PageIsSharedSettingsSecrets}}open{{end}}>
 | 
			
		||||
		<details class="item toggleable-item" {{if or .PageIsSharedSettingsRunners .PageIsSharedSettingsSecrets .PageIsSharedSettingsVariables}}open{{end}}>
 | 
			
		||||
			<summary>{{.locale.Tr "actions.actions"}}</summary>
 | 
			
		||||
			<div class="menu">
 | 
			
		||||
				<a class="{{if .PageIsSharedSettingsRunners}}active {{end}}item" href="{{.RepoLink}}/settings/actions/runners">
 | 
			
		||||
@@ -43,6 +43,9 @@
 | 
			
		||||
				<a class="{{if .PageIsSharedSettingsSecrets}}active {{end}}item" href="{{.RepoLink}}/settings/actions/secrets">
 | 
			
		||||
					{{.locale.Tr "secrets.secrets"}}
 | 
			
		||||
				</a>
 | 
			
		||||
				<a class="{{if .PageIsSharedSettingsVariables}}active {{end}}item" href="{{.RepoLink}}/settings/actions/variables">
 | 
			
		||||
					{{.locale.Tr "actions.variables"}}
 | 
			
		||||
				</a>
 | 
			
		||||
			</div>
 | 
			
		||||
		</details>
 | 
			
		||||
		{{end}}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,52 +1,40 @@
 | 
			
		||||
<h4 class="ui top attached header">
 | 
			
		||||
	{{.locale.Tr "secrets.management"}}
 | 
			
		||||
	<div class="ui right">
 | 
			
		||||
		<button class="ui primary tiny show-panel button" data-panel="#add-secret-panel">{{.locale.Tr "secrets.creation"}}</button>
 | 
			
		||||
		<button class="ui primary tiny button show-modal"
 | 
			
		||||
			data-modal="#add-secret-modal"
 | 
			
		||||
			data-modal-form.action="{{.Link}}"
 | 
			
		||||
			data-modal-header="{{.locale.Tr "secrets.creation"}}"
 | 
			
		||||
		>
 | 
			
		||||
			{{.locale.Tr "secrets.creation"}}
 | 
			
		||||
		</button>
 | 
			
		||||
	</div>
 | 
			
		||||
</h4>
 | 
			
		||||
<div class="ui attached segment">
 | 
			
		||||
	<div class="{{if not .HasError}}gt-hidden {{end}}gt-mb-4" id="add-secret-panel">
 | 
			
		||||
		<form class="ui form" action="{{.Link}}" method="post">
 | 
			
		||||
			{{.CsrfTokenHtml}}
 | 
			
		||||
			<div class="field">
 | 
			
		||||
				{{.locale.Tr "secrets.description"}}
 | 
			
		||||
			</div>
 | 
			
		||||
			<div class="field{{if .Err_Title}} error{{end}}">
 | 
			
		||||
				<label for="secret-title">{{.locale.Tr "secrets.name"}}</label>
 | 
			
		||||
				<input id="secret-title" name="title" value="{{.title}}" autofocus required pattern="^[a-zA-Z_][a-zA-Z0-9_]*$" placeholder="{{.locale.Tr "secrets.creation.name_placeholder"}}">
 | 
			
		||||
			</div>
 | 
			
		||||
			<div class="field{{if .Err_Content}} error{{end}}">
 | 
			
		||||
				<label for="secret-content">{{.locale.Tr "secrets.value"}}</label>
 | 
			
		||||
				<textarea id="secret-content" name="content" required placeholder="{{.locale.Tr "secrets.creation.value_placeholder"}}">{{.content}}</textarea>
 | 
			
		||||
			</div>
 | 
			
		||||
			<button class="ui green button">
 | 
			
		||||
				{{.locale.Tr "secrets.creation"}}
 | 
			
		||||
			</button>
 | 
			
		||||
			<button class="ui hide-panel button" data-panel="#add-secret-panel">
 | 
			
		||||
				{{.locale.Tr "cancel"}}
 | 
			
		||||
			</button>
 | 
			
		||||
		</form>
 | 
			
		||||
	</div>
 | 
			
		||||
	{{if .Secrets}}
 | 
			
		||||
	<div class="ui key list">
 | 
			
		||||
		{{range .Secrets}}
 | 
			
		||||
		<div class="item">
 | 
			
		||||
			<div class="right floated content">
 | 
			
		||||
				<button class="ui red tiny button delete-button" data-url="{{$.Link}}/delete" data-id="{{.ID}}">
 | 
			
		||||
					{{$.locale.Tr "settings.delete_key"}}
 | 
			
		||||
				</button>
 | 
			
		||||
			</div>
 | 
			
		||||
			<div class="left floated content">
 | 
			
		||||
				<i>{{svg "octicon-key" 32}}</i>
 | 
			
		||||
		{{range $i, $v := .Secrets}}
 | 
			
		||||
		<div class="item gt-df gt-ac gt-fw {{if gt $i 0}} gt-py-4{{end}}">
 | 
			
		||||
			<div class="content gt-f1 gt-df gt-js">
 | 
			
		||||
				<div class="content">
 | 
			
		||||
					<i>{{svg "octicon-key" 32}}</i>
 | 
			
		||||
				</div>
 | 
			
		||||
				<div class="content gt-ml-3 gt-ellipsis">
 | 
			
		||||
					<strong>{{$v.Name}}</strong>
 | 
			
		||||
					<div class="print meta">******</div>
 | 
			
		||||
				</div>
 | 
			
		||||
			</div>
 | 
			
		||||
			<div class="content">
 | 
			
		||||
				<strong>{{.Name}}</strong>
 | 
			
		||||
				<div class="print meta">******</div>
 | 
			
		||||
				<div class="activity meta">
 | 
			
		||||
					<i>
 | 
			
		||||
						{{$.locale.Tr "settings.added_on" (DateTime "short" .CreatedUnix) | Safe}}
 | 
			
		||||
					</i>
 | 
			
		||||
				</div>
 | 
			
		||||
				<span class="color-text-light-2 gt-mr-5">
 | 
			
		||||
					{{$.locale.Tr "settings.added_on" (DateTime "short" $v.CreatedUnix) | Safe}}
 | 
			
		||||
				</span>
 | 
			
		||||
				<button class="ui btn interact-bg link-action gt-p-3"
 | 
			
		||||
					data-url="{{$.Link}}/delete?id={{.ID}}"
 | 
			
		||||
					data-modal-confirm="{{$.locale.Tr "secrets.deletion.description"}}"
 | 
			
		||||
					data-tooltip-content="{{$.locale.Tr "secrets.deletion"}}"
 | 
			
		||||
				>
 | 
			
		||||
					{{svg "octicon-trash"}}
 | 
			
		||||
				</button>
 | 
			
		||||
			</div>
 | 
			
		||||
		</div>
 | 
			
		||||
		{{end}}
 | 
			
		||||
@@ -55,13 +43,37 @@
 | 
			
		||||
		{{.locale.Tr "secrets.none"}}
 | 
			
		||||
	{{end}}
 | 
			
		||||
</div>
 | 
			
		||||
<div class="ui g-modal-confirm delete modal">
 | 
			
		||||
 | 
			
		||||
{{/* Add secret dialog */}}
 | 
			
		||||
<div class="ui small modal" id="add-secret-modal">
 | 
			
		||||
	<div class="header">
 | 
			
		||||
		{{svg "octicon-trash"}}
 | 
			
		||||
		{{.locale.Tr "secrets.deletion"}}
 | 
			
		||||
		<span id="actions-modal-header"></span>
 | 
			
		||||
	</div>
 | 
			
		||||
	<div class="content">
 | 
			
		||||
		<p>{{.locale.Tr "secrets.deletion.description"}}</p>
 | 
			
		||||
	</div>
 | 
			
		||||
	{{template "base/modal_actions_confirm" .}}
 | 
			
		||||
	<form class="ui form form-fetch-action" method="post">
 | 
			
		||||
		<div class="content">
 | 
			
		||||
			{{.CsrfTokenHtml}}
 | 
			
		||||
			<div class="field">
 | 
			
		||||
				{{.locale.Tr "secrets.description"}}
 | 
			
		||||
			</div>
 | 
			
		||||
			<div class="field">
 | 
			
		||||
				<label for="secret-name">{{.locale.Tr "name"}}</label>
 | 
			
		||||
				<input autofocus required
 | 
			
		||||
					id="secret-name"
 | 
			
		||||
					name="name"
 | 
			
		||||
					value="{{.name}}"
 | 
			
		||||
					pattern="^[a-zA-Z_][a-zA-Z0-9_]*$"
 | 
			
		||||
					placeholder="{{.locale.Tr "secrets.creation.name_placeholder"}}"
 | 
			
		||||
				>
 | 
			
		||||
			</div>
 | 
			
		||||
			<div class="field">
 | 
			
		||||
				<label for="secret-data">{{.locale.Tr "value"}}</label>
 | 
			
		||||
				<textarea required
 | 
			
		||||
					id="secret-data"
 | 
			
		||||
					name="data"
 | 
			
		||||
					placeholder="{{.locale.Tr "secrets.creation.value_placeholder"}}"
 | 
			
		||||
				></textarea>
 | 
			
		||||
			</div>
 | 
			
		||||
		</div>
 | 
			
		||||
		{{template "base/modal_actions_confirm" (dict "locale" $.locale "ModalButtonTypes" "confirm")}}
 | 
			
		||||
	</form>
 | 
			
		||||
</div>
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										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" .}}
 | 
			
		||||
	{{else if eq .PageType "runners"}}
 | 
			
		||||
		{{template "shared/actions/runner_list" .}}
 | 
			
		||||
	{{else if eq .PageType "variables"}}
 | 
			
		||||
		{{template "shared/variables/variable_list" .}}
 | 
			
		||||
	{{end}}
 | 
			
		||||
	</div>
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -20,7 +20,7 @@
 | 
			
		||||
			{{.locale.Tr "settings.ssh_gpg_keys"}}
 | 
			
		||||
		</a>
 | 
			
		||||
		{{if .EnableActions}}
 | 
			
		||||
		<details class="item toggleable-item" {{if or .PageIsSharedSettingsRunners .PageIsSharedSettingsSecrets}}open{{end}}>
 | 
			
		||||
		<details class="item toggleable-item" {{if or .PageIsSharedSettingsRunners .PageIsSharedSettingsSecrets .PageIsSharedSettingsVariables}}open{{end}}>
 | 
			
		||||
			<summary>{{.locale.Tr "actions.actions"}}</summary>
 | 
			
		||||
			<div class="menu">
 | 
			
		||||
				<a class="{{if .PageIsSharedSettingsRunners}}active {{end}}item" href="{{AppSubUrl}}/user/settings/actions/runners">
 | 
			
		||||
@@ -29,6 +29,9 @@
 | 
			
		||||
				<a class="{{if .PageIsSharedSettingsSecrets}}active {{end}}item" href="{{AppSubUrl}}/user/settings/actions/secrets">
 | 
			
		||||
					{{.locale.Tr "secrets.secrets"}}
 | 
			
		||||
				</a>
 | 
			
		||||
				<a class="{{if .PageIsSharedSettingsVariables}}active {{end}}item" href="{{AppSubUrl}}/user/settings/actions/variables">
 | 
			
		||||
					{{.locale.Tr "actions.variables"}}
 | 
			
		||||
				</a>
 | 
			
		||||
			</div>
 | 
			
		||||
		</details>
 | 
			
		||||
		{{end}}
 | 
			
		||||
 
 | 
			
		||||
@@ -25,11 +25,15 @@
 | 
			
		||||
  display: inline-block;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.ui.modal {
 | 
			
		||||
  background: var(--color-body);
 | 
			
		||||
  box-shadow: 1px 3px 3px 0 var(--color-shadow), 1px 3px 15px 2px var(--color-shadow);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Gitea sometimes use a form in a modal dialog, then the "positive" button could submit the form directly */
 | 
			
		||||
 | 
			
		||||
.ui.modal > .content,
 | 
			
		||||
.ui.modal > form > .content {
 | 
			
		||||
  background: var(--color-body);
 | 
			
		||||
  padding: 1.5em;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -354,6 +354,57 @@ export function initGlobalLinkActions() {
 | 
			
		||||
  $('.link-action').on('click', linkAction);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function initGlobalShowModal() {
 | 
			
		||||
  // A ".show-modal" button will show a modal dialog defined by its "data-modal" attribute.
 | 
			
		||||
  // Each "data-modal-{target}" attribute will be filled to target element's value or text-content.
 | 
			
		||||
  // * First, try to query '#target'
 | 
			
		||||
  // * Then, try to query '.target'
 | 
			
		||||
  // * Then, try to query 'target' as HTML tag
 | 
			
		||||
  // If there is a ".{attr}" part like "data-modal-form.action", then the form's "action" attribute will be set.
 | 
			
		||||
  $('.show-modal').on('click', function (e) {
 | 
			
		||||
    e.preventDefault();
 | 
			
		||||
    const $el = $(this);
 | 
			
		||||
    const modalSelector = $el.attr('data-modal');
 | 
			
		||||
    const $modal = $(modalSelector);
 | 
			
		||||
    if (!$modal.length) {
 | 
			
		||||
      throw new Error('no modal for this action');
 | 
			
		||||
    }
 | 
			
		||||
    const modalAttrPrefix = 'data-modal-';
 | 
			
		||||
    for (const attrib of this.attributes) {
 | 
			
		||||
      if (!attrib.name.startsWith(modalAttrPrefix)) {
 | 
			
		||||
        continue;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      const attrTargetCombo = attrib.name.substring(modalAttrPrefix.length);
 | 
			
		||||
      const [attrTargetName, attrTargetAttr] = attrTargetCombo.split('.');
 | 
			
		||||
      // try to find target by: "#target" -> ".target" -> "target tag"
 | 
			
		||||
      let $attrTarget = $modal.find(`#${attrTargetName}`);
 | 
			
		||||
      if (!$attrTarget.length) $attrTarget = $modal.find(`.${attrTargetName}`);
 | 
			
		||||
      if (!$attrTarget.length) $attrTarget = $modal.find(`${attrTargetName}`);
 | 
			
		||||
      if (!$attrTarget.length) continue; // TODO: show errors in dev mode to remind developers that there is a bug
 | 
			
		||||
 | 
			
		||||
      if (attrTargetAttr) {
 | 
			
		||||
        $attrTarget[0][attrTargetAttr] = attrib.value;
 | 
			
		||||
      } else if ($attrTarget.is('input') || $attrTarget.is('textarea')) {
 | 
			
		||||
        $attrTarget.val(attrib.value); // FIXME: add more supports like checkbox
 | 
			
		||||
      } else {
 | 
			
		||||
        $attrTarget.text(attrib.value); // FIXME: it should be more strict here, only handle div/span/p
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    const colorPickers = $modal.find('.color-picker');
 | 
			
		||||
    if (colorPickers.length > 0) {
 | 
			
		||||
      initCompColorPicker(); // FIXME: this might cause duplicate init
 | 
			
		||||
    }
 | 
			
		||||
    $modal.modal('setting', {
 | 
			
		||||
      onApprove: () => {
 | 
			
		||||
        // "form-fetch-action" can handle network errors gracefully,
 | 
			
		||||
        // so keep the modal dialog to make users can re-submit the form if anything wrong happens.
 | 
			
		||||
        if ($modal.find('.form-fetch-action').length) return false;
 | 
			
		||||
      },
 | 
			
		||||
    }).modal('show');
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function initGlobalButtons() {
 | 
			
		||||
  // There are many "cancel button" elements in modal dialogs, Fomantic UI expects they are button-like elements but never submit a form.
 | 
			
		||||
  // However, Gitea misuses the modal dialog and put the cancel buttons inside forms, so we must prevent the form submission.
 | 
			
		||||
@@ -391,27 +442,7 @@ export function initGlobalButtons() {
 | 
			
		||||
    alert('Nothing to hide');
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  $('.show-modal').on('click', function (e) {
 | 
			
		||||
    e.preventDefault();
 | 
			
		||||
    const modalDiv = $($(this).attr('data-modal'));
 | 
			
		||||
    for (const attrib of this.attributes) {
 | 
			
		||||
      if (!attrib.name.startsWith('data-modal-')) {
 | 
			
		||||
        continue;
 | 
			
		||||
      }
 | 
			
		||||
      const id = attrib.name.substring(11);
 | 
			
		||||
      const target = modalDiv.find(`#${id}`);
 | 
			
		||||
      if (target.is('input')) {
 | 
			
		||||
        target.val(attrib.value);
 | 
			
		||||
      } else {
 | 
			
		||||
        target.text(attrib.value);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    modalDiv.modal('show');
 | 
			
		||||
    const colorPickers = $($(this).attr('data-modal')).find('.color-picker');
 | 
			
		||||
    if (colorPickers.length > 0) {
 | 
			
		||||
      initCompColorPicker();
 | 
			
		||||
    }
 | 
			
		||||
  });
 | 
			
		||||
  initGlobalShowModal();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user