From 34f741beeface64920bf8fc4a184c50ed21dbc3b Mon Sep 17 00:00:00 2001 From: Arunavo Ray Date: Tue, 30 Sep 2025 23:12:33 +0530 Subject: [PATCH] fix: Forgejo 12 compatibility - use separate auth fields for private repos (#102) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem Forgejo 12.0+ rejects migration API calls with credentials embedded in URLs, causing HTTP 422 errors when mirroring private GitHub repositories. ## Root Cause Breaking security change in Forgejo 12.0 (July 2025) enforces credential separation to prevent accidental exposure in logs/errors. Previous versions (Forgejo 11.x, Gitea 1.x) accepted embedded credentials. ## Solution - Use separate `auth_username` and `auth_token` fields instead of embedding credentials in clone URLs - Set `auth_username` to "oauth2" for GitHub token authentication - Pass GitHub token via `auth_token` field ## Changes - src/lib/gitea.ts: - mirrorGithubRepoToGitea(): Use separate auth fields for private repos - mirrorGitHubRepoToGiteaOrg(): Use separate auth fields for private repos - .github/workflows/docker-build.yml: - Enable PR image building and pushing to GHCR - Tag PR images as pr- for easy testing - Add automated PR comment with image details and testing instructions - Separate load step for security scanning ## Backward Compatibility ✅ Works with Forgejo 12.0+ ✅ Works with Forgejo 11.x and earlier ✅ Works with Gitea 1.x ## Testing Public repos: ✅ Working (no auth needed) Private repos: ✅ Fixed (separate auth fields) Fixes #102 --- .github/workflows/docker-build.yml | 67 ++++++++++++++-- src/lib/gitea.ts | 125 ++++++++++++++++------------- 2 files changed, 129 insertions(+), 63 deletions(-) diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index d4ad4d9..d6c3b11 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -48,7 +48,6 @@ jobs: - name: Log into registry uses: docker/login-action@v3 - if: github.event_name != 'pull_request' with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} @@ -89,6 +88,7 @@ jobs: type=sha,prefix=,suffix=,format=short type=raw,value=latest,enable={{is_default_branch}} type=raw,value=${{ steps.tag_version.outputs.VERSION }} + type=ref,event=pr,prefix=pr- # Build and push Docker image - name: Build and push Docker image @@ -97,20 +97,77 @@ jobs: with: context: . platforms: ${{ github.event_name == 'pull_request' && 'linux/amd64' || 'linux/amd64,linux/arm64' }} - push: ${{ github.event_name != 'pull_request' }} - load: ${{ github.event_name == 'pull_request' }} - tags: ${{ github.event_name == 'pull_request' && 'gitea-mirror:scan' || steps.meta.outputs.tags }} + push: true + tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max + # Load image locally for security scanning (PRs only) + - name: Load image for scanning + if: github.event_name == 'pull_request' + uses: docker/build-push-action@v5 + with: + context: . + platforms: linux/amd64 + load: true + tags: gitea-mirror:scan + cache-from: type=gha + # Wait for image to be available in registry - name: Wait for image availability - if: github.event_name != 'pull_request' run: | echo "Waiting for image to be available in registry..." sleep 5 + # Add comment to PR with image details + - name: Comment PR with image tag + if: github.event_name == 'pull_request' + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const prNumber = context.payload.pull_request.number; + const imageTag = `pr-${prNumber}`; + const imagePath = `${{ env.REGISTRY }}/${{ env.IMAGE }}:${imageTag}`; + + const comment = `## 🐳 Docker Image Built Successfully + + Your PR image is available for testing: + + **Image Tag:** \`${imageTag}\` + **Full Image Path:** \`${imagePath}\` + + ### Pull and Test + \`\`\`bash + docker pull ${imagePath} + docker run -d -p 3000:3000 --name gitea-mirror-test ${imagePath} + \`\`\` + + ### Docker Compose Testing + \`\`\`yaml + services: + gitea-mirror: + image: ${imagePath} + ports: + - "3000:3000" + environment: + - BETTER_AUTH_SECRET=your-secret-here + \`\`\` + + > 💡 **Note:** PR images are tagged as \`pr-\` and only built for \`linux/amd64\` to speed up CI. + > Production images (\`latest\`, version tags) are multi-platform (\`linux/amd64\`, \`linux/arm64\`). + + --- + 📦 View in [GitHub Packages](https://github.com/${{ github.repository }}/pkgs/container/gitea-mirror)`; + + github.rest.issues.createComment({ + issue_number: prNumber, + owner: context.repo.owner, + repo: context.repo.repo, + body: comment + }); + # Docker Scout comprehensive security analysis - name: Docker Scout - Vulnerability Analysis & Recommendations uses: docker/scout-action@v1 diff --git a/src/lib/gitea.ts b/src/lib/gitea.ts index e9c7137..5e09b15 100644 --- a/src/lib/gitea.ts +++ b/src/lib/gitea.ts @@ -356,21 +356,8 @@ export const mirrorGithubRepoToGitea = async ({ status: "mirroring", }); - let cloneAddress = repository.cloneUrl; - - // If the repository is private, inject the GitHub token into the clone URL - if (repository.isPrivate) { - if (!config.githubConfig.token) { - throw new Error( - "GitHub token is required to mirror private repositories." - ); - } - - cloneAddress = repository.cloneUrl.replace( - "https://", - `https://${decryptedConfig.githubConfig.token}@` - ); - } + // Use clean clone URL without embedded credentials (Forgejo 12+ security requirement) + const cloneAddress = repository.cloneUrl; const apiUrl = `${config.giteaConfig.url}/api/v1/repos/migrate`; @@ -384,17 +371,17 @@ export const mirrorGithubRepoToGitea = async ({ }); } catch (orgError) { console.error(`Failed to create/access organization ${repoOwner}: ${orgError instanceof Error ? orgError.message : String(orgError)}`); - + // Check if we should fallback to user account - if (orgError instanceof Error && - (orgError.message.includes('Permission denied') || + if (orgError instanceof Error && + (orgError.message.includes('Permission denied') || orgError.message.includes('Authentication failed') || orgError.message.includes('does not have permission'))) { console.warn(`[Fallback] Organization creation/access failed. Attempting to mirror to user account instead.`); - + // Update the repository owner to use the user account repoOwner = config.giteaConfig.defaultOwner; - + // Log this fallback in the database await db .update(repositories) @@ -420,7 +407,7 @@ export const mirrorGithubRepoToGitea = async ({ if (existingRepo && !existingRepo.mirror) { console.log(`Repository ${targetRepoName} exists but is not a mirror. Handling...`); - + // Handle the existing non-mirror repository await handleExistingNonMirrorRepo({ config, @@ -428,25 +415,42 @@ export const mirrorGithubRepoToGitea = async ({ repoInfo: existingRepo, strategy: "delete", // Can be configured: "skip", "delete", or "rename" }); - + // After handling, proceed with mirror creation console.log(`Proceeding with mirror creation for ${targetRepoName}`); } + // Prepare migration payload + // For private repos, use separate auth fields instead of embedding credentials in URL + // This is required for Forgejo 12+ which rejects URLs with embedded credentials + const migratePayload: any = { + clone_addr: cloneAddress, + repo_name: targetRepoName, + mirror: true, + mirror_interval: config.giteaConfig?.mirrorInterval || "8h", + wiki: config.giteaConfig?.wiki || false, + lfs: config.giteaConfig?.lfs || false, + private: repository.isPrivate, + repo_owner: repoOwner, + description: "", + service: "git", + }; + + // Add authentication for private repositories + if (repository.isPrivate) { + if (!config.githubConfig.token) { + throw new Error( + "GitHub token is required to mirror private repositories." + ); + } + // Use separate auth fields (required for Forgejo 12+ compatibility) + migratePayload.auth_username = "oauth2"; // GitHub tokens work with any username + migratePayload.auth_token = decryptedConfig.githubConfig.token; + } + const response = await httpPost( apiUrl, - { - clone_addr: cloneAddress, - repo_name: targetRepoName, - mirror: true, - mirror_interval: config.giteaConfig?.mirrorInterval || "8h", // Set mirror interval - wiki: config.giteaConfig?.wiki || false, // will mirror wiki if it exists - lfs: config.giteaConfig?.lfs || false, // Enable LFS mirroring if configured - private: repository.isPrivate, - repo_owner: repoOwner, - description: "", - service: "git", - }, + migratePayload, { Authorization: `token ${decryptedConfig.giteaConfig.token}`, } @@ -800,20 +804,8 @@ export async function mirrorGitHubRepoToGiteaOrg({ `Mirroring repository ${repository.fullName} to organization ${orgName} as ${targetRepoName}` ); - let cloneAddress = repository.cloneUrl; - - if (repository.isPrivate) { - if (!config.githubConfig?.token) { - throw new Error( - "GitHub token is required to mirror private repositories." - ); - } - - cloneAddress = repository.cloneUrl.replace( - "https://", - `https://${decryptedConfig.githubConfig.token}@` - ); - } + // Use clean clone URL without embedded credentials (Forgejo 12+ security requirement) + const cloneAddress = repository.cloneUrl; // Mark repos as "mirroring" in DB await db @@ -829,18 +821,35 @@ export async function mirrorGitHubRepoToGiteaOrg({ const apiUrl = `${config.giteaConfig.url}/api/v1/repos/migrate`; + // Prepare migration payload + // For private repos, use separate auth fields instead of embedding credentials in URL + // This is required for Forgejo 12+ which rejects URLs with embedded credentials + const migratePayload: any = { + clone_addr: cloneAddress, + uid: giteaOrgId, + repo_name: targetRepoName, + mirror: true, + mirror_interval: config.giteaConfig?.mirrorInterval || "8h", + wiki: config.giteaConfig?.wiki || false, + lfs: config.giteaConfig?.lfs || false, + private: repository.isPrivate, + }; + + // Add authentication for private repositories + if (repository.isPrivate) { + if (!config.githubConfig?.token) { + throw new Error( + "GitHub token is required to mirror private repositories." + ); + } + // Use separate auth fields (required for Forgejo 12+ compatibility) + migratePayload.auth_username = "oauth2"; // GitHub tokens work with any username + migratePayload.auth_token = decryptedConfig.githubConfig.token; + } + const migrateRes = await httpPost( apiUrl, - { - clone_addr: cloneAddress, - uid: giteaOrgId, - repo_name: targetRepoName, - mirror: true, - mirror_interval: config.giteaConfig?.mirrorInterval || "8h", // Set mirror interval - wiki: config.giteaConfig?.wiki || false, // will mirror wiki if it exists - lfs: config.giteaConfig?.lfs || false, // Enable LFS mirroring if configured - private: repository.isPrivate, - }, + migratePayload, { Authorization: `token ${decryptedConfig.giteaConfig.token}`, }