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 1 image 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) }) }