You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

556 lines
18 KiB

/**
* Git HTTP backend API route
* Handles git clone, push, pull operations via git-http-backend
*/
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { RepoManager } from '$lib/services/git/repo-manager.js';
import { nip19 } from 'nostr-tools';
import { spawn, execSync } from 'child_process';
import { existsSync } from 'fs';
import { join, resolve } from 'path';
import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js';
import { NostrClient } from '$lib/services/nostr/nostr-client.js';
import { KIND } from '$lib/types/nostr.js';
import type { NostrEvent } from '$lib/types/nostr.js';
import { verifyNIP98Auth } from '$lib/services/nostr/nip98-auth.js';
import { OwnershipTransferService } from '$lib/services/nostr/ownership-transfer-service.js';
import { MaintainerService } from '$lib/services/nostr/maintainer-service.js';
import { auditLogger } from '$lib/services/security/audit-logger.js';
const repoRoot = process.env.GIT_REPO_ROOT || '/repos';
const repoManager = new RepoManager(repoRoot);
const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS);
const ownershipTransferService = new OwnershipTransferService(DEFAULT_NOSTR_RELAYS);
const maintainerService = new MaintainerService(DEFAULT_NOSTR_RELAYS);
// Path to git-http-backend (common locations)
// Alpine Linux: /usr/lib/git-core/git-http-backend
// Debian/Ubuntu: /usr/lib/git-core/git-http-backend
// macOS: /usr/local/libexec/git-core/git-http-backend or /opt/homebrew/libexec/git-core/git-http-backend
const GIT_HTTP_BACKEND_PATHS = [
'/usr/lib/git-core/git-http-backend', // Alpine, Debian, Ubuntu
'/usr/libexec/git-core/git-http-backend',
'/usr/local/libexec/git-core/git-http-backend',
'/opt/homebrew/libexec/git-core/git-http-backend'
];
/**
* Find git-http-backend executable
*/
function findGitHttpBackend(): string | null {
for (const path of GIT_HTTP_BACKEND_PATHS) {
if (existsSync(path)) {
return path;
}
}
// Try to find it via which/whereis
try {
const result = execSync('which git-http-backend 2>/dev/null || whereis -b git-http-backend 2>/dev/null', { encoding: 'utf-8' });
const lines = result.trim().split(/\s+/);
for (const line of lines) {
if (line.includes('git-http-backend') && existsSync(line)) {
return line;
}
}
} catch {
// Ignore errors
}
return null;
}
/**
* Get repository announcement to extract clone URLs for post-receive sync
*/
async function getRepoAnnouncement(npub: string, repoName: string): Promise<NostrEvent | null> {
try {
const decoded = nip19.decode(npub);
if (decoded.type !== 'npub') {
return null;
}
const pubkey = decoded.data as string;
const events = await nostrClient.fetchEvents([
{
kinds: [KIND.REPO_ANNOUNCEMENT],
authors: [pubkey],
'#d': [repoName],
limit: 1
}
]);
return events.length > 0 ? events[0] : null;
} catch {
return null;
}
}
/**
* Extract clone URLs from repository announcement
*/
function extractCloneUrls(event: NostrEvent): string[] {
const urls: string[] = [];
for (const tag of event.tags) {
if (tag[0] === 'clone') {
for (let i = 1; i < tag.length; i++) {
const url = tag[i];
if (url && typeof url === 'string') {
urls.push(url);
}
}
}
}
return urls;
}
export const GET: RequestHandler = async ({ params, url, request }) => {
const path = params.path || '';
// Parse path: {npub}/{repo-name}.git/{git-path}
const match = path.match(/^([^\/]+)\/([^\/]+)\.git(?:\/(.+))?$/);
if (!match) {
return error(400, 'Invalid path format. Expected: {npub}/{repo-name}.git[/{git-path}]');
}
const [, npub, repoName, gitPath = ''] = match;
const service = url.searchParams.get('service');
// Build absolute request URL for NIP-98 validation
const protocol = request.headers.get('x-forwarded-proto') || (url.protocol === 'https:' ? 'https' : 'http');
const host = request.headers.get('host') || url.host;
const requestUrl = `${protocol}://${host}${url.pathname}${url.search}`;
// Validate npub format
try {
const decoded = nip19.decode(npub);
if (decoded.type !== 'npub') {
return error(400, 'Invalid npub format');
}
} catch {
return error(400, 'Invalid npub format');
}
// Get repository path with security validation
const repoPath = join(repoRoot, npub, `${repoName}.git`);
// Security: Ensure the resolved path is within repoRoot to prevent path traversal
const resolvedPath = resolve(repoPath);
const resolvedRoot = resolve(repoRoot);
if (!resolvedPath.startsWith(resolvedRoot + '/') && resolvedPath !== resolvedRoot) {
return error(403, 'Invalid repository path');
}
if (!repoManager.repoExists(repoPath)) {
return error(404, 'Repository not found');
}
// Check repository privacy for clone/fetch operations
let originalOwnerPubkey: string;
try {
const decoded = nip19.decode(npub);
if (decoded.type !== 'npub') {
return error(400, 'Invalid npub format');
}
originalOwnerPubkey = decoded.data as string;
} catch {
return error(400, 'Invalid npub format');
}
// For clone/fetch operations, check if repo is private
// If private, require NIP-98 authentication
const privacyInfo = await maintainerService.getPrivacyInfo(originalOwnerPubkey, repoName);
if (privacyInfo.isPrivate) {
// Private repos require authentication for clone/fetch
const authHeader = request.headers.get('Authorization');
if (!authHeader || !authHeader.startsWith('Nostr ')) {
return error(401, 'This repository is private. Authentication required.');
}
// Build absolute request URL for NIP-98 validation
const protocol = request.headers.get('x-forwarded-proto') || (url.protocol === 'https:' ? 'https' : 'http');
const host = request.headers.get('host') || url.host;
const requestUrl = `${protocol}://${host}${url.pathname}${url.search}`;
// Verify NIP-98 authentication
const authResult = verifyNIP98Auth(
authHeader,
requestUrl,
request.method,
undefined // GET requests don't have body
);
if (!authResult.valid) {
return error(401, authResult.error || 'Authentication required');
}
// Verify user can view the repo
const canView = await maintainerService.canView(authResult.pubkey || null, originalOwnerPubkey, repoName);
if (!canView) {
const clientIp = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown';
auditLogger.logRepoAccess(
authResult.pubkey || null,
clientIp,
'clone',
`${npub}/${repoName}`,
'denied',
'Insufficient permissions'
);
return error(403, 'You do not have permission to access this private repository.');
}
}
// Find git-http-backend
const gitHttpBackend = findGitHttpBackend();
if (!gitHttpBackend) {
return error(500, 'git-http-backend not found. Please install git.');
}
// Build PATH_INFO
// Security: Since we're setting GIT_PROJECT_ROOT to the specific repo path,
// PATH_INFO should be relative to that repo (just the git operation path)
// For info/refs: /info/refs
// For other operations: /{git-path}
const pathInfo = gitPath ? `/${gitPath}` : `/info/refs`;
// Set up environment variables for git-http-backend
// Security: Use the specific repository path, not the entire repoRoot
// This limits git-http-backend's view to only this repository
const envVars = {
...process.env,
GIT_PROJECT_ROOT: resolve(repoPath), // Use specific repo path, not repoRoot
GIT_HTTP_EXPORT_ALL: '1',
REQUEST_METHOD: request.method,
PATH_INFO: pathInfo,
QUERY_STRING: url.searchParams.toString(),
CONTENT_TYPE: request.headers.get('Content-Type') || '',
CONTENT_LENGTH: request.headers.get('Content-Length') || '0',
HTTP_USER_AGENT: request.headers.get('User-Agent') || '',
};
// Execute git-http-backend with timeout and security hardening
const clientIp = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown';
const operation = service === 'git-upload-pack' || gitPath === 'git-upload-pack' ? 'fetch' : 'clone';
return new Promise((resolve) => {
// Security: Set timeout for git operations (5 minutes max)
const timeoutMs = 5 * 60 * 1000;
let timeoutId: NodeJS.Timeout;
const gitProcess = spawn(gitHttpBackend, [], {
env: envVars,
stdio: ['pipe', 'pipe', 'pipe'],
// Security: Don't inherit parent's environment fully
shell: false
});
timeoutId = setTimeout(() => {
gitProcess.kill('SIGTERM');
auditLogger.logRepoAccess(
originalOwnerPubkey,
clientIp,
operation,
`${npub}/${repoName}`,
'failure',
'Operation timeout'
);
resolve(error(504, 'Git operation timeout'));
}, timeoutMs);
const chunks: Buffer[] = [];
let errorOutput = '';
gitProcess.stdout.on('data', (chunk: Buffer) => {
chunks.push(chunk);
});
gitProcess.stderr.on('data', (chunk: Buffer) => {
errorOutput += chunk.toString();
});
gitProcess.on('close', (code) => {
clearTimeout(timeoutId);
// Log audit entry after operation completes
if (code === 0) {
// Success: operation completed successfully
auditLogger.logRepoAccess(
originalOwnerPubkey,
clientIp,
operation,
`${npub}/${repoName}`,
'success'
);
} else {
// Failure: operation failed
auditLogger.logRepoAccess(
originalOwnerPubkey,
clientIp,
operation,
`${npub}/${repoName}`,
'failure',
errorOutput || 'Unknown error'
);
}
if (code !== 0 && chunks.length === 0) {
resolve(error(500, `git-http-backend error: ${errorOutput || 'Unknown error'}`));
return;
}
const body = Buffer.concat(chunks);
// Determine content type based on service
let contentType = 'application/x-git-upload-pack-result';
if (service === 'git-receive-pack' || gitPath === 'git-receive-pack') {
contentType = 'application/x-git-receive-pack-result';
} else if (service === 'git-upload-pack' || gitPath === 'git-upload-pack') {
contentType = 'application/x-git-upload-pack-result';
} else if (pathInfo.includes('info/refs')) {
contentType = 'text/plain; charset=utf-8';
}
resolve(new Response(body, {
status: code === 0 ? 200 : 500,
headers: {
'Content-Type': contentType,
'Content-Length': body.length.toString(),
}
}));
});
gitProcess.on('error', (err) => {
clearTimeout(timeoutId);
// Log audit entry for process error
auditLogger.logRepoAccess(
originalOwnerPubkey,
clientIp,
operation,
`${npub}/${repoName}`,
'failure',
`Process error: ${err.message}`
);
resolve(error(500, `Failed to execute git-http-backend: ${err.message}`));
});
});
};
export const POST: RequestHandler = async ({ params, url, request }) => {
const path = params.path || '';
// Parse path: {npub}/{repo-name}.git/{git-path}
const match = path.match(/^([^\/]+)\/([^\/]+)\.git(?:\/(.+))?$/);
if (!match) {
return error(400, 'Invalid path format. Expected: {npub}/{repo-name}.git[/{git-path}]');
}
const [, npub, repoName, gitPath = ''] = match;
// Validate npub format and decode to get pubkey
let originalOwnerPubkey: string;
try {
const decoded = nip19.decode(npub);
if (decoded.type !== 'npub') {
return error(400, 'Invalid npub format');
}
originalOwnerPubkey = decoded.data as string;
} catch {
return error(400, 'Invalid npub format');
}
// Get repository path with security validation
const repoPath = join(repoRoot, npub, `${repoName}.git`);
// Security: Ensure the resolved path is within repoRoot to prevent path traversal
const resolvedPath = resolve(repoPath);
const resolvedRoot = resolve(repoRoot);
if (!resolvedPath.startsWith(resolvedRoot + '/') && resolvedPath !== resolvedRoot) {
return error(403, 'Invalid repository path');
}
if (!repoManager.repoExists(repoPath)) {
return error(404, 'Repository not found');
}
// Get current owner (may be different if ownership was transferred)
const currentOwnerPubkey = await ownershipTransferService.getCurrentOwner(originalOwnerPubkey, repoName);
// Build absolute request URL for NIP-98 validation
const protocol = request.headers.get('x-forwarded-proto') || (url.protocol === 'https:' ? 'https' : 'http');
const host = request.headers.get('host') || url.host;
const requestUrl = `${protocol}://${host}${url.pathname}${url.search}`;
// Get request body (read once, use for both auth and git-http-backend)
const body = await request.arrayBuffer();
const bodyBuffer = Buffer.from(body);
// For push operations (git-receive-pack), require NIP-98 authentication
if (gitPath === 'git-receive-pack' || path.includes('git-receive-pack')) {
// Verify NIP-98 authentication
const authResult = verifyNIP98Auth(
request.headers.get('Authorization'),
requestUrl,
request.method,
bodyBuffer.length > 0 ? bodyBuffer : undefined
);
if (!authResult.valid) {
return error(401, authResult.error || 'Authentication required');
}
// Verify pubkey matches current repo owner (may have been transferred)
if (authResult.pubkey !== currentOwnerPubkey) {
return error(403, 'Event pubkey does not match repository owner');
}
}
// Find git-http-backend
const gitHttpBackend = findGitHttpBackend();
if (!gitHttpBackend) {
return error(500, 'git-http-backend not found. Please install git.');
}
// Build PATH_INFO
// Security: Since we're setting GIT_PROJECT_ROOT to the specific repo path,
// PATH_INFO should be relative to that repo (just the git operation path)
const pathInfo = gitPath ? `/${gitPath}` : `/`;
// Set up environment variables for git-http-backend
// Security: Use the specific repository path, not the entire repoRoot
// This limits git-http-backend's view to only this repository
const envVars = {
...process.env,
GIT_PROJECT_ROOT: resolve(repoPath), // Use specific repo path, not repoRoot
GIT_HTTP_EXPORT_ALL: '1',
REQUEST_METHOD: request.method,
PATH_INFO: pathInfo,
QUERY_STRING: url.searchParams.toString(),
CONTENT_TYPE: request.headers.get('Content-Type') || 'application/x-git-receive-pack-request',
CONTENT_LENGTH: bodyBuffer.length.toString(),
HTTP_USER_AGENT: request.headers.get('User-Agent') || '',
};
// Execute git-http-backend with timeout and security hardening
const clientIp = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown';
const operation = gitPath === 'git-receive-pack' || path.includes('git-receive-pack') ? 'push' : 'fetch';
return new Promise((resolve) => {
// Security: Set timeout for git operations (5 minutes max)
const timeoutMs = 5 * 60 * 1000;
let timeoutId: NodeJS.Timeout;
const gitProcess = spawn(gitHttpBackend, [], {
env: envVars,
stdio: ['pipe', 'pipe', 'pipe'],
// Security: Don't inherit parent's environment fully
shell: false
});
timeoutId = setTimeout(() => {
gitProcess.kill('SIGTERM');
auditLogger.logRepoAccess(
currentOwnerPubkey,
clientIp,
operation,
`${npub}/${repoName}`,
'failure',
'Operation timeout'
);
resolve(error(504, 'Git operation timeout'));
}, timeoutMs);
const chunks: Buffer[] = [];
let errorOutput = '';
// Write request body to git-http-backend stdin
gitProcess.stdin.write(bodyBuffer);
gitProcess.stdin.end();
gitProcess.stdout.on('data', (chunk: Buffer) => {
chunks.push(chunk);
});
gitProcess.stderr.on('data', (chunk: Buffer) => {
errorOutput += chunk.toString();
});
gitProcess.on('close', async (code) => {
clearTimeout(timeoutId);
// Log audit entry after operation completes
if (code === 0) {
// Success: operation completed successfully
auditLogger.logRepoAccess(
currentOwnerPubkey,
clientIp,
operation,
`${npub}/${repoName}`,
'success'
);
} else {
// Failure: operation failed
auditLogger.logRepoAccess(
currentOwnerPubkey,
clientIp,
operation,
`${npub}/${repoName}`,
'failure',
errorOutput || 'Git operation failed'
);
}
// If this was a successful push, sync to other remotes
if (code === 0 && (gitPath === 'git-receive-pack' || path.includes('git-receive-pack'))) {
try {
const announcement = await getRepoAnnouncement(npub, repoName);
if (announcement) {
const cloneUrls = extractCloneUrls(announcement);
const gitDomain = process.env.GIT_DOMAIN || 'localhost:6543';
const otherUrls = cloneUrls.filter(url => !url.includes(gitDomain));
if (otherUrls.length > 0) {
// Sync in background (don't wait for it)
repoManager.syncToRemotes(repoPath, otherUrls).catch(err => {
console.error('Failed to sync to remotes after push:', err);
});
}
}
} catch (err) {
console.error('Failed to sync to remotes:', err);
// Don't fail the request if sync fails
}
}
if (code !== 0 && chunks.length === 0) {
resolve(error(500, `git-http-backend error: ${errorOutput || 'Unknown error'}`));
return;
}
const responseBody = Buffer.concat(chunks);
// Determine content type
let contentType = 'application/x-git-receive-pack-result';
if (gitPath === 'git-upload-pack' || path.includes('git-upload-pack')) {
contentType = 'application/x-git-upload-pack-result';
}
resolve(new Response(responseBody, {
status: code === 0 ? 200 : 500,
headers: {
'Content-Type': contentType,
'Content-Length': responseBody.length.toString(),
}
}));
});
gitProcess.on('error', (err) => {
clearTimeout(timeoutId);
// Log audit entry for process error
auditLogger.logRepoAccess(
currentOwnerPubkey,
clientIp,
operation,
`${npub}/${repoName}`,
'failure',
`Process error: ${err.message}`
);
resolve(error(500, `Failed to execute git-http-backend: ${err.message}`));
});
});
};