Browse Source

refactor 13

Nostr-Signature: f41c8662dcbf1be408c560d11eda0890c40582a8ea8bb3220116e645cc6a2bb5 573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc 2b7b70089cecfa4652fe236fa586a6fe1b05c1c95434a160717cbf5ee2f37382cdd8e8f31d7b3a7576ee5264e9e70c7a8651591caaea0cd311d1be4c561d282f
main
Silberengel 2 weeks ago
parent
commit
0532541a5c
  1. 1
      nostr/commit-signatures.jsonl
  2. 22
      src/app.html
  3. 10
      src/lib/components/RepoHeaderEnhanced.svelte
  4. 108
      src/lib/components/UserBadge.svelte
  5. 166
      src/lib/services/git/repo-manager.ts
  6. 48
      src/lib/services/git/repo-url-parser.ts
  7. 73
      src/lib/services/nostr/nostr-client.ts
  8. 28
      src/routes/api/repos/[npub]/[repo]/clone/+server.ts
  9. 7
      src/routes/api/repos/[npub]/[repo]/verify/+server.ts
  10. 10
      src/routes/repos/+page.svelte
  11. 52
      src/routes/repos/[npub]/[repo]/+page.svelte
  12. 21
      src/routes/repos/[npub]/[repo]/components/dialogs/CloneUrlVerificationDialog.svelte
  13. 20
      src/routes/repos/[npub]/[repo]/components/dialogs/CommitDialog.svelte
  14. 20
      src/routes/repos/[npub]/[repo]/components/dialogs/CreateBranchDialog.svelte
  15. 20
      src/routes/repos/[npub]/[repo]/components/dialogs/CreateFileDialog.svelte
  16. 20
      src/routes/repos/[npub]/[repo]/components/dialogs/CreateIssueDialog.svelte
  17. 20
      src/routes/repos/[npub]/[repo]/components/dialogs/CreatePRDialog.svelte
  18. 20
      src/routes/repos/[npub]/[repo]/components/dialogs/CreatePatchDialog.svelte
  19. 20
      src/routes/repos/[npub]/[repo]/components/dialogs/CreateReleaseDialog.svelte
  20. 20
      src/routes/repos/[npub]/[repo]/components/dialogs/CreateTagDialog.svelte
  21. 20
      src/routes/repos/[npub]/[repo]/components/dialogs/CreateThreadDialog.svelte
  22. 20
      src/routes/repos/[npub]/[repo]/components/dialogs/PatchCommentDialog.svelte
  23. 20
      src/routes/repos/[npub]/[repo]/components/dialogs/PatchHighlightDialog.svelte
  24. 20
      src/routes/repos/[npub]/[repo]/components/dialogs/ReplyDialog.svelte
  25. 55
      src/routes/repos/[npub]/[repo]/components/dialogs/VerificationDialog.svelte
  26. 170
      src/routes/repos/[npub]/[repo]/services/repo-operations.ts

1
nostr/commit-signatures.jsonl

@ -107,3 +107,4 @@ @@ -107,3 +107,4 @@
{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772141183,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","bug-fixes"]],"content":"Signed commit: bug-fixes","id":"b92b203686c0629409fef055e7f3189cf9f26be5cca0253ab00cf7e8498e1115","sig":"06a13aac9d2f794e52b0416044db6ebf9dd248d254d2166d7e7f3fefd2b7d37d1a85072c3e92316898c31068e25cf37bc5afd2fcd8ae2050d0a30b1bc1973678"}
{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772142448,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","refactor 11"]],"content":"Signed commit: refactor 11","id":"bb9d5c56a291e48221df96868fb925e309cb560aa350c2cf5f9c4ddd5e5c4a6b","sig":"75662c916bf4d8bb3d70cdae4e4882382692c6f1ca67598a69abe3dc96069ef6f2bda5a1b8f91b724aa43b3cb3c6b8ad6cbce286b5d165377a34a881e7275d2a"}
{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772142558,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","remove redundancy"]],"content":"Signed commit: remove redundancy","id":"11ac91151bebd4dd49b91bcdef7b0b7157f0afd8ce710f7231be4860fb073d08","sig":"a7efcafa5ea83a0c37eae4562a84a7581c3d5c5dd1416f8f3e2bd2633d8523ae0eb7cc56dc4292c127ea16fb2dd5bc639483cb096263a850956b47312ed7ff6f"}
{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772182112,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","refactor 12"]],"content":"Signed commit: refactor 12","id":"73671ae6535309f9eae164f7a3ec403b1bc818ef811b9692fd0122d0b72c2774","sig":"0df56b009f5afb77de334225ab30cff55586ac0cf48f5ee435428201a1e72dc357a0fb5e80ef196f5bd76d6d448056d25f0feab0b1bcbe45f9af1a2a0d5453ca"}

22
src/app.html

@ -17,6 +17,28 @@ @@ -17,6 +17,28 @@
document.documentElement.setAttribute('data-theme', 'dark');
}
})();
// Handle unhandled promise rejections from relay errors
// This prevents console errors from relay payment/restriction messages
window.addEventListener('unhandledrejection', (event) => {
const reason = event.reason;
const errorMessage = reason instanceof Error ? reason.message : String(reason);
// Handle relay-specific errors gracefully (payment requirements, restrictions, etc.)
if (errorMessage.includes('restricted') ||
errorMessage.includes('Pay on') ||
errorMessage.includes('payment required') ||
errorMessage.includes('rate limit') ||
errorMessage.includes('bad req')) {
// These are expected relay errors - prevent them from showing as uncaught errors
event.preventDefault();
// Optionally log for debugging (but don't spam console)
if (typeof console !== 'undefined' && console.debug) {
console.debug('[Relay]', errorMessage);
}
}
// Other unhandled rejections will still be logged by the browser
});
</script>
%sveltekit.head%
</head>

10
src/lib/components/RepoHeaderEnhanced.svelte

@ -42,6 +42,7 @@ @@ -42,6 +42,7 @@
needsClone?: boolean;
allMaintainers?: Array<{ pubkey: string; isOwner: boolean }>;
onCopyEventId?: () => void;
onRemoveFromServer?: () => void;
topics?: string[];
}
@ -84,6 +85,7 @@ @@ -84,6 +85,7 @@
needsClone = false,
allMaintainers = [],
onCopyEventId,
onRemoveFromServer,
topics = []
}: Props = $props();
@ -268,6 +270,14 @@ @@ -268,6 +270,14 @@
{deletingAnnouncement ? 'Deleting...' : 'Delete Announcement'}
</button>
{/if}
{#if onRemoveFromServer}
<button
class="menu-item menu-item-danger"
onclick={() => { onRemoveFromServer(); showMoreMenu = false; }}
>
Remove this repo from the server
</button>
{/if}
</div>
{/if}
</div>

108
src/lib/components/UserBadge.svelte

@ -5,6 +5,8 @@ @@ -5,6 +5,8 @@
import { KIND } from '../types/nostr.js';
import { eventCache } from '../services/nostr/event-cache.js';
import { nip19 } from 'nostr-tools';
import { userStore } from '../stores/user-store.js';
import { hasUnlimitedAccess } from '../utils/user-access.js';
interface Props {
pubkey: string;
@ -14,6 +16,38 @@ @@ -14,6 +16,38 @@
let { pubkey, disableLink = false, inline = false }: Props = $props();
// Check if this user has unlimited access (verified)
const isVerified = $derived.by(() => {
const currentUser = $userStore;
// Check if the pubkey matches the current user's pubkey
try {
// Convert pubkey to hex for comparison
let pubkeyHex: string;
if (/^[0-9a-f]{64}$/i.test(pubkey)) {
pubkeyHex = pubkey.toLowerCase();
} else {
try {
const decoded = nip19.decode(pubkey);
if (decoded.type === 'npub') {
pubkeyHex = decoded.data as string;
} else {
return false;
}
} catch {
return false;
}
}
// Compare with current user's pubkey
if (currentUser?.userPubkeyHex === pubkeyHex) {
return hasUnlimitedAccess(currentUser.userLevel);
}
} catch {
// If comparison fails, not verified
}
return false;
});
// Convert pubkey to npub for navigation (reactive)
const profileUrl = $derived.by(() => {
try {
@ -200,11 +234,16 @@ @@ -200,11 +234,16 @@
{/if}
{:else if disableLink}
<div class="user-badge">
<div class="user-badge-avatar-wrapper" title={isVerified ? 'Verified.' : undefined}>
{#if userProfile?.picture}
<img src={userProfile.picture} alt="Profile" class="user-badge-avatar" />
{:else}
<img src="/favicon.png" alt="Profile" class="user-badge-avatar user-badge-avatar-fallback" />
{/if}
{#if isVerified}
<div class="verification-wreath" aria-label="Verified"></div>
{/if}
</div>
<span class="user-badge-name">{truncateHandle(userProfile?.name)}</span>
</div>
{:else}
@ -215,11 +254,16 @@ @@ -215,11 +254,16 @@
e.stopPropagation();
}}
>
<div class="user-badge-avatar-wrapper" title={isVerified ? 'Verified. This user has write-access to the server because it has write-access to at least one default Nostr relay.' : undefined}>
{#if userProfile?.picture}
<img src={userProfile.picture} alt="Profile" class="user-badge-avatar" />
{:else}
<img src="/favicon.png" alt="Profile" class="user-badge-avatar user-badge-avatar-fallback" />
{/if}
{#if isVerified}
<div class="verification-wreath" aria-label="Verified"></div>
{/if}
</div>
<span class="user-badge-name">{truncateHandle(userProfile?.name)}</span>
</a>
{/if}
@ -244,12 +288,18 @@ @@ -244,12 +288,18 @@
background: var(--bg-secondary);
}
.user-badge-avatar-wrapper {
position: relative;
display: inline-block;
flex-shrink: 0;
}
.user-badge-avatar {
width: 24px;
height: 24px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
display: block;
}
.user-badge-avatar-fallback {
@ -257,6 +307,62 @@ @@ -257,6 +307,62 @@
opacity: 0.7;
}
/* Theme-aware laurel wreath verification indicator */
.verification-wreath {
position: absolute;
top: -3px;
left: -3px;
right: -3px;
bottom: -3px;
border-radius: 50%;
border: 2.5px solid var(--accent); /* Theme-aware accent color */
pointer-events: none;
/* Create laurel wreath effect with theme-aware glow */
box-shadow:
0 0 0 1px var(--accent),
inset 0 0 0 1px var(--accent),
/* Decorative shadows for laurel wreath effect */
2px -2px 0 -1px var(--accent),
-2px 2px 0 -1px var(--accent),
2px 2px 0 -1px var(--accent),
-2px -2px 0 -1px var(--accent),
/* Subtle outer glow */
0 0 4px var(--accent);
filter: opacity(0.85);
animation: verification-pulse 2s ease-in-out infinite;
}
.user-badge-avatar-wrapper {
cursor: help; /* Show help cursor to indicate tooltip */
}
.user-badge-avatar-wrapper:hover .verification-wreath {
border-color: var(--accent-hover); /* Theme-aware hover color */
filter: opacity(1);
box-shadow:
0 0 0 1px var(--accent-hover),
inset 0 0 0 1px var(--accent-hover),
/* Enhanced laurel wreath effect on hover */
2px -2px 0 -1px var(--accent-hover),
-2px 2px 0 -1px var(--accent-hover),
2px 2px 0 -1px var(--accent-hover),
-2px -2px 0 -1px var(--accent-hover),
/* Enhanced glow on hover */
0 0 8px var(--accent-hover),
0 0 12px var(--accent);
}
@keyframes verification-pulse {
0%, 100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.85;
transform: scale(1.02);
}
}
.user-badge-name {
font-size: 0.875rem;
color: var(--text-primary);

166
src/lib/services/git/repo-manager.ts

@ -70,10 +70,28 @@ export class RepoManager { @@ -70,10 +70,28 @@ export class RepoManager {
* @param event - The repo announcement event
* @param selfTransferEvent - Optional self-transfer event to include in initial commit
* @param isExistingRepo - Whether this is an existing repo being added to the server
* @param allowMissingDomainUrl - In development, allow provisioning even if domain URL isn't in announcement
*/
async provisionRepo(event: NostrEvent, selfTransferEvent?: NostrEvent, isExistingRepo: boolean = false): Promise<void> {
async provisionRepo(event: NostrEvent, selfTransferEvent?: NostrEvent, isExistingRepo: boolean = false, allowMissingDomainUrl: boolean = false): Promise<void> {
const cloneUrls = this.urlParser.extractCloneUrls(event);
const domainUrl = cloneUrls.find(url => url.includes(this.domain));
let domainUrl = cloneUrls.find(url => url.includes(this.domain));
// In development, if domain URL not found and allowed, construct it from the event
if (!domainUrl && allowMissingDomainUrl) {
const isLocalhost = this.domain.includes('localhost') || this.domain.includes('127.0.0.1');
if (isLocalhost) {
// Extract npub and repo name from event
const dTag = event.tags.find(t => t[0] === 'd')?.[1];
if (dTag) {
const protocol = this.domain.startsWith('localhost') || this.domain.startsWith('127.0.0.1') ? 'http' : 'https';
// Get npub from event pubkey
const { nip19 } = await import('nostr-tools');
const npub = nip19.npubEncode(event.pubkey);
domainUrl = `${protocol}://${this.domain}/${npub}/${dTag}.git`;
logger.info({ domain: this.domain, npub, repo: dTag, constructedUrl: domainUrl }, 'Constructed domain URL for development provisioning');
}
}
}
if (!domainUrl) {
throw new Error(`No ${this.domain} URL found in repo announcement`);
@ -125,16 +143,29 @@ export class RepoManager { @@ -125,16 +143,29 @@ export class RepoManager {
const git = simpleGit();
await git.init(['--bare', repoPath.fullPath]);
// Ensure announcement event is saved to nostr/repo-events.jsonl in the repository
await this.announcementManager.ensureAnnouncementInRepo(repoPath.fullPath, event, selfTransferEvent);
// If there are other clone URLs, sync from them after creating the repo
if (otherUrls.length > 0) {
const remoteUrls = this.urlParser.prepareRemoteUrls(otherUrls);
await this.remoteSync.syncFromRemotes(repoPath.fullPath, remoteUrls);
} else {
// No external URLs - this is a brand new repo, create initial branch and README
}
// Check if branches exist after sync (if any)
const repoGit = simpleGit(repoPath.fullPath);
let hasBranches = false;
try {
const branches = await repoGit.branch(['-a']);
hasBranches = branches.all.length > 0;
} catch {
hasBranches = false;
}
if (!hasBranches) {
// No branches exist - create initial branch and README (which includes announcement)
await this.createInitialBranchAndReadme(repoPath.fullPath, repoPath.npub, repoPath.repoName, event);
} else {
// Branches exist (from sync) - ensure announcement is committed to the default branch
// This must happen after syncing so we can commit it to the existing default branch
await this.announcementManager.ensureAnnouncementInRepo(repoPath.fullPath, event, selfTransferEvent);
}
} else {
// For existing repos, check if announcement exists in repo
@ -169,8 +200,48 @@ export class RepoManager { @@ -169,8 +200,48 @@ export class RepoManager {
announcementEvent: NostrEvent
): Promise<void> {
try {
// Get default branch from environment or use 'master'
const defaultBranch = process.env.DEFAULT_BRANCH || 'master';
// Get default branch from git config, environment, or use 'master'
// Check git's init.defaultBranch config first (respects user's git settings)
let defaultBranch = process.env.DEFAULT_BRANCH || 'master';
try {
const git = simpleGit();
// Try to get git's default branch setting
const defaultBranchConfig = await git.raw(['config', '--get', 'init.defaultBranch']).catch(() => null);
if (defaultBranchConfig && defaultBranchConfig.trim()) {
defaultBranch = defaultBranchConfig.trim();
}
} catch {
// If git config fails, use environment or fallback to 'master'
}
// Check if any branches already exist (e.g., from a remote sync)
const repoGit = simpleGit(repoPath);
let existingBranches: string[] = [];
try {
const branches = await repoGit.branch(['-a']);
existingBranches = branches.all.map(b => b.replace(/^remotes\/origin\//, '').replace(/^remotes\//, '').replace(/^refs\/heads\//, ''));
// Remove duplicates
existingBranches = [...new Set(existingBranches)];
// If branches exist, check if one matches our default branch preference
if (existingBranches.length > 0) {
// Prefer existing branches that match common defaults
const preferredBranches = [defaultBranch, 'main', 'master', 'dev'];
for (const preferred of preferredBranches) {
if (existingBranches.includes(preferred)) {
defaultBranch = preferred;
break;
}
}
// If no match, use the first existing branch
if (!existingBranches.includes(defaultBranch)) {
defaultBranch = existingBranches[0];
}
}
} catch {
// No branches exist, use the determined default
}
// Get repo name from d-tag or use repoName from path
const dTag = announcementEvent.tags.find(t => t[0] === 'd')?.[1] || repoName;
@ -203,19 +274,9 @@ Your commits will all be signed by your Nostr keys and saved to the event files @@ -203,19 +274,9 @@ Your commits will all be signed by your Nostr keys and saved to the event files
const { FileManager } = await import('./file-manager.js');
const fileManager = new FileManager(this.repoRoot);
// For a new repo with no branches, we need to create an orphan branch first
// Check if repo has any branches
const git = simpleGit(repoPath);
let hasBranches = false;
try {
const branches = await git.branch(['-a']);
hasBranches = branches.all.length > 0;
} catch {
// No branches exist
hasBranches = false;
}
if (!hasBranches) {
// If no branches exist, create an orphan branch
// We already checked for existing branches above, so if existingBranches is empty, create one
if (existingBranches.length === 0) {
// Create orphan branch first (pass undefined for fromBranch to create orphan)
await fileManager.createBranch(npub, repoName, defaultBranch, undefined);
}
@ -359,17 +420,25 @@ Your commits will all be signed by your Nostr keys and saved to the event files @@ -359,17 +420,25 @@ Your commits will all be signed by your Nostr keys and saved to the event files
let remoteUrls: string[] = [];
try {
// Check if we're in development mode (localhost)
const isLocalhost = this.domain.includes('localhost') || this.domain.includes('127.0.0.1');
// If in development, prefer localhost URLs from GIT_DOMAIN
if (isLocalhost) {
// Construct localhost URL from GIT_DOMAIN
const protocol = this.domain.startsWith('localhost') || this.domain.startsWith('127.0.0.1') ? 'http' : 'https';
const localhostUrl = `${protocol}://${this.domain}/${npub}/${repoName}.git`;
// Always use localhost URL in development, even if it's not in the clone URLs
// This allows cloning from the local server during development
remoteUrls = [localhostUrl];
logger.info({ npub, repoName, url: localhostUrl, domain: this.domain }, 'Using localhost URL for development clone');
} else {
// Prepare remote URLs (filters out localhost/our domain, converts SSH to HTTPS)
remoteUrls = this.urlParser.prepareRemoteUrls(cloneUrls);
if (remoteUrls.length === 0) {
logger.warn({ npub, repoName, cloneUrls, announcementEventId: announcementEvent.id }, 'No remote clone URLs found for on-demand fetch');
return { success: false, needsAnnouncement: false };
}
logger.debug({ npub, repoName, cloneUrls, remoteUrls, isPublic }, 'On-demand fetch details');
// Check if repoRoot exists and is writable
// Check if repoRoot exists and is writable (needed for both provisioning and cloning)
if (!existsSync(this.repoRoot)) {
try {
mkdirSync(this.repoRoot, { recursive: true });
@ -417,6 +486,42 @@ Your commits will all be signed by your Nostr keys and saved to the event files @@ -417,6 +486,42 @@ Your commits will all be signed by your Nostr keys and saved to the event files
}
}
if (remoteUrls.length === 0) {
// No remote URLs - this is an empty repo, provision it instead
logger.info({ npub, repoName, cloneUrls, announcementEventId: announcementEvent.id }, 'No remote clone URLs found - provisioning empty repository');
try {
await this.provisionRepo(announcementEvent, undefined, false);
logger.info({ npub, repoName }, 'Empty repository provisioned successfully');
return { success: true, cloneUrls, remoteUrls: [] };
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
logger.error({ npub, repoName, error: error.message }, 'Failed to provision empty repository');
return { success: false, error: error.message, cloneUrls, remoteUrls: [] };
}
}
logger.debug({ npub, repoName, cloneUrls, remoteUrls, isPublic }, 'On-demand fetch details');
// In development mode, if using localhost URL, check if repo exists locally first
// If it doesn't exist, provision it instead of trying to clone from non-existent URL
if (isLocalhost && remoteUrls[0].includes(this.domain)) {
const localRepoPath = join(this.repoRoot, npub, `${repoName}.git`);
if (!existsSync(localRepoPath)) {
// Repo doesn't exist on localhost - provision it instead
logger.info({ npub, repoName, url: remoteUrls[0] }, 'Localhost URL specified but repo does not exist locally - provisioning instead');
try {
// In development, allow provisioning even if domain URL isn't in announcement
await this.provisionRepo(announcementEvent, undefined, false, true);
logger.info({ npub, repoName }, 'Repository provisioned successfully on localhost');
return { success: true, cloneUrls, remoteUrls: [] };
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
logger.error({ npub, repoName, error: error.message }, 'Failed to provision repository on localhost');
return { success: false, error: error.message, cloneUrls, remoteUrls: [] };
}
}
}
// Get git environment for URL (handles Tor proxy, etc.)
const gitEnv = this.remoteSync.getGitEnvForUrl(remoteUrls[0]);
@ -430,7 +535,8 @@ Your commits will all be signed by your Nostr keys and saved to the event files @@ -430,7 +535,8 @@ Your commits will all be signed by your Nostr keys and saved to the event files
repoName,
sourceUrl: remoteUrls[0],
cloneUrls,
authenticated: isAuthenticated
authenticated: isAuthenticated,
isLocalhost
}, 'Fetching repository on-demand from remote');
// Clone as bare repository with timeout

48
src/lib/services/git/repo-url-parser.ts

@ -90,8 +90,50 @@ export class RepoUrlParser { @@ -90,8 +90,50 @@ export class RepoUrlParser {
/**
* Filter and prepare remote URLs from clone URLs
* Respects the repo owner's order in the clone list
* In development (localhost), prefers localhost URLs over remote URLs
*/
prepareRemoteUrls(cloneUrls: string[]): string[] {
// Check if we're in development mode (localhost)
const isLocalhost = this.domain.includes('localhost') || this.domain.includes('127.0.0.1');
// In development, prefer localhost URLs
if (isLocalhost) {
const localhostUrls: string[] = [];
const otherUrls: string[] = [];
for (const url of cloneUrls) {
const lowerUrl = url.toLowerCase();
if (lowerUrl.includes('localhost') ||
lowerUrl.includes('127.0.0.1') ||
url.includes(this.domain)) {
localhostUrls.push(url);
} else {
otherUrls.push(url);
}
}
// Prefer localhost URLs in development
if (localhostUrls.length > 0) {
return localhostUrls;
}
// Fall back to other URLs if no localhost URLs found
if (otherUrls.length > 0) {
return this.prepareRemoteUrlsFromList(otherUrls);
}
// If no URLs at all, return empty
return [];
}
// Production mode: filter out localhost/our domain
return this.prepareRemoteUrlsFromList(cloneUrls);
}
/**
* Helper method to prepare remote URLs from a list (filters out localhost/our domain)
*/
private prepareRemoteUrlsFromList(cloneUrls: string[]): string[] {
const httpsUrls: string[] = [];
const sshUrls: string[] = [];
@ -132,12 +174,6 @@ export class RepoUrlParser { @@ -132,12 +174,6 @@ export class RepoUrlParser {
remoteUrls = cloneUrls.filter(url => !url.includes(this.domain));
}
// If still no remote URLs, but there are *any* clone URLs, try the first one
// This handles cases where the only clone URL is our own domain, but the repo doesn't exist locally yet
if (remoteUrls.length === 0 && cloneUrls.length > 0) {
remoteUrls.push(cloneUrls[0]);
}
return remoteUrls;
}

73
src/lib/services/nostr/nostr-client.ts

@ -436,19 +436,54 @@ export class NostrClient { @@ -436,19 +436,54 @@ export class NostrClient {
const failed: Array<{ relay: string; error: string }> = [];
// Use nostr-tools SimplePool to publish to all relays
// Wrap in Promise.resolve().then() to catch any synchronous errors and ensure all async errors are caught
// SimplePool.publish can throw errors from WebSocket handlers that aren't caught by normal try-catch
// We need to wrap it carefully to catch all errors
try {
// Create a promise that will catch all errors, including those from WebSocket event handlers
const publishPromise = Promise.resolve().then(async () => {
// Wrap publish in a promise that catches all errors, including unhandled promise rejections
const publishPromise = new Promise<string[]>((resolve, reject) => {
// Set up a timeout to prevent hanging
const timeout = setTimeout(() => {
reject(new Error('Publish timeout after 30 seconds'));
}, 30000);
// Publish to relays - wrap in try-catch to catch synchronous errors
try {
await this.pool.publish(targetRelays, event);
// SimplePool.publish returns a promise, but errors from individual relays
// may not be properly caught. We'll handle them at multiple levels.
const poolPublishPromise = this.pool.publish(targetRelays, event);
// Handle the promise result
poolPublishPromise
.then(() => {
clearTimeout(timeout);
// If publish succeeded, all relays succeeded
// Note: SimplePool.publish doesn't return per-relay results, so we assume all succeeded
return targetRelays;
} catch (error) {
// If publish failed, mark all as failed
// In a more sophisticated implementation, we could check individual relays
throw error;
resolve(targetRelays);
})
.catch((error: unknown) => {
clearTimeout(timeout);
// Handle specific relay errors gracefully
const errorMessage = error instanceof Error ? error.message : String(error);
// Check for common relay error messages that shouldn't be fatal
if (errorMessage.includes('restricted') ||
errorMessage.includes('Pay on') ||
errorMessage.includes('payment required') ||
errorMessage.includes('rate limit')) {
// These are relay-specific restrictions, not fatal errors
// Log but don't fail - we'll mark relays as failed below
logger.debug({ error: errorMessage, eventId: event.id }, 'Relay restriction encountered (payment/rate limit)');
// Resolve with empty success - we'll mark all as failed below
resolve([]);
} else {
// Other errors should be rejected
reject(error);
}
});
} catch (syncError) {
// Catch any synchronous errors
clearTimeout(timeout);
reject(syncError);
}
});
@ -458,14 +493,21 @@ export class NostrClient { @@ -458,14 +493,21 @@ export class NostrClient {
new Promise<string[]>((_, reject) =>
setTimeout(() => reject(new Error('Publish timeout')), 30000)
)
]).catch(error => {
]).catch((error: unknown) => {
// Log error but don't throw - we'll mark relays as failed below
logger.debug({ error, eventId: event.id }, 'Error publishing event to relays');
return null;
const errorMessage = error instanceof Error ? error.message : String(error);
logger.debug({ error: errorMessage, eventId: event.id }, 'Error publishing event to relays');
return [];
});
if (publishedRelays) {
if (publishedRelays && publishedRelays.length > 0) {
success.push(...publishedRelays);
// Mark any relays not in success as failed
targetRelays.forEach(relay => {
if (!publishedRelays.includes(relay)) {
failed.push({ relay, error: 'Relay did not accept event' });
}
});
} else {
// If publish failed or timed out, mark all as failed
targetRelays.forEach(relay => {
@ -474,9 +516,10 @@ export class NostrClient { @@ -474,9 +516,10 @@ export class NostrClient {
}
} catch (error) {
// Catch any synchronous errors
logger.debug({ error, eventId: event.id }, 'Synchronous error in publishEvent');
const errorMessage = error instanceof Error ? error.message : String(error);
logger.debug({ error: errorMessage, eventId: event.id }, 'Synchronous error in publishEvent');
targetRelays.forEach(relay => {
failed.push({ relay, error: String(error) });
failed.push({ relay, error: errorMessage });
});
}

28
src/routes/api/repos/[npub]/[repo]/clone/+server.ts

@ -105,18 +105,28 @@ export const POST: RequestHandler = async (event) => { @@ -105,18 +105,28 @@ export const POST: RequestHandler = async (event) => {
logger.info({ userPubkeyHex: userPubkeyHex.slice(0, 16) + '...' }, 'Verified unlimited access from proof event');
userLevel = getCachedUserLevel(userPubkeyHex); // Get the cached value
} else {
// Check if relays are down
if (verification.relayDown) {
// Relays are down - check cache again (might have been cached from previous request)
// Verification failed - check cache before denying access
// Cache exists for exactly this reason: to allow access when verification temporarily fails
userLevel = getCachedUserLevel(userPubkeyHex);
if (!userLevel || !hasUnlimitedAccess(userLevel.level)) {
if (userLevel && hasUnlimitedAccess(userLevel.level)) {
// User has cached unlimited access - use it even though verification failed
// This handles cases where relays are down or proof event hasn't propagated yet
logger.info({
userPubkeyHex: userPubkeyHex.slice(0, 16) + '...',
error: verification.error,
cachedLevel: userLevel.level,
cachedAt: new Date(userLevel.cachedAt).toISOString()
}, 'Verification failed but using cached unlimited access');
} else if (verification.relayDown) {
// Relays are down and no cache - temporary issue
logger.warn({ userPubkeyHex: userPubkeyHex.slice(0, 16) + '...', error: verification.error }, 'Relays down and no cached unlimited access');
throw error(503, 'Relays are temporarily unavailable and no cached access level found. Please verify your access level first by visiting your profile page.');
}
} else {
// Verification failed - user doesn't have write access
// Verification failed and no cache - user doesn't have write access
logger.warn({ userPubkeyHex: userPubkeyHex.slice(0, 16) + '...', error: verification.error }, 'User does not have unlimited access');
throw error(403, `Only users with unlimited access can clone repositories to the server. ${verification.error || 'Please verify you can write to at least one default Nostr relay.'}`);
const errorMsg = verification.error || 'Please verify you can write to at least one default Nostr relay.';
throw error(403, `Only users with unlimited access can clone repositories to the server. ${errorMsg} Note: You only need write access to ONE default relay, not all of them.`);
}
}
} catch (err) {
@ -132,7 +142,7 @@ export const POST: RequestHandler = async (event) => { @@ -132,7 +142,7 @@ export const POST: RequestHandler = async (event) => {
// No proof event or auth header - check if we have any cached level
if (!userLevel) {
logger.warn({ userPubkeyHex: userPubkeyHex.slice(0, 16) + '...' }, 'No cached user level and no proof event or NIP-98 auth header');
throw error(403, 'Only users with unlimited access can clone repositories to the server. Please verify your access level first by visiting your profile page or ensuring you can write to at least one default Nostr relay.');
throw error(403, 'Only users with unlimited access can clone repositories to the server. Please verify your access level first by visiting your profile page or ensuring you can write to at least one default Nostr relay. Note: You only need write access to ONE default relay, not all of them.');
}
}
}
@ -143,7 +153,7 @@ export const POST: RequestHandler = async (event) => { @@ -143,7 +153,7 @@ export const POST: RequestHandler = async (event) => {
userPubkeyHex: userPubkeyHex.slice(0, 16) + '...',
cachedLevel: userLevel?.level || 'none'
}, 'User does not have unlimited access');
throw error(403, 'Only users with unlimited access can clone repositories to the server. Please verify you can write to at least one default Nostr relay.');
throw error(403, 'Only users with unlimited access can clone repositories to the server. Please verify you can write to at least one default Nostr relay. Note: You only need write access to ONE default relay, not all of them.');
}
try {

7
src/routes/api/repos/[npub]/[repo]/verify/+server.ts

@ -183,10 +183,11 @@ export const POST: RequestHandler = createRepoPostHandler( @@ -183,10 +183,11 @@ export const POST: RequestHandler = createRepoPostHandler(
return error(401, 'Authentication required. Please provide userPubkey.');
}
// Check if user is a maintainer
// Check if user is a maintainer or the repository owner
const isMaintainer = await maintainerService.isMaintainer(userPubkeyHex, context.repoOwnerPubkey, context.repo);
if (!isMaintainer) {
return error(403, 'Only repository maintainers can verify clone URLs.');
const isOwner = userPubkeyHex === context.repoOwnerPubkey;
if (!isMaintainer && !isOwner) {
return error(403, 'Only repository owners and maintainers can save announcements.');
}
// Check if repository is cloned

10
src/routes/repos/+page.svelte

@ -847,16 +847,6 @@ @@ -847,16 +847,6 @@
<a href="/repos/{item.npub}/{item.repoName}" class="view-button" title="View repository">
<img src="/icons/arrow-right.svg" alt="View" />
</a>
{#if userPubkey && canDelete}
<button
class="delete-button"
onclick={() => deleteLocalRepo(item.npub, item.repoName)}
disabled={deletingRepo?.npub === item.npub && deletingRepo?.repo === item.repoName}
title="Delete repository"
>
{deletingRepo?.npub === item.npub && deletingRepo?.repo === item.repoName ? 'Deleting...' : 'Delete'}
</button>
{/if}
</div>
</div>
<div class="repo-meta">

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

@ -192,6 +192,7 @@ @@ -192,6 +192,7 @@
generateAnnouncementFileForRepo as generateAnnouncementFileForRepoService,
copyVerificationToClipboard as copyVerificationToClipboardService,
downloadVerificationFile as downloadVerificationFileService,
saveAnnouncementToRepo as saveAnnouncementToRepoService,
verifyCloneUrl as verifyCloneUrlService,
deleteAnnouncement as deleteAnnouncementService,
copyEventId as copyEventIdService
@ -598,13 +599,47 @@ @@ -598,13 +599,47 @@
await generateAnnouncementFileForRepoService(state, repoOwnerPubkeyDerived);
}
const copyVerificationToClipboard = () => copyVerificationToClipboardService(state);
const downloadVerificationFile = () => downloadVerificationFileService(state);
async function saveAnnouncementToRepo() {
await saveAnnouncementToRepoService(state, repoOwnerPubkeyDerived);
// Reload branches and files to show the new commit
if (state.clone.isCloned) {
await loadBranches();
await loadFiles();
}
}
async function verifyCloneUrl() {
await verifyCloneUrlService(state, repoOwnerPubkeyDerived, { checkVerification });
}
async function deleteAnnouncement() {
await deleteAnnouncementService(state, repoOwnerPubkeyDerived, announcementEventId);
}
const downloadVerificationFile = () => downloadVerificationFileService(state);
async function removeRepoFromServer() {
if (!confirm(`Are you sure you want to remove "${state.repo}" from this server?\n\nThis will permanently delete the local clone of the repository. The announcement on Nostr will NOT be deleted.\n\nThis action cannot be undone.\n\nClick OK to delete, or Cancel to abort.`)) {
return;
}
try {
const headers = buildApiHeaders();
const response = await fetch(`/api/repos/${state.npub}/${state.repo}/delete`, {
method: 'DELETE',
headers
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'Failed to delete repository');
}
// Redirect to repos list after successful deletion
alert('Repository removed from server successfully');
goto('/repos');
} catch (err) {
alert(`Failed to remove repository: ${err instanceof Error ? err.message : String(err)}`);
}
}
const downloadRepository = (ref?: string, filename?: string) => downloadRepoUtil({ npub: state.npub, repo: state.repo, ref, filename });
// Safe wrapper functions for SSR
@ -613,6 +648,7 @@ @@ -613,6 +648,7 @@
const safeToggleBookmark = () => safeAsync(() => toggleBookmark());
const safeForkRepository = () => safeAsync(() => forkRepository());
const safeCloneRepository = () => safeAsync(() => cloneRepository());
const safeRemoveRepoFromServer = () => safeAsync(removeRepoFromServer);
const safeHandleBranchChange = (branch: string) => safeSync(() => handleBranchChangeDirect(branch));
// Initialize activeTab from URL query parameter
@ -855,6 +891,18 @@ @@ -855,6 +891,18 @@
await checkVerification();
if (!state.isMounted) return;
// Log verification status for maintenance (after check completes)
if (state.verification.status) {
const status = state.verification.status;
console.log('[Page Load] Verification Status:', {
verified: status.verified,
error: status.error || null,
message: status.message || null,
cloneCount: status.cloneVerifications?.length || 0,
verifiedClones: status.cloneVerifications?.filter(cv => cv.verified).length || 0
});
}
await loadReadme();
if (!state.isMounted) return;
@ -1055,6 +1103,7 @@ @@ -1055,6 +1103,7 @@
needsClone={needsClone}
allMaintainers={state.maintainers.all}
onCopyEventId={copyEventId}
onRemoveFromServer={repoOwnerPubkeyDerived && state.user.pubkeyHex === repoOwnerPubkeyDerived && state.clone.isCloned ? safeRemoveRepoFromServer : undefined}
/>
{/if}
@ -1905,6 +1954,7 @@ @@ -1905,6 +1954,7 @@
{state}
onCopy={copyVerificationToClipboard}
onDownload={downloadVerificationFile}
onSave={state.clone.isCloned && (state.maintainers.isMaintainer || state.user.pubkeyHex === repoOwnerPubkeyDerived) ? saveAnnouncementToRepo : undefined}
onClose={() => state.openDialog = null}
/>

21
src/routes/repos/[npub]/[repo]/components/dialogs/CloneUrlVerificationDialog.svelte

@ -73,6 +73,7 @@ @@ -73,6 +73,7 @@
.verification-code {
background: var(--bg-secondary, #f5f5f5);
color: var(--text-primary);
padding: 0.2rem 0.4rem;
border-radius: 3px;
font-family: monospace;
@ -112,8 +113,14 @@ @@ -112,8 +113,14 @@
}
.primary-button {
background: var(--primary-color, #2196f3);
color: white;
background: var(--button-primary);
color: var(--accent-text, #ffffff);
font-family: 'IBM Plex Serif', serif;
transition: background 0.2s ease;
}
.primary-button:hover:not(:disabled) {
background: var(--button-primary-hover);
}
.primary-button:disabled {
@ -122,7 +129,15 @@ @@ -122,7 +129,15 @@
}
.cancel-button {
background: var(--cancel-bg, #e0e0e0);
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
color: var(--text-primary);
font-family: 'IBM Plex Serif', serif;
transition: background 0.2s ease, color 0.2s ease;
}
.cancel-button:hover:not(:disabled) {
background: var(--bg-secondary);
}
.cancel-button:disabled {

20
src/routes/repos/[npub]/[repo]/components/dialogs/CommitDialog.svelte

@ -84,12 +84,26 @@ @@ -84,12 +84,26 @@
}
.cancel-button {
background: var(--cancel-bg, #e0e0e0);
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
color: var(--text-primary);
font-family: 'IBM Plex Serif', serif;
transition: background 0.2s ease, color 0.2s ease;
}
.cancel-button:hover {
background: var(--bg-secondary);
}
.save-button {
background: var(--primary-color, #2196f3);
color: white;
background: var(--button-primary);
color: var(--accent-text, #ffffff);
font-family: 'IBM Plex Serif', serif;
transition: background 0.2s ease;
}
.save-button:hover:not(:disabled) {
background: var(--button-primary-hover);
}
.save-button:disabled {

20
src/routes/repos/[npub]/[repo]/components/dialogs/CreateBranchDialog.svelte

@ -81,12 +81,26 @@ @@ -81,12 +81,26 @@
}
.cancel-button {
background: var(--cancel-bg, #e0e0e0);
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
color: var(--text-primary);
font-family: 'IBM Plex Serif', serif;
transition: background 0.2s ease, color 0.2s ease;
}
.cancel-button:hover {
background: var(--bg-secondary);
}
.save-button {
background: var(--primary-color, #2196f3);
color: white;
background: var(--button-primary);
color: var(--accent-text, #ffffff);
font-family: 'IBM Plex Serif', serif;
transition: background 0.2s ease;
}
.save-button:hover:not(:disabled) {
background: var(--button-primary-hover);
}
.save-button:disabled {

20
src/routes/repos/[npub]/[repo]/components/dialogs/CreateFileDialog.svelte

@ -86,12 +86,26 @@ @@ -86,12 +86,26 @@
}
.cancel-button {
background: var(--cancel-bg, #e0e0e0);
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
color: var(--text-primary);
font-family: 'IBM Plex Serif', serif;
transition: background 0.2s ease, color 0.2s ease;
}
.cancel-button:hover {
background: var(--bg-secondary);
}
.save-button {
background: var(--primary-color, #2196f3);
color: white;
background: var(--button-primary);
color: var(--accent-text, #ffffff);
font-family: 'IBM Plex Serif', serif;
transition: background 0.2s ease;
}
.save-button:hover:not(:disabled) {
background: var(--button-primary-hover);
}
.save-button:disabled {

20
src/routes/repos/[npub]/[repo]/components/dialogs/CreateIssueDialog.svelte

@ -75,18 +75,26 @@ @@ -75,18 +75,26 @@
}
.cancel-button {
background: var(--cancel-bg, var(--bg-secondary, #2a2a2a));
color: var(--text-primary, #e0e0e0);
border: 1px solid var(--border-color, #333);
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
color: var(--text-primary);
font-family: 'IBM Plex Serif', serif;
transition: background 0.2s ease, color 0.2s ease;
}
.cancel-button:hover {
background: var(--bg-hover, #3a3a3a);
background: var(--bg-secondary);
}
.save-button {
background: var(--primary-color, var(--accent-color, #2196f3));
color: white;
background: var(--button-primary);
color: var(--accent-text, #ffffff);
font-family: 'IBM Plex Serif', serif;
transition: background 0.2s ease;
}
.save-button:hover:not(:disabled) {
background: var(--button-primary-hover);
}
.save-button:hover {

20
src/routes/repos/[npub]/[repo]/components/dialogs/CreatePRDialog.svelte

@ -83,18 +83,26 @@ @@ -83,18 +83,26 @@
}
.cancel-button {
background: var(--cancel-bg, var(--bg-secondary, #2a2a2a));
color: var(--text-primary, #e0e0e0);
border: 1px solid var(--border-color, #333);
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
color: var(--text-primary);
font-family: 'IBM Plex Serif', serif;
transition: background 0.2s ease, color 0.2s ease;
}
.cancel-button:hover {
background: var(--bg-hover, #3a3a3a);
background: var(--bg-secondary);
}
.save-button {
background: var(--primary-color, var(--accent-color, #2196f3));
color: white;
background: var(--button-primary);
color: var(--accent-text, #ffffff);
font-family: 'IBM Plex Serif', serif;
transition: background 0.2s ease;
}
.save-button:hover:not(:disabled) {
background: var(--button-primary-hover);
}
.save-button:hover {

20
src/routes/repos/[npub]/[repo]/components/dialogs/CreatePatchDialog.svelte

@ -79,18 +79,26 @@ @@ -79,18 +79,26 @@
}
.cancel-button {
background: var(--cancel-bg, var(--bg-secondary, #2a2a2a));
color: var(--text-primary, #e0e0e0);
border: 1px solid var(--border-color, #333);
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
color: var(--text-primary);
font-family: 'IBM Plex Serif', serif;
transition: background 0.2s ease, color 0.2s ease;
}
.cancel-button:hover {
background: var(--bg-hover, #3a3a3a);
background: var(--bg-secondary);
}
.save-button {
background: var(--primary-color, var(--accent-color, #2196f3));
color: white;
background: var(--button-primary);
color: var(--accent-text, #ffffff);
font-family: 'IBM Plex Serif', serif;
transition: background 0.2s ease;
}
.save-button:hover:not(:disabled) {
background: var(--button-primary-hover);
}
.save-button:hover {

20
src/routes/repos/[npub]/[repo]/components/dialogs/CreateReleaseDialog.svelte

@ -81,12 +81,26 @@ @@ -81,12 +81,26 @@
}
.cancel-button {
background: var(--cancel-bg, #e0e0e0);
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
color: var(--text-primary);
font-family: 'IBM Plex Serif', serif;
transition: background 0.2s ease, color 0.2s ease;
}
.cancel-button:hover {
background: var(--bg-secondary);
}
.save-button {
background: var(--primary-color, #2196f3);
color: white;
background: var(--button-primary);
color: var(--accent-text, #ffffff);
font-family: 'IBM Plex Serif', serif;
transition: background 0.2s ease;
}
.save-button:hover:not(:disabled) {
background: var(--button-primary-hover);
}
.save-button:disabled {

20
src/routes/repos/[npub]/[repo]/components/dialogs/CreateTagDialog.svelte

@ -71,12 +71,26 @@ @@ -71,12 +71,26 @@
}
.cancel-button {
background: var(--cancel-bg, #e0e0e0);
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
color: var(--text-primary);
font-family: 'IBM Plex Serif', serif;
transition: background 0.2s ease, color 0.2s ease;
}
.cancel-button:hover {
background: var(--bg-secondary);
}
.save-button {
background: var(--primary-color, #2196f3);
color: white;
background: var(--button-primary);
color: var(--accent-text, #ffffff);
font-family: 'IBM Plex Serif', serif;
transition: background 0.2s ease;
}
.save-button:hover:not(:disabled) {
background: var(--button-primary-hover);
}
.save-button:disabled {

20
src/routes/repos/[npub]/[repo]/components/dialogs/CreateThreadDialog.svelte

@ -64,12 +64,26 @@ @@ -64,12 +64,26 @@
}
.cancel-button {
background: var(--cancel-bg, #e0e0e0);
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
color: var(--text-primary);
font-family: 'IBM Plex Serif', serif;
transition: background 0.2s ease, color 0.2s ease;
}
.cancel-button:hover {
background: var(--bg-secondary);
}
.save-button {
background: var(--primary-color, #2196f3);
color: white;
background: var(--button-primary);
color: var(--accent-text, #ffffff);
font-family: 'IBM Plex Serif', serif;
transition: background 0.2s ease;
}
.save-button:hover:not(:disabled) {
background: var(--button-primary-hover);
}
.save-button:disabled {

20
src/routes/repos/[npub]/[repo]/components/dialogs/PatchCommentDialog.svelte

@ -69,12 +69,26 @@ @@ -69,12 +69,26 @@
}
.cancel-button {
background: var(--cancel-bg, #e0e0e0);
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
color: var(--text-primary);
font-family: 'IBM Plex Serif', serif;
transition: background 0.2s ease, color 0.2s ease;
}
.cancel-button:hover {
background: var(--bg-secondary);
}
.save-button {
background: var(--primary-color, #2196f3);
color: white;
background: var(--button-primary);
color: var(--accent-text, #ffffff);
font-family: 'IBM Plex Serif', serif;
transition: background 0.2s ease;
}
.save-button:hover:not(:disabled) {
background: var(--button-primary-hover);
}
.save-button:disabled {

20
src/routes/repos/[npub]/[repo]/components/dialogs/PatchHighlightDialog.svelte

@ -79,12 +79,26 @@ @@ -79,12 +79,26 @@
}
.cancel-button {
background: var(--cancel-bg, #e0e0e0);
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
color: var(--text-primary);
font-family: 'IBM Plex Serif', serif;
transition: background 0.2s ease, color 0.2s ease;
}
.cancel-button:hover {
background: var(--bg-secondary);
}
.save-button {
background: var(--primary-color, #2196f3);
color: white;
background: var(--button-primary);
color: var(--accent-text, #ffffff);
font-family: 'IBM Plex Serif', serif;
transition: background 0.2s ease;
}
.save-button:hover:not(:disabled) {
background: var(--button-primary-hover);
}
.save-button:disabled {

20
src/routes/repos/[npub]/[repo]/components/dialogs/ReplyDialog.svelte

@ -71,12 +71,26 @@ @@ -71,12 +71,26 @@
}
.cancel-button {
background: var(--cancel-bg, #e0e0e0);
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
color: var(--text-primary);
font-family: 'IBM Plex Serif', serif;
transition: background 0.2s ease, color 0.2s ease;
}
.cancel-button:hover {
background: var(--bg-secondary);
}
.save-button {
background: var(--primary-color, #2196f3);
color: white;
background: var(--button-primary);
color: var(--accent-text, #ffffff);
font-family: 'IBM Plex Serif', serif;
transition: background 0.2s ease;
}
.save-button:hover:not(:disabled) {
background: var(--button-primary-hover);
}
.save-button:disabled {

55
src/routes/repos/[npub]/[repo]/components/dialogs/VerificationDialog.svelte

@ -7,10 +7,11 @@ @@ -7,10 +7,11 @@
state: RepoState;
onCopy: () => void;
onDownload: () => void;
onSave?: () => void;
onClose: () => void;
}
let { open, state, onCopy, onDownload, onClose }: Props = $props();
let { open, state, onCopy, onDownload, onSave, onClose }: Props = $props();
</script>
<Modal {open} title="Repository Verification File" ariaLabel="Repository verification file" {onClose}>
@ -31,7 +32,12 @@ @@ -31,7 +32,12 @@
</div>
</div>
<div class="modal-actions">
<button onclick={onClose} class="cancel-button">Close</button>
{#if onSave}
<button onclick={onSave} class="save-button" disabled={state.creating.announcement || !state.clone.isCloned}>
{state.creating.announcement ? 'Saving...' : 'Save to Repo'}
</button>
{/if}
<button onclick={onClose} class="cancel-button">Cancel</button>
</div>
</Modal>
@ -47,6 +53,7 @@ @@ -47,6 +53,7 @@
.verification-code {
background: var(--bg-secondary, #f5f5f5);
color: var(--text-primary);
padding: 0.2rem 0.4rem;
border-radius: 3px;
font-family: monospace;
@ -70,6 +77,7 @@ @@ -70,6 +77,7 @@
.filename {
font-weight: bold;
font-family: monospace;
color: var(--text-primary);
}
.file-actions {
@ -82,8 +90,17 @@ @@ -82,8 +90,17 @@
padding: 0.25rem 0.75rem;
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 4px;
background: white;
background: var(--button-secondary, var(--bg-tertiary));
color: var(--text-primary);
cursor: pointer;
font-family: 'IBM Plex Serif', serif;
transition: background 0.2s ease, color 0.2s ease;
}
.copy-button:hover,
.download-button:hover {
background: var(--button-secondary-hover, var(--bg-tertiary));
opacity: 0.9;
}
.file-content {
@ -92,12 +109,15 @@ @@ -92,12 +109,15 @@
overflow-x: auto;
max-height: 400px;
overflow-y: auto;
background: var(--bg-primary);
color: var(--text-primary);
}
.file-content code {
font-family: monospace;
font-size: 0.85rem;
white-space: pre;
color: var(--text-primary);
}
.modal-actions {
@ -108,10 +128,37 @@ @@ -108,10 +128,37 @@
}
.cancel-button {
padding: 0.5rem 1rem;
border: 1px solid var(--border-color);
border-radius: 4px;
cursor: pointer;
background: var(--bg-tertiary);
color: var(--text-primary);
font-family: 'IBM Plex Serif', serif;
transition: background 0.2s ease, color 0.2s ease;
}
.cancel-button:hover {
background: var(--bg-secondary);
}
.save-button {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
background: var(--cancel-bg, #e0e0e0);
background: var(--button-primary);
color: var(--accent-text, #ffffff);
font-family: 'IBM Plex Serif', serif;
transition: background 0.2s ease;
}
.save-button:hover:not(:disabled) {
background: var(--button-primary-hover);
}
.save-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>

170
src/routes/repos/[npub]/[repo]/services/repo-operations.ts

@ -13,6 +13,8 @@ import { getUserRelays } from '$lib/services/nostr/user-relays.js'; @@ -13,6 +13,8 @@ import { getUserRelays } from '$lib/services/nostr/user-relays.js';
import { KIND } from '$lib/types/nostr.js';
import type { NostrEvent } from '$lib/types/nostr.js';
import { goto } from '$app/navigation';
import logger from '$lib/services/logger.js';
import { isNIP07Available } from '$lib/services/nostr/nip07-signer.js';
interface RepoOperationsCallbacks {
checkCloneStatus: (force: boolean) => Promise<void>;
@ -110,9 +112,94 @@ export async function cloneRepository( @@ -110,9 +112,94 @@ export async function cloneRepository(
): Promise<void> {
if (state.clone.cloning) return;
if (!state.user.pubkeyHex) {
alert('Please log in to clone repositories.');
return;
}
state.clone.cloning = true;
try {
const data = await apiPost<{ alreadyExists?: boolean }>(`/api/repos/${state.npub}/${state.repo}/clone`, {});
// Create and send proof event to verify write access
// This ensures the clone endpoint can verify access even if cache is empty
let proofEvent: NostrEvent | null = null;
try {
if (isNIP07Available() && state.user.pubkeyHex) {
const { createProofEvent } = await import('$lib/services/nostr/relay-write-proof.js');
const { signEventWithNIP07 } = await import('$lib/services/nostr/nip07-signer.js');
// Create proof event template
const proofEventTemplate = createProofEvent(
state.user.pubkeyHex,
`gitrepublic-clone-proof-${Date.now()}`
);
// Sign with NIP-07
proofEvent = await signEventWithNIP07(proofEventTemplate);
// Publish to relays so server can verify it
// User only needs write access to ONE relay, not all
const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS);
let publishResult;
try {
publishResult = await nostrClient.publishEvent(proofEvent, DEFAULT_NOSTR_RELAYS).catch((err: unknown) => {
// Catch any unhandled errors from publishEvent
const errorMessage = err instanceof Error ? err.message : String(err);
logger.debug({ error: errorMessage }, '[Clone] Error in publishEvent, marking all relays as failed');
// Return a result with all relays failed
return {
success: [],
failed: DEFAULT_NOSTR_RELAYS.map(relay => ({ relay, error: errorMessage }))
};
});
} catch (err) {
// Catch synchronous errors
const errorMessage = err instanceof Error ? err.message : String(err);
logger.debug({ error: errorMessage }, '[Clone] Synchronous error publishing proof event');
publishResult = {
success: [],
failed: DEFAULT_NOSTR_RELAYS.map(relay => ({ relay, error: errorMessage }))
};
}
// If at least one relay accepted the event, wait for propagation
// If all relays failed, still try (might be cached or server can retry)
if (publishResult && publishResult.success.length > 0) {
logger.debug({
successCount: publishResult.success.length,
successfulRelays: publishResult.success,
failedRelays: publishResult.failed.map(f => f.relay)
}, '[Clone] Proof event published to at least one relay, waiting for propagation');
// Wait longer for event to propagate to relays (3 seconds should be enough)
await new Promise(resolve => setTimeout(resolve, 3000));
} else {
const failedDetails = publishResult?.failed.map(f => `${f.relay}: ${f.error}`) || ['Unknown error'];
logger.warn({
failedRelays: failedDetails
}, '[Clone] Proof event failed to publish to all relays, but continuing (server may retry or use cache)');
// Still wait a bit in case some relays are slow
await new Promise(resolve => setTimeout(resolve, 2000));
}
// Clean up client
try {
nostrClient.close();
} catch (closeErr) {
// Ignore close errors
logger.debug({ error: closeErr }, '[Clone] Error closing NostrClient');
}
}
} catch (proofErr) {
// If proof creation fails, continue anyway - clone endpoint will check cache
logger.debug({ error: proofErr }, '[Clone] Failed to create proof event, will rely on cache');
}
// Send clone request with proof event in body (if available)
const requestBody: { proofEvent?: NostrEvent } = {};
if (proofEvent) {
requestBody.proofEvent = proofEvent;
}
const data = await apiPost<{ alreadyExists?: boolean }>(`/api/repos/${state.npub}/${state.repo}/clone`, requestBody);
if (data.alreadyExists) {
alert('Repository already exists locally.');
@ -382,7 +469,25 @@ export async function checkVerification( @@ -382,7 +469,25 @@ export async function checkVerification(
state.verification.status = { verified: false, error: 'Failed to check verification' };
} finally {
state.loading.verification = false;
console.log('[Verification] Status after check:', state.verification.status);
// Log verification status for maintenance
if (state.verification.status) {
const status = state.verification.status;
console.log('[Verification Status]', {
verified: status.verified,
error: status.error || null,
message: status.message || null,
cloneCount: status.cloneVerifications?.length || 0,
verifiedClones: status.cloneVerifications?.filter(cv => cv.verified).length || 0,
cloneDetails: status.cloneVerifications?.map(cv => ({
url: cv.url.substring(0, 50) + (cv.url.length > 50 ? '...' : ''),
verified: cv.verified,
error: cv.error || null
})) || []
});
} else {
console.log('[Verification Status] Not available');
}
}
}
@ -593,7 +698,15 @@ export async function generateAnnouncementFileForRepo( @@ -593,7 +698,15 @@ export async function generateAnnouncementFileForRepo(
}
try {
// Fetch the repository announcement event
// First, try to use the announcement from pageData (already loaded)
let announcement: NostrEvent | null = null;
if (state.pageData?.announcement) {
announcement = state.pageData.announcement as NostrEvent;
}
// If not available in pageData, fetch from Nostr
if (!announcement) {
const { NostrClient } = await import('$lib/services/nostr/nostr-client.js');
const { DEFAULT_NOSTR_RELAYS, DEFAULT_NOSTR_SEARCH_RELAYS } = await import('$lib/config.js');
const { KIND } = await import('$lib/types/nostr.js');
@ -612,7 +725,9 @@ export async function generateAnnouncementFileForRepo( @@ -612,7 +725,9 @@ export async function generateAnnouncementFileForRepo(
return;
}
const announcement = events[0] as NostrEvent;
announcement = events[0] as NostrEvent;
}
// Generate announcement event JSON (for download/reference)
state.verification.fileContent = JSON.stringify(announcement, null, 2) + '\n';
state.openDialog = 'verification';
@ -636,6 +751,53 @@ export function copyVerificationToClipboard(state: RepoState): void { @@ -636,6 +751,53 @@ export function copyVerificationToClipboard(state: RepoState): void {
});
}
/**
* Save announcement to repository
* Uses the existing verify endpoint which saves and commits the announcement
*/
export async function saveAnnouncementToRepo(
state: RepoState,
repoOwnerPubkeyDerived: string | null
): Promise<void> {
if (!repoOwnerPubkeyDerived || !state.user.pubkeyHex) {
state.error = 'Unable to save announcement: missing repository or user information';
return;
}
if (!state.clone.isCloned) {
state.error = 'Repository must be cloned first. Please clone the repository before saving the announcement.';
return;
}
// Check if user is owner or maintainer
if (!state.maintainers.isMaintainer && state.user.pubkeyHex !== repoOwnerPubkeyDerived) {
state.error = 'Only repository owners and maintainers can save announcements.';
return;
}
try {
state.creating.announcement = true;
state.error = null;
// Use the existing verify endpoint which saves and commits the announcement
const data = await apiRequest<{ message?: string; announcementId?: string }>(`/api/repos/${state.npub}/${state.repo}/verify`, {
method: 'POST'
} as RequestInit);
// Close dialog and show success
state.openDialog = null;
alert(data.message || 'Announcement saved to repository successfully!');
// Reload branches and files to show the new commit
// The callbacks will be passed from the component
} catch (err) {
console.error('Failed to save announcement:', err);
state.error = `Failed to save announcement: ${err instanceof Error ? err.message : String(err)}`;
} finally {
state.creating.announcement = false;
}
}
/**
* Download verification file
*/

Loading…
Cancel
Save