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( @@ -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 });
}
}
);

178
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,12 +77,17 @@ export const GET: RequestHandler = async ({ params, url, request }: { params: { @@ -61,12 +77,17 @@ export const GET: RequestHandler = async ({ params, url, request }: { params: {
if (events.length > 0) {
// Try API-based fetching first (no cloning)
try {
const { tryApiFetchFile } = await import('$lib/utils/api-repo-helper.js');
const fileContent = await tryApiFetchFile(events[0], npub, repo, filePath, ref);
if (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
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: { @@ -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
// 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.');
}
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 {
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(
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(

526
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';
// 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">
<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