diff --git a/repos/npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z/aitherboard.work b/repos/npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z/aitherboard.work new file mode 160000 index 0000000..432ca40 --- /dev/null +++ b/repos/npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z/aitherboard.work @@ -0,0 +1 @@ +Subproject commit 432ca405d33a6389b1d12529e6e0e76b523b39f8 diff --git a/src/lib/components/ThemeToggle.svelte b/src/lib/components/ThemeToggle.svelte index e343181..b563a95 100644 --- a/src/lib/components/ThemeToggle.svelte +++ b/src/lib/components/ThemeToggle.svelte @@ -51,9 +51,23 @@ @@ -87,7 +101,15 @@ } .theme-toggle-icon { - font-size: 1rem; - line-height: 1; + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + } + + .theme-toggle-icon svg { + width: 100%; + height: 100%; } diff --git a/src/lib/services/git/file-manager.ts b/src/lib/services/git/file-manager.ts index a4cc38d..ab812c3 100644 --- a/src/lib/services/git/file-manager.ts +++ b/src/lib/services/git/file-manager.ts @@ -282,6 +282,10 @@ export class FileManager { * Validate and sanitize file path to prevent path traversal attacks */ private validateFilePath(filePath: string): { valid: boolean; error?: string; normalized?: string } { + // Allow empty string for root directory + if (filePath === '') { + return { valid: true, normalized: '' }; + } if (!filePath || typeof filePath !== 'string') { return { valid: false, error: 'File path must be a non-empty string' }; } diff --git a/src/routes/api/repos/[npub]/[repo]/download/+server.ts b/src/routes/api/repos/[npub]/[repo]/download/+server.ts index 06f3e2e..8abdc21 100644 --- a/src/routes/api/repos/[npub]/[repo]/download/+server.ts +++ b/src/routes/api/repos/[npub]/[repo]/download/+server.ts @@ -4,7 +4,7 @@ import { error, json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; -import { fileManager } from '$lib/services/service-registry.js'; +import { fileManager, repoManager, nostrClient } from '$lib/services/service-registry.js'; import { createRepoGetHandler } from '$lib/utils/api-handlers.js'; import type { RepoRequestContext, RequestEvent } from '$lib/utils/api-context.js'; import { spawn } from 'child_process'; @@ -13,7 +13,10 @@ import { join, resolve } from 'path'; import logger from '$lib/services/logger.js'; import { isValidBranchName, sanitizeError } from '$lib/utils/security.js'; import simpleGit from 'simple-git'; -import { handleApiError } from '$lib/utils/error-handler.js'; +import { handleApiError, handleNotFoundError } from '$lib/utils/error-handler.js'; +import { KIND } from '$lib/types/nostr.js'; +import { existsSync } from 'fs'; +import { repoCache, RepoCache } from '$lib/services/git/repo-cache.js'; const repoRoot = typeof process !== 'undefined' && process.env?.GIT_REPO_ROOT ? process.env.GIT_REPO_ROOT @@ -21,20 +24,108 @@ const repoRoot = typeof process !== 'undefined' && process.env?.GIT_REPO_ROOT export const GET: RequestHandler = createRepoGetHandler( async (context: RepoRequestContext, event: RequestEvent) => { - const ref = event.url.searchParams.get('ref') || 'HEAD'; + const repoPath = join(repoRoot, context.npub, `${context.repo}.git`); + + // If repo doesn't exist, try to fetch it on-demand + if (!existsSync(repoPath)) { + try { + // Fetch repository announcement from Nostr + const events = await nostrClient.fetchEvents([ + { + kinds: [KIND.REPO_ANNOUNCEMENT], + authors: [context.repoOwnerPubkey], + '#d': [context.repo], + limit: 1 + } + ]); + + if (events.length > 0) { + // Try to fetch the repository from remote clone URLs + const fetched = await repoManager.fetchRepoOnDemand( + context.npub, + context.repo, + events[0] + ); + + // Always check if repo exists after fetch attempt (might have been created) + // Also clear cache to ensure fileManager sees it + if (existsSync(repoPath)) { + repoCache.delete(RepoCache.repoExistsKey(context.npub, context.repo)); + // Repo exists, continue with normal flow + } else if (!fetched) { + // Fetch failed and repo doesn't exist + throw handleNotFoundError( + 'Repository not found and could not be fetched from remote. The repository may not have any accessible clone URLs.', + { operation: 'download', npub: context.npub, repo: context.repo } + ); + } else { + // Fetch returned true but repo doesn't exist - this shouldn't happen, but clear cache anyway + repoCache.delete(RepoCache.repoExistsKey(context.npub, context.repo)); + // Wait a moment for filesystem to sync, then check again + await new Promise(resolve => setTimeout(resolve, 100)); + if (!existsSync(repoPath)) { + throw handleNotFoundError( + 'Repository fetch completed but repository is not accessible', + { operation: 'download', npub: context.npub, repo: context.repo } + ); + } + } + } else { + throw handleNotFoundError( + 'Repository announcement not found in Nostr', + { operation: 'download', npub: context.npub, repo: context.repo } + ); + } + } catch (err) { + // Check if repo was created by another concurrent request + if (existsSync(repoPath)) { + // Repo exists now, clear cache and continue with normal flow + repoCache.delete(RepoCache.repoExistsKey(context.npub, context.repo)); + } else { + // If fetching fails, return 404 + throw handleNotFoundError( + 'Repository not found', + { operation: 'download', npub: context.npub, repo: context.repo } + ); + } + } + } + + // Double-check repo exists after on-demand fetch + if (!existsSync(repoPath)) { + throw handleNotFoundError( + 'Repository not found', + { operation: 'download', npub: context.npub, repo: context.repo } + ); + } + + let ref = event.url.searchParams.get('ref') || 'HEAD'; const format = event.url.searchParams.get('format') || 'zip'; // zip or tar.gz - // Security: Validate ref to prevent command injection - if (ref !== 'HEAD' && !isValidBranchName(ref)) { - throw error(400, 'Invalid ref format'); + // If ref is a branch name, validate it exists or use default branch + if (ref !== 'HEAD' && !ref.startsWith('refs/')) { + // Security: Validate ref to prevent command injection + if (!isValidBranchName(ref)) { + throw error(400, 'Invalid ref format'); + } + + // Validate branch exists or use default + try { + const branches = await fileManager.getBranches(context.npub, context.repo); + if (!branches.includes(ref)) { + // Branch doesn't exist, use default branch + ref = await fileManager.getDefaultBranch(context.npub, context.repo); + } + } catch { + // If we can't get branches, fall back to HEAD + ref = 'HEAD'; + } } // Security: Validate format if (format !== 'zip' && format !== 'tar.gz') { throw error(400, 'Invalid format. Must be "zip" or "tar.gz"'); } - - const repoPath = join(repoRoot, context.npub, `${context.repo}.git`); // Security: Ensure resolved path is within repoRoot const resolvedRepoPath = resolve(repoPath).replace(/\\/g, '/'); const resolvedRoot = resolve(repoRoot).replace(/\\/g, '/'); @@ -77,6 +168,13 @@ export const GET: RequestHandler = createRepoGetHandler( // Remove .git directory using fs/promises await rm(join(workDir, '.git'), { recursive: true, force: true }); + // Verify workDir has content before archiving + const { readdir } = await import('fs/promises'); + const workDirContents = await readdir(workDir); + if (workDirContents.length === 0) { + throw new Error('Repository work directory is empty, cannot create archive'); + } + // Create archive using spawn (safer than exec) if (format === 'tar.gz') { await new Promise((resolve, reject) => { @@ -96,21 +194,45 @@ export const GET: RequestHandler = createRepoGetHandler( }); } else { // Use zip command (requires zip utility) - using spawn for safety + // Make archive path absolute for zip command + const absoluteArchivePath = resolve(archivePath); + + // Ensure the archive directory exists + const archiveDir = join(absoluteArchivePath, '..'); + await mkdir(archiveDir, { recursive: true }); + await new Promise((resolve, reject) => { - const zipProcess = spawn('zip', ['-r', archivePath, '.'], { + const zipProcess = spawn('zip', ['-r', absoluteArchivePath, '.'], { cwd: workDir, stdio: ['ignore', 'pipe', 'pipe'] }); + let stdout = ''; let stderr = ''; + zipProcess.stdout.on('data', (chunk) => { stdout += chunk.toString(); }); zipProcess.stderr.on('data', (chunk) => { stderr += chunk.toString(); }); - zipProcess.on('close', (code) => { + zipProcess.on('close', async (code) => { if (code === 0) { - resolve(); + // Verify archive was created + try { + const fs = await import('fs/promises'); + await fs.access(absoluteArchivePath); + resolve(); + } catch { + reject(new Error(`zip command succeeded but archive file was not created at ${absoluteArchivePath}`)); + } + } else { + const errorMsg = (stderr || stdout || 'Unknown error').trim(); + reject(new Error(`zip failed with code ${code}: ${errorMsg || 'No error message'}`)); + } + }); + zipProcess.on('error', (err) => { + // If zip command doesn't exist, provide helpful error + if (err.message.includes('ENOENT') || (err as any).code === 'ENOENT') { + reject(new Error('zip command not found. Please install zip utility (e.g., apt-get install zip or brew install zip)')); } else { - reject(new Error(`zip failed: ${stderr}`)); + reject(err); } }); - zipProcess.on('error', reject); }); } @@ -138,5 +260,5 @@ export const GET: RequestHandler = createRepoGetHandler( throw archiveError; } }, - { operation: 'download' } + { operation: 'download', requireRepoExists: false, requireRepoAccess: false } // Handle on-demand fetching, downloads are public ); diff --git a/src/routes/api/repos/[npub]/[repo]/file/+server.ts b/src/routes/api/repos/[npub]/[repo]/file/+server.ts index a4c6102..04974bc 100644 --- a/src/routes/api/repos/[npub]/[repo]/file/+server.ts +++ b/src/routes/api/repos/[npub]/[repo]/file/+server.ts @@ -5,7 +5,7 @@ import { json, error } from '@sveltejs/kit'; // @ts-ignore - SvelteKit generates this type import type { RequestHandler } from './$types'; -import { FileManager } from '$lib/services/git/file-manager.js'; +import { fileManager, repoManager, nostrClient } from '$lib/services/service-registry.js'; import { MaintainerService } from '$lib/services/nostr/maintainer-service.js'; import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js'; import { nip19 } from 'nostr-tools'; @@ -15,15 +15,20 @@ import logger from '$lib/services/logger.js'; import type { NostrEvent } from '$lib/types/nostr.js'; import { requireNpubHex, decodeNpubToHex } from '$lib/utils/npub-utils.js'; import { handleApiError, handleValidationError, handleNotFoundError } from '$lib/utils/error-handler.js'; +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'; -const repoRoot = process.env.GIT_REPO_ROOT || '/repos'; -const fileManager = new FileManager(repoRoot); +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 }) => { const { npub, repo } = params; const filePath = url.searchParams.get('path'); - const ref = url.searchParams.get('ref') || 'HEAD'; + let ref = url.searchParams.get('ref') || 'HEAD'; const userPubkey = url.searchParams.get('userPubkey') || request.headers.get('x-user-pubkey'); if (!npub || !repo || !filePath) { @@ -31,11 +36,75 @@ export const GET: RequestHandler = async ({ params, url, request }: { params: { } try { - if (!fileManager.repoExists(npub, repo)) { + const repoPath = join(repoRoot, npub, `${repo}.git`); + + // If repo doesn't exist, try to fetch it on-demand + if (!existsSync(repoPath)) { + try { + // Get repo owner pubkey + let repoOwnerPubkey: string; + try { + repoOwnerPubkey = requireNpubHex(npub); + } catch { + return error(400, 'Invalid npub format'); + } + + // Fetch repository announcement from Nostr + const events = await nostrClient.fetchEvents([ + { + kinds: [KIND.REPO_ANNOUNCEMENT], + authors: [repoOwnerPubkey], + '#d': [repo], + limit: 1 + } + ]); + + if (events.length > 0) { + // Try to fetch the repository from remote clone URLs + const fetched = await repoManager.fetchRepoOnDemand( + npub, + repo, + events[0] + ); + + // Always check if repo exists after fetch attempt (might have been created) + // Also clear cache to ensure fileManager sees it + if (existsSync(repoPath)) { + repoCache.delete(RepoCache.repoExistsKey(npub, repo)); + // Repo exists, continue with normal flow + } else if (!fetched) { + // Fetch failed and repo doesn't exist + return error(404, 'Repository not found and could not be fetched from remote. The repository may not have any accessible clone URLs.'); + } else { + // Fetch returned true but repo doesn't exist - this shouldn't happen, but clear cache anyway + repoCache.delete(RepoCache.repoExistsKey(npub, repo)); + // Wait a moment for filesystem to sync, then check again + await new Promise(resolve => setTimeout(resolve, 100)); + if (!existsSync(repoPath)) { + return error(404, 'Repository fetch completed but repository is not accessible'); + } + } + } else { + return error(404, 'Repository announcement not found in Nostr'); + } + } catch (err) { + // Check if repo was created by another concurrent request + if (existsSync(repoPath)) { + // Repo exists now, clear cache and continue with normal flow + repoCache.delete(RepoCache.repoExistsKey(npub, repo)); + } else { + // If fetching fails, return 404 + return error(404, 'Repository not found'); + } + } + } + + // Double-check repo exists after on-demand fetch + if (!existsSync(repoPath)) { return error(404, 'Repository not found'); } - // Check repository privacy + // Get repo owner pubkey for access check (already validated above if we did on-demand fetch) let repoOwnerPubkey: string; try { repoOwnerPubkey = requireNpubHex(npub); @@ -43,6 +112,21 @@ export const GET: RequestHandler = async ({ params, url, request }: { params: { return error(400, 'Invalid npub format'); } + // If ref is a branch name, validate it exists or use default branch + if (ref !== 'HEAD' && !ref.startsWith('refs/')) { + 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); + } + } catch { + // If we can't get branches, fall back to HEAD + ref = 'HEAD'; + } + } + + // Check repository privacy (repoOwnerPubkey already declared above) const canView = await maintainerService.canView(userPubkey || null, repoOwnerPubkey, repo); if (!canView) { const clientIp = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown'; diff --git a/src/routes/repos/[npub]/[repo]/+page.svelte b/src/routes/repos/[npub]/[repo]/+page.svelte index c3e9113..a818f8a 100644 --- a/src/routes/repos/[npub]/[repo]/+page.svelte +++ b/src/routes/repos/[npub]/[repo]/+page.svelte @@ -126,6 +126,9 @@ let repoImage = $state(null); let repoBanner = $state(null); + // Mobile view toggle for file list/file viewer + let showFileListOnMobile = $state(true); + async function loadReadme() { if (repoNotFound) return; loadingReadme = true; @@ -212,6 +215,12 @@ 'conf': 'ini', 'log': 'plaintext', 'txt': 'plaintext', + 'md': 'markdown', + 'markdown': 'markdown', + 'mdown': 'markdown', + 'mkdn': 'markdown', + 'mkd': 'markdown', + 'mdwn': 'markdown', 'adoc': 'asciidoc', 'asciidoc': 'asciidoc', 'ad': 'asciidoc', @@ -226,6 +235,96 @@ const hljs = hljsModule.default || hljsModule; const lang = getHighlightLanguage(ext); + // Register Markdown language if needed (not in highlight.js by default) + if (lang === 'markdown' && !hljs.getLanguage('markdown')) { + hljs.registerLanguage('markdown', function(hljs) { + return { + name: 'Markdown', + aliases: ['md', 'mkdown', 'mkd'], + contains: [ + // Headers + { + className: 'section', + begin: /^#{1,6}\s+/, + relevance: 10 + }, + // Bold + { + className: 'strong', + begin: /\*\*[^*]+\*\*/, + relevance: 0 + }, + { + className: 'strong', + begin: /__[^_]+__/, + relevance: 0 + }, + // Italic + { + className: 'emphasis', + begin: /\*[^*]+\*/, + relevance: 0 + }, + { + className: 'emphasis', + begin: /_[^_]+_/, + relevance: 0 + }, + // Inline code + { + className: 'code', + begin: /`[^`]+`/, + relevance: 0 + }, + // Code blocks + { + className: 'code', + begin: /^```[\w]*/, + end: /^```$/, + contains: [{ begin: /./ }] + }, + // Links + { + className: 'link', + begin: /\[/, + end: /\]/, + contains: [ + { + className: 'string', + begin: /\(/, + end: /\)/ + } + ] + }, + // Images + { + className: 'string', + begin: /!\[/, + end: /\]/ + }, + // Lists + { + className: 'bullet', + begin: /^(\s*)([*+-]|\d+\.)\s+/, + relevance: 0 + }, + // Blockquotes + { + className: 'quote', + begin: /^>\s+/, + relevance: 0 + }, + // Horizontal rules + { + className: 'horizontal_rule', + begin: /^(\*{3,}|-{3,}|_{3,})$/, + relevance: 0 + } + ] + }; + }); + } + // Register AsciiDoc language if needed (not in highlight.js by default) if (lang === 'asciidoc' && !hljs.getLanguage('asciidoc')) { hljs.registerLanguage('asciidoc', function(hljs) { @@ -692,6 +791,10 @@ loadFiles(file.path); } else { loadFile(file.path); + // On mobile, switch to file viewer when a file is clicked + if (window.innerWidth <= 768) { + showFileListOnMobile = false; + } } } @@ -1184,10 +1287,10 @@ - {#if pageData.image || repoImage} + {#if (pageData.image || repoImage) && String(pageData.image || repoImage).trim()} {/if} - {#if pageData.banner || repoBanner} + {#if (pageData.banner || repoBanner) && String(pageData.banner || repoBanner).trim()} {/if} @@ -1390,14 +1493,14 @@ class:active={activeTab === 'docs'} onclick={() => activeTab = 'docs'} > - Documentation + Docs
{#if activeTab === 'files'} -