From c202705dea857624784c1183cd63680ebd09acaf Mon Sep 17 00:00:00 2001 From: Tom Thornton Date: Fri, 8 May 2026 16:29:50 +0100 Subject: [PATCH 1/3] Added github app auth for migrations + mirrors from GitHub --- models/auth/github_app_credential.go | 93 ++++++++++ models/migrations/migrations.go | 3 + models/migrations/v1_27/v332.go | 28 +++ modules/auth/github_app.go | 226 +++++++++++++++++++++++ modules/migration/options.go | 2 + options/locale/locale_en-US.json | 26 +++ routers/web/repo/migrate.go | 61 ++++-- routers/web/user/setting/github_apps.go | 125 +++++++++++++ routers/web/web.go | 6 + services/forms/admin.go | 15 ++ services/forms/repo_form.go | 11 +- services/migrations/github.go | 72 ++++++++ templates/repo/migrate/github.tmpl | 28 ++- templates/user/settings/github_apps.tmpl | 101 ++++++++++ templates/user/settings/navbar.tmpl | 3 + web_src/js/features/repo-migrate.ts | 61 ------ web_src/js/features/repo-migration.ts | 86 +++++++++ web_src/js/index.ts | 4 +- 18 files changed, 863 insertions(+), 88 deletions(-) create mode 100644 models/auth/github_app_credential.go create mode 100644 models/migrations/v1_27/v332.go create mode 100644 modules/auth/github_app.go create mode 100644 routers/web/user/setting/github_apps.go create mode 100644 templates/user/settings/github_apps.tmpl delete mode 100644 web_src/js/features/repo-migrate.ts diff --git a/models/auth/github_app_credential.go b/models/auth/github_app_credential.go new file mode 100644 index 0000000000..1637b038b8 --- /dev/null +++ b/models/auth/github_app_credential.go @@ -0,0 +1,93 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "context" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/timeutil" + "code.gitea.io/gitea/modules/util" + + "xorm.io/builder" +) + +// GithubAppCredential represents a GitHub App credential for migrations +type GithubAppCredential struct { + ID int64 `xorm:"pk autoincr"` + OwnerID int64 `xorm:"INDEX NOT NULL"` + Name string `xorm:"NOT NULL"` + AppID int64 `xorm:"NOT NULL"` + InstallationID int64 `xorm:"NOT NULL"` + PrivateKeyEncrypted string `xorm:"TEXT NOT NULL"` + BaseURL string `xorm:"VARCHAR(255) NOT NULL DEFAULT 'https://api.github.com'"` + CreatedUnix timeutil.TimeStamp `xorm:"created"` + UpdatedUnix timeutil.TimeStamp `xorm:"updated"` +} + +// HasRecentActivity returns true if this credential was used recently +func (g *GithubAppCredential) HasRecentActivity() bool { + // Consider activity within the last 7 days as recent + return timeutil.TimeStampNow()-g.UpdatedUnix < 7*24*3600 +} + +// HasUsed returns true if this credential has been used (updated after creation) +func (g *GithubAppCredential) HasUsed() bool { + return g.UpdatedUnix > g.CreatedUnix +} + +func init() { + db.RegisterModel(new(GithubAppCredential)) +} + +// TableName returns the table name for GithubAppCredential +func (g *GithubAppCredential) TableName() string { + return "github_app_credential" +} + +// CreateGithubAppCredential creates a new GitHub App credential +func CreateGithubAppCredential(ctx context.Context, cred *GithubAppCredential) error { + _, err := db.GetEngine(ctx).Insert(cred) + return err +} + +// GetGithubAppCredentialByID gets a GitHub App credential by ID +func GetGithubAppCredentialByID(ctx context.Context, id int64) (*GithubAppCredential, error) { + cred := &GithubAppCredential{} + has, err := db.GetEngine(ctx).ID(id).Get(cred) + if err != nil { + return nil, err + } + if !has { + return nil, util.ErrNotExist + } + return cred, nil +} + +// GetGithubAppCredentialsByOwnerID gets all GitHub App credentials for an owner +func GetGithubAppCredentialsByOwnerID(ctx context.Context, ownerID int64) ([]*GithubAppCredential, error) { + creds := make([]*GithubAppCredential, 0, 5) + return creds, db.GetEngine(ctx). + Where("owner_id = ?", ownerID). + Find(&creds) +} + +// UpdateGithubAppCredential updates a GitHub App credential +func UpdateGithubAppCredential(ctx context.Context, cred *GithubAppCredential) error { + _, err := db.GetEngine(ctx).ID(cred.ID).AllCols().Update(cred) + return err +} + +// DeleteGithubAppCredential deletes a GitHub App credential +func DeleteGithubAppCredential(ctx context.Context, id int64) error { + _, err := db.GetEngine(ctx).ID(id).Delete(&GithubAppCredential{}) + return err +} + +// CheckGithubAppCredentialOwnership checks if a user owns a GitHub App credential +func CheckGithubAppCredentialOwnership(ctx context.Context, credID, ownerID int64) (bool, error) { + return db.GetEngine(ctx). + Where(builder.Eq{"id": credID, "owner_id": ownerID}). + Exist(&GithubAppCredential{}) +} diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index c3a8f08b5d..1762681e9b 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -409,6 +409,9 @@ func prepareMigrationTasks() []*migration { // Gitea 1.26.0 ends at migration ID number 330 (database version 331) newMigration(331, "Add ActionRunAttempt model and related action fields", v1_27.AddActionRunAttemptModel), + // Gitea 1.27.0 ends at migration ID number 331 (database version 332) + + newMigration(332, "Add GitHub App credential table", v1_27.AddGithubAppCredentialTable), } return preparedMigrations } diff --git a/models/migrations/v1_27/v332.go b/models/migrations/v1_27/v332.go new file mode 100644 index 0000000000..6537e8c588 --- /dev/null +++ b/models/migrations/v1_27/v332.go @@ -0,0 +1,28 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_27 + +import ( + "context" + + "code.gitea.io/gitea/modules/timeutil" + + "xorm.io/xorm" +) + +func AddGithubAppCredentialTable(ctx context.Context, x *xorm.Engine) error { + type GithubAppCredential struct { + ID int64 `xorm:"pk autoincr"` + OwnerID int64 `xorm:"INDEX NOT NULL"` + Name string `xorm:"NOT NULL"` + AppID int64 `xorm:"NOT NULL"` + InstallationID int64 `xorm:"NOT NULL"` + PrivateKeyEncrypted string `xorm:"TEXT NOT NULL"` + BaseURL string `xorm:"VARCHAR(255) NOT NULL DEFAULT 'https://api.github.com'"` + CreatedUnix timeutil.TimeStamp `xorm:"created"` + UpdatedUnix timeutil.TimeStamp `xorm:"updated"` + } + + return x.Sync(new(GithubAppCredential)) +} diff --git a/modules/auth/github_app.go b/modules/auth/github_app.go new file mode 100644 index 0000000000..b56bddce1f --- /dev/null +++ b/modules/auth/github_app.go @@ -0,0 +1,226 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "context" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "fmt" + "net/http" + "strings" + "sync" + "time" + + "code.gitea.io/gitea/modules/log" + + "github.com/golang-jwt/jwt/v5" + "github.com/google/go-github/v84/github" +) + +// GitHubAppTokenCache caches installation access tokens +type GitHubAppTokenCache struct { + mu sync.RWMutex + tokens map[string]*cachedToken +} + +type cachedToken struct { + token string + expiresAt time.Time + httpClient *http.Client +} + +var globalTokenCache = &GitHubAppTokenCache{ + tokens: make(map[string]*cachedToken), +} + +// GenerateGitHubAppJWT generates a JWT for GitHub App authentication +func GenerateGitHubAppJWT(appID int64, privateKeyPEM string) (string, error) { + // Log the private key format for debugging + lines := strings.Split(privateKeyPEM, "\n") + if len(lines) > 0 { + log.Debug("Private key first line: %s", lines[0]) + if len(lines) > 1 { + log.Debug("Private key last line: %s", lines[len(lines)-1]) + } + log.Debug("Private key total lines: %d", len(lines)) + } + + // Parse the private key + block, _ := pem.Decode([]byte(privateKeyPEM)) + if block == nil { + return "", fmt.Errorf("failed to parse PEM block containing the private key") + } + + privateKey, err := x509.ParsePKCS1PrivateKey(block.Bytes) + if err != nil { + // Try PKCS8 format + key, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return "", fmt.Errorf("failed to parse private key: %w", err) + } + var ok bool + privateKey, ok = key.(*rsa.PrivateKey) + if !ok { + return "", fmt.Errorf("private key is not RSA") + } + } + + // Create JWT claims + now := time.Now() + claims := jwt.RegisteredClaims{ + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(now.Add(10 * time.Minute)), // GitHub requires max 10 minutes + Issuer: fmt.Sprintf("%d", appID), + } + + // Create token + token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + + // Sign token + signedToken, err := token.SignedString(privateKey) + if err != nil { + return "", fmt.Errorf("failed to sign JWT: %w", err) + } + + return signedToken, nil +} + +// GetGitHubAppInstallationToken exchanges a JWT for an installation access token +func GetGitHubAppInstallationToken(ctx context.Context, appID, installationID int64, privateKeyPEM, baseURL string, httpTransport *http.Transport) (string, *http.Client, error) { + // Check cache first + cacheKey := fmt.Sprintf("%d:%d:%s", appID, installationID, baseURL) + + globalTokenCache.mu.RLock() + cached, exists := globalTokenCache.tokens[cacheKey] + globalTokenCache.mu.RUnlock() + + if exists && time.Now().Before(cached.expiresAt.Add(-5*time.Minute)) { + // Token is still valid (with 5 minute buffer) + return cached.token, cached.httpClient, nil + } + + // Generate JWT + jwtToken, err := GenerateGitHubAppJWT(appID, privateKeyPEM) + if err != nil { + return "", nil, fmt.Errorf("failed to generate JWT: %w", err) + } + log.Debug("Generated JWT for GitHub App %d (token: %s...%s)", appID, jwtToken[:20], jwtToken[len(jwtToken)-20:]) + + // Create HTTP client with JWT for authentication + jwtHTTPClient := &http.Client{ + Transport: &jwtTransport{ + token: jwtToken, + transport: httpTransport, + }, + } + + // Create GitHub client + var githubClient *github.Client + if baseURL == "" || baseURL == "https://api.github.com" { + githubClient = github.NewClient(jwtHTTPClient) + } else { + githubClient, err = github.NewClient(jwtHTTPClient).WithEnterpriseURLs(baseURL, baseURL) + if err != nil { + return "", nil, fmt.Errorf("failed to create GitHub client: %w", err) + } + } + + // Get installation access token + installationToken, _, err := githubClient.Apps.CreateInstallationToken( + ctx, + installationID, + &github.InstallationTokenOptions{}, + ) + if err != nil { + return "", nil, fmt.Errorf("failed to create installation token: %w", err) + } + + // Create HTTP client with installation token + tokenHTTPClient := &http.Client{ + Transport: &tokenTransport{ + token: installationToken.GetToken(), + transport: httpTransport, + }, + } + + // Cache the token + globalTokenCache.mu.Lock() + globalTokenCache.tokens[cacheKey] = &cachedToken{ + token: installationToken.GetToken(), + expiresAt: installationToken.GetExpiresAt().Time, + httpClient: tokenHTTPClient, + } + globalTokenCache.mu.Unlock() + + log.Debug("Generated new GitHub App installation token for app %d, installation %d (expires at %v)", + appID, installationID, installationToken.GetExpiresAt().Time) + + return installationToken.GetToken(), tokenHTTPClient, nil +} + +// jwtTransport is an http.RoundTripper that adds JWT authentication +type jwtTransport struct { + token string + transport http.RoundTripper +} + +func (t *jwtTransport) RoundTrip(req *http.Request) (*http.Response, error) { + // GitHub requires "Bearer" prefix for JWT authentication + req.Header.Set("Authorization", "Bearer "+t.token) + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + + log.Debug("GitHub App JWT request: %s %s", req.Method, req.URL.String()) + + transport := t.transport + if transport == nil { + transport = http.DefaultTransport + } + + resp, err := transport.RoundTrip(req) + if err != nil { + log.Error("GitHub App JWT request failed: %v", err) + return resp, err + } + + log.Debug("GitHub App JWT response: %d %s", resp.StatusCode, resp.Status) + return resp, nil +} + +// tokenTransport is an http.RoundTripper that adds token authentication +type tokenTransport struct { + token string + transport http.RoundTripper +} + +func (t *tokenTransport) RoundTrip(req *http.Request) (*http.Response, error) { + req.Header.Set("Authorization", "token "+t.token) + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + + log.Debug("GitHub App token request: %s %s (token: %s...%s)", req.Method, req.URL.String(), t.token[:20], t.token[len(t.token)-20:]) + + transport := t.transport + if transport == nil { + transport = http.DefaultTransport + } + + resp, err := transport.RoundTrip(req) + if err != nil { + log.Error("GitHub App token request failed: %v", err) + return resp, err + } + + log.Debug("GitHub App token response: %d %s", resp.StatusCode, resp.Status) + return resp, nil +} + +// ClearTokenCache clears the token cache (useful for testing) +func ClearTokenCache() { + globalTokenCache.mu.Lock() + defer globalTokenCache.mu.Unlock() + globalTokenCache.tokens = make(map[string]*cachedToken) +} diff --git a/modules/migration/options.go b/modules/migration/options.go index 163aa0cfaa..53e28dde49 100644 --- a/modules/migration/options.go +++ b/modules/migration/options.go @@ -17,6 +17,8 @@ type MigrateOptions struct { AuthPasswordEncrypted string `json:"auth_password_encrypted,omitempty"` AuthToken string `json:"-"` AuthTokenEncrypted string `json:"auth_token_encrypted,omitempty"` + // GitHub App authentication + GitHubAppCredentialID int64 `json:"github_app_credential_id"` // required: true UID int `json:"uid" binding:"Required"` // required: true diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index c7ec133e57..3bf6374d4e 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -657,6 +657,25 @@ "settings.ssh_gpg_keys": "SSH / GPG Keys", "settings.social": "Social Accounts", "settings.applications": "Applications", + "settings.github_apps": "GitHub Apps", + "settings.manage_github_apps": "Manage GitHub App Credentials", + "settings.github_app_desc": "GitHub App credentials can be used for repository migrations with enhanced security and better rate limits.", + "settings.github_app_name": "Credential Name", + "settings.github_app_id": "App ID / Client ID", + "settings.github_app_id_helper": "The GitHub App ID / Client ID from your app settings", + "settings.github_app_installation_id": "Installation ID", + "settings.github_app_installation_id_helper": "The Installation ID from your app installation", + "settings.github_app_private_key": "Private Key (PEM format)", + "settings.github_app_private_key_helper": "The private key generated for your GitHub App", + "settings.github_app_base_url": "Base URL", + "settings.github_app_base_url_helper": "For GitHub Enterprise Server, use your instance API URL (e.g., https://github.example.com/api/v3). Leave empty for GitHub.com.", + "settings.add_github_app": "Add GitHub App Credential", + "settings.add_github_app_success": "GitHub App credential has been added successfully.", + "settings.delete_github_app": "Delete Credential", + "settings.delete_github_app_success": "GitHub App credential has been deleted successfully.", + "settings.github_app_deletion": "GitHub App Credential Deletion", + "settings.github_app_deletion_desc": "Deleting a GitHub App credential will prevent it from being used for future migrations. This action cannot be undone. Continue?", + "settings.github_app_state_desc": "This credential has been used recently", "settings.orgs": "Manage Organizations", "settings.repos": "Repositories", "settings.delete": "Delete Account", @@ -1107,6 +1126,13 @@ "repo.migrate_repo": "Migrate Repository", "repo.migrate.clone_address": "Migrate / Clone From URL", "repo.migrate.clone_address_desc": "The HTTP(S) or Git 'clone' URL of an existing repository", + "repo.migrate.auth_method": "Authentication Method", + "repo.migrate.auth_method_token": "Personal Access Token", + "repo.migrate.auth_method_github_app": "GitHub App", + "repo.migrate.github_app_credential": "GitHub App Credential", + "repo.migrate.github_app_credential_select": "Select a credential", + "repo.migrate.github_app_credential_helper": "Use a GitHub App for authentication with enhanced security and better rate limits.", + "repo.migrate.github_app_credential_manage": "Manage credentials", "repo.migrate.github_token_desc": "You can put one or more tokens here, separated by commas, to make migrating faster by circumventing the GitHub API rate limit. WARNING: Abusing this feature may violate the service provider's policy and may lead to getting your account(s) blocked.", "repo.migrate.clone_local_path": "or a local server path", "repo.migrate.permission_denied": "You are not allowed to import local repositories.", diff --git a/routers/web/repo/migrate.go b/routers/web/repo/migrate.go index bb6f1e6b7e..4beb85c303 100644 --- a/routers/web/repo/migrate.go +++ b/routers/web/repo/migrate.go @@ -10,6 +10,7 @@ import ( "strings" admin_model "code.gitea.io/gitea/models/admin" + auth_model "code.gitea.io/gitea/models/auth" "code.gitea.io/gitea/models/db" repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" @@ -68,6 +69,17 @@ func Migrate(ctx *context.Context) { } ctx.Data["ContextUser"] = ctxUser + // Load GitHub App credentials for GitHub migrations + if serviceType == structs.GithubService && ctx.Doer != nil { + credentials, err := auth_model.GetGithubAppCredentialsByOwnerID(ctx, ctx.Doer.ID) + if err != nil { + log.Error("Failed to load GitHub App credentials: %v", err) + } else { + ctx.Data["GitHubAppCredentials"] = credentials + log.Debug("Loaded %d GitHub App credentials for user %d", len(credentials), ctx.Doer.ID) + } + } + ctx.HTML(http.StatusOK, templates.TplName("repo/migrate/"+serviceType.Name())) } @@ -205,25 +217,26 @@ func MigratePost(ctx *context.Context) { } opts := migrations.MigrateOptions{ - OriginalURL: form.CloneAddr, - GitServiceType: form.Service, - CloneAddr: remoteAddr, - RepoName: form.RepoName, - Description: form.Description, - Private: form.Private || setting.Repository.ForcePrivate, - Mirror: form.Mirror, - LFS: form.LFS, - LFSEndpoint: form.LFSEndpoint, - AuthUsername: form.AuthUsername, - AuthPassword: form.AuthPassword, - AuthToken: form.AuthToken, - Wiki: form.Wiki, - Issues: form.Issues, - Milestones: form.Milestones, - Labels: form.Labels, - Comments: form.Issues || form.PullRequests, - PullRequests: form.PullRequests, - Releases: form.Releases, + OriginalURL: form.CloneAddr, + GitServiceType: form.Service, + CloneAddr: remoteAddr, + RepoName: form.RepoName, + Description: form.Description, + Private: form.Private || setting.Repository.ForcePrivate, + Mirror: form.Mirror, + LFS: form.LFS, + LFSEndpoint: form.LFSEndpoint, + AuthUsername: form.AuthUsername, + AuthPassword: form.AuthPassword, + AuthToken: form.AuthToken, + GitHubAppCredentialID: form.GitHubAppCredentialID, + Wiki: form.Wiki, + Issues: form.Issues, + Milestones: form.Milestones, + Labels: form.Labels, + Comments: form.Issues || form.PullRequests, + PullRequests: form.PullRequests, + Releases: form.Releases, } if opts.Mirror { opts.Issues = false @@ -263,6 +276,16 @@ func setMigrationContextData(ctx *context.Context, serviceType structs.GitServic // Plain git should be first ctx.Data["Services"] = append([]structs.GitServiceType{structs.PlainGitService}, structs.SupportedFullGitService...) ctx.Data["service"] = serviceType + + // Load GitHub App credentials for the current user if viewing GitHub migration + if serviceType == structs.GithubService && ctx.Doer != nil { + credentials, err := auth_model.GetGithubAppCredentialsByOwnerID(ctx, ctx.Doer.ID) + if err != nil { + log.Error("Failed to load GitHub App credentials: %v", err) + } else { + ctx.Data["GitHubAppCredentials"] = credentials + } + } } func MigrateRetryPost(ctx *context.Context) { diff --git a/routers/web/user/setting/github_apps.go b/routers/web/user/setting/github_apps.go new file mode 100644 index 0000000000..ef1b43801f --- /dev/null +++ b/routers/web/user/setting/github_apps.go @@ -0,0 +1,125 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "net/http" + "strings" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/modules/secret" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/templates" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/forms" +) + +const ( + tplSettingsGitHubApps templates.TplName = "user/settings/github_apps" +) + +// GitHubApps renders the GitHub App credentials management page +func GitHubApps(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings.github_apps") + ctx.Data["PageIsSettingsGitHubApps"] = true + + loadGitHubAppsData(ctx) + + ctx.HTML(http.StatusOK, tplSettingsGitHubApps) +} + +// GitHubAppsPost handles adding a new GitHub App credential +func GitHubAppsPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.NewGitHubAppCredentialForm) + ctx.Data["Title"] = ctx.Tr("settings.github_apps") + ctx.Data["PageIsSettingsGitHubApps"] = true + + if ctx.HasError() { + loadGitHubAppsData(ctx) + ctx.HTML(http.StatusOK, tplSettingsGitHubApps) + return + } + + // Normalize and validate the private key + privateKey := strings.TrimSpace(form.PrivateKey) + + // Ensure the private key has proper PEM format + if !strings.HasPrefix(privateKey, "-----BEGIN") { + ctx.Flash.Error(ctx.Tr("settings.github_app_invalid_private_key")) + loadGitHubAppsData(ctx) + ctx.HTML(http.StatusOK, tplSettingsGitHubApps) + return + } + + if !strings.HasSuffix(privateKey, "-----") { + ctx.Flash.Error(ctx.Tr("settings.github_app_invalid_private_key")) + loadGitHubAppsData(ctx) + ctx.HTML(http.StatusOK, tplSettingsGitHubApps) + return + } + + // Encrypt the private key + encryptedKey, err := secret.EncryptSecret(setting.SecretKey, privateKey) + if err != nil { + ctx.ServerError("EncryptSecret", err) + return + } + + // Set default base URL if empty + baseURL := form.BaseURL + if baseURL == "" { + baseURL = "https://api.github.com" + } + + cred := &auth_model.GithubAppCredential{ + OwnerID: ctx.Doer.ID, + Name: form.Name, + AppID: form.AppID, + InstallationID: form.InstallationID, + PrivateKeyEncrypted: encryptedKey, + BaseURL: baseURL, + } + + if err := auth_model.CreateGithubAppCredential(ctx, cred); err != nil { + ctx.ServerError("CreateGithubAppCredential", err) + return + } + + ctx.Flash.Success(ctx.Tr("settings.add_github_app_success")) + ctx.Redirect(setting.AppSubURL + "/user/settings/github_apps") +} + +// DeleteGitHubApp handles deleting a GitHub App credential +func DeleteGitHubApp(ctx *context.Context) { + id := ctx.FormInt64("id") + + // Check ownership + owned, err := auth_model.CheckGithubAppCredentialOwnership(ctx, id, ctx.Doer.ID) + if err != nil { + ctx.ServerError("CheckGithubAppCredentialOwnership", err) + return + } + if !owned { + ctx.NotFound(nil) + return + } + + if err := auth_model.DeleteGithubAppCredential(ctx, id); err != nil { + ctx.Flash.Error("DeleteGithubAppCredential: " + err.Error()) + } else { + ctx.Flash.Success(ctx.Tr("settings.delete_github_app_success")) + } + + ctx.JSONRedirect(setting.AppSubURL + "/user/settings/github_apps") +} + +func loadGitHubAppsData(ctx *context.Context) { + creds, err := auth_model.GetGithubAppCredentialsByOwnerID(ctx, ctx.Doer.ID) + if err != nil { + ctx.ServerError("GetGithubAppCredentialsByOwnerID", err) + return + } + ctx.Data["Credentials"] = creds +} diff --git a/routers/web/web.go b/routers/web/web.go index ecd75250d2..676a692fb1 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -670,6 +670,12 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { m.Post("/delete", user_setting.DeleteApplication) }) + m.Group("/github_apps", func() { + m.Combo("").Get(user_setting.GitHubApps). + Post(web.Bind(forms.NewGitHubAppCredentialForm{}), user_setting.GitHubAppsPost) + m.Post("/delete", user_setting.DeleteGitHubApp) + }) + m.Combo("/keys").Get(user_setting.Keys). Post(web.Bind(forms.AddKeyForm{}), user_setting.KeysPost) m.Post("/keys/delete", user_setting.DeleteKey) diff --git a/services/forms/admin.go b/services/forms/admin.go index c90ddf7d54..c68ecb23e9 100644 --- a/services/forms/admin.go +++ b/services/forms/admin.go @@ -96,3 +96,18 @@ func (f *AdminDashboardForm) Validate(req *http.Request, errs binding.Errors) bi ctx := context.GetValidateContext(req) return middleware.Validate(errs, ctx.Data, f, ctx.Locale) } + +// NewGitHubAppCredentialForm form for creating GitHub App credential +type NewGitHubAppCredentialForm struct { + Name string `binding:"Required;MaxSize(255)" locale:"settings.github_app_name"` + AppID int64 `binding:"Required" locale:"settings.github_app_id"` + InstallationID int64 `binding:"Required" locale:"settings.github_app_installation_id"` + PrivateKey string `binding:"Required" locale:"settings.github_app_private_key"` + BaseURL string `binding:"MaxSize(255)" locale:"settings.github_app_base_url"` +} + +// Validate validates the fields +func (f *NewGitHubAppCredentialForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { + ctx := context.GetValidateContext(req) + return middleware.Validate(errs, ctx.Data, f, ctx.Locale) +} diff --git a/services/forms/repo_form.go b/services/forms/repo_form.go index d8e019f860..96eceec89d 100644 --- a/services/forms/repo_form.go +++ b/services/forms/repo_form.go @@ -57,11 +57,12 @@ func (f *CreateRepoForm) Validate(req *http.Request, errs binding.Errors) bindin // this is used to interact with web ui type MigrateRepoForm struct { // required: true - CloneAddr string `json:"clone_addr" binding:"Required"` - Service structs.GitServiceType `json:"service"` - AuthUsername string `json:"auth_username"` - AuthPassword string `json:"auth_password"` - AuthToken string `json:"auth_token"` + CloneAddr string `json:"clone_addr" binding:"Required"` + Service structs.GitServiceType `json:"service"` + AuthUsername string `json:"auth_username"` + AuthPassword string `json:"auth_password"` + AuthToken string `json:"auth_token"` + GitHubAppCredentialID int64 `json:"github_app_credential_id" form:"github_app_credential_id"` // required: true UID int64 `json:"uid" binding:"Required"` // required: true diff --git a/services/migrations/github.go b/services/migrations/github.go index 87744fef6c..5ff090ac9e 100644 --- a/services/migrations/github.go +++ b/services/migrations/github.go @@ -14,10 +14,14 @@ import ( "strings" "time" + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/modules/auth" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/log" base "code.gitea.io/gitea/modules/migration" "code.gitea.io/gitea/modules/proxy" + "code.gitea.io/gitea/modules/secret" + "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/structs" "github.com/google/go-github/v84/github" @@ -52,6 +56,12 @@ func (f *GithubDownloaderV3Factory) New(ctx context.Context, opts base.MigrateOp log.Trace("Create github downloader BaseURL: %s %s/%s", baseURL, oldOwner, oldName) + // Check if GitHub App authentication is requested + if opts.GitHubAppCredentialID > 0 { + log.Info("Using GitHub App authentication with credential ID: %d", opts.GitHubAppCredentialID) + return NewGithubDownloaderV3WithApp(ctx, baseURL, opts.GitHubAppCredentialID, oldOwner, oldName) + } + return NewGithubDownloaderV3(ctx, baseURL, opts.AuthUsername, opts.AuthPassword, opts.AuthToken, oldOwner, oldName), nil } @@ -118,6 +128,68 @@ func NewGithubDownloaderV3(_ context.Context, baseURL, userName, password, token return &downloader } +// NewGithubDownloaderV3WithApp creates a github Downloader using GitHub App authentication +func NewGithubDownloaderV3WithApp(ctx context.Context, baseURL string, credentialID int64, repoOwner, repoName string) (*GithubDownloaderV3, error) { + // Get the GitHub App credential from database + cred, err := auth_model.GetGithubAppCredentialByID(ctx, credentialID) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub App credential: %w", err) + } + + // Decrypt the private key + privateKey, err := secret.DecryptSecret(setting.SecretKey, cred.PrivateKeyEncrypted) + if err != nil { + return nil, fmt.Errorf("failed to decrypt private key: %w", err) + } + + // Determine base URL for API + apiBaseURL := cred.BaseURL + if apiBaseURL == "" { + apiBaseURL = "https://api.github.com" + } + + // Get installation access token + token, _, err := auth.GetGitHubAppInstallationToken( + ctx, + cred.AppID, + cred.InstallationID, + privateKey, + apiBaseURL, + NewMigrationHTTPTransport(), + ) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub App installation token: %w", err) + } + + downloader := &GithubDownloaderV3{ + baseURL: baseURL, + repoOwner: repoOwner, + repoName: repoName, + maxPerPage: 100, + } + + // Use the token directly with go-github's built-in authentication + // Create a simple HTTP client with the migration transport + httpClient := &http.Client{ + Transport: NewMigrationHTTPTransport(), + } + + // Create GitHub client and set the auth token + githubClient := github.NewClient(httpClient).WithAuthToken(token) + + if baseURL != "https://github.com" && baseURL != "https://api.github.com" { + githubClient, err = githubClient.WithEnterpriseURLs(baseURL, baseURL) + if err != nil { + return nil, fmt.Errorf("failed to set enterprise URLs: %w", err) + } + } + + downloader.clients = append(downloader.clients, githubClient) + downloader.rates = append(downloader.rates, nil) + + return downloader, nil +} + // String implements Stringer func (g *GithubDownloaderV3) String() string { return fmt.Sprintf("migration from github server %s %s/%s", g.baseURL, g.repoOwner, g.repoName) diff --git a/templates/repo/migrate/github.tmpl b/templates/repo/migrate/github.tmpl index 87a95d3ce4..6c36d7859c 100644 --- a/templates/repo/migrate/github.tmpl +++ b/templates/repo/migrate/github.tmpl @@ -18,7 +18,20 @@ -
+
+ + +
+ +
{{svg "octicon-question"}} @@ -27,6 +40,19 @@
+ + {{template "repo/migrate/options" .}}
diff --git a/templates/user/settings/github_apps.tmpl b/templates/user/settings/github_apps.tmpl new file mode 100644 index 0000000000..e545431866 --- /dev/null +++ b/templates/user/settings/github_apps.tmpl @@ -0,0 +1,101 @@ +{{template "user/settings/layout_head" (dict "pageClass" "user settings github-apps")}} +
+

+ {{ctx.Locale.Tr "settings.manage_github_apps"}} +

+
+
+
+ {{ctx.Locale.Tr "settings.github_app_desc"}} +
+ {{range .Credentials}} +
+
+ + {{svg "octicon-key" 32}} + +
+
+
+ {{.Name}} +

+ {{ctx.Locale.Tr "settings.github_app_id"}}: {{.AppID}} +

+

+ {{ctx.Locale.Tr "settings.github_app_installation_id"}}: {{.InstallationID}} +

+

+ {{ctx.Locale.Tr "settings.github_app_base_url"}}: {{.BaseURL}} +

+
+
+ {{ctx.Locale.Tr "settings.added_on" (DateUtils.AbsoluteShort .CreatedUnix)}} — {{svg "octicon-info"}} {{if .HasUsed}}{{ctx.Locale.Tr "settings.last_used"}} {{DateUtils.AbsoluteShort .UpdatedUnix}}{{else}}{{ctx.Locale.Tr "settings.no_activity"}}{{end}} +
+
+
+ +
+
+ {{end}} +
+
+
+
+

{{ctx.Locale.Tr "settings.add_github_app"}}

+
+ {{.CsrfTokenHtml}} +
+ + +
+
+ + + + {{ctx.Locale.Tr "settings.github_app_id_helper"}} + +
+
+ + + + {{ctx.Locale.Tr "settings.github_app_installation_id_helper"}} + +
+
+ + + + {{ctx.Locale.Tr "settings.github_app_private_key_helper"}} + +
+
+ + + + {{ctx.Locale.Tr "settings.github_app_base_url_helper"}} + +
+ +
+
+
+
+ + + +{{template "user/settings/layout_footer" .}} diff --git a/templates/user/settings/navbar.tmpl b/templates/user/settings/navbar.tmpl index e2bc72ca5f..cf5b443d8d 100644 --- a/templates/user/settings/navbar.tmpl +++ b/templates/user/settings/navbar.tmpl @@ -28,6 +28,9 @@ {{ctx.Locale.Tr "settings.applications"}} + + {{ctx.Locale.Tr "settings.github_apps"}} + {{if not ($.UserDisabledFeatures.Contains "manage_ssh_keys" "manage_gpg_keys")}} {{ctx.Locale.Tr "settings.ssh_gpg_keys"}} diff --git a/web_src/js/features/repo-migrate.ts b/web_src/js/features/repo-migrate.ts deleted file mode 100644 index 556f049246..0000000000 --- a/web_src/js/features/repo-migrate.ts +++ /dev/null @@ -1,61 +0,0 @@ -import {hideElem, showElem} from '../utils/dom.ts'; -import {GET, POST} from '../modules/fetch.ts'; - -export function initRepoMigrationStatusChecker() { - const repoMigrating = document.querySelector('#repo_migrating'); - if (!repoMigrating) return; - - document.querySelector('#repo_migrating_retry')?.addEventListener('click', doMigrationRetry); - - const repoLink = repoMigrating.getAttribute('data-migrating-repo-link'); - - // returns true if the refresh still needs to be called after a while - const refresh = async () => { - const res = await GET(`${repoLink}/-/migrate/status`); - if (res.status !== 200) return true; // continue to refresh if network error occurs - - const data = await res.json(); - - // for all status - if (data.message) { - document.querySelector('#repo_migrating_progress_message')!.textContent = data.message; - } - - // TaskStatusFinished - if (data.status === 4) { - window.location.reload(); - return false; - } - - // TaskStatusFailed - if (data.status === 3) { - hideElem('#repo_migrating_progress'); - hideElem('#repo_migrating'); - showElem('#repo_migrating_retry'); - showElem('#repo_migrating_failed'); - showElem('#repo_migrating_failed_image'); - document.querySelector('#repo_migrating_failed_error')!.textContent = data.message; - return false; - } - - return true; // continue to refresh - }; - - const syncTaskStatus = async () => { - let doNextRefresh = true; - try { - doNextRefresh = await refresh(); - } finally { - if (doNextRefresh) { - setTimeout(syncTaskStatus, 2000); - } - } - }; - - syncTaskStatus(); // no await -} - -async function doMigrationRetry(e: Event) { - await POST((e.target as HTMLElement).getAttribute('data-migrating-task-retry-url')!); - window.location.reload(); -} diff --git a/web_src/js/features/repo-migration.ts b/web_src/js/features/repo-migration.ts index 7376ff7fa4..2a4c00c7e9 100644 --- a/web_src/js/features/repo-migration.ts +++ b/web_src/js/features/repo-migration.ts @@ -1,4 +1,5 @@ import {hideElem, showElem, toggleElem} from '../utils/dom.ts'; +import {GET, POST} from '../modules/fetch.ts'; import {sanitizeRepoName} from './repo-common.ts'; const service = document.querySelector('#service_type'); @@ -78,3 +79,88 @@ function setLFSSettingsVisibility() { toggleElem(lfsSettings, visible); hideElem(lfsEndpoint); } + +export function initRepoMigrate() { + const authMethodDropdown = document.querySelector('input[name="auth_method"]'); + if (!authMethodDropdown) return; + + const authTokenField = document.querySelector('#auth_token_field'); + const githubAppField = document.querySelector('#github_app_field'); + + function updateAuthFields() { + const authMethod = authMethodDropdown?.value || 'token'; + + if (authMethod === 'github_app') { + authTokenField?.style.setProperty('display', 'none'); + githubAppField?.style.removeProperty('display'); + } else { + authTokenField?.style.removeProperty('display'); + githubAppField?.style.setProperty('display', 'none'); + } + } + + // Initialize on page load + updateAuthFields(); + + // Listen for changes + authMethodDropdown.addEventListener('change', updateAuthFields); +} + +export function initRepoMigrationStatusChecker() { + const repoMigrating = document.querySelector('#repo_migrating'); + if (!repoMigrating) return; + + document.querySelector('#repo_migrating_retry')?.addEventListener('click', doMigrationRetry); + + const repoLink = repoMigrating.getAttribute('data-migrating-repo-link'); + + // returns true if the refresh still needs to be called after a while + const refresh = async () => { + const res = await GET(`${repoLink}/-/migrate/status`); + if (res.status !== 200) return true; // continue to refresh if network error occurs + + const data = await res.json(); + + // for all status + if (data.message) { + document.querySelector('#repo_migrating_progress_message')!.textContent = data.message; + } + + // TaskStatusFinished + if (data.status === 4) { + window.location.reload(); + return false; + } + + // TaskStatusFailed + if (data.status === 3) { + hideElem('#repo_migrating_progress'); + hideElem('#repo_migrating'); + showElem('#repo_migrating_retry'); + showElem('#repo_migrating_failed'); + showElem('#repo_migrating_failed_image'); + document.querySelector('#repo_migrating_failed_error')!.textContent = data.message; + return false; + } + + return true; // continue to refresh + }; + + const syncTaskStatus = async () => { + let doNextRefresh = true; + try { + doNextRefresh = await refresh(); + } finally { + if (doNextRefresh) { + setTimeout(syncTaskStatus, 2000); + } + } + }; + + syncTaskStatus(); // no await +} + +async function doMigrationRetry(e: Event) { + await POST((e.target as HTMLElement).getAttribute('data-migrating-task-retry-url')!); + window.location.reload(); +} diff --git a/web_src/js/index.ts b/web_src/js/index.ts index cb2b56a5bd..0cc236ddf8 100644 --- a/web_src/js/index.ts +++ b/web_src/js/index.ts @@ -6,7 +6,7 @@ import {initGlobalCopyToClipboardListener} from './features/clipboard.ts'; import {initRepoGraphGit} from './features/repo-graph.ts'; import {initHeatmap} from './features/heatmap.ts'; import {initImageDiff} from './features/imagediff.ts'; -import {initRepoMigration} from './features/repo-migration.ts'; +import {initRepoMigration, initRepoMigrate, initRepoMigrationStatusChecker} from './features/repo-migration.ts'; import {initRepoProjectsView} from './features/repo-projects.ts'; import {initTableSort} from './features/tablesort.ts'; import {initAdminUserListSearchForm} from './features/admin/users.ts'; @@ -27,7 +27,6 @@ import {initRepoCodeView} from './features/repo-code.ts'; import {initSshKeyFormParser} from './features/sshkey-helper.ts'; import {initUserSettings} from './features/user-settings.ts'; import {initRepoActivityTopAuthorsChart, initRepoArchiveLinks} from './features/repo-common.ts'; -import {initRepoMigrationStatusChecker} from './features/repo-migrate.ts'; import {initRepoDiffView} from './features/repo-diff.ts'; import {initOrgTeam} from './features/org-team.ts'; import {initUserAuthWebAuthn, initUserAuthWebAuthnRegister} from './features/user-auth-webauthn.ts'; @@ -130,6 +129,7 @@ const initPerformanceTracer = callInitFunctions([ initRepoIssueList, initRepoIssueFilterItemLabel, initRepoMigration, + initRepoMigrate, initRepoMigrationStatusChecker, initRepoProjectsView, initRepoPullRequestReview, From f18a93a9cc809fb55b27f5edcf7c0b5dc31293e1 Mon Sep 17 00:00:00 2001 From: Tom Thornton Date: Fri, 8 May 2026 16:33:40 +0100 Subject: [PATCH 2/3] feat: Added github app auth for migrations + mirrors from GitHub --- models/auth/github_app_credential.go | 93 ++++++++++ models/migrations/migrations.go | 3 + models/migrations/v1_27/v332.go | 28 +++ modules/auth/github_app.go | 226 +++++++++++++++++++++++ modules/migration/options.go | 2 + options/locale/locale_en-US.json | 26 +++ routers/web/repo/migrate.go | 61 ++++-- routers/web/user/setting/github_apps.go | 125 +++++++++++++ routers/web/web.go | 6 + services/forms/admin.go | 15 ++ services/forms/repo_form.go | 11 +- services/migrations/github.go | 72 ++++++++ templates/repo/migrate/github.tmpl | 28 ++- templates/user/settings/github_apps.tmpl | 101 ++++++++++ templates/user/settings/navbar.tmpl | 3 + web_src/js/features/repo-migrate.ts | 61 ------ web_src/js/features/repo-migration.ts | 86 +++++++++ web_src/js/index.ts | 4 +- 18 files changed, 863 insertions(+), 88 deletions(-) create mode 100644 models/auth/github_app_credential.go create mode 100644 models/migrations/v1_27/v332.go create mode 100644 modules/auth/github_app.go create mode 100644 routers/web/user/setting/github_apps.go create mode 100644 templates/user/settings/github_apps.tmpl delete mode 100644 web_src/js/features/repo-migrate.ts diff --git a/models/auth/github_app_credential.go b/models/auth/github_app_credential.go new file mode 100644 index 0000000000..1637b038b8 --- /dev/null +++ b/models/auth/github_app_credential.go @@ -0,0 +1,93 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "context" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/timeutil" + "code.gitea.io/gitea/modules/util" + + "xorm.io/builder" +) + +// GithubAppCredential represents a GitHub App credential for migrations +type GithubAppCredential struct { + ID int64 `xorm:"pk autoincr"` + OwnerID int64 `xorm:"INDEX NOT NULL"` + Name string `xorm:"NOT NULL"` + AppID int64 `xorm:"NOT NULL"` + InstallationID int64 `xorm:"NOT NULL"` + PrivateKeyEncrypted string `xorm:"TEXT NOT NULL"` + BaseURL string `xorm:"VARCHAR(255) NOT NULL DEFAULT 'https://api.github.com'"` + CreatedUnix timeutil.TimeStamp `xorm:"created"` + UpdatedUnix timeutil.TimeStamp `xorm:"updated"` +} + +// HasRecentActivity returns true if this credential was used recently +func (g *GithubAppCredential) HasRecentActivity() bool { + // Consider activity within the last 7 days as recent + return timeutil.TimeStampNow()-g.UpdatedUnix < 7*24*3600 +} + +// HasUsed returns true if this credential has been used (updated after creation) +func (g *GithubAppCredential) HasUsed() bool { + return g.UpdatedUnix > g.CreatedUnix +} + +func init() { + db.RegisterModel(new(GithubAppCredential)) +} + +// TableName returns the table name for GithubAppCredential +func (g *GithubAppCredential) TableName() string { + return "github_app_credential" +} + +// CreateGithubAppCredential creates a new GitHub App credential +func CreateGithubAppCredential(ctx context.Context, cred *GithubAppCredential) error { + _, err := db.GetEngine(ctx).Insert(cred) + return err +} + +// GetGithubAppCredentialByID gets a GitHub App credential by ID +func GetGithubAppCredentialByID(ctx context.Context, id int64) (*GithubAppCredential, error) { + cred := &GithubAppCredential{} + has, err := db.GetEngine(ctx).ID(id).Get(cred) + if err != nil { + return nil, err + } + if !has { + return nil, util.ErrNotExist + } + return cred, nil +} + +// GetGithubAppCredentialsByOwnerID gets all GitHub App credentials for an owner +func GetGithubAppCredentialsByOwnerID(ctx context.Context, ownerID int64) ([]*GithubAppCredential, error) { + creds := make([]*GithubAppCredential, 0, 5) + return creds, db.GetEngine(ctx). + Where("owner_id = ?", ownerID). + Find(&creds) +} + +// UpdateGithubAppCredential updates a GitHub App credential +func UpdateGithubAppCredential(ctx context.Context, cred *GithubAppCredential) error { + _, err := db.GetEngine(ctx).ID(cred.ID).AllCols().Update(cred) + return err +} + +// DeleteGithubAppCredential deletes a GitHub App credential +func DeleteGithubAppCredential(ctx context.Context, id int64) error { + _, err := db.GetEngine(ctx).ID(id).Delete(&GithubAppCredential{}) + return err +} + +// CheckGithubAppCredentialOwnership checks if a user owns a GitHub App credential +func CheckGithubAppCredentialOwnership(ctx context.Context, credID, ownerID int64) (bool, error) { + return db.GetEngine(ctx). + Where(builder.Eq{"id": credID, "owner_id": ownerID}). + Exist(&GithubAppCredential{}) +} diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index c3a8f08b5d..1762681e9b 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -409,6 +409,9 @@ func prepareMigrationTasks() []*migration { // Gitea 1.26.0 ends at migration ID number 330 (database version 331) newMigration(331, "Add ActionRunAttempt model and related action fields", v1_27.AddActionRunAttemptModel), + // Gitea 1.27.0 ends at migration ID number 331 (database version 332) + + newMigration(332, "Add GitHub App credential table", v1_27.AddGithubAppCredentialTable), } return preparedMigrations } diff --git a/models/migrations/v1_27/v332.go b/models/migrations/v1_27/v332.go new file mode 100644 index 0000000000..6537e8c588 --- /dev/null +++ b/models/migrations/v1_27/v332.go @@ -0,0 +1,28 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_27 + +import ( + "context" + + "code.gitea.io/gitea/modules/timeutil" + + "xorm.io/xorm" +) + +func AddGithubAppCredentialTable(ctx context.Context, x *xorm.Engine) error { + type GithubAppCredential struct { + ID int64 `xorm:"pk autoincr"` + OwnerID int64 `xorm:"INDEX NOT NULL"` + Name string `xorm:"NOT NULL"` + AppID int64 `xorm:"NOT NULL"` + InstallationID int64 `xorm:"NOT NULL"` + PrivateKeyEncrypted string `xorm:"TEXT NOT NULL"` + BaseURL string `xorm:"VARCHAR(255) NOT NULL DEFAULT 'https://api.github.com'"` + CreatedUnix timeutil.TimeStamp `xorm:"created"` + UpdatedUnix timeutil.TimeStamp `xorm:"updated"` + } + + return x.Sync(new(GithubAppCredential)) +} diff --git a/modules/auth/github_app.go b/modules/auth/github_app.go new file mode 100644 index 0000000000..b56bddce1f --- /dev/null +++ b/modules/auth/github_app.go @@ -0,0 +1,226 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "context" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "fmt" + "net/http" + "strings" + "sync" + "time" + + "code.gitea.io/gitea/modules/log" + + "github.com/golang-jwt/jwt/v5" + "github.com/google/go-github/v84/github" +) + +// GitHubAppTokenCache caches installation access tokens +type GitHubAppTokenCache struct { + mu sync.RWMutex + tokens map[string]*cachedToken +} + +type cachedToken struct { + token string + expiresAt time.Time + httpClient *http.Client +} + +var globalTokenCache = &GitHubAppTokenCache{ + tokens: make(map[string]*cachedToken), +} + +// GenerateGitHubAppJWT generates a JWT for GitHub App authentication +func GenerateGitHubAppJWT(appID int64, privateKeyPEM string) (string, error) { + // Log the private key format for debugging + lines := strings.Split(privateKeyPEM, "\n") + if len(lines) > 0 { + log.Debug("Private key first line: %s", lines[0]) + if len(lines) > 1 { + log.Debug("Private key last line: %s", lines[len(lines)-1]) + } + log.Debug("Private key total lines: %d", len(lines)) + } + + // Parse the private key + block, _ := pem.Decode([]byte(privateKeyPEM)) + if block == nil { + return "", fmt.Errorf("failed to parse PEM block containing the private key") + } + + privateKey, err := x509.ParsePKCS1PrivateKey(block.Bytes) + if err != nil { + // Try PKCS8 format + key, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return "", fmt.Errorf("failed to parse private key: %w", err) + } + var ok bool + privateKey, ok = key.(*rsa.PrivateKey) + if !ok { + return "", fmt.Errorf("private key is not RSA") + } + } + + // Create JWT claims + now := time.Now() + claims := jwt.RegisteredClaims{ + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(now.Add(10 * time.Minute)), // GitHub requires max 10 minutes + Issuer: fmt.Sprintf("%d", appID), + } + + // Create token + token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + + // Sign token + signedToken, err := token.SignedString(privateKey) + if err != nil { + return "", fmt.Errorf("failed to sign JWT: %w", err) + } + + return signedToken, nil +} + +// GetGitHubAppInstallationToken exchanges a JWT for an installation access token +func GetGitHubAppInstallationToken(ctx context.Context, appID, installationID int64, privateKeyPEM, baseURL string, httpTransport *http.Transport) (string, *http.Client, error) { + // Check cache first + cacheKey := fmt.Sprintf("%d:%d:%s", appID, installationID, baseURL) + + globalTokenCache.mu.RLock() + cached, exists := globalTokenCache.tokens[cacheKey] + globalTokenCache.mu.RUnlock() + + if exists && time.Now().Before(cached.expiresAt.Add(-5*time.Minute)) { + // Token is still valid (with 5 minute buffer) + return cached.token, cached.httpClient, nil + } + + // Generate JWT + jwtToken, err := GenerateGitHubAppJWT(appID, privateKeyPEM) + if err != nil { + return "", nil, fmt.Errorf("failed to generate JWT: %w", err) + } + log.Debug("Generated JWT for GitHub App %d (token: %s...%s)", appID, jwtToken[:20], jwtToken[len(jwtToken)-20:]) + + // Create HTTP client with JWT for authentication + jwtHTTPClient := &http.Client{ + Transport: &jwtTransport{ + token: jwtToken, + transport: httpTransport, + }, + } + + // Create GitHub client + var githubClient *github.Client + if baseURL == "" || baseURL == "https://api.github.com" { + githubClient = github.NewClient(jwtHTTPClient) + } else { + githubClient, err = github.NewClient(jwtHTTPClient).WithEnterpriseURLs(baseURL, baseURL) + if err != nil { + return "", nil, fmt.Errorf("failed to create GitHub client: %w", err) + } + } + + // Get installation access token + installationToken, _, err := githubClient.Apps.CreateInstallationToken( + ctx, + installationID, + &github.InstallationTokenOptions{}, + ) + if err != nil { + return "", nil, fmt.Errorf("failed to create installation token: %w", err) + } + + // Create HTTP client with installation token + tokenHTTPClient := &http.Client{ + Transport: &tokenTransport{ + token: installationToken.GetToken(), + transport: httpTransport, + }, + } + + // Cache the token + globalTokenCache.mu.Lock() + globalTokenCache.tokens[cacheKey] = &cachedToken{ + token: installationToken.GetToken(), + expiresAt: installationToken.GetExpiresAt().Time, + httpClient: tokenHTTPClient, + } + globalTokenCache.mu.Unlock() + + log.Debug("Generated new GitHub App installation token for app %d, installation %d (expires at %v)", + appID, installationID, installationToken.GetExpiresAt().Time) + + return installationToken.GetToken(), tokenHTTPClient, nil +} + +// jwtTransport is an http.RoundTripper that adds JWT authentication +type jwtTransport struct { + token string + transport http.RoundTripper +} + +func (t *jwtTransport) RoundTrip(req *http.Request) (*http.Response, error) { + // GitHub requires "Bearer" prefix for JWT authentication + req.Header.Set("Authorization", "Bearer "+t.token) + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + + log.Debug("GitHub App JWT request: %s %s", req.Method, req.URL.String()) + + transport := t.transport + if transport == nil { + transport = http.DefaultTransport + } + + resp, err := transport.RoundTrip(req) + if err != nil { + log.Error("GitHub App JWT request failed: %v", err) + return resp, err + } + + log.Debug("GitHub App JWT response: %d %s", resp.StatusCode, resp.Status) + return resp, nil +} + +// tokenTransport is an http.RoundTripper that adds token authentication +type tokenTransport struct { + token string + transport http.RoundTripper +} + +func (t *tokenTransport) RoundTrip(req *http.Request) (*http.Response, error) { + req.Header.Set("Authorization", "token "+t.token) + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + + log.Debug("GitHub App token request: %s %s (token: %s...%s)", req.Method, req.URL.String(), t.token[:20], t.token[len(t.token)-20:]) + + transport := t.transport + if transport == nil { + transport = http.DefaultTransport + } + + resp, err := transport.RoundTrip(req) + if err != nil { + log.Error("GitHub App token request failed: %v", err) + return resp, err + } + + log.Debug("GitHub App token response: %d %s", resp.StatusCode, resp.Status) + return resp, nil +} + +// ClearTokenCache clears the token cache (useful for testing) +func ClearTokenCache() { + globalTokenCache.mu.Lock() + defer globalTokenCache.mu.Unlock() + globalTokenCache.tokens = make(map[string]*cachedToken) +} diff --git a/modules/migration/options.go b/modules/migration/options.go index 163aa0cfaa..53e28dde49 100644 --- a/modules/migration/options.go +++ b/modules/migration/options.go @@ -17,6 +17,8 @@ type MigrateOptions struct { AuthPasswordEncrypted string `json:"auth_password_encrypted,omitempty"` AuthToken string `json:"-"` AuthTokenEncrypted string `json:"auth_token_encrypted,omitempty"` + // GitHub App authentication + GitHubAppCredentialID int64 `json:"github_app_credential_id"` // required: true UID int `json:"uid" binding:"Required"` // required: true diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index c7ec133e57..3bf6374d4e 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -657,6 +657,25 @@ "settings.ssh_gpg_keys": "SSH / GPG Keys", "settings.social": "Social Accounts", "settings.applications": "Applications", + "settings.github_apps": "GitHub Apps", + "settings.manage_github_apps": "Manage GitHub App Credentials", + "settings.github_app_desc": "GitHub App credentials can be used for repository migrations with enhanced security and better rate limits.", + "settings.github_app_name": "Credential Name", + "settings.github_app_id": "App ID / Client ID", + "settings.github_app_id_helper": "The GitHub App ID / Client ID from your app settings", + "settings.github_app_installation_id": "Installation ID", + "settings.github_app_installation_id_helper": "The Installation ID from your app installation", + "settings.github_app_private_key": "Private Key (PEM format)", + "settings.github_app_private_key_helper": "The private key generated for your GitHub App", + "settings.github_app_base_url": "Base URL", + "settings.github_app_base_url_helper": "For GitHub Enterprise Server, use your instance API URL (e.g., https://github.example.com/api/v3). Leave empty for GitHub.com.", + "settings.add_github_app": "Add GitHub App Credential", + "settings.add_github_app_success": "GitHub App credential has been added successfully.", + "settings.delete_github_app": "Delete Credential", + "settings.delete_github_app_success": "GitHub App credential has been deleted successfully.", + "settings.github_app_deletion": "GitHub App Credential Deletion", + "settings.github_app_deletion_desc": "Deleting a GitHub App credential will prevent it from being used for future migrations. This action cannot be undone. Continue?", + "settings.github_app_state_desc": "This credential has been used recently", "settings.orgs": "Manage Organizations", "settings.repos": "Repositories", "settings.delete": "Delete Account", @@ -1107,6 +1126,13 @@ "repo.migrate_repo": "Migrate Repository", "repo.migrate.clone_address": "Migrate / Clone From URL", "repo.migrate.clone_address_desc": "The HTTP(S) or Git 'clone' URL of an existing repository", + "repo.migrate.auth_method": "Authentication Method", + "repo.migrate.auth_method_token": "Personal Access Token", + "repo.migrate.auth_method_github_app": "GitHub App", + "repo.migrate.github_app_credential": "GitHub App Credential", + "repo.migrate.github_app_credential_select": "Select a credential", + "repo.migrate.github_app_credential_helper": "Use a GitHub App for authentication with enhanced security and better rate limits.", + "repo.migrate.github_app_credential_manage": "Manage credentials", "repo.migrate.github_token_desc": "You can put one or more tokens here, separated by commas, to make migrating faster by circumventing the GitHub API rate limit. WARNING: Abusing this feature may violate the service provider's policy and may lead to getting your account(s) blocked.", "repo.migrate.clone_local_path": "or a local server path", "repo.migrate.permission_denied": "You are not allowed to import local repositories.", diff --git a/routers/web/repo/migrate.go b/routers/web/repo/migrate.go index bb6f1e6b7e..4beb85c303 100644 --- a/routers/web/repo/migrate.go +++ b/routers/web/repo/migrate.go @@ -10,6 +10,7 @@ import ( "strings" admin_model "code.gitea.io/gitea/models/admin" + auth_model "code.gitea.io/gitea/models/auth" "code.gitea.io/gitea/models/db" repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" @@ -68,6 +69,17 @@ func Migrate(ctx *context.Context) { } ctx.Data["ContextUser"] = ctxUser + // Load GitHub App credentials for GitHub migrations + if serviceType == structs.GithubService && ctx.Doer != nil { + credentials, err := auth_model.GetGithubAppCredentialsByOwnerID(ctx, ctx.Doer.ID) + if err != nil { + log.Error("Failed to load GitHub App credentials: %v", err) + } else { + ctx.Data["GitHubAppCredentials"] = credentials + log.Debug("Loaded %d GitHub App credentials for user %d", len(credentials), ctx.Doer.ID) + } + } + ctx.HTML(http.StatusOK, templates.TplName("repo/migrate/"+serviceType.Name())) } @@ -205,25 +217,26 @@ func MigratePost(ctx *context.Context) { } opts := migrations.MigrateOptions{ - OriginalURL: form.CloneAddr, - GitServiceType: form.Service, - CloneAddr: remoteAddr, - RepoName: form.RepoName, - Description: form.Description, - Private: form.Private || setting.Repository.ForcePrivate, - Mirror: form.Mirror, - LFS: form.LFS, - LFSEndpoint: form.LFSEndpoint, - AuthUsername: form.AuthUsername, - AuthPassword: form.AuthPassword, - AuthToken: form.AuthToken, - Wiki: form.Wiki, - Issues: form.Issues, - Milestones: form.Milestones, - Labels: form.Labels, - Comments: form.Issues || form.PullRequests, - PullRequests: form.PullRequests, - Releases: form.Releases, + OriginalURL: form.CloneAddr, + GitServiceType: form.Service, + CloneAddr: remoteAddr, + RepoName: form.RepoName, + Description: form.Description, + Private: form.Private || setting.Repository.ForcePrivate, + Mirror: form.Mirror, + LFS: form.LFS, + LFSEndpoint: form.LFSEndpoint, + AuthUsername: form.AuthUsername, + AuthPassword: form.AuthPassword, + AuthToken: form.AuthToken, + GitHubAppCredentialID: form.GitHubAppCredentialID, + Wiki: form.Wiki, + Issues: form.Issues, + Milestones: form.Milestones, + Labels: form.Labels, + Comments: form.Issues || form.PullRequests, + PullRequests: form.PullRequests, + Releases: form.Releases, } if opts.Mirror { opts.Issues = false @@ -263,6 +276,16 @@ func setMigrationContextData(ctx *context.Context, serviceType structs.GitServic // Plain git should be first ctx.Data["Services"] = append([]structs.GitServiceType{structs.PlainGitService}, structs.SupportedFullGitService...) ctx.Data["service"] = serviceType + + // Load GitHub App credentials for the current user if viewing GitHub migration + if serviceType == structs.GithubService && ctx.Doer != nil { + credentials, err := auth_model.GetGithubAppCredentialsByOwnerID(ctx, ctx.Doer.ID) + if err != nil { + log.Error("Failed to load GitHub App credentials: %v", err) + } else { + ctx.Data["GitHubAppCredentials"] = credentials + } + } } func MigrateRetryPost(ctx *context.Context) { diff --git a/routers/web/user/setting/github_apps.go b/routers/web/user/setting/github_apps.go new file mode 100644 index 0000000000..ef1b43801f --- /dev/null +++ b/routers/web/user/setting/github_apps.go @@ -0,0 +1,125 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "net/http" + "strings" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/modules/secret" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/templates" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/forms" +) + +const ( + tplSettingsGitHubApps templates.TplName = "user/settings/github_apps" +) + +// GitHubApps renders the GitHub App credentials management page +func GitHubApps(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings.github_apps") + ctx.Data["PageIsSettingsGitHubApps"] = true + + loadGitHubAppsData(ctx) + + ctx.HTML(http.StatusOK, tplSettingsGitHubApps) +} + +// GitHubAppsPost handles adding a new GitHub App credential +func GitHubAppsPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.NewGitHubAppCredentialForm) + ctx.Data["Title"] = ctx.Tr("settings.github_apps") + ctx.Data["PageIsSettingsGitHubApps"] = true + + if ctx.HasError() { + loadGitHubAppsData(ctx) + ctx.HTML(http.StatusOK, tplSettingsGitHubApps) + return + } + + // Normalize and validate the private key + privateKey := strings.TrimSpace(form.PrivateKey) + + // Ensure the private key has proper PEM format + if !strings.HasPrefix(privateKey, "-----BEGIN") { + ctx.Flash.Error(ctx.Tr("settings.github_app_invalid_private_key")) + loadGitHubAppsData(ctx) + ctx.HTML(http.StatusOK, tplSettingsGitHubApps) + return + } + + if !strings.HasSuffix(privateKey, "-----") { + ctx.Flash.Error(ctx.Tr("settings.github_app_invalid_private_key")) + loadGitHubAppsData(ctx) + ctx.HTML(http.StatusOK, tplSettingsGitHubApps) + return + } + + // Encrypt the private key + encryptedKey, err := secret.EncryptSecret(setting.SecretKey, privateKey) + if err != nil { + ctx.ServerError("EncryptSecret", err) + return + } + + // Set default base URL if empty + baseURL := form.BaseURL + if baseURL == "" { + baseURL = "https://api.github.com" + } + + cred := &auth_model.GithubAppCredential{ + OwnerID: ctx.Doer.ID, + Name: form.Name, + AppID: form.AppID, + InstallationID: form.InstallationID, + PrivateKeyEncrypted: encryptedKey, + BaseURL: baseURL, + } + + if err := auth_model.CreateGithubAppCredential(ctx, cred); err != nil { + ctx.ServerError("CreateGithubAppCredential", err) + return + } + + ctx.Flash.Success(ctx.Tr("settings.add_github_app_success")) + ctx.Redirect(setting.AppSubURL + "/user/settings/github_apps") +} + +// DeleteGitHubApp handles deleting a GitHub App credential +func DeleteGitHubApp(ctx *context.Context) { + id := ctx.FormInt64("id") + + // Check ownership + owned, err := auth_model.CheckGithubAppCredentialOwnership(ctx, id, ctx.Doer.ID) + if err != nil { + ctx.ServerError("CheckGithubAppCredentialOwnership", err) + return + } + if !owned { + ctx.NotFound(nil) + return + } + + if err := auth_model.DeleteGithubAppCredential(ctx, id); err != nil { + ctx.Flash.Error("DeleteGithubAppCredential: " + err.Error()) + } else { + ctx.Flash.Success(ctx.Tr("settings.delete_github_app_success")) + } + + ctx.JSONRedirect(setting.AppSubURL + "/user/settings/github_apps") +} + +func loadGitHubAppsData(ctx *context.Context) { + creds, err := auth_model.GetGithubAppCredentialsByOwnerID(ctx, ctx.Doer.ID) + if err != nil { + ctx.ServerError("GetGithubAppCredentialsByOwnerID", err) + return + } + ctx.Data["Credentials"] = creds +} diff --git a/routers/web/web.go b/routers/web/web.go index ecd75250d2..676a692fb1 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -670,6 +670,12 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { m.Post("/delete", user_setting.DeleteApplication) }) + m.Group("/github_apps", func() { + m.Combo("").Get(user_setting.GitHubApps). + Post(web.Bind(forms.NewGitHubAppCredentialForm{}), user_setting.GitHubAppsPost) + m.Post("/delete", user_setting.DeleteGitHubApp) + }) + m.Combo("/keys").Get(user_setting.Keys). Post(web.Bind(forms.AddKeyForm{}), user_setting.KeysPost) m.Post("/keys/delete", user_setting.DeleteKey) diff --git a/services/forms/admin.go b/services/forms/admin.go index c90ddf7d54..c68ecb23e9 100644 --- a/services/forms/admin.go +++ b/services/forms/admin.go @@ -96,3 +96,18 @@ func (f *AdminDashboardForm) Validate(req *http.Request, errs binding.Errors) bi ctx := context.GetValidateContext(req) return middleware.Validate(errs, ctx.Data, f, ctx.Locale) } + +// NewGitHubAppCredentialForm form for creating GitHub App credential +type NewGitHubAppCredentialForm struct { + Name string `binding:"Required;MaxSize(255)" locale:"settings.github_app_name"` + AppID int64 `binding:"Required" locale:"settings.github_app_id"` + InstallationID int64 `binding:"Required" locale:"settings.github_app_installation_id"` + PrivateKey string `binding:"Required" locale:"settings.github_app_private_key"` + BaseURL string `binding:"MaxSize(255)" locale:"settings.github_app_base_url"` +} + +// Validate validates the fields +func (f *NewGitHubAppCredentialForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { + ctx := context.GetValidateContext(req) + return middleware.Validate(errs, ctx.Data, f, ctx.Locale) +} diff --git a/services/forms/repo_form.go b/services/forms/repo_form.go index d8e019f860..96eceec89d 100644 --- a/services/forms/repo_form.go +++ b/services/forms/repo_form.go @@ -57,11 +57,12 @@ func (f *CreateRepoForm) Validate(req *http.Request, errs binding.Errors) bindin // this is used to interact with web ui type MigrateRepoForm struct { // required: true - CloneAddr string `json:"clone_addr" binding:"Required"` - Service structs.GitServiceType `json:"service"` - AuthUsername string `json:"auth_username"` - AuthPassword string `json:"auth_password"` - AuthToken string `json:"auth_token"` + CloneAddr string `json:"clone_addr" binding:"Required"` + Service structs.GitServiceType `json:"service"` + AuthUsername string `json:"auth_username"` + AuthPassword string `json:"auth_password"` + AuthToken string `json:"auth_token"` + GitHubAppCredentialID int64 `json:"github_app_credential_id" form:"github_app_credential_id"` // required: true UID int64 `json:"uid" binding:"Required"` // required: true diff --git a/services/migrations/github.go b/services/migrations/github.go index 87744fef6c..5ff090ac9e 100644 --- a/services/migrations/github.go +++ b/services/migrations/github.go @@ -14,10 +14,14 @@ import ( "strings" "time" + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/modules/auth" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/log" base "code.gitea.io/gitea/modules/migration" "code.gitea.io/gitea/modules/proxy" + "code.gitea.io/gitea/modules/secret" + "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/structs" "github.com/google/go-github/v84/github" @@ -52,6 +56,12 @@ func (f *GithubDownloaderV3Factory) New(ctx context.Context, opts base.MigrateOp log.Trace("Create github downloader BaseURL: %s %s/%s", baseURL, oldOwner, oldName) + // Check if GitHub App authentication is requested + if opts.GitHubAppCredentialID > 0 { + log.Info("Using GitHub App authentication with credential ID: %d", opts.GitHubAppCredentialID) + return NewGithubDownloaderV3WithApp(ctx, baseURL, opts.GitHubAppCredentialID, oldOwner, oldName) + } + return NewGithubDownloaderV3(ctx, baseURL, opts.AuthUsername, opts.AuthPassword, opts.AuthToken, oldOwner, oldName), nil } @@ -118,6 +128,68 @@ func NewGithubDownloaderV3(_ context.Context, baseURL, userName, password, token return &downloader } +// NewGithubDownloaderV3WithApp creates a github Downloader using GitHub App authentication +func NewGithubDownloaderV3WithApp(ctx context.Context, baseURL string, credentialID int64, repoOwner, repoName string) (*GithubDownloaderV3, error) { + // Get the GitHub App credential from database + cred, err := auth_model.GetGithubAppCredentialByID(ctx, credentialID) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub App credential: %w", err) + } + + // Decrypt the private key + privateKey, err := secret.DecryptSecret(setting.SecretKey, cred.PrivateKeyEncrypted) + if err != nil { + return nil, fmt.Errorf("failed to decrypt private key: %w", err) + } + + // Determine base URL for API + apiBaseURL := cred.BaseURL + if apiBaseURL == "" { + apiBaseURL = "https://api.github.com" + } + + // Get installation access token + token, _, err := auth.GetGitHubAppInstallationToken( + ctx, + cred.AppID, + cred.InstallationID, + privateKey, + apiBaseURL, + NewMigrationHTTPTransport(), + ) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub App installation token: %w", err) + } + + downloader := &GithubDownloaderV3{ + baseURL: baseURL, + repoOwner: repoOwner, + repoName: repoName, + maxPerPage: 100, + } + + // Use the token directly with go-github's built-in authentication + // Create a simple HTTP client with the migration transport + httpClient := &http.Client{ + Transport: NewMigrationHTTPTransport(), + } + + // Create GitHub client and set the auth token + githubClient := github.NewClient(httpClient).WithAuthToken(token) + + if baseURL != "https://github.com" && baseURL != "https://api.github.com" { + githubClient, err = githubClient.WithEnterpriseURLs(baseURL, baseURL) + if err != nil { + return nil, fmt.Errorf("failed to set enterprise URLs: %w", err) + } + } + + downloader.clients = append(downloader.clients, githubClient) + downloader.rates = append(downloader.rates, nil) + + return downloader, nil +} + // String implements Stringer func (g *GithubDownloaderV3) String() string { return fmt.Sprintf("migration from github server %s %s/%s", g.baseURL, g.repoOwner, g.repoName) diff --git a/templates/repo/migrate/github.tmpl b/templates/repo/migrate/github.tmpl index 87a95d3ce4..6c36d7859c 100644 --- a/templates/repo/migrate/github.tmpl +++ b/templates/repo/migrate/github.tmpl @@ -18,7 +18,20 @@
-
+
+ + +
+ +
+ + {{template "repo/migrate/options" .}}
diff --git a/templates/user/settings/github_apps.tmpl b/templates/user/settings/github_apps.tmpl new file mode 100644 index 0000000000..e545431866 --- /dev/null +++ b/templates/user/settings/github_apps.tmpl @@ -0,0 +1,101 @@ +{{template "user/settings/layout_head" (dict "pageClass" "user settings github-apps")}} +
+

+ {{ctx.Locale.Tr "settings.manage_github_apps"}} +

+
+
+
+ {{ctx.Locale.Tr "settings.github_app_desc"}} +
+ {{range .Credentials}} +
+
+ + {{svg "octicon-key" 32}} + +
+
+
+ {{.Name}} +

+ {{ctx.Locale.Tr "settings.github_app_id"}}: {{.AppID}} +

+

+ {{ctx.Locale.Tr "settings.github_app_installation_id"}}: {{.InstallationID}} +

+

+ {{ctx.Locale.Tr "settings.github_app_base_url"}}: {{.BaseURL}} +

+
+
+ {{ctx.Locale.Tr "settings.added_on" (DateUtils.AbsoluteShort .CreatedUnix)}} — {{svg "octicon-info"}} {{if .HasUsed}}{{ctx.Locale.Tr "settings.last_used"}} {{DateUtils.AbsoluteShort .UpdatedUnix}}{{else}}{{ctx.Locale.Tr "settings.no_activity"}}{{end}} +
+
+
+ +
+
+ {{end}} +
+
+
+
+

{{ctx.Locale.Tr "settings.add_github_app"}}

+
+ {{.CsrfTokenHtml}} +
+ + +
+
+ + + + {{ctx.Locale.Tr "settings.github_app_id_helper"}} + +
+
+ + + + {{ctx.Locale.Tr "settings.github_app_installation_id_helper"}} + +
+
+ + + + {{ctx.Locale.Tr "settings.github_app_private_key_helper"}} + +
+
+ + + + {{ctx.Locale.Tr "settings.github_app_base_url_helper"}} + +
+ +
+
+
+
+ + + +{{template "user/settings/layout_footer" .}} diff --git a/templates/user/settings/navbar.tmpl b/templates/user/settings/navbar.tmpl index e2bc72ca5f..cf5b443d8d 100644 --- a/templates/user/settings/navbar.tmpl +++ b/templates/user/settings/navbar.tmpl @@ -28,6 +28,9 @@ {{ctx.Locale.Tr "settings.applications"}} + + {{ctx.Locale.Tr "settings.github_apps"}} + {{if not ($.UserDisabledFeatures.Contains "manage_ssh_keys" "manage_gpg_keys")}} {{ctx.Locale.Tr "settings.ssh_gpg_keys"}} diff --git a/web_src/js/features/repo-migrate.ts b/web_src/js/features/repo-migrate.ts deleted file mode 100644 index 556f049246..0000000000 --- a/web_src/js/features/repo-migrate.ts +++ /dev/null @@ -1,61 +0,0 @@ -import {hideElem, showElem} from '../utils/dom.ts'; -import {GET, POST} from '../modules/fetch.ts'; - -export function initRepoMigrationStatusChecker() { - const repoMigrating = document.querySelector('#repo_migrating'); - if (!repoMigrating) return; - - document.querySelector('#repo_migrating_retry')?.addEventListener('click', doMigrationRetry); - - const repoLink = repoMigrating.getAttribute('data-migrating-repo-link'); - - // returns true if the refresh still needs to be called after a while - const refresh = async () => { - const res = await GET(`${repoLink}/-/migrate/status`); - if (res.status !== 200) return true; // continue to refresh if network error occurs - - const data = await res.json(); - - // for all status - if (data.message) { - document.querySelector('#repo_migrating_progress_message')!.textContent = data.message; - } - - // TaskStatusFinished - if (data.status === 4) { - window.location.reload(); - return false; - } - - // TaskStatusFailed - if (data.status === 3) { - hideElem('#repo_migrating_progress'); - hideElem('#repo_migrating'); - showElem('#repo_migrating_retry'); - showElem('#repo_migrating_failed'); - showElem('#repo_migrating_failed_image'); - document.querySelector('#repo_migrating_failed_error')!.textContent = data.message; - return false; - } - - return true; // continue to refresh - }; - - const syncTaskStatus = async () => { - let doNextRefresh = true; - try { - doNextRefresh = await refresh(); - } finally { - if (doNextRefresh) { - setTimeout(syncTaskStatus, 2000); - } - } - }; - - syncTaskStatus(); // no await -} - -async function doMigrationRetry(e: Event) { - await POST((e.target as HTMLElement).getAttribute('data-migrating-task-retry-url')!); - window.location.reload(); -} diff --git a/web_src/js/features/repo-migration.ts b/web_src/js/features/repo-migration.ts index 7376ff7fa4..2a4c00c7e9 100644 --- a/web_src/js/features/repo-migration.ts +++ b/web_src/js/features/repo-migration.ts @@ -1,4 +1,5 @@ import {hideElem, showElem, toggleElem} from '../utils/dom.ts'; +import {GET, POST} from '../modules/fetch.ts'; import {sanitizeRepoName} from './repo-common.ts'; const service = document.querySelector('#service_type'); @@ -78,3 +79,88 @@ function setLFSSettingsVisibility() { toggleElem(lfsSettings, visible); hideElem(lfsEndpoint); } + +export function initRepoMigrate() { + const authMethodDropdown = document.querySelector('input[name="auth_method"]'); + if (!authMethodDropdown) return; + + const authTokenField = document.querySelector('#auth_token_field'); + const githubAppField = document.querySelector('#github_app_field'); + + function updateAuthFields() { + const authMethod = authMethodDropdown?.value || 'token'; + + if (authMethod === 'github_app') { + authTokenField?.style.setProperty('display', 'none'); + githubAppField?.style.removeProperty('display'); + } else { + authTokenField?.style.removeProperty('display'); + githubAppField?.style.setProperty('display', 'none'); + } + } + + // Initialize on page load + updateAuthFields(); + + // Listen for changes + authMethodDropdown.addEventListener('change', updateAuthFields); +} + +export function initRepoMigrationStatusChecker() { + const repoMigrating = document.querySelector('#repo_migrating'); + if (!repoMigrating) return; + + document.querySelector('#repo_migrating_retry')?.addEventListener('click', doMigrationRetry); + + const repoLink = repoMigrating.getAttribute('data-migrating-repo-link'); + + // returns true if the refresh still needs to be called after a while + const refresh = async () => { + const res = await GET(`${repoLink}/-/migrate/status`); + if (res.status !== 200) return true; // continue to refresh if network error occurs + + const data = await res.json(); + + // for all status + if (data.message) { + document.querySelector('#repo_migrating_progress_message')!.textContent = data.message; + } + + // TaskStatusFinished + if (data.status === 4) { + window.location.reload(); + return false; + } + + // TaskStatusFailed + if (data.status === 3) { + hideElem('#repo_migrating_progress'); + hideElem('#repo_migrating'); + showElem('#repo_migrating_retry'); + showElem('#repo_migrating_failed'); + showElem('#repo_migrating_failed_image'); + document.querySelector('#repo_migrating_failed_error')!.textContent = data.message; + return false; + } + + return true; // continue to refresh + }; + + const syncTaskStatus = async () => { + let doNextRefresh = true; + try { + doNextRefresh = await refresh(); + } finally { + if (doNextRefresh) { + setTimeout(syncTaskStatus, 2000); + } + } + }; + + syncTaskStatus(); // no await +} + +async function doMigrationRetry(e: Event) { + await POST((e.target as HTMLElement).getAttribute('data-migrating-task-retry-url')!); + window.location.reload(); +} diff --git a/web_src/js/index.ts b/web_src/js/index.ts index cb2b56a5bd..0cc236ddf8 100644 --- a/web_src/js/index.ts +++ b/web_src/js/index.ts @@ -6,7 +6,7 @@ import {initGlobalCopyToClipboardListener} from './features/clipboard.ts'; import {initRepoGraphGit} from './features/repo-graph.ts'; import {initHeatmap} from './features/heatmap.ts'; import {initImageDiff} from './features/imagediff.ts'; -import {initRepoMigration} from './features/repo-migration.ts'; +import {initRepoMigration, initRepoMigrate, initRepoMigrationStatusChecker} from './features/repo-migration.ts'; import {initRepoProjectsView} from './features/repo-projects.ts'; import {initTableSort} from './features/tablesort.ts'; import {initAdminUserListSearchForm} from './features/admin/users.ts'; @@ -27,7 +27,6 @@ import {initRepoCodeView} from './features/repo-code.ts'; import {initSshKeyFormParser} from './features/sshkey-helper.ts'; import {initUserSettings} from './features/user-settings.ts'; import {initRepoActivityTopAuthorsChart, initRepoArchiveLinks} from './features/repo-common.ts'; -import {initRepoMigrationStatusChecker} from './features/repo-migrate.ts'; import {initRepoDiffView} from './features/repo-diff.ts'; import {initOrgTeam} from './features/org-team.ts'; import {initUserAuthWebAuthn, initUserAuthWebAuthnRegister} from './features/user-auth-webauthn.ts'; @@ -130,6 +129,7 @@ const initPerformanceTracer = callInitFunctions([ initRepoIssueList, initRepoIssueFilterItemLabel, initRepoMigration, + initRepoMigrate, initRepoMigrationStatusChecker, initRepoProjectsView, initRepoPullRequestReview, From 68623cb4f6632544e9631bc6b386fceb96b3a3a2 Mon Sep 17 00:00:00 2001 From: Tom Thornton Date: Fri, 8 May 2026 16:59:18 +0100 Subject: [PATCH 3/3] fmt(lint): fixed lint issues --- models/migrations/v1_27/v332.go | 18 +++++++++--------- modules/auth/github_app.go | 8 +++++--- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/models/migrations/v1_27/v332.go b/models/migrations/v1_27/v332.go index 6537e8c588..fd02429fcf 100644 --- a/models/migrations/v1_27/v332.go +++ b/models/migrations/v1_27/v332.go @@ -13,15 +13,15 @@ import ( func AddGithubAppCredentialTable(ctx context.Context, x *xorm.Engine) error { type GithubAppCredential struct { - ID int64 `xorm:"pk autoincr"` - OwnerID int64 `xorm:"INDEX NOT NULL"` - Name string `xorm:"NOT NULL"` - AppID int64 `xorm:"NOT NULL"` - InstallationID int64 `xorm:"NOT NULL"` - PrivateKeyEncrypted string `xorm:"TEXT NOT NULL"` - BaseURL string `xorm:"VARCHAR(255) NOT NULL DEFAULT 'https://api.github.com'"` - CreatedUnix timeutil.TimeStamp `xorm:"created"` - UpdatedUnix timeutil.TimeStamp `xorm:"updated"` + ID int64 `xorm:"pk autoincr"` + OwnerID int64 `xorm:"INDEX NOT NULL"` + Name string `xorm:"NOT NULL"` + AppID int64 `xorm:"NOT NULL"` + InstallationID int64 `xorm:"NOT NULL"` + PrivateKeyEncrypted string `xorm:"TEXT NOT NULL"` + BaseURL string `xorm:"VARCHAR(255) NOT NULL DEFAULT 'https://api.github.com'"` + CreatedUnix timeutil.TimeStamp `xorm:"created"` + UpdatedUnix timeutil.TimeStamp `xorm:"updated"` } return x.Sync(new(GithubAppCredential)) diff --git a/modules/auth/github_app.go b/modules/auth/github_app.go index b56bddce1f..81aabd9083 100644 --- a/modules/auth/github_app.go +++ b/modules/auth/github_app.go @@ -8,8 +8,10 @@ import ( "crypto/rsa" "crypto/x509" "encoding/pem" + "errors" "fmt" "net/http" + "strconv" "strings" "sync" "time" @@ -51,7 +53,7 @@ func GenerateGitHubAppJWT(appID int64, privateKeyPEM string) (string, error) { // Parse the private key block, _ := pem.Decode([]byte(privateKeyPEM)) if block == nil { - return "", fmt.Errorf("failed to parse PEM block containing the private key") + return "", errors.New("failed to parse PEM block containing the private key") } privateKey, err := x509.ParsePKCS1PrivateKey(block.Bytes) @@ -64,7 +66,7 @@ func GenerateGitHubAppJWT(appID int64, privateKeyPEM string) (string, error) { var ok bool privateKey, ok = key.(*rsa.PrivateKey) if !ok { - return "", fmt.Errorf("private key is not RSA") + return "", errors.New("private key is not RSA") } } @@ -73,7 +75,7 @@ func GenerateGitHubAppJWT(appID int64, privateKeyPEM string) (string, error) { claims := jwt.RegisteredClaims{ IssuedAt: jwt.NewNumericDate(now), ExpiresAt: jwt.NewNumericDate(now.Add(10 * time.Minute)), // GitHub requires max 10 minutes - Issuer: fmt.Sprintf("%d", appID), + Issuer: strconv.FormatInt(appID, 10), } // Create token