Browse Source

bug-fixes

main
Silberengel 4 weeks ago
parent
commit
12c3484d04
  1. 11
      src/lib/services/git/commit-signer.ts
  2. 252
      src/lib/services/git/file-manager.ts
  3. 8
      src/lib/utils/api-repo-helper.ts
  4. 19
      src/routes/api/repos/[npub]/[repo]/branches/+server.ts
  5. 21
      src/routes/api/repos/[npub]/[repo]/default-branch/+server.ts
  6. 178
      src/routes/api/repos/[npub]/[repo]/file/+server.ts
  7. 526
      src/routes/repos/[npub]/[repo]/+page.svelte

11
src/lib/services/git/commit-signer.ts

@ -160,14 +160,19 @@ export async function createGitCommitSignature(
useNIP07?: boolean; useNIP07?: boolean;
nip98Event?: NostrEvent; nip98Event?: NostrEvent;
nsecKey?: string; nsecKey?: string;
commitSignatureEvent?: NostrEvent; // Pre-signed event from client (NIP-07)
timestamp?: number; timestamp?: number;
} = {} } = {}
): Promise<{ signedMessage: string; signatureEvent: NostrEvent }> { ): Promise<{ signedMessage: string; signatureEvent: NostrEvent }> {
const timestamp = options.timestamp || Math.floor(Date.now() / 1000); const timestamp = options.timestamp || Math.floor(Date.now() / 1000);
let signedEvent: NostrEvent; let signedEvent: NostrEvent;
// Method 1: Use NIP-07 browser extension (client-side) // Method 1: Use pre-signed commit signature event from client (NIP-07)
if (options.useNIP07) { if (options.commitSignatureEvent && options.commitSignatureEvent.sig && options.commitSignatureEvent.id) {
signedEvent = options.commitSignatureEvent;
}
// Method 2: Use NIP-07 browser extension (client-side) - DEPRECATED: use commitSignatureEvent instead
else if (options.useNIP07) {
// NIP-07 will add pubkey automatically, so we don't need it in the template // NIP-07 will add pubkey automatically, so we don't need it in the template
const eventTemplate: Omit<NostrEvent, 'sig' | 'id'> = { const eventTemplate: Omit<NostrEvent, 'sig' | 'id'> = {
kind: KIND.COMMIT_SIGNATURE, kind: KIND.COMMIT_SIGNATURE,
@ -238,7 +243,7 @@ export async function createGitCommitSignature(
signedEvent = finalizeEvent(eventTemplate, keyBytes); signedEvent = finalizeEvent(eventTemplate, keyBytes);
} else { } else {
throw new Error('No signing method provided. Use useNIP07, nip98Event, or nsecKey.'); throw new Error('No signing method provided. Use commitSignatureEvent (pre-signed from client), useNIP07, nip98Event, or nsecKey.');
} }
// Create a signature trailer that git can recognize // Create a signature trailer that git can recognize

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

@ -70,24 +70,61 @@ export class FileManager {
} }
const worktreeRoot = join(this.repoRoot, npub, `${repoName}.worktrees`); const worktreeRoot = join(this.repoRoot, npub, `${repoName}.worktrees`);
const worktreePath = join(worktreeRoot, branch); // Use resolve to ensure we have an absolute path (important for git worktree add)
const worktreePath = resolve(join(worktreeRoot, branch));
const resolvedWorktreeRoot = resolve(worktreeRoot);
// Additional security: Ensure resolved path is still within worktreeRoot // Additional security: Ensure resolved path is still within worktreeRoot
const resolvedPath = resolve(worktreePath).replace(/\\/g, '/'); const resolvedPath = worktreePath.replace(/\\/g, '/');
const resolvedRoot = resolve(worktreeRoot).replace(/\\/g, '/'); const resolvedRoot = resolvedWorktreeRoot.replace(/\\/g, '/');
if (!resolvedPath.startsWith(resolvedRoot + '/')) { if (!resolvedPath.startsWith(resolvedRoot + '/')) {
throw new Error('Path traversal detected: worktree path outside allowed root'); throw new Error('Path traversal detected: worktree path outside allowed root');
} }
const { mkdir, rm } = await import('fs/promises'); const { mkdir, rm } = await import('fs/promises');
// Ensure worktree root exists // Ensure worktree root exists (use resolved path)
if (!existsSync(worktreeRoot)) { if (!existsSync(resolvedWorktreeRoot)) {
await mkdir(worktreeRoot, { recursive: true }); await mkdir(resolvedWorktreeRoot, { recursive: true });
} }
const git = simpleGit(repoPath); const git = simpleGit(repoPath);
// Check if worktree already exists // Check for existing worktrees for this branch and clean them up if they're in the wrong location
try {
const worktreeList = await git.raw(['worktree', 'list', '--porcelain']);
const lines = worktreeList.split('\n');
let currentWorktreePath: string | null = null;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.startsWith('worktree ')) {
currentWorktreePath = line.substring(9).trim();
} else if (line.startsWith('branch ') && line.includes(`refs/heads/${branch}`)) {
// Found a worktree for this branch
if (currentWorktreePath && currentWorktreePath !== worktreePath) {
// Worktree exists but in wrong location - remove it
logger.warn({ oldPath: currentWorktreePath, newPath: worktreePath, branch }, 'Removing worktree from incorrect location');
try {
await git.raw(['worktree', 'remove', currentWorktreePath, '--force']);
} catch (err) {
// If git worktree remove fails, try to remove the directory manually
logger.warn({ error: err, path: currentWorktreePath }, 'Failed to remove worktree via git, will try manual removal');
try {
await rm(currentWorktreePath, { recursive: true, force: true });
} catch (rmErr) {
logger.error({ error: rmErr, path: currentWorktreePath }, 'Failed to manually remove worktree directory');
}
}
}
break;
}
}
} catch (err) {
// If worktree list fails, continue - might be no worktrees yet
logger.debug({ error: err }, 'Could not list worktrees (this is okay if no worktrees exist)');
}
// Check if worktree already exists at the correct location
if (existsSync(worktreePath)) { if (existsSync(worktreePath)) {
// Verify it's a valid worktree // Verify it's a valid worktree
try { try {
@ -202,10 +239,23 @@ export class FileManager {
gitProcess.on('error', reject); gitProcess.on('error', reject);
}); });
// Verify the worktree directory was actually created (after the promise resolves)
if (!existsSync(worktreePath)) {
throw new Error(`Worktree directory was not created: ${worktreePath}`);
}
// Verify it's a valid git repository
const worktreeGit = simpleGit(worktreePath);
try {
await worktreeGit.status();
} catch (err) {
throw new Error(`Created worktree directory is not a valid git repository: ${worktreePath}`);
}
return worktreePath; return worktreePath;
} catch (error) { } catch (error) {
const sanitizedError = sanitizeError(error); const sanitizedError = sanitizeError(error);
logger.error({ error: sanitizedError, repoPath, branch }, 'Failed to create worktree'); logger.error({ error: sanitizedError, repoPath, branch, worktreePath }, 'Failed to create worktree');
throw new Error(`Failed to create worktree: ${sanitizedError}`); throw new Error(`Failed to create worktree: ${sanitizedError}`);
} }
} }
@ -505,7 +555,38 @@ export class FileManager {
try { try {
// Get file content using git show // Get file content using git show
const content = await git.show([`${ref}:${filePath}`]); // Use raw() for better error handling and to catch stderr
let content: string;
try {
content = await git.raw(['show', `${ref}:${filePath}`]);
} catch (gitError: any) {
// simple-git might throw errors in different formats
// Check stderr if available
const stderr = gitError?.stderr || gitError?.message || String(gitError);
const stderrLower = stderr.toLowerCase();
logger.debug({ gitError, repoPath, filePath, ref, stderr }, 'git.raw() error details');
// Check if it's a "not found" type error
if (stderrLower.includes('not found') ||
stderrLower.includes('no such file') ||
stderrLower.includes('does not exist') ||
stderrLower.includes('fatal:') ||
stderr.includes('pathspec') ||
stderr.includes('ambiguous argument') ||
stderr.includes('unknown revision') ||
stderr.includes('bad revision')) {
throw new Error(`File not found: ${filePath} at ref ${ref}`);
}
// Re-throw with more context
throw new Error(`Git command failed: ${stderr}`);
}
// Check if content is undefined or null (indicates error)
if (content === undefined || content === null) {
throw new Error(`File not found: ${filePath} at ref ${ref}`);
}
// Try to determine encoding (assume UTF-8 for text files) // Try to determine encoding (assume UTF-8 for text files)
const encoding = 'utf-8'; const encoding = 'utf-8';
@ -517,15 +598,40 @@ export class FileManager {
size size
}; };
} catch (error) { } catch (error) {
logger.error({ error, repoPath, filePath, ref }, 'Error reading file'); const errorMessage = error instanceof Error ? error.message : String(error);
throw new Error(`Failed to read file: ${error instanceof Error ? error.message : String(error)}`); const errorLower = errorMessage.toLowerCase();
const errorString = String(error);
const errorStringLower = errorString.toLowerCase();
logger.error({ error, repoPath, filePath, ref, errorMessage, errorString }, 'Error reading file');
// Check if it's a "not found" type error (check both errorMessage and errorString)
if (errorLower.includes('not found') ||
errorStringLower.includes('not found') ||
errorLower.includes('no such file') ||
errorStringLower.includes('no such file') ||
errorLower.includes('does not exist') ||
errorStringLower.includes('does not exist') ||
errorLower.includes('fatal:') ||
errorStringLower.includes('fatal:') ||
errorMessage.includes('pathspec') ||
errorString.includes('pathspec') ||
errorMessage.includes('ambiguous argument') ||
errorString.includes('ambiguous argument') ||
errorString.includes('unknown revision') ||
errorString.includes('bad revision')) {
throw new Error(`File not found: ${filePath} at ref ${ref}`);
}
throw new Error(`Failed to read file: ${errorMessage}`);
} }
} }
/** /**
* Write file and commit changes * Write file and commit changes
* @param signingOptions - Optional commit signing options: * @param signingOptions - Optional commit signing options:
* - useNIP07: Use NIP-07 browser extension (client-side, secure - keys never leave browser) * - commitSignatureEvent: Pre-signed commit signature event from client (NIP-07, recommended)
* - useNIP07: Use NIP-07 browser extension (DEPRECATED: use commitSignatureEvent instead)
* - nip98Event: Use NIP-98 auth event as signature (server-side, for git operations) * - 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) * - nsecKey: Use direct nsec/hex key (server-side ONLY, via environment variables - NOT for client requests)
*/ */
@ -539,6 +645,7 @@ export class FileManager {
authorEmail: string, authorEmail: string,
branch: string = 'main', branch: string = 'main',
signingOptions?: { signingOptions?: {
commitSignatureEvent?: NostrEvent;
useNIP07?: boolean; useNIP07?: boolean;
nip98Event?: NostrEvent; nip98Event?: NostrEvent;
nsecKey?: string; nsecKey?: string;
@ -630,7 +737,7 @@ export class FileManager {
// Sign commit if signing options are provided // Sign commit if signing options are provided
let finalCommitMessage = commitMessage; let finalCommitMessage = commitMessage;
if (signingOptions && (signingOptions.useNIP07 || signingOptions.nip98Event || signingOptions.nsecKey)) { if (signingOptions && (signingOptions.commitSignatureEvent || signingOptions.useNIP07 || signingOptions.nip98Event || signingOptions.nsecKey)) {
try { try {
const { signedMessage } = await createGitCommitSignature( const { signedMessage } = await createGitCommitSignature(
commitMessage, commitMessage,
@ -652,8 +759,9 @@ export class FileManager {
'--author': `${authorName} <${authorEmail}>` '--author': `${authorName} <${authorEmail}>`
}); });
// Push to bare repo (worktree is already connected) // Note: No push needed - worktrees of bare repos share the same object database,
await workGit.push(['origin', branch]); // so the commit is already in the bare repository. We don't push to remote origin
// to avoid requiring remote authentication and to keep changes local-only.t
// Clean up worktree (but keep it for potential reuse) // Clean up worktree (but keep it for potential reuse)
// Note: We could keep worktrees for better performance, but clean up for now // Note: We could keep worktrees for better performance, but clean up for now
@ -742,10 +850,44 @@ export class FileManager {
const git: SimpleGit = simpleGit(repoPath); const git: SimpleGit = simpleGit(repoPath);
try { try {
const branches = await git.branch(['-r']); // For bare repositories, list local branches (they're stored in refs/heads/)
const branchList = branches.all // Also check remote branches in case the repo has remotes configured
const [localBranches, remoteBranches] = await Promise.all([
git.branch(['-a']).catch(() => ({ all: [] })), // List all branches (local and remote)
git.branch(['-r']).catch(() => ({ all: [] })) // Also try remote branches separately
]);
// Combine local and remote branches, removing duplicates
const allBranches = new Set<string>();
// Add local branches (from -a, filter out remotes)
localBranches.all
.filter(b => !b.startsWith('remotes/') && !b.includes('HEAD'))
.forEach(b => allBranches.add(b));
// Add remote branches (remove origin/ prefix)
remoteBranches.all
.map(b => b.replace(/^origin\//, '')) .map(b => b.replace(/^origin\//, ''))
.filter(b => !b.includes('HEAD')); .filter(b => !b.includes('HEAD'))
.forEach(b => allBranches.add(b));
// If no branches found, try listing refs directly (for bare repos)
if (allBranches.size === 0) {
try {
const refs = await git.raw(['for-each-ref', '--format=%(refname:short)', 'refs/heads/']);
if (refs) {
refs.trim().split('\n').forEach(b => {
if (b && !b.includes('HEAD')) {
allBranches.add(b);
}
});
}
} catch {
// If that fails too, continue with empty set
}
}
const branchList = Array.from(allBranches).sort();
// Cache the result (cache for 2 minutes) // Cache the result (cache for 2 minutes)
repoCache.set(cacheKey, branchList, 2 * 60 * 1000); repoCache.set(cacheKey, branchList, 2 * 60 * 1000);
@ -796,6 +938,7 @@ export class FileManager {
authorEmail: string, authorEmail: string,
branch: string = 'main', branch: string = 'main',
signingOptions?: { signingOptions?: {
commitSignatureEvent?: NostrEvent;
useNIP07?: boolean; useNIP07?: boolean;
nip98Event?: NostrEvent; nip98Event?: NostrEvent;
nsecKey?: string; nsecKey?: string;
@ -867,7 +1010,7 @@ export class FileManager {
// Sign commit if signing options are provided // Sign commit if signing options are provided
let finalCommitMessage = commitMessage; let finalCommitMessage = commitMessage;
if (signingOptions && (signingOptions.useNIP07 || signingOptions.nip98Event || signingOptions.nsecKey)) { if (signingOptions && (signingOptions.commitSignatureEvent || signingOptions.useNIP07 || signingOptions.nip98Event || signingOptions.nsecKey)) {
try { try {
const { signedMessage } = await createGitCommitSignature( const { signedMessage } = await createGitCommitSignature(
commitMessage, commitMessage,
@ -889,8 +1032,9 @@ export class FileManager {
'--author': `${authorName} <${authorEmail}>` '--author': `${authorName} <${authorEmail}>`
}); });
// Push to bare repo (worktree is already connected) // Note: No push needed - worktrees of bare repos share the same object database,
await workGit.push(['origin', branch]); // so the commit is already in the bare repository. We don't push to remote origin
// to avoid requiring remote authentication and to keep changes local-only.
// Clean up worktree // Clean up worktree
await this.removeWorktree(repoPath, workDir); await this.removeWorktree(repoPath, workDir);
@ -931,8 +1075,9 @@ export class FileManager {
// Create and checkout new branch // Create and checkout new branch
await workGit.checkout(['-b', branchName]); await workGit.checkout(['-b', branchName]);
// Push new branch // Note: No push needed - worktrees of bare repos share the same object database,
await workGit.push(['origin', branchName]); // so the branch is already in the bare repository. We don't push to remote origin
// to avoid requiring remote authentication and to keep changes local-only.
// Clean up worktree // Clean up worktree
await this.removeWorktree(repoPath, workDir); await this.removeWorktree(repoPath, workDir);
@ -942,6 +1087,67 @@ export class FileManager {
} }
} }
/**
* Delete a branch
*/
async deleteBranch(
npub: string,
repoName: string,
branchName: string
): Promise<void> {
// Security: Validate branch name to prevent path traversal
if (!isValidBranchName(branchName)) {
throw new Error(`Invalid branch name: ${branchName}`);
}
const repoPath = this.getRepoPath(npub, repoName);
if (!this.repoExists(npub, repoName)) {
throw new Error('Repository not found');
}
// Prevent deleting the default branch
const defaultBranch = await this.getDefaultBranch(npub, repoName);
if (branchName === defaultBranch) {
throw new Error(`Cannot delete the default branch (${defaultBranch}). Please switch to a different branch first.`);
}
const git: SimpleGit = simpleGit(repoPath);
try {
// Check if branch exists
const branches = await git.branch(['-a']);
const branchExists = branches.all.some(b =>
b === branchName ||
b === `refs/heads/${branchName}` ||
b.replace(/^origin\//, '') === branchName
);
if (!branchExists) {
throw new Error(`Branch ${branchName} does not exist`);
}
// Delete the branch (use -D to force delete even if not merged)
// For bare repos, we delete the ref directly
await git.raw(['branch', '-D', branchName]).catch(async () => {
// If branch -D fails (might be a remote branch reference), try deleting the ref directly
try {
await git.raw(['update-ref', '-d', `refs/heads/${branchName}`]);
} catch (refError) {
// If that also fails, the branch might not exist locally
throw new Error(`Failed to delete branch: ${branchName}`);
}
});
// Invalidate branches cache
const cacheKey = RepoCache.branchesKey(npub, repoName);
repoCache.delete(cacheKey);
} catch (error) {
logger.error({ error, repoPath, branchName, npub }, 'Error deleting branch');
throw new Error(`Failed to delete branch: ${error instanceof Error ? error.message : String(error)}`);
}
}
/** /**
* Get commit history * Get commit history
*/ */

8
src/lib/utils/api-repo-helper.ts

@ -160,7 +160,13 @@ export async function tryApiFetchFile(
if (platform === 'gitea' || platform === 'github') { if (platform === 'gitea' || platform === 'github') {
// Gitea and GitHub return base64 encoded content // Gitea and GitHub return base64 encoded content
if (fileData.content) { if (fileData.content) {
const content = atob(fileData.content.replace(/\s/g, '')); // Use Buffer on server-side, atob on client-side
let content: string;
if (isServerSide()) {
content = Buffer.from(fileData.content.replace(/\s/g, ''), 'base64').toString('utf-8');
} else {
content = atob(fileData.content.replace(/\s/g, ''));
}
return { return {
content, content,
encoding: 'base64' encoding: 'base64'

19
src/routes/api/repos/[npub]/[repo]/branches/+server.ts

@ -138,8 +138,25 @@ export const POST: RequestHandler = createRepoPostHandler(
throw handleValidationError('Missing branchName parameter', { operation: 'createBranch', npub: context.npub, repo: context.repo }); throw handleValidationError('Missing branchName parameter', { operation: 'createBranch', npub: context.npub, repo: context.repo });
} }
await fileManager.createBranch(context.npub, context.repo, branchName, fromBranch || 'main'); // Get default branch if fromBranch not provided
const sourceBranch = fromBranch || await fileManager.getDefaultBranch(context.npub, context.repo);
await fileManager.createBranch(context.npub, context.repo, branchName, sourceBranch);
return json({ success: true, message: 'Branch created successfully' }); return json({ success: true, message: 'Branch created successfully' });
}, },
{ operation: 'createBranch' } { operation: 'createBranch' }
); );
export const DELETE: RequestHandler = createRepoPostHandler(
async (context: RepoRequestContext, event: RequestEvent) => {
const body = await event.request.json();
const { branchName } = body;
if (!branchName) {
throw handleValidationError('Missing branchName parameter', { operation: 'deleteBranch', npub: context.npub, repo: context.repo });
}
await fileManager.deleteBranch(context.npub, context.repo, branchName);
return json({ success: true, message: 'Branch deleted successfully' });
},
{ operation: 'deleteBranch' }
);

21
src/routes/api/repos/[npub]/[repo]/default-branch/+server.ts

@ -0,0 +1,21 @@
/**
* API endpoint for getting the default branch of a repository
*/
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { fileManager } from '$lib/services/service-registry.js';
import { createRepoGetHandler } from '$lib/utils/api-handlers.js';
import type { RepoRequestContext } from '$lib/utils/api-context.js';
import { handleApiError } from '$lib/utils/error-handler.js';
export const GET: RequestHandler = createRepoGetHandler(
async (context: RepoRequestContext) => {
try {
const defaultBranch = await fileManager.getDefaultBranch(context.npub, context.repo);
return json({ defaultBranch, branch: defaultBranch });
} catch (error) {
throw handleApiError(error, { operation: 'getDefaultBranch', npub: context.npub, repo: context.repo });
}
}
);

178
src/routes/api/repos/[npub]/[repo]/file/+server.ts

@ -19,17 +19,33 @@ import { KIND } from '$lib/types/nostr.js';
import { join } from 'path'; import { join } from 'path';
import { existsSync } from 'fs'; import { existsSync } from 'fs';
import { repoCache, RepoCache } from '$lib/services/git/repo-cache.js'; import { repoCache, RepoCache } from '$lib/services/git/repo-cache.js';
import { extractRequestContext } from '$lib/utils/api-context.js';
const repoRoot = typeof process !== 'undefined' && process.env?.GIT_REPO_ROOT const repoRoot = typeof process !== 'undefined' && process.env?.GIT_REPO_ROOT
? process.env.GIT_REPO_ROOT ? process.env.GIT_REPO_ROOT
: '/repos'; : '/repos';
const maintainerService = new MaintainerService(DEFAULT_NOSTR_RELAYS); const maintainerService = new MaintainerService(DEFAULT_NOSTR_RELAYS);
export const GET: RequestHandler = async ({ params, url, request }: { params: { npub?: string; repo?: string }; url: URL; request: Request }) => { export const GET: RequestHandler = async (event) => {
const { params, url, request } = event;
const { npub, repo } = params; const { npub, repo } = params;
const filePath = url.searchParams.get('path'); const filePath = url.searchParams.get('path');
let ref = url.searchParams.get('ref') || 'HEAD'; let ref = url.searchParams.get('ref') || 'HEAD';
const userPubkey = url.searchParams.get('userPubkey') || request.headers.get('x-user-pubkey');
// Extract user pubkey using the same method as other endpoints
const requestContext = extractRequestContext(event);
const userPubkey = requestContext.userPubkey;
const userPubkeyHex = requestContext.userPubkeyHex;
// Debug logging for file endpoint
logger.debug({
hasUserPubkey: !!userPubkey,
hasUserPubkeyHex: !!userPubkeyHex,
userPubkeyHex: userPubkeyHex ? userPubkeyHex.substring(0, 16) + '...' : null,
npub,
repo,
filePath
}, 'File endpoint - extracted user context');
if (!npub || !repo || !filePath) { if (!npub || !repo || !filePath) {
return error(400, 'Missing npub, repo, or path parameter'); return error(400, 'Missing npub, repo, or path parameter');
@ -61,12 +77,17 @@ export const GET: RequestHandler = async ({ params, url, request }: { params: {
if (events.length > 0) { if (events.length > 0) {
// Try API-based fetching first (no cloning) // Try API-based fetching first (no cloning)
try {
const { tryApiFetchFile } = await import('$lib/utils/api-repo-helper.js'); const { tryApiFetchFile } = await import('$lib/utils/api-repo-helper.js');
const fileContent = await tryApiFetchFile(events[0], npub, repo, filePath, ref); const fileContent = await tryApiFetchFile(events[0], npub, repo, filePath, ref);
if (fileContent) { if (fileContent && fileContent.content) {
return json(fileContent); return json(fileContent);
} }
} catch (apiErr) {
// Log the error but don't throw - we'll return a helpful error message below
logger.debug({ error: apiErr, npub, repo, filePath, ref }, 'API file fetch failed, will return 404');
}
// API fetch failed - repo is not cloned and API fetch didn't work // API fetch failed - repo is not cloned and API fetch didn't work
return error(404, 'Repository is not cloned locally and could not fetch file via API. Privileged users can clone this repository using the "Clone to Server" button.'); return error(404, 'Repository is not cloned locally and could not fetch file via API. Privileged users can clone this repository using the "Clone to Server" button.');
@ -74,6 +95,7 @@ export const GET: RequestHandler = async ({ params, url, request }: { params: {
return error(404, 'Repository announcement not found in Nostr'); return error(404, 'Repository announcement not found in Nostr');
} }
} catch (err) { } catch (err) {
logger.error({ error: err, npub, repo, filePath }, 'Error in on-demand file fetch');
// Check if repo was created by another concurrent request // Check if repo was created by another concurrent request
if (existsSync(repoPath)) { if (existsSync(repoPath)) {
// Repo exists now, clear cache and continue with normal flow // Repo exists now, clear cache and continue with normal flow
@ -103,22 +125,43 @@ export const GET: RequestHandler = async ({ params, url, request }: { params: {
try { try {
const branches = await fileManager.getBranches(npub, repo); const branches = await fileManager.getBranches(npub, repo);
if (!branches.includes(ref)) { if (!branches.includes(ref)) {
// Branch doesn't exist, use default branch // Branch doesn't exist, try to get default branch
try {
ref = await fileManager.getDefaultBranch(npub, repo); ref = await fileManager.getDefaultBranch(npub, repo);
logger.debug({ npub, repo, originalRef: url.searchParams.get('ref'), newRef: ref }, 'Branch not found, using default branch');
} catch (defaultBranchErr) {
// If we can't get default branch, fall back to HEAD
logger.warn({ error: defaultBranchErr, npub, repo, ref }, 'Could not get default branch, falling back to HEAD');
ref = 'HEAD';
} }
} catch { }
} catch (branchErr) {
// If we can't get branches, fall back to HEAD // If we can't get branches, fall back to HEAD
logger.warn({ error: branchErr, npub, repo, ref }, 'Could not get branches, falling back to HEAD');
ref = 'HEAD'; ref = 'HEAD';
} }
} }
// Check repository privacy (repoOwnerPubkey already declared above) // Check repository privacy (repoOwnerPubkey already declared above)
const canView = await maintainerService.canView(userPubkey || null, repoOwnerPubkey, repo); logger.debug({
userPubkeyHex: userPubkeyHex ? userPubkeyHex.substring(0, 16) + '...' : null,
repoOwnerPubkey: repoOwnerPubkey.substring(0, 16) + '...',
repo
}, 'File endpoint - checking canView before access check');
const canView = await maintainerService.canView(userPubkeyHex || null, repoOwnerPubkey, repo);
logger.debug({
canView,
userPubkeyHex: userPubkeyHex ? userPubkeyHex.substring(0, 16) + '...' : null,
repoOwnerPubkey: repoOwnerPubkey.substring(0, 16) + '...',
repo
}, 'File endpoint - canView result');
if (!canView) { if (!canView) {
const clientIp = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown';
auditLogger.logFileOperation( auditLogger.logFileOperation(
userPubkey || null, userPubkeyHex || null,
clientIp, requestContext.clientIp,
'read', 'read',
`${npub}/${repo}`, `${npub}/${repo}`,
filePath, filePath,
@ -127,13 +170,38 @@ export const GET: RequestHandler = async ({ params, url, request }: { params: {
); );
return error(403, 'This repository is private. Only owners and maintainers can view it.'); return error(403, 'This repository is private. Only owners and maintainers can view it.');
} }
try {
// Log what we're trying to do
logger.debug({ npub, repo, filePath, ref }, 'Attempting to read file from cloned repository');
const clientIp = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown'; let fileContent;
try { try {
const fileContent = await fileManager.getFileContent(npub, repo, filePath, ref); fileContent = await fileManager.getFileContent(npub, repo, filePath, ref);
} catch (firstErr) {
// If the first attempt fails and ref is not HEAD, try with HEAD as fallback
if (ref !== 'HEAD' && !ref.startsWith('refs/')) {
logger.warn({
error: firstErr,
npub,
repo,
filePath,
originalRef: ref
}, 'Failed to read file with specified ref, trying HEAD as fallback');
try {
fileContent = await fileManager.getFileContent(npub, repo, filePath, 'HEAD');
ref = 'HEAD'; // Update ref for logging
} catch (headErr) {
// If HEAD also fails, throw the original error
throw firstErr;
}
} else {
throw firstErr;
}
}
auditLogger.logFileOperation( auditLogger.logFileOperation(
userPubkey || null, userPubkeyHex || null,
clientIp, requestContext.clientIp,
'read', 'read',
`${npub}/${repo}`, `${npub}/${repo}`,
filePath, filePath,
@ -141,18 +209,75 @@ export const GET: RequestHandler = async ({ params, url, request }: { params: {
); );
return json(fileContent); return json(fileContent);
} catch (err) { } catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
const errorLower = errorMessage.toLowerCase();
const errorStack = err instanceof Error ? err.stack : undefined;
logger.error({
error: err,
errorStack,
npub,
repo,
filePath,
ref,
repoExists: existsSync(repoPath),
errorMessage
}, 'Error reading file from cloned repository');
auditLogger.logFileOperation( auditLogger.logFileOperation(
userPubkey || null, userPubkeyHex || null,
clientIp, requestContext.clientIp,
'read', 'read',
`${npub}/${repo}`, `${npub}/${repo}`,
filePath, filePath,
'failure', 'failure',
err instanceof Error ? err.message : String(err) errorMessage
); );
throw err; // If file not found or path doesn't exist, return 404 instead of 500
if (errorLower.includes('not found') ||
errorLower.includes('no such file') ||
errorLower.includes('does not exist') ||
errorLower.includes('fatal:') ||
errorMessage.includes('pathspec')) {
return error(404, `File not found: ${filePath} at ref ${ref}`);
}
// For other errors, return 500 with a more helpful message
return error(500, `Failed to read file: ${errorMessage}`);
} }
} catch (err) { } catch (err) {
// This catch block handles errors that occur outside the file reading try-catch
// (e.g., in branch validation, access checks, etc.)
// If it's already a Response (from error handlers), return it
if (err instanceof Response) {
return err;
}
// If it's a SvelteKit HttpError (from error() function), re-throw it
// SvelteKit errors have a status property and body property
if (err && typeof err === 'object' && 'status' in err && 'body' in err) {
throw err;
}
const errorMessage = err instanceof Error ? err.message : String(err);
const errorStack = err instanceof Error ? err.stack : undefined;
logger.error({
error: err,
errorStack,
npub,
repo,
filePath,
ref: url.searchParams.get('ref'),
errorMessage
}, 'Unexpected error in file endpoint (outside file reading block)');
// Check if it's a "not found" type error
const errorLower = errorMessage.toLowerCase();
if (errorLower.includes('not found') ||
errorLower.includes('repository not found')) {
return error(404, errorMessage);
}
return handleApiError(err, { operation: 'readFile', npub, repo, filePath }, 'Failed to read file'); return handleApiError(err, { operation: 'readFile', npub, repo, filePath }, 'Failed to read file');
} }
}; };
@ -168,7 +293,7 @@ export const POST: RequestHandler = async ({ params, url, request }: { params: {
try { try {
const body = await request.json(); const body = await request.json();
path = body.path; path = body.path;
const { content, commitMessage, authorName, authorEmail, branch, action, userPubkey, useNIP07, nsecKey } = body; const { content, commitMessage, authorName, authorEmail, branch, action, userPubkey, useNIP07, nsecKey, commitSignatureEvent } = body;
// Check for NIP-98 authentication (for git operations) // Check for NIP-98 authentication (for git operations)
const authHeader = request.headers.get('Authorization'); const authHeader = request.headers.get('Authorization');
@ -242,13 +367,16 @@ export const POST: RequestHandler = async ({ params, url, request }: { params: {
useNIP07?: boolean; useNIP07?: boolean;
nip98Event?: NostrEvent; nip98Event?: NostrEvent;
nsecKey?: string; nsecKey?: string;
commitSignatureEvent?: NostrEvent;
} = {}; } = {};
if (useNIP07) { // If client sent a pre-signed commit signature event (from NIP-07), use it
signingOptions.useNIP07 = true; if (commitSignatureEvent && commitSignatureEvent.sig && commitSignatureEvent.id) {
signingOptions.commitSignatureEvent = commitSignatureEvent;
} else if (nip98Event) { } else if (nip98Event) {
signingOptions.nip98Event = nip98Event; signingOptions.nip98Event = nip98Event;
} }
// Note: useNIP07 is no longer used since signing happens client-side
// Explicitly ignore nsecKey from client requests - it's a security risk // Explicitly ignore nsecKey from client requests - it's a security risk
// Server-side signing should use NOSTRGIT_SECRET_KEY environment variable instead // Server-side signing should use NOSTRGIT_SECRET_KEY environment variable instead
if (nsecKey) { if (nsecKey) {
@ -270,6 +398,9 @@ export const POST: RequestHandler = async ({ params, url, request }: { params: {
if (action === 'delete') { if (action === 'delete') {
try { try {
// Get default branch if not provided
const targetBranch = branch || await fileManager.getDefaultBranch(npub, repo);
await fileManager.deleteFile( await fileManager.deleteFile(
npub, npub,
repo, repo,
@ -277,7 +408,7 @@ export const POST: RequestHandler = async ({ params, url, request }: { params: {
commitMessage, commitMessage,
authorName, authorName,
authorEmail, authorEmail,
branch || 'main', targetBranch,
Object.keys(signingOptions).length > 0 ? signingOptions : undefined Object.keys(signingOptions).length > 0 ? signingOptions : undefined
); );
auditLogger.logFileOperation( auditLogger.logFileOperation(
@ -306,6 +437,9 @@ export const POST: RequestHandler = async ({ params, url, request }: { params: {
return error(400, 'Content is required for create/update operations'); return error(400, 'Content is required for create/update operations');
} }
try { try {
// Get default branch if not provided
const targetBranch = branch || await fileManager.getDefaultBranch(npub, repo);
await fileManager.writeFile( await fileManager.writeFile(
npub, npub,
repo, repo,
@ -314,7 +448,7 @@ export const POST: RequestHandler = async ({ params, url, request }: { params: {
commitMessage, commitMessage,
authorName, authorName,
authorEmail, authorEmail,
branch || 'main', targetBranch,
Object.keys(signingOptions).length > 0 ? signingOptions : undefined Object.keys(signingOptions).length > 0 ? signingOptions : undefined
); );
auditLogger.logFileOperation( auditLogger.logFileOperation(

526
src/routes/repos/[npub]/[repo]/+page.svelte

@ -52,7 +52,8 @@
let hasChanges = $state(false); let hasChanges = $state(false);
let saving = $state(false); let saving = $state(false);
let branches = $state<Array<string | { name: string; commit?: any }>>([]); let branches = $state<Array<string | { name: string; commit?: any }>>([]);
let currentBranch = $state('main'); let currentBranch = $state<string | null>(null);
let defaultBranch = $state<string | null>(null);
let commitMessage = $state(''); let commitMessage = $state('');
let userPubkey = $state<string | null>(null); let userPubkey = $state<string | null>(null);
let userPubkeyHex = $state<string | null>(null); let userPubkeyHex = $state<string | null>(null);
@ -73,9 +74,16 @@
if (wasDifferent) { if (wasDifferent) {
// Reset repoNotFound flag when user logs in, so we can retry loading // Reset repoNotFound flag when user logs in, so we can retry loading
repoNotFound = false; repoNotFound = false;
// Clear cached email and name when user changes
cachedUserEmail = null;
cachedUserName = null;
checkMaintainerStatus().catch(err => console.warn('Failed to reload maintainer status after login:', err)); checkMaintainerStatus().catch(err => console.warn('Failed to reload maintainer status after login:', err));
loadBookmarkStatus().catch(err => console.warn('Failed to reload bookmark status after login:', err)); loadBookmarkStatus().catch(err => console.warn('Failed to reload bookmark status after login:', err));
// Recheck clone status after login (force refresh) - delay slightly to ensure auth headers are ready
setTimeout(() => {
checkCloneStatus(true).catch(err => console.warn('Failed to recheck clone status after login:', err));
}, 100);
// Reload all repository data with the new user context // Reload all repository data with the new user context
if (!loading) { if (!loading) {
loadBranches().catch(err => console.warn('Failed to reload branches after login:', err)); loadBranches().catch(err => console.warn('Failed to reload branches after login:', err));
@ -89,6 +97,9 @@
} else { } else {
userPubkey = null; userPubkey = null;
userPubkeyHex = null; userPubkeyHex = null;
// Clear cached email and name when user logs out
cachedUserEmail = null;
cachedUserName = null;
// Reload data when user logs out to hide private content // Reload data when user logs out to hide private content
if (wasLoggedIn) { if (wasLoggedIn) {
@ -113,7 +124,7 @@
// Branch creation // Branch creation
let showCreateBranchDialog = $state(false); let showCreateBranchDialog = $state(false);
let newBranchName = $state(''); let newBranchName = $state('');
let newBranchFrom = $state('main'); let newBranchFrom = $state<string | null>(null);
// Commit history // Commit history
let commits = $state<Array<{ hash: string; message: string; author: string; date: string; files: string[] }>>([]); let commits = $state<Array<{ hash: string; message: string; author: string; date: string; files: string[] }>>([]);
@ -557,18 +568,25 @@
return count; return count;
} }
async function checkCloneStatus() { async function checkCloneStatus(force: boolean = false) {
if (checkingCloneStatus || isRepoCloned !== null) return; if (checkingCloneStatus || (!force && isRepoCloned !== null)) return;
checkingCloneStatus = true; checkingCloneStatus = true;
try { try {
// Check if repo exists locally by trying to fetch branches // Check if repo exists locally by trying to fetch branches
// If it returns 404, repo is not cloned // 404 = repo not cloned, 403 = repo exists but access denied (cloned), 200 = cloned and accessible
const response = await fetch(`/api/repos/${npub}/${repo}/branches`, { const response = await fetch(`/api/repos/${npub}/${repo}/branches`, {
headers: buildApiHeaders() headers: buildApiHeaders()
}); });
isRepoCloned = response.ok; // If response is 403, repo exists (cloned) but user doesn't have access
// If response is 404, repo doesn't exist (not cloned)
// If response is 200, repo exists and is accessible (cloned)
const wasCloned = response.status !== 404;
isRepoCloned = wasCloned;
console.log(`[Clone Status] Repo ${wasCloned ? 'is cloned' : 'is not cloned'} (status: ${response.status})`);
} catch (err) { } catch (err) {
// On error, assume not cloned
console.warn('[Clone Status] Error checking clone status:', err);
isRepoCloned = false; isRepoCloned = false;
} finally { } finally {
checkingCloneStatus = false; checkingCloneStatus = false;
@ -594,14 +612,23 @@
} }
const data = await response.json(); const data = await response.json();
isRepoCloned = true;
if (data.alreadyExists) { if (data.alreadyExists) {
alert('Repository already exists locally.'); alert('Repository already exists locally.');
// Force refresh clone status
await checkCloneStatus(true);
} else { } else {
alert('Repository cloned successfully! The repository is now available on this server.'); alert('Repository cloned successfully! The repository is now available on this server.');
// Reload the page to show the cloned repo // Force refresh clone status
window.location.reload(); await checkCloneStatus(true);
// Reload data to use the cloned repo instead of API
await Promise.all([
loadBranches(),
loadFiles(currentPath),
loadReadme(),
loadTags(),
loadCommitHistory()
]);
} }
} catch (err) { } catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to clone repository'; const errorMessage = err instanceof Error ? err.message : 'Failed to clone repository';
@ -1282,14 +1309,7 @@
loading = false; loading = false;
return; return;
} }
// Update currentBranch to first available branch if 'main' doesn't exist // loadBranches() already handles setting currentBranch to the default branch
if (branches.length > 0) {
// Branches can be an array of objects with .name property or array of strings
const branchNames = branches.map((b: any) => typeof b === 'string' ? b : b.name);
if (!branchNames.includes(currentBranch)) {
currentBranch = branchNames[0];
}
}
await loadFiles(); await loadFiles();
await checkAuth(); await checkAuth();
await loadTags(); await loadTags();
@ -1552,8 +1572,34 @@
if (branches.length > 0) { if (branches.length > 0) {
// Branches can be an array of objects with .name property or array of strings // Branches can be an array of objects with .name property or array of strings
const branchNames = branches.map((b: any) => typeof b === 'string' ? b : b.name); const branchNames = branches.map((b: any) => typeof b === 'string' ? b : b.name);
if (!branchNames.includes(currentBranch)) {
currentBranch = branchNames[0]; // Fetch the actual default branch from the API
try {
const defaultBranchResponse = await fetch(`/api/repos/${npub}/${repo}/default-branch`, {
headers: buildApiHeaders()
});
if (defaultBranchResponse.ok) {
const defaultBranchData = await defaultBranchResponse.json();
defaultBranch = defaultBranchData.defaultBranch || defaultBranchData.branch || null;
}
} catch (err) {
console.warn('Failed to fetch default branch, using fallback logic:', err);
}
// Fallback: Detect default branch: prefer master, then main, then first branch
if (!defaultBranch) {
if (branchNames.includes('master')) {
defaultBranch = 'master';
} else if (branchNames.includes('main')) {
defaultBranch = 'main';
} else {
defaultBranch = branchNames[0];
}
}
// Only update currentBranch if it's not set or if the current branch doesn't exist
if (!currentBranch || !branchNames.includes(currentBranch)) {
currentBranch = defaultBranch;
} }
} }
} else if (response.status === 404) { } else if (response.status === 404) {
@ -1590,7 +1636,10 @@
} else if (response.status === 403) { } else if (response.status === 403) {
// 403 means access denied - don't set repoNotFound, just show error // 403 means access denied - don't set repoNotFound, just show error
// This allows retry after login // This allows retry after login
throw new Error(`Access denied: ${response.statusText}. You may need to log in or you may not have permission to view this repository.`); const accessDeniedError = new Error(`Access denied: ${response.statusText}. You may need to log in or you may not have permission to view this repository.`);
// Log as info since this is normal client behavior (not logged in or no access)
console.info('Access denied (normal behavior):', accessDeniedError.message);
throw accessDeniedError;
} }
throw new Error(`Failed to load files: ${response.statusText}`); throw new Error(`Failed to load files: ${response.statusText}`);
} }
@ -1610,7 +1659,12 @@
} }
} catch (err) { } catch (err) {
error = err instanceof Error ? err.message : 'Failed to load files'; error = err instanceof Error ? err.message : 'Failed to load files';
// Only log as error if it's not a 403 (access denied), which is normal behavior
if (err instanceof Error && err.message.includes('Access denied')) {
// Already logged as info above, don't log again
} else {
console.error('Error loading files:', err); console.error('Error loading files:', err);
}
} finally { } finally {
loading = false; loading = false;
} }
@ -1658,13 +1712,18 @@
error = null; error = null;
try { try {
// Ensure currentBranch is a string (branch name), not an object // Ensure currentBranch is a string (branch name), not an object
// If currentBranch is not set, use the first available branch or 'master' as fallback
const branchName = typeof currentBranch === 'string' const branchName = typeof currentBranch === 'string'
? currentBranch ? currentBranch
: (typeof currentBranch === 'object' && currentBranch !== null && 'name' in currentBranch : (typeof currentBranch === 'object' && currentBranch !== null && 'name' in currentBranch
? (currentBranch as { name: string }).name ? (currentBranch as { name: string }).name
: 'main'); : (branches.length > 0
? (typeof branches[0] === 'string' ? branches[0] : branches[0].name)
: 'master'));
const url = `/api/repos/${npub}/${repo}/file?path=${encodeURIComponent(filePath)}&ref=${branchName}`; const url = `/api/repos/${npub}/${repo}/file?path=${encodeURIComponent(filePath)}&ref=${branchName}`;
const response = await fetch(url); const response = await fetch(url, {
headers: buildApiHeaders()
});
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to load file: ${response.statusText}`); throw new Error(`Failed to load file: ${response.statusText}`);
@ -1731,6 +1790,179 @@
} }
} }
// Cache for user profile email and name
let cachedUserEmail = $state<string | null>(null);
let cachedUserName = $state<string | null>(null);
let fetchingUserEmail = $state(false);
let fetchingUserName = $state(false);
async function getUserEmail(): Promise<string> {
// Return cached email if available
if (cachedUserEmail) {
return cachedUserEmail;
}
// If no user pubkey, can't proceed
if (!userPubkeyHex) {
throw new Error('User not authenticated');
}
// Prevent concurrent fetches
if (fetchingUserEmail) {
// Wait a bit and retry (shouldn't happen, but just in case)
await new Promise(resolve => setTimeout(resolve, 100));
if (cachedUserEmail) {
return cachedUserEmail;
}
}
fetchingUserEmail = true;
let nip05Email: string | null = null;
try {
const client = new NostrClient(DEFAULT_NOSTR_RELAYS);
const profileEvents = await client.fetchEvents([
{
kinds: [0], // Kind 0 = profile metadata
authors: [userPubkeyHex],
limit: 1
}
]);
if (profileEvents.length > 0) {
const event = profileEvents[0];
// First check tags (newer format where NIP-05 might be in tags)
const nip05Tag = event.tags.find((tag: string[]) =>
(tag[0] === 'nip05' || tag[0] === 'l') && tag[1]
);
if (nip05Tag && nip05Tag[1]) {
nip05Email = nip05Tag[1];
}
// Also check JSON content (traditional format)
if (!nip05Email) {
try {
const profile = JSON.parse(event.content);
// NIP-05 is stored as 'nip05' in the profile JSON
if (profile.nip05 && typeof profile.nip05 === 'string') {
nip05Email = profile.nip05;
}
} catch {
// Invalid JSON, ignore
}
}
}
} catch (err) {
console.warn('Failed to fetch user profile for email:', err);
} finally {
fetchingUserEmail = false;
}
// Always prompt user for email address (they might want to use a different domain)
// Always use userPubkeyHex to generate npub (userPubkey might be hex instead of npub)
const npubFromPubkey = userPubkeyHex ? nip19.npubEncode(userPubkeyHex) : (userPubkey || 'unknown');
const fallbackEmail = `${npubFromPubkey}@nostr`;
const prefillEmail = nip05Email || fallbackEmail;
// Prompt user for email address
const userEmail = prompt(
'Please enter your email address for git commits.\n\n' +
'This will be used as the author email in your commits.\n' +
'You can use any email address you prefer.',
prefillEmail
);
if (userEmail && userEmail.trim()) {
// Basic email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (emailRegex.test(userEmail.trim())) {
cachedUserEmail = userEmail.trim();
return cachedUserEmail;
} else {
alert('Invalid email format. Using fallback email address.');
}
}
// Use fallback if user cancelled or entered invalid email
cachedUserEmail = fallbackEmail;
return cachedUserEmail;
}
async function getUserName(): Promise<string> {
// Return cached name if available
if (cachedUserName) {
return cachedUserName;
}
// If no user pubkey, can't proceed
if (!userPubkeyHex) {
throw new Error('User not authenticated');
}
// Prevent concurrent fetches
if (fetchingUserName) {
// Wait a bit and retry (shouldn't happen, but just in case)
await new Promise(resolve => setTimeout(resolve, 100));
if (cachedUserName) {
return cachedUserName;
}
}
fetchingUserName = true;
let profileName: string | null = null;
try {
const client = new NostrClient(DEFAULT_NOSTR_RELAYS);
const profileEvents = await client.fetchEvents([
{
kinds: [0], // Kind 0 = profile metadata
authors: [userPubkeyHex],
limit: 1
}
]);
if (profileEvents.length > 0) {
try {
const profile = JSON.parse(profileEvents[0].content);
// Name is stored as 'name' in the profile JSON
if (profile.name && typeof profile.name === 'string') {
profileName = profile.name;
}
} catch {
// Invalid JSON, ignore
}
}
} catch (err) {
console.warn('Failed to fetch user profile for name:', err);
} finally {
fetchingUserName = false;
}
// Always prompt user for name (they might want to use a different name)
// Always use userPubkeyHex to generate npub (userPubkey might be hex instead of npub)
const npubFromPubkey = userPubkeyHex ? nip19.npubEncode(userPubkeyHex) : (userPubkey || 'unknown');
const fallbackName = npubFromPubkey;
const prefillName = profileName || fallbackName;
// Prompt user for name
const userName = prompt(
'Please enter your name for git commits.\n\n' +
'This will be used as the author name in your commits.\n' +
'You can use any name you prefer.',
prefillName
);
if (userName && userName.trim()) {
cachedUserName = userName.trim();
return cachedUserName;
}
// Use fallback if user cancelled
cachedUserName = fallbackName;
return cachedUserName;
}
async function saveFile() { async function saveFile() {
if (!currentFile || !commitMessage.trim()) { if (!currentFile || !commitMessage.trim()) {
alert('Please enter a commit message'); alert('Please enter a commit message');
@ -1742,12 +1974,42 @@
return; return;
} }
// Validate branch selection
if (!currentBranch || typeof currentBranch !== 'string') {
alert('Please select a branch before saving the file');
return;
}
saving = true; saving = true;
error = null; error = null;
try { try {
// Get npub from pubkey // Get user email and name (from profile or prompt)
const npubFromPubkey = nip19.npubEncode(userPubkey); const authorEmail = await getUserEmail();
const authorName = await getUserName();
// Sign commit with NIP-07 (client-side)
let commitSignatureEvent: NostrEvent | null = null;
if (isNIP07Available()) {
try {
const { KIND } = await import('$lib/types/nostr.js');
const timestamp = Math.floor(Date.now() / 1000);
const eventTemplate: Omit<NostrEvent, 'sig' | 'id'> = {
kind: KIND.COMMIT_SIGNATURE,
pubkey: '', // Will be filled by NIP-07
created_at: timestamp,
tags: [
['author', authorName, authorEmail],
['message', commitMessage.trim()]
],
content: `Signed commit: ${commitMessage.trim()}`
};
commitSignatureEvent = await signEventWithNIP07(eventTemplate);
} catch (err) {
console.warn('Failed to sign commit with NIP-07:', err);
// Continue without signature if signing fails
}
}
const response = await fetch(`/api/repos/${npub}/${repo}/file`, { const response = await fetch(`/api/repos/${npub}/${repo}/file`, {
method: 'POST', method: 'POST',
@ -1759,11 +2021,11 @@
path: currentFile, path: currentFile,
content: editedContent, content: editedContent,
commitMessage: commitMessage.trim(), commitMessage: commitMessage.trim(),
authorName: 'Web Editor', authorName: authorName,
authorEmail: `${npubFromPubkey}@nostr`, authorEmail: authorEmail,
branch: currentBranch, branch: currentBranch,
userPubkey: userPubkey, userPubkey: userPubkey,
useNIP07: true // Use NIP-07 for commit signing in web UI commitSignatureEvent: commitSignatureEvent // Send the signed event to server
}) })
}); });
@ -1786,14 +2048,37 @@
} }
} }
function handleBranchChange(event: Event) { async function handleBranchChange(event: Event) {
const target = event.target as HTMLSelectElement; const target = event.target as HTMLSelectElement;
currentBranch = target.value; currentBranch = target.value;
// Reload all branch-dependent data
const reloadPromises: Promise<void>[] = [];
// Always reload files (and current file if open)
if (currentFile) { if (currentFile) {
loadFile(currentFile); reloadPromises.push(loadFile(currentFile).catch(err => console.warn('Failed to reload file after branch change:', err)));
} else { } else {
loadFiles(currentPath); reloadPromises.push(loadFiles(currentPath).catch(err => console.warn('Failed to reload files after branch change:', err)));
} }
// Reload README (branch-specific)
reloadPromises.push(loadReadme().catch(err => console.warn('Failed to reload README after branch change:', err)));
// Reload commit history if history tab is active
if (activeTab === 'history') {
reloadPromises.push(loadCommitHistory().catch(err => console.warn('Failed to reload commit history after branch change:', err)));
}
// Reload documentation if docs tab is active (might be branch-specific)
if (activeTab === 'docs') {
// Reset documentation HTML to force reload
documentationHtml = null;
reloadPromises.push(loadDocumentation().catch(err => console.warn('Failed to reload documentation after branch change:', err)));
}
// Wait for all reloads to complete
await Promise.all(reloadPromises);
} }
async function createFile() { async function createFile() {
@ -1807,12 +2092,44 @@
return; return;
} }
// Validate branch selection
if (!currentBranch || typeof currentBranch !== 'string') {
alert('Please select a branch before creating the file');
return;
}
saving = true; saving = true;
error = null; error = null;
try { try {
const npubFromPubkey = nip19.npubEncode(userPubkey); // Get user email and name (from profile or prompt)
const authorEmail = await getUserEmail();
const authorName = await getUserName();
const filePath = currentPath ? `${currentPath}/${newFileName}` : newFileName; const filePath = currentPath ? `${currentPath}/${newFileName}` : newFileName;
const commitMsg = `Create ${newFileName}`;
// Sign commit with NIP-07 (client-side)
let commitSignatureEvent: NostrEvent | null = null;
if (isNIP07Available()) {
try {
const { KIND } = await import('$lib/types/nostr.js');
const timestamp = Math.floor(Date.now() / 1000);
const eventTemplate: Omit<NostrEvent, 'sig' | 'id'> = {
kind: KIND.COMMIT_SIGNATURE,
pubkey: '', // Will be filled by NIP-07
created_at: timestamp,
tags: [
['author', authorName, authorEmail],
['message', commitMsg]
],
content: `Signed commit: ${commitMsg}`
};
commitSignatureEvent = await signEventWithNIP07(eventTemplate);
} catch (err) {
console.warn('Failed to sign commit with NIP-07:', err);
// Continue without signature if signing fails
}
}
const response = await fetch(`/api/repos/${npub}/${repo}/file`, { const response = await fetch(`/api/repos/${npub}/${repo}/file`, {
method: 'POST', method: 'POST',
@ -1823,12 +2140,13 @@
body: JSON.stringify({ body: JSON.stringify({
path: filePath, path: filePath,
content: newFileContent, content: newFileContent,
commitMessage: `Create ${newFileName}`, commitMessage: commitMsg,
authorName: 'Web Editor', authorName: authorName,
authorEmail: `${npubFromPubkey}@nostr`, authorEmail: authorEmail,
branch: currentBranch, branch: currentBranch,
action: 'create', action: 'create',
userPubkey: userPubkey userPubkey: userPubkey,
commitSignatureEvent: commitSignatureEvent // Send the signed event to server
}) })
}); });
@ -1859,11 +2177,43 @@
return; return;
} }
// Validate branch selection
if (!currentBranch || typeof currentBranch !== 'string') {
alert('Please select a branch before deleting the file');
return;
}
saving = true; saving = true;
error = null; error = null;
try { try {
const npubFromPubkey = nip19.npubEncode(userPubkey); // Get user email and name (from profile or prompt)
const authorEmail = await getUserEmail();
const authorName = await getUserName();
const commitMsg = `Delete ${filePath}`;
// Sign commit with NIP-07 (client-side)
let commitSignatureEvent: NostrEvent | null = null;
if (isNIP07Available()) {
try {
const { KIND } = await import('$lib/types/nostr.js');
const timestamp = Math.floor(Date.now() / 1000);
const eventTemplate: Omit<NostrEvent, 'sig' | 'id'> = {
kind: KIND.COMMIT_SIGNATURE,
pubkey: '', // Will be filled by NIP-07
created_at: timestamp,
tags: [
['author', authorName, authorEmail],
['message', commitMsg]
],
content: `Signed commit: ${commitMsg}`
};
commitSignatureEvent = await signEventWithNIP07(eventTemplate);
} catch (err) {
console.warn('Failed to sign commit with NIP-07:', err);
// Continue without signature if signing fails
}
}
const response = await fetch(`/api/repos/${npub}/${repo}/file`, { const response = await fetch(`/api/repos/${npub}/${repo}/file`, {
method: 'POST', method: 'POST',
@ -1873,12 +2223,13 @@
}, },
body: JSON.stringify({ body: JSON.stringify({
path: filePath, path: filePath,
commitMessage: `Delete ${filePath}`, commitMessage: commitMsg,
authorName: 'Web Editor', authorName: authorName,
authorEmail: `${npubFromPubkey}@nostr`, authorEmail: authorEmail,
branch: currentBranch, branch: currentBranch,
action: 'delete', action: 'delete',
userPubkey: userPubkey userPubkey: userPubkey,
commitSignatureEvent: commitSignatureEvent // Send the signed event to server
}) })
}); });
@ -1917,7 +2268,7 @@
}, },
body: JSON.stringify({ body: JSON.stringify({
branchName: newBranchName, branchName: newBranchName,
fromBranch: newBranchFrom fromBranch: newBranchFrom || currentBranch
}) })
}); });
@ -1937,6 +2288,52 @@
} }
} }
async function deleteBranch(branchName: string) {
if (!confirm(`Are you sure you want to delete the branch "${branchName}"? This action cannot be undone.`)) {
return;
}
if (!userPubkey) {
alert('Please connect your NIP-07 extension');
return;
}
// Prevent deleting the current branch
if (branchName === currentBranch) {
alert('Cannot delete the currently selected branch. Please switch to a different branch first.');
return;
}
saving = true;
error = null;
try {
const response = await fetch(`/api/repos/${npub}/${repo}/branches`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
...buildApiHeaders()
},
body: JSON.stringify({
branchName: branchName
})
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || 'Failed to delete branch');
}
await loadBranches();
alert('Branch deleted successfully!');
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to delete branch';
alert(error);
} finally {
saving = false;
}
}
async function loadCommitHistory() { async function loadCommitHistory() {
loadingCommits = true; loadingCommits = true;
error = null; error = null;
@ -2231,12 +2628,34 @@
} }
}); });
// Only load readme when branch changes, not on every render // Reload all branch-dependent data when branch changes
let lastBranch = $state<string | null>(null); let lastBranch = $state<string | null>(null);
$effect(() => { $effect(() => {
if (currentBranch && currentBranch !== lastBranch) { if (currentBranch && currentBranch !== lastBranch) {
lastBranch = currentBranch; lastBranch = currentBranch;
loadReadme();
// Reload README (always branch-specific)
loadReadme().catch(err => console.warn('Failed to reload README after branch change:', err));
// Reload files if files tab is active
if (activeTab === 'files') {
if (currentFile) {
loadFile(currentFile).catch(err => console.warn('Failed to reload file after branch change:', err));
} else {
loadFiles(currentPath).catch(err => console.warn('Failed to reload files after branch change:', err));
}
}
// Reload commit history if history tab is active
if (activeTab === 'history') {
loadCommitHistory().catch(err => console.warn('Failed to reload commit history after branch change:', err));
}
// Reload documentation if docs tab is active (reset to force reload)
if (activeTab === 'docs') {
documentationHtml = null;
loadDocumentation().catch(err => console.warn('Failed to reload documentation after branch change:', err));
}
} }
}); });
</script> </script>
@ -2417,14 +2836,31 @@
</div> </div>
</div> </div>
<div class="header-actions"> <div class="header-actions">
{#if branches.length > 0} <div style="display: flex; align-items: center; gap: 0.5rem;">
<select bind:value={currentBranch} onchange={handleBranchChange} class="branch-select"> <select bind:value={currentBranch} onchange={handleBranchChange} class="branch-select" disabled={branches.length === 0 && loading}>
{#if branches.length === 0}
<!-- Show current branch even if branches haven't loaded yet -->
<option value={currentBranch}>{currentBranch}{loading ? ' (loading...)' : ''}</option>
{:else}
{#each branches as branch} {#each branches as branch}
{@const branchName = typeof branch === 'string' ? branch : (branch as { name: string }).name} {@const branchName = typeof branch === 'string' ? branch : (branch as { name: string }).name}
<option value={branchName}>{branchName}</option> <option value={branchName}>{branchName}</option>
{/each} {/each}
{/if}
</select> </select>
{#if isMaintainer && branches.length > 0 && currentBranch && branches.length > 1}
{@const canDelete = defaultBranch !== null && currentBranch !== defaultBranch}
{#if canDelete && currentBranch}
<button
onclick={() => currentBranch && deleteBranch(currentBranch)}
class="delete-branch-button"
disabled={saving}
title="Delete current branch"
style="padding: 0.25rem 0.5rem; font-size: 0.875rem; background: var(--danger, #dc2626); color: white; border: none; border-radius: 0.25rem; cursor: pointer;"
</button>
{/if}
{/if} {/if}
</div>
{#if verificationStatus} {#if verificationStatus}
<span class="verification-status" class:verified={verificationStatus.verified} class:unverified={!verificationStatus.verified}> <span class="verification-status" class:verified={verificationStatus.verified} class:unverified={!verificationStatus.verified}>
{#if verificationStatus.verified} {#if verificationStatus.verified}

Loading…
Cancel
Save