mirror of
https://github.com/RayLabsHQ/gitea-mirror.git
synced 2025-12-08 04:26:44 +03:00
fix: Forgejo 12 compatibility - use separate auth fields for private repos (#102)
## 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-<number> 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
This commit is contained in:
67
.github/workflows/docker-build.yml
vendored
67
.github/workflows/docker-build.yml
vendored
@@ -48,7 +48,6 @@ jobs:
|
|||||||
|
|
||||||
- name: Log into registry
|
- name: Log into registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
if: github.event_name != 'pull_request'
|
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ${{ env.REGISTRY }}
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
@@ -89,6 +88,7 @@ jobs:
|
|||||||
type=sha,prefix=,suffix=,format=short
|
type=sha,prefix=,suffix=,format=short
|
||||||
type=raw,value=latest,enable={{is_default_branch}}
|
type=raw,value=latest,enable={{is_default_branch}}
|
||||||
type=raw,value=${{ steps.tag_version.outputs.VERSION }}
|
type=raw,value=${{ steps.tag_version.outputs.VERSION }}
|
||||||
|
type=ref,event=pr,prefix=pr-
|
||||||
|
|
||||||
# Build and push Docker image
|
# Build and push Docker image
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
@@ -97,20 +97,77 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
platforms: ${{ github.event_name == 'pull_request' && 'linux/amd64' || 'linux/amd64,linux/arm64' }}
|
platforms: ${{ github.event_name == 'pull_request' && 'linux/amd64' || 'linux/amd64,linux/arm64' }}
|
||||||
push: ${{ github.event_name != 'pull_request' }}
|
push: true
|
||||||
load: ${{ github.event_name == 'pull_request' }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
tags: ${{ github.event_name == 'pull_request' && 'gitea-mirror:scan' || steps.meta.outputs.tags }}
|
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
cache-to: type=gha,mode=max
|
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
|
# Wait for image to be available in registry
|
||||||
- name: Wait for image availability
|
- name: Wait for image availability
|
||||||
if: github.event_name != 'pull_request'
|
|
||||||
run: |
|
run: |
|
||||||
echo "Waiting for image to be available in registry..."
|
echo "Waiting for image to be available in registry..."
|
||||||
sleep 5
|
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-<number>\` 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
|
# Docker Scout comprehensive security analysis
|
||||||
- name: Docker Scout - Vulnerability Analysis & Recommendations
|
- name: Docker Scout - Vulnerability Analysis & Recommendations
|
||||||
uses: docker/scout-action@v1
|
uses: docker/scout-action@v1
|
||||||
|
|||||||
125
src/lib/gitea.ts
125
src/lib/gitea.ts
@@ -356,21 +356,8 @@ export const mirrorGithubRepoToGitea = async ({
|
|||||||
status: "mirroring",
|
status: "mirroring",
|
||||||
});
|
});
|
||||||
|
|
||||||
let cloneAddress = repository.cloneUrl;
|
// Use clean clone URL without embedded credentials (Forgejo 12+ security requirement)
|
||||||
|
const 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}@`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const apiUrl = `${config.giteaConfig.url}/api/v1/repos/migrate`;
|
const apiUrl = `${config.giteaConfig.url}/api/v1/repos/migrate`;
|
||||||
|
|
||||||
@@ -384,17 +371,17 @@ export const mirrorGithubRepoToGitea = async ({
|
|||||||
});
|
});
|
||||||
} catch (orgError) {
|
} catch (orgError) {
|
||||||
console.error(`Failed to create/access organization ${repoOwner}: ${orgError instanceof Error ? orgError.message : String(orgError)}`);
|
console.error(`Failed to create/access organization ${repoOwner}: ${orgError instanceof Error ? orgError.message : String(orgError)}`);
|
||||||
|
|
||||||
// Check if we should fallback to user account
|
// Check if we should fallback to user account
|
||||||
if (orgError instanceof Error &&
|
if (orgError instanceof Error &&
|
||||||
(orgError.message.includes('Permission denied') ||
|
(orgError.message.includes('Permission denied') ||
|
||||||
orgError.message.includes('Authentication failed') ||
|
orgError.message.includes('Authentication failed') ||
|
||||||
orgError.message.includes('does not have permission'))) {
|
orgError.message.includes('does not have permission'))) {
|
||||||
console.warn(`[Fallback] Organization creation/access failed. Attempting to mirror to user account instead.`);
|
console.warn(`[Fallback] Organization creation/access failed. Attempting to mirror to user account instead.`);
|
||||||
|
|
||||||
// Update the repository owner to use the user account
|
// Update the repository owner to use the user account
|
||||||
repoOwner = config.giteaConfig.defaultOwner;
|
repoOwner = config.giteaConfig.defaultOwner;
|
||||||
|
|
||||||
// Log this fallback in the database
|
// Log this fallback in the database
|
||||||
await db
|
await db
|
||||||
.update(repositories)
|
.update(repositories)
|
||||||
@@ -420,7 +407,7 @@ export const mirrorGithubRepoToGitea = async ({
|
|||||||
|
|
||||||
if (existingRepo && !existingRepo.mirror) {
|
if (existingRepo && !existingRepo.mirror) {
|
||||||
console.log(`Repository ${targetRepoName} exists but is not a mirror. Handling...`);
|
console.log(`Repository ${targetRepoName} exists but is not a mirror. Handling...`);
|
||||||
|
|
||||||
// Handle the existing non-mirror repository
|
// Handle the existing non-mirror repository
|
||||||
await handleExistingNonMirrorRepo({
|
await handleExistingNonMirrorRepo({
|
||||||
config,
|
config,
|
||||||
@@ -428,25 +415,42 @@ export const mirrorGithubRepoToGitea = async ({
|
|||||||
repoInfo: existingRepo,
|
repoInfo: existingRepo,
|
||||||
strategy: "delete", // Can be configured: "skip", "delete", or "rename"
|
strategy: "delete", // Can be configured: "skip", "delete", or "rename"
|
||||||
});
|
});
|
||||||
|
|
||||||
// After handling, proceed with mirror creation
|
// After handling, proceed with mirror creation
|
||||||
console.log(`Proceeding with mirror creation for ${targetRepoName}`);
|
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(
|
const response = await httpPost(
|
||||||
apiUrl,
|
apiUrl,
|
||||||
{
|
migratePayload,
|
||||||
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",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
Authorization: `token ${decryptedConfig.giteaConfig.token}`,
|
Authorization: `token ${decryptedConfig.giteaConfig.token}`,
|
||||||
}
|
}
|
||||||
@@ -800,20 +804,8 @@ export async function mirrorGitHubRepoToGiteaOrg({
|
|||||||
`Mirroring repository ${repository.fullName} to organization ${orgName} as ${targetRepoName}`
|
`Mirroring repository ${repository.fullName} to organization ${orgName} as ${targetRepoName}`
|
||||||
);
|
);
|
||||||
|
|
||||||
let cloneAddress = repository.cloneUrl;
|
// Use clean clone URL without embedded credentials (Forgejo 12+ security requirement)
|
||||||
|
const 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}@`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mark repos as "mirroring" in DB
|
// Mark repos as "mirroring" in DB
|
||||||
await db
|
await db
|
||||||
@@ -829,18 +821,35 @@ export async function mirrorGitHubRepoToGiteaOrg({
|
|||||||
|
|
||||||
const apiUrl = `${config.giteaConfig.url}/api/v1/repos/migrate`;
|
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(
|
const migrateRes = await httpPost(
|
||||||
apiUrl,
|
apiUrl,
|
||||||
{
|
migratePayload,
|
||||||
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,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
Authorization: `token ${decryptedConfig.giteaConfig.token}`,
|
Authorization: `token ${decryptedConfig.giteaConfig.token}`,
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user