Browse Source

verify button for cloned repos

Nostr-Signature: 4710ea5de6287e00b5da9a6d7cd6568901e3db45a71476b56dc83ec39b8be73d 573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc 7613ca0847af4eb1fd3f52ef0f59c8f6316ba75605085da8eb0a64ced6fe43897d6af26b84d218155ab61ab8e1b42cbc2a686f2eab9572734fb7d911961d3e85
main
Silberengel 3 weeks ago
parent
commit
93e3653c91
  1. 1
      nostr/commit-signatures.jsonl
  2. 16
      src/lib/styles/repo.css
  3. 116
      src/routes/api/repos/[npub]/[repo]/verify/+server.ts
  4. 196
      src/routes/repos/[npub]/[repo]/+page.svelte

1
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":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":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":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"}

16
src/lib/styles/repo.css

@ -2182,6 +2182,22 @@ span.clone-more {
color: var(--error-text); 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 { .reachability-badge {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;

116
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 { existsSync } from 'fs';
import { join } from 'path'; import { join } from 'path';
import { decodeNpubToHex } from '$lib/utils/npub-utils.js'; import { decodeNpubToHex } from '$lib/utils/npub-utils.js';
import { createRepoGetHandler } from '$lib/utils/api-handlers.js'; import { createRepoGetHandler, createRepoPostHandler } from '$lib/utils/api-handlers.js';
import type { RepoRequestContext } from '$lib/utils/api-context.js'; import type { RepoRequestContext, RequestEvent } from '$lib/utils/api-context.js';
import { handleApiError } from '$lib/utils/error-handler.js'; import { handleApiError, handleValidationError } from '$lib/utils/error-handler.js';
import { eventCache } from '$lib/services/nostr/event-cache.js'; import { eventCache } from '$lib/services/nostr/event-cache.js';
import { fetchRepoAnnouncementsWithCache, findRepoAnnouncement } from '$lib/utils/nostr-utils.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 const repoRoot = typeof process !== 'undefined' && process.env?.GIT_REPO_ROOT
? 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 { 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 }
);

196
src/routes/repos/[npub]/[repo]/+page.svelte

@ -504,6 +504,11 @@
} | null>(null); } | null>(null);
let showVerificationDialog = $state(false); let showVerificationDialog = $state(false);
let verificationFileContent = $state<string | null>(null); let verificationFileContent = $state<string | null>(null);
// Clone URL verification dialog
let showCloneUrlVerificationDialog = $state(false);
let verifyingCloneUrl = $state(false);
let selectedCloneUrlForVerification = $state<string | null>(null);
let loadingVerification = $state(false); let loadingVerification = $state(false);
// Deletion request // 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() { async function deleteAnnouncement() {
if (!userPubkey || !userPubkeyHex) { if (!userPubkey || !userPubkeyHex) {
alert('Please connect your NIP-07 extension'); alert('Please connect your NIP-07 extension');
@ -5527,26 +5581,68 @@
<span style="opacity: 0.5;"></span> <span style="opacity: 0.5;"></span>
</span> </span>
{:else if cloneVerification !== undefined} {:else if cloneVerification !== undefined}
<span {#if cloneVerification.verified}
class="verification-badge" <span
class:verified={cloneVerification.verified} class="verification-badge verified"
class:unverified={!cloneVerification.verified} title="Verified ownership"
title={cloneVerification.verified ? 'Verified ownership' : (cloneVerification.error || 'Unverified')} >
>
{#if cloneVerification.verified}
<img src="/icons/check-circle.svg" alt="Verified" class="icon-inline" /> <img src="/icons/check-circle.svg" alt="Verified" class="icon-inline" />
</span>
{:else}
{#if userPubkey && (isMaintainer || userPubkeyHex === repoOwnerPubkeyDerived) && isRepoCloned === true}
<button
class="verification-badge unverified clickable"
title="Click to verify this repository by committing the repo announcement event"
onclick={() => {
selectedCloneUrlForVerification = cloneUrl;
showCloneUrlVerificationDialog = true;
}}
>
<img src="/icons/alert-triangle.svg" alt="Unverified" class="icon-inline" />
</button>
{:else} {:else}
<img src="/icons/alert-triangle.svg" alt="Unverified" class="icon-inline" /> <span
class="verification-badge unverified"
title={cloneVerification.error || 'Unverified'}
>
<img src="/icons/alert-triangle.svg" alt="Unverified" class="icon-inline" />
</span>
{/if} {/if}
</span> {/if}
{:else if verificationStatus} {:else if verificationStatus}
<span class="verification-badge unverified" title="Verification status unknown"> {#if userPubkey && (isMaintainer || userPubkeyHex === repoOwnerPubkeyDerived) && isRepoCloned === true}
<img src="/icons/alert-triangle.svg" alt="Unknown" class="icon-inline" /> <button
</span> class="verification-badge unverified clickable"
title="Click to verify this repository by committing the repo announcement event"
onclick={() => {
selectedCloneUrlForVerification = cloneUrl;
showCloneUrlVerificationDialog = true;
}}
>
<img src="/icons/alert-triangle.svg" alt="Unknown" class="icon-inline" />
</button>
{:else}
<span class="verification-badge unverified" title="Verification status unknown">
<img src="/icons/alert-triangle.svg" alt="Unknown" class="icon-inline" />
</span>
{/if}
{:else} {:else}
<span class="verification-badge unverified" title="Verification not checked"> {#if userPubkey && (isMaintainer || userPubkeyHex === repoOwnerPubkeyDerived) && isRepoCloned === true}
<img src="/icons/alert-triangle.svg" alt="Not checked" class="icon-inline" /> <button
</span> class="verification-badge unverified clickable"
title="Click to verify this repository by committing the repo announcement event"
onclick={() => {
selectedCloneUrlForVerification = cloneUrl;
showCloneUrlVerificationDialog = true;
}}
>
<img src="/icons/alert-triangle.svg" alt="Not checked" class="icon-inline" />
</button>
{:else}
<span class="verification-badge unverified" title="Verification not checked">
<img src="/icons/alert-triangle.svg" alt="Not checked" class="icon-inline" />
</span>
{/if}
{/if} {/if}
{#if isChecking || loadingReachability} {#if isChecking || loadingReachability}
<span class="reachability-badge loading" title="Checking reachability..."> <span class="reachability-badge loading" title="Checking reachability...">
@ -7586,6 +7682,76 @@
</div> </div>
</div> </div>
{/if} {/if}
<!-- Clone URL Verification Dialog -->
{#if showCloneUrlVerificationDialog}
<div
class="modal-overlay"
role="dialog"
aria-modal="true"
aria-label="Verify repository"
onclick={() => showCloneUrlVerificationDialog = false}
onkeydown={(e) => e.key === 'Escape' && (showCloneUrlVerificationDialog = false)}
tabindex="-1"
>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
class="modal verification-modal"
role="document"
onclick={(e) => e.stopPropagation()}
>
<div class="modal-header">
<h3>Verify Repository</h3>
</div>
<div class="modal-body">
<p class="verification-instructions">
Verify this repository by committing the repo announcement event to it.
</p>
{#if selectedCloneUrlForVerification}
<p style="margin: 1rem 0;">
<strong>Clone URL:</strong> <code>{selectedCloneUrlForVerification}</code>
</p>
{/if}
{#if isRepoCloned !== true}
<div class="error-message" style="margin: 1rem 0; padding: 0.75rem; background: var(--bg-warning, #fff3cd); border-left: 4px solid var(--text-warning, #856404); border-radius: 4px; color: var(--text-warning, #856404);">
<strong>Repository must be cloned first.</strong> Please clone this repository to the server before verifying ownership.
</div>
{:else}
<p style="margin: 1rem 0; color: var(--text-secondary);">
This will commit the repository announcement event to <code>nostr/repo-events.jsonl</code> in the default branch, which verifies that you control this repository.
</p>
{/if}
{#if error}
<div class="error-message" style="margin: 1rem 0; padding: 0.75rem; background: var(--bg-error, #fee); border-left: 4px solid var(--accent-error, #f00); border-radius: 4px;">
{error}
</div>
{/if}
</div>
<div class="modal-footer">
<button
onclick={verifyCloneUrl}
class="primary-button"
disabled={verifyingCloneUrl || !isRepoCloned}
title={!isRepoCloned ? 'Repository must be cloned first' : ''}
>
{verifyingCloneUrl ? 'Verifying...' : 'Verify Repository'}
</button>
<button
onclick={() => {
showCloneUrlVerificationDialog = false;
selectedCloneUrlForVerification = null;
error = null;
}}
class="cancel-button"
disabled={verifyingCloneUrl}
>
Cancel
</button>
</div>
</div>
</div>
{/if}
</div> </div>
<style> <style>

Loading…
Cancel
Save