From 6ce30aa144bfdec1aaff89a301b9945ecefa146e Mon Sep 17 00:00:00 2001 From: pomidorry Date: Sun, 7 Jun 2026 14:37:22 +0300 Subject: [PATCH] let user choose SSH key owner when migrating to an org --- modules/migration/options.go | 3 ++ modules/ssh/managed.go | 20 +++++--- modules/structs/repo.go | 3 ++ options/locale/locale_en-US.json | 4 +- routers/api/v1/repo/migrate.go | 1 + routers/web/repo/migrate.go | 1 + services/forms/repo_form.go | 3 ++ services/migrations/gitea_uploader.go | 1 + services/mirror/mirror_pull.go | 2 +- services/mirror/mirror_push.go | 2 +- services/repository/migrate.go | 8 +-- templates/repo/migrate/git.tmpl | 7 +++ web_src/js/features/repo-migrate.ts | 73 ++++++++++++++++++++++++--- 13 files changed, 109 insertions(+), 19 deletions(-) diff --git a/modules/migration/options.go b/modules/migration/options.go index cf696d8ce4..067b1df43d 100644 --- a/modules/migration/options.go +++ b/modules/migration/options.go @@ -17,6 +17,9 @@ type MigrateOptions struct { AuthPasswordEncrypted string `json:"auth_password_encrypted,omitempty"` AuthToken string `json:"-"` AuthTokenEncrypted string `json:"auth_token_encrypted,omitempty"` + // SSHKeyOwnerID overrides the owner whose managed SSH keypair authenticates + // the SSH clone. 0 means use the target repository owner (the default). + SSHKeyOwnerID int64 `json:"ssh_key_owner_id,omitempty"` // required: true UID int `json:"uid" binding:"Required"` // required: true diff --git a/modules/ssh/managed.go b/modules/ssh/managed.go index 25056425e1..11e3e9684d 100644 --- a/modules/ssh/managed.go +++ b/modules/ssh/managed.go @@ -5,13 +5,14 @@ package ssh import ( "context" + "errors" "fmt" - "gitea.dev/models/db" repo_model "gitea.dev/models/repo" user_model "gitea.dev/models/user" giturl "gitea.dev/modules/git/url" "gitea.dev/modules/log" + "gitea.dev/modules/util" ) // IsSSHURL checks if a URL is an SSH URL @@ -25,7 +26,7 @@ func IsSSHURL(remote string) bool { func GetOrCreateSSHKeypair(ctx context.Context, ownerID int64) (*user_model.SSHKeypair, error) { keypair, err := user_model.GetSSHKeypairByOwner(ctx, ownerID) if err != nil { - if db.IsErrNotExist(err) { + if errors.Is(err, util.ErrNotExist) { log.Debug("Creating new SSH keypair for owner %d", ownerID) return user_model.CreateSSHKeypair(ctx, ownerID) } @@ -43,15 +44,22 @@ func GetSSHKeypairForRepository(ctx context.Context, repo *repo_model.Repository // migration git operation against remoteURL on behalf of repo. For non-SSH // URLs (or when no keypair is available) it is a no-op. The returned cleanup // is never nil and must always be called by the caller (typically via defer). -func SetupManagedSSHAgent(ctx context.Context, repo *repo_model.Repository, remoteURL string) (sshAuthSock string, cleanup func(), err error) { +// If sshKeyOwnerID is non-zero, the keypair of that owner is used instead of +// the repository owner's (used when migrating to an org and the user wants +// to authenticate with their personal managed key). +func SetupManagedSSHAgent(ctx context.Context, repo *repo_model.Repository, remoteURL string, sshKeyOwnerID int64) (sshAuthSock string, cleanup func(), err error) { noop := func() {} if !IsSSHURL(remoteURL) { return "", noop, nil } - keypair, err := GetSSHKeypairForRepository(ctx, repo) + ownerID := repo.OwnerID + if sshKeyOwnerID != 0 { + ownerID = sshKeyOwnerID + } + keypair, err := GetOrCreateSSHKeypair(ctx, ownerID) if err != nil { - return "", noop, fmt.Errorf("failed to get SSH keypair for repository: %w", err) + return "", noop, fmt.Errorf("failed to get SSH keypair for owner %d: %w", ownerID, err) } if keypair == nil { return "", noop, nil @@ -67,6 +75,6 @@ func SetupManagedSSHAgent(ctx context.Context, repo *repo_model.Repository, remo return "", noop, fmt.Errorf("failed to create SSH agent: %w", err) } - log.Debug("SSH agent ready for mirror %s (socket: %s)", repo.FullName(), socketPath) + log.Debug("SSH agent ready for %s (socket: %s)", repo.FullName(), socketPath) return socketPath, agentCleanup, nil } diff --git a/modules/structs/repo.go b/modules/structs/repo.go index eca1b15b02..351477ec96 100644 --- a/modules/structs/repo.go +++ b/modules/structs/repo.go @@ -404,6 +404,9 @@ type MigrateRepoOptions struct { AuthUsername string `json:"auth_username"` AuthPassword string `json:"auth_password"` AuthToken string `json:"auth_token"` + // SSHKeyOwnerID picks which managed SSH keypair to use for SSH clones. + // 0 means use the target repo owner (default). + SSHKeyOwnerID int64 `json:"ssh_key_owner_id"` Mirror bool `json:"mirror"` LFS bool `json:"lfs"` diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index 96eab2b0d3..2ba38e0892 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -1122,6 +1122,8 @@ "repo.migrate.ssh_helper_title": "SSH URLs", "repo.migrate.ssh_helper_desc": "Upload your SSH mirror keys to the remote SSH server for authentication.", "repo.migrate.ssh_helper_link": "View your SSH keys (if migrating to an organization, you may need to upload the organization's SSH keys).", + "repo.migrate.ssh_key_owner_label": "Authenticate with", + "repo.migrate.ssh_key_owner_repo_default": "Repository owner's managed SSH key (default)", "repo.migrate.permission_denied": "You are not allowed to import local repositories.", "repo.migrate.permission_denied_blocked": "You cannot import from disallowed hosts. Please ask the admin to check ALLOWED_DOMAINS/ALLOW_LOCALNETWORKS/BLOCKED_DOMAINS settings.", "repo.migrate.invalid_local_path": "The local path is invalid. It doesn't exist or is not a directory.", @@ -2792,7 +2794,7 @@ "org.settings.rename_success": "Organization %[1]s has been renamed to %[2]s successfully.", "org.settings.rename_no_change": "Organization name is not changed.", "org.settings.rename_new_org_name": "New Organization Name", - "org.settings.ssh_keys": "SSH Mirror Keys", + "org.settings.ssh_keys": "Managed SSH Keys", "org.settings.rename_failed": "Renaming organization failed because of an internal error", "org.settings.rename_notices_1": "This operation CANNOT be undone.", "org.settings.rename_notices_2": "The old name will redirect until it is claimed.", diff --git a/routers/api/v1/repo/migrate.go b/routers/api/v1/repo/migrate.go index 65ef10777c..9dcb37be01 100644 --- a/routers/api/v1/repo/migrate.go +++ b/routers/api/v1/repo/migrate.go @@ -146,6 +146,7 @@ func Migrate(ctx *context.APIContext) { AuthUsername: form.AuthUsername, AuthPassword: form.AuthPassword, AuthToken: form.AuthToken, + SSHKeyOwnerID: form.SSHKeyOwnerID, Wiki: form.Wiki, Issues: form.Issues, Milestones: form.Milestones, diff --git a/routers/web/repo/migrate.go b/routers/web/repo/migrate.go index c8666eecaa..fcd94bb2b1 100644 --- a/routers/web/repo/migrate.go +++ b/routers/web/repo/migrate.go @@ -217,6 +217,7 @@ func MigratePost(ctx *context.Context) { AuthUsername: form.AuthUsername, AuthPassword: form.AuthPassword, AuthToken: form.AuthToken, + SSHKeyOwnerID: form.SSHKeyOwnerID, Wiki: form.Wiki, Issues: form.Issues, Milestones: form.Milestones, diff --git a/services/forms/repo_form.go b/services/forms/repo_form.go index 5b65369dd6..8c4b95f1a8 100644 --- a/services/forms/repo_form.go +++ b/services/forms/repo_form.go @@ -62,6 +62,9 @@ type MigrateRepoForm struct { AuthUsername string `json:"auth_username"` AuthPassword string `json:"auth_password"` AuthToken string `json:"auth_token"` + // SSHKeyOwnerID picks which managed SSH keypair to use for SSH clones. + // 0 means use the target repo owner (default). + SSHKeyOwnerID int64 `json:"ssh_key_owner_id"` // required: true UID int64 `json:"uid" binding:"Required"` // required: true diff --git a/services/migrations/gitea_uploader.go b/services/migrations/gitea_uploader.go index d195d69e1e..69c00a1c06 100644 --- a/services/migrations/gitea_uploader.go +++ b/services/migrations/gitea_uploader.go @@ -132,6 +132,7 @@ func (g *GiteaLocalUploader) CreateRepo(ctx context.Context, repo *base.Reposito Wiki: opts.Wiki, Releases: opts.Releases, // if didn't get releases, then sync them from tags MirrorInterval: opts.MirrorInterval, + SSHKeyOwnerID: opts.SSHKeyOwnerID, }, NewMigrationHTTPTransport()) g.sameApp = strings.HasPrefix(repo.OriginalURL, setting.AppURL) diff --git a/services/mirror/mirror_pull.go b/services/mirror/mirror_pull.go index 7e362c8dd1..11fd5ac30e 100644 --- a/services/mirror/mirror_pull.go +++ b/services/mirror/mirror_pull.go @@ -119,7 +119,7 @@ func runSync(ctx context.Context, m *repo_model.Mirror) ([]*repo_module.SyncResu timeout := time.Duration(setting.Git.Timeout.Mirror) * time.Second // Setup SSH authentication if needed - sshAuthSock, cleanup, sshErr := ssh_module.SetupManagedSSHAgent(ctx, m.Repo, remoteURL.String()) + sshAuthSock, cleanup, sshErr := ssh_module.SetupManagedSSHAgent(ctx, m.Repo, remoteURL.String(), 0) if sshErr != nil { log.Error("SyncMirrors [repo: %-v]: SSH setup error %v", m.Repo, sshErr) return nil, false diff --git a/services/mirror/mirror_push.go b/services/mirror/mirror_push.go index 5020e6a414..d3843e4deb 100644 --- a/services/mirror/mirror_push.go +++ b/services/mirror/mirror_push.go @@ -168,7 +168,7 @@ func runPushSync(ctx context.Context, m *repo_model.PushMirror) error { } // Setup SSH authentication - sshAuthSock, cleanup, err := ssh_module.SetupManagedSSHAgent(ctx, repo, remoteURL.String()) + sshAuthSock, cleanup, err := ssh_module.SetupManagedSSHAgent(ctx, repo, remoteURL.String(), 0) if err != nil { log.Error("Failed to set up SSH agent for push mirror %s: %v", repo.FullName(), err) return util.SanitizeErrorCredentialURLs(err) diff --git a/services/repository/migrate.go b/services/repository/migrate.go index 23e5629d30..8112241759 100644 --- a/services/repository/migrate.go +++ b/services/repository/migrate.go @@ -28,8 +28,8 @@ import ( "gitea.dev/modules/util" ) -func cloneExternalRepoWithSSHAuth(ctx context.Context, repo *repo_model.Repository, remoteURL string, storageRepo gitrepo.Repository, cloneOpts git.CloneRepoOptions) error { - sshAuthSock, cleanup, err := ssh_module.SetupManagedSSHAgent(ctx, repo, remoteURL) +func cloneExternalRepoWithSSHAuth(ctx context.Context, repo *repo_model.Repository, remoteURL string, storageRepo gitrepo.Repository, cloneOpts git.CloneRepoOptions, sshKeyOwnerID int64) error { + sshAuthSock, cleanup, err := ssh_module.SetupManagedSSHAgent(ctx, repo, remoteURL, sshKeyOwnerID) if err != nil { return err } @@ -63,7 +63,7 @@ func cloneWiki(ctx context.Context, repo *repo_model.Repository, opts migration. SkipTLSVerify: setting.Migrations.SkipTLSVerify, } - if err := cloneExternalRepoWithSSHAuth(ctx, repo, wikiRemoteURL, storageRepo, cloneOpts); err != nil { + if err := cloneExternalRepoWithSSHAuth(ctx, repo, wikiRemoteURL, storageRepo, cloneOpts, opts.SSHKeyOwnerID); err != nil { log.Error("Clone wiki failed, err: %v", err) cleanIncompleteWikiPath() return "", err @@ -115,7 +115,7 @@ func MigrateRepositoryGitData(ctx context.Context, u *user_model.User, repo.Owner = u } - if err := cloneExternalRepoWithSSHAuth(ctx, repo, opts.CloneAddr, repo, cloneOpts); err != nil { + if err := cloneExternalRepoWithSSHAuth(ctx, repo, opts.CloneAddr, repo, cloneOpts, opts.SSHKeyOwnerID); err != nil { if errors.Is(err, context.DeadlineExceeded) { return repo, fmt.Errorf("clone timed out, consider increasing [git.timeout] MIGRATE in app.ini, underlying err: %w", err) } diff --git a/templates/repo/migrate/git.tmpl b/templates/repo/migrate/git.tmpl index c280587f68..e6c29de16b 100644 --- a/templates/repo/migrate/git.tmpl +++ b/templates/repo/migrate/git.tmpl @@ -30,6 +30,13 @@ +
+ + + +
{{template "repo/migrate/options" .}} diff --git a/web_src/js/features/repo-migrate.ts b/web_src/js/features/repo-migrate.ts index 4429111412..b7570a6cdf 100644 --- a/web_src/js/features/repo-migrate.ts +++ b/web_src/js/features/repo-migrate.ts @@ -60,6 +60,12 @@ async function doMigrationRetry(e: Event) { window.location.reload(); } +function isSSHURL(url: string): boolean { + return url.startsWith('ssh://') || + url.startsWith('git@') || + (url.includes('@') && url.includes(':') && !url.includes('://')); +} + export function initRepoMigrationForm() { const cloneAddrInput = document.querySelector('#clone_addr'); const authUsernameInput = document.querySelector('#auth_username'); @@ -68,12 +74,6 @@ export function initRepoMigrationForm() { if (!cloneAddrInput || !authUsernameInput || !authPasswordInput || !sshHelpText) return; - function isSSHURL(url: string): boolean { - return url.startsWith('ssh://') || - url.startsWith('git@') || - (url.includes('@') && url.includes(':') && !url.includes('://')); - } - function updateAuthFields() { const url = cloneAddrInput!.value.trim(); const isSSH = isSSHURL(url); @@ -99,4 +99,65 @@ export function initRepoMigrationForm() { updateAuthFields(); cloneAddrInput.addEventListener('input', updateAuthFields); cloneAddrInput.addEventListener('blur', updateAuthFields); + + initSSHKeyOwnerSelector(cloneAddrInput); +} + +// initSSHKeyOwnerSelector wires the "managed SSH key owner" selector. It is +// hidden by default and only shown when an SSH URL is entered AND the chosen +// target owner is an organisation (i.e. not the signed-in user) — in that case +// the user can pick between the org's managed key (default) and their personal +// managed key. The hidden #ssh_key_owner_id field is submitted with the form. +function initSSHKeyOwnerSelector(cloneAddrInput: HTMLInputElement) { + const container = document.querySelector('.ssh-key-owner-selector'); + const select = document.querySelector('#ssh_key_owner_select'); + const hiddenId = document.querySelector('#ssh_key_owner_id'); + const uidInput = document.querySelector('#uid'); + if (!container || !select || !hiddenId || !uidInput) return; + + const signedUserID = container.dataset.signedUserId ?? ''; + const signedUserName = container.dataset.signedUserName ?? ''; + + // Build {ownerID -> name} from the owner dropdown menu items + const ownerNameById = new Map(); + for (const item of document.querySelectorAll('.owner.dropdown .menu .item')) { + const id = item.dataset.value; + const name = item.getAttribute('title') ?? item.textContent?.trim() ?? ''; + if (id) ownerNameById.set(id, name); + } + + function update() { + const isSSH = isSSHURL(cloneAddrInput.value.trim()); + const targetUid = uidInput!.value; + + // No choice: non-SSH URL, or migrating into the user's own account + if (!isSSH || targetUid === signedUserID) { + hideElem(container!); + hiddenId!.value = '0'; + return; + } + + // Target is an organisation — offer both keys + const orgName = ownerNameById.get(targetUid) ?? `#${targetUid}`; + select!.innerHTML = ''; + select!.add(new Option(`Use ${orgName}'s managed SSH key (default)`, '0')); + select!.add(new Option(`Use your personal managed SSH key (${signedUserName})`, signedUserID)); + select!.value = hiddenId!.value || '0'; + hiddenId!.value = select!.value; + showElem(container!); + } + + select.addEventListener('change', () => { + hiddenId!.value = select!.value; + }); + + // Semantic UI updates the #uid hidden input via menu item clicks + for (const item of document.querySelectorAll('.owner.dropdown .menu .item')) { + item.addEventListener('click', () => setTimeout(update, 0)); + } + + cloneAddrInput.addEventListener('input', update); + cloneAddrInput.addEventListener('blur', update); + + update(); }