0
0
mirror of https://github.com/go-gitea/gitea.git synced 2026-05-14 10:57:54 +02:00

Merge ae0b9a3b3e410887e01bd39e5349c329daf91d59 into ce089f498bce32305b2d9e8c6adfd8cb7c82f88f

This commit is contained in:
Tom T 2026-05-09 18:55:21 +08:00 committed by GitHub
commit 0b72a065ea
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 865 additions and 88 deletions

View File

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

View File

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

View File

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

228
modules/auth/github_app.go Normal file
View File

@ -0,0 +1,228 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package auth
import (
"context"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"net/http"
"strconv"
"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 "", errors.New("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 "", errors.New("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: strconv.FormatInt(appID, 10),
}
// 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)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -18,7 +18,20 @@
</span>
</div>
<div class="inline field {{if .Err_Auth}}error{{end}}">
<div class="inline field">
<label>{{ctx.Locale.Tr "repo.migrate.auth_method"}}</label>
<div class="ui selection dropdown">
<input type="hidden" name="auth_method" value="{{.auth_method}}">
<div class="default text">{{ctx.Locale.Tr "repo.migrate.auth_method_token"}}</div>
{{svg "octicon-triangle-down" 14 "dropdown icon"}}
<div class="menu">
<div class="item" data-value="token">{{ctx.Locale.Tr "repo.migrate.auth_method_token"}}</div>
<div class="item" data-value="github_app">{{ctx.Locale.Tr "repo.migrate.auth_method_github_app"}}</div>
</div>
</div>
</div>
<div id="auth_token_field" class="inline field {{if .Err_Auth}}error{{end}}">
<label for="auth_token">{{ctx.Locale.Tr "access_token"}}</label>
<input id="auth_token" name="auth_token" type="password" autocomplete="new-password" value="{{.auth_token}}" {{if not .auth_token}}data-need-clear="true"{{end}}>
<a target="_blank" href="https://docs.github.com/en/github/authenticating-to-github/creating-a-personal-access-token">{{svg "octicon-question"}}</a>
@ -27,6 +40,19 @@
</span>
</div>
<div id="github_app_field" class="inline field {{if .Err_GithubAppCredential}}error{{end}}" style="display: none;">
<label for="github_app_credential_id">{{ctx.Locale.Tr "repo.migrate.github_app_credential"}}</label>
<select id="github_app_credential_id" name="github_app_credential_id" class="ui selection dropdown">
<option value="">{{ctx.Locale.Tr "repo.migrate.github_app_credential_select"}}</option>
{{range .GitHubAppCredentials}}
<option value="{{.ID}}" {{if eq $.github_app_credential_id .ID}}selected{{end}}>{{.Name}}</option>
{{end}}
</select>
<span class="help">
{{ctx.Locale.Tr "repo.migrate.github_app_credential_helper"}} <a href="{{AppSubUrl}}/user/settings/github_apps">{{ctx.Locale.Tr "repo.migrate.github_app_credential_manage"}}</a>
</span>
</div>
{{template "repo/migrate/options" .}}
<div class="inline field">

View File

@ -0,0 +1,101 @@
{{template "user/settings/layout_head" (dict "pageClass" "user settings github-apps")}}
<div class="user-setting-content">
<h4 class="ui top attached header">
{{ctx.Locale.Tr "settings.manage_github_apps"}}
</h4>
<div class="ui attached segment">
<div class="flex-divided-list items-with-main">
<div class="item">
{{ctx.Locale.Tr "settings.github_app_desc"}}
</div>
{{range .Credentials}}
<div class="item">
<div class="item-leading">
<span class="{{if .HasRecentActivity}}tw-text-green{{end}}" {{if .HasRecentActivity}}data-tooltip-content="{{ctx.Locale.Tr "settings.github_app_state_desc"}}"{{end}}>
{{svg "octicon-key" 32}}
</span>
</div>
<div class="item-main">
<details>
<summary><span class="item-title">{{.Name}}</span></summary>
<p class="tw-my-1">
{{ctx.Locale.Tr "settings.github_app_id"}}: {{.AppID}}
</p>
<p class="tw-my-1">
{{ctx.Locale.Tr "settings.github_app_installation_id"}}: {{.InstallationID}}
</p>
<p class="tw-my-1">
{{ctx.Locale.Tr "settings.github_app_base_url"}}: {{.BaseURL}}
</p>
</details>
<div class="item-body">
<i>{{ctx.Locale.Tr "settings.added_on" (DateUtils.AbsoluteShort .CreatedUnix)}}{{svg "octicon-info"}} {{if .HasUsed}}{{ctx.Locale.Tr "settings.last_used"}} <span {{if .HasRecentActivity}}class="tw-text-green"{{end}}>{{DateUtils.AbsoluteShort .UpdatedUnix}}</span>{{else}}{{ctx.Locale.Tr "settings.no_activity"}}{{end}}</i>
</div>
</div>
<div class="item-trailing">
<button class="ui red tiny button delete-button" data-modal-id="delete-github-app" data-url="{{$.Link}}/delete" data-id="{{.ID}}">
{{svg "octicon-trash"}}
{{ctx.Locale.Tr "settings.delete_github_app"}}
</button>
</div>
</div>
{{end}}
</div>
</div>
<div class="ui bottom attached segment">
<details {{if or .name (not .Credentials)}}open{{end}}>
<summary><h4 class="ui header tw-inline-block tw-my-2">{{ctx.Locale.Tr "settings.add_github_app"}}</h4></summary>
<form class="ui form ignore-dirty" action="{{.Link}}" method="post">
{{.CsrfTokenHtml}}
<div class="field {{if .Err_Name}}error{{end}}">
<label for="name">{{ctx.Locale.Tr "settings.github_app_name"}}</label>
<input id="name" name="name" value="{{.name}}" required maxlength="255">
</div>
<div class="field {{if .Err_AppID}}error{{end}}">
<label for="app_id">{{ctx.Locale.Tr "settings.github_app_id"}}</label>
<input id="app_id" name="app_id" type="number" value="{{.app_id}}" required>
<span class="help">
{{ctx.Locale.Tr "settings.github_app_id_helper"}}
</span>
</div>
<div class="field {{if .Err_InstallationID}}error{{end}}">
<label for="installation_id">{{ctx.Locale.Tr "settings.github_app_installation_id"}}</label>
<input id="installation_id" name="installation_id" type="number" value="{{.installation_id}}" required>
<span class="help">
{{ctx.Locale.Tr "settings.github_app_installation_id_helper"}}
</span>
</div>
<div class="field {{if .Err_PrivateKey}}error{{end}}">
<label for="private_key">{{ctx.Locale.Tr "settings.github_app_private_key"}}</label>
<textarea id="private_key" name="private_key" rows="10" required placeholder="-----BEGIN RSA PRIVATE KEY-----&#10;...&#10;-----END RSA PRIVATE KEY-----">{{.private_key}}</textarea>
<span class="help">
{{ctx.Locale.Tr "settings.github_app_private_key_helper"}}
</span>
</div>
<div class="field {{if .Err_BaseURL}}error{{end}}">
<label for="base_url">{{ctx.Locale.Tr "settings.github_app_base_url"}}</label>
<input id="base_url" name="base_url" value="{{.base_url}}" placeholder="https://api.github.com" maxlength="255">
<span class="help">
{{ctx.Locale.Tr "settings.github_app_base_url_helper"}}
</span>
</div>
<button class="ui primary button">
{{ctx.Locale.Tr "settings.add_github_app"}}
</button>
</form>
</details>
</div>
</div>
<div class="ui g-modal-confirm delete modal" id="delete-github-app">
<div class="header">
{{svg "octicon-trash"}}
{{ctx.Locale.Tr "settings.github_app_deletion"}}
</div>
<div class="content">
<p>{{ctx.Locale.Tr "settings.github_app_deletion_desc"}}</p>
</div>
{{template "base/modal_actions_confirm"}}
</div>
{{template "user/settings/layout_footer" .}}

View File

@ -28,6 +28,9 @@
<a class="{{if .PageIsSettingsApplications}}active {{end}}item" href="{{AppSubUrl}}/user/settings/applications">
{{ctx.Locale.Tr "settings.applications"}}
</a>
<a class="{{if .PageIsSettingsGitHubApps}}active {{end}}item" href="{{AppSubUrl}}/user/settings/github_apps">
{{ctx.Locale.Tr "settings.github_apps"}}
</a>
{{if not ($.UserDisabledFeatures.Contains "manage_ssh_keys" "manage_gpg_keys")}}
<a class="{{if .PageIsSettingsKeys}}active {{end}}item" href="{{AppSubUrl}}/user/settings/keys">
{{ctx.Locale.Tr "settings.ssh_gpg_keys"}}

View File

@ -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<HTMLButtonElement>('#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();
}

View File

@ -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<HTMLInputElement>('#service_type');
@ -78,3 +79,88 @@ function setLFSSettingsVisibility() {
toggleElem(lfsSettings, visible);
hideElem(lfsEndpoint);
}
export function initRepoMigrate() {
const authMethodDropdown = document.querySelector<HTMLInputElement>('input[name="auth_method"]');
if (!authMethodDropdown) return;
const authTokenField = document.querySelector<HTMLElement>('#auth_token_field');
const githubAppField = document.querySelector<HTMLElement>('#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<HTMLButtonElement>('#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();
}

View File

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