]> source.dussan.org Git - gitea.git/commitdiff
Refactor secrets modification logic (#26873)
authorKN4CK3R <admin@oldschoolhack.me>
Tue, 5 Sep 2023 15:21:02 +0000 (17:21 +0200)
committerGitHub <noreply@github.com>
Tue, 5 Sep 2023 15:21:02 +0000 (15:21 +0000)
- Share code between web and api
- Add some tests

models/secret/secret.go
routers/api/v1/org/action.go
routers/api/v1/repo/action.go
routers/api/v1/user/action.go
routers/web/shared/actions/variables.go
routers/web/shared/secrets/secrets.go
services/secrets/secrets.go [new file with mode: 0644]
services/secrets/validation.go [new file with mode: 0644]
templates/swagger/v1_json.tmpl
tests/integration/api_repo_secrets_test.go [new file with mode: 0644]

index 1cb816e9db27377392ae26af7e9fd48364c92dd0..8df46b6c38ec7c8a0e9a9f3851c2a77e44c52b5e 100644 (file)
@@ -33,12 +33,6 @@ type ErrSecretNotFound struct {
        Name string
 }
 
-// IsErrSecretNotFound checks if an error is a ErrSecretNotFound.
-func IsErrSecretNotFound(err error) bool {
-       _, ok := err.(ErrSecretNotFound)
-       return ok
-}
-
 func (err ErrSecretNotFound) Error() string {
        return fmt.Sprintf("secret was not found [name: %s]", err.Name)
 }
@@ -47,23 +41,18 @@ func (err ErrSecretNotFound) Unwrap() error {
        return util.ErrNotExist
 }
 
-// newSecret Creates a new already encrypted secret
-func newSecret(ownerID, repoID int64, name, data string) *Secret {
-       return &Secret{
-               OwnerID: ownerID,
-               RepoID:  repoID,
-               Name:    strings.ToUpper(name),
-               Data:    data,
-       }
-}
-
 // InsertEncryptedSecret Creates, encrypts, and validates a new secret with yet unencrypted data and insert into database
 func InsertEncryptedSecret(ctx context.Context, ownerID, repoID int64, name, data string) (*Secret, error) {
        encrypted, err := secret_module.EncryptSecret(setting.SecretKey, data)
        if err != nil {
                return nil, err
        }
-       secret := newSecret(ownerID, repoID, name, encrypted)
+       secret := &Secret{
+               OwnerID: ownerID,
+               RepoID:  repoID,
+               Name:    strings.ToUpper(name),
+               Data:    encrypted,
+       }
        if err := secret.Validate(); err != nil {
                return secret, err
        }
@@ -83,8 +72,10 @@ func (s *Secret) Validate() error {
 
 type FindSecretsOptions struct {
        db.ListOptions
-       OwnerID int64
-       RepoID  int64
+       OwnerID  int64
+       RepoID   int64
+       SecretID int64
+       Name     string
 }
 
 func (opts *FindSecretsOptions) toConds() builder.Cond {
@@ -95,6 +86,12 @@ func (opts *FindSecretsOptions) toConds() builder.Cond {
        if opts.RepoID > 0 {
                cond = cond.And(builder.Eq{"repo_id": opts.RepoID})
        }
+       if opts.SecretID != 0 {
+               cond = cond.And(builder.Eq{"id": opts.SecretID})
+       }
+       if opts.Name != "" {
+               cond = cond.And(builder.Eq{"name": strings.ToUpper(opts.Name)})
+       }
 
        return cond
 }
@@ -116,75 +113,18 @@ func CountSecrets(ctx context.Context, opts *FindSecretsOptions) (int64, error)
 }
 
 // UpdateSecret changes org or user reop secret.
-func UpdateSecret(ctx context.Context, orgID, repoID int64, name, data string) error {
-       sc := new(Secret)
-       name = strings.ToUpper(name)
-       has, err := db.GetEngine(ctx).
-               Where("owner_id=?", orgID).
-               And("repo_id=?", repoID).
-               And("name=?", name).
-               Get(sc)
-       if err != nil {
-               return err
-       } else if !has {
-               return ErrSecretNotFound{Name: name}
-       }
-
+func UpdateSecret(ctx context.Context, secretID int64, data string) error {
        encrypted, err := secret_module.EncryptSecret(setting.SecretKey, data)
        if err != nil {
                return err
        }
 
-       sc.Data = encrypted
-       _, err = db.GetEngine(ctx).ID(sc.ID).Cols("data").Update(sc)
-       return err
-}
-
-// DeleteSecret deletes secret from an organization.
-func DeleteSecret(ctx context.Context, orgID, repoID int64, name string) error {
-       sc := new(Secret)
-       has, err := db.GetEngine(ctx).
-               Where("owner_id=?", orgID).
-               And("repo_id=?", repoID).
-               And("name=?", strings.ToUpper(name)).
-               Get(sc)
-       if err != nil {
-               return err
-       } else if !has {
-               return ErrSecretNotFound{Name: name}
-       }
-
-       if _, err := db.GetEngine(ctx).ID(sc.ID).Delete(new(Secret)); err != nil {
-               return fmt.Errorf("Delete: %w", err)
-       }
-
-       return nil
-}
-
-// CreateOrUpdateSecret creates or updates a secret and returns true if it was created
-func CreateOrUpdateSecret(ctx context.Context, orgID, repoID int64, name, data string) (bool, error) {
-       sc := new(Secret)
-       name = strings.ToUpper(name)
-       has, err := db.GetEngine(ctx).
-               Where("owner_id=?", orgID).
-               And("repo_id=?", repoID).
-               And("name=?", name).
-               Get(sc)
-       if err != nil {
-               return false, err
+       s := &Secret{
+               Data: encrypted,
        }
-
-       if !has {
-               _, err = InsertEncryptedSecret(ctx, orgID, repoID, name, data)
-               if err != nil {
-                       return false, err
-               }
-               return true, nil
+       affected, err := db.GetEngine(ctx).ID(secretID).Cols("data").Update(s)
+       if affected != 1 {
+               return ErrSecretNotFound{}
        }
-
-       if err := UpdateSecret(ctx, orgID, repoID, name, data); err != nil {
-               return false, err
-       }
-
-       return false, nil
+       return err
 }
index a04058be1974aa8de3ff40a9f0c0b3514e10b28e..e50a77f362e2d1e7d4652f3406847cd4c5bdc535 100644 (file)
@@ -4,14 +4,16 @@
 package org
 
 import (
+       "errors"
        "net/http"
 
        secret_model "code.gitea.io/gitea/models/secret"
        "code.gitea.io/gitea/modules/context"
        api "code.gitea.io/gitea/modules/structs"
+       "code.gitea.io/gitea/modules/util"
        "code.gitea.io/gitea/modules/web"
        "code.gitea.io/gitea/routers/api/v1/utils"
-       "code.gitea.io/gitea/routers/web/shared/actions"
+       secret_service "code.gitea.io/gitea/services/secrets"
 )
 
 // ListActionsSecrets list an organization's actions secrets
@@ -39,11 +41,6 @@ func ListActionsSecrets(ctx *context.APIContext) {
        //   "200":
        //     "$ref": "#/responses/SecretList"
 
-       listActionsSecrets(ctx)
-}
-
-// listActionsSecrets list an organization's actions secrets
-func listActionsSecrets(ctx *context.APIContext) {
        opts := &secret_model.FindSecretsOptions{
                OwnerID:     ctx.Org.Organization.ID,
                ListOptions: utils.GetListOptions(ctx),
@@ -104,25 +101,28 @@ func CreateOrUpdateSecret(ctx *context.APIContext) {
        //     description: response when updating a secret
        //   "400":
        //     "$ref": "#/responses/error"
-       //   "403":
-       //     "$ref": "#/responses/forbidden"
-       secretName := ctx.Params(":secretname")
-       if err := actions.NameRegexMatch(secretName); err != nil {
-               ctx.Error(http.StatusBadRequest, "CreateOrUpdateSecret", err)
-               return
-       }
+       //   "404":
+       //     "$ref": "#/responses/notFound"
+
        opt := web.GetForm(ctx).(*api.CreateOrUpdateSecretOption)
-       isCreated, err := secret_model.CreateOrUpdateSecret(ctx, ctx.Org.Organization.ID, 0, secretName, opt.Data)
+
+       _, created, err := secret_service.CreateOrUpdateSecret(ctx, ctx.Org.Organization.ID, 0, ctx.Params("secretname"), opt.Data)
        if err != nil {
-               ctx.Error(http.StatusInternalServerError, "CreateOrUpdateSecret", err)
+               if errors.Is(err, util.ErrInvalidArgument) {
+                       ctx.Error(http.StatusBadRequest, "CreateOrUpdateSecret", err)
+               } else if errors.Is(err, util.ErrNotExist) {
+                       ctx.Error(http.StatusNotFound, "CreateOrUpdateSecret", err)
+               } else {
+                       ctx.Error(http.StatusInternalServerError, "CreateOrUpdateSecret", err)
+               }
                return
        }
-       if isCreated {
+
+       if created {
                ctx.Status(http.StatusCreated)
-               return
+       } else {
+               ctx.Status(http.StatusNoContent)
        }
-
-       ctx.Status(http.StatusNoContent)
 }
 
 // DeleteSecret delete one secret of the organization
@@ -148,22 +148,20 @@ func DeleteSecret(ctx *context.APIContext) {
        // responses:
        //   "204":
        //     description: delete one secret of the organization
-       //   "403":
-       //     "$ref": "#/responses/forbidden"
-       secretName := ctx.Params(":secretname")
-       if err := actions.NameRegexMatch(secretName); err != nil {
-               ctx.Error(http.StatusBadRequest, "DeleteSecret", err)
-               return
-       }
-       err := secret_model.DeleteSecret(
-               ctx, ctx.Org.Organization.ID, 0, secretName,
-       )
-       if secret_model.IsErrSecretNotFound(err) {
-               ctx.NotFound(err)
-               return
-       }
+       //   "400":
+       //     "$ref": "#/responses/error"
+       //   "404":
+       //     "$ref": "#/responses/notFound"
+
+       err := secret_service.DeleteSecretByName(ctx, ctx.Org.Organization.ID, 0, ctx.Params("secretname"))
        if err != nil {
-               ctx.Error(http.StatusInternalServerError, "DeleteSecret", err)
+               if errors.Is(err, util.ErrInvalidArgument) {
+                       ctx.Error(http.StatusBadRequest, "DeleteSecret", err)
+               } else if errors.Is(err, util.ErrNotExist) {
+                       ctx.Error(http.StatusNotFound, "DeleteSecret", err)
+               } else {
+                       ctx.Error(http.StatusInternalServerError, "DeleteSecret", err)
+               }
                return
        }
 
index b7642b6af9f8371f78eb7ba7dee4badb8f7e037b..039cdadac9c144ef99adee803e2e65c03f93bc13 100644 (file)
@@ -4,13 +4,14 @@
 package repo
 
 import (
+       "errors"
        "net/http"
 
-       secret_model "code.gitea.io/gitea/models/secret"
        "code.gitea.io/gitea/modules/context"
        api "code.gitea.io/gitea/modules/structs"
+       "code.gitea.io/gitea/modules/util"
        "code.gitea.io/gitea/modules/web"
-       "code.gitea.io/gitea/routers/web/shared/actions"
+       secret_service "code.gitea.io/gitea/services/secrets"
 )
 
 // create or update one secret of the repository
@@ -49,29 +50,31 @@ func CreateOrUpdateSecret(ctx *context.APIContext) {
        //     description: response when updating a secret
        //   "400":
        //     "$ref": "#/responses/error"
-       //   "403":
-       //     "$ref": "#/responses/forbidden"
+       //   "404":
+       //     "$ref": "#/responses/notFound"
 
        owner := ctx.Repo.Owner
        repo := ctx.Repo.Repository
 
-       secretName := ctx.Params(":secretname")
-       if err := actions.NameRegexMatch(secretName); err != nil {
-               ctx.Error(http.StatusBadRequest, "CreateOrUpdateSecret", err)
-               return
-       }
        opt := web.GetForm(ctx).(*api.CreateOrUpdateSecretOption)
-       isCreated, err := secret_model.CreateOrUpdateSecret(ctx, owner.ID, repo.ID, secretName, opt.Data)
+
+       _, created, err := secret_service.CreateOrUpdateSecret(ctx, owner.ID, repo.ID, ctx.Params("secretname"), opt.Data)
        if err != nil {
-               ctx.Error(http.StatusInternalServerError, "CreateOrUpdateSecret", err)
+               if errors.Is(err, util.ErrInvalidArgument) {
+                       ctx.Error(http.StatusBadRequest, "CreateOrUpdateSecret", err)
+               } else if errors.Is(err, util.ErrNotExist) {
+                       ctx.Error(http.StatusNotFound, "CreateOrUpdateSecret", err)
+               } else {
+                       ctx.Error(http.StatusInternalServerError, "CreateOrUpdateSecret", err)
+               }
                return
        }
-       if isCreated {
+
+       if created {
                ctx.Status(http.StatusCreated)
-               return
+       } else {
+               ctx.Status(http.StatusNoContent)
        }
-
-       ctx.Status(http.StatusNoContent)
 }
 
 // DeleteSecret delete one secret of the repository
@@ -102,26 +105,23 @@ func DeleteSecret(ctx *context.APIContext) {
        // responses:
        //   "204":
        //     description: delete one secret of the organization
-       //   "403":
-       //     "$ref": "#/responses/forbidden"
+       //   "400":
+       //     "$ref": "#/responses/error"
+       //   "404":
+       //     "$ref": "#/responses/notFound"
 
        owner := ctx.Repo.Owner
        repo := ctx.Repo.Repository
 
-       secretName := ctx.Params(":secretname")
-       if err := actions.NameRegexMatch(secretName); err != nil {
-               ctx.Error(http.StatusBadRequest, "DeleteSecret", err)
-               return
-       }
-       err := secret_model.DeleteSecret(
-               ctx, owner.ID, repo.ID, secretName,
-       )
-       if secret_model.IsErrSecretNotFound(err) {
-               ctx.NotFound(err)
-               return
-       }
+       err := secret_service.DeleteSecretByName(ctx, owner.ID, repo.ID, ctx.Params("secretname"))
        if err != nil {
-               ctx.Error(http.StatusInternalServerError, "DeleteSecret", err)
+               if errors.Is(err, util.ErrInvalidArgument) {
+                       ctx.Error(http.StatusBadRequest, "DeleteSecret", err)
+               } else if errors.Is(err, util.ErrNotExist) {
+                       ctx.Error(http.StatusNotFound, "DeleteSecret", err)
+               } else {
+                       ctx.Error(http.StatusInternalServerError, "DeleteSecret", err)
+               }
                return
        }
 
index 885e4114620038661b6b4011374d369ff74bf35f..cbe332a7798f7385432bb6e4706b2497f76f2ae4 100644 (file)
@@ -4,13 +4,14 @@
 package user
 
 import (
+       "errors"
        "net/http"
 
-       secret_model "code.gitea.io/gitea/models/secret"
        "code.gitea.io/gitea/modules/context"
        api "code.gitea.io/gitea/modules/structs"
+       "code.gitea.io/gitea/modules/util"
        "code.gitea.io/gitea/modules/web"
-       "code.gitea.io/gitea/routers/web/shared/actions"
+       secret_service "code.gitea.io/gitea/services/secrets"
 )
 
 // create or update one secret of the user scope
@@ -42,23 +43,25 @@ func CreateOrUpdateSecret(ctx *context.APIContext) {
        //   "404":
        //     "$ref": "#/responses/notFound"
 
-       secretName := ctx.Params(":secretname")
-       if err := actions.NameRegexMatch(secretName); err != nil {
-               ctx.Error(http.StatusBadRequest, "CreateOrUpdateSecret", err)
-               return
-       }
        opt := web.GetForm(ctx).(*api.CreateOrUpdateSecretOption)
-       isCreated, err := secret_model.CreateOrUpdateSecret(ctx, ctx.Doer.ID, 0, secretName, opt.Data)
+
+       _, created, err := secret_service.CreateOrUpdateSecret(ctx, ctx.Doer.ID, 0, ctx.Params("secretname"), opt.Data)
        if err != nil {
-               ctx.Error(http.StatusInternalServerError, "CreateOrUpdateSecret", err)
+               if errors.Is(err, util.ErrInvalidArgument) {
+                       ctx.Error(http.StatusBadRequest, "CreateOrUpdateSecret", err)
+               } else if errors.Is(err, util.ErrNotExist) {
+                       ctx.Error(http.StatusNotFound, "CreateOrUpdateSecret", err)
+               } else {
+                       ctx.Error(http.StatusInternalServerError, "CreateOrUpdateSecret", err)
+               }
                return
        }
-       if isCreated {
+
+       if created {
                ctx.Status(http.StatusCreated)
-               return
+       } else {
+               ctx.Status(http.StatusNoContent)
        }
-
-       ctx.Status(http.StatusNoContent)
 }
 
 // DeleteSecret delete one secret of the user scope
@@ -84,20 +87,15 @@ func DeleteSecret(ctx *context.APIContext) {
        //   "404":
        //     "$ref": "#/responses/notFound"
 
-       secretName := ctx.Params(":secretname")
-       if err := actions.NameRegexMatch(secretName); err != nil {
-               ctx.Error(http.StatusBadRequest, "DeleteSecret", err)
-               return
-       }
-       err := secret_model.DeleteSecret(
-               ctx, ctx.Doer.ID, 0, secretName,
-       )
-       if secret_model.IsErrSecretNotFound(err) {
-               ctx.NotFound(err)
-               return
-       }
+       err := secret_service.DeleteSecretByName(ctx, ctx.Doer.ID, 0, ctx.Params("secretname"))
        if err != nil {
-               ctx.Error(http.StatusInternalServerError, "DeleteSecret", err)
+               if errors.Is(err, util.ErrInvalidArgument) {
+                       ctx.Error(http.StatusBadRequest, "DeleteSecret", err)
+               } else if errors.Is(err, util.ErrNotExist) {
+                       ctx.Error(http.StatusNotFound, "DeleteSecret", err)
+               } else {
+                       ctx.Error(http.StatusInternalServerError, "DeleteSecret", err)
+               }
                return
        }
 
index 8d1516c91ceb3f99bb7aad60e1a0ea92a74c0a2d..341c18f589c584bd283dd3be75a978deefe2c1b5 100644 (file)
@@ -14,6 +14,7 @@ import (
        "code.gitea.io/gitea/modules/log"
        "code.gitea.io/gitea/modules/web"
        "code.gitea.io/gitea/services/forms"
+       secret_service "code.gitea.io/gitea/services/secrets"
 )
 
 func SetVariablesContext(ctx *context.Context, ownerID, repoID int64) {
@@ -33,20 +34,9 @@ func SetVariablesContext(ctx *context.Context, ownerID, repoID int64) {
 // 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")
@@ -58,7 +48,7 @@ func envNameCIRegexMatch(name string) error {
 func CreateVariable(ctx *context.Context, ownerID, repoID int64, redirectURL string) {
        form := web.GetForm(ctx).(*forms.EditVariableForm)
 
-       if err := NameRegexMatch(form.Name); err != nil {
+       if err := secret_service.ValidateName(form.Name); err != nil {
                ctx.JSONError(err.Error())
                return
        }
@@ -82,7 +72,7 @@ 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 {
+       if err := secret_service.ValidateName(form.Name); err != nil {
                ctx.JSONError(err.Error())
                return
        }
index c09ce51499a400457ca6b8609bf62b0f30670947..875cb0cfec74e5630ee3ea95c978402de405d04c 100644 (file)
@@ -4,13 +4,13 @@
 package secrets
 
 import (
-       "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"
+       secret_service "code.gitea.io/gitea/services/secrets"
 )
 
 func SetSecretsContext(ctx *context.Context, ownerID, repoID int64) {
@@ -26,14 +26,9 @@ func SetSecretsContext(ctx *context.Context, ownerID, repoID int64) {
 func PerformSecretsPost(ctx *context.Context, ownerID, repoID int64, redirectURL string) {
        form := web.GetForm(ctx).(*forms.AddSecretForm)
 
-       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.Name, actions.ReserveLineBreakForTextarea(form.Data))
+       s, _, err := secret_service.CreateOrUpdateSecret(ctx, ownerID, repoID, form.Name, actions.ReserveLineBreakForTextarea(form.Data))
        if err != nil {
-               log.Error("InsertEncryptedSecret: %v", err)
+               log.Error("CreateOrUpdateSecret failed: %v", err)
                ctx.JSONError(ctx.Tr("secrets.creation.failed"))
                return
        }
@@ -45,11 +40,13 @@ func PerformSecretsPost(ctx *context.Context, ownerID, repoID int64, redirectURL
 func PerformSecretsDelete(ctx *context.Context, ownerID, repoID int64, redirectURL string) {
        id := ctx.FormInt64("id")
 
-       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)
+       err := secret_service.DeleteSecretByID(ctx, ownerID, repoID, id)
+       if err != nil {
+               log.Error("DeleteSecretByID(%d) failed: %v", id, err)
                ctx.JSONError(ctx.Tr("secrets.deletion.failed"))
                return
        }
+
        ctx.Flash.Success(ctx.Tr("secrets.deletion.success"))
        ctx.JSONRedirect(redirectURL)
 }
diff --git a/services/secrets/secrets.go b/services/secrets/secrets.go
new file mode 100644 (file)
index 0000000..1c4772d
--- /dev/null
@@ -0,0 +1,83 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package secrets
+
+import (
+       "context"
+
+       "code.gitea.io/gitea/models/db"
+       secret_model "code.gitea.io/gitea/models/secret"
+)
+
+func CreateOrUpdateSecret(ctx context.Context, ownerID, repoID int64, name, data string) (*secret_model.Secret, bool, error) {
+       if err := ValidateName(name); err != nil {
+               return nil, false, err
+       }
+
+       s, err := secret_model.FindSecrets(ctx, secret_model.FindSecretsOptions{
+               OwnerID: ownerID,
+               RepoID:  repoID,
+               Name:    name,
+       })
+       if err != nil {
+               return nil, false, err
+       }
+
+       if len(s) == 0 {
+               s, err := secret_model.InsertEncryptedSecret(ctx, ownerID, repoID, name, data)
+               if err != nil {
+                       return nil, false, err
+               }
+               return s, true, nil
+       }
+
+       if err := secret_model.UpdateSecret(ctx, s[0].ID, data); err != nil {
+               return nil, false, err
+       }
+
+       return s[0], false, nil
+}
+
+func DeleteSecretByID(ctx context.Context, ownerID, repoID, secretID int64) error {
+       s, err := secret_model.FindSecrets(ctx, secret_model.FindSecretsOptions{
+               OwnerID:  ownerID,
+               RepoID:   repoID,
+               SecretID: secretID,
+       })
+       if err != nil {
+               return err
+       }
+       if len(s) != 1 {
+               return secret_model.ErrSecretNotFound{}
+       }
+
+       return deleteSecret(ctx, s[0])
+}
+
+func DeleteSecretByName(ctx context.Context, ownerID, repoID int64, name string) error {
+       if err := ValidateName(name); err != nil {
+               return err
+       }
+
+       s, err := secret_model.FindSecrets(ctx, secret_model.FindSecretsOptions{
+               OwnerID: ownerID,
+               RepoID:  repoID,
+               Name:    name,
+       })
+       if err != nil {
+               return err
+       }
+       if len(s) != 1 {
+               return secret_model.ErrSecretNotFound{}
+       }
+
+       return deleteSecret(ctx, s[0])
+}
+
+func deleteSecret(ctx context.Context, s *secret_model.Secret) error {
+       if _, err := db.DeleteByID(ctx, s.ID, s); err != nil {
+               return err
+       }
+       return nil
+}
diff --git a/services/secrets/validation.go b/services/secrets/validation.go
new file mode 100644 (file)
index 0000000..3db5b96
--- /dev/null
@@ -0,0 +1,25 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package secrets
+
+import (
+       "regexp"
+
+       "code.gitea.io/gitea/modules/util"
+)
+
+// https://docs.github.com/en/actions/security-guides/encrypted-secrets#naming-your-secrets
+var (
+       namePattern            = regexp.MustCompile("(?i)^[A-Z_][A-Z0-9_]*$")
+       forbiddenPrefixPattern = regexp.MustCompile("(?i)^GIT(EA|HUB)_")
+
+       ErrInvalidName = util.NewInvalidArgumentErrorf("invalid secret name")
+)
+
+func ValidateName(name string) error {
+       if !namePattern.MatchString(name) || forbiddenPrefixPattern.MatchString(name) {
+               return ErrInvalidName
+       }
+       return nil
+}
index 94955c5fd715c5b3680d309e094a403e43af802d..03beca3f73de5af776d9da7452961315318a2011 100644 (file)
           "400": {
             "$ref": "#/responses/error"
           },
-          "403": {
-            "$ref": "#/responses/forbidden"
+          "404": {
+            "$ref": "#/responses/notFound"
           }
         }
       },
           "204": {
             "description": "delete one secret of the organization"
           },
-          "403": {
-            "$ref": "#/responses/forbidden"
+          "400": {
+            "$ref": "#/responses/error"
+          },
+          "404": {
+            "$ref": "#/responses/notFound"
           }
         }
       }
           "400": {
             "$ref": "#/responses/error"
           },
-          "403": {
-            "$ref": "#/responses/forbidden"
+          "404": {
+            "$ref": "#/responses/notFound"
           }
         }
       },
           "204": {
             "description": "delete one secret of the organization"
           },
-          "403": {
-            "$ref": "#/responses/forbidden"
+          "400": {
+            "$ref": "#/responses/error"
+          },
+          "404": {
+            "$ref": "#/responses/notFound"
           }
         }
       }
diff --git a/tests/integration/api_repo_secrets_test.go b/tests/integration/api_repo_secrets_test.go
new file mode 100644 (file)
index 0000000..263ad16
--- /dev/null
@@ -0,0 +1,103 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+       "fmt"
+       "net/http"
+       "testing"
+
+       auth_model "code.gitea.io/gitea/models/auth"
+       repo_model "code.gitea.io/gitea/models/repo"
+       "code.gitea.io/gitea/models/unittest"
+       user_model "code.gitea.io/gitea/models/user"
+       api "code.gitea.io/gitea/modules/structs"
+       "code.gitea.io/gitea/tests"
+)
+
+func TestAPIRepoSecrets(t *testing.T) {
+       defer tests.PrepareTestEnv(t)()
+
+       repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+       user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+       session := loginUser(t, user.Name)
+       token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
+
+       t.Run("Create", func(t *testing.T) {
+               cases := []struct {
+                       Name           string
+                       ExpectedStatus int
+               }{
+                       {
+                               Name:           "",
+                               ExpectedStatus: http.StatusNotFound,
+                       },
+                       {
+                               Name:           "-",
+                               ExpectedStatus: http.StatusBadRequest,
+                       },
+                       {
+                               Name:           "_",
+                               ExpectedStatus: http.StatusCreated,
+                       },
+                       {
+                               Name:           "secret",
+                               ExpectedStatus: http.StatusCreated,
+                       },
+                       {
+                               Name:           "2secret",
+                               ExpectedStatus: http.StatusBadRequest,
+                       },
+                       {
+                               Name:           "GITEA_secret",
+                               ExpectedStatus: http.StatusBadRequest,
+                       },
+                       {
+                               Name:           "GITHUB_secret",
+                               ExpectedStatus: http.StatusBadRequest,
+                       },
+               }
+
+               for _, c := range cases {
+                       req := NewRequestWithJSON(t, "PUT", fmt.Sprintf("/api/v1/repos/%s/actions/secrets/%s?token=%s", repo.FullName(), c.Name, token), api.CreateOrUpdateSecretOption{
+                               Data: "data",
+                       })
+                       MakeRequest(t, req, c.ExpectedStatus)
+               }
+       })
+
+       t.Run("Update", func(t *testing.T) {
+               name := "update_secret"
+               url := fmt.Sprintf("/api/v1/repos/%s/actions/secrets/%s?token=%s", repo.FullName(), name, token)
+
+               req := NewRequestWithJSON(t, "PUT", url, api.CreateOrUpdateSecretOption{
+                       Data: "initial",
+               })
+               MakeRequest(t, req, http.StatusCreated)
+
+               req = NewRequestWithJSON(t, "PUT", url, api.CreateOrUpdateSecretOption{
+                       Data: "changed",
+               })
+               MakeRequest(t, req, http.StatusNoContent)
+       })
+
+       t.Run("Delete", func(t *testing.T) {
+               name := "delete_secret"
+               url := fmt.Sprintf("/api/v1/repos/%s/actions/secrets/%s?token=%s", repo.FullName(), name, token)
+
+               req := NewRequestWithJSON(t, "PUT", url, api.CreateOrUpdateSecretOption{
+                       Data: "initial",
+               })
+               MakeRequest(t, req, http.StatusCreated)
+
+               req = NewRequest(t, "DELETE", url)
+               MakeRequest(t, req, http.StatusNoContent)
+
+               req = NewRequest(t, "DELETE", url)
+               MakeRequest(t, req, http.StatusNotFound)
+
+               req = NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/repos/%s/actions/secrets/000?token=%s", repo.FullName(), token))
+               MakeRequest(t, req, http.StatusBadRequest)
+       })
+}