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.
 
 
 
 
 

873 lines
26 KiB

/**
* File manager for git repositories
* Handles reading, writing, and listing files in git repos
*/
import simpleGit, { type SimpleGit } from 'simple-git';
import { readFile, readdir, stat } from 'fs/promises';
import { join, dirname, normalize, resolve } from 'path';
import { existsSync } from 'fs';
import { RepoManager } from './repo-manager.js';
import { createGitCommitSignature } from './commit-signer.js';
import type { NostrEvent } from '../../types/nostr.js';
export interface FileEntry {
name: string;
path: string;
type: 'file' | 'directory';
size?: number;
}
export interface FileContent {
content: string;
encoding: string;
size: number;
}
export interface Commit {
hash: string;
message: string;
author: string;
date: string;
files: string[];
}
export interface Diff {
file: string;
additions: number;
deletions: number;
diff: string;
}
export interface Tag {
name: string;
hash: string;
message?: string;
}
export class FileManager {
private repoManager: RepoManager;
private repoRoot: string;
constructor(repoRoot: string = '/repos') {
this.repoRoot = repoRoot;
this.repoManager = new RepoManager(repoRoot);
}
/**
* Get the full path to a repository
*/
private getRepoPath(npub: string, repoName: string): string {
const repoPath = join(this.repoRoot, npub, `${repoName}.git`);
// Security: Ensure the resolved path is within repoRoot to prevent path traversal
const resolvedPath = resolve(repoPath);
const resolvedRoot = resolve(this.repoRoot);
if (!resolvedPath.startsWith(resolvedRoot + '/') && resolvedPath !== resolvedRoot) {
throw new Error('Path traversal detected: repository path outside allowed root');
}
return repoPath;
}
/**
* 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);
}
/**
* 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)) {
throw new Error('Repository not found');
}
const git: SimpleGit = simpleGit(repoPath);
try {
// Get the tree for the specified path
const tree = await git.raw(['ls-tree', '-l', ref, path || '.']);
if (!tree) {
return [];
}
const entries: FileEntry[] = [];
const lines = tree.trim().split('\n').filter(line => line.length > 0);
for (const line of lines) {
// Format: <mode> <type> <object> <size>\t<file>
const match = line.match(/^(\d+)\s+(\w+)\s+(\w+)\s+(\d+|-)\s+(.+)$/);
if (match) {
const [, , type, , size, name] = match;
const fullPath = path ? join(path, name) : name;
entries.push({
name,
path: fullPath,
type: type === 'tree' ? 'directory' : 'file',
size: size !== '-' ? parseInt(size, 10) : undefined
});
}
}
return entries.sort((a, b) => {
// Directories first, then files, both alphabetically
if (a.type !== b.type) {
return a.type === 'directory' ? -1 : 1;
}
return a.name.localeCompare(b.name);
});
} catch (error) {
console.error('Error listing files:', error);
throw new Error(`Failed to list files: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* 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)) {
throw new Error('Repository not found');
}
const git: SimpleGit = simpleGit(repoPath);
try {
// Get file content using git show
const content = await git.show([`${ref}:${filePath}`]);
// Try to determine encoding (assume UTF-8 for text files)
const encoding = 'utf-8';
const size = Buffer.byteLength(content, encoding);
return {
content,
encoding,
size
};
} catch (error) {
console.error('Error reading file:', error);
throw new Error(`Failed to read file: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Write file and commit changes
* @param signingOptions - Optional commit signing options:
* - useNIP07: Use NIP-07 browser extension (client-side, secure - keys never leave browser)
* - nip98Event: Use NIP-98 auth event as signature (server-side, for git operations)
* - nsecKey: Use direct nsec/hex key (server-side ONLY, via environment variables - NOT for client requests)
*/
async writeFile(
npub: string,
repoName: string,
filePath: string,
content: string,
commitMessage: string,
authorName: string,
authorEmail: string,
branch: string = 'main',
signingOptions?: {
useNIP07?: boolean;
nip98Event?: NostrEvent;
nsecKey?: string;
}
): 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 = 500 * 1024 * 1024; // 500 MB per file (allows for images and demo videos)
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)) {
throw new Error('Repository not found');
}
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');
// Remove work directory if it exists to ensure clean state
if (existsSync(workDir)) {
await rm(workDir, { recursive: true, force: true });
}
// Clone the bare repo to a working directory
const git: SimpleGit = simpleGit();
await git.clone(repoPath, workDir);
// Use the work directory for operations
const workGit: SimpleGit = simpleGit(workDir);
// Checkout the branch (or create it)
try {
await workGit.checkout([branch]);
} catch {
// Branch doesn't exist, create it
await workGit.checkout(['-b', branch]);
}
// 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');
await mkdir(fileDir, { recursive: true });
}
const { writeFile: writeFileFs } = await import('fs/promises');
await writeFileFs(fullFilePath, content, 'utf-8');
// Stage the file (use validated path)
await workGit.add(validatedPath);
// Sign commit if signing options are provided
let finalCommitMessage = commitMessage;
if (signingOptions && (signingOptions.useNIP07 || signingOptions.nip98Event || signingOptions.nsecKey)) {
try {
const { signedMessage } = await createGitCommitSignature(
commitMessage,
authorName,
authorEmail,
signingOptions
);
finalCommitMessage = signedMessage;
} catch (err) {
// Security: Sanitize error messages (never log private keys)
const sanitizedErr = err instanceof Error ? err.message.replace(/nsec[0-9a-z]+/gi, '[REDACTED]').replace(/[0-9a-f]{64}/g, '[REDACTED]') : String(err);
console.warn('Failed to sign commit:', sanitizedErr);
// Continue without signature if signing fails
}
}
// Commit
await workGit.commit(finalCommitMessage, [filePath], {
'--author': `${authorName} <${authorEmail}>`
});
// Push to bare repo
await workGit.push(['origin', branch]);
// Clean up work directory
await rm(workDir, { recursive: true, force: true });
} catch (error) {
console.error('Error writing file:', error);
throw new Error(`Failed to write file: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Get list of branches
*/
async getBranches(npub: string, repoName: string): Promise<string[]> {
const repoPath = this.getRepoPath(npub, repoName);
if (!this.repoExists(npub, repoName)) {
throw new Error('Repository not found');
}
const git: SimpleGit = simpleGit(repoPath);
try {
const branches = await git.branch(['-r']);
return branches.all
.map(b => b.replace(/^origin\//, ''))
.filter(b => !b.includes('HEAD'));
} catch (error) {
console.error('Error getting branches:', error);
return ['main', 'master']; // Default branches
}
}
/**
* Create a new file
* @param signingOptions - Optional commit signing options (see writeFile)
*/
async createFile(
npub: string,
repoName: string,
filePath: string,
content: string,
commitMessage: string,
authorName: string,
authorEmail: string,
branch: string = 'main',
signingOptions?: {
useNIP07?: boolean;
nip98Event?: NostrEvent;
nsecKey?: string;
}
): Promise<void> {
// Reuse writeFile logic - it will create the file if it doesn't exist
return this.writeFile(npub, repoName, filePath, content, commitMessage, authorName, authorEmail, branch, signingOptions);
}
/**
* Delete a file
* @param signingOptions - Optional commit signing options (see writeFile)
*/
async deleteFile(
npub: string,
repoName: string,
filePath: string,
commitMessage: string,
authorName: string,
authorEmail: string,
branch: string = 'main',
signingOptions?: {
useNIP07?: boolean;
nip98Event?: NostrEvent;
nsecKey?: string;
}
): 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)) {
throw new Error('Repository not found');
}
try {
const workDir = join(this.repoRoot, npub, `${repoName}.work`);
const { rm } = await import('fs/promises');
if (existsSync(workDir)) {
await rm(workDir, { recursive: true, force: true });
}
const git: SimpleGit = simpleGit();
await git.clone(repoPath, workDir);
const workGit: SimpleGit = simpleGit(workDir);
try {
await workGit.checkout([branch]);
} catch {
await workGit.checkout(['-b', branch]);
}
// 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 (use validated path)
await workGit.rm([validatedPath]);
// Sign commit if signing options are provided
let finalCommitMessage = commitMessage;
if (signingOptions && (signingOptions.useNIP07 || signingOptions.nip98Event || signingOptions.nsecKey)) {
try {
const { signedMessage } = await createGitCommitSignature(
commitMessage,
authorName,
authorEmail,
signingOptions
);
finalCommitMessage = signedMessage;
} catch (err) {
// Security: Sanitize error messages (never log private keys)
const sanitizedErr = err instanceof Error ? err.message.replace(/nsec[0-9a-z]+/gi, '[REDACTED]').replace(/[0-9a-f]{64}/g, '[REDACTED]') : String(err);
console.warn('Failed to sign commit:', sanitizedErr);
// Continue without signature if signing fails
}
}
// Commit
await workGit.commit(finalCommitMessage, [filePath], {
'--author': `${authorName} <${authorEmail}>`
});
// Push to bare repo
await workGit.push(['origin', branch]);
// Clean up
await rm(workDir, { recursive: true, force: true });
} catch (error) {
console.error('Error deleting file:', error);
throw new Error(`Failed to delete file: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Create a new branch
*/
async createBranch(
npub: string,
repoName: string,
branchName: string,
fromBranch: string = 'main'
): Promise<void> {
const repoPath = this.getRepoPath(npub, repoName);
if (!this.repoExists(npub, repoName)) {
throw new Error('Repository not found');
}
try {
const workDir = join(this.repoRoot, npub, `${repoName}.work`);
const { rm } = await import('fs/promises');
if (existsSync(workDir)) {
await rm(workDir, { recursive: true, force: true });
}
const git: SimpleGit = simpleGit();
await git.clone(repoPath, workDir);
const workGit: SimpleGit = simpleGit(workDir);
// Checkout source branch
await workGit.checkout([fromBranch]);
// Create and checkout new branch
await workGit.checkout(['-b', branchName]);
// Push new branch
await workGit.push(['origin', branchName]);
// Clean up
await rm(workDir, { recursive: true, force: true });
} catch (error) {
console.error('Error creating branch:', error);
throw new Error(`Failed to create branch: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Get commit history
*/
async getCommitHistory(
npub: string,
repoName: string,
branch: string = 'main',
limit: number = 50,
path?: string
): Promise<Commit[]> {
const repoPath = this.getRepoPath(npub, repoName);
if (!this.repoExists(npub, repoName)) {
throw new Error('Repository not found');
}
const git: SimpleGit = simpleGit(repoPath);
try {
const logOptions: any = {
maxCount: limit,
from: branch
};
if (path) {
logOptions.file = path;
}
const log = await git.log(logOptions);
return log.all.map(commit => ({
hash: commit.hash,
message: commit.message,
author: `${commit.author_name} <${commit.author_email}>`,
date: commit.date,
files: commit.diff?.files?.map((f: any) => f.file) || []
}));
} catch (error) {
console.error('Error getting commit history:', error);
throw new Error(`Failed to get commit history: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Get diff between two commits or for a file
*/
async getDiff(
npub: string,
repoName: string,
fromRef: string,
toRef: string = 'HEAD',
filePath?: string
): Promise<Diff[]> {
const repoPath = this.getRepoPath(npub, repoName);
if (!this.repoExists(npub, repoName)) {
throw new Error('Repository not found');
}
const git: SimpleGit = simpleGit(repoPath);
try {
const diffOptions: string[] = [fromRef, toRef];
if (filePath) {
diffOptions.push('--', filePath);
}
const diff = await git.diff(diffOptions);
const stats = await git.diffSummary(diffOptions);
// Parse diff output
const files: Diff[] = [];
const diffLines = diff.split('\n');
let currentFile = '';
let currentDiff = '';
let inFileHeader = false;
for (const line of diffLines) {
if (line.startsWith('diff --git')) {
if (currentFile) {
files.push({
file: currentFile,
additions: 0,
deletions: 0,
diff: currentDiff
});
}
const match = line.match(/diff --git a\/(.+?) b\/(.+?)$/);
if (match) {
currentFile = match[2];
currentDiff = line + '\n';
inFileHeader = true;
}
} else {
currentDiff += line + '\n';
if (line.startsWith('@@')) {
inFileHeader = false;
}
if (!inFileHeader && (line.startsWith('+') || line.startsWith('-'))) {
// Count additions/deletions
}
}
}
if (currentFile) {
files.push({
file: currentFile,
additions: 0,
deletions: 0,
diff: currentDiff
});
}
// Add stats from diffSummary
if (stats.files && files.length > 0) {
for (const statFile of stats.files) {
const file = files.find(f => f.file === statFile.file);
if (file && 'insertions' in statFile && 'deletions' in statFile) {
file.additions = statFile.insertions;
file.deletions = statFile.deletions;
}
}
}
return files;
} catch (error) {
console.error('Error getting diff:', error);
throw new Error(`Failed to get diff: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Create a tag
*/
async createTag(
npub: string,
repoName: string,
tagName: string,
ref: string = 'HEAD',
message?: string,
authorName?: string,
authorEmail?: string
): Promise<void> {
const repoPath = this.getRepoPath(npub, repoName);
if (!this.repoExists(npub, repoName)) {
throw new Error('Repository not found');
}
const git: SimpleGit = simpleGit(repoPath);
try {
if (message) {
// Create annotated tag
await git.addTag(tagName);
// Note: simple-git addTag doesn't support message directly, use raw command
if (ref !== 'HEAD') {
await git.raw(['tag', '-a', tagName, '-m', message, ref]);
} else {
await git.raw(['tag', '-a', tagName, '-m', message]);
}
} else {
// Create lightweight tag
if (ref !== 'HEAD') {
await git.raw(['tag', tagName, ref]);
} else {
await git.addTag(tagName);
}
}
} catch (error) {
console.error('Error creating tag:', error);
throw new Error(`Failed to create tag: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Get list of tags
*/
async getTags(npub: string, repoName: string): Promise<Tag[]> {
const repoPath = this.getRepoPath(npub, repoName);
if (!this.repoExists(npub, repoName)) {
throw new Error('Repository not found');
}
const git: SimpleGit = simpleGit(repoPath);
try {
const tags = await git.tags();
const tagList: Tag[] = [];
for (const tagName of tags.all) {
try {
// Try to get tag message
const tagInfo = await git.raw(['cat-file', '-p', tagName]);
const messageMatch = tagInfo.match(/^(.+)$/m);
const hash = await git.raw(['rev-parse', tagName]);
tagList.push({
name: tagName,
hash: hash.trim(),
message: messageMatch ? messageMatch[1] : undefined
});
} catch {
// Lightweight tag
const hash = await git.raw(['rev-parse', tagName]);
tagList.push({
name: tagName,
hash: hash.trim()
});
}
}
return tagList;
} catch (error) {
console.error('Error getting tags:', error);
return [];
}
}
}