Browse Source

high-priority items

main
Silberengel 4 weeks ago
parent
commit
6299eccb55
  1. 243
      src/lib/services/git/file-manager.ts
  2. 67
      src/lib/services/git/repo-manager.ts
  3. 141
      src/lib/services/nostr/nostr-client.ts
  4. 89
      src/lib/services/nostr/relay-write-proof.ts
  5. 391
      src/routes/api/git/[...path]/+server.ts

243
src/lib/services/git/file-manager.ts

@ -5,7 +5,7 @@ @@ -5,7 +5,7 @@
import simpleGit, { type SimpleGit } from 'simple-git';
import { readFile, readdir, stat } from 'fs/promises';
import { join, dirname } from 'path';
import { join, dirname, normalize, resolve } from 'path';
import { existsSync } from 'fs';
import { RepoManager } from './repo-manager.js';
@ -59,10 +59,101 @@ export class FileManager { @@ -59,10 +59,101 @@ export class FileManager {
return join(this.repoRoot, npub, `${repoName}.git`);
}
/**
* Validate and sanitize file path to prevent path traversal attacks
*/
private validateFilePath(filePath: string): { valid: boolean; error?: string; normalized?: string } {
if (!filePath || typeof filePath !== 'string') {
return { valid: false, error: 'File path must be a non-empty string' };
}
// Normalize the path (resolves .. and .)
const normalized = normalize(filePath);
// Check for path traversal attempts
if (normalized.includes('..')) {
return { valid: false, error: 'Path traversal detected (..)' };
}
// Check for absolute paths
if (normalized.startsWith('/')) {
return { valid: false, error: 'Absolute paths are not allowed' };
}
// Check for null bytes
if (normalized.includes('\0')) {
return { valid: false, error: 'Null bytes are not allowed in paths' };
}
// Check for control characters
if (/[\x00-\x1f\x7f]/.test(normalized)) {
return { valid: false, error: 'Control characters are not allowed in paths' };
}
// Limit path length (reasonable limit)
if (normalized.length > 4096) {
return { valid: false, error: 'Path is too long (max 4096 characters)' };
}
return { valid: true, normalized };
}
/**
* Validate repository name to prevent injection attacks
*/
private validateRepoName(repoName: string): { valid: boolean; error?: string } {
if (!repoName || typeof repoName !== 'string') {
return { valid: false, error: 'Repository name must be a non-empty string' };
}
// Check length
if (repoName.length > 100) {
return { valid: false, error: 'Repository name is too long (max 100 characters)' };
}
// Check for invalid characters (alphanumeric, hyphens, underscores, dots)
if (!/^[a-zA-Z0-9._-]+$/.test(repoName)) {
return { valid: false, error: 'Repository name contains invalid characters' };
}
// Check for path traversal
if (repoName.includes('..') || repoName.includes('/') || repoName.includes('\\')) {
return { valid: false, error: 'Repository name contains invalid path characters' };
}
return { valid: true };
}
/**
* Validate npub format
*/
private validateNpub(npub: string): { valid: boolean; error?: string } {
if (!npub || typeof npub !== 'string') {
return { valid: false, error: 'npub must be a non-empty string' };
}
// Basic npub format check (starts with npub, base58 encoded)
if (!npub.startsWith('npub1') || npub.length < 10 || npub.length > 100) {
return { valid: false, error: 'Invalid npub format' };
}
return { valid: true };
}
/**
* Check if repository exists
*/
repoExists(npub: string, repoName: string): boolean {
// Validate inputs
const npubValidation = this.validateNpub(npub);
if (!npubValidation.valid) {
return false;
}
const repoValidation = this.validateRepoName(repoName);
if (!repoValidation.valid) {
return false;
}
const repoPath = this.getRepoPath(npub, repoName);
return this.repoManager.repoExists(repoPath);
}
@ -71,6 +162,21 @@ export class FileManager { @@ -71,6 +162,21 @@ export class FileManager {
* List files and directories in a repository at a given path
*/
async listFiles(npub: string, repoName: string, ref: string = 'HEAD', path: string = ''): Promise<FileEntry[]> {
// Validate inputs
const npubValidation = this.validateNpub(npub);
if (!npubValidation.valid) {
throw new Error(`Invalid npub: ${npubValidation.error}`);
}
const repoValidation = this.validateRepoName(repoName);
if (!repoValidation.valid) {
throw new Error(`Invalid repository name: ${repoValidation.error}`);
}
const pathValidation = this.validateFilePath(path);
if (!pathValidation.valid) {
throw new Error(`Invalid file path: ${pathValidation.error}`);
}
const repoPath = this.getRepoPath(npub, repoName);
if (!this.repoExists(npub, repoName)) {
@ -123,6 +229,21 @@ export class FileManager { @@ -123,6 +229,21 @@ export class FileManager {
* Get file content from a repository
*/
async getFileContent(npub: string, repoName: string, filePath: string, ref: string = 'HEAD'): Promise<FileContent> {
// Validate inputs
const npubValidation = this.validateNpub(npub);
if (!npubValidation.valid) {
throw new Error(`Invalid npub: ${npubValidation.error}`);
}
const repoValidation = this.validateRepoName(repoName);
if (!repoValidation.valid) {
throw new Error(`Invalid repository name: ${repoValidation.error}`);
}
const pathValidation = this.validateFilePath(filePath);
if (!pathValidation.valid) {
throw new Error(`Invalid file path: ${pathValidation.error}`);
}
const repoPath = this.getRepoPath(npub, repoName);
if (!this.repoExists(npub, repoName)) {
@ -163,6 +284,43 @@ export class FileManager { @@ -163,6 +284,43 @@ export class FileManager {
authorEmail: string,
branch: string = 'main'
): Promise<void> {
// Validate inputs
const npubValidation = this.validateNpub(npub);
if (!npubValidation.valid) {
throw new Error(`Invalid npub: ${npubValidation.error}`);
}
const repoValidation = this.validateRepoName(repoName);
if (!repoValidation.valid) {
throw new Error(`Invalid repository name: ${repoValidation.error}`);
}
const pathValidation = this.validateFilePath(filePath);
if (!pathValidation.valid) {
throw new Error(`Invalid file path: ${pathValidation.error}`);
}
// Validate content size (prevent extremely large files)
const maxFileSize = 100 * 1024 * 1024; // 100 MB per file
if (Buffer.byteLength(content, 'utf-8') > maxFileSize) {
throw new Error(`File is too large (max ${maxFileSize / 1024 / 1024} MB)`);
}
// Validate commit message
if (!commitMessage || typeof commitMessage !== 'string' || commitMessage.trim().length === 0) {
throw new Error('Commit message is required');
}
if (commitMessage.length > 1000) {
throw new Error('Commit message is too long (max 1000 characters)');
}
// Validate author info
if (!authorName || typeof authorName !== 'string' || authorName.trim().length === 0) {
throw new Error('Author name is required');
}
if (!authorEmail || typeof authorEmail !== 'string' || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(authorEmail)) {
throw new Error('Valid author email is required');
}
const repoPath = this.getRepoPath(npub, repoName);
if (!this.repoExists(npub, repoName)) {
@ -170,6 +328,12 @@ export class FileManager { @@ -170,6 +328,12 @@ export class FileManager {
}
try {
// Check repository size before writing
const repoSizeCheck = await this.repoManager.checkRepoSizeLimit(repoPath);
if (!repoSizeCheck.withinLimit) {
throw new Error(repoSizeCheck.error || 'Repository size limit exceeded');
}
// Clone bare repo to a temporary working directory (non-bare)
const workDir = join(this.repoRoot, npub, `${repoName}.work`);
const { rm } = await import('fs/promises');
@ -194,10 +358,18 @@ export class FileManager { @@ -194,10 +358,18 @@ export class FileManager {
await workGit.checkout(['-b', branch]);
}
// Write the file
const fullFilePath = join(workDir, filePath);
// Write the file (use validated path)
const validatedPath = pathValidation.normalized || filePath;
const fullFilePath = join(workDir, validatedPath);
const fileDir = dirname(fullFilePath);
// Additional security: ensure the resolved path is still within workDir
const resolvedPath = resolve(fullFilePath);
const resolvedWorkDir = resolve(workDir);
if (!resolvedPath.startsWith(resolvedWorkDir)) {
throw new Error('Path validation failed: resolved path outside work directory');
}
// Ensure directory exists
if (!existsSync(fileDir)) {
const { mkdir } = await import('fs/promises');
@ -207,8 +379,8 @@ export class FileManager { @@ -207,8 +379,8 @@ export class FileManager {
const { writeFile: writeFileFs } = await import('fs/promises');
await writeFileFs(fullFilePath, content, 'utf-8');
// Stage the file
await workGit.add(filePath);
// Stage the file (use validated path)
await workGit.add(validatedPath);
// Commit
await workGit.commit(commitMessage, [filePath], {
@ -278,6 +450,34 @@ export class FileManager { @@ -278,6 +450,34 @@ export class FileManager {
authorEmail: string,
branch: string = 'main'
): Promise<void> {
// Validate inputs
const npubValidation = this.validateNpub(npub);
if (!npubValidation.valid) {
throw new Error(`Invalid npub: ${npubValidation.error}`);
}
const repoValidation = this.validateRepoName(repoName);
if (!repoValidation.valid) {
throw new Error(`Invalid repository name: ${repoValidation.error}`);
}
const pathValidation = this.validateFilePath(filePath);
if (!pathValidation.valid) {
throw new Error(`Invalid file path: ${pathValidation.error}`);
}
// Validate commit message
if (!commitMessage || typeof commitMessage !== 'string' || commitMessage.trim().length === 0) {
throw new Error('Commit message is required');
}
// Validate author info
if (!authorName || typeof authorName !== 'string' || authorName.trim().length === 0) {
throw new Error('Author name is required');
}
if (!authorEmail || typeof authorEmail !== 'string' || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(authorEmail)) {
throw new Error('Valid author email is required');
}
const repoPath = this.getRepoPath(npub, repoName);
if (!this.repoExists(npub, repoName)) {
@ -303,15 +503,24 @@ export class FileManager { @@ -303,15 +503,24 @@ export class FileManager {
await workGit.checkout(['-b', branch]);
}
// Remove the file
const fullFilePath = join(workDir, filePath);
// Remove the file (use validated path)
const validatedPath = pathValidation.normalized || filePath;
const fullFilePath = join(workDir, validatedPath);
// Additional security: ensure the resolved path is still within workDir
const resolvedPath = resolve(fullFilePath);
const resolvedWorkDir = resolve(workDir);
if (!resolvedPath.startsWith(resolvedWorkDir)) {
throw new Error('Path validation failed: resolved path outside work directory');
}
if (existsSync(fullFilePath)) {
const { unlink } = await import('fs/promises');
await unlink(fullFilePath);
}
// Stage the deletion
await workGit.rm([filePath]);
// Stage the deletion (use validated path)
await workGit.rm([validatedPath]);
// Commit
await workGit.commit(commitMessage, [filePath], {
@ -491,7 +700,7 @@ export class FileManager { @@ -491,7 +700,7 @@ export class FileManager {
if (stats.files && files.length > 0) {
for (const statFile of stats.files) {
const file = files.find(f => f.file === statFile.file);
if (file) {
if (file && 'insertions' in statFile && 'deletions' in statFile) {
file.additions = statFile.insertions;
file.deletions = statFile.deletions;
}
@ -528,14 +737,20 @@ export class FileManager { @@ -528,14 +737,20 @@ export class FileManager {
try {
if (message) {
// Create annotated tag
const tagOptions: string[] = ['-a', tagName, '-m', message];
await git.addTag(tagName);
// Note: simple-git addTag doesn't support message directly, use raw command
if (ref !== 'HEAD') {
tagOptions.push(ref);
await git.raw(['tag', '-a', tagName, '-m', message, ref]);
} else {
await git.raw(['tag', '-a', tagName, '-m', message]);
}
await git.addTag(tagName, message);
} else {
// Create lightweight tag
await git.addTag(tagName);
if (ref !== 'HEAD') {
await git.raw(['tag', tagName, ref]);
} else {
await git.addTag(tagName);
}
}
} catch (error) {
console.error('Error creating tag:', error);

67
src/lib/services/git/repo-manager.ts

@ -5,8 +5,9 @@ @@ -5,8 +5,9 @@
import { exec } from 'child_process';
import { promisify } from 'util';
import { existsSync, mkdirSync, writeFileSync } from 'fs';
import { existsSync, mkdirSync, writeFileSync, statSync } from 'fs';
import { join } from 'path';
import { readdir } from 'fs/promises';
import type { NostrEvent } from '../../types/nostr.js';
import { GIT_DOMAIN } from '../../config.js';
import { generateVerificationFile, VERIFICATION_FILE_PATH } from '../nostr/repo-verification.js';
@ -149,6 +150,70 @@ export class RepoManager { @@ -149,6 +150,70 @@ export class RepoManager {
return existsSync(repoPath);
}
/**
* Get repository size in bytes
* Returns the total size of the repository directory
*/
async getRepoSize(repoPath: string): Promise<number> {
if (!existsSync(repoPath)) {
return 0;
}
let totalSize = 0;
async function calculateSize(dirPath: string): Promise<number> {
let size = 0;
try {
const entries = await readdir(dirPath, { withFileTypes: true });
for (const entry of entries) {
const fullPath = join(dirPath, entry.name);
if (entry.isDirectory()) {
size += await calculateSize(fullPath);
} else if (entry.isFile()) {
try {
const stats = statSync(fullPath);
size += stats.size;
} catch {
// Ignore errors accessing files
}
}
}
} catch {
// Ignore errors accessing directories
}
return size;
}
totalSize = await calculateSize(repoPath);
return totalSize;
}
/**
* Check if repository size exceeds the maximum (2 GB)
*/
async checkRepoSizeLimit(repoPath: string, maxSizeBytes: number = 2 * 1024 * 1024 * 1024): Promise<{ withinLimit: boolean; currentSize: number; maxSize: number; error?: string }> {
try {
const currentSize = await this.getRepoSize(repoPath);
const withinLimit = currentSize <= maxSizeBytes;
return {
withinLimit,
currentSize,
maxSize: maxSizeBytes,
...(withinLimit ? {} : { error: `Repository size (${(currentSize / 1024 / 1024 / 1024).toFixed(2)} GB) exceeds maximum (${(maxSizeBytes / 1024 / 1024 / 1024).toFixed(2)} GB)` })
};
} catch (error) {
return {
withinLimit: false,
currentSize: 0,
maxSize: maxSizeBytes,
error: `Failed to check repository size: ${error instanceof Error ? error.message : String(error)}`
};
}
}
/**
* Create verification file in a new repository
* This proves the repository is owned by the announcement author

141
src/lib/services/nostr/nostr-client.ts

@ -39,30 +39,74 @@ export class NostrClient { @@ -39,30 +39,74 @@ export class NostrClient {
return new Promise((resolve, reject) => {
const ws = new WebSocket(relay);
const events: NostrEvent[] = [];
let resolved = false;
let timeoutId: ReturnType<typeof setTimeout> | null = null;
const cleanup = () => {
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
try {
ws.close();
} catch {
// Ignore errors during cleanup
}
}
};
const resolveOnce = (value: NostrEvent[]) => {
if (!resolved) {
resolved = true;
cleanup();
resolve(value);
}
};
const rejectOnce = (error: Error) => {
if (!resolved) {
resolved = true;
cleanup();
reject(error);
}
};
ws.onopen = () => {
ws.send(JSON.stringify(['REQ', 'sub', ...filters]));
try {
ws.send(JSON.stringify(['REQ', 'sub', ...filters]));
} catch (error) {
rejectOnce(error instanceof Error ? error : new Error(String(error)));
}
};
ws.onmessage = (event: MessageEvent) => {
const message = JSON.parse(event.data);
try {
const message = JSON.parse(event.data);
if (message[0] === 'EVENT') {
events.push(message[2]);
} else if (message[0] === 'EOSE') {
ws.close();
resolve(events);
if (message[0] === 'EVENT') {
events.push(message[2]);
} else if (message[0] === 'EOSE') {
resolveOnce(events);
}
} catch (error) {
// Ignore parse errors, continue receiving events
}
};
ws.onerror = (error) => {
ws.close();
reject(error);
rejectOnce(new Error(`WebSocket error for ${relay}: ${error}`));
};
setTimeout(() => {
ws.close();
resolve(events);
ws.onclose = () => {
// If we haven't resolved yet, resolve with what we have
if (!resolved) {
resolveOnce(events);
}
};
timeoutId = setTimeout(() => {
resolveOnce(events);
}, 5000);
});
}
@ -89,33 +133,76 @@ export class NostrClient { @@ -89,33 +133,76 @@ export class NostrClient {
private async publishToRelay(relay: string, nostrEvent: NostrEvent): Promise<void> {
return new Promise((resolve, reject) => {
const ws = new WebSocket(relay);
let resolved = false;
let timeoutId: ReturnType<typeof setTimeout> | null = null;
const cleanup = () => {
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
try {
ws.close();
} catch {
// Ignore errors during cleanup
}
}
};
const resolveOnce = () => {
if (!resolved) {
resolved = true;
cleanup();
resolve();
}
};
const rejectOnce = (error: Error) => {
if (!resolved) {
resolved = true;
cleanup();
reject(error);
}
};
ws.onopen = () => {
ws.send(JSON.stringify(['EVENT', nostrEvent]));
try {
ws.send(JSON.stringify(['EVENT', nostrEvent]));
} catch (error) {
rejectOnce(error instanceof Error ? error : new Error(String(error)));
}
};
ws.onmessage = (event: MessageEvent) => {
const message = JSON.parse(event.data);
if (message[0] === 'OK' && message[1] === nostrEvent.id) {
if (message[2] === true) {
ws.close();
resolve();
} else {
ws.close();
reject(new Error(message[3] || 'Publish rejected'));
try {
const message = JSON.parse(event.data);
if (message[0] === 'OK' && message[1] === nostrEvent.id) {
if (message[2] === true) {
resolveOnce();
} else {
rejectOnce(new Error(message[3] || 'Publish rejected'));
}
}
} catch (error) {
// Ignore parse errors, continue waiting for OK message
}
};
ws.onerror = (error) => {
ws.close();
reject(error);
rejectOnce(new Error(`WebSocket error for ${relay}: ${error}`));
};
ws.onclose = () => {
// If we haven't resolved yet, it's an unexpected close
if (!resolved) {
rejectOnce(new Error('WebSocket closed unexpectedly'));
}
};
setTimeout(() => {
ws.close();
reject(new Error('Timeout'));
timeoutId = setTimeout(() => {
rejectOnce(new Error('Publish timeout'));
}, 5000);
});
}

89
src/lib/services/nostr/relay-write-proof.ts

@ -0,0 +1,89 @@ @@ -0,0 +1,89 @@
/**
* Service for verifying that a user can write to at least one default relay
* This replaces rate limiting by requiring proof of relay write capability
*/
import { verifyEvent, getEventHash } from 'nostr-tools';
import type { NostrEvent } from '../../types/nostr.js';
import { NostrClient } from './nostr-client.js';
import { DEFAULT_NOSTR_RELAYS } from '../../config.js';
export interface RelayWriteProof {
event: NostrEvent;
relay: string;
timestamp: number;
}
/**
* Verify that a user can write to at least one default relay
* The proof should be a recent event (within last 5 minutes) published to a default relay
*/
export async function verifyRelayWriteProof(
proofEvent: NostrEvent,
userPubkey: string,
relays: string[] = DEFAULT_NOSTR_RELAYS
): Promise<{ valid: boolean; error?: string; relay?: string }> {
// Verify the event signature
if (!verifyEvent(proofEvent)) {
return { valid: false, error: 'Invalid event signature' };
}
// Verify the pubkey matches
if (proofEvent.pubkey !== userPubkey) {
return { valid: false, error: 'Event pubkey does not match user pubkey' };
}
// Verify the event is recent (within last 5 minutes)
const now = Math.floor(Date.now() / 1000);
const eventAge = now - proofEvent.created_at;
if (eventAge > 300) { // 5 minutes
return { valid: false, error: 'Proof event is too old (must be within 5 minutes)' };
}
if (eventAge < 0) {
return { valid: false, error: 'Proof event has future timestamp' };
}
// Try to verify the event exists on at least one default relay
const nostrClient = new NostrClient(relays);
try {
const events = await nostrClient.fetchEvents([
{
ids: [proofEvent.id],
authors: [userPubkey],
limit: 1
}
]);
if (events.length === 0) {
return { valid: false, error: 'Proof event not found on any default relay' };
}
// Verify the fetched event matches
const fetchedEvent = events[0];
if (fetchedEvent.id !== proofEvent.id) {
return { valid: false, error: 'Fetched event does not match proof event' };
}
// Determine which relay(s) have the event (we can't know for sure, but we verified it exists)
return { valid: true, relay: relays[0] }; // Return first relay as indication
} catch (error) {
return {
valid: false,
error: `Failed to verify proof on relays: ${error instanceof Error ? error.message : String(error)}`
};
}
}
/**
* Create a proof event that can be used to prove relay write capability
* This is a simple kind 1 (text note) event with a specific content
*/
export function createProofEvent(userPubkey: string, content: string = 'gitrepublic-write-proof'): Omit<NostrEvent, 'sig' | 'id'> {
return {
kind: 1,
pubkey: userPubkey,
created_at: Math.floor(Date.now() / 1000),
content: content,
tags: [['t', 'gitrepublic-proof']]
};
}

391
src/routes/api/git/[...path]/+server.ts

@ -3,38 +3,387 @@ @@ -3,38 +3,387 @@
* Handles git clone, push, pull operations via git-http-backend
*/
import { json } from '@sveltejs/kit';
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { RepoManager } from '$lib/services/git/repo-manager.js';
import { verifyEvent } from 'nostr-tools';
import { nip19 } from 'nostr-tools';
import { spawn, execSync } from 'child_process';
import { existsSync } from 'fs';
import { join } 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';
// This will be implemented to proxy requests to git-http-backend
// For now, return a placeholder
export const GET: RequestHandler = async ({ params, url }) => {
const repoRoot = process.env.GIT_REPO_ROOT || '/repos';
const repoManager = new RepoManager(repoRoot);
const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS);
// Path to git-http-backend (common locations)
const GIT_HTTP_BACKEND_PATHS = [
'/usr/lib/git-core/git-http-backend',
'/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;
}
/**
* Verify NIP-98 authentication for push operations
*/
async function verifyNIP98Auth(
request: Request,
expectedPubkey: string
): Promise<{ valid: boolean; error?: string }> {
const authHeader = request.headers.get('Authorization');
if (!authHeader || !authHeader.startsWith('Nostr ')) {
return { valid: false, error: 'Missing or invalid Authorization header (expected "Nostr <event>")' };
}
try {
const eventJson = authHeader.slice(7); // Remove "Nostr " prefix
const nostrEvent: NostrEvent = JSON.parse(eventJson);
// Verify event signature
if (!verifyEvent(nostrEvent)) {
return { valid: false, error: 'Invalid event signature' };
}
// Verify pubkey matches repo owner
if (nostrEvent.pubkey !== expectedPubkey) {
return { valid: false, error: 'Event pubkey does not match repository owner' };
}
// Verify event is recent (within last 5 minutes)
const now = Math.floor(Date.now() / 1000);
const eventAge = now - nostrEvent.created_at;
if (eventAge > 300) {
return { valid: false, error: 'Authentication event is too old (must be within 5 minutes)' };
}
if (eventAge < 0) {
return { valid: false, error: 'Authentication event has future timestamp' };
}
// Verify the event method and URL match the request
const methodTag = nostrEvent.tags.find(t => t[0] === 'method');
const urlTag = nostrEvent.tags.find(t => t[0] === 'u');
if (methodTag && methodTag[1] !== request.method) {
return { valid: false, error: 'Event method does not match request method' };
}
return { valid: true };
} catch (err) {
return {
valid: false,
error: `Failed to parse or verify authentication: ${err instanceof Error ? err.message : String(err)}`
};
}
}
/**
* 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');
// TODO: Implement git-http-backend integration
// This should:
// 1. Authenticate using NIP-98 (HTTP auth with Nostr)
// 2. Map URL path to git repo ({domain}/{npub}/{repo-name}.git)
// 3. Proxy request to git-http-backend
// 4. Handle git smart HTTP protocol
return json({
message: 'Git HTTP backend not yet implemented',
path,
service
// 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
const repoPath = join(repoRoot, npub, `${repoName}.git`);
if (!repoManager.repoExists(repoPath)) {
return error(404, 'Repository not found');
}
// Find git-http-backend
const gitHttpBackend = findGitHttpBackend();
if (!gitHttpBackend) {
return error(500, 'git-http-backend not found. Please install git.');
}
// Build PATH_INFO
// For info/refs, git-http-backend expects: /{npub}/{repo-name}.git/info/refs
// For other operations: /{npub}/{repo-name}.git/{git-path}
const pathInfo = gitPath ? `/${npub}/${repoName}.git/${gitPath}` : `/${npub}/${repoName}.git/info/refs`;
// Set up environment variables for git-http-backend
const envVars = {
...process.env,
GIT_PROJECT_ROOT: 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
return new Promise((resolve) => {
const gitProcess = spawn(gitHttpBackend, [], {
env: envVars,
stdio: ['pipe', 'pipe', 'pipe']
});
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) => {
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) => {
resolve(error(500, `Failed to execute git-http-backend: ${err.message}`));
});
});
};
export const POST: RequestHandler = async ({ params, url, request }) => {
const path = params.path || '';
const service = url.searchParams.get('service');
// TODO: Implement git-http-backend integration for push operations
// 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 repoOwnerPubkey: string;
try {
const decoded = nip19.decode(npub);
if (decoded.type !== 'npub') {
return error(400, 'Invalid npub format');
}
repoOwnerPubkey = decoded.data as string;
} catch {
return error(400, 'Invalid npub format');
}
// Get repository path
const repoPath = join(repoRoot, npub, `${repoName}.git`);
if (!repoManager.repoExists(repoPath)) {
return error(404, 'Repository not found');
}
// For push operations (git-receive-pack), require NIP-98 authentication
if (gitPath === 'git-receive-pack' || path.includes('git-receive-pack')) {
const authResult = await verifyNIP98Auth(request, repoOwnerPubkey);
if (!authResult.valid) {
return error(401, authResult.error || 'Authentication required');
}
}
// Find git-http-backend
const gitHttpBackend = findGitHttpBackend();
if (!gitHttpBackend) {
return error(500, 'git-http-backend not found. Please install git.');
}
// Build PATH_INFO
const pathInfo = gitPath ? `/${npub}/${repoName}.git/${gitPath}` : `/${npub}/${repoName}.git`;
// Get request body
const body = await request.arrayBuffer();
const bodyBuffer = Buffer.from(body);
// Set up environment variables for git-http-backend
const envVars = {
...process.env,
GIT_PROJECT_ROOT: 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
return new Promise((resolve) => {
const gitProcess = spawn(gitHttpBackend, [], {
env: envVars,
stdio: ['pipe', 'pipe', 'pipe']
});
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) => {
// 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(),
}
}));
});
return json({
message: 'Git HTTP backend not yet implemented',
path,
service
gitProcess.on('error', (err) => {
resolve(error(500, `Failed to execute git-http-backend: ${err.message}`));
});
});
};

Loading…
Cancel
Save