Browse Source
implement more GRASP support Nostr-Signature: 6ae016621b13e22809e7bcebe34e5250fd6e0767d2b12ca634104def4ca78a29 573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc 99c34f66a8a67d352622621536545b7dee11cfd9d14a007ec0550d138109116a2f24483c6836fea59b94b9e96066fba548bcb7600bc55adbe0562d999c3c651dmain
8 changed files with 729 additions and 47 deletions
@ -0,0 +1,394 @@
@@ -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]://<grasp-path>/<valid-npub>/<string>.git
|
||||
* 2. AND relays tag: [ws/wss]://<grasp-path> (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<ReachabilityResult> { |
||||
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<ReachabilityResult[]> { |
||||
// 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<string, { result: ReachabilityResult; expiresAt: number }>(); |
||||
|
||||
/** |
||||
* 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<ReachabilityResult> { |
||||
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<ReachabilityResult[]> { |
||||
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); |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,117 @@
@@ -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}`); |
||||
} |
||||
}; |
||||
Loading…
Reference in new issue