0
0
mirror of https://github.com/go-gitea/gitea.git synced 2026-06-21 21:12:26 +02:00

let user choose SSH key owner when migrating to an org

This commit is contained in:
pomidorry 2026-06-07 14:37:22 +03:00
parent 781666b5eb
commit 6ce30aa144
13 changed files with 109 additions and 19 deletions

View File

@ -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

View File

@ -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
}

View File

@ -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"`

View File

@ -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 <strong>CANNOT</strong> be undone.",
"org.settings.rename_notices_2": "The old name will redirect until it is claimed.",

View File

@ -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,

View File

@ -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,

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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)

View File

@ -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)
}

View File

@ -30,6 +30,13 @@
<label for="auth_password">{{ctx.Locale.Tr "password"}}</label>
<input id="auth_password" name="auth_password" type="password" value="{{.auth_password}}">
</div>
<div class="inline field ssh-key-owner-selector tw-hidden" data-signed-user-id="{{.SignedUser.ID}}" data-signed-user-name="{{.SignedUser.Name}}">
<label for="ssh_key_owner_select">{{ctx.Locale.Tr "repo.migrate.ssh_key_owner_label"}}</label>
<select id="ssh_key_owner_select" name="ssh_key_owner_id_select">
<option value="0">{{ctx.Locale.Tr "repo.migrate.ssh_key_owner_repo_default"}}</option>
</select>
<input id="ssh_key_owner_id" name="ssh_key_owner_id" type="hidden" value="0">
</div>
{{template "repo/migrate/options" .}}

View File

@ -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<HTMLInputElement>('#clone_addr');
const authUsernameInput = document.querySelector<HTMLInputElement>('#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<HTMLElement>('.ssh-key-owner-selector');
const select = document.querySelector<HTMLSelectElement>('#ssh_key_owner_select');
const hiddenId = document.querySelector<HTMLInputElement>('#ssh_key_owner_id');
const uidInput = document.querySelector<HTMLInputElement>('#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<string, string>();
for (const item of document.querySelectorAll<HTMLElement>('.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<HTMLElement>('.owner.dropdown .menu .item')) {
item.addEventListener('click', () => setTimeout(update, 0));
}
cloneAddrInput.addEventListener('input', update);
cloneAddrInput.addEventListener('blur', update);
update();
}