Files
bolt-diy/app/routes/api.github-template.ts
Stijnus f65a6889a1 Fix GitHub template authentication issue
- Add fallback support for VITE_GITHUB_ACCESS_TOKEN environment variable
- Fix 403 Forbidden error when fetching GitHub templates
- Improve authentication compatibility for different token naming conventions
- Ensure all GitHub-based templates work properly
2025-08-30 12:05:17 +02:00

243 lines
7.1 KiB
TypeScript

import { json } from '@remix-run/cloudflare';
import JSZip from 'jszip';
// Function to detect if we're running in Cloudflare
function isCloudflareEnvironment(context: any): boolean {
// Check if we're in production AND have Cloudflare Pages specific env vars
const isProduction = process.env.NODE_ENV === 'production';
const hasCfPagesVars = !!(
context?.cloudflare?.env?.CF_PAGES ||
context?.cloudflare?.env?.CF_PAGES_URL ||
context?.cloudflare?.env?.CF_PAGES_COMMIT_SHA
);
return isProduction && hasCfPagesVars;
}
// Cloudflare-compatible method using GitHub Contents API
async function fetchRepoContentsCloudflare(repo: string, githubToken?: string) {
const baseUrl = 'https://api.github.com';
// Get repository info to find default branch
const repoResponse = await fetch(`${baseUrl}/repos/${repo}`, {
headers: {
Accept: 'application/vnd.github.v3+json',
'User-Agent': 'bolt.diy-app',
...(githubToken ? { Authorization: `Bearer ${githubToken}` } : {}),
},
});
if (!repoResponse.ok) {
throw new Error(`Repository not found: ${repo}`);
}
const repoData = (await repoResponse.json()) as any;
const defaultBranch = repoData.default_branch;
// Get the tree recursively
const treeResponse = await fetch(`${baseUrl}/repos/${repo}/git/trees/${defaultBranch}?recursive=1`, {
headers: {
Accept: 'application/vnd.github.v3+json',
'User-Agent': 'bolt.diy-app',
...(githubToken ? { Authorization: `Bearer ${githubToken}` } : {}),
},
});
if (!treeResponse.ok) {
throw new Error(`Failed to fetch repository tree: ${treeResponse.status}`);
}
const treeData = (await treeResponse.json()) as any;
// Filter for files only (not directories) and limit size
const files = treeData.tree.filter((item: any) => {
if (item.type !== 'blob') {
return false;
}
if (item.path.startsWith('.git/')) {
return false;
}
// Allow lock files even if they're large
const isLockFile =
item.path.endsWith('package-lock.json') ||
item.path.endsWith('yarn.lock') ||
item.path.endsWith('pnpm-lock.yaml');
// For non-lock files, limit size to 100KB
if (!isLockFile && item.size >= 100000) {
return false;
}
return true;
});
// Fetch file contents in batches to avoid overwhelming the API
const batchSize = 10;
const fileContents = [];
for (let i = 0; i < files.length; i += batchSize) {
const batch = files.slice(i, i + batchSize);
const batchPromises = batch.map(async (file: any) => {
try {
const contentResponse = await fetch(`${baseUrl}/repos/${repo}/contents/${file.path}`, {
headers: {
Accept: 'application/vnd.github.v3+json',
'User-Agent': 'bolt.diy-app',
...(githubToken ? { Authorization: `Bearer ${githubToken}` } : {}),
},
});
if (!contentResponse.ok) {
console.warn(`Failed to fetch ${file.path}: ${contentResponse.status}`);
return null;
}
const contentData = (await contentResponse.json()) as any;
const content = atob(contentData.content.replace(/\s/g, ''));
return {
name: file.path.split('/').pop() || '',
path: file.path,
content,
};
} catch (error) {
console.warn(`Error fetching ${file.path}:`, error);
return null;
}
});
const batchResults = await Promise.all(batchPromises);
fileContents.push(...batchResults.filter(Boolean));
// Add a small delay between batches to be respectful to the API
if (i + batchSize < files.length) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
}
return fileContents;
}
// Your existing method for non-Cloudflare environments
async function fetchRepoContentsZip(repo: string, githubToken?: string) {
const baseUrl = 'https://api.github.com';
// Get the latest release
const releaseResponse = await fetch(`${baseUrl}/repos/${repo}/releases/latest`, {
headers: {
Accept: 'application/vnd.github.v3+json',
'User-Agent': 'bolt.diy-app',
...(githubToken ? { Authorization: `Bearer ${githubToken}` } : {}),
},
});
if (!releaseResponse.ok) {
throw new Error(`GitHub API error: ${releaseResponse.status} - ${releaseResponse.statusText}`);
}
const releaseData = (await releaseResponse.json()) as any;
const zipballUrl = releaseData.zipball_url;
// Fetch the zipball
const zipResponse = await fetch(zipballUrl, {
headers: {
...(githubToken ? { Authorization: `Bearer ${githubToken}` } : {}),
},
});
if (!zipResponse.ok) {
throw new Error(`Failed to fetch release zipball: ${zipResponse.status}`);
}
// Get the zip content as ArrayBuffer
const zipArrayBuffer = await zipResponse.arrayBuffer();
// Use JSZip to extract the contents
const zip = await JSZip.loadAsync(zipArrayBuffer);
// Find the root folder name
let rootFolderName = '';
zip.forEach((relativePath) => {
if (!rootFolderName && relativePath.includes('/')) {
rootFolderName = relativePath.split('/')[0];
}
});
// Extract all files
const promises = Object.keys(zip.files).map(async (filename) => {
const zipEntry = zip.files[filename];
// Skip directories
if (zipEntry.dir) {
return null;
}
// Skip the root folder itself
if (filename === rootFolderName) {
return null;
}
// Remove the root folder from the path
let normalizedPath = filename;
if (rootFolderName && filename.startsWith(rootFolderName + '/')) {
normalizedPath = filename.substring(rootFolderName.length + 1);
}
// Get the file content
const content = await zipEntry.async('string');
return {
name: normalizedPath.split('/').pop() || '',
path: normalizedPath,
content,
};
});
const results = await Promise.all(promises);
return results.filter(Boolean);
}
export async function loader({ request, context }: { request: Request; context: any }) {
const url = new URL(request.url);
const repo = url.searchParams.get('repo');
if (!repo) {
return json({ error: 'Repository name is required' }, { status: 400 });
}
try {
// Access environment variables from Cloudflare context or process.env
const githubToken =
context?.cloudflare?.env?.GITHUB_TOKEN || process.env.GITHUB_TOKEN || process.env.VITE_GITHUB_ACCESS_TOKEN;
let fileList;
if (isCloudflareEnvironment(context)) {
fileList = await fetchRepoContentsCloudflare(repo, githubToken);
} else {
fileList = await fetchRepoContentsZip(repo, githubToken);
}
// Filter out .git files for both methods
const filteredFiles = fileList.filter((file: any) => !file.path.startsWith('.git'));
return json(filteredFiles);
} catch (error) {
console.error('Error processing GitHub template:', error);
console.error('Repository:', repo);
console.error('Error details:', error instanceof Error ? error.message : String(error));
return json(
{
error: 'Failed to fetch template files',
details: error instanceof Error ? error.message : String(error),
},
{ status: 500 },
);
}
}