diff --git a/README.md b/README.md index 3dc18a6..1798e4e 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,7 @@ All three interfaces use the same underlying Nostr-based authentication and repo - **NIP-98 HTTP Authentication**: Git operations (clone, push, pull) authenticated using ephemeral Nostr events - **Auto-provisioning**: Automatically creates git repositories from NIP-34 announcements - **Multi-remote Sync**: Automatically syncs repositories to multiple remotes listed in announcements +- **GRASP Interoperability**: Minimal GRASP support for seamless compatibility with GRASP servers - **Repository Size Limits**: Enforces 2 GB maximum repository size - **Relay Write Proof**: Verifies users can write to at least one default Nostr relay before allowing operations @@ -415,12 +416,15 @@ The credential helper will automatically generate NIP-98 authentication tokens f # List repositories gitrep repos list -# Get repository details +# Get repository details (includes clone URL reachability) gitrep repos get # Push to all remotes gitrep push-all main +# Pull from all remotes and merge +gitrep pull-all --merge + # Publish repository announcement gitrep publish repo-announcement ``` @@ -523,6 +527,21 @@ src/ └── hooks.server.ts # Server initialization (starts polling) ``` +## GRASP Support + +GitRepublic provides **minimal GRASP (Git Repository Announcement and Synchronization Protocol) interoperability**: + +- ✅ **GRASP Server Detection**: Automatically identifies GRASP servers from repository announcements +- ✅ **Clone URL Reachability**: Tests and displays reachability status for all clone URLs +- ✅ **Multi-Remote Sync**: Syncs to all remotes (including GRASP servers) when you push +- ✅ **Local Pull Command**: `gitrep pull-all --merge` to fetch and merge from all remotes +- ✅ **Standard Git Operations**: Full compatibility with GRASP servers (clone, push, pull) + +**What we don't support** (by design): +- ❌ Full GRASP-01 server compliance (we're not a full GRASP server) +- ❌ GRASP-02 proactive sync (no server-side hourly pulls - user-controlled via CLI) +- ❌ GRASP-05 archive mode + ## Additional Documentation - [Architecture FAQ](./docs/ARCHITECTURE_FAQ.md) - Answers to common architecture questions diff --git a/docs/tutorial.md b/docs/tutorial.md index 6e1da43..9ec1e78 100644 --- a/docs/tutorial.md +++ b/docs/tutorial.md @@ -177,6 +177,8 @@ This automatically configures the credential helper and commit signing hook. See If a repository has multiple clone URLs configured, GitRepublic will automatically sync changes to all remotes when you push. You can see all clone URLs on the repository page. +For information about GRASP (Git Repository Announcement and Synchronization Protocol) support, including how to work with GRASP servers, see [GRASP.md](./GRASP.md). + --- ## Making Changes and Pushing @@ -824,47 +826,44 @@ See the [NIP-34 documentation](/docs/nip34) for full details. ### GRASP Protocol Support -GitRepublic supports the [GRASP (Git Repository Announcement and Synchronization Protocol)](https://gitworkshop.dev/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/grasp) for interoperability with other git hosting services built on Nostr. +GitRepublic provides **minimal GRASP (Git Repository Announcement and Synchronization Protocol) interoperability** for seamless compatibility with GRASP servers. **What is GRASP?** -GRASP is a protocol that extends NIP-34 to provide a standardized way for git repositories to be announced, synchronized, and managed across different hosting providers. GRASP servers implement additional NIP-34 event kinds (such as patches, pull request updates, repository state tracking, and user grasp lists) that enable more advanced collaboration features. +GRASP is a protocol specification for decentralized git hosting that combines git smart HTTP with Nostr relays. GRASP servers provide git repository hosting with Nostr-based announcements and state management. -**How GitRepublic Supports GRASP:** +**What GitRepublic Supports:** -While GitRepublic is directly git-based and doesn't require all NIP-34 features to function, we fully support repositories hosted on GRASP servers: +1. **GRASP Server Detection**: Automatically identifies GRASP servers from repository announcements using GRASP-01 identification (clone URL pattern + matching `relays` tag) -1. **Multi-Remote Synchronization**: When you create a repository announcement with multiple `clone` URLs (including GRASP server URLs), GitRepublic automatically: - - Syncs from GRASP servers when provisioning new repositories - - Pushes changes to all configured remotes (including GRASP servers) after each push - - Keeps your repository synchronized across all hosting providers +2. **Clone URL Reachability**: Tests and displays reachability status for all clone URLs, showing which remotes (including GRASP servers) are accessible -2. **On-Demand Repository Fetching**: If a repository is announced on Nostr but not yet provisioned locally, GitRepublic can: - - Automatically fetch the repository from any configured clone URL (including GRASP servers) - - Display and serve repositories hosted entirely on GRASP servers - - Clone repositories from GRASP servers when users access them +3. **Multi-Remote Synchronization**: When you push, automatically syncs to all remotes listed in your announcement, including GRASP servers -3. **Interoperability**: You can: - - Use GitRepublic as your primary git host while syncing to GRASP servers - - Host repositories on GRASP servers and have them accessible through GitRepublic - - Migrate repositories between GRASP servers and GitRepublic seamlessly +4. **Local Pull Command**: Use `gitrep pull-all --merge` to fetch and merge from all remotes (including GRASP servers) + - Checks reachability first, only pulls from accessible remotes + - Detects conflicts before merging (aborts unless `--allow-conflicts`) -**Example: Using GRASP Servers** +5. **Standard Git Operations**: Full compatibility with GRASP servers for clone, push, pull using standard git smart HTTP protocol -When creating a repository, you can add GRASP server URLs as clone URLs: +**What We Don't Support (By Design):** -``` -https://grasp.example.com/user/repo.git -``` +- Full GRASP-01 server compliance (we're not a full GRASP server) +- GRASP-02 proactive sync (no server-side hourly pulls - user-controlled via CLI) +- GRASP-05 archive mode -GitRepublic will automatically: -- Clone from the GRASP server if the repo doesn't exist locally -- Push all changes to the GRASP server after each push -- Keep both repositories in sync +**Example: Working with GRASP Servers** -This means you can use GitRepublic's direct git-based workflow while maintaining compatibility with GRASP-based services that use patches, PR updates, and other advanced NIP-34 features. +```bash +# Clone from a GRASP server (works just like any git server) +gitrep clone https://grasp.example.com/npub1.../repo.git + +# Push to your repo (automatically syncs to all remotes including GRASP) +gitrep push origin main -For more information about the GRASP protocol, see the [GRASP Protocol Documentation](https://gitworkshop.dev/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/grasp). +# Pull from all remotes including GRASP servers +gitrep pull-all --merge +``` ### NIP-98 HTTP Authentication diff --git a/nostr/commit-signatures.jsonl b/nostr/commit-signatures.jsonl index 5591534..feb1053 100644 --- a/nostr/commit-signatures.jsonl +++ b/nostr/commit-signatures.jsonl @@ -56,3 +56,4 @@ {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771754094,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","bug-fixes"]],"content":"Signed commit: bug-fixes","id":"146ea5bbc462c4f0188ec4a35a248c2cf518af7088714a4c1ce8e6e35f524e2a","sig":"dfc5d8d9a2f35e1898404d096f6e3e334885cdb0076caab0f3ea3efd1236e53d4172ed2b9ec16cff80ff364898c287ddb400b7a52cb65a3aedc05bb9df0f7ace"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771754488,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","fix menu responsivenes on repo-header"]],"content":"Signed commit: fix menu responsivenes on repo-header","id":"4dd8101d8edc9431df49d9fe23b7e1e545e11ef32b024b44f871bb962fb8ad4c","sig":"dbcfbfafe02495971b3f3d18466ecf1d894e4001a41e4038d17fd78bb65124de347017273a0a437c397a79ff8226ec6b0718436193e474ef8969392df027fa34"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771755811,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","fix creating new branch"]],"content":"Signed commit: fix creating new branch","id":"bc6c623532064f9b2db08fa41bbc6c5ff42419415ca7e1ecb1162a884face2eb","sig":"ad1152e2848755e1afa7d9350716fa6bb709698a5036e21efa61b3ac755d334155f02a0622ad49f6dc060d523f4f886eb2acc8c80356a426b0d8ba454fdcb8ee"} +{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771829031,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","fix file management and refactor"]],"content":"Signed commit: fix file management and refactor","id":"626196cdbf9eab28b44990706281878083d66983b503e8a81df7421054ed6caf","sig":"516c0001a800083411a1e04340e82116a82c975f38b984e92ebe021b61271ba7d6f645466ddba3594320c228193e708675a5d7a144b2f3d5e9bfbc65c4c7372b"} diff --git a/src/lib/services/git/clone-url-reachability.ts b/src/lib/services/git/clone-url-reachability.ts new file mode 100644 index 0000000..3b7df19 --- /dev/null +++ b/src/lib/services/git/clone-url-reachability.ts @@ -0,0 +1,394 @@ +/** + * Service for testing clone URL reachability + * Checks if git clone URLs are accessible and responding + */ + +import logger from '../logger.js'; + +/** + * Git server type classification + * + * Note: Both 'git' and 'grasp' servers use the same git smart HTTP protocol. + * The distinction is informational: + * - 'git': Regular git server (GitHub, GitLab, Gitea, etc.) + * - 'grasp': GRASP server (git server + Nostr relay + GRASP features) + * - 'unknown': Could not determine (shouldn't happen in practice) + */ +export type GitServerType = 'git' | 'grasp' | 'unknown'; + +export interface ReachabilityResult { + url: string; + reachable: boolean; + error?: string; + checkedAt: number; + serverType: GitServerType; +} + +/** + * Check if a URL has npub in the path (potential GRASP server pattern) + * Note: This alone doesn't make it a GRASP server - need to check relays tag too + */ +function hasNpubInPath(url: string): boolean { + // GRASP URLs have npub (starts with npub1) in the path + return /\/npub1[a-z0-9]+/i.test(url); +} + +/** + * Extract base domain from a URL (hostname without protocol) + */ +function getBaseDomain(url: string): string | null { + try { + const urlObj = new URL(url); + return urlObj.hostname; + } catch { + return null; + } +} + +/** + * Extract base domain from a relay URL (wss:// or ws://) + */ +function getRelayBaseDomain(relayUrl: string): string | null { + try { + // Remove ws:// or wss:// prefix + const httpUrl = relayUrl.replace(/^wss?:\/\//, 'https://'); + return getBaseDomain(httpUrl); + } catch { + return null; + } +} + +/** + * Check if a clone URL's domain matches any relay URL from the relays tag + * This is the proper way to identify GRASP servers per GRASP-01 spec + */ +function isGraspServer(cloneUrl: string, relayUrls: string[]): boolean { + // Must have npub in path AND matching relay URL + if (!hasNpubInPath(cloneUrl)) { + return false; + } + + const cloneDomain = getBaseDomain(cloneUrl); + if (!cloneDomain) { + return false; + } + + // Check if any relay URL matches the clone URL's domain + for (const relayUrl of relayUrls) { + const relayDomain = getRelayBaseDomain(relayUrl); + if (relayDomain && relayDomain === cloneDomain) { + return true; + } + } + + return false; +} + +/** + * Detect server type from URL, response, and optional relays tags + * + * Per GRASP-01 spec: A GRASP server is identified by: + * 1. Clone URL pattern: [http|https]:////.git + * 2. AND relays tag: [ws/wss]:// (matching the clone URL's domain) + * + * Note: Both GRASP and regular git servers use the same git protocol. + * We distinguish them for informational purposes (user awareness, future GRASP-specific features). + * + * @param url - The clone URL + * @param relayUrls - Optional array of relay URLs from the announcement's relays tag + * @param response - Optional HTTP response (for future header-based detection) + * @returns Server type: 'grasp' if both URL pattern and relay match, 'git' otherwise + */ +function detectServerType( + url: string, + relayUrls?: string[], + response?: Response +): GitServerType { + // If we have relay URLs, use proper GRASP detection + if (relayUrls && relayUrls.length > 0) { + if (isGraspServer(url, relayUrls)) { + return 'grasp'; + } + } else { + // Fallback: if URL has npub but no relays context, we can't be sure + // But we'll still check the pattern for informational purposes + // (This handles cases where relays tag isn't available) + if (hasNpubInPath(url)) { + // Without relays tag, we can't definitively say it's GRASP + // But it might be, so we'll mark it as 'git' (not GRASP) to be conservative + // The CLI has better context, so it can make this determination + } + } + + // Could also check response headers for GRASP indicators in the future + // (e.g., NIP-11 document, GRASP-specific headers) + // For now, if it's not GRASP by proper detection, assume regular git server + return 'git'; +} + +/** + * Test if a clone URL is reachable + * Attempts to connect to the URL and check if it responds + * + * @param url - The clone URL to test + * @param timeout - Timeout in milliseconds (default: 5000) + * @param relayUrls - Optional array of relay URLs from announcement's relays tag (for GRASP detection) + * @returns Promise resolving to reachability result + */ +export async function testCloneUrlReachability( + url: string, + timeout: number = 5000, + relayUrls?: string[] +): Promise { + const startTime = Date.now(); + + try { + // Parse URL to extract base URL for testing + const urlObj = new URL(url); + + // For git URLs, we test the base domain + // Try to fetch info/refs endpoint (lightweight git protocol check) + const testUrl = `${urlObj.protocol}//${urlObj.host}${urlObj.pathname}/info/refs?service=git-upload-pack`; + + // Use AbortController for timeout + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + + try { + // Use fetch with timeout and proper error handling + // Note: fetch is available in Node.js 18+ and browsers + const response = await fetch(testUrl, { + method: 'GET', + signal: controller.signal, + // Don't follow redirects - we want to know if the server responds + redirect: 'manual', + // Set a reasonable timeout + headers: { + 'User-Agent': 'GitRepublic/1.0', + 'Accept': '*/*' + } + } as RequestInit); + + clearTimeout(timeoutId); + + // Consider reachable if we get any response (even 404 means server is up) + // 200, 401, 403, 404 all mean the server is reachable + // 500 might mean server is up but has issues, still consider reachable + const isReachable = response.status < 600; // Any valid HTTP status means reachable + + // Detect server type + const serverType = detectServerType(url, relayUrls, response); + + return { + url, + reachable: isReachable, + error: isReachable ? undefined : `HTTP ${response.status}`, + checkedAt: Date.now(), + serverType + }; + } catch (fetchError: unknown) { + clearTimeout(timeoutId); + + // Handle abort (timeout) + if (fetchError instanceof Error && fetchError.name === 'AbortError') { + const serverType = detectServerType(url, relayUrls); + return { + url, + reachable: false, + error: 'Timeout', + checkedAt: Date.now(), + serverType + }; + } + + // Handle network errors + if (fetchError instanceof TypeError) { + // Usually means DNS resolution failed or connection refused + const serverType = detectServerType(url, relayUrls); + return { + url, + reachable: false, + error: 'Network error (DNS or connection failed)', + checkedAt: Date.now(), + serverType + }; + } + + // Other errors + const serverType = detectServerType(url, relayUrls); + return { + url, + reachable: false, + error: fetchError instanceof Error ? fetchError.message : String(fetchError), + checkedAt: Date.now(), + serverType + }; + } + } catch (urlError) { + // Invalid URL format + const serverType = detectServerType(url, relayUrls); + return { + url, + reachable: false, + error: urlError instanceof Error ? urlError.message : 'Invalid URL format', + checkedAt: Date.now(), + serverType + }; + } +} + +/** + * Test multiple clone URLs in parallel + * + * @param urls - Array of clone URLs to test + * @param timeout - Timeout per URL in milliseconds (default: 5000) + * @param relayUrls - Optional array of relay URLs from announcement's relays tag (for GRASP detection) + * @returns Promise resolving to array of reachability results + */ +export async function testCloneUrlsReachability( + urls: string[], + timeout: number = 5000, + relayUrls?: string[] +): Promise { + // Test all URLs in parallel + const results = await Promise.allSettled( + urls.map(url => testCloneUrlReachability(url, timeout, relayUrls)) + ); + + // Convert settled results to reachability results + return results.map((result, index) => { + if (result.status === 'fulfilled') { + return result.value; + } else { + const url = urls[index]; + return { + url, + reachable: false, + error: result.reason instanceof Error ? result.reason.message : String(result.reason), + checkedAt: Date.now(), + serverType: detectServerType(url, relayUrls) + }; + } + }); +} + +/** + * Cache for reachability results + * Key: URL, Value: { result, expiresAt } + */ +const reachabilityCache = new Map(); + +/** + * Cache duration: 5 minutes + */ +const CACHE_DURATION_MS = 5 * 60 * 1000; + +/** + * Get cached reachability result or test if not cached/expired + * + * @param url - The clone URL to check + * @param timeout - Timeout in milliseconds (default: 5000) + * @param forceRefresh - Force refresh even if cached (default: false) + * @param relayUrls - Optional array of relay URLs from announcement's relays tag (for GRASP detection) + * @returns Promise resolving to reachability result + */ +export async function getCloneUrlReachability( + url: string, + timeout: number = 5000, + forceRefresh: boolean = false, + relayUrls?: string[] +): Promise { + const now = Date.now(); + const cached = reachabilityCache.get(url); + + // Return cached result if valid and not forcing refresh + // Note: We cache by URL only, so serverType might be incorrect if relayUrls change + // But this is acceptable since relayUrls rarely change for a given repo + if (!forceRefresh && cached && cached.expiresAt > now) { + return cached.result; + } + + // Test reachability + const result = await testCloneUrlReachability(url, timeout, relayUrls); + + // Cache the result + reachabilityCache.set(url, { + result, + expiresAt: now + CACHE_DURATION_MS + }); + + return result; +} + +/** + * Get reachability for multiple URLs with caching + * + * @param urls - Array of clone URLs to check + * @param timeout - Timeout per URL in milliseconds (default: 5000) + * @param forceRefresh - Force refresh even if cached (default: false) + * @param relayUrls - Optional array of relay URLs from announcement's relays tag (for GRASP detection) + * @returns Promise resolving to array of reachability results + */ +export async function getCloneUrlsReachability( + urls: string[], + timeout: number = 5000, + forceRefresh: boolean = false, + relayUrls?: string[] +): Promise { + const now = Date.now(); + const results: ReachabilityResult[] = []; + const urlsToTest: string[] = []; + const urlIndices: number[] = []; + + // Check cache for each URL + urls.forEach((url, index) => { + const cached = reachabilityCache.get(url); + + if (!forceRefresh && cached && cached.expiresAt > now) { + // Use cached result + results[index] = cached.result; + } else { + // Need to test + urlsToTest.push(url); + urlIndices.push(index); + } + }); + + // Test URLs that aren't cached + if (urlsToTest.length > 0) { + const testResults = await testCloneUrlsReachability(urlsToTest, timeout, relayUrls); + + // Store results and cache them + testResults.forEach((result, testIndex) => { + const originalIndex = urlIndices[testIndex]; + results[originalIndex] = result; + + // Cache the result + reachabilityCache.set(result.url, { + result, + expiresAt: now + CACHE_DURATION_MS + }); + }); + } + + return results; +} + +/** + * Clear reachability cache + */ +export function clearReachabilityCache(): void { + reachabilityCache.clear(); +} + +/** + * Clear expired entries from cache + */ +export function clearExpiredCacheEntries(): void { + const now = Date.now(); + for (const [url, cached] of reachabilityCache.entries()) { + if (cached.expiresAt <= now) { + reachabilityCache.delete(url); + } + } +} diff --git a/src/lib/services/git/repo-manager.ts b/src/lib/services/git/repo-manager.ts index 0f415e4..59794e6 100644 --- a/src/lib/services/git/repo-manager.ts +++ b/src/lib/services/git/repo-manager.ts @@ -739,10 +739,9 @@ Your commits will all be signed by your Nostr keys and saved to the event files try { - // Filter and convert URLs: - // 1. Skip SSH URLs (git@... or ssh://) - convert to HTTPS when possible - // 2. Filter out localhost and our own domain - // 3. Prioritize HTTPS non-GRASP URLs, then GRASP URLs + // Filter and convert URLs while respecting the repo owner's order in the clone list. + // The owner knows their infrastructure best and has ordered URLs by preference. + // We only filter out localhost/our domain and convert SSH to HTTPS when possible. const httpsUrls: string[] = []; const sshUrls: string[] = []; @@ -759,23 +758,20 @@ Your commits will all be signed by your Nostr keys and saved to the event files // Check if it's an SSH URL if (url.startsWith('git@') || url.startsWith('ssh://')) { sshUrls.push(url); - // Try to convert to HTTPS + // Try to convert to HTTPS (preserve original order by appending) const httpsUrl = this.convertSshToHttps(url); if (httpsUrl) { httpsUrls.push(httpsUrl); } } else { - // It's already HTTPS/HTTP + // It's already HTTPS/HTTP - preserve original order httpsUrls.push(url); } } - // Separate HTTPS URLs into non-GRASP and GRASP - const nonGraspHttpsUrls = httpsUrls.filter(url => !isGraspUrl(url)); - const graspHttpsUrls = httpsUrls.filter(url => isGraspUrl(url)); - - // Prioritize: non-GRASP HTTPS, then GRASP HTTPS, then converted SSH->HTTPS, finally SSH (if no HTTPS available) - remoteUrls = [...nonGraspHttpsUrls, ...graspHttpsUrls]; + // Respect the repo owner's order: use HTTPS URLs in the order they appeared in clone list + // This assumes the owner has ordered them by preference (best first) + remoteUrls = httpsUrls; // If no HTTPS URLs, try SSH URLs (but log a warning) if (remoteUrls.length === 0 && sshUrls.length > 0) { @@ -783,7 +779,7 @@ Your commits will all be signed by your Nostr keys and saved to the event files remoteUrls = sshUrls; } - // If no external URLs, try any URL that's not our domain + // If no external URLs, try any URL that's not our domain (preserve order) if (remoteUrls.length === 0) { remoteUrls = cloneUrls.filter(url => !url.includes(this.domain)); } diff --git a/src/lib/styles/repo.css b/src/lib/styles/repo.css index 0d5f9b1..e1db6e8 100644 --- a/src/lib/styles/repo.css +++ b/src/lib/styles/repo.css @@ -1531,6 +1531,68 @@ span.clone-more { color: var(--error-text); } +.reachability-badge { + display: inline-flex; + align-items: center; + padding: 0.125rem 0.25rem; + border-radius: 0.25rem; + font-size: 0.75rem; + margin-left: 0.25rem; +} + +.reachability-badge.reachable { + color: #22c55e; /* green-500 */ +} + +.reachability-badge.unreachable { + color: #ef4444; /* red-500 */ +} + +.reachability-badge.loading { + opacity: 0.6; +} + +.reachability-refresh-button { + padding: 0.25rem 0.5rem; + font-size: 0.875rem; + background: var(--bg-secondary, #e8e8e8); + border: 1px solid var(--border-color, #ccc); + border-radius: 4px; + cursor: pointer; + transition: opacity 0.2s; +} + +.reachability-refresh-button:hover:not(:disabled) { + opacity: 0.8; +} + +.reachability-refresh-button:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.server-type-badge { + display: inline-flex; + align-items: center; + padding: 0.125rem 0.375rem; + border-radius: 0.25rem; + font-size: 0.7rem; + font-weight: 500; + margin-left: 0.25rem; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.server-type-badge.grasp-badge { + background: #8b5cf6; /* purple-500 */ + color: white; +} + +.server-type-badge.git-badge { + background: #6b7280; /* gray-500 */ + color: white; +} + .empty { padding: 2rem; text-align: center; diff --git a/src/routes/api/repos/[npub]/[repo]/clone-urls/reachability/+server.ts b/src/routes/api/repos/[npub]/[repo]/clone-urls/reachability/+server.ts new file mode 100644 index 0000000..dbf6226 --- /dev/null +++ b/src/routes/api/repos/[npub]/[repo]/clone-urls/reachability/+server.ts @@ -0,0 +1,117 @@ +/** + * API endpoint for testing clone URL reachability + * POST: Test reachability of clone URLs + * GET: Get cached reachability status + */ + +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { getCloneUrlsReachability, type ReachabilityResult } from '$lib/services/git/clone-url-reachability.js'; +import { extractCloneUrls } from '$lib/utils/nostr-utils.js'; +import { NostrClient } from '$lib/services/nostr/nostr-client.js'; +import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js'; +import { KIND } from '$lib/types/nostr.js'; +import { nip19 } from 'nostr-tools'; +import { requireNpubHex } from '$lib/utils/npub-utils.js'; +import { fetchRepoAnnouncementsWithCache, findRepoAnnouncement } from '$lib/utils/nostr-utils.js'; +import { eventCache } from '$lib/services/nostr/event-cache.js'; +import logger from '$lib/services/logger.js'; + +/** + * GET: Get reachability status for clone URLs + * Query params: + * - forceRefresh: boolean (optional) - Force refresh even if cached + */ +export const GET: RequestHandler = async ({ params, url }) => { + const { npub, repo } = params; + + if (!npub || !repo) { + return error(400, 'Missing npub or repo parameter'); + } + + try { + // Decode npub to get pubkey + const decoded = nip19.decode(npub); + if (decoded.type !== 'npub') { + return error(400, 'Invalid npub format'); + } + + const repoOwnerPubkey = decoded.data as string; + + // Fetch repository announcement + const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS); + const allEvents = await fetchRepoAnnouncementsWithCache(nostrClient, repoOwnerPubkey, eventCache); + const announcement = findRepoAnnouncement(allEvents, repo); + + if (!announcement) { + return error(404, 'Repository announcement not found'); + } + + // Extract clone URLs + const cloneUrls = extractCloneUrls(announcement, false); + + if (cloneUrls.length === 0) { + return json({ results: [] }); + } + + // Extract relay URLs from relays tag (for proper GRASP server detection) + const relayUrls: string[] = []; + for (const tag of announcement.tags) { + if (tag[0] === 'relays') { + for (let i = 1; i < tag.length; i++) { + const relayUrl = tag[i]; + if (relayUrl && typeof relayUrl === 'string' && (relayUrl.startsWith('ws://') || relayUrl.startsWith('wss://'))) { + relayUrls.push(relayUrl); + } + } + } + } + + // Check if force refresh is requested + const forceRefresh = url.searchParams.get('forceRefresh') === 'true'; + + // Get reachability for all clone URLs (with relay URLs for GRASP detection) + const results = await getCloneUrlsReachability(cloneUrls, 5000, forceRefresh, relayUrls.length > 0 ? relayUrls : undefined); + + return json({ results }); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : String(err); + logger.error({ error: errorMessage, npub, repo }, 'Failed to check clone URL reachability'); + return error(500, `Failed to check clone URL reachability: ${errorMessage}`); + } +}; + +/** + * POST: Test reachability of specific clone URLs + * Body: { urls: string[], forceRefresh?: boolean } + */ +export const POST: RequestHandler = async ({ request, params }) => { + const { npub, repo } = params; + + if (!npub || !repo) { + return error(400, 'Missing npub or repo parameter'); + } + + try { + const body = await request.json(); + const { urls, forceRefresh = false } = body; + + if (!Array.isArray(urls) || urls.length === 0) { + return error(400, 'urls must be a non-empty array'); + } + + // Validate URLs are strings + if (!urls.every(url => typeof url === 'string')) { + return error(400, 'All URLs must be strings'); + } + + // Get reachability for specified URLs + const results = await getCloneUrlsReachability(urls, 5000, forceRefresh); + + return json({ results }); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : String(err); + logger.error({ error: errorMessage, npub, repo }, 'Failed to test clone URL reachability'); + return error(500, `Failed to test clone URL reachability: ${errorMessage}`); + } +}; diff --git a/src/routes/repos/[npub]/[repo]/+page.svelte b/src/routes/repos/[npub]/[repo]/+page.svelte index 1958979..2874991 100644 --- a/src/routes/repos/[npub]/[repo]/+page.svelte +++ b/src/routes/repos/[npub]/[repo]/+page.svelte @@ -794,10 +794,56 @@ // Show all clone URLs (beyond the first 3) let showAllCloneUrls = $state(false); + // Clone URL reachability + let cloneUrlReachability = $state>(new Map()); + let loadingReachability = $state(false); + let checkingReachability = $state>(new Set()); + // Guard to prevent README auto-load loop let readmeAutoLoadAttempted = $state(false); let readmeAutoLoadTimeout: ReturnType | null = null; + // Load clone URL reachability status + async function loadCloneUrlReachability(forceRefresh: boolean = false) { + if (!pageData.repoCloneUrls || pageData.repoCloneUrls.length === 0) { + return; + } + + if (loadingReachability) return; + + loadingReachability = true; + try { + const response = await fetch( + `/api/repos/${npub}/${repo}/clone-urls/reachability${forceRefresh ? '?forceRefresh=true' : ''}`, + { + headers: buildApiHeaders() + } + ); + + if (response.ok) { + const data = await response.json(); + const newMap = new Map(); + + if (data.results && Array.isArray(data.results)) { + for (const result of data.results) { + newMap.set(result.url, { + reachable: result.reachable, + error: result.error, + checkedAt: result.checkedAt + }); + } + } + + cloneUrlReachability = newMap; + } + } catch (err) { + console.warn('Failed to load clone URL reachability:', err); + } finally { + loadingReachability = false; + checkingReachability.clear(); + } + } + async function loadReadme() { if (repoNotFound) return; loadingReadme = true; @@ -2084,6 +2130,9 @@ // Initialize bookmarks service bookmarksService = new BookmarksService(DEFAULT_NOSTR_SEARCH_RELAYS); + // Load clone URL reachability status + loadCloneUrlReachability().catch(err => console.warn('Failed to load clone URL reachability:', err)); + // Decode npub to get repo owner pubkey for bookmark address try { const decoded = nip19.decode(npub); @@ -2126,6 +2175,9 @@ await loadForkInfo(); await loadRepoImages(); + // Load clone URL reachability status + loadCloneUrlReachability().catch(err => console.warn('Failed to load clone URL reachability:', err)); + // Set up auto-save if enabled setupAutoSave().catch(err => console.warn('Failed to setup auto-save:', err)); }); @@ -4334,16 +4386,27 @@ {/if} {#if pageData.repoCloneUrls && pageData.repoCloneUrls.length > 0}
- + +
{#if isRepoCloned === true}
{/each} {#if pageData.repoCloneUrls.length > 3}