{{ctx.Locale.Tr "repo.settings.deploy_key_deletion_desc"}}
diff --git a/cmd/hook.go b/cmd/hook.go
index 77a8fafaa9e..b0065d2af2c 100644
--- a/cmd/hook.go
+++ b/cmd/hook.go
@@ -186,23 +186,24 @@ Gitea or set your environment appropriately.`, "")
// the environment is set by serv command
isWiki, _ := strconv.ParseBool(os.Getenv(repo_module.EnvRepoIsWiki))
- username := os.Getenv(repo_module.EnvRepoUsername)
- reponame := os.Getenv(repo_module.EnvRepoName)
+ ownerName := os.Getenv(repo_module.EnvRepoUsername)
+ repoName := os.Getenv(repo_module.EnvRepoName)
userID, _ := strconv.ParseInt(os.Getenv(repo_module.EnvPusherID), 10, 64)
prID, _ := strconv.ParseInt(os.Getenv(repo_module.EnvPRID), 10, 64)
- deployKeyID, _ := strconv.ParseInt(os.Getenv(repo_module.EnvDeployKeyID), 10, 64)
- actionsTaskID, _ := strconv.ParseInt(os.Getenv(repo_module.EnvActionsTaskID), 10, 64)
hookOptions := private.HookOptions{
- UserID: userID,
+ IsWiki: isWiki,
+
GitAlternativeObjectDirectories: os.Getenv(private.GitAlternativeObjectDirectories),
GitObjectDirectory: os.Getenv(private.GitObjectDirectory),
GitQuarantinePath: os.Getenv(private.GitQuarantinePath),
GitPushOptions: pushOptions(),
- PullRequestID: prID,
- DeployKeyID: deployKeyID,
- ActionsTaskID: actionsTaskID,
- IsWiki: isWiki,
+
+ PullRequestID: prID,
+
+ UserID: userID,
+ UserName: os.Getenv(repo_module.EnvPusherName),
+ UserExtDoerData: os.Getenv(repo_module.EnvPusherExtDoerData),
}
scanner := bufio.NewScanner(os.Stdin)
@@ -257,7 +258,7 @@ Gitea or set your environment appropriately.`, "")
hookOptions.OldCommitIDs = oldCommitIDs
hookOptions.NewCommitIDs = newCommitIDs
hookOptions.RefFullNames = refFullNames
- extra := private.HookPreReceive(ctx, username, reponame, hookOptions)
+ extra := private.HookPreReceive(ctx, ownerName, repoName, hookOptions)
if extra.HasError() {
return fail(ctx, extra.UserMsg, "HookPreReceive(batch) failed: %v", extra.Error)
}
@@ -283,7 +284,7 @@ Gitea or set your environment appropriately.`, "")
fmt.Fprintf(out, " Checking %d references\n", count)
- extra := private.HookPreReceive(ctx, username, reponame, hookOptions)
+ extra := private.HookPreReceive(ctx, ownerName, repoName, hookOptions)
if extra.HasError() {
return fail(ctx, extra.UserMsg, "HookPreReceive(last) failed: %v", extra.Error)
}
@@ -353,18 +354,21 @@ Gitea or set your environment appropriately.`, "")
repoName := os.Getenv(repo_module.EnvRepoName)
pusherID, _ := strconv.ParseInt(os.Getenv(repo_module.EnvPusherID), 10, 64)
prID, _ := strconv.ParseInt(os.Getenv(repo_module.EnvPRID), 10, 64)
- pusherName := os.Getenv(repo_module.EnvPusherName)
hookOptions := private.HookOptions{
- UserName: pusherName,
- UserID: pusherID,
+ IsWiki: isWiki,
+
GitAlternativeObjectDirectories: os.Getenv(private.GitAlternativeObjectDirectories),
GitObjectDirectory: os.Getenv(private.GitObjectDirectory),
GitQuarantinePath: os.Getenv(private.GitQuarantinePath),
GitPushOptions: pushOptions(),
- PullRequestID: prID,
- PushTrigger: repo_module.PushTrigger(os.Getenv(repo_module.EnvPushTrigger)),
- IsWiki: isWiki,
+
+ PullRequestID: prID,
+ PushTrigger: repo_module.PushTrigger(os.Getenv(repo_module.EnvPushTrigger)),
+
+ UserID: pusherID,
+ UserName: os.Getenv(repo_module.EnvPusherName),
+ UserExtDoerData: os.Getenv(repo_module.EnvPusherExtDoerData),
}
oldCommitIDs := make([]string, 0, hookBatchSize)
@@ -481,7 +485,6 @@ Gitea or set your environment appropriately.`, "")
isWiki, _ := strconv.ParseBool(os.Getenv(repo_module.EnvRepoIsWiki))
repoName := os.Getenv(repo_module.EnvRepoName)
pusherID, _ := strconv.ParseInt(os.Getenv(repo_module.EnvPusherID), 10, 64)
- pusherName := os.Getenv(repo_module.EnvPusherName)
// 1. Version and features negotiation.
// S: PKT-LINE(version=1\0push-options atomic...) / PKT-LINE(version=1\n)
@@ -553,10 +556,13 @@ Gitea or set your environment appropriately.`, "")
// S: ... ...
// S: flush-pkt
hookOptions := private.HookOptions{
- UserName: pusherName,
- UserID: pusherID,
+ IsWiki: isWiki,
+
GitPushOptions: make(map[string]string),
- IsWiki: isWiki,
+
+ UserID: pusherID,
+ UserName: os.Getenv(repo_module.EnvPusherName),
+ UserExtDoerData: os.Getenv(repo_module.EnvPusherExtDoerData),
}
hookOptions.OldCommitIDs = make([]string, 0, hookBatchSize)
hookOptions.NewCommitIDs = make([]string, 0, hookBatchSize)
diff --git a/cmd/serv.go b/cmd/serv.go
index eddbbcb3ab9..1d9ab781268 100644
--- a/cmd/serv.go
+++ b/cmd/serv.go
@@ -256,7 +256,7 @@ func runServ(ctx context.Context, c *cli.Command) error {
if results.IsWiki {
return fail(ctx, "LFS Transfer is not supported for wikis", "")
}
- token, err := lfs.GetLFSAuthTokenWithBearer(lfs.AuthTokenOptions{Op: lfsVerb, UserID: results.UserID, RepoID: results.RepoID})
+ token, err := lfs.GetLFSAuthTokenWithBearer(lfs.AuthTokenOptions{Op: lfsVerb, UserID: results.UserID, UserExtDoerData: results.UserExtDoerData, RepoID: results.RepoID})
if err != nil {
return err
}
@@ -270,7 +270,7 @@ func runServ(ctx context.Context, c *cli.Command) error {
}
lfsTokenHref := fmt.Sprintf("%s%s/%s.git/info/lfs", setting.AppURL, url.PathEscape(results.OwnerName), url.PathEscape(results.RepoName))
- token, err := lfs.GetLFSAuthTokenWithBearer(lfs.AuthTokenOptions{Op: lfsVerb, UserID: results.UserID, RepoID: results.RepoID})
+ token, err := lfs.GetLFSAuthTokenWithBearer(lfs.AuthTokenOptions{Op: lfsVerb, UserID: results.UserID, UserExtDoerData: results.UserExtDoerData, RepoID: results.RepoID})
if err != nil {
return err
}
@@ -314,15 +314,20 @@ func runServ(ctx context.Context, c *cli.Command) error {
command.Env = append(command.Env, os.Environ()...)
command.Env = append(command.Env,
repo_module.EnvRepoIsWiki+"="+strconv.FormatBool(results.IsWiki),
- repo_module.EnvRepoName+"="+results.RepoName,
+
repo_module.EnvRepoUsername+"="+results.OwnerName,
+ repo_module.EnvRepoName+"="+results.RepoName,
+ repo_module.EnvRepoID+"="+strconv.FormatInt(results.RepoID, 10),
+
+ repo_module.EnvKeyID+"="+strconv.FormatInt(results.PublicKeyID, 10),
+
+ repo_module.EnvPusherID+"="+strconv.FormatInt(results.UserID, 10),
repo_module.EnvPusherName+"="+results.UserName,
repo_module.EnvPusherEmail+"="+results.UserEmail,
- repo_module.EnvPusherID+"="+strconv.FormatInt(results.UserID, 10),
- repo_module.EnvRepoID+"="+strconv.FormatInt(results.RepoID, 10),
+ repo_module.EnvPusherExtDoerData+"="+results.UserExtDoerData,
+
repo_module.EnvPRID+"="+strconv.Itoa(0),
- repo_module.EnvDeployKeyID+"="+strconv.FormatInt(results.DeployKeyID, 10),
- repo_module.EnvKeyID+"="+strconv.FormatInt(results.KeyID, 10),
+
repo_module.EnvAppURL+"="+setting.AppURL,
)
// to avoid breaking, here only use the minimal environment variables for the "gitea serv" command.
@@ -334,8 +339,8 @@ func runServ(ctx context.Context, c *cli.Command) error {
}
// Update user key activity.
- if results.KeyID > 0 {
- if err = private.UpdatePublicKeyInRepo(ctx, results.KeyID, results.RepoID); err != nil {
+ if results.PublicKeyID > 0 {
+ if err = private.UpdatePublicKeyInRepo(ctx, results.PublicKeyID, results.RepoID); err != nil {
return fail(ctx, "Failed to update public key", "UpdatePublicKeyInRepo: %v", err)
}
}
diff --git a/modelmigration/migrations.go b/modelmigration/migrations.go
index 245a7a44ea4..47e9f8a3250 100644
--- a/modelmigration/migrations.go
+++ b/modelmigration/migrations.go
@@ -425,6 +425,7 @@ func prepareMigrationTasks() []*migration {
newMigration(349, "Expand action_schedule content column", v28.ExpandActionScheduleContent),
newMigration(350, "Add published_unix column to release", v28.AddPublishedUnixToRelease),
newMigration(351, "Track transfer recipient access grants", v28.AddRecipientAccessGrantedToRepoTransfer),
+ newMigration(352, "Add token columns to deploy_key", v28.AddTokenToDeployKey),
}
return preparedMigrations
}
diff --git a/modelmigration/v28/v352.go b/modelmigration/v28/v352.go
new file mode 100644
index 00000000000..d79c7399643
--- /dev/null
+++ b/modelmigration/v28/v352.go
@@ -0,0 +1,38 @@
+// Copyright 2026 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package v28
+
+import (
+ "context"
+
+ "gitea.dev/modelmigration/base"
+
+ "xorm.io/xorm"
+)
+
+func AddTokenToDeployKey(ctx context.Context, x base.EngineMigration) error {
+ // Drop the old UNIQUE(s) index on (key_id, repo_id). Every token row carries key
+ // id 0, so the pair can no longer be unique. AddDeployKey still checks it in code.
+ indexes, err := x.Dialect().GetIndexes(x.DB(), ctx, "deploy_key")
+ if err != nil {
+ return err
+ }
+ if idx, ok := indexes["s"]; ok {
+ if _, err := x.Exec(x.Dialect().DropIndexSQL("deploy_key", idx)); err != nil {
+ return err
+ }
+ }
+
+ type DeployKey struct {
+ KeyID int64 `xorm:"INDEX"`
+ RepoID int64 `xorm:"INDEX"`
+ KeyType int `xorm:"NOT NULL DEFAULT 1"` // every existing row is an SSH key
+ TokenHash string `xorm:"INDEX"`
+ }
+ _, err = x.SyncWithOptions(xorm.SyncOptions{
+ IgnoreConstrains: true,
+ IgnoreDropIndices: true, // the bean only describes the new columns
+ }, new(DeployKey))
+ return err
+}
diff --git a/models/actions/run_attempt_list.go b/models/actions/run_attempt_list.go
index 9c125d559e0..f25e8fb0af8 100644
--- a/models/actions/run_attempt_list.go
+++ b/models/actions/run_attempt_list.go
@@ -27,14 +27,7 @@ func (attempts ActionRunAttemptList) LoadTriggerUser(ctx context.Context) error
return err
}
for _, attempt := range attempts {
- if attempt.TriggerUserID == user_model.ActionsUserID {
- attempt.TriggerUser = user_model.NewActionsUser()
- } else {
- attempt.TriggerUser = users[attempt.TriggerUserID]
- if attempt.TriggerUser == nil {
- attempt.TriggerUser = user_model.NewGhostUser()
- }
- }
+ attempt.TriggerUser = user_model.GetPossibleUserFromMap(attempt.TriggerUserID, users)
}
return nil
}
diff --git a/models/asymkey/error.go b/models/asymkey/error.go
index 1a862954a32..347de3fb512 100644
--- a/models/asymkey/error.go
+++ b/models/asymkey/error.go
@@ -215,67 +215,6 @@ func (err ErrKeyAccessDenied) Unwrap() error {
return util.ErrPermissionDenied
}
-// ErrDeployKeyNotExist represents a "DeployKeyNotExist" kind of error.
-type ErrDeployKeyNotExist struct {
- ID int64
- KeyID int64
- RepoID int64
-}
-
-// IsErrDeployKeyNotExist checks if an error is a ErrDeployKeyNotExist.
-func IsErrDeployKeyNotExist(err error) bool {
- _, ok := err.(ErrDeployKeyNotExist)
- return ok
-}
-
-func (err ErrDeployKeyNotExist) Error() string {
- return fmt.Sprintf("Deploy key does not exist [id: %d, key_id: %d, repo_id: %d]", err.ID, err.KeyID, err.RepoID)
-}
-
-func (err ErrDeployKeyNotExist) Unwrap() error {
- return util.ErrNotExist
-}
-
-// ErrDeployKeyAlreadyExist represents a "DeployKeyAlreadyExist" kind of error.
-type ErrDeployKeyAlreadyExist struct {
- KeyID int64
- RepoID int64
-}
-
-// IsErrDeployKeyAlreadyExist checks if an error is a ErrDeployKeyAlreadyExist.
-func IsErrDeployKeyAlreadyExist(err error) bool {
- _, ok := err.(ErrDeployKeyAlreadyExist)
- return ok
-}
-
-func (err ErrDeployKeyAlreadyExist) Error() string {
- return fmt.Sprintf("public key already exists [key_id: %d, repo_id: %d]", err.KeyID, err.RepoID)
-}
-
-func (err ErrDeployKeyAlreadyExist) Unwrap() error {
- return util.ErrAlreadyExist
-}
-
-// ErrDeployKeyNameAlreadyUsed represents a "DeployKeyNameAlreadyUsed" kind of error.
-type ErrDeployKeyNameAlreadyUsed struct {
- RepoID int64
- Name string
-}
-
-// IsErrDeployKeyNameAlreadyUsed checks if an error is a ErrDeployKeyNameAlreadyUsed.
-func IsErrDeployKeyNameAlreadyUsed(err error) bool {
- _, ok := err.(ErrDeployKeyNameAlreadyUsed)
- return ok
-}
-
-func (err ErrDeployKeyNameAlreadyUsed) Error() string {
- return fmt.Sprintf("public key with name already exists [repo_id: %d, name: %s]", err.RepoID, err.Name)
-}
-
-func (err ErrDeployKeyNameAlreadyUsed) Unwrap() error {
- return util.ErrNotExist
-}
-
// ErrSSHInvalidTokenSignature represents a "ErrSSHInvalidTokenSignature" kind of error.
type ErrSSHInvalidTokenSignature struct {
Wrapped error
diff --git a/models/asymkey/ssh_key.go b/models/asymkey/ssh_key.go
index 1c7828dd18e..f41ec04dc89 100644
--- a/models/asymkey/ssh_key.go
+++ b/models/asymkey/ssh_key.go
@@ -89,6 +89,36 @@ func addPublicKey(ctx context.Context, key *PublicKey) (err error) {
return appendAuthorizedKeysToFile(key)
}
+// FindOrAddDeployPublicKey returns the shared public key that deploy keys of the given content link to, adding it on first use.
+func FindOrAddDeployPublicKey(ctx context.Context, content string) (*PublicKey, error) {
+ fingerprint, err := CalcFingerprint(content)
+ if err != nil {
+ return nil, err
+ }
+
+ pkey, exist, err := db.Get[PublicKey](ctx, builder.Eq{"fingerprint": fingerprint})
+ if err != nil {
+ return nil, err
+ } else if exist {
+ if pkey.Type != KeyTypeDeploy {
+ return nil, ErrKeyAlreadyExist{0, fingerprint, ""}
+ }
+ return pkey, nil
+ }
+
+ pkey = &PublicKey{
+ Mode: perm.AccessModeNone,
+ Type: KeyTypeDeploy,
+ Name: "(DeployKey)",
+ Content: content,
+ Fingerprint: fingerprint,
+ }
+ if err = addPublicKey(ctx, pkey); err != nil {
+ return nil, fmt.Errorf("addPublicKey: %w", err)
+ }
+ return pkey, nil
+}
+
// AddPublicKey adds new public key to database and authorized_keys file.
func AddPublicKey(ctx context.Context, ownerID int64, name, content string, authSourceID int64, verified bool) (*PublicKey, error) {
log.Trace(content)
diff --git a/models/asymkey/ssh_key_deploy.go b/models/asymkey/ssh_key_deploy.go
deleted file mode 100644
index 5847fbb495b..00000000000
--- a/models/asymkey/ssh_key_deploy.go
+++ /dev/null
@@ -1,175 +0,0 @@
-// Copyright 2021 The Gitea Authors. All rights reserved.
-// SPDX-License-Identifier: MIT
-
-package asymkey
-
-import (
- "context"
- "fmt"
- "time"
-
- "gitea.dev/models/db"
- "gitea.dev/models/perm"
- "gitea.dev/modules/timeutil"
- "gitea.dev/modules/util"
-
- "xorm.io/builder"
-)
-
-// DeployKey represents deploy key information and its relation with repository.
-type DeployKey struct {
- ID int64 `xorm:"pk autoincr"`
- KeyID int64 `xorm:"UNIQUE(s) INDEX"`
- RepoID int64 `xorm:"UNIQUE(s) INDEX"`
- Name string
- Fingerprint string
-
- Mode perm.AccessMode `xorm:"NOT NULL DEFAULT 1"`
-
- CreatedUnix timeutil.TimeStamp `xorm:"created"`
- UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
-
- PublicKey *PublicKey `xorm:"-"`
-}
-
-func (key *DeployKey) HasUsed() bool {
- return key.UpdatedUnix > key.CreatedUnix
-}
-
-func (key *DeployKey) HasRecentActivity() bool {
- return key.UpdatedUnix.AddDuration(7*24*time.Hour) > timeutil.TimeStampNow()
-}
-
-func (key *DeployKey) LoadPublicKey(ctx context.Context) (err error) {
- if key.PublicKey != nil {
- return nil
- }
- key.PublicKey, err = GetPublicKeyByID(ctx, key.KeyID)
- return err
-}
-
-// IsReadOnly checks if the key can only be used for read operations, used by template
-func (key *DeployKey) IsReadOnly() bool {
- return key.Mode == perm.AccessModeRead
-}
-
-func init() {
- db.RegisterModel(new(DeployKey))
-}
-
-func checkDeployKey(ctx context.Context, repoID, publicKeyID int64, name string) error {
- // Note: We want error detail, not just true or false here.
- has, err := db.GetEngine(ctx).
- Where("repo_id=? AND (key_id=? OR name=?)", repoID, publicKeyID, name).
- Get(new(DeployKey))
- if err != nil {
- return err
- } else if has {
- return ErrDeployKeyAlreadyExist{publicKeyID, repoID}
- }
- return nil
-}
-
-// addDeployKey adds new key-repo relation.
-func addDeployKey(ctx context.Context, repoID, publicKeyID int64, name, fingerprint string, mode perm.AccessMode) (*DeployKey, error) {
- if err := checkDeployKey(ctx, repoID, publicKeyID, name); err != nil {
- return nil, err
- }
-
- key := &DeployKey{KeyID: publicKeyID, RepoID: repoID, Name: name, Fingerprint: fingerprint, Mode: mode}
- return key, db.Insert(ctx, key)
-}
-
-// AddDeployKey add new deploy key to database and authorized_keys file.
-func AddDeployKey(ctx context.Context, repoID int64, name, content string, accessMode perm.AccessMode) (*DeployKey, error) {
- fingerprint, err := CalcFingerprint(content)
- if err != nil {
- return nil, err
- }
-
- if accessMode != perm.AccessModeRead && accessMode != perm.AccessModeWrite {
- return nil, util.NewInvalidArgumentErrorf("invalid access mode")
- }
- return db.WithTx2(ctx, func(ctx context.Context) (*DeployKey, error) {
- pkey, exist, err := db.Get[PublicKey](ctx, builder.Eq{"fingerprint": fingerprint})
- if err != nil {
- return nil, err
- } else if exist {
- if pkey.Type != KeyTypeDeploy {
- return nil, ErrKeyAlreadyExist{0, fingerprint, ""}
- }
- } else {
- // First time use this deploy key, add a shared public key
- pkey = &PublicKey{
- Mode: perm.AccessModeNone,
- Type: KeyTypeDeploy,
- Name: "(DeployKey)",
- Content: content,
- Fingerprint: fingerprint,
- }
- if err = addPublicKey(ctx, pkey); err != nil {
- return nil, fmt.Errorf("addPublicKey: %w", err)
- }
- }
- return addDeployKey(ctx, repoID, pkey.ID, name, fingerprint, accessMode)
- })
-}
-
-// GetDeployKeyByID returns deploy key by given ID.
-func GetDeployKeyByID(ctx context.Context, repoID, deployKeyID int64) (*DeployKey, error) {
- key, exist, err := db.Get[DeployKey](ctx, builder.Eq{"id": deployKeyID, "repo_id": repoID})
- if err != nil {
- return nil, err
- } else if !exist {
- return nil, ErrDeployKeyNotExist{deployKeyID, 0, repoID}
- }
- return key, nil
-}
-
-// GetDeployKeyByRepoPublicKey returns deploy key by given public key ID and repository ID.
-func GetDeployKeyByRepoPublicKey(ctx context.Context, repoID, publicKeyID int64) (*DeployKey, error) {
- key, exist, err := db.Get[DeployKey](ctx, builder.Eq{"key_id": publicKeyID, "repo_id": repoID})
- if err != nil {
- return nil, err
- } else if !exist {
- return nil, ErrDeployKeyNotExist{0, publicKeyID, repoID}
- }
- return key, nil
-}
-
-// IsDeployKeyExistByPublicKeyID return true if there is at least one deploy-key with the key id
-func IsDeployKeyExistByPublicKeyID(ctx context.Context, keyID int64) (bool, error) {
- return db.GetEngine(ctx).
- Where("key_id = ?", keyID).
- Get(new(DeployKey))
-}
-
-// UpdateDeployKeyCols updates deploy key information in the specified columns.
-func UpdateDeployKeyCols(ctx context.Context, key *DeployKey, cols ...string) error {
- _, err := db.GetEngine(ctx).ID(key.ID).Cols(cols...).Update(key)
- return err
-}
-
-// ListDeployKeysOptions are options for ListDeployKeys
-type ListDeployKeysOptions struct {
- db.ListOptions
- RepoID int64
- KeyID int64
- Fingerprint string
-}
-
-func (opt ListDeployKeysOptions) ToOrders() string {
- return "name"
-}
-
-func (opt ListDeployKeysOptions) ToConds() builder.Cond {
- cond := builder.NewCond()
- cond = cond.And(builder.Eq{"repo_id": opt.RepoID}) // repo ID must be used
- if opt.KeyID != 0 {
- cond = cond.And(builder.Eq{"key_id": opt.KeyID})
- }
- if opt.Fingerprint != "" {
- cond = cond.And(builder.Eq{"fingerprint": opt.Fingerprint})
- }
- return cond
-}
diff --git a/models/deploykey/deploykey.go b/models/deploykey/deploykey.go
new file mode 100644
index 00000000000..ce74c4e1413
--- /dev/null
+++ b/models/deploykey/deploykey.go
@@ -0,0 +1,99 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package deploykey
+
+import (
+ "context"
+ "time"
+
+ "gitea.dev/models/asymkey"
+ "gitea.dev/models/db"
+ "gitea.dev/models/perm"
+ "gitea.dev/modules/timeutil"
+
+ "xorm.io/builder"
+)
+
+type KeyType int // SSH public key or HTTP auth token
+
+const (
+ KeyTypeSSH KeyType = iota + 1
+ KeyTypeToken
+)
+
+type DeployKey struct {
+ ID int64 `xorm:"pk autoincr"`
+ KeyID int64 `xorm:"INDEX"`
+ RepoID int64 `xorm:"INDEX"`
+ Name string
+
+ KeyType KeyType `xorm:"NOT NULL DEFAULT 1"`
+
+ Fingerprint string
+ PublicKey *asymkey.PublicKey `xorm:"-"`
+
+ TokenHash string `xorm:"INDEX"` // sha256 of the token, which carries enough entropy to need no salt
+ Token string `xorm:"-"` // only set when the token is created
+
+ Mode perm.AccessMode `xorm:"NOT NULL DEFAULT 1"`
+
+ CreatedUnix timeutil.TimeStamp `xorm:"created"`
+ UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
+}
+
+// these methods below are mainly used by templates
+
+func (key *DeployKey) HasRecentActivity() bool {
+ return key.UpdatedUnix.AddDuration(7*24*time.Hour) > timeutil.TimeStampNow()
+}
+
+func (key *DeployKey) HasUsed() bool { return key.UpdatedUnix > key.CreatedUnix }
+
+func (key *DeployKey) IsReadOnly() bool { return key.Mode == perm.AccessModeRead }
+
+func (key *DeployKey) IsKeyTypeToken() bool { return key.KeyType == KeyTypeToken }
+
+func init() {
+ db.RegisterModel(new(DeployKey))
+}
+
+func checkDeployKeyName(ctx context.Context, repoID int64, name string) error {
+ has, err := db.Exist[DeployKey](ctx, builder.Eq{"repo_id": repoID, "name": name})
+ if err != nil {
+ return err
+ } else if has {
+ return ErrDeployKeyNameAlreadyUsed{repoID, name}
+ }
+ return nil
+}
+
+// UpdateDeployKeyLastUsed marks the key as used now.
+func UpdateDeployKeyLastUsed(ctx context.Context, id int64) error {
+ _, err := db.GetEngine(ctx).ID(id).Cols("updated_unix").Update(&DeployKey{UpdatedUnix: timeutil.TimeStampNow()})
+ return err
+}
+
+// ListDeployKeysOptions are options for ListDeployKeys
+type ListDeployKeysOptions struct {
+ db.ListOptions
+ RepoID int64
+ KeyID int64
+ Fingerprint string
+}
+
+func (opt ListDeployKeysOptions) ToOrders() string {
+ return "name"
+}
+
+func (opt ListDeployKeysOptions) ToConds() builder.Cond {
+ cond := builder.NewCond()
+ cond = cond.And(builder.Eq{"repo_id": opt.RepoID}) // repo ID must be used
+ if opt.KeyID != 0 {
+ cond = cond.And(builder.Eq{"key_id": opt.KeyID})
+ }
+ if opt.Fingerprint != "" {
+ cond = cond.And(builder.Eq{"fingerprint": opt.Fingerprint})
+ }
+ return cond
+}
diff --git a/models/deploykey/deploykey_ssh.go b/models/deploykey/deploykey_ssh.go
new file mode 100644
index 00000000000..c072a4f26c1
--- /dev/null
+++ b/models/deploykey/deploykey_ssh.go
@@ -0,0 +1,75 @@
+// Copyright 2026 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package deploykey
+
+import (
+ "context"
+
+ "gitea.dev/models/asymkey"
+ "gitea.dev/models/db"
+ "gitea.dev/models/perm"
+ "gitea.dev/modules/util"
+
+ "xorm.io/builder"
+)
+
+// AddDeployKeySSH add new deploy-key to database and authorized_keys file.
+func AddDeployKeySSH(ctx context.Context, repoID int64, name, content string, accessMode perm.AccessMode) (*DeployKey, error) {
+ if accessMode != perm.AccessModeRead && accessMode != perm.AccessModeWrite {
+ return nil, util.NewInvalidArgumentErrorf("invalid access mode")
+ }
+ return db.WithTx2(ctx, func(ctx context.Context) (*DeployKey, error) {
+ pkey, err := asymkey.FindOrAddDeployPublicKey(ctx, content)
+ if err != nil {
+ return nil, err
+ }
+ if has, err := db.Exist[DeployKey](ctx, builder.Eq{"repo_id": repoID, "key_id": pkey.ID}); err != nil {
+ return nil, err
+ } else if has {
+ return nil, ErrDeployKeyAlreadyExist{pkey.ID, repoID}
+ }
+ if err := checkDeployKeyName(ctx, repoID, name); err != nil {
+ return nil, err
+ }
+
+ key := &DeployKey{KeyID: pkey.ID, RepoID: repoID, KeyType: KeyTypeSSH, Name: name, Fingerprint: pkey.Fingerprint, Mode: accessMode}
+ return key, db.Insert(ctx, key)
+ })
+}
+
+func (key *DeployKey) LoadPublicKey(ctx context.Context) (err error) {
+ if key.PublicKey != nil {
+ return nil
+ }
+ key.PublicKey, err = asymkey.GetPublicKeyByID(ctx, key.KeyID)
+ return err
+}
+
+// GetDeployKeyByID returns deploy-key by given ID.
+func GetDeployKeyByID(ctx context.Context, repoID, deployKeyID int64) (*DeployKey, error) {
+ key, exist, err := db.Get[DeployKey](ctx, builder.Eq{"id": deployKeyID, "repo_id": repoID})
+ if err != nil {
+ return nil, err
+ } else if !exist {
+ return nil, ErrDeployKeyNotExist{deployKeyID, 0, repoID}
+ }
+ return key, nil
+}
+
+// GetDeployKeyByRepoPublicKey returns deploy-key by given public key ID and repository ID.
+func GetDeployKeyByRepoPublicKey(ctx context.Context, repoID, publicKeyID int64) (*DeployKey, error) {
+ // the type is part of the condition because every token row carries key id 0
+ key, exist, err := db.Get[DeployKey](ctx, builder.Eq{"key_id": publicKeyID, "repo_id": repoID, "key_type": KeyTypeSSH})
+ if err != nil {
+ return nil, err
+ } else if !exist {
+ return nil, ErrDeployKeyNotExist{0, publicKeyID, repoID}
+ }
+ return key, nil
+}
+
+// IsDeployKeyExistByPublicKeyID return true if there is at least one deploy-key with the key id
+func IsDeployKeyExistByPublicKeyID(ctx context.Context, keyID int64) (bool, error) {
+ return db.Exist[DeployKey](ctx, builder.Eq{"key_id": keyID, "key_type": KeyTypeSSH})
+}
diff --git a/models/deploykey/deploykey_token.go b/models/deploykey/deploykey_token.go
new file mode 100644
index 00000000000..e6a78da9b92
--- /dev/null
+++ b/models/deploykey/deploykey_token.go
@@ -0,0 +1,76 @@
+// Copyright 2026 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package deploykey
+
+import (
+ "context"
+ "strings"
+
+ "gitea.dev/models/db"
+ "gitea.dev/models/perm"
+ "gitea.dev/modules/base"
+ "gitea.dev/modules/util"
+
+ "xorm.io/builder"
+)
+
+const (
+ DeployTokenPrefix = "gdt_" // lets a secret scanner recognize a leaked token
+ deployTokenLength = 43 // 256 bits of entropy over the 62 alphanumerical characters
+)
+
+func (key *DeployKey) generateToken() {
+ key.Token = DeployTokenPrefix + util.CryptoRandomString(deployTokenLength)
+ key.TokenHash = base.EncodeSha256(key.Token)
+ key.Fingerprint = key.Token[:len(DeployTokenPrefix)+2] + "********" + key.Token[len(key.Token)-2:]
+}
+
+// AddDeployKeyToken adds a token that authenticates git HTTP requests for one repository.
+// The plaintext token is only readable on the returned key.
+func AddDeployKeyToken(ctx context.Context, repoID int64, name string, accessMode perm.AccessMode) (*DeployKey, error) {
+ key := &DeployKey{
+ RepoID: repoID,
+ KeyType: KeyTypeToken,
+ Name: name,
+ Mode: accessMode,
+ }
+ key.generateToken()
+
+ return db.WithTx2(ctx, func(ctx context.Context) (*DeployKey, error) {
+ if err := checkDeployKeyName(ctx, repoID, name); err != nil {
+ return nil, err
+ }
+ return key, db.Insert(ctx, key)
+ })
+}
+
+// RegenerateDeployKeyToken replaces the token value of an existing deploy token, keeping its name and access mode.
+func RegenerateDeployKeyToken(ctx context.Context, repoID, keyID int64) (*DeployKey, error) {
+ key, err := GetDeployKeyByID(ctx, repoID, keyID)
+ if err != nil {
+ return nil, err
+ }
+ if key.KeyType != KeyTypeToken {
+ return nil, ErrDeployKeyNotExist{keyID, 0, repoID}
+ }
+
+ key.generateToken()
+ _, err = db.GetEngine(ctx).ID(key.ID).Cols("token_hash", "fingerprint").NoAutoTime().Update(key)
+ return key, err
+}
+
+// VerifyDeployKeyToken returns the deploy-key which the given plaintext token authenticates.
+func VerifyDeployKeyToken(ctx context.Context, token string) (*DeployKey, error) {
+ if !strings.HasPrefix(token, DeployTokenPrefix) { // spares a query for every password of a normal user
+ return nil, ErrDeployKeyNotExist{}
+ }
+
+ key, exist, err := db.Get[DeployKey](ctx, builder.Eq{"token_hash": base.EncodeSha256(token), "key_type": KeyTypeToken})
+ if err != nil {
+ return nil, err
+ } else if !exist {
+ return nil, ErrDeployKeyNotExist{}
+ }
+ return key, nil
+}
diff --git a/models/deploykey/deploykey_token_test.go b/models/deploykey/deploykey_token_test.go
new file mode 100644
index 00000000000..85cfdfbef4f
--- /dev/null
+++ b/models/deploykey/deploykey_token_test.go
@@ -0,0 +1,52 @@
+// Copyright 2026 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package deploykey
+
+import (
+ "testing"
+
+ "gitea.dev/models/db"
+ "gitea.dev/models/perm"
+ "gitea.dev/models/unittest"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestDeployToken(t *testing.T) {
+ require.NoError(t, unittest.PrepareTestDatabase())
+
+ key, err := AddDeployKeyToken(t.Context(), 1, "ci", perm.AccessModeWrite)
+ require.NoError(t, err)
+ assert.False(t, key.IsReadOnly())
+ assert.Len(t, key.Token, len(DeployTokenPrefix)+deployTokenLength)
+
+ got, err := VerifyDeployKeyToken(t.Context(), key.Token)
+ require.NoError(t, err)
+ assert.Equal(t, key.ID, got.ID)
+ assert.Empty(t, got.Token, "the token itself is never stored")
+
+ _, err = VerifyDeployKeyToken(t.Context(), "not-a-token")
+ assert.True(t, IsErrDeployKeyNotExist(err))
+
+ _, err = AddDeployKeyToken(t.Context(), 1, "ci", perm.AccessModeWrite)
+ assert.True(t, IsErrDeployKeyNameAlreadyUsed(err))
+
+ regenerated, err := RegenerateDeployKeyToken(t.Context(), 1, key.ID)
+ require.NoError(t, err)
+ assert.Equal(t, key.Name, regenerated.Name)
+ assert.Equal(t, key.Mode, regenerated.Mode)
+ assert.False(t, unittest.AssertExistsAndLoadBean(t, &DeployKey{ID: key.ID}).HasUsed(), "regenerating is not a use")
+
+ _, err = VerifyDeployKeyToken(t.Context(), key.Token)
+ assert.True(t, IsErrDeployKeyNotExist(err), "the old token stops working")
+ _, err = VerifyDeployKeyToken(t.Context(), regenerated.Token)
+ require.NoError(t, err)
+
+ // an SSH deploy-key has no token to regenerate
+ sshKey := &DeployKey{RepoID: 1, KeyType: KeyTypeSSH, Name: "ssh"}
+ require.NoError(t, db.Insert(t.Context(), sshKey))
+ _, err = RegenerateDeployKeyToken(t.Context(), 1, sshKey.ID)
+ assert.True(t, IsErrDeployKeyNotExist(err))
+}
diff --git a/models/deploykey/error.go b/models/deploykey/error.go
new file mode 100644
index 00000000000..eef343e6957
--- /dev/null
+++ b/models/deploykey/error.go
@@ -0,0 +1,71 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package deploykey
+
+import (
+ "fmt"
+
+ "gitea.dev/modules/util"
+)
+
+// ErrDeployKeyNotExist represents a "DeployKeyNotExist" kind of error.
+type ErrDeployKeyNotExist struct {
+ ID int64
+ KeyID int64
+ RepoID int64
+}
+
+// IsErrDeployKeyNotExist checks if an error is a ErrDeployKeyNotExist.
+func IsErrDeployKeyNotExist(err error) bool {
+ _, ok := err.(ErrDeployKeyNotExist)
+ return ok
+}
+
+func (err ErrDeployKeyNotExist) Error() string {
+ return fmt.Sprintf("Deploy key does not exist [id: %d, key_id: %d, repo_id: %d]", err.ID, err.KeyID, err.RepoID)
+}
+
+func (err ErrDeployKeyNotExist) Unwrap() error {
+ return util.ErrNotExist
+}
+
+// ErrDeployKeyAlreadyExist represents a "DeployKeyAlreadyExist" kind of error.
+type ErrDeployKeyAlreadyExist struct {
+ KeyID int64
+ RepoID int64
+}
+
+// IsErrDeployKeyAlreadyExist checks if an error is a ErrDeployKeyAlreadyExist.
+func IsErrDeployKeyAlreadyExist(err error) bool {
+ _, ok := err.(ErrDeployKeyAlreadyExist)
+ return ok
+}
+
+func (err ErrDeployKeyAlreadyExist) Error() string {
+ return fmt.Sprintf("public key already exists [key_id: %d, repo_id: %d]", err.KeyID, err.RepoID)
+}
+
+func (err ErrDeployKeyAlreadyExist) Unwrap() error {
+ return util.ErrAlreadyExist
+}
+
+// ErrDeployKeyNameAlreadyUsed represents a "DeployKeyNameAlreadyUsed" kind of error.
+type ErrDeployKeyNameAlreadyUsed struct {
+ RepoID int64
+ Name string
+}
+
+// IsErrDeployKeyNameAlreadyUsed checks if an error is a ErrDeployKeyNameAlreadyUsed.
+func IsErrDeployKeyNameAlreadyUsed(err error) bool {
+ _, ok := err.(ErrDeployKeyNameAlreadyUsed)
+ return ok
+}
+
+func (err ErrDeployKeyNameAlreadyUsed) Error() string {
+ return fmt.Sprintf("public key with name already exists [repo_id: %d, name: %s]", err.RepoID, err.Name)
+}
+
+func (err ErrDeployKeyNameAlreadyUsed) Unwrap() error {
+ return util.ErrNotExist
+}
diff --git a/models/deploykey/main_test.go b/models/deploykey/main_test.go
new file mode 100644
index 00000000000..f4fe1a94703
--- /dev/null
+++ b/models/deploykey/main_test.go
@@ -0,0 +1,14 @@
+// Copyright 2026 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package deploykey
+
+import (
+ "testing"
+
+ "gitea.dev/models/unittest"
+)
+
+func TestMain(m *testing.M) {
+ unittest.MainTest(m, &unittest.TestOptions{FixtureFiles: []string{}}) // the tests insert what they assert on
+}
diff --git a/models/fixtures/repo_unit.yml b/models/fixtures/repo_unit.yml
index 69f083ccd75..b3af6afc132 100644
--- a/models/fixtures/repo_unit.yml
+++ b/models/fixtures/repo_unit.yml
@@ -748,4 +748,18 @@
config: "{}"
created_unix: 946684810
+-
+ id: 113
+ repo_id: 19
+ type: 1
+ config: "{}"
+ created_unix: 946684810
+
+-
+ id: 114
+ repo_id: 20
+ type: 1
+ config: "{}"
+ created_unix: 946684810
+
# DO NOT add more test data in the fixtures, test case should prepare their own test data separately and clearly
diff --git a/models/perm/access/repo_permission.go b/models/perm/access/repo_permission.go
index ff677906afe..89ce6dc604c 100644
--- a/models/perm/access/repo_permission.go
+++ b/models/perm/access/repo_permission.go
@@ -13,6 +13,7 @@ import (
actions_model "gitea.dev/models/actions"
"gitea.dev/models/db"
+ deploykey_model "gitea.dev/models/deploykey"
"gitea.dev/models/organization"
perm_model "gitea.dev/models/perm"
repo_model "gitea.dev/models/repo"
@@ -383,12 +384,39 @@ func GetActionsUserRepoPermission(ctx context.Context, repo *repo_model.Reposito
return perm, nil
}
+// getDeployKeyRepoPermission returns the permissions that a deploy key grants on a repository.
+// A key only ever reaches the git data of the one repository it was added to, at its own access mode.
+func getDeployKeyRepoPermission(ctx context.Context, repo *repo_model.Repository, keyID int64) (perm Permission, err error) {
+ key, err := deploykey_model.GetDeployKeyByID(ctx, repo.ID, keyID)
+ if err != nil {
+ if deploykey_model.IsErrDeployKeyNotExist(err) {
+ return perm, nil // the key belongs to another repository, so it grants nothing here
+ }
+ return perm, err
+ }
+ if err = repo.LoadUnits(ctx); err != nil {
+ return perm, err
+ }
+
+ perm.units = repo.Units
+ perm.unitsMode = make(map[unit.Type]perm_model.AccessMode)
+ for _, u := range repo.Units {
+ if u.Type == unit.TypeCode || u.Type == unit.TypeWiki { // a deploy-key only ever reaches git data
+ perm.unitsMode[u.Type] = key.Mode
+ }
+ }
+ return perm, nil
+}
+
// GetDoerRepoPermission returns the repository permission for the current actor,
-// dispatching to GetActionsUserRepoPermission when the actor is an Actions token user.
+// dispatching to the credential-scoped permissions when the actor is a token or key user.
func GetDoerRepoPermission(ctx context.Context, repo *repo_model.Repository, user *user_model.User) (Permission, error) {
if taskID, ok := user_model.GetActionsUserTaskID(user); ok {
return GetActionsUserRepoPermission(ctx, repo, user, taskID)
}
+ if keyID, ok := user_model.GetDeployKeyUserDeployKeyID(user); ok {
+ return getDeployKeyRepoPermission(ctx, repo, keyID)
+ }
return GetIndividualUserRepoPermission(ctx, repo, user)
}
diff --git a/models/user/avatar.go b/models/user/avatar.go
index 5b4d1cef41a..6572746b242 100644
--- a/models/user/avatar.go
+++ b/models/user/avatar.go
@@ -47,9 +47,7 @@ func GenerateRandomAvatar(ctx context.Context, u *User) error {
// AvatarLinkWithSize returns a link to the user's avatar with size. size <= 0 means default size
func (u *User) AvatarLinkWithSize(ctx context.Context, size int) string {
- // ghost user was deleted, Gitea actions is a bot user, 0 means the user should be a virtual user
- // which comes from git configure information
- if u.IsGhost() || u.IsGiteaActions() || u.ID <= 0 {
+ if u.ID <= 0 {
return avatars.DefaultAvatarLink()
}
diff --git a/models/user/user.go b/models/user/user.go
index fff576aa860..77da0fcdf2f 100644
--- a/models/user/user.go
+++ b/models/user/user.go
@@ -159,6 +159,11 @@ type User struct {
DiffViewStyle string `xorm:"NOT NULL DEFAULT ''"`
Theme string `xorm:"NOT NULL DEFAULT ''"`
KeepActivityPrivate bool `xorm:"NOT NULL DEFAULT false"`
+
+ // When the user model is used as a doer (all existing code does so), the doer can have extra details.
+ // * Actions task doer needs to bind to the task
+ // * Deploy-key doer needs to bind to the key
+ ExtDoerData ExtDoerData `xorm:"-"`
}
// Meta defines the meta information of a user, to be stored in the K/V table
@@ -418,9 +423,9 @@ func (u *User) IsOrganization() bool {
return u.Type == UserTypeOrganization
}
-// IsIndividual returns true if user is actually a individual user.
+// IsIndividual returns true if user is actually an individual user.
func (u *User) IsIndividual() bool {
- return u.Type == UserTypeIndividual
+ return u.ID > 0 && u.Type == UserTypeIndividual
}
// IsTypeBot returns whether the user is of type bot
@@ -513,9 +518,8 @@ func (u *User) GitName() string {
}
// IsMailable checks if a user is eligible to receive emails.
-// System users like Ghost and Gitea Actions are excluded.
func (u *User) IsMailable() bool {
- return u.IsActive && !u.IsGiteaActions() && !u.IsGhost()
+ return u.ID > 0 && u.IsActive && u.IsIndividual()
}
// IsUserExist checks if given username exist,
@@ -551,10 +555,11 @@ type globalVarsStruct struct {
emailToReplacer *strings.Replacer
emailRegexp *regexp.Regexp
systemUserNewFuncs map[int64]func() *User
+ systemUserNameIdMap map[string]int64
}
var globalVars = sync.OnceValue(func() *globalVarsStruct {
- return &globalVarsStruct{
+ ret := &globalVarsStruct{
// Note: The set of characters here can safely expand without a breaking change,
// but characters removed from this set can cause user account linking to break
customCharsReplacement: strings.NewReplacer("Æ", "AE"),
@@ -573,12 +578,17 @@ var globalVars = sync.OnceValue(func() *globalVarsStruct {
";", "",
),
emailRegexp: regexp.MustCompile("^[a-zA-Z0-9.!#$%&'*+-/=?^_`{|}~]*@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$"),
-
- systemUserNewFuncs: map[int64]func() *User{
- GhostUserID: NewGhostUser,
- ActionsUserID: NewActionsUser,
- },
}
+
+ userFuncs := []func() *User{NewGhostUser, NewActionsUser, NewDeployKeyUser}
+ ret.systemUserNewFuncs = map[int64]func() *User{}
+ ret.systemUserNameIdMap = map[string]int64{}
+ for _, fn := range userFuncs {
+ u := fn()
+ ret.systemUserNewFuncs[u.ID] = fn
+ ret.systemUserNameIdMap[u.LowerName] = u.ID
+ }
+ return ret
})
// NormalizeUserName only takes the name part if it is an email address, transforms it diacritics to ASCII characters.
@@ -1023,7 +1033,7 @@ func GetUserByIDs(ctx context.Context, ids []int64) ([]*User, error) {
return users, err
}
-// GetPossibleUserByID returns the possible user and its ID. If the user doesn't exist, it returns Ghost user
+// GetPossibleUserByID returns the possible user and its ID. If the user doesn't exist, it returns Ghost user
func GetPossibleUserByID(ctx context.Context, id int64) (_ int64, u *User, err error) {
if id < 0 {
if newFunc, ok := globalVars().systemUserNewFuncs[id]; ok {
diff --git a/models/user/user_extdata.go b/models/user/user_extdata.go
new file mode 100644
index 00000000000..62d6f5d6a7e
--- /dev/null
+++ b/models/user/user_extdata.go
@@ -0,0 +1,46 @@
+// Copyright 2026 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package user
+
+import (
+ "strconv"
+ "strings"
+)
+
+type ExtDoerData interface {
+ EncodeToString() string
+ DecodeFromString(string) error
+}
+
+type extDoerGiteaActions struct {
+ TaskID int64
+}
+
+var _ ExtDoerData = (*extDoerGiteaActions)(nil)
+
+func (e *extDoerGiteaActions) EncodeToString() string {
+ return "gitea-actions:" + strconv.FormatInt(e.TaskID, 10)
+}
+
+func (e *extDoerGiteaActions) DecodeFromString(s string) (err error) {
+ idStr, _ := strings.CutPrefix(s, "gitea-actions:")
+ e.TaskID, err = strconv.ParseInt(idStr, 10, 64)
+ return err
+}
+
+type extDoerDeployKey struct {
+ DeployKeyID int64
+}
+
+var _ ExtDoerData = (*extDoerDeployKey)(nil)
+
+func (e *extDoerDeployKey) EncodeToString() string {
+ return "deploy-key:" + strconv.FormatInt(e.DeployKeyID, 10)
+}
+
+func (e *extDoerDeployKey) DecodeFromString(s string) (err error) {
+ idStr, _ := strings.CutPrefix(s, "deploy-key:")
+ e.DeployKeyID, err = strconv.ParseInt(idStr, 10, 64)
+ return err
+}
diff --git a/models/user/user_list.go b/models/user/user_list.go
index c0802860599..f639a1f71a6 100644
--- a/models/user/user_list.go
+++ b/models/user/user_list.go
@@ -31,18 +31,15 @@ func GetUsersMapByIDs(ctx context.Context, userIDs []int64) (map[int64]*User, er
}
func GetPossibleUserFromMap(userID int64, usererMaps map[int64]*User) *User {
- switch userID {
- case GhostUserID:
- return NewGhostUser()
- case ActionsUserID:
- return NewActionsUser()
- case 0:
+ if userID == 0 {
return nil
- default:
- user, ok := usererMaps[userID]
- if !ok {
- return NewGhostUser()
- }
- return user
}
+ if newFunc, ok := globalVars().systemUserNewFuncs[userID]; ok {
+ return newFunc()
+ }
+ user, ok := usererMaps[userID]
+ if !ok {
+ return NewGhostUser()
+ }
+ return user
}
diff --git a/models/user/user_system.go b/models/user/user_system.go
index 5a71553e9ec..aa04015468e 100644
--- a/models/user/user_system.go
+++ b/models/user/user_system.go
@@ -4,7 +4,7 @@
package user
import (
- "strconv"
+ "context"
"strings"
"gitea.dev/modules/structs"
@@ -32,59 +32,86 @@ func (u *User) IsGhost() bool {
return u.ID == GhostUserID && u.Name == GhostUserName
}
+// newSystemUser creates and returns a fake user for system use.
+// The builtin username can be wrapped in parentheses to avoid conflicts with real usernames.
+func newSystemUser(id int64, name, fullName string) *User {
+ return &User{
+ ID: id,
+ Name: name,
+ LowerName: strings.ToLower(name),
+ IsActive: true,
+ FullName: fullName,
+ Type: UserTypeBot,
+ Visibility: structs.VisibleTypePublic,
+ }
+}
+
const (
- ActionsUserID int64 = -2
- ActionsUserName = "gitea-actions"
- ActionsUserEmail = "teabot@gitea.io"
+ ActionsUserID int64 = -2
+ DeployKeyUserID int64 = -3
)
// NewActionsUser creates and returns a fake user for running the actions.
func NewActionsUser() *User {
- return &User{
- ID: ActionsUserID,
- Name: ActionsUserName,
- LowerName: ActionsUserName,
- IsActive: true,
- FullName: "Gitea Actions",
- Email: ActionsUserEmail,
- KeepEmailPrivate: true,
- LoginName: ActionsUserName,
- Type: UserTypeBot,
- Visibility: structs.VisibleTypePublic,
+ return newSystemUser(ActionsUserID, "gitea-actions", "Gitea Actions")
+}
+
+func GetActionsUserTaskID(u *User) (int64, bool) {
+ if u == nil || u.ExtDoerData == nil || u.ID != ActionsUserID {
+ return 0, false
}
+ extData := u.ExtDoerData.(*extDoerGiteaActions) //nolint:forcetypeassert // must be valid
+ return extData.TaskID, true
}
func NewActionsUserWithTaskID(id int64) *User {
u := NewActionsUser()
- // LoginName is for only internal usage in this case, so it can be moved to other fields in the future
- u.LoginSource = -1
- u.LoginName = "@" + ActionsUserName + "/" + strconv.FormatInt(id, 10)
+ u.ExtDoerData = &extDoerGiteaActions{TaskID: id}
return u
}
-func GetActionsUserTaskID(u *User) (int64, bool) {
- if u == nil || u.ID != ActionsUserID {
- return 0, false
- }
- prefix, payload, _ := strings.Cut(u.LoginName, "/")
- if prefix != "@"+ActionsUserName {
- return 0, false
- } else if taskID, err := strconv.ParseInt(payload, 10, 64); err == nil {
- return taskID, true
- }
- return 0, false
+func NewDeployKeyUser() *User {
+ return newSystemUser(DeployKeyUserID, "(deploy-key)", "Deploy Key")
}
-func (u *User) IsGiteaActions() bool {
- return u != nil && u.ID == ActionsUserID
+func GetDeployKeyUserDeployKeyID(u *User) (int64, bool) {
+ // ok, the function name seems wordy, it is intentionally to distinguish from other "keys" like "public key id"
+ // it was a mess in the "pre-receive" hook code
+ if u == nil || u.ExtDoerData == nil || u.ID != DeployKeyUserID {
+ return 0, false
+ }
+ extData := u.ExtDoerData.(*extDoerDeployKey) //nolint:forcetypeassert // must be valid
+ return extData.DeployKeyID, true
+}
+
+func NewDeployKeyUserWithKeyID(id int64) *User {
+ u := NewDeployKeyUser()
+ u.ExtDoerData = &extDoerDeployKey{DeployKeyID: id}
+ return u
}
func GetSystemUserByName(name string) *User {
- if strings.EqualFold(name, GhostUserName) {
- return NewGhostUser()
- }
- if strings.EqualFold(name, ActionsUserName) {
- return NewActionsUser()
+ lowerName := strings.ToLower(name)
+ uid := globalVars().systemUserNameIdMap[lowerName]
+ if fn := globalVars().systemUserNewFuncs[uid]; fn != nil {
+ return fn()
}
return nil
}
+
+func GetDoerUser(ctx context.Context, id int64, extDoerData string) (u *User, _ error) {
+ if id > 0 {
+ return GetUserByID(ctx, id)
+ }
+ switch id {
+ case ActionsUserID:
+ u = NewActionsUser()
+ u.ExtDoerData = &extDoerGiteaActions{}
+ case DeployKeyUserID:
+ u = NewDeployKeyUser()
+ u.ExtDoerData = &extDoerDeployKey{}
+ default:
+ return nil, ErrUserNotExist{UID: id}
+ }
+ return u, u.ExtDoerData.DecodeFromString(extDoerData)
+}
diff --git a/models/user/user_system_test.go b/models/user/user_system_test.go
index 3ae9c6e3665..8111e7b54c9 100644
--- a/models/user/user_system_test.go
+++ b/models/user/user_system_test.go
@@ -27,7 +27,6 @@ func TestSystemUser(t *testing.T) {
assert.Equal(t, int64(-2), uid)
assert.Equal(t, "gitea-actions", u.Name)
assert.Equal(t, "gitea-actions", u.LowerName)
- assert.True(t, u.IsGiteaActions())
u = GetSystemUserByName("Gitea-actionS")
require.NotNil(t, u)
diff --git a/modules/private/hook.go b/modules/private/hook.go
index 0ca1f36e318..9e47f10fc95 100644
--- a/modules/private/hook.go
+++ b/modules/private/hook.go
@@ -24,20 +24,23 @@ const (
// HookOptions represents the options for the Hook calls
type HookOptions struct {
- OldCommitIDs []string
- NewCommitIDs []string
- RefFullNames []git.RefName
- UserID int64
- UserName string
+ IsWiki bool
+
+ OldCommitIDs []string
+ NewCommitIDs []string
+ RefFullNames []git.RefName
+
GitObjectDirectory string
GitAlternativeObjectDirectories string
GitQuarantinePath string
GitPushOptions GitPushOptions
- PullRequestID int64
- PushTrigger repository.PushTrigger
- DeployKeyID int64 // if the pusher is a DeployKey, then UserID is the repo's org user.
- IsWiki bool
- ActionsTaskID int64 // if the pusher is an Actions user, the task ID
+
+ PullRequestID int64
+ PushTrigger repository.PushTrigger
+
+ UserID int64
+ UserName string
+ UserExtDoerData string
}
// SSHLogOption ssh log options
diff --git a/modules/private/serv.go b/modules/private/serv.go
index 20768d10196..c61ef20476c 100644
--- a/modules/private/serv.go
+++ b/modules/private/serv.go
@@ -33,16 +33,18 @@ func ServNoCommand(ctx context.Context, keyID int64) (*asymkey_model.PublicKey,
// ServCommandResults are the results of a call to the private route serv
type ServCommandResults struct {
- IsWiki bool
- DeployKeyID int64
- KeyID int64 // public key
- KeyName string // this field is ambiguous, it can be the name of DeployKey, or the name of the PublicKey
- UserName string
- UserEmail string
- UserID int64
- OwnerName string
- RepoName string
- RepoID int64
+ IsWiki bool
+
+ OwnerName string
+ RepoName string
+ RepoID int64
+
+ PublicKeyID int64
+
+ UserName string
+ UserEmail string
+ UserID int64
+ UserExtDoerData string
RepoStoragePath string
}
diff --git a/modules/repository/env.go b/modules/repository/env.go
index 5eecb54e07b..d2586eaf54d 100644
--- a/modules/repository/env.go
+++ b/modules/repository/env.go
@@ -16,21 +16,23 @@ import (
// env keys for git hooks need
const (
- EnvRepoName = "GITEA_REPO_NAME"
- EnvRepoUsername = "GITEA_REPO_USER_NAME"
- EnvRepoID = "GITEA_REPO_ID"
- EnvRepoIsWiki = "GITEA_REPO_IS_WIKI"
- EnvPusherName = "GITEA_PUSHER_NAME"
- EnvPusherEmail = "GITEA_PUSHER_EMAIL"
- EnvPusherID = "GITEA_PUSHER_ID"
- EnvKeyID = "GITEA_KEY_ID" // public key ID
- EnvDeployKeyID = "GITEA_DEPLOY_KEY_ID"
- EnvPRID = "GITEA_PR_ID"
- EnvPRIndex = "GITEA_PR_INDEX" // not used by Gitea at the moment, it is for custom git hooks
- EnvPushTrigger = "GITEA_PUSH_TRIGGER"
- EnvIsInternal = "GITEA_INTERNAL_PUSH"
- EnvAppURL = "GITEA_ROOT_URL"
- EnvActionsTaskID = "GITEA_ACTIONS_TASK_ID"
+ EnvRepoName = "GITEA_REPO_NAME"
+ EnvRepoUsername = "GITEA_REPO_USER_NAME" // owner name
+ EnvRepoID = "GITEA_REPO_ID"
+ EnvRepoIsWiki = "GITEA_REPO_IS_WIKI"
+
+ EnvKeyID = "GITEA_KEY_ID" // public key ID
+
+ EnvPusherName = "GITEA_PUSHER_NAME"
+ EnvPusherEmail = "GITEA_PUSHER_EMAIL"
+ EnvPusherID = "GITEA_PUSHER_ID"
+ EnvPusherExtDoerData = "GITEA_PUSHER_EXT_DOER_DATA"
+
+ EnvPRID = "GITEA_PR_ID"
+ EnvPRIndex = "GITEA_PR_INDEX" // not used by Gitea at the moment, it is for custom git hooks
+ EnvPushTrigger = "GITEA_PUSH_TRIGGER"
+ EnvIsInternal = "GITEA_INTERNAL_PUSH"
+ EnvAppURL = "GITEA_ROOT_URL"
)
type PushTrigger string
@@ -68,8 +70,8 @@ func DoerPushingEnvironment(doer *user_model.User, repo *repo_model.Repository,
if !doer.KeepEmailPrivate {
env = append(env, EnvPusherEmail+"="+doer.Email)
}
- if taskID, isActionsUser := user_model.GetActionsUserTaskID(doer); isActionsUser {
- env = append(env, EnvActionsTaskID+"="+strconv.FormatInt(taskID, 10))
+ if doer.ExtDoerData != nil {
+ env = append(env, EnvPusherExtDoerData+"="+doer.ExtDoerData.EncodeToString())
}
return env
}
diff --git a/modules/reqctx/datastore.go b/modules/reqctx/datastore.go
index 282d1ddd877..d1e7aab1a39 100644
--- a/modules/reqctx/datastore.go
+++ b/modules/reqctx/datastore.go
@@ -146,8 +146,15 @@ func NewRequestContext(parentCtx context.Context, profDesc string) (_ context.Co
}
}
-// NewRequestContextForTest creates a new RequestContext for testing purposes
-// It doesn't add the context to the process manager, nor do cleanup
-func NewRequestContextForTest(parentCtx context.Context) RequestContext {
- return &requestContext{Context: parentCtx, RequestDataStore: &requestDataStore{values: make(map[any]any)}}
+type TestingT interface {
+ Cleanup(func())
+ Context() context.Context
+}
+
+// NewRequestContextForTest creates a new RequestContext for testing purposes
+func NewRequestContextForTest(t TestingT) RequestContext {
+ store := &requestDataStore{values: make(map[any]any)}
+ ret := &requestContext{Context: t.Context(), RequestDataStore: store}
+ t.Cleanup(store.cleanUp)
+ return ret
}
diff --git a/modules/structs/repo_key.go b/modules/structs/repo_key.go
index a13cde71fbf..ba9b5e6e621 100644
--- a/modules/structs/repo_key.go
+++ b/modules/structs/repo_key.go
@@ -7,30 +7,33 @@ import (
"time"
)
-// DeployKey a deploy key
type DeployKey struct {
- // ID is the unique identifier for the deploy key
+ // ID is the unique identifier for the deploy-key
ID int64 `json:"id"`
+ // Type tells whether the key authenticates over SSH or with a token over HTTPS
+ // enum: ssh,token
+ KeyType string `json:"key_type"`
// KeyID is the associated public key ID
KeyID int64 `json:"key_id"`
// Key contains the actual SSH key content
Key string `json:"key"`
- // URL is the API URL for this deploy key
+ // URL is the API URL for this deploy-key
URL string `json:"url"`
// Title is the human-readable name for the key
Title string `json:"title"`
// Fingerprint is the key's fingerprint
Fingerprint string `json:"fingerprint"`
+ // Token is the plaintext token of an HTTPS key, only returned when it is created
+ Token string `json:"token,omitempty"`
// swagger:strfmt date-time
- // Created is the time when the deploy key was added
+ // Created is the time when the deploy-key was added
Created time.Time `json:"created_at"`
// ReadOnly indicates if the key has read-only access
ReadOnly bool `json:"read_only"`
- // Repository is the repository this deploy key belongs to
+ // Repository is the repository this deploy-key belongs to
Repository *Repository `json:"repository,omitempty"`
}
-// CreateKeyOption options when creating a key
type CreateKeyOption struct {
// Title of the key to add
//
@@ -43,7 +46,15 @@ type CreateKeyOption struct {
// unique: true
Key string `json:"key" binding:"Required"`
// Describe if the key has only read access or read/write
- //
- // required: false
+ ReadOnly bool `json:"read_only"`
+}
+
+type CreateDeployKeyTokenOption struct {
+ // Title of the token to add
+ //
+ // required: true
+ // unique: true
+ Title string `json:"title" binding:"Required;MaxSize(50)"`
+ // Describe if the token has only read access or read/write
ReadOnly bool `json:"read_only"`
}
diff --git a/modules/templates/util_render_comment_test.go b/modules/templates/util_render_comment_test.go
index e0e912a3f81..5dd5b27a0e5 100644
--- a/modules/templates/util_render_comment_test.go
+++ b/modules/templates/util_render_comment_test.go
@@ -15,7 +15,7 @@ import (
)
func TestRenderTimelineEventComment(t *testing.T) {
- ctx := reqctx.NewRequestContextForTest(t.Context())
+ ctx := reqctx.NewRequestContextForTest(t)
ctx.SetContextValue(translation.ContextKey, &translation.MockLocale{})
ut := &RenderUtils{ctx: ctx}
var createdStr template.HTML = "(created-at)"
diff --git a/modules/templates/util_render_test.go b/modules/templates/util_render_test.go
index d9dabf8ec8b..f90ee35fdf0 100644
--- a/modules/templates/util_render_test.go
+++ b/modules/templates/util_render_test.go
@@ -62,7 +62,7 @@ func TestMain(m *testing.M) {
}
func newTestRenderUtils(t *testing.T) *RenderUtils {
- ctx := reqctx.NewRequestContextForTest(t.Context())
+ ctx := reqctx.NewRequestContextForTest(t)
ctx.SetContextValue(translation.ContextKey, &translation.MockLocale{})
return NewRenderUtils(ctx)
}
diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json
index f5b7ed1268b..6cb8a033a0e 100644
--- a/options/locale/locale_en-US.json
+++ b/options/locale/locale_en-US.json
@@ -2387,19 +2387,22 @@
"repo.settings.packagist_api_token": "API token",
"repo.settings.packagist_package_url": "Packagist package URL",
"repo.settings.deploy_keys": "Deploy Keys",
- "repo.settings.add_deploy_key": "Add Deploy Key",
- "repo.settings.deploy_key_desc": "Deploy keys have read-only pull access to the repository.",
"repo.settings.is_writable": "Enable Write Access",
"repo.settings.is_writable_info": "Allow this deploy key to push to the repository.",
"repo.settings.no_deploy_keys": "There are no deploy keys yet.",
"repo.settings.title": "Title",
- "repo.settings.deploy_key_content": "Content",
+ "repo.settings.add_deploy_key_ssh": "Add SSH key",
+ "repo.settings.deploy_key_ssh_desc": "Add an SSH public key as deploy key, then use its private key to access the repository.",
+ "repo.settings.deploy_key_token_desc": "An HTTP token is used as the password of a Git request over HTTPS for this repository.",
+ "repo.settings.generate_deploy_token": "Generate HTTP Token",
+ "repo.settings.generate_deploy_token_success": "HTTP token %s generated. Copy it now, it is not shown again.",
+ "repo.settings.regenerate_deploy_token_desc": "Regenerating an HTTP token will revoke the current one. Continue?",
+ "repo.settings.regenerate_deploy_token_success": "HTTP token %s regenerated. Copy it now, it is not shown again.",
+ "repo.settings.add_key_success": "Deploy key %s has been added.",
"repo.settings.key_been_used": "A deploy key with identical content is already in use.",
"repo.settings.key_name_used": "A deploy key with the same name already exists.",
- "repo.settings.add_key_success": "The deploy key \"%s\" has been added.",
- "repo.settings.deploy_key_deletion": "Remove Deploy Key",
"repo.settings.deploy_key_deletion_desc": "Removing a deploy key will revoke its access to this repository. Continue?",
- "repo.settings.deploy_key_deletion_success": "The deploy key has been removed.",
+ "repo.settings.deploy_key_deletion_success": "Deploy key %s has been removed.",
"repo.settings.branches": "Branches",
"repo.settings.protected_branch": "Branch Protection",
"repo.settings.protected_branch.save_rule": "Save Rule",
diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go
index 1860f8b820f..51fca8e85c6 100644
--- a/routers/api/v1/api.go
+++ b/routers/api/v1/api.go
@@ -189,13 +189,7 @@ func repoAssignment() func(ctx *context.APIContext) {
repo.Owner = owner
ctx.Repo.Repository = repo
- if taskID, ok := user_model.GetActionsUserTaskID(ctx.Doer); ok {
- ctx.Repo.Permission, err = access_model.GetActionsUserRepoPermission(ctx, repo, ctx.Doer, taskID)
- if err != nil {
- ctx.APIErrorInternal(err)
- return
- }
- } else {
+ {
needTwoFactor, err := doerNeedTwoFactorAuth(ctx, ctx.Doer)
if err != nil {
ctx.APIErrorInternal(err)
@@ -228,7 +222,7 @@ func doerNeedTwoFactorAuth(ctx gocontext.Context, doer *user_model.User) (bool,
if !setting.TwoFactorAuthEnforced {
return false, nil
}
- if doer == nil {
+ if doer == nil || !doer.IsIndividual() { // system doers like Actions tasks or deploy-keys can never enroll 2FA
return false, nil
}
has, err := auth_model.HasTwoFactorOrWebAuthn(ctx, doer.ID)
@@ -1448,6 +1442,7 @@ func Routes() *web.Router {
m.Group("/keys", func() {
m.Combo("").Get(repo.ListDeployKeys).
Post(bind(api.CreateKeyOption{}), repo.CreateDeployKey)
+ m.Post("/tokens", bind(api.CreateDeployKeyTokenOption{}), repo.CreateDeployToken)
m.Combo("/{id}").Get(repo.GetDeployKey).
Delete(repo.DeleteDeployKey)
}, reqToken(), reqAdmin())
diff --git a/routers/api/v1/api_test.go b/routers/api/v1/api_test.go
new file mode 100644
index 00000000000..8ab4a496e2a
--- /dev/null
+++ b/routers/api/v1/api_test.go
@@ -0,0 +1,25 @@
+// Copyright 2026 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package v1
+
+import (
+ "testing"
+
+ user_model "gitea.dev/models/user"
+ "gitea.dev/modules/setting"
+ "gitea.dev/modules/test"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestDoerNeedTwoFactorAuth(t *testing.T) {
+ defer test.MockVariableValue(&setting.TwoFactorAuthEnforced, true)()
+
+ for _, doer := range []*user_model.User{nil, user_model.NewActionsUser(), user_model.NewDeployKeyUser()} {
+ need, err := doerNeedTwoFactorAuth(t.Context(), doer)
+ require.NoError(t, err)
+ assert.False(t, need)
+ }
+}
diff --git a/routers/api/v1/repo/key.go b/routers/api/v1/repo/key.go
index 465a775c931..b0da7816e89 100644
--- a/routers/api/v1/repo/key.go
+++ b/routers/api/v1/repo/key.go
@@ -11,6 +11,7 @@ import (
asymkey_model "gitea.dev/models/asymkey"
"gitea.dev/models/db"
+ deploykey_model "gitea.dev/models/deploykey"
"gitea.dev/models/perm"
access_model "gitea.dev/models/perm/access"
repo_model "gitea.dev/models/repo"
@@ -24,7 +25,7 @@ import (
)
// appendPrivateInformation appends the owner and key type information to api.PublicKey
-func appendPrivateInformation(ctx stdCtx.Context, apiKey *api.DeployKey, key *asymkey_model.DeployKey, repository *repo_model.Repository) (*api.DeployKey, error) {
+func appendPrivateInformation(ctx stdCtx.Context, apiKey *api.DeployKey, key *deploykey_model.DeployKey, repository *repo_model.Repository) (*api.DeployKey, error) {
apiKey.ReadOnly = key.Mode == perm.AccessModeRead
if repository.ID == key.RepoID {
apiKey.Repository = convert.ToRepo(ctx, repository, access_model.Permission{AccessMode: key.Mode})
@@ -78,14 +79,14 @@ func ListDeployKeys(ctx *context.APIContext) {
// "404":
// "$ref": "#/responses/notFound"
- opts := asymkey_model.ListDeployKeysOptions{
+ opts := deploykey_model.ListDeployKeysOptions{
ListOptions: utils.GetListOptions(ctx),
RepoID: ctx.Repo.Repository.ID,
KeyID: ctx.FormInt64("key_id"),
Fingerprint: ctx.FormString("fingerprint"),
}
- keys, count, err := db.FindAndCount[asymkey_model.DeployKey](ctx, opts)
+ keys, count, err := db.FindAndCount[deploykey_model.DeployKey](ctx, opts)
if err != nil {
ctx.APIErrorInternal(err)
return
@@ -133,7 +134,7 @@ func GetDeployKey(ctx *context.APIContext) {
// "404":
// "$ref": "#/responses/notFound"
- key, err := asymkey_model.GetDeployKeyByID(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("id"))
+ key, err := deploykey_model.GetDeployKeyByID(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("id"))
if err != nil {
ctx.APIErrorAuto(err)
return
@@ -160,13 +161,13 @@ func HandleCheckKeyStringError(ctx *context.APIContext, err error) {
// HandleAddKeyError handle add key error
func HandleAddKeyError(ctx *context.APIContext, err error) {
switch {
- case asymkey_model.IsErrDeployKeyAlreadyExist(err):
+ case deploykey_model.IsErrDeployKeyAlreadyExist(err):
ctx.APIError(http.StatusUnprocessableEntity, "This key has already been added to this repository")
case asymkey_model.IsErrKeyAlreadyExist(err):
ctx.APIError(http.StatusUnprocessableEntity, "Key content has been used as non-deploy key")
case asymkey_model.IsErrKeyNameAlreadyUsed(err):
ctx.APIError(http.StatusUnprocessableEntity, "Key title has been used")
- case asymkey_model.IsErrDeployKeyNameAlreadyUsed(err):
+ case deploykey_model.IsErrDeployKeyNameAlreadyUsed(err):
ctx.APIError(http.StatusUnprocessableEntity, "A key with the same name already exists")
default:
ctx.APIErrorInternal(err)
@@ -213,7 +214,7 @@ func CreateDeployKey(ctx *context.APIContext) {
}
accessMode := util.Iif(form.ReadOnly, perm.AccessModeRead, perm.AccessModeWrite)
- key, err := asymkey_model.AddDeployKey(ctx, ctx.Repo.Repository.ID, form.Title, content, accessMode)
+ key, err := deploykey_model.AddDeployKeySSH(ctx, ctx.Repo.Repository.ID, form.Title, content, accessMode)
if err != nil {
HandleAddKeyError(ctx, err)
return
@@ -221,6 +222,49 @@ func CreateDeployKey(ctx *context.APIContext) {
ctx.JSON(http.StatusCreated, convert.ToDeployKey(ctx, ctx.Repo.Repository, key))
}
+// CreateDeployToken create a deploy token for a repository
+func CreateDeployToken(ctx *context.APIContext) {
+ // swagger:operation POST /repos/{owner}/{repo}/keys/tokens repository repoCreateDeployToken
+ // ---
+ // summary: Add a deploy token to a repository, it authenticates git over HTTPS
+ // consumes:
+ // - application/json
+ // produces:
+ // - application/json
+ // parameters:
+ // - name: owner
+ // in: path
+ // description: owner of the repo
+ // type: string
+ // required: true
+ // - name: repo
+ // in: path
+ // description: name of the repo
+ // type: string
+ // required: true
+ // - name: body
+ // in: body
+ // schema:
+ // "$ref": "#/definitions/CreateDeployKeyTokenOption"
+ // responses:
+ // "201":
+ // "$ref": "#/responses/DeployKey"
+ // "404":
+ // "$ref": "#/responses/notFound"
+ // "422":
+ // "$ref": "#/responses/validationError"
+
+ form := web.GetForm[*api.CreateDeployKeyTokenOption](ctx)
+ accessMode := util.Iif(form.ReadOnly, perm.AccessModeRead, perm.AccessModeWrite)
+ key, err := deploykey_model.AddDeployKeyToken(ctx, ctx.Repo.Repository.ID, form.Title, accessMode)
+ if err != nil {
+ HandleAddKeyError(ctx, err)
+ return
+ }
+
+ ctx.JSON(http.StatusCreated, convert.ToDeployKey(ctx, ctx.Repo.Repository, key))
+}
+
// DeleteDeployKey delete deploy key for a repository
func DeleteDeployKey(ctx *context.APIContext) {
// swagger:operation DELETE /repos/{owner}/{repo}/keys/{id} repository repoDeleteKey
@@ -251,7 +295,8 @@ func DeleteDeployKey(ctx *context.APIContext) {
// "404":
// "$ref": "#/responses/notFound"
- if err := asymkey_service.DeleteDeployKey(ctx, ctx.Repo.Repository, ctx.PathParamInt64("id")); err != nil {
+ // a key that is already gone still leaves the caller with the state it asked for
+ if _, err := asymkey_service.DeleteDeployKey(ctx, ctx.Repo.Repository, ctx.PathParamInt64("id")); err != nil && !deploykey_model.IsErrDeployKeyNotExist(err) {
if asymkey_model.IsErrKeyAccessDenied(err) {
ctx.APIError(http.StatusForbidden, "You do not have access to this key")
} else {
diff --git a/routers/api/v1/swagger/options.go b/routers/api/v1/swagger/options.go
index e87b1761ad8..0522fcec680 100644
--- a/routers/api/v1/swagger/options.go
+++ b/routers/api/v1/swagger/options.go
@@ -53,6 +53,9 @@ type swaggerParameterBodies struct {
// in:body
CreateKeyOption api.CreateKeyOption
+ // in:body
+ CreateDeployKeyTokenOption api.CreateDeployKeyTokenOption
+
// in:body
RenameUserOption api.RenameUserOption
diff --git a/routers/api/v1/user/user.go b/routers/api/v1/user/user.go
index 8343e380773..22bd2b9e494 100644
--- a/routers/api/v1/user/user.go
+++ b/routers/api/v1/user/user.go
@@ -58,16 +58,13 @@ func Search(ctx *context.APIContext) {
uid := ctx.FormInt64("uid")
var users []*user_model.User
var maxResults int64
- var err error
-
- switch uid {
- case user_model.GhostUserID:
- maxResults = 1
- users = []*user_model.User{user_model.NewGhostUser()}
- case user_model.ActionsUserID:
- maxResults = 1
- users = []*user_model.User{user_model.NewActionsUser()}
- default:
+ if uid < 0 {
+ _, sysUser, _ := user_model.GetPossibleUserByID(ctx, uid)
+ if sysUser != nil && sysUser.ID == uid {
+ maxResults = 1
+ users = []*user_model.User{sysUser}
+ }
+ } else {
opts := user_model.SearchUserOptions{
Actor: ctx.Doer,
Keyword: ctx.FormTrim("q"),
@@ -77,6 +74,7 @@ func Search(ctx *context.APIContext) {
ListOptions: listOptions,
}
opts.ApplyPublicOnly(ctx.PublicOnly)
+ var err error
users, maxResults, err = user_model.SearchUsers(ctx, opts)
if err != nil {
ctx.JSON(http.StatusInternalServerError, map[string]any{
diff --git a/routers/common/errpage_test.go b/routers/common/errpage_test.go
index 319ae7c1711..667155b19bf 100644
--- a/routers/common/errpage_test.go
+++ b/routers/common/errpage_test.go
@@ -21,7 +21,7 @@ func TestRenderPanicErrorPage(t *testing.T) {
t.Run("HTML", func(t *testing.T) {
w := httptest.NewRecorder()
req := &http.Request{URL: &url.URL{}, Header: http.Header{"Accept": []string{"text/html"}}}
- req = req.WithContext(reqctx.NewRequestContextForTest(t.Context()))
+ req = req.WithContext(reqctx.NewRequestContextForTest(t))
renderPanicErrorPage(w, req, errors.New("fake panic error (for test only)"))
respContent := w.Body.String()
assert.Contains(t, respContent, `class="page-content status-page-500"`)
@@ -36,7 +36,7 @@ func TestRenderPanicErrorPage(t *testing.T) {
t.Run("Plain", func(t *testing.T) {
w := httptest.NewRecorder()
req := &http.Request{URL: &url.URL{}}
- req = req.WithContext(reqctx.NewRequestContextForTest(t.Context()))
+ req = req.WithContext(reqctx.NewRequestContextForTest(t))
renderServiceUnavailable(w, req)
assert.Equal(t, "Service Unavailable", w.Body.String())
})
diff --git a/routers/private/hook_post_receive.go b/routers/private/hook_post_receive.go
index 9d48292a084..365c4eacc51 100644
--- a/routers/private/hook_post_receive.go
+++ b/routers/private/hook_post_receive.go
@@ -4,18 +4,13 @@
package private
import (
- "context"
"errors"
"fmt"
"net/http"
git_model "gitea.dev/models/git"
issues_model "gitea.dev/models/issues"
- access_model "gitea.dev/models/perm/access"
repo_model "gitea.dev/models/repo"
- user_model "gitea.dev/models/user"
- "gitea.dev/modules/cache"
- "gitea.dev/modules/cachegroup"
"gitea.dev/modules/git"
"gitea.dev/modules/log"
"gitea.dev/modules/private"
@@ -103,15 +98,11 @@ func HookPostReceive(ctx *gitea_context.PrivateContext) {
setting.PanicInDevOrTesting("wiki hook-post-receive is not supported")
return
}
-
- ownerName := ctx.PathParam("owner")
- repoName := ctx.PathParam("repo")
- repo := loadRepository(ctx, ownerName, repoName)
- if ctx.Written() {
+ if !loadContextDoerPermission(ctx, opts.UserID, opts.UserExtDoerData) {
return
}
- // now, repo can't be nil
+ repo := ctx.Repo.Repository
// first, collect updates and sync branches
updates := hookPostReceiveCollectPushUpdates(opts, repo)
if !hookPostReceiveSyncDatabaseBranches(ctx, opts, repo, updates) {
@@ -144,17 +135,7 @@ func hookPostReceiveUpdateRepoByOptions(ctx *gitea_context.PrivateContext, opts
isTemplate := opts.GitPushOptions.Bool(private.GitPushOptionRepoTemplate)
// Handle Push Options
if isPrivate.Has() || isTemplate.Has() {
- pusher, err := loadContextCacheUser(ctx, opts.UserID)
- if err != nil {
- ctx.PrivateInternalErrorf("failed to load pusher user: %v", err)
- return false
- }
- perm, err := access_model.GetDoerRepoPermission(ctx, repo, pusher)
- if err != nil {
- ctx.PrivateInternalErrorf("failed to load doer repo permission: %v", err)
- return false
- }
- if !perm.IsOwner() && !perm.IsAdmin() {
+ if !ctx.Repo.Permission.IsAdmin() {
ctx.PrivateUserErrorf(http.StatusNotFound, "permission denied")
return false
}
@@ -171,13 +152,13 @@ func hookPostReceiveUpdateRepoByOptions(ctx *gitea_context.PrivateContext, opts
// yet; setting the flags directly is sufficient in this push-to-create case.
if isPrivate.Has() && repo.IsPrivate != isPrivate.Value() {
repo.IsPrivate = isPrivate.Value()
- if err = repo_model.UpdateRepositoryColsNoAutoTime(ctx, repo, "is_private"); err != nil {
+ if err := repo_model.UpdateRepositoryColsNoAutoTime(ctx, repo, "is_private"); err != nil {
log.Error("failed to update repo is_private: %v", err)
}
}
if isTemplate.Has() && repo.IsTemplate != isTemplate.Value() {
repo.IsTemplate = isTemplate.Value()
- if err = repo_model.UpdateRepositoryColsNoAutoTime(ctx, repo, "is_template"); err != nil {
+ if err := repo_model.UpdateRepositoryColsNoAutoTime(ctx, repo, "is_template"); err != nil {
log.Error("failed to update repo is_template: %v", err)
}
}
@@ -244,10 +225,6 @@ func hookPostReceiveRespondWithTrailer(ctx *gitea_context.PrivateContext, opts *
ctx.JSON(http.StatusOK, private.HookPostReceiveResult{Results: results})
}
-func loadContextCacheUser(ctx context.Context, id int64) (*user_model.User, error) {
- return cache.GetWithContextCache(ctx, cachegroup.User, id, user_model.GetUserByID)
-}
-
// hookPostReceiveHandlePullRequestMerging handle pull request merging, a pull request action should push at least 1 commit
func hookPostReceiveHandlePullRequestMerging(ctx *gitea_context.PrivateContext, opts *private.HookOptions, updates []*repo_module.PushUpdateOptions) bool {
if len(updates) == 0 {
@@ -261,15 +238,9 @@ func hookPostReceiveHandlePullRequestMerging(ctx *gitea_context.PrivateContext,
return false
}
- pusher, err := loadContextCacheUser(ctx, opts.UserID)
- if err != nil {
- ctx.PrivateInternalErrorf("failed to load pusher user %d: %v", opts.UserID, err)
- return false
- }
-
// FIXME: Maybe we need a `PullRequestStatusMerged` status for PRs that are merged, currently we use the previous status
// here to keep it as before, that maybe PullRequestStatusMergeable
- _, err = pull_service.SetMerged(ctx, pr, updates[len(updates)-1].NewCommitID, timeutil.TimeStampNow(), pusher, pr.Status)
+ _, err = pull_service.SetMerged(ctx, pr, updates[len(updates)-1].NewCommitID, timeutil.TimeStampNow(), ctx.Doer, pr.Status)
if err != nil {
ctx.PrivateInternalErrorf("failed to set pr %d to merged: %v", pr.ID, err)
return false
diff --git a/routers/private/hook_post_receive_test.go b/routers/private/hook_post_receive_test.go
index b465c7f6e8c..a7099aa58df 100644
--- a/routers/private/hook_post_receive_test.go
+++ b/routers/private/hook_post_receive_test.go
@@ -25,13 +25,14 @@ func TestHandlePullRequestMerging(t *testing.T) {
assert.NoError(t, pr.LoadBaseRepo(t.Context()))
user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
-
+ user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
err = pull_model.ScheduleAutoMerge(t.Context(), user1, pr.ID, repo_model.MergeStyleSquash, "squash merge a pr", false)
assert.NoError(t, err)
autoMerge := unittest.AssertExistsAndLoadBean(t, &pull_model.AutoMerge{PullID: pr.ID})
ctx, resp := contexttest.MockPrivateContext(t, "/")
+ ctx.Doer = user2
hookPostReceiveHandlePullRequestMerging(ctx, &private.HookOptions{
PullRequestID: pr.ID,
UserID: 2,
diff --git a/routers/private/hook_pre_receive.go b/routers/private/hook_pre_receive.go
index d33b1a32ffb..82e1cd78053 100644
--- a/routers/private/hook_pre_receive.go
+++ b/routers/private/hook_pre_receive.go
@@ -8,11 +8,8 @@ import (
"net/http"
"os"
- asymkey_model "gitea.dev/models/asymkey"
git_model "gitea.dev/models/git"
issues_model "gitea.dev/models/issues"
- perm_model "gitea.dev/models/perm"
- access_model "gitea.dev/models/perm/access"
"gitea.dev/models/unit"
user_model "gitea.dev/models/user"
"gitea.dev/modules/git"
@@ -27,29 +24,18 @@ import (
type preReceiveContext struct {
*gitea_context.PrivateContext
-
- user *user_model.User // the "pusher", it's the org user if a DeployKey is used
- userPerm access_model.Permission
- deployKeyAccessMode perm_model.AccessMode
-
- canCreatePullRequest bool
- checkedCanCreatePullRequest bool
-
- protectedTags []*git_model.ProtectedTag
- gotProtectedTags bool
-
- env []string
-
+ env []string
opts *private.HookOptions
// this context should only contain shared variables, mutable variables like "current branch name" shouldn't be put here
canWriteCodeUnitCached *bool
+ canCreatePullRequest *bool
+ protectedTags []*git_model.ProtectedTag
}
func (ctx *preReceiveContext) canWriteCodeUnit() bool {
if ctx.canWriteCodeUnitCached == nil {
- canWrite := ctx.userPerm.CanWrite(unit.TypeCode) || ctx.deployKeyAccessMode >= perm_model.AccessModeWrite
- ctx.canWriteCodeUnitCached = &canWrite
+ ctx.canWriteCodeUnitCached = new(ctx.Repo.Permission.CanWrite(unit.TypeCode))
}
return *ctx.canWriteCodeUnitCached
}
@@ -63,7 +49,7 @@ func (ctx *preReceiveContext) canWriteCodeRef(refFullName git.RefName) bool {
if !refFullName.IsBranch() {
return false
}
- return issues_model.CanMaintainerWriteToBranch(ctx, ctx.userPerm, refFullName.BranchName(), ctx.user)
+ return issues_model.CanMaintainerWriteToBranch(ctx, ctx.Repo.Permission, refFullName.BranchName(), ctx.Doer)
}
// assertCanWriteRef returns true if pusher can write to the code ref, otherwise it responds with 403 Forbidden and returns false
@@ -80,11 +66,10 @@ func (ctx *preReceiveContext) assertCanWriteRef(refFullName git.RefName) bool {
// CanCreatePullRequest returns true if pusher can create pull requests
func (ctx *preReceiveContext) CanCreatePullRequest() bool {
- if !ctx.checkedCanCreatePullRequest {
- ctx.canCreatePullRequest = ctx.userPerm.CanRead(unit.TypePullRequests)
- ctx.checkedCanCreatePullRequest = true
+ if ctx.canCreatePullRequest == nil {
+ ctx.canCreatePullRequest = new(ctx.Repo.Permission.CanRead(unit.TypePullRequests))
}
- return ctx.canCreatePullRequest
+ return *ctx.canCreatePullRequest
}
// AssertCreatePullRequest returns true if can create pull requests
@@ -102,6 +87,9 @@ func (ctx *preReceiveContext) AssertCreatePullRequest() bool {
// HookPreReceive checks whether a individual commit is acceptable
func HookPreReceive(ctx *gitea_context.PrivateContext) {
opts := web.GetForm[*private.HookOptions](ctx)
+ if !loadContextDoerPermission(ctx, opts.UserID, opts.UserExtDoerData) {
+ return
+ }
ourCtx := &preReceiveContext{
PrivateContext: ctx,
@@ -109,10 +97,6 @@ func HookPreReceive(ctx *gitea_context.PrivateContext) {
opts: opts,
}
- if !ourCtx.loadPusherAndPermission() {
- return // if error occurs, loadPusherAndPermission had written the error response
- }
-
// Iterate across the provided old commit IDs
for i := range opts.OldCommitIDs {
oldCommitID := opts.OldCommitIDs[i]
@@ -236,7 +220,7 @@ func preReceiveBranch(ctx *preReceiveContext, oldCommitID, newCommitID string, r
// 5. Check if the doer is allowed to push (and force-push if the incoming push is a force-push)
var canPush bool
- if ctx.opts.DeployKeyID != 0 {
+ if ctx.opts.UserID == user_model.DeployKeyUserID {
// This flag is only ever true if protectBranch.CanForcePush is true
if isForcePush {
canPush = !changedProtectedfiles && protectBranch.CanPush && (!protectBranch.EnableForcePushAllowlist || protectBranch.ForcePushAllowlistDeployKeys)
@@ -245,9 +229,9 @@ func preReceiveBranch(ctx *preReceiveContext, oldCommitID, newCommitID string, r
}
} else {
if isForcePush {
- canPush = !changedProtectedfiles && protectBranch.CanUserForcePush(ctx, ctx.user)
+ canPush = !changedProtectedfiles && protectBranch.CanUserForcePush(ctx, ctx.Doer)
} else {
- canPush = !changedProtectedfiles && protectBranch.CanUserPush(ctx, ctx.user)
+ canPush = !changedProtectedfiles && protectBranch.CanUserPush(ctx, ctx.Doer)
}
}
@@ -296,7 +280,7 @@ func preReceiveBranch(ctx *preReceiveContext, oldCommitID, newCommitID string, r
// Now check if the user is allowed to merge PRs for this repository
// Note: we can use ctx.perm and ctx.user directly as they will have been loaded above
- allowedMerge, err := pull_service.IsUserAllowedToMerge(ctx, pr, ctx.userPerm, ctx.user)
+ allowedMerge, err := pull_service.IsUserAllowedToMerge(ctx, pr, ctx.Repo.Permission, ctx.Doer)
if err != nil {
ctx.PrivateInternalErrorf("Error calculating if allowed to merge: %v", err)
return
@@ -308,7 +292,7 @@ func preReceiveBranch(ctx *preReceiveContext, oldCommitID, newCommitID string, r
}
// If we can bypass branch protection we can ignore status checks, reviews and protected files
- if git_model.CanBypassBranchProtection(ctx, protectBranch, ctx.user, ctx.userPerm.IsAdmin()) {
+ if git_model.CanBypassBranchProtection(ctx, protectBranch, ctx.Doer, ctx.Repo.Permission.IsAdmin()) {
return
}
@@ -337,14 +321,14 @@ func preReceiveTag(ctx *preReceiveContext, refFullName git.RefName) {
tagName := refFullName.TagName()
- if !ctx.gotProtectedTags {
+ if ctx.protectedTags == nil {
var err error
ctx.protectedTags, err = git_model.GetProtectedTags(ctx, ctx.Repo.Repository.ID)
if err != nil {
ctx.PrivateInternalErrorf("Unable to get protected tags: %v", err)
return
}
- ctx.gotProtectedTags = true
+ ctx.protectedTags = util.SliceNilAsEmpty(ctx.protectedTags)
}
isAllowed, err := git_model.IsUserAllowedToControlTag(ctx, ctx.protectedTags, tagName, ctx.opts.UserID)
@@ -399,45 +383,3 @@ func generateGitEnv(opts *private.HookOptions) (env []string) {
}
return env
}
-
-// loadPusherAndPermission returns false if an error occurs, and it writes the error response
-func (ctx *preReceiveContext) loadPusherAndPermission() bool {
- if ctx.opts.UserID == user_model.ActionsUserID {
- taskID := ctx.opts.ActionsTaskID
- ctx.user = user_model.NewActionsUserWithTaskID(taskID)
- if taskID == 0 {
- ctx.PrivateUserErrorf(http.StatusInternalServerError, "ActionsUser with task ID 0")
- return false
- }
-
- userPerm, err := access_model.GetActionsUserRepoPermission(ctx, ctx.Repo.Repository, ctx.user, taskID)
- if err != nil {
- ctx.PrivateInternalErrorf("Unable to get Actions user repo permission for task %d Error: %v", taskID, err)
- return false
- }
- ctx.userPerm = userPerm
- } else {
- user, err := user_model.GetUserByID(ctx, ctx.opts.UserID)
- if err != nil {
- ctx.PrivateInternalErrorf("Unable to get User id %d Error: %v", ctx.opts.UserID, err)
- return false
- }
- ctx.user = user
- userPerm, err := access_model.GetDoerRepoPermission(ctx, ctx.Repo.Repository, user)
- if err != nil {
- ctx.PrivateInternalErrorf("Unable to get Repo permission of repo %s/%s of User %s: %v", ctx.Repo.Repository.OwnerName, ctx.Repo.Repository.Name, user.Name, err)
- return false
- }
- ctx.userPerm = userPerm
- }
-
- if ctx.opts.DeployKeyID != 0 {
- deployKey, err := asymkey_model.GetDeployKeyByID(ctx, ctx.Repo.Repository.ID, ctx.opts.DeployKeyID)
- if err != nil {
- ctx.PrivateInternalErrorf("Unable to get DeployKey id %d Error: %v", ctx.opts.DeployKeyID, err)
- return false
- }
- ctx.deployKeyAccessMode = deployKey.Mode
- }
- return true
-}
diff --git a/routers/private/hook_pre_receive_test.go b/routers/private/hook_pre_receive_test.go
index 3c1c21673f2..60d5513522f 100644
--- a/routers/private/hook_pre_receive_test.go
+++ b/routers/private/hook_pre_receive_test.go
@@ -7,7 +7,6 @@ import (
"testing"
issues_model "gitea.dev/models/issues"
- "gitea.dev/models/perm/access"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
"gitea.dev/modules/git"
@@ -19,7 +18,7 @@ import (
// TestPreReceiveCanWriteCodePerBranch ensures the maintainer-edit write grant is evaluated against
// the exact ref being pushed on every call, derived from that ref rather than shared mutable state.
-// Otherwise a per-branch grant (an open PR with "allow edits from maintainers") could be batched
+// Otherwise, a per-branch grant (an open PR with "allow edits from maintainers") could be batched
// together with a protected branch or a tag to escalate into full repository write.
func TestPreReceiveCanWriteCodePerBranch(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
@@ -45,16 +44,12 @@ func TestPreReceiveCanWriteCodePerBranch(t *testing.T) {
require.NoError(t, issues_model.NewPullRequest(t.Context(), baseRepo, pr.Issue, nil, nil, pr))
// The pusher is the base repo owner (the maintainer) with only read access on the head repo.
- maintainer := baseRepo.Owner
- headPerm, err := access.GetIndividualUserRepoPermission(t.Context(), headRepo, maintainer)
- require.NoError(t, err)
-
mockCtx, _ := contexttest.MockPrivateContext(t, "/")
- ctx := &preReceiveContext{
- PrivateContext: mockCtx,
- user: maintainer,
- userPerm: headPerm,
- }
+ ctx := &preReceiveContext{PrivateContext: mockCtx}
+ ctx.SetPathParam("owner", headRepo.OwnerName)
+ ctx.SetPathParam("repo", headRepo.Name)
+ RepoAssignment(ctx.PrivateContext)
+ loadContextDoerPermission(ctx.PrivateContext, baseRepo.OwnerID, "")
// The granted branch must be writable...
assert.True(t, ctx.canWriteCodeRef(git.RefNameFromBranch("granted-branch")))
diff --git a/routers/private/hook_proc_receive.go b/routers/private/hook_proc_receive.go
index 73542c6dd76..c56276f6a8e 100644
--- a/routers/private/hook_proc_receive.go
+++ b/routers/private/hook_proc_receive.go
@@ -23,8 +23,17 @@ func HookProcReceive(ctx *gitea_context.PrivateContext) {
ctx.Status(http.StatusNotFound)
return
}
+ if !loadContextDoerPermission(ctx, opts.UserID, opts.UserExtDoerData) {
+ return
+ }
- results, err := agit.ProcReceive(ctx, ctx.Repo.Repository, ctx.Repo.GitRepo, opts)
+ results, err := agit.ProcReceive(ctx, ctx.Repo.Repository, ctx.Repo.GitRepo, &agit.ProcReceiveOptions{
+ OldCommitIDs: opts.OldCommitIDs,
+ NewCommitIDs: opts.NewCommitIDs,
+ RefFullNames: opts.RefFullNames,
+ GitPushOptions: opts.GitPushOptions,
+ Doer: ctx.Doer,
+ })
if err != nil {
if errors.Is(err, issues_model.ErrMustCollaborator) {
ctx.PrivateUserErrorf(http.StatusUnauthorized, "You must be a collaborator to create pull request.")
diff --git a/routers/private/internal.go b/routers/private/internal.go
index 2519b4ee3cb..412c5017eb0 100644
--- a/routers/private/internal.go
+++ b/routers/private/internal.go
@@ -80,7 +80,7 @@ func Routes() *web.Router {
r.Post("/ssh/{id}/update/{repoid}", UpdatePublicKeyInRepo)
r.Post("/ssh/log", bind(private.SSHLogOption{}), SSHLog)
r.Post("/hook/pre-receive/{owner}/{repo}", RepoAssignment, bind(private.HookOptions{}), HookPreReceive)
- r.Post("/hook/post-receive/{owner}/{repo}", context.OverrideContext(), bind(private.HookOptions{}), HookPostReceive)
+ r.Post("/hook/post-receive/{owner}/{repo}", context.OverrideContext(), RepoAssignment, bind(private.HookOptions{}), HookPostReceive)
r.Post("/hook/proc-receive/{owner}/{repo}", context.OverrideContext(), RepoAssignment, bind(private.HookOptions{}), HookProcReceive)
r.Get("/serv/none/{keyid}", ServNoCommand)
r.Get("/serv/command/{keyid}/{owner}/{repo}", ServCommand)
diff --git a/routers/private/internal_repo.go b/routers/private/internal_repo.go
index c4563c1433e..98bcae51d1d 100644
--- a/routers/private/internal_repo.go
+++ b/routers/private/internal_repo.go
@@ -4,7 +4,9 @@
package private
import (
+ "gitea.dev/models/perm/access"
repo_model "gitea.dev/models/repo"
+ "gitea.dev/models/user"
"gitea.dev/modules/git"
gitea_context "gitea.dev/services/context"
)
@@ -27,10 +29,7 @@ func RepoAssignment(ctx *gitea_context.PrivateContext) {
ctx.PrivateInternalErrorf("Failed to open repository: %s/%s Error: %v", ownerName, repoName, err)
return
}
- ctx.Repo = &gitea_context.Repository{
- Repository: repo,
- GitRepo: gitRepo,
- }
+ ctx.Repo = &gitea_context.Repository{Repository: repo, GitRepo: gitRepo}
}
func loadRepository(ctx *gitea_context.PrivateContext, ownerName, repoName string) *repo_model.Repository {
@@ -44,3 +43,18 @@ func loadRepository(ctx *gitea_context.PrivateContext, ownerName, repoName strin
}
return repo
}
+
+func loadContextDoerPermission(ctx *gitea_context.PrivateContext, userID int64, extDoerData string) bool {
+ doer, err := user.GetDoerUser(ctx, userID, extDoerData)
+ if err != nil {
+ ctx.PrivateInternalErrorf("Failed to get user: %d, error: %v", userID, err)
+ return false
+ }
+ ctx.Doer = doer
+ ctx.Repo.Permission, err = access.GetDoerRepoPermission(ctx, ctx.Repo.Repository, doer)
+ if err != nil {
+ ctx.PrivateInternalErrorf("Failed to get permission for user: %d, error: %v", userID, err)
+ return false
+ }
+ return true
+}
diff --git a/routers/private/key.go b/routers/private/key.go
index 2a89e2790a5..46bb311bb74 100644
--- a/routers/private/key.go
+++ b/routers/private/key.go
@@ -7,7 +7,7 @@ import (
"net/http"
asymkey_model "gitea.dev/models/asymkey"
- "gitea.dev/modules/timeutil"
+ deploykey_model "gitea.dev/models/deploykey"
"gitea.dev/services/context"
)
@@ -20,17 +20,16 @@ func UpdatePublicKeyInRepo(ctx *context.PrivateContext) {
return
}
- deployKey, err := asymkey_model.GetDeployKeyByRepoPublicKey(ctx, repoID, keyID)
+ deployKey, err := deploykey_model.GetDeployKeyByRepoPublicKey(ctx, repoID, keyID)
if err != nil {
- if asymkey_model.IsErrDeployKeyNotExist(err) {
+ if deploykey_model.IsErrDeployKeyNotExist(err) {
ctx.PlainText(http.StatusOK, "success")
return
}
ctx.PrivateInternalErrorf("%v", err)
return
}
- deployKey.UpdatedUnix = timeutil.TimeStampNow()
- if err = asymkey_model.UpdateDeployKeyCols(ctx, deployKey, "updated_unix"); err != nil {
+ if err = deploykey_model.UpdateDeployKeyLastUsed(ctx, deployKey.ID); err != nil {
ctx.PrivateInternalErrorf("%v", err)
return
}
diff --git a/routers/private/serv.go b/routers/private/serv.go
index dc1c7c3dd69..f900361819a 100644
--- a/routers/private/serv.go
+++ b/routers/private/serv.go
@@ -8,6 +8,7 @@ import (
"strings"
asymkey_model "gitea.dev/models/asymkey"
+ deploykey_model "gitea.dev/models/deploykey"
"gitea.dev/models/perm"
access_model "gitea.dev/models/perm/access"
repo_model "gitea.dev/models/repo"
@@ -73,9 +74,9 @@ func ServCommand(ctx *context.PrivateContext) {
// Set the basic parts of the results to return
results := private.ServCommandResults{
- OwnerName: reqOwnerName, // it might be changed if there is "renamed user redirection"
- RepoName: reqRepoName, // it might be changed if there is "renamed repo redirection", or the repo is a wiki
- KeyID: keyID,
+ OwnerName: reqOwnerName, // it might be changed if there is "renamed user redirection"
+ RepoName: reqRepoName, // it might be changed if there is "renamed repo redirection", or the repo is a wiki
+ PublicKeyID: keyID,
}
repoLogName := reqOwnerName + "/" + reqRepoName
@@ -184,40 +185,25 @@ func ServCommand(ctx *context.PrivateContext) {
ctx.PrivateInternalErrorf("Unable to get key: %d, error: %v", keyID, err)
return
}
- results.KeyName = key.Name
- results.KeyID = key.ID
- results.UserID = key.OwnerID
+ results.PublicKeyID = key.ID
- // Deploy Keys have ownerID set to 0 therefore we can't use the owner
- // So now we need to check if the key is a deploy key
- // We'll keep hold of the deploy key here for permissions checking
- var deployKey *asymkey_model.DeployKey
+ var deployKey *deploykey_model.DeployKey
var user *user_model.User
if key.Type == asymkey_model.KeyTypeDeploy {
if repo == nil {
ctx.PrivateUserErrorf(http.StatusNotFound, "Cannot find repository %s", repoLogName)
return
}
- deployKey, err = asymkey_model.GetDeployKeyByRepoPublicKey(ctx, repo.ID, key.ID)
+ deployKey, err = deploykey_model.GetDeployKeyByRepoPublicKey(ctx, repo.ID, key.ID)
if err != nil {
- if asymkey_model.IsErrDeployKeyNotExist(err) {
- ctx.PrivateUserErrorf(http.StatusNotFound, "Deploy key %d:%s has no %q permission for %s.", key.ID, key.Name, modeString, repoLogName)
+ if deploykey_model.IsErrDeployKeyNotExist(err) {
+ ctx.PrivateUserErrorf(http.StatusNotFound, "Deploy-key %d:%s has no %q permission for %s.", key.ID, key.Name, modeString, repoLogName)
return
}
ctx.PrivateInternalErrorf("Unable to get deploy for public (deploy) key %d for %s, error: %v", key.ID, repoLogName, err)
return
}
- results.DeployKeyID = deployKey.ID
- results.KeyName = deployKey.Name
-
- // FIXME: Deploy keys aren't really the owner of the repo pushing changes
- // however we don't have good way of representing deploy keys in hook.go
- // so for now use the owner of the repository
- results.UserName = results.OwnerName
- results.UserID = repo.OwnerID
- if !repo.Owner.KeepEmailPrivate {
- results.UserEmail = repo.Owner.Email
- }
+ user = user_model.NewDeployKeyUserWithKeyID(deployKey.ID)
} else {
// Get the user represented by the Key
user, err = user_model.GetUserByID(ctx, key.OwnerID)
@@ -229,16 +215,19 @@ func ServCommand(ctx *context.PrivateContext) {
ctx.PrivateInternalErrorf("Unable to get key owner %d for public key %d:%s, error: %v", key.OwnerID, key.ID, key.Name, err)
return
}
-
if !user.IsActive || user.ProhibitLogin {
ctx.PrivateUserErrorf(http.StatusForbidden, "Your account is disabled.")
return
}
+ }
- results.UserName = user.Name
- if !user.KeepEmailPrivate {
- results.UserEmail = user.Email
- }
+ results.UserID = user.ID
+ results.UserName = user.Name
+ if !user.KeepEmailPrivate {
+ results.UserEmail = user.Email
+ }
+ if user.ExtDoerData != nil {
+ results.UserExtDoerData = user.ExtDoerData.EncodeToString()
}
// Don't allow pushing if the repo is archived
@@ -252,37 +241,29 @@ func ServCommand(ctx *context.PrivateContext) {
(mode > perm.AccessModeRead ||
repo.IsPrivate ||
owner.Visibility.IsPrivate() ||
- (user != nil && user.IsRestricted) || // user will be nil if the key is a deploy key
+ user.IsRestricted ||
setting.Service.RequireSignInViewStrict) {
- if key.Type == asymkey_model.KeyTypeDeploy {
- if deployKey == nil || deployKey.Mode < mode {
- ctx.PrivateUserErrorf(http.StatusUnauthorized, "Deploy key %d:%s has no %q permission for %s.", key.ID, key.Name, modeString, repoLogName)
- return
- }
- } else {
- // Because of the special ref "refs/for" (AGit) we will need to delay write permission check,
- // AGit flow needs to write its own ref when the doer has "reader" permission (allowing to create PR).
- // The real permission check is done in HookPreReceive (routers/private/hook_pre_receive.go).
- // Here it should relax the permission check for "git push (git-receive-pack)", but not for others like LFS operations.
- if git.DefaultFeatures().SupportProcReceive && unitType == unit.TypeCode && verb == git.CmdVerbReceivePack {
- mode = perm.AccessModeRead
- }
+ // Because of the special ref "refs/for" (AGit) we will need to delay write permission check,
+ // AGit flow needs to write its own ref when the doer has "reader" permission (allowing to create PR).
+ // The real permission check is done in HookPreReceive (routers/private/hook_pre_receive.go).
+ // Here it should relax the permission check for "git push (git-receive-pack)", but not for others like LFS operations.
+ if git.DefaultFeatures().SupportProcReceive && unitType == unit.TypeCode && verb == git.CmdVerbReceivePack {
+ mode = perm.AccessModeRead
+ }
- userPerm, err := access_model.GetDoerRepoPermission(ctx, repo, user)
- if err != nil {
- ctx.PrivateInternalErrorf("Unable to get permissions for %-v with key %d in %-v, error: %v", user, key.ID, repo, err)
- return
- }
+ userPerm, err := access_model.GetDoerRepoPermission(ctx, repo, user)
+ if err != nil {
+ ctx.PrivateInternalErrorf("Unable to get permissions for %-v with key %d in %-v, error: %v", user, key.ID, repo, err)
+ return
+ }
- userMode := userPerm.UnitAccessMode(unitType)
- if userMode < mode {
- ctx.PrivateUserErrorf(http.StatusUnauthorized, "User %d with key %d:%s has no %q permission for %s", key.OwnerID, key.ID, key.Name, modeString, repoLogName)
- return
- }
+ userMode := userPerm.UnitAccessMode(unitType)
+ if userMode < mode {
+ ctx.PrivateUserErrorf(http.StatusUnauthorized, "User key %d:%s has no %q permission for %s", key.ID, key.Name, modeString, repoLogName)
+ return
}
}
- // We already know we aren't using a deploy key
if repo == nil {
if owner.IsOrganization() && !setting.Repository.EnablePushCreateOrg {
ctx.PrivateUserErrorf(http.StatusForbidden, "Push to create is not enabled for organizations.")
diff --git a/routers/web/repo/actions/actions_test.go b/routers/web/repo/actions/actions_test.go
index 86b4fdb6e4d..5fcf59ada50 100644
--- a/routers/web/repo/actions/actions_test.go
+++ b/routers/web/repo/actions/actions_test.go
@@ -212,7 +212,7 @@ func newWorkflowBadgeTestContext(t *testing.T) *web_context.Context {
req := httptest.NewRequest(http.MethodGet, "https://gitea.example.com/user1/repo1/actions", nil)
resp := httptest.NewRecorder()
- ctx := web_context.NewWebContext(web_context.NewBaseContextForTest(resp, req), nil, nil)
+ ctx := web_context.NewWebContext(web_context.NewBaseContextForTest(t, resp, req), nil, nil)
ctx.Repo.Repository = &repo_model.Repository{
OwnerName: "user1",
Name: "repo1",
diff --git a/routers/web/repo/githttp.go b/routers/web/repo/githttp.go
index f45155568e4..ee0ed77a799 100644
--- a/routers/web/repo/githttp.go
+++ b/routers/web/repo/githttp.go
@@ -163,7 +163,7 @@ func httpBase(ctx *context.Context, optGitService ...string) *serviceHandler {
return nil
}
- if ctx.IsBasicAuth && ctx.Data["ApiTokenScope"] == nil && !ctx.Doer.IsGiteaActions() {
+ if ctx.IsBasicAuth && ctx.Data["ApiTokenScope"] == nil && ctx.Doer.IsIndividual() {
_, err = auth_model.GetTwoFactorByUID(ctx, ctx.Doer.ID)
if err == nil {
// TODO: This response should be changed to "invalid credentials" for security reasons once the expectation behind it (creating an app token to authenticate) is properly documented
@@ -252,7 +252,6 @@ func httpBase(ctx *context.Context, optGitService ...string) *serviceHandler {
var environ []string
if !isPull {
- // if not "pull", then must be "push", and doer must exist
environ = repo_module.DoerPushingEnvironment(ctx.Doer, repo, isWiki)
}
diff --git a/routers/web/repo/setting/deploy_key.go b/routers/web/repo/setting/deploy_key.go
index 3620b01a2dc..2ba5307be7e 100644
--- a/routers/web/repo/setting/deploy_key.go
+++ b/routers/web/repo/setting/deploy_key.go
@@ -9,7 +9,9 @@ import (
asymkey_model "gitea.dev/models/asymkey"
"gitea.dev/models/db"
+ deploykey_model "gitea.dev/models/deploykey"
"gitea.dev/models/perm"
+ "gitea.dev/modules/htmlutil"
"gitea.dev/modules/setting"
"gitea.dev/modules/util"
asymkey_service "gitea.dev/services/asymkey"
@@ -17,23 +19,20 @@ import (
"gitea.dev/services/forms"
)
-// DeployKeys render the deploy-keys list of a repository page
func DeployKeys(ctx *context.Context) {
- ctx.Data["Title"] = ctx.Tr("repo.settings.deploy_keys") + " / " + ctx.Tr("secrets.secrets")
+ ctx.Data["Title"] = ctx.Tr("repo.settings.deploy_keys")
ctx.Data["PageIsSettingsKeys"] = true
ctx.Data["DisableSSH"] = setting.SSH.Disabled
- keys, err := db.Find[asymkey_model.DeployKey](ctx, asymkey_model.ListDeployKeysOptions{RepoID: ctx.Repo.Repository.ID})
+ keys, err := db.Find[deploykey_model.DeployKey](ctx, deploykey_model.ListDeployKeysOptions{RepoID: ctx.Repo.Repository.ID})
if err != nil {
ctx.ServerError("ListDeployKeys", err)
return
}
ctx.Data["RepoDeployKeys"] = keys
-
ctx.HTML(http.StatusOK, tplDeployKeys)
}
-// DeployKeysPost response for adding a deploy-key of a repository
func DeployKeysPost(ctx *context.Context) {
form := context.GetFetchActionForm[*forms.AddKeyForm](ctx)
if form == nil {
@@ -54,16 +53,14 @@ func DeployKeysPost(ctx *context.Context) {
}
accessMode := util.Iif(form.IsWritable, perm.AccessModeWrite, perm.AccessModeRead)
- key, err := asymkey_model.AddDeployKey(ctx, ctx.Repo.Repository.ID, form.Title, content, accessMode)
+ key, err := deploykey_model.AddDeployKeySSH(ctx, ctx.Repo.Repository.ID, form.Title, content, accessMode)
if err != nil {
switch {
- case asymkey_model.IsErrDeployKeyAlreadyExist(err):
+ case deploykey_model.IsErrDeployKeyAlreadyExist(err):
ctx.JSONErrorWithField(ctx.Tr("repo.settings.key_been_used"), "content")
case asymkey_model.IsErrKeyAlreadyExist(err):
ctx.JSONErrorWithField(ctx.Tr("settings.ssh_key_been_used"), "content")
- case asymkey_model.IsErrKeyNameAlreadyUsed(err):
- ctx.JSONErrorWithField(ctx.Tr("repo.settings.key_name_used"), "title")
- case asymkey_model.IsErrDeployKeyNameAlreadyUsed(err):
+ case asymkey_model.IsErrKeyNameAlreadyUsed(err), deploykey_model.IsErrDeployKeyNameAlreadyUsed(err):
ctx.JSONErrorWithField(ctx.Tr("repo.settings.key_name_used"), "title")
default:
ctx.ServerError("AddDeployKey", err)
@@ -75,12 +72,50 @@ func DeployKeysPost(ctx *context.Context) {
ctx.JSONRedirect(ctx.Repo.RepoLink + "/settings/keys")
}
-// DeleteDeployKey response for deleting a deploy-key
func DeleteDeployKey(ctx *context.Context) {
- if err := asymkey_service.DeleteDeployKey(ctx, ctx.Repo.Repository, ctx.FormInt64("id")); err != nil {
+ key, err := asymkey_service.DeleteDeployKey(ctx, ctx.Repo.Repository, ctx.FormInt64("id"))
+ if err != nil && !deploykey_model.IsErrDeployKeyNotExist(err) { // a key that is already gone leaves the caller with the state it asked for
ctx.ServerError("DeleteDeployKey", err)
- } else {
- ctx.Flash.Success(ctx.Tr("repo.settings.deploy_key_deletion_success"))
+ return
+ }
+ if key != nil {
+ ctx.Flash.Success(ctx.Tr("repo.settings.deploy_key_deletion_success", key.Name))
}
ctx.JSONRedirect(ctx.Repo.RepoLink + "/settings/keys")
}
+
+func DeployKeyGenerateToken(ctx *context.Context) {
+ form := context.GetFetchActionForm[*forms.AddDeployTokenForm](ctx)
+ if form == nil {
+ return
+ }
+
+ accessMode := util.Iif(form.IsWritable, perm.AccessModeWrite, perm.AccessModeRead)
+ key, err := deploykey_model.AddDeployKeyToken(ctx, ctx.Repo.Repository.ID, form.Title, accessMode)
+ if err != nil {
+ if deploykey_model.IsErrDeployKeyNameAlreadyUsed(err) {
+ ctx.JSONErrorWithField(ctx.Tr("repo.settings.key_name_used"), "title")
+ } else {
+ ctx.ServerError("AddDeployToken", err)
+ }
+ return
+ }
+
+ ctx.Flash.Success(ctx.Tr("repo.settings.generate_deploy_token_success", htmlutil.HTMLFormat("%s", key.Token)))
+ ctx.JSONRedirect(ctx.Repo.RepoLink + "/settings/keys")
+}
+
+func DeployKeyRegenerateToken(ctx *context.Context) {
+ key, err := deploykey_model.RegenerateDeployKeyToken(ctx, ctx.Repo.Repository.ID, ctx.FormInt64("id"))
+ if err != nil {
+ if deploykey_model.IsErrDeployKeyNotExist(err) {
+ ctx.JSONErrorNotFound()
+ } else {
+ ctx.ServerError("RegenerateDeployToken", err)
+ }
+ return
+ }
+
+ ctx.Flash.Success(ctx.Tr("repo.settings.regenerate_deploy_token_success", htmlutil.HTMLFormat("%s", key.Token)))
+ ctx.JSONRedirect(ctx.Repo.RepoLink + "/settings/keys")
+}
diff --git a/routers/web/repo/setting/settings_test.go b/routers/web/repo/setting/settings_test.go
index b88940c83be..f8cb64e4f55 100644
--- a/routers/web/repo/setting/settings_test.go
+++ b/routers/web/repo/setting/settings_test.go
@@ -8,7 +8,7 @@ import (
"net/url"
"testing"
- asymkey_model "gitea.dev/models/asymkey"
+ deploykey_model "gitea.dev/models/deploykey"
"gitea.dev/models/organization"
"gitea.dev/models/perm"
access_model "gitea.dev/models/perm/access"
@@ -38,7 +38,7 @@ func TestAddDeployKey(t *testing.T) {
contexttest.LoadRepo(t, ctx, 2)
DeployKeysPost(ctx)
assert.Equal(t, http.StatusOK, ctx.Resp.WrittenStatus())
- unittest.AssertExistsAndLoadBean(t, &asymkey_model.DeployKey{Name: "read-only", Mode: perm.AccessModeRead})
+ unittest.AssertExistsAndLoadBean(t, &deploykey_model.DeployKey{Name: "read-only", Mode: perm.AccessModeRead})
})
t.Run("ReadWrite", func(t *testing.T) {
const testKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEHjnNEfE88W1pvBLdV3otv28x760gdmPao3lVD5uAt9\n"
@@ -47,7 +47,7 @@ func TestAddDeployKey(t *testing.T) {
contexttest.LoadRepo(t, ctx, 2)
DeployKeysPost(ctx)
assert.Equal(t, http.StatusOK, ctx.Resp.WrittenStatus())
- unittest.AssertExistsAndLoadBean(t, &asymkey_model.DeployKey{Name: "read-write", Mode: perm.AccessModeWrite})
+ unittest.AssertExistsAndLoadBean(t, &deploykey_model.DeployKey{Name: "read-write", Mode: perm.AccessModeWrite})
})
}
diff --git a/routers/web/web.go b/routers/web/web.go
index ed1593cf8fd..861df9d866a 100644
--- a/routers/web/web.go
+++ b/routers/web/web.go
@@ -94,12 +94,14 @@ func optionsCorsHandler() func(next http.Handler) http.Handler {
type AuthMiddleware struct {
AllowOAuth2 types.PreMiddlewareProvider
AllowBasic types.PreMiddlewareProvider
+ AllowDeployToken types.PreMiddlewareProvider
MiddlewareHandler func(*context.Context)
}
func newWebAuthMiddleware() *AuthMiddleware {
type keyAllowOAuth2 struct{}
type keyAllowBasic struct{}
+ type keyAllowDeployToken struct{}
webAuth := &AuthMiddleware{}
middlewareSetContextValue := func(key, val any) types.PreMiddlewareProvider {
@@ -114,11 +116,13 @@ func newWebAuthMiddleware() *AuthMiddleware {
webAuth.AllowBasic = middlewareSetContextValue(keyAllowBasic{}, true)
webAuth.AllowOAuth2 = middlewareSetContextValue(keyAllowOAuth2{}, true)
+ webAuth.AllowDeployToken = middlewareSetContextValue(keyAllowDeployToken{}, true)
enableSSPI := setting.IsWindows && auth_model.IsSSPIEnabled(graceful.GetManager().ShutdownContext())
webAuth.MiddlewareHandler = func(ctx *context.Context) {
allowBasic := ctx.GetContextValue(keyAllowBasic{}) == true
allowOAuth2 := ctx.GetContextValue(keyAllowOAuth2{}) == true
+ allowDeployToken := ctx.GetContextValue(keyAllowDeployToken{}) == true
group := auth_service.NewGroup()
@@ -127,13 +131,16 @@ func newWebAuthMiddleware() *AuthMiddleware {
if allowOAuth2 {
group.Add(&auth_service.OAuth2{})
}
+ if allowDeployToken {
+ group.Add(&auth_service.DeployToken{}) // before Basic, which would try the token as a password
+ }
if allowBasic {
group.Add(&auth_service.Basic{})
}
// Sessionless means the route's auth can be done without web ui, then it doesn't need to create a session
// For example: accessing git via http, access rss feeds, downloading attachments, etc
- isSessionless := allowOAuth2 || allowBasic
+ isSessionless := allowOAuth2 || allowBasic || allowDeployToken
if setting.Service.EnableReverseProxyAuth {
// reverse-proxy should before Session, otherwise the header will be ignored if user has login
@@ -1223,6 +1230,8 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
m.Group("/keys", func() {
m.Combo("").Get(repo_setting.DeployKeys).
Post(repo_setting.DeployKeysPost)
+ m.Post("/generate-token", repo_setting.DeployKeyGenerateToken)
+ m.Post("/regenerate-token", repo_setting.DeployKeyRegenerateToken)
m.Post("/delete", repo_setting.DeleteDeployKey)
})
@@ -1743,12 +1752,12 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
// git lfs uses its own jwt key, and it handles the token & auth by itself, it conflicts with the general "OAuth2" auth method
// pattern: "/{username}/{reponame}/{lfs-paths}": git-lfs support, see also addOwnerRepoGitHTTPRouters
- common.AddOwnerRepoGitLFSRoutes(m, lfsServerEnabled, webAuth.AllowBasic, repo.CorsHandler(), optSignInFromAnyOrigin)
+ common.AddOwnerRepoGitLFSRoutes(m, lfsServerEnabled, webAuth.AllowBasic, webAuth.AllowDeployToken, repo.CorsHandler(), optSignInFromAnyOrigin)
// Some users want to use "web-based git client" to access Gitea's repositories,
// so the CORS handler and OPTIONS method are used.
// pattern: "/{username}/{reponame}/{git-paths}": git http support
- addOwnerRepoGitHTTPRouters(m, repo.HTTPGitEnabledHandler, webAuth.AllowBasic, webAuth.AllowOAuth2, repo.CorsHandler(), optSignInFromAnyOrigin, context.UserAssignmentWeb())
+ addOwnerRepoGitHTTPRouters(m, repo.HTTPGitEnabledHandler, webAuth.AllowBasic, webAuth.AllowOAuth2, webAuth.AllowDeployToken, repo.CorsHandler(), optSignInFromAnyOrigin, context.UserAssignmentWeb())
m.Group("/notifications", func() {
m.Get("", user.Notifications)
diff --git a/services/actions/notifier_helper.go b/services/actions/notifier_helper.go
index dc91e8bdba2..980b50fc88d 100644
--- a/services/actions/notifier_helper.go
+++ b/services/actions/notifier_helper.go
@@ -121,7 +121,7 @@ func (input *notifyInput) Notify(ctx context.Context) {
func notify(ctx context.Context, input *notifyInput) error {
shouldDetectSchedules := input.Event == webhook_module.HookEventPush && input.Ref.BranchName() == input.Repo.DefaultBranch
- if input.Doer.IsGiteaActions() {
+ if input.Doer.ID == user_model.ActionsUserID {
// avoiding triggering cyclically, for example:
// a comment of an issue will trigger the runner to add a new comment as reply,
// and the new comment will trigger the runner again.
diff --git a/services/agit/agit.go b/services/agit/agit.go
index 8b2bb575fd9..2ed2e3642c9 100644
--- a/services/agit/agit.go
+++ b/services/agit/agit.go
@@ -60,8 +60,18 @@ func GetAgitBranchInfo(ctx context.Context, repoID int64, baseBranchName string)
return "", "", util.NewNotExistErrorf("base branch does not exist")
}
+type ProcReceiveOptions struct {
+ OldCommitIDs []string
+ NewCommitIDs []string
+ RefFullNames []git.RefName
+
+ GitPushOptions private.GitPushOptions
+
+ Doer *user_model.User
+}
+
// ProcReceive handle proc receive work
-func ProcReceive(ctx context.Context, repo *repo_model.Repository, gitRepo *git.Repository, opts *private.HookOptions) ([]private.HookProcReceiveRefResult, error) {
+func ProcReceive(ctx context.Context, repo *repo_model.Repository, gitRepo *git.Repository, opts *ProcReceiveOptions) ([]private.HookProcReceiveRefResult, error) {
results := make([]private.HookProcReceiveRefResult, 0, len(opts.OldCommitIDs))
forcePush := opts.GitPushOptions.Bool(private.GitPushOptionForcePush)
topicBranch := opts.GitPushOptions["topic"]
@@ -72,12 +82,9 @@ func ProcReceive(ctx context.Context, repo *repo_model.Repository, gitRepo *git.
description := parseAgitPushOptionValue(opts.GitPushOptions["description"])
objectFormat := git.ObjectFormatFromName(repo.ObjectFormatName)
- userName := strings.ToLower(opts.UserName)
- pusher, err := user_model.GetUserByID(ctx, opts.UserID)
- if err != nil {
- return nil, fmt.Errorf("failed to get user. Error: %w", err)
- }
+ pusher := opts.Doer
+ userName := strings.ToLower(pusher.Name)
for i := range opts.OldCommitIDs {
if opts.NewCommitIDs[i] == objectFormat.EmptyObjectID().String() {
diff --git a/services/asymkey/deploy_key.go b/services/asymkey/deploy_key.go
index 66c22407a14..0a97bd2de2e 100644
--- a/services/asymkey/deploy_key.go
+++ b/services/asymkey/deploy_key.go
@@ -9,12 +9,13 @@ import (
asymkey_model "gitea.dev/models/asymkey"
"gitea.dev/models/db"
+ deploykey_model "gitea.dev/models/deploykey"
repo_model "gitea.dev/models/repo"
)
// DeleteRepoDeployKeys deletes all deploy keys of a repository. permissions check should be done outside
func DeleteRepoDeployKeys(ctx context.Context, repoID int64) (int, error) {
- deployKeys, err := db.Find[asymkey_model.DeployKey](ctx, asymkey_model.ListDeployKeysOptions{RepoID: repoID})
+ deployKeys, err := db.Find[deploykey_model.DeployKey](ctx, deploykey_model.ListDeployKeysOptions{RepoID: repoID})
if err != nil {
return 0, fmt.Errorf("listDeployKeys: %w", err)
}
@@ -28,13 +29,17 @@ func DeleteRepoDeployKeys(ctx context.Context, repoID int64) (int, error) {
}
// deleteDeployKeyFromDB delete deploy keys from database
-func deleteDeployKeyFromDB(ctx context.Context, key *asymkey_model.DeployKey) error {
- if _, err := db.DeleteByID[asymkey_model.DeployKey](ctx, key.ID); err != nil {
+func deleteDeployKeyFromDB(ctx context.Context, key *deploykey_model.DeployKey) error {
+ if _, err := db.DeleteByID[deploykey_model.DeployKey](ctx, key.ID); err != nil {
return fmt.Errorf("delete deploy key [%d]: %w", key.ID, err)
}
+ if key.KeyType == deploykey_model.KeyTypeToken { // a token has no public key to clean up
+ return nil
+ }
+
// Check if this is the last reference to same key content.
- has, err := asymkey_model.IsDeployKeyExistByPublicKeyID(ctx, key.KeyID)
+ has, err := deploykey_model.IsDeployKeyExistByPublicKeyID(ctx, key.KeyID)
if err != nil {
return err
} else if !has {
@@ -46,21 +51,22 @@ func deleteDeployKeyFromDB(ctx context.Context, key *asymkey_model.DeployKey) er
return nil
}
-// DeleteDeployKey deletes deploy key from its repository authorized_keys file if needed.
-// Permissions check should be done outside.
-func DeleteDeployKey(ctx context.Context, repo *repo_model.Repository, id int64) error {
- if err := db.WithTx(ctx, func(ctx context.Context) error {
- key, err := asymkey_model.GetDeployKeyByID(ctx, repo.ID, id)
+// DeleteDeployKey deletes deploy key from its repository authorized_keys file if needed,
+// and returns the key it deleted. Permissions check should be done outside.
+func DeleteDeployKey(ctx context.Context, repo *repo_model.Repository, id int64) (*deploykey_model.DeployKey, error) {
+ deleted, err := db.WithTx2(ctx, func(ctx context.Context) (*deploykey_model.DeployKey, error) {
+ key, err := deploykey_model.GetDeployKeyByID(ctx, repo.ID, id)
if err != nil {
- if asymkey_model.IsErrDeployKeyNotExist(err) {
- return nil
- }
- return fmt.Errorf("GetDeployKeyByID: %w", err)
+ return nil, err
}
- return deleteDeployKeyFromDB(ctx, key)
- }); err != nil {
- return err
+ return key, deleteDeployKeyFromDB(ctx, key)
+ })
+ if err != nil {
+ return nil, err
+ }
+ if deleted.KeyType == deploykey_model.KeyTypeToken {
+ return deleted, nil // a token never appears in the authorized_keys file
}
- return RewriteAllPublicKeys(ctx)
+ return deleted, RewriteAllPublicKeys(ctx)
}
diff --git a/services/auth/basic.go b/services/auth/basic.go
index e0825f88753..d562addfc60 100644
--- a/services/auth/basic.go
+++ b/services/auth/basic.go
@@ -29,6 +29,7 @@ const (
AccessTokenMethodName = "access_token"
OAuth2TokenMethodName = "oauth2_token"
ActionTokenMethodName = "action_token"
+ DeployTokenMethodName = "deploy_token"
)
// Basic implements the Auth interface and authenticates requests (API requests
@@ -41,7 +42,7 @@ func (b *Basic) Name() string {
return BasicMethodName
}
-func (b *Basic) parseAuthBasic(req *http.Request) (ret struct{ authToken, uname, passwd string }) {
+func parseAuthBasic(req *http.Request) (ret struct{ authToken, uname, passwd string }) {
authHeader := req.Header.Get("Authorization")
if authHeader == "" {
return ret
@@ -53,7 +54,7 @@ func (b *Basic) parseAuthBasic(req *http.Request) (ret struct{ authToken, uname,
uname, passwd := parsed.BasicAuth.Username, parsed.BasicAuth.Password
// Check if username or password is a token
- isUsernameToken := len(passwd) == 0 || passwd == "x-oauth-basic"
+ isUsernameToken := passwd == "" || passwd == "x-oauth-basic"
// Assume username is token
authToken := uname
if !isUsernameToken {
@@ -122,7 +123,7 @@ func (b *Basic) VerifyAuthToken(req *http.Request, w http.ResponseWriter, store
// name/token on successful validation.
// Returns nil if header is empty or validation fails.
func (b *Basic) Verify(req *http.Request, w http.ResponseWriter, store DataStore, sess SessionStore) (*user_model.User, error) {
- parseBasicRet := b.parseAuthBasic(req)
+ parseBasicRet := parseAuthBasic(req)
authToken, uname, passwd := parseBasicRet.authToken, parseBasicRet.uname, parseBasicRet.passwd
if authToken == "" && uname == "" {
return nil, nil //nolint:nilnil // the auth method is not applicable
diff --git a/services/auth/deploy_token.go b/services/auth/deploy_token.go
new file mode 100644
index 00000000000..bbe76e557ab
--- /dev/null
+++ b/services/auth/deploy_token.go
@@ -0,0 +1,47 @@
+// Copyright 2026 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package auth
+
+import (
+ "net/http"
+
+ deploykey_model "gitea.dev/models/deploykey"
+ user_model "gitea.dev/models/user"
+ "gitea.dev/modules/log"
+)
+
+var _ Method = &DeployToken{}
+
+// DeployToken authenticates a deploy key token given as HTTP basic auth credential.
+// Only add it to an auth group where a repo scoped credential makes sense.
+type DeployToken struct{}
+
+func (d *DeployToken) Name() string {
+ return DeployTokenMethodName
+}
+
+// Verify returns a user that stands for the deploy key alone. Its permissions come from the key,
+// see access_model.getDeployKeyRepoPermission, so the request can never reach another repository
+// or exceed the access mode of the key.
+func (d *DeployToken) Verify(req *http.Request, _ http.ResponseWriter, store DataStore, _ SessionStore) (*user_model.User, error) {
+ authToken := parseAuthBasic(req).authToken
+ if authToken == "" {
+ return nil, nil //nolint:nilnil // the auth method is not applicable
+ }
+
+ key, err := deploykey_model.VerifyDeployKeyToken(req.Context(), authToken)
+ if err != nil {
+ if deploykey_model.IsErrDeployKeyNotExist(err) {
+ return nil, nil //nolint:nilnil // not a deploy token, let the other methods try
+ }
+ return nil, err
+ }
+
+ if err := deploykey_model.UpdateDeployKeyLastUsed(req.Context(), key.ID); err != nil {
+ log.Error("UpdateDeployKeyUpdated: %v", err)
+ }
+
+ store.GetData()["LoginMethod"] = DeployTokenMethodName
+ return user_model.NewDeployKeyUserWithKeyID(key.ID), nil
+}
diff --git a/services/context/base.go b/services/context/base.go
index 7fc1100b0c6..bb0798b2423 100644
--- a/services/context/base.go
+++ b/services/context/base.go
@@ -225,11 +225,11 @@ func NewBaseContext(resp http.ResponseWriter, req *http.Request) *Base {
return b
}
-func NewBaseContextForTest(resp http.ResponseWriter, req *http.Request) *Base {
+func NewBaseContextForTest(t reqctx.TestingT, resp http.ResponseWriter, req *http.Request) *Base {
if !setting.IsInTesting {
panic("This function is only for testing")
}
- ctx := reqctx.NewRequestContextForTest(req.Context())
+ ctx := reqctx.NewRequestContextForTest(t)
*req = *req.WithContext(ctx)
return NewBaseContext(resp, req)
}
diff --git a/services/context/base_test.go b/services/context/base_test.go
index e842d75e9ac..03192e423f7 100644
--- a/services/context/base_test.go
+++ b/services/context/base_test.go
@@ -29,7 +29,7 @@ func TestRedirect(t *testing.T) {
}
for _, c := range cases {
resp := httptest.NewRecorder()
- b := NewBaseContextForTest(resp, req)
+ b := NewBaseContextForTest(t, resp, req)
resp.Header().Add("Set-Cookie", (&http.Cookie{Name: setting.SessionConfig.CookieName, Value: "dummy"}).String())
b.Redirect(c.url)
has := resp.Header().Get("Set-Cookie") == "i_like_gitea=dummy"
@@ -39,7 +39,7 @@ func TestRedirect(t *testing.T) {
req, _ = http.NewRequest(http.MethodGet, "/", nil)
resp := httptest.NewRecorder()
req.Header.Add("X-Gitea-Fetch-Action", "1")
- b := NewBaseContextForTest(resp, req)
+ b := NewBaseContextForTest(t, resp, req)
b.Redirect("/other")
assert.Contains(t, resp.Header().Get("Content-Type"), "application/json")
assert.JSONEq(t, `{"redirect":"/other"}`, resp.Body.String())
diff --git a/services/context/context_test.go b/services/context/context_test.go
index ddda6e5d4fb..f6e34619761 100644
--- a/services/context/context_test.go
+++ b/services/context/context_test.go
@@ -42,7 +42,7 @@ func TestRedirectToCurrentSite(t *testing.T) {
t.Run(c.location, func(t *testing.T) {
req := &http.Request{URL: &url.URL{Path: "/"}}
resp := httptest.NewRecorder()
- base := NewBaseContextForTest(resp, req)
+ base := NewBaseContextForTest(t, resp, req)
ctx := NewWebContext(base, nil, nil)
ctx.RedirectToCurrentSite(c.location)
redirect := test.RedirectURL(resp)
@@ -58,7 +58,7 @@ func TestAppFullLink(t *testing.T) {
defer test.MockVariableValue(&setting.PublicURLDetection, setting.PublicURLNever)()
req := httptest.NewRequest(http.MethodGet, "https://gitea.example.com/sub/", nil)
- tmplCtx := NewTemplateContext(reqctx.NewRequestContextForTest(req.Context()), req)
+ tmplCtx := NewTemplateContext(reqctx.NewRequestContextForTest(t), req)
assert.Equal(t, "https://gitea.example.com/sub", string(tmplCtx.AppFullLink()))
assert.Equal(t, "https://gitea.example.com/sub/user/repo", string(tmplCtx.AppFullLink("user/repo")))
diff --git a/services/context/private.go b/services/context/private.go
index 0adfaeafcd4..49b0d9b97c9 100644
--- a/services/context/private.go
+++ b/services/context/private.go
@@ -9,6 +9,7 @@ import (
"net/http"
"time"
+ user_model "gitea.dev/models/user"
"gitea.dev/modules/graceful"
"gitea.dev/modules/log"
"gitea.dev/modules/private"
@@ -23,6 +24,7 @@ type PrivateContext struct {
*Base
Override context.Context
+ Doer *user_model.User
Repo *Repository
}
diff --git a/services/contexttest/context_tests.go b/services/contexttest/context_tests.go
index 80739ca6c0e..41cf9eaaf8f 100644
--- a/services/contexttest/context_tests.go
+++ b/services/contexttest/context_tests.go
@@ -42,7 +42,7 @@ func mockRequest(t *testing.T, reqPath string) *http.Request {
requestURL, err := url.Parse(path)
assert.NoError(t, err)
req := &http.Request{Method: method, Host: requestURL.Host, URL: requestURL, Form: maps.Clone(requestURL.Query()), Header: http.Header{}}
- req = req.WithContext(reqctx.NewRequestContextForTest(req.Context()))
+ req = req.WithContext(reqctx.NewRequestContextForTest(t))
return req
}
diff --git a/services/convert/convert.go b/services/convert/convert.go
index ee3c87ebdf3..da9bfb5de08 100644
--- a/services/convert/convert.go
+++ b/services/convert/convert.go
@@ -20,6 +20,7 @@ import (
asymkey_model "gitea.dev/models/asymkey"
"gitea.dev/models/auth"
"gitea.dev/models/db"
+ deploykey_model "gitea.dev/models/deploykey"
git_model "gitea.dev/models/git"
issues_model "gitea.dev/models/issues"
"gitea.dev/models/organization"
@@ -846,19 +847,21 @@ func ToGitHook(h *git.Hook) *api.GitHook {
}
}
-// ToDeployKey convert asymkey_model.DeployKey to api.DeployKey
-func ToDeployKey(ctx context.Context, repo *repo_model.Repository, deployKey *asymkey_model.DeployKey) *api.DeployKey {
+// ToDeployKey convert deploykey_model.DeployKey to api.DeployKey
+func ToDeployKey(ctx context.Context, repo *repo_model.Repository, deployKey *deploykey_model.DeployKey) *api.DeployKey {
k := &api.DeployKey{
- ID: deployKey.ID,
- KeyID: deployKey.KeyID,
- URL: repo.APIURL(ctx) + fmt.Sprintf("/keys/%d", deployKey.ID),
- Title: deployKey.Name,
- Created: deployKey.CreatedUnix.AsTime(),
- ReadOnly: deployKey.Mode == perm.AccessModeRead, // All deploy keys are read-only.
+ ID: deployKey.ID,
+ KeyType: util.Iif(deployKey.KeyType == deploykey_model.KeyTypeSSH, "ssh", "token"),
+ KeyID: deployKey.KeyID,
+ Token: deployKey.Token,
+ URL: repo.APIURL(ctx) + fmt.Sprintf("/keys/%d", deployKey.ID),
+ Title: deployKey.Name,
+ Fingerprint: deployKey.Fingerprint,
+ Created: deployKey.CreatedUnix.AsTime(),
+ ReadOnly: deployKey.IsReadOnly(),
}
- if err := deployKey.LoadPublicKey(ctx); err == nil {
+ if deployKey.KeyType == deploykey_model.KeyTypeSSH && deployKey.LoadPublicKey(ctx) == nil {
k.Key = deployKey.PublicKey.Content
- k.Fingerprint = deployKey.PublicKey.Fingerprint
}
return k
}
diff --git a/services/forms/repo_form.go b/services/forms/repo_form.go
index e795d7ffb0c..cd7b94b7e2b 100644
--- a/services/forms/repo_form.go
+++ b/services/forms/repo_form.go
@@ -576,3 +576,10 @@ type SaveTopicForm struct {
middleware.FormDefaultValidator
Topics []string `binding:"topics;Required;"`
}
+
+// AddDeployTokenForm form for adding a deploy token to a repository
+type AddDeployTokenForm struct {
+ middleware.FormDefaultValidator
+ Title string `binding:"Required;MaxSize(50)"`
+ IsWritable bool
+}
diff --git a/services/lfs/server.go b/services/lfs/server.go
index 93387401369..ccca2297eea 100644
--- a/services/lfs/server.go
+++ b/services/lfs/server.go
@@ -49,16 +49,18 @@ type requestContext struct {
// Claims is a JWT Token Claims
type Claims struct {
- RepoID int64
- Op string
- UserID int64
+ RepoID int64
+ Op string
+ UserID int64
+ UserExtDoerData string
jwt.RegisteredClaims
}
type AuthTokenOptions struct {
- Op string
- UserID int64
- RepoID int64
+ Op string
+ UserID int64
+ UserExtDoerData string
+ RepoID int64
}
func GetLFSAuthTokenWithBearer(opts AuthTokenOptions) (string, error) {
@@ -68,9 +70,10 @@ func GetLFSAuthTokenWithBearer(opts AuthTokenOptions) (string, error) {
ExpiresAt: jwt.NewNumericDate(now.Add(setting.LFS.HTTPAuthExpiry)),
NotBefore: jwt.NewNumericDate(now),
},
- RepoID: opts.RepoID,
- Op: opts.Op,
- UserID: opts.UserID,
+ RepoID: opts.RepoID,
+ Op: opts.Op,
+ UserID: opts.UserID,
+ UserExtDoerData: opts.UserExtDoerData,
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
@@ -544,15 +547,6 @@ func authenticate(ctx *context.Context, repository *repo_model.Repository, autho
accessMode = perm_model.AccessModeWrite
}
- if taskID, ok := user_model.GetActionsUserTaskID(ctx.Doer); ok {
- perm, err := access_model.GetActionsUserRepoPermission(ctx, repository, ctx.Doer, taskID)
- if err != nil {
- log.Error("Unable to GetActionsUserRepoPermission for task[%d] Error: %v", taskID, err)
- return false
- }
- return perm.CanAccess(accessMode, unit.TypeCode)
- }
-
// it works for both anonymous request and signed-in user, then perm.CanAccess will do the permission check
perm, err := access_model.GetDoerRepoPermission(ctx, repository, ctx.Doer)
if err != nil {
@@ -604,9 +598,9 @@ func handleLFSToken(ctx stdCtx.Context, tokenSHA string, target *repo_model.Repo
return nil, errors.New("invalid token claim")
}
- u, err := user_model.GetUserByID(ctx, claims.UserID)
+ u, err := user_model.GetDoerUser(ctx, claims.UserID, claims.UserExtDoerData)
if err != nil {
- log.Error("Unable to GetUserById[%d]: Error: %v", claims.UserID, err)
+ log.Error("Unable to GetDoerUser[%d]: Error: %v", claims.UserID, err)
return nil, err
}
if !u.IsActive || u.ProhibitLogin {
diff --git a/services/lfs/server_test.go b/services/lfs/server_test.go
index f7312d2a6b9..8ccf777caff 100644
--- a/services/lfs/server_test.go
+++ b/services/lfs/server_test.go
@@ -8,6 +8,7 @@ import (
"testing"
"gitea.dev/models/db"
+ deploykey_model "gitea.dev/models/deploykey"
perm_model "gitea.dev/models/perm"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
@@ -101,4 +102,23 @@ func TestAuthenticate(t *testing.T) {
err := handleLFSTokenTestPerm("upload", 2, repo1, perm_model.AccessModeWrite)
assert.NoError(t, err)
})
+
+ // a deploy-key doer has no user row, so the token must carry its ext doer data to stay redeemable
+ t.Run("handleLFSToken resolves deploy-key doers", func(t *testing.T) {
+ key, err := deploykey_model.AddDeployKeyToken(t.Context(), repo1.ID, "lfs", perm_model.AccessModeRead)
+ require.NoError(t, err)
+ doer := user_model.NewDeployKeyUserWithKeyID(key.ID)
+ getDoerToken := func(op string) string {
+ s, _ := GetLFSAuthTokenWithBearer(AuthTokenOptions{Op: op, UserID: doer.ID, UserExtDoerData: doer.ExtDoerData.EncodeToString(), RepoID: repo1.ID})
+ _, token, _ := strings.Cut(s, " ")
+ return token
+ }
+
+ u, err := handleLFSToken(ctx, getDoerToken("download"), repo1, perm_model.AccessModeRead)
+ require.NoError(t, err)
+ assert.Equal(t, user_model.DeployKeyUserID, u.ID)
+
+ _, err = handleLFSToken(ctx, getDoerToken("upload"), repo1, perm_model.AccessModeWrite)
+ assert.ErrorContains(t, err, "no permission to access the repository")
+ })
}
diff --git a/services/markup/renderhelper_mention_test.go b/services/markup/renderhelper_mention_test.go
index d4b34c2011f..54e51b613b6 100644
--- a/services/markup/renderhelper_mention_test.go
+++ b/services/markup/renderhelper_mention_test.go
@@ -38,7 +38,7 @@ func TestRenderHelperMention(t *testing.T) {
// when using web context, use user.IsUserVisibleToViewer to check
req, err := http.NewRequest(http.MethodGet, "/", nil)
assert.NoError(t, err)
- base := gitea_context.NewBaseContextForTest(httptest.NewRecorder(), req)
+ base := gitea_context.NewBaseContextForTest(t, httptest.NewRecorder(), req)
giteaCtx := gitea_context.NewWebContext(base, &contexttest.MockRender{}, nil)
assert.True(t, FormalRenderHelperFuncs().IsUsernameMentionable(giteaCtx, userPublic))
diff --git a/services/repository/push.go b/services/repository/push.go
index a8b8187b633..ebfb116944a 100644
--- a/services/repository/push.go
+++ b/services/repository/push.go
@@ -96,13 +96,8 @@ func pushQueueHandleUpdates(optsList []*repo_module.PushUpdateOptions) error {
if opts.RefFullName.IsTag() {
if pusher == nil || pusher.ID != opts.PusherID {
- if opts.PusherID == user_model.ActionsUserID {
- pusher = user_model.NewActionsUser()
- } else {
- var err error
- if pusher, err = user_model.GetUserByID(ctx, opts.PusherID); err != nil {
- return err
- }
+ if _, pusher, err = user_model.GetPossibleUserByID(ctx, opts.PusherID); err != nil {
+ return err
}
}
tagName := opts.RefFullName.TagName()
@@ -143,13 +138,8 @@ func pushQueueHandleUpdates(optsList []*repo_module.PushUpdateOptions) error {
}
} else if opts.RefFullName.IsBranch() {
if pusher == nil || pusher.ID != opts.PusherID {
- if opts.PusherID == user_model.ActionsUserID {
- pusher = user_model.NewActionsUser()
- } else {
- var err error
- if pusher, err = user_model.GetUserByID(ctx, opts.PusherID); err != nil {
- return err
- }
+ if _, pusher, err = user_model.GetPossibleUserByID(ctx, opts.PusherID); err != nil {
+ return err
}
}
diff --git a/templates/repo/settings/deploy_key_list.tmpl b/templates/repo/settings/deploy_key_list.tmpl
new file mode 100644
index 00000000000..bfe9a85fa42
--- /dev/null
+++ b/templates/repo/settings/deploy_key_list.tmpl
@@ -0,0 +1,44 @@
+{{if .RepoDeployKeys}}
+
{{ctx.Locale.Tr "repo.settings.deploy_key_deletion_desc"}}
{{ctx.Locale.Tr "repo.settings.regenerate_deploy_token_desc"}}