Co-Author: @silverwind @wxiaoguang Replace: #24404 See: - [defining configuration variables for multiple workflows](https://docs.github.com/en/actions/learn-github-actions/variables#defining-configuration-variables-for-multiple-workflows) - [vars context](https://docs.github.com/en/actions/learn-github-actions/contexts#vars-context) Related to: - [x] protocol: https://gitea.com/gitea/actions-proto-def/pulls/7 - [x] act_runner: https://gitea.com/gitea/act_runner/pulls/157 - [x] act: https://gitea.com/gitea/act/pulls/43 #### Screenshoot Create Variable: ![image](https://user-images.githubusercontent.com/33891828/236758288-032b7f64-44e7-48ea-b07d-de8b8b0e3729.png) ![image](https://user-images.githubusercontent.com/33891828/236758174-5203f64c-1d0e-4737-a5b0-62061dee86f8.png) Workflow: ```yaml test_vars: runs-on: ubuntu-latest steps: - name: Print Custom Variables run: echo "${{ vars.test_key }}" - name: Try to print a non-exist var run: echo "${{ vars.NON_EXIST_VAR }}" ``` Actions Log: ![image](https://user-images.githubusercontent.com/33891828/236759075-af0c5950-368d-4758-a8ac-47a96e43b6e2.png) --- This PR just implement the org / user (depends on the owner of the current repository) and repo level variables, The Environment level variables have not been implemented. Because [Environment](https://docs.github.com/en/actions/deployment/targeting-different-environments/using-environments-for-deployment#about-environments) is a module separate from `Actions`. Maybe it would be better to create a new PR to do it. --------- Co-authored-by: silverwind <me@silverwind.io> Co-authored-by: wxiaoguang <wxiaoguang@gmail.com> Co-authored-by: Giteabot <teabot@gitea.io>tags/v1.21.0-rc0
@@ -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 |
@@ -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, |
@@ -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) | |||
} |
@@ -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") | |||
if err := actions.NameRegexMatch(form.Name); err != nil { | |||
ctx.JSONError(ctx.Tr("secrets.creation.failed")) | |||
return | |||
} | |||
s, err := secret_model.InsertEncryptedSecret(ctx, ownerID, repoID, form.Title, content) | |||
s, err := secret_model.InsertEncryptedSecret(ctx, ownerID, repoID, form.Name, actions.ReserveLineBreakForTextarea(form.Data)) | |||
if err != nil { | |||
log.Error("InsertEncryptedSecret: %v", err) | |||
ctx.Flash.Error(ctx.Tr("secrets.creation.failed")) | |||
} else { | |||
ctx.Flash.Success(ctx.Tr("secrets.creation.success", s.Name)) | |||
ctx.JSONError(ctx.Tr("secrets.creation.failed")) | |||
return | |||
} | |||
ctx.Redirect(redirectURL) | |||
ctx.Flash.Success(ctx.Tr("secrets.creation.success", s.Name)) | |||
ctx.JSONRedirect(redirectURL) | |||
} | |||
func PerformSecretsDelete(ctx *context.Context, ownerID, repoID int64, redirectURL string) { | |||
@@ -52,12 +47,9 @@ func PerformSecretsDelete(ctx *context.Context, ownerID, repoID int64, redirectU | |||
if _, err := db.DeleteByBean(ctx, &secret_model.Secret{ID: id, OwnerID: ownerID, RepoID: repoID}); err != nil { | |||
log.Error("Delete secret %d failed: %v", id, err) | |||
ctx.Flash.Error(ctx.Tr("secrets.deletion.failed")) | |||
} else { | |||
ctx.Flash.Success(ctx.Tr("secrets.deletion.success")) | |||
ctx.JSONError(ctx.Tr("secrets.deletion.failed")) | |||
return | |||
} | |||
ctx.JSON(http.StatusOK, map[string]interface{}{ | |||
"redirect": redirectURL, | |||
}) | |||
ctx.Flash.Success(ctx.Tr("secrets.deletion.success")) | |||
ctx.JSONRedirect(redirectURL) | |||
} |
@@ -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"}} | |||
</div> | |||
<div class="content"> | |||
<p>{{.locale.Tr "secrets.deletion.description"}}</p> | |||
<span id="actions-modal-header"></span> | |||
</div> | |||
{{template "base/modal_actions_confirm" .}} | |||
<form class="ui form form-fetch-action" method="post"> | |||
<div class="content"> | |||
{{.CsrfTokenHtml}} | |||
<div class="field"> | |||
{{.locale.Tr "secrets.description"}} | |||
</div> | |||
<div class="field"> | |||
<label for="secret-name">{{.locale.Tr "name"}}</label> | |||
<input autofocus required | |||
id="secret-name" | |||
name="name" | |||
value="{{.name}}" | |||
pattern="^[a-zA-Z_][a-zA-Z0-9_]*$" | |||
placeholder="{{.locale.Tr "secrets.creation.name_placeholder"}}" | |||
> | |||
</div> | |||
<div class="field"> | |||
<label for="secret-data">{{.locale.Tr "value"}}</label> | |||
<textarea required | |||
id="secret-data" | |||
name="data" | |||
placeholder="{{.locale.Tr "secrets.creation.value_placeholder"}}" | |||
></textarea> | |||
</div> | |||
</div> | |||
{{template "base/modal_actions_confirm" (dict "locale" $.locale "ModalButtonTypes" "confirm")}} | |||
</form> | |||
</div> |
@@ -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(); | |||
} | |||
/** |