From 93e3653c91e079b24a14db26928f5ae61ea3b472 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Wed, 25 Feb 2026 07:12:18 +0100 Subject: [PATCH] verify button for cloned repos Nostr-Signature: 4710ea5de6287e00b5da9a6d7cd6568901e3db45a71476b56dc83ec39b8be73d 573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc 7613ca0847af4eb1fd3f52ef0f59c8f6316ba75605085da8eb0a64ced6fe43897d6af26b84d218155ab61ab8e1b42cbc2a686f2eab9572734fb7d911961d3e85 --- nostr/commit-signatures.jsonl | 1 + src/lib/styles/repo.css | 16 ++ .../api/repos/[npub]/[repo]/verify/+server.ts | 116 ++++++++++- src/routes/repos/[npub]/[repo]/+page.svelte | 196 ++++++++++++++++-- 4 files changed, 311 insertions(+), 18 deletions(-) diff --git a/nostr/commit-signatures.jsonl b/nostr/commit-signatures.jsonl index fb79ed1..8960ea6 100644 --- a/nostr/commit-signatures.jsonl +++ b/nostr/commit-signatures.jsonl @@ -76,3 +76,4 @@ {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771967413,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","get rid of light theme"]],"content":"Signed commit: get rid of light theme","id":"16cc720587afa7994fdf4d1951934298d731f79d8fe4a3c5d4b9143e3b41abfd","sig":"125b3afa090a8a2679d6e2614163c8c95a42ba6d3323e9682ce94ecff387da8d1abbfffcc61d59646c6925d8e845527570387b012c194deed032fa7d43bceac0"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771968145,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","fix repo search"]],"content":"Signed commit: fix repo search","id":"e9eff432fe83e0b629e217fe4c00b19858a797127ff0dad28e248e02629d938c","sig":"47c81818e91fa1b2760ceee78da38a82698c9609df0acc7f4dfaae7c6e7025c870cf2a8c34f3ea9c09dc9110be74f4605762a903d722f8bc15d2a0a2fd7edd04"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771970166,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","patch highlights and comments\nupdate prs to match"]],"content":"Signed commit: patch highlights and comments\nupdate prs to match","id":"f85ce49f7314d99b96e3d837d096fa36745f4dd6087123c51a4a9110f23fcbfa","sig":"a323eb2081c46974a2fa09835bde3a65e5242f782ef34a1ec363e9da0784a00ad63c3f9f6cee016612bdab0d4d9a0e54e2a5bdb4424a635c650e317704331825"} +{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771999453,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","load files from HEAD"]],"content":"Signed commit: load files from HEAD","id":"214fc0597e79b465c0c718a2227de942697409002b6cf5c322c9a6d9b36de333","sig":"713a33e751e0582669e9328bca2ac048585534111984bd6ca938270409f7957178d497c92a981719594e927ca7d301e033306c1d1b261395984b91b2d81762e2"} diff --git a/src/lib/styles/repo.css b/src/lib/styles/repo.css index 15aabd5..a81750c 100644 --- a/src/lib/styles/repo.css +++ b/src/lib/styles/repo.css @@ -2182,6 +2182,22 @@ span.clone-more { color: var(--error-text); } +.verification-badge.clickable { + cursor: pointer; + border: 1px solid transparent; + transition: all 0.2s ease; +} + +.verification-badge.clickable:hover { + background: var(--bg-warning, rgba(255, 193, 7, 0.2)); + border-color: var(--error-text); + transform: scale(1.05); +} + +.verification-badge.clickable:active { + transform: scale(0.98); +} + .reachability-badge { display: inline-flex; align-items: center; diff --git a/src/routes/api/repos/[npub]/[repo]/verify/+server.ts b/src/routes/api/repos/[npub]/[repo]/verify/+server.ts index 6238978..7e36652 100644 --- a/src/routes/api/repos/[npub]/[repo]/verify/+server.ts +++ b/src/routes/api/repos/[npub]/[repo]/verify/+server.ts @@ -13,11 +13,18 @@ import { KIND } from '$lib/types/nostr.js'; import { existsSync } from 'fs'; import { join } from 'path'; import { decodeNpubToHex } from '$lib/utils/npub-utils.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'; +import { createRepoGetHandler, createRepoPostHandler } from '$lib/utils/api-handlers.js'; +import type { RepoRequestContext, RequestEvent } from '$lib/utils/api-context.js'; +import { handleApiError, handleValidationError } from '$lib/utils/error-handler.js'; import { eventCache } from '$lib/services/nostr/event-cache.js'; import { fetchRepoAnnouncementsWithCache, findRepoAnnouncement } from '$lib/utils/nostr-utils.js'; +import { MaintainerService } from '$lib/services/nostr/maintainer-service.js'; +import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js'; +import { AnnouncementManager } from '$lib/services/git/announcement-manager.js'; +import { extractRequestContext } from '$lib/utils/api-context.js'; +import { fetchUserEmail, fetchUserName } from '$lib/utils/user-profile.js'; +import simpleGit from 'simple-git'; +import logger from '$lib/services/logger.js'; const repoRoot = typeof process !== 'undefined' && process.env?.GIT_REPO_ROOT ? process.env.GIT_REPO_ROOT @@ -163,3 +170,106 @@ export const GET: RequestHandler = createRepoGetHandler( }, { operation: 'verifyRepo', requireRepoExists: false, requireRepoAccess: false } // Verification is public, doesn't need repo to exist ); + +const maintainerService = new MaintainerService(DEFAULT_NOSTR_RELAYS); +const announcementManager = new AnnouncementManager(repoRoot); + +export const POST: RequestHandler = createRepoPostHandler( + async (context: RepoRequestContext, event: RequestEvent) => { + const requestContext = extractRequestContext(event); + const userPubkeyHex = requestContext.userPubkeyHex; + + if (!userPubkeyHex) { + return error(401, 'Authentication required. Please provide userPubkey.'); + } + + // Check if user is a maintainer + const isMaintainer = await maintainerService.isMaintainer(userPubkeyHex, context.repoOwnerPubkey, context.repo); + if (!isMaintainer) { + return error(403, 'Only repository maintainers can verify clone URLs.'); + } + + // Check if repository is cloned + const repoPath = join(repoRoot, context.npub, `${context.repo}.git`); + if (!existsSync(repoPath)) { + return error(404, 'Repository is not cloned locally. Please clone the repository first.'); + } + + // Fetch the repository announcement + const allEvents = await fetchRepoAnnouncementsWithCache(nostrClient, context.repoOwnerPubkey, eventCache); + const announcement = findRepoAnnouncement(allEvents, context.repo); + + if (!announcement) { + return error(404, 'Repository announcement not found'); + } + + try { + // Get default branch + const defaultBranch = await fileManager.getDefaultBranch(context.npub, context.repo); + + // Get worktree for the default branch + const worktreePath = await fileManager.getWorktree(repoPath, defaultBranch, context.npub, context.repo); + + // Check if announcement already exists + const hasAnnouncement = await announcementManager.hasAnnouncementInRepo(worktreePath, announcement.id); + + if (hasAnnouncement) { + // Announcement already exists, but we'll update it anyway to ensure it's the latest + logger.debug({ npub: context.npub, repo: context.repo, eventId: announcement.id }, 'Announcement already exists, updating anyway'); + } + + // Save announcement to worktree + const saved = await announcementManager.saveRepoEventToWorktree(worktreePath, announcement, 'announcement', false); + + if (!saved) { + return error(500, 'Failed to save announcement to repository'); + } + + // Stage the file + const workGit = simpleGit(worktreePath); + await workGit.add('nostr/repo-events.jsonl'); + + // Get author info + let authorName = await fetchUserName(userPubkeyHex, requestContext.userPubkey || '', DEFAULT_NOSTR_RELAYS); + let authorEmail = await fetchUserEmail(userPubkeyHex, requestContext.userPubkey || '', DEFAULT_NOSTR_RELAYS); + + if (!authorName) { + const { nip19 } = await import('nostr-tools'); + const npub = requestContext.userPubkey || nip19.npubEncode(userPubkeyHex); + authorName = npub.substring(0, 20); + } + if (!authorEmail) { + const { nip19 } = await import('nostr-tools'); + const npub = requestContext.userPubkey || nip19.npubEncode(userPubkeyHex); + authorEmail = `${npub.substring(0, 20)}@gitrepublic.web`; + } + + // Commit the announcement + const commitMessage = `Verify repository ownership by committing repo announcement event\n\nEvent ID: ${announcement.id}`; + await workGit.commit(commitMessage, ['nostr/repo-events.jsonl'], { + '--author': `${authorName} <${authorEmail}>` + }); + + // Push to default branch (if there's a remote) + try { + await workGit.push('origin', defaultBranch); + } catch (pushErr) { + // Push might fail if there's no remote, that's okay + logger.debug({ error: pushErr, npub: context.npub, repo: context.repo }, 'Push failed (may not have remote)'); + } + + // Clean up worktree + await fileManager.removeWorktree(repoPath, worktreePath); + + return json({ + success: true, + message: 'Repository announcement committed successfully. Verification should update shortly.', + announcementId: announcement.id + }); + } catch (err) { + logger.error({ error: err, npub: context.npub, repo: context.repo }, 'Failed to commit announcement for verification'); + return handleApiError(err, { operation: 'verifyRepoCommit', npub: context.npub, repo: context.repo }, 'Failed to commit announcement'); + } + }, + { operation: 'verifyRepoCommit', requireRepoExists: true, requireRepoAccess: true } +); diff --git a/src/routes/repos/[npub]/[repo]/+page.svelte b/src/routes/repos/[npub]/[repo]/+page.svelte index e8f3d6c..297c63e 100644 --- a/src/routes/repos/[npub]/[repo]/+page.svelte +++ b/src/routes/repos/[npub]/[repo]/+page.svelte @@ -504,6 +504,11 @@ } | null>(null); let showVerificationDialog = $state(false); let verificationFileContent = $state(null); + + // Clone URL verification dialog + let showCloneUrlVerificationDialog = $state(false); + let verifyingCloneUrl = $state(false); + let selectedCloneUrlForVerification = $state(null); let loadingVerification = $state(false); // Deletion request @@ -3085,6 +3090,55 @@ }); } + // Verify clone URL by committing announcement + async function verifyCloneUrl() { + if (!selectedCloneUrlForVerification || !userPubkey || !userPubkeyHex) { + error = 'Unable to verify: missing information'; + return; + } + + if (!isMaintainer && userPubkeyHex !== repoOwnerPubkeyDerived) { + error = 'Only repository owners and maintainers can verify clone URLs'; + return; + } + + verifyingCloneUrl = true; + error = null; + + try { + const response = await fetch(`/api/repos/${npub}/${repo}/verify`, { + method: 'POST', + headers: buildApiHeaders() + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(errorText || `Failed to verify: ${response.statusText}`); + } + + const data = await response.json(); + + // Close dialog + showCloneUrlVerificationDialog = false; + selectedCloneUrlForVerification = null; + + // Reload verification status after a short delay + setTimeout(() => { + checkVerification().catch((err: unknown) => { + console.warn('Failed to reload verification status:', err); + }); + }, 1000); + + // Show success message + alert(data.message || 'Repository verification initiated. The verification status will update shortly.'); + } catch (err) { + error = err instanceof Error ? err.message : 'Failed to verify repository'; + console.error('Error verifying clone URL:', err); + } finally { + verifyingCloneUrl = false; + } + } + async function deleteAnnouncement() { if (!userPubkey || !userPubkeyHex) { alert('Please connect your NIP-07 extension'); @@ -5527,26 +5581,68 @@ {:else if cloneVerification !== undefined} - - {#if cloneVerification.verified} + {#if cloneVerification.verified} + Verified + + {:else} + {#if userPubkey && (isMaintainer || userPubkeyHex === repoOwnerPubkeyDerived) && isRepoCloned === true} + {:else} - Unverified + + Unverified + {/if} - + {/if} {:else if verificationStatus} - - Unknown - + {#if userPubkey && (isMaintainer || userPubkeyHex === repoOwnerPubkeyDerived) && isRepoCloned === true} + + {:else} + + Unknown + + {/if} {:else} - - Not checked - + {#if userPubkey && (isMaintainer || userPubkeyHex === repoOwnerPubkeyDerived) && isRepoCloned === true} + + {:else} + + Not checked + + {/if} {/if} {#if isChecking || loadingReachability} @@ -7586,6 +7682,76 @@ {/if} + + + {#if showCloneUrlVerificationDialog} + + {/if}