From 02b1b8a5497cc32f85a0106c66c8658a5b826c2b Mon Sep 17 00:00:00 2001
From: pomidorry <106489913+Pomidorry@users.noreply.github.com>
Date: Fri, 1 May 2026 14:00:03 +0300
Subject: [PATCH] Add mirror auth updates to repo edit API and settings
(#37468)
## Summary
This PR adds support for updating pull mirror authentication via the
repository edit API and UI.
It introduces new mirror authentication fields in _EditRepoOption_,
updates the API logic to safely handle partial credential updates, and
fixes the web settings flow so that the existing remote username is
preserved when only the password is changed.
### What changed
- added _auth_username_, _auth_password_, and _auth_token_ to
EditRepoOption
- updated the repository edit API to apply mirror auth changes via
_updateMirror_
- preserved existing username/password when only part of the auth
payload is provided
- used oauth2 as the default username when _auth_token_ is provided
- kept stored mirror URLs sanitized in DB and API responses
- updated Swagger schema for the new API fields
- added API integration tests for password-only and token-only updates
- added a web settings test to ensure username preservation on partial
updates
## Why
Some use cases require automated synchronization of pull mirrors, for
example in CI/CD pipelines or integrations with external systems.
At the same time, many organizations enforce security policies that
require periodic token rotation (e.g., monthly).
Currently, mirror credentials can only be updated via the UI, which
makes automation difficult.
## This change enables:
- automated token rotation
- avoiding manual updates via the UI
- easier integration with secret management systems
## Testing
- added integration coverage for mirror auth updates via _PATCH
/api/v1/repos/{owner}/{repo}_
- added web settings tests for password-only updates preserving the
existing username
## Result
Ability to automate auth update
Generative AI was used to help with making this PR.
##
---
modules/structs/repo.go | 6 +++
routers/api/v1/repo/migrate.go | 2 +-
routers/api/v1/repo/repo.go | 60 ++++++++++++++++++++++-
routers/web/repo/setting/setting.go | 9 +++-
routers/web/repo/setting/settings_test.go | 45 +++++++++++++++++
templates/swagger/v1_json.tmpl | 15 ++++++
templates/swagger/v1_openapi3_json.tmpl | 15 ++++++
tests/integration/api_repo_edit_test.go | 54 ++++++++++++++++++++
8 files changed, 202 insertions(+), 4 deletions(-)
diff --git a/modules/structs/repo.go b/modules/structs/repo.go
index 138be33c1c5..0c3a0ab44e3 100644
--- a/modules/structs/repo.go
+++ b/modules/structs/repo.go
@@ -236,6 +236,12 @@ type EditRepoOption struct {
MirrorInterval *string `json:"mirror_interval,omitempty"`
// enable prune - remove obsolete remote-tracking references when mirroring
EnablePrune *bool `json:"enable_prune,omitempty"`
+ // authentication username for the remote repository (mirrors)
+ MirrorUsername *string `json:"mirror_username,omitempty"`
+ // authentication password for the remote repository (mirrors)
+ MirrorPassword *string `json:"mirror_password,omitempty"`
+ // authentication token for the remote repository (mirrors)
+ MirrorToken *string `json:"mirror_token,omitempty"`
}
// GenerateRepoOption options when creating a repository using a template
diff --git a/routers/api/v1/repo/migrate.go b/routers/api/v1/repo/migrate.go
index dc99cf8c162..7431493a3fc 100644
--- a/routers/api/v1/repo/migrate.go
+++ b/routers/api/v1/repo/migrate.go
@@ -257,7 +257,7 @@ func handleRemoteAddrError(ctx *context.APIContext, err error) {
addrErr := err.(*git.ErrInvalidCloneAddr)
switch {
case addrErr.IsURLError:
- ctx.APIError(http.StatusUnprocessableEntity, err)
+ ctx.APIError(http.StatusUnprocessableEntity, "The provided URL is invalid.")
case addrErr.IsPermissionDenied:
if addrErr.LocalPath {
ctx.APIError(http.StatusUnprocessableEntity, "You are not allowed to import local repositories.")
diff --git a/routers/api/v1/repo/repo.go b/routers/api/v1/repo/repo.go
index f0428f2f6d6..8b0dc7c863b 100644
--- a/routers/api/v1/repo/repo.go
+++ b/routers/api/v1/repo/repo.go
@@ -21,6 +21,7 @@ import (
repo_model "code.gitea.io/gitea/models/repo"
unit_model "code.gitea.io/gitea/models/unit"
user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/gitrepo"
"code.gitea.io/gitea/modules/label"
"code.gitea.io/gitea/modules/log"
@@ -37,6 +38,8 @@ import (
"code.gitea.io/gitea/services/convert"
feed_service "code.gitea.io/gitea/services/feed"
"code.gitea.io/gitea/services/issue"
+ "code.gitea.io/gitea/services/migrations"
+ mirror_service "code.gitea.io/gitea/services/mirror"
repo_service "code.gitea.io/gitea/services/repository"
)
@@ -628,7 +631,11 @@ func Edit(ctx *context.APIContext) {
}
}
- if opts.MirrorInterval != nil || opts.EnablePrune != nil {
+ if opts.MirrorInterval != nil ||
+ opts.EnablePrune != nil ||
+ opts.MirrorUsername != nil ||
+ opts.MirrorPassword != nil ||
+ opts.MirrorToken != nil {
if err := updateMirror(ctx, opts); err != nil {
return
}
@@ -1059,6 +1066,57 @@ func updateMirror(ctx *context.APIContext, opts api.EditRepoOption) error {
log.Trace("Repository %s Mirror[%d] Set EnablePrune: %t", repo.FullName(), mirror.ID, mirror.EnablePrune)
}
+ authUpdateRequested := opts.MirrorPassword != nil || opts.MirrorToken != nil || opts.MirrorUsername != nil
+ if authUpdateRequested {
+ remoteURL, err := gitrepo.GitRemoteGetURL(ctx, repo, mirror.GetRemoteName())
+ if err != nil {
+ ctx.APIErrorInternal(err)
+ return err
+ }
+
+ authUsername := ""
+ if opts.MirrorUsername != nil {
+ authUsername = *opts.MirrorUsername
+ } else if remoteURL.User != nil {
+ authUsername = remoteURL.User.Username()
+ }
+
+ authPassword := ""
+ authToken := ""
+ if opts.MirrorPassword != nil {
+ authPassword = *opts.MirrorPassword
+ }
+ if opts.MirrorToken != nil {
+ authToken = *opts.MirrorToken
+ }
+
+ if opts.MirrorPassword == nil && opts.MirrorToken == nil && remoteURL.User != nil && (authUsername == "" || authUsername == remoteURL.User.Username()) {
+ authPassword, _ = remoteURL.User.Password()
+ }
+
+ if authToken != "" {
+ authPassword = authToken
+ }
+
+ composedAddress, err := git.ParseRemoteAddr(repo.OriginalURL, authUsername, authPassword)
+ if err == nil {
+ err = migrations.IsMigrateURLAllowed(composedAddress, ctx.Doer)
+ }
+ if err != nil {
+ handleRemoteAddrError(ctx, err)
+ return err
+ }
+
+ if err := mirror_service.UpdateAddress(ctx, mirror, composedAddress); err != nil {
+ ctx.APIErrorInternal(err)
+ return err
+ }
+
+ if sanitized, err := util.SanitizeURL(repo.OriginalURL); err == nil {
+ mirror.RemoteAddress = sanitized
+ }
+ }
+
// finally update the mirror in the DB
if err := repo_model.UpdateMirror(ctx, mirror); err != nil {
log.Error("Failed to Set Mirror Interval: %s", err)
diff --git a/routers/web/repo/setting/setting.go b/routers/web/repo/setting/setting.go
index 703d0022504..816fd91cd8b 100644
--- a/routers/web/repo/setting/setting.go
+++ b/routers/web/repo/setting/setting.go
@@ -265,8 +265,13 @@ func handleSettingsPostMirror(ctx *context.Context) {
handleSettingRemoteAddrError(ctx, err, form)
return
}
- if u.User != nil && form.MirrorPassword == "" && form.MirrorUsername == u.User.Username() {
- form.MirrorPassword, _ = u.User.Password()
+ if u.User != nil {
+ if form.MirrorUsername == "" {
+ form.MirrorUsername = u.User.Username()
+ }
+ if form.MirrorPassword == "" && form.MirrorUsername == u.User.Username() {
+ form.MirrorPassword, _ = u.User.Password()
+ }
}
address, err := git.ParseRemoteAddr(form.MirrorAddress, form.MirrorUsername, form.MirrorPassword)
diff --git a/routers/web/repo/setting/settings_test.go b/routers/web/repo/setting/settings_test.go
index 4c65b696c59..154d01fda40 100644
--- a/routers/web/repo/setting/settings_test.go
+++ b/routers/web/repo/setting/settings_test.go
@@ -13,15 +13,18 @@ import (
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/gitrepo"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/test"
"code.gitea.io/gitea/modules/web"
"code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/contexttest"
"code.gitea.io/gitea/services/forms"
+ mirror_service "code.gitea.io/gitea/services/mirror"
repo_service "code.gitea.io/gitea/services/repository"
"github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
)
func TestAddReadOnlyDeployKey(t *testing.T) {
@@ -386,3 +389,45 @@ func TestDeleteTeam(t *testing.T) {
assert.False(t, repo_service.HasRepository(t.Context(), team, re.ID))
}
+
+func TestHandleSettingsPostMirrorPreservesExistingUsername(t *testing.T) {
+ defer test.MockVariableValue(&setting.Mirror.Enabled, true)()
+
+ unittest.PrepareTestEnv(t)
+
+ // Use the existing fixture mirror repo (org3/repo5) which has a git repo on disk.
+ mirrorRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 5})
+ mirror := unittest.AssertExistsAndLoadBean(t, &repo_model.Mirror{RepoID: 5})
+
+ require.NoError(t, mirror_service.UpdateAddress(t.Context(), mirror, "https://existing-user:existing-password@example.com/user2/repo1.git"))
+
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+
+ ctx, _ := contexttest.MockContext(t, mirrorRepo.Link()+"/settings")
+ contexttest.LoadUser(t, ctx, user.ID)
+ contexttest.LoadRepo(t, ctx, mirrorRepo.ID)
+
+ web.SetForm(ctx, &forms.RepoSettingForm{
+ Interval: "8h",
+ MirrorAddress: "https://example.com/user2/repo1.git",
+ MirrorPassword: "updated-password",
+ })
+
+ handleSettingsPostMirror(ctx)
+
+ assert.Equal(t, http.StatusSeeOther, ctx.Resp.WrittenStatus())
+
+ updatedMirror := unittest.AssertExistsAndLoadBean(t, &repo_model.Mirror{RepoID: mirrorRepo.ID})
+ assert.Equal(t, "https://example.com/user2/repo1.git", updatedMirror.RemoteAddress)
+
+ updatedRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: mirrorRepo.ID})
+ assert.Equal(t, "https://example.com/user2/repo1.git", updatedRepo.OriginalURL)
+
+ remoteURL, err := gitrepo.GitRemoteGetURL(t.Context(), updatedRepo, updatedMirror.GetRemoteName())
+ require.NoError(t, err)
+ require.NotNil(t, remoteURL.User)
+ assert.Equal(t, "existing-user", remoteURL.User.Username())
+ password, ok := remoteURL.User.Password()
+ require.True(t, ok)
+ assert.Equal(t, "updated-password", password)
+}
diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl
index eac1ae67247..26d45940f25 100644
--- a/templates/swagger/v1_json.tmpl
+++ b/templates/swagger/v1_json.tmpl
@@ -25510,6 +25510,21 @@
"type": "string",
"x-go-name": "MirrorInterval"
},
+ "mirror_password": {
+ "description": "authentication password for the remote repository (mirrors)",
+ "type": "string",
+ "x-go-name": "MirrorPassword"
+ },
+ "mirror_token": {
+ "description": "authentication token for the remote repository (mirrors)",
+ "type": "string",
+ "x-go-name": "MirrorToken"
+ },
+ "mirror_username": {
+ "description": "authentication username for the remote repository (mirrors)",
+ "type": "string",
+ "x-go-name": "MirrorUsername"
+ },
"name": {
"description": "name of the repository",
"type": "string",
diff --git a/templates/swagger/v1_openapi3_json.tmpl b/templates/swagger/v1_openapi3_json.tmpl
index a45def2ddfe..33adff75e0d 100644
--- a/templates/swagger/v1_openapi3_json.tmpl
+++ b/templates/swagger/v1_openapi3_json.tmpl
@@ -5738,6 +5738,21 @@
"type": "string",
"x-go-name": "MirrorInterval"
},
+ "mirror_password": {
+ "description": "authentication password for the remote repository (mirrors)",
+ "type": "string",
+ "x-go-name": "MirrorPassword"
+ },
+ "mirror_token": {
+ "description": "authentication token for the remote repository (mirrors)",
+ "type": "string",
+ "x-go-name": "MirrorToken"
+ },
+ "mirror_username": {
+ "description": "authentication username for the remote repository (mirrors)",
+ "type": "string",
+ "x-go-name": "MirrorUsername"
+ },
"name": {
"description": "name of the repository",
"type": "string",
diff --git a/tests/integration/api_repo_edit_test.go b/tests/integration/api_repo_edit_test.go
index 46bcccc31ee..e37b214fa88 100644
--- a/tests/integration/api_repo_edit_test.go
+++ b/tests/integration/api_repo_edit_test.go
@@ -15,9 +15,12 @@ import (
unit_model "code.gitea.io/gitea/models/unit"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/gitrepo"
api "code.gitea.io/gitea/modules/structs"
+ mirror_service "code.gitea.io/gitea/services/mirror"
"github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
)
// getRepoEditOptionFromRepo gets the options for an existing repo exactly as is
@@ -432,5 +435,56 @@ func TestAPIRepoEdit(t *testing.T) {
DefaultDeleteBranchAfterMerge: &bFalse,
}).AddTokenAuth(token2)
_ = MakeRequest(t, req, http.StatusOK)
+
+ // Test updating mirror password without changing the existing username
+ ctx := t.Context()
+ mirrorRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 5})
+ mirror := unittest.AssertExistsAndLoadBean(t, &repo_model.Mirror{RepoID: 5})
+ newPassword := "updated-password"
+
+ require.NoError(t, mirror_service.UpdateAddress(ctx, mirror, "https://existing-user:existing-password@example.com/user2/repo1.git"))
+
+ req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s", mirrorRepo.OwnerName, mirrorRepo.Name), &api.EditRepoOption{
+ MirrorPassword: &newPassword,
+ }).AddTokenAuth(token2)
+ MakeRequest(t, req, http.StatusOK)
+
+ updatedMirror := unittest.AssertExistsAndLoadBean(t, &repo_model.Mirror{RepoID: mirrorRepo.ID})
+ assert.Equal(t, "https://example.com/user2/repo1.git", updatedMirror.RemoteAddress)
+
+ updatedRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: mirrorRepo.ID})
+ assert.Equal(t, "https://example.com/user2/repo1.git", updatedRepo.OriginalURL)
+
+ remoteURL, err := gitrepo.GitRemoteGetURL(ctx, updatedRepo, updatedMirror.GetRemoteName())
+ require.NoError(t, err)
+ require.NotNil(t, remoteURL.User)
+ assert.Equal(t, "existing-user", remoteURL.User.Username())
+ password, ok := remoteURL.User.Password()
+ require.True(t, ok)
+ assert.Equal(t, newPassword, password)
+
+ // Test updating mirror token without guessing a username
+ token := "mirror-token-value"
+
+ require.NoError(t, mirror_service.UpdateAddress(ctx, mirror, "https://example.com/user2/repo1.git"))
+
+ req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s", mirrorRepo.OwnerName, mirrorRepo.Name), &api.EditRepoOption{
+ MirrorToken: &token,
+ }).AddTokenAuth(token2)
+ MakeRequest(t, req, http.StatusOK)
+
+ updatedMirror = unittest.AssertExistsAndLoadBean(t, &repo_model.Mirror{RepoID: mirrorRepo.ID})
+ assert.Equal(t, "https://example.com/user2/repo1.git", updatedMirror.RemoteAddress)
+
+ updatedRepo = unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: mirrorRepo.ID})
+ assert.Equal(t, "https://example.com/user2/repo1.git", updatedRepo.OriginalURL)
+
+ remoteURL, err = gitrepo.GitRemoteGetURL(ctx, updatedRepo, updatedMirror.GetRemoteName())
+ require.NoError(t, err)
+ require.NotNil(t, remoteURL.User)
+ assert.Empty(t, remoteURL.User.Username())
+ password, ok = remoteURL.User.Password()
+ require.True(t, ok)
+ assert.Equal(t, token, password)
})
}