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. 188
      src/routes/api/repos/[npub]/[repo]/file/+server.ts
  7. 540
      src/routes/repos/[npub]/[repo]/+page.svelte

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

@ -160,14 +160,19 @@ export async function createGitCommitSignature( @@ -160,14 +160,19 @@ export async function createGitCommitSignature(
useNIP07?: boolean;
nip98Event?: NostrEvent;
nsecKey?: string;
commitSignatureEvent?: NostrEvent; // Pre-signed event from client (NIP-07)
timestamp?: number;
} = {}
): Promise<{ signedMessage: string; signatureEvent: NostrEvent }> {
const timestamp = options.timestamp || Math.floor(Date.now() / 1000);
let signedEvent: NostrEvent;
// Method 1: Use NIP-07 browser extension (client-side)
if (options.useNIP07) {
// Method 1: Use pre-signed commit signature event from client (NIP-07)
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
const eventTemplate: Omit<NostrEvent, 'sig' | 'id'> = {
kind: KIND.COMMIT_SIGNATURE,
@ -238,7 +243,7 @@ export async function createGitCommitSignature( @@ -238,7 +243,7 @@ export async function createGitCommitSignature(
signedEvent = finalizeEvent(eventTemplate, keyBytes);
} 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

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

@ -70,24 +70,61 @@ export class FileManager { @@ -70,24 +70,61 @@ export class FileManager {
}
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
const resolvedPath = resolve(worktreePath).replace(/\\/g, '/');
const resolvedRoot = resolve(worktreeRoot).replace(/\\/g, '/');
const resolvedPath = worktreePath.replace(/\\/g, '/');
const resolvedRoot = resolvedWorktreeRoot.replace(/\\/g, '/');
if (!resolvedPath.startsWith(resolvedRoot + '/')) {
throw new Error('Path traversal detected: worktree path outside allowed root');
}
const { mkdir, rm } = await import('fs/promises');
// Ensure worktree root exists
if (!existsSync(worktreeRoot)) {
await mkdir(worktreeRoot, { recursive: true });
// Ensure worktree root exists (use resolved path)
if (!existsSync(resolvedWorktreeRoot)) {
await mkdir(resolvedWorktreeRoot, { recursive: true });
}
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)) {
// Verify it's a valid worktree
try {
@ -202,10 +239,23 @@ export class FileManager { @@ -202,10 +239,23 @@ export class FileManager {
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;
} catch (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}`);
}
}
@ -505,7 +555,38 @@ export class FileManager { @@ -505,7 +555,38 @@ export class FileManager {
try {
// 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)
const encoding = 'utf-8';
@ -517,15 +598,40 @@ export class FileManager { @@ -517,15 +598,40 @@ export class FileManager {
size
};
} catch (error) {
logger.error({ error, repoPath, filePath, ref }, 'Error reading file');
throw new Error(`Failed to read file: ${error instanceof Error ? error.message : String(error)}`);
const errorMessage = 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
* @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)
* - nsecKey: Use direct nsec/hex key (server-side ONLY, via environment variables - NOT for client requests)
*/
@ -539,6 +645,7 @@ export class FileManager { @@ -539,6 +645,7 @@ export class FileManager {
authorEmail: string,
branch: string = 'main',
signingOptions?: {
commitSignatureEvent?: NostrEvent;
useNIP07?: boolean;
nip98Event?: NostrEvent;
nsecKey?: string;
@ -630,7 +737,7 @@ export class FileManager { @@ -630,7 +737,7 @@ export class FileManager {
// Sign commit if signing options are provided
let finalCommitMessage = commitMessage;
if (signingOptions && (signingOptions.useNIP07 || signingOptions.nip98Event || signingOptions.nsecKey)) {
if (signingOptions && (signingOptions.commitSignatureEvent || signingOptions.useNIP07 || signingOptions.nip98Event || signingOptions.nsecKey)) {
try {
const { signedMessage } = await createGitCommitSignature(
commitMessage,
@ -652,8 +759,9 @@ export class FileManager { @@ -652,8 +759,9 @@ export class FileManager {
'--author': `${authorName} <${authorEmail}>`
});
// Push to bare repo (worktree is already connected)
await workGit.push(['origin', branch]);
// Note: No push needed - worktrees of bare repos share the same object database,
// 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)
// Note: We could keep worktrees for better performance, but clean up for now
@ -742,10 +850,44 @@ export class FileManager { @@ -742,10 +850,44 @@ export class FileManager {
const git: SimpleGit = simpleGit(repoPath);
try {
const branches = await git.branch(['-r']);
const branchList = branches.all
// For bare repositories, list local branches (they're stored in refs/heads/)
// 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\//, ''))
.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)
repoCache.set(cacheKey, branchList, 2 * 60 * 1000);
@ -796,6 +938,7 @@ export class FileManager { @@ -796,6 +938,7 @@ export class FileManager {
authorEmail: string,
branch: string = 'main',
signingOptions?: {
commitSignatureEvent?: NostrEvent;
useNIP07?: boolean;
nip98Event?: NostrEvent;
nsecKey?: string;
@ -867,7 +1010,7 @@ export class FileManager { @@ -867,7 +1010,7 @@ export class FileManager {
// Sign commit if signing options are provided
let finalCommitMessage = commitMessage;
if (signingOptions && (signingOptions.useNIP07 || signingOptions.nip98Event || signingOptions.nsecKey)) {
if (signingOptions && (signingOptions.commitSignatureEvent || signingOptions.useNIP07 || signingOptions.nip98Event || signingOptions.nsecKey)) {
try {
const { signedMessage } = await createGitCommitSignature(
commitMessage,
@ -889,8 +1032,9 @@ export class FileManager { @@ -889,8 +1032,9 @@ export class FileManager {
'--author': `${authorName} <${authorEmail}>`
});
// Push to bare repo (worktree is already connected)
await workGit.push(['origin', branch]);
// Note: No push needed - worktrees of bare repos share the same object database,
// 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
await this.removeWorktree(repoPath, workDir);
@ -931,8 +1075,9 @@ export class FileManager { @@ -931,8 +1075,9 @@ export class FileManager {
// Create and checkout new branch
await workGit.checkout(['-b', branchName]);
// Push new branch
await workGit.push(['origin', branchName]);
// Note: No push needed - worktrees of bare repos share the same object database,
// 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
await this.removeWorktree(repoPath, workDir);
@ -942,6 +1087,67 @@ export class FileManager { @@ -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
*/

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

@ -160,7 +160,13 @@ export async function tryApiFetchFile( @@ -160,7 +160,13 @@ export async function tryApiFetchFile(
if (platform === 'gitea' || platform === 'github') {
// Gitea and GitHub return base64 encoded 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 {
content,
encoding: 'base64'

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

@ -138,8 +138,25 @@ export const POST: RequestHandler = createRepoPostHandler( @@ -138,8 +138,25 @@ export const POST: RequestHandler = createRepoPostHandler(
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' });
},
{ 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 @@ @@ -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 });
}
}
);

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

@ -19,17 +19,33 @@ import { KIND } from '$lib/types/nostr.js'; @@ -19,17 +19,33 @@ import { KIND } from '$lib/types/nostr.js';
import { join } from 'path';
import { existsSync } from 'fs';
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
? process.env.GIT_REPO_ROOT
: '/repos';
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 filePath = url.searchParams.get('path');
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) {
return error(400, 'Missing npub, repo, or path parameter');
@ -61,11 +77,16 @@ export const GET: RequestHandler = async ({ params, url, request }: { params: { @@ -61,11 +77,16 @@ export const GET: RequestHandler = async ({ params, url, request }: { params: {
if (events.length > 0) {
// Try API-based fetching first (no cloning)
const { tryApiFetchFile } = await import('$lib/utils/api-repo-helper.js');
const fileContent = await tryApiFetchFile(events[0], npub, repo, filePath, ref);
try {
const { tryApiFetchFile } = await import('$lib/utils/api-repo-helper.js');
const fileContent = await tryApiFetchFile(events[0], npub, repo, filePath, ref);
if (fileContent) {
return json(fileContent);
if (fileContent && fileContent.content) {
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
@ -74,6 +95,7 @@ export const GET: RequestHandler = async ({ params, url, request }: { params: { @@ -74,6 +95,7 @@ export const GET: RequestHandler = async ({ params, url, request }: { params: {
return error(404, 'Repository announcement not found in Nostr');
}
} catch (err) {
logger.error({ error: err, npub, repo, filePath }, 'Error in on-demand file fetch');
// Check if repo was created by another concurrent request
if (existsSync(repoPath)) {
// Repo exists now, clear cache and continue with normal flow
@ -103,22 +125,43 @@ export const GET: RequestHandler = async ({ params, url, request }: { params: { @@ -103,22 +125,43 @@ export const GET: RequestHandler = async ({ params, url, request }: { params: {
try {
const branches = await fileManager.getBranches(npub, repo);
if (!branches.includes(ref)) {
// Branch doesn't exist, use default branch
ref = await fileManager.getDefaultBranch(npub, repo);
// Branch doesn't exist, try to get default branch
try {
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
logger.warn({ error: branchErr, npub, repo, ref }, 'Could not get branches, falling back to HEAD');
ref = 'HEAD';
}
}
// 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) {
const clientIp = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown';
auditLogger.logFileOperation(
userPubkey || null,
clientIp,
userPubkeyHex || null,
requestContext.clientIp,
'read',
`${npub}/${repo}`,
filePath,
@ -127,13 +170,38 @@ export const GET: RequestHandler = async ({ params, url, request }: { params: { @@ -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.');
}
const clientIp = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown';
try {
const fileContent = await fileManager.getFileContent(npub, repo, filePath, ref);
// Log what we're trying to do
logger.debug({ npub, repo, filePath, ref }, 'Attempting to read file from cloned repository');
let fileContent;
try {
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(
userPubkey || null,
clientIp,
userPubkeyHex || null,
requestContext.clientIp,
'read',
`${npub}/${repo}`,
filePath,
@ -141,18 +209,75 @@ export const GET: RequestHandler = async ({ params, url, request }: { params: { @@ -141,18 +209,75 @@ export const GET: RequestHandler = async ({ params, url, request }: { params: {
);
return json(fileContent);
} 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(
userPubkey || null,
clientIp,
userPubkeyHex || null,
requestContext.clientIp,
'read',
`${npub}/${repo}`,
filePath,
'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) {
// 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');
}
};
@ -168,7 +293,7 @@ export const POST: RequestHandler = async ({ params, url, request }: { params: { @@ -168,7 +293,7 @@ export const POST: RequestHandler = async ({ params, url, request }: { params: {
try {
const body = await request.json();
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)
const authHeader = request.headers.get('Authorization');
@ -242,13 +367,16 @@ export const POST: RequestHandler = async ({ params, url, request }: { params: { @@ -242,13 +367,16 @@ export const POST: RequestHandler = async ({ params, url, request }: { params: {
useNIP07?: boolean;
nip98Event?: NostrEvent;
nsecKey?: string;
commitSignatureEvent?: NostrEvent;
} = {};
if (useNIP07) {
signingOptions.useNIP07 = true;
// If client sent a pre-signed commit signature event (from NIP-07), use it
if (commitSignatureEvent && commitSignatureEvent.sig && commitSignatureEvent.id) {
signingOptions.commitSignatureEvent = commitSignatureEvent;
} else if (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
// Server-side signing should use NOSTRGIT_SECRET_KEY environment variable instead
if (nsecKey) {
@ -270,6 +398,9 @@ export const POST: RequestHandler = async ({ params, url, request }: { params: { @@ -270,6 +398,9 @@ export const POST: RequestHandler = async ({ params, url, request }: { params: {
if (action === 'delete') {
try {
// Get default branch if not provided
const targetBranch = branch || await fileManager.getDefaultBranch(npub, repo);
await fileManager.deleteFile(
npub,
repo,
@ -277,7 +408,7 @@ export const POST: RequestHandler = async ({ params, url, request }: { params: { @@ -277,7 +408,7 @@ export const POST: RequestHandler = async ({ params, url, request }: { params: {
commitMessage,
authorName,
authorEmail,
branch || 'main',
targetBranch,
Object.keys(signingOptions).length > 0 ? signingOptions : undefined
);
auditLogger.logFileOperation(
@ -306,6 +437,9 @@ export const POST: RequestHandler = async ({ params, url, request }: { params: { @@ -306,6 +437,9 @@ export const POST: RequestHandler = async ({ params, url, request }: { params: {
return error(400, 'Content is required for create/update operations');
}
try {
// Get default branch if not provided
const targetBranch = branch || await fileManager.getDefaultBranch(npub, repo);
await fileManager.writeFile(
npub,
repo,
@ -314,7 +448,7 @@ export const POST: RequestHandler = async ({ params, url, request }: { params: { @@ -314,7 +448,7 @@ export const POST: RequestHandler = async ({ params, url, request }: { params: {
commitMessage,
authorName,
authorEmail,
branch || 'main',
targetBranch,
Object.keys(signingOptions).length > 0 ? signingOptions : undefined
);
auditLogger.logFileOperation(

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

@ -52,7 +52,8 @@ @@ -52,7 +52,8 @@
let hasChanges = $state(false);
let saving = $state(false);
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 userPubkey = $state<string | null>(null);
let userPubkeyHex = $state<string | null>(null);
@ -73,9 +74,16 @@ @@ -73,9 +74,16 @@
if (wasDifferent) {
// Reset repoNotFound flag when user logs in, so we can retry loading
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));
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
if (!loading) {
loadBranches().catch(err => console.warn('Failed to reload branches after login:', err));
@ -89,6 +97,9 @@ @@ -89,6 +97,9 @@
} else {
userPubkey = 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
if (wasLoggedIn) {
@ -113,7 +124,7 @@ @@ -113,7 +124,7 @@
// Branch creation
let showCreateBranchDialog = $state(false);
let newBranchName = $state('');
let newBranchFrom = $state('main');
let newBranchFrom = $state<string | null>(null);
// Commit history
let commits = $state<Array<{ hash: string; message: string; author: string; date: string; files: string[] }>>([]);
@ -557,18 +568,25 @@ @@ -557,18 +568,25 @@
return count;
}
async function checkCloneStatus() {
if (checkingCloneStatus || isRepoCloned !== null) return;
async function checkCloneStatus(force: boolean = false) {
if (checkingCloneStatus || (!force && isRepoCloned !== null)) return;
checkingCloneStatus = true;
try {
// 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`, {
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) {
// On error, assume not cloned
console.warn('[Clone Status] Error checking clone status:', err);
isRepoCloned = false;
} finally {
checkingCloneStatus = false;
@ -594,14 +612,23 @@ @@ -594,14 +612,23 @@
}
const data = await response.json();
isRepoCloned = true;
if (data.alreadyExists) {
alert('Repository already exists locally.');
// Force refresh clone status
await checkCloneStatus(true);
} else {
alert('Repository cloned successfully! The repository is now available on this server.');
// Reload the page to show the cloned repo
window.location.reload();
// Force refresh clone status
await checkCloneStatus(true);
// Reload data to use the cloned repo instead of API
await Promise.all([
loadBranches(),
loadFiles(currentPath),
loadReadme(),
loadTags(),
loadCommitHistory()
]);
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to clone repository';
@ -1282,14 +1309,7 @@ @@ -1282,14 +1309,7 @@
loading = false;
return;
}
// Update currentBranch to first available branch if 'main' doesn't exist
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];
}
}
// loadBranches() already handles setting currentBranch to the default branch
await loadFiles();
await checkAuth();
await loadTags();
@ -1552,8 +1572,34 @@ @@ -1552,8 +1572,34 @@
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];
// 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) {
@ -1590,7 +1636,10 @@ @@ -1590,7 +1636,10 @@
} else if (response.status === 403) {
// 403 means access denied - don't set repoNotFound, just show error
// 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}`);
}
@ -1610,7 +1659,12 @@ @@ -1610,7 +1659,12 @@
}
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to load files';
console.error('Error loading files:', err);
// 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);
}
} finally {
loading = false;
}
@ -1658,13 +1712,18 @@ @@ -1658,13 +1712,18 @@
error = null;
try {
// 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'
? currentBranch
: (typeof currentBranch === 'object' && currentBranch !== null && 'name' in currentBranch
? (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 response = await fetch(url);
const response = await fetch(url, {
headers: buildApiHeaders()
});
if (!response.ok) {
throw new Error(`Failed to load file: ${response.statusText}`);
@ -1731,6 +1790,179 @@ @@ -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() {
if (!currentFile || !commitMessage.trim()) {
alert('Please enter a commit message');
@ -1742,12 +1974,42 @@ @@ -1742,12 +1974,42 @@
return;
}
// Validate branch selection
if (!currentBranch || typeof currentBranch !== 'string') {
alert('Please select a branch before saving the file');
return;
}
saving = true;
error = null;
try {
// Get npub from pubkey
const npubFromPubkey = nip19.npubEncode(userPubkey);
// Get user email and name (from profile or prompt)
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`, {
method: 'POST',
@ -1759,11 +2021,11 @@ @@ -1759,11 +2021,11 @@
path: currentFile,
content: editedContent,
commitMessage: commitMessage.trim(),
authorName: 'Web Editor',
authorEmail: `${npubFromPubkey}@nostr`,
authorName: authorName,
authorEmail: authorEmail,
branch: currentBranch,
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 @@ @@ -1786,14 +2048,37 @@
}
}
function handleBranchChange(event: Event) {
async function handleBranchChange(event: Event) {
const target = event.target as HTMLSelectElement;
currentBranch = target.value;
// Reload all branch-dependent data
const reloadPromises: Promise<void>[] = [];
// Always reload files (and current file if open)
if (currentFile) {
loadFile(currentFile);
reloadPromises.push(loadFile(currentFile).catch(err => console.warn('Failed to reload file after branch change:', err)));
} 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() {
@ -1807,12 +2092,44 @@ @@ -1807,12 +2092,44 @@
return;
}
// Validate branch selection
if (!currentBranch || typeof currentBranch !== 'string') {
alert('Please select a branch before creating the file');
return;
}
saving = true;
error = null;
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 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`, {
method: 'POST',
@ -1823,12 +2140,13 @@ @@ -1823,12 +2140,13 @@
body: JSON.stringify({
path: filePath,
content: newFileContent,
commitMessage: `Create ${newFileName}`,
authorName: 'Web Editor',
authorEmail: `${npubFromPubkey}@nostr`,
commitMessage: commitMsg,
authorName: authorName,
authorEmail: authorEmail,
branch: currentBranch,
action: 'create',
userPubkey: userPubkey
userPubkey: userPubkey,
commitSignatureEvent: commitSignatureEvent // Send the signed event to server
})
});
@ -1859,11 +2177,43 @@ @@ -1859,11 +2177,43 @@
return;
}
// Validate branch selection
if (!currentBranch || typeof currentBranch !== 'string') {
alert('Please select a branch before deleting the file');
return;
}
saving = true;
error = null;
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`, {
method: 'POST',
@ -1873,12 +2223,13 @@ @@ -1873,12 +2223,13 @@
},
body: JSON.stringify({
path: filePath,
commitMessage: `Delete ${filePath}`,
authorName: 'Web Editor',
authorEmail: `${npubFromPubkey}@nostr`,
commitMessage: commitMsg,
authorName: authorName,
authorEmail: authorEmail,
branch: currentBranch,
action: 'delete',
userPubkey: userPubkey
userPubkey: userPubkey,
commitSignatureEvent: commitSignatureEvent // Send the signed event to server
})
});
@ -1917,7 +2268,7 @@ @@ -1917,7 +2268,7 @@
},
body: JSON.stringify({
branchName: newBranchName,
fromBranch: newBranchFrom
fromBranch: newBranchFrom || currentBranch
})
});
@ -1937,6 +2288,52 @@ @@ -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() {
loadingCommits = true;
error = null;
@ -2231,12 +2628,34 @@ @@ -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);
$effect(() => {
if (currentBranch && currentBranch !== lastBranch) {
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>
@ -2417,14 +2836,31 @@ @@ -2417,14 +2836,31 @@
</div>
</div>
<div class="header-actions">
{#if branches.length > 0}
<select bind:value={currentBranch} onchange={handleBranchChange} class="branch-select">
{#each branches as branch}
{@const branchName = typeof branch === 'string' ? branch : (branch as { name: string }).name}
<option value={branchName}>{branchName}</option>
{/each}
</select>
{/if}
<div style="display: flex; align-items: center; gap: 0.5rem;">
<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}
{@const branchName = typeof branch === 'string' ? branch : (branch as { name: string }).name}
<option value={branchName}>{branchName}</option>
{/each}
{/if}
</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}
</div>
{#if verificationStatus}
<span class="verification-status" class:verified={verificationStatus.verified} class:unverified={!verificationStatus.verified}>
{#if verificationStatus.verified}

Loading…
Cancel
Save