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:
parent
781666b5eb
commit
6ce30aa144
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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"`
|
||||
|
||||
@ -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.",
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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" .}}
|
||||
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user