From 7cca952ea8f2fb089d2c47735ff42ceffbb9780e Mon Sep 17 00:00:00 2001 From: Silberengel Date: Wed, 18 Feb 2026 14:10:07 +0100 Subject: [PATCH] more bug-fixes add verification-file generator for off-server repos --- src/app.css | 138 ++++- src/hooks.server.ts | 24 +- src/lib/components/NavBar.svelte | 3 +- src/lib/services/nostr/bookmarks-service.ts | 54 +- src/lib/services/security/rate-limiter.ts | 2 +- src/lib/utils/api-auth.ts | 35 +- src/routes/+page.svelte | 143 ++++-- src/routes/dashboard/+page.svelte | 21 +- src/routes/repos/+page.svelte | 231 +++++++-- src/routes/repos/[npub]/[repo]/+page.svelte | 237 +++++++-- .../repos/[npub]/[repo]/settings/+page.svelte | 19 + src/routes/search/+page.svelte | 32 ++ src/routes/users/[npub]/+page.svelte | 36 ++ src/routes/verify/+page.svelte | 474 ++++++++++++++++++ 14 files changed, 1275 insertions(+), 174 deletions(-) create mode 100644 src/routes/verify/+page.svelte diff --git a/src/app.css b/src/app.css index 7873c60..e738fd7 100644 --- a/src/app.css +++ b/src/app.css @@ -23,6 +23,7 @@ --accent: var(--royal-plum); --accent-hover: #6a1f4d; --accent-light: var(--lilac); + --accent-text: #ffffff; /* White text for accent backgrounds */ --link-color: #5a0d4f; /* Darker plum for better contrast on light bg */ --link-hover: #4a0a3f; /* Even darker for hover */ --card-bg: #ffffff; @@ -62,6 +63,7 @@ --accent: var(--royal-plum); --accent-hover: #b84a8a; --accent-light: var(--lilac); + --accent-text: #ffffff; /* White text for accent backgrounds */ --link-color: #d84ab8; /* Brighter plum for better contrast on dark bg */ --link-hover: #e85ac8; /* Even brighter for hover */ --card-bg: var(--lavender-blush); @@ -750,6 +752,94 @@ input:disabled, textarea:disabled, select:disabled { } /* Cards */ +/* My Repositories Section */ +.my-repos-section { + margin-bottom: 2rem; + padding: 1.5rem; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 0.5rem; +} + +.my-repos-section h3 { + margin: 0 0 1rem 0; + font-size: 1.25rem; + color: var(--text-primary); + font-weight: 600; +} + +.my-repos-badges { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; +} + +.repo-badge { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 0.375rem; + text-decoration: none; + color: var(--text-primary); + transition: all 0.2s ease; + font-size: 0.9rem; +} + +.repo-badge:hover { + background: var(--bg-secondary); + border-color: var(--accent); + transform: translateY(-1px); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +.repo-badge-image { + width: 24px; + height: 24px; + object-fit: cover; + border-radius: 50%; + flex-shrink: 0; +} + +.repo-badge-icon { + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.2rem; + flex-shrink: 0; +} + +.repo-badge-name { + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 200px; +} + +@media (max-width: 768px) { + .my-repos-section { + padding: 1rem; + } + + .my-repos-badges { + gap: 0.5rem; + } + + .repo-badge { + padding: 0.4rem 0.6rem; + font-size: 0.85rem; + } + + .repo-badge-name { + max-width: 150px; + } +} + .card, .repo-card { border: 1px solid var(--card-border); border-radius: 0.5rem; @@ -783,7 +873,7 @@ input:disabled, textarea:disabled, select:disabled { .repo-card-image, .repo-image { width: 64px; height: 64px; - border-radius: 8px; + border-radius: 50%; object-fit: cover; flex-shrink: 0; } @@ -862,22 +952,62 @@ input:disabled, textarea:disabled, select:disabled { color: var(--success-text); } +/* Fork button - ensure it uses primary button style */ +.fork-button { + background: var(--button-primary); + color: var(--accent-text, #ffffff); + border: none; + padding: 0.5rem 1rem; + border-radius: 0.375rem; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: background 0.2s ease; + font-family: 'IBM Plex Serif', serif; +} + +.fork-button:hover:not(:disabled) { + background: var(--button-primary-hover); +} + +.fork-button:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +[data-theme="dark"] .fork-button { + background: var(--button-primary); + color: #ffffff; +} + .fork-badge { padding: 0.25rem 0.5rem; - background: var(--accent-light); - color: var(--text-primary); /* Better contrast */ + background: var(--accent); + color: var(--accent-text, #ffffff); border-radius: 4px; font-size: 0.85rem; margin-left: 0.5rem; + font-weight: 500; +} + +[data-theme="dark"] .fork-badge { + background: var(--accent); + color: #ffffff; } .fork-badge a { - color: var(--link-color); /* Use link color for better visibility */ + color: var(--accent-text, #ffffff); text-decoration: none; + font-weight: 500; +} + +[data-theme="dark"] .fork-badge a { + color: #ffffff; } .fork-badge a:hover { text-decoration: underline; + opacity: 0.9; } .verification-status { diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 076dd7b..f30feec 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -53,7 +53,7 @@ export const handle: Handle = async ({ event, resolve }) => { } else if (url.pathname.startsWith('/api/search')) { rateLimitType = 'search'; } - + // Extract user pubkey for rate limiting (authenticated users get higher limits) const userPubkey = event.request.headers.get('X-User-Pubkey') || event.request.headers.get('x-user-pubkey') || @@ -66,8 +66,26 @@ export const handle: Handle = async ({ event, resolve }) => { const rateLimitIdentifier = userPubkey ? `user:${userPubkey}` : `ip:${clientIp}`; const isAnonymous = !userPubkey; - // Check rate limit (skip for Vite internal requests) - const rateLimitResult = isViteInternalRequest + // Skip rate limiting for read-only GET requests to repo endpoints (page loads) + // These are necessary for normal page functionality and are not write operations + const isReadOnlyRepoRequest = event.request.method === 'GET' && + url.pathname.startsWith('/api/repos/') && + !url.pathname.includes('/file') && // File operations are rate limited separately + !url.pathname.includes('/delete') && + !url.pathname.includes('/transfer') && + !url.pathname.includes('/settings') && // Settings might be write operations + (url.pathname.endsWith('/fork') || // GET /fork is read-only + url.pathname.endsWith('/verify') || // GET /verify is read-only + url.pathname.endsWith('/readme') || // GET /readme is read-only + url.pathname.endsWith('/branches') || // GET /branches is read-only + url.pathname.endsWith('/tags') || // GET /tags is read-only + url.pathname.endsWith('/tree') || // GET /tree is read-only + url.pathname.endsWith('/commits') || // GET /commits is read-only + url.pathname.endsWith('/access') || // GET /access is read-only + url.pathname.endsWith('/maintainers')); // GET /maintainers is read-only + + // Check rate limit (skip for Vite internal requests and read-only repo requests) + const rateLimitResult = (isViteInternalRequest || isReadOnlyRepoRequest) ? { allowed: true, resetAt: Date.now() } : rateLimiter.check(rateLimitType, rateLimitIdentifier, isAnonymous); if (!rateLimitResult.allowed) { diff --git a/src/lib/components/NavBar.svelte b/src/lib/components/NavBar.svelte index ad5ee09..4187364 100644 --- a/src/lib/components/NavBar.svelte +++ b/src/lib/components/NavBar.svelte @@ -96,9 +96,10 @@ diff --git a/src/lib/services/nostr/bookmarks-service.ts b/src/lib/services/nostr/bookmarks-service.ts index a9d8d6b..2b4faf1 100644 --- a/src/lib/services/nostr/bookmarks-service.ts +++ b/src/lib/services/nostr/bookmarks-service.ts @@ -77,44 +77,50 @@ export class BookmarksService { /** * Add a repo to bookmarks - * Creates or updates the bookmark list event + * Creates or updates the bookmark list event, preserving all existing tags + * Per NIP-51: new items are appended to the end, and duplicates are removed */ async addBookmark(pubkey: string, repoAddress: string, relays: string[]): Promise { try { // Get existing bookmarks const existingBookmarks = await this.getBookmarks(pubkey); - // Extract existing a-tags (for repos) - const existingATags: string[] = []; + // Preserve ALL existing tags (not just a-tags) + const existingTags: string[][] = []; + const seenAddresses = new Set(); + if (existingBookmarks) { for (const tag of existingBookmarks.tags) { + // For 'a' tags, deduplicate repo addresses if (tag[0] === 'a' && tag[1]) { - // Only include repo announcement addresses if (tag[1].startsWith(`${KIND.REPO_ANNOUNCEMENT}:`)) { - existingATags.push(tag[1]); + // Skip if we've already seen this address (deduplication) + if (seenAddresses.has(tag[1])) { + continue; + } + seenAddresses.add(tag[1]); } } + // Preserve all other tags as-is + existingTags.push([...tag]); } } // Check if already bookmarked - if (existingATags.includes(repoAddress)) { + if (seenAddresses.has(repoAddress)) { logger.debug({ pubkey: truncatePubkey(pubkey), repoAddress }, 'Repo already bookmarked'); return true; } // Add new bookmark to the end (chronological order per NIP-51) - existingATags.push(repoAddress); - - // Create new bookmark event - const tags: string[][] = existingATags.map(addr => ['a', addr]); + existingTags.push(['a', repoAddress]); const eventTemplate: Omit = { kind: KIND.BOOKMARKS, pubkey, created_at: Math.floor(Date.now() / 1000), content: '', // Public bookmarks use tags, not encrypted content - tags + tags: existingTags }; // Sign with NIP-07 @@ -138,7 +144,7 @@ export class BookmarksService { /** * Remove a repo from bookmarks - * Creates a new bookmark list event without the specified repo + * Creates a new bookmark list event without the specified repo, preserving all other tags */ async removeBookmark(pubkey: string, repoAddress: string, relays: string[]): Promise { try { @@ -150,32 +156,32 @@ export class BookmarksService { return true; } - // Extract existing a-tags (for repos), excluding the one to remove - const existingATags: string[] = []; + // Preserve ALL existing tags except the one to remove + const existingTags: string[][] = []; + let found = false; + for (const tag of existingBookmarks.tags) { - if (tag[0] === 'a' && tag[1]) { - // Only include repo announcement addresses, and exclude the one to remove - if (tag[1].startsWith(`${KIND.REPO_ANNOUNCEMENT}:`) && tag[1] !== repoAddress) { - existingATags.push(tag[1]); - } + // Skip the tag that matches the repo address to remove + if (tag[0] === 'a' && tag[1] === repoAddress) { + found = true; + continue; // Skip this tag } + // Preserve all other tags + existingTags.push([...tag]); } // Check if it was bookmarked - if (existingATags.length === existingBookmarks.tags.filter(t => t[0] === 'a' && t[1]?.startsWith(`${KIND.REPO_ANNOUNCEMENT}:`)).length) { + if (!found) { logger.debug({ pubkey: truncatePubkey(pubkey), repoAddress }, 'Repo was not bookmarked'); return true; } - // Create new bookmark event without the removed bookmark - const tags: string[][] = existingATags.map(addr => ['a', addr]); - const eventTemplate: Omit = { kind: KIND.BOOKMARKS, pubkey, created_at: Math.floor(Date.now() / 1000), content: '', // Public bookmarks use tags, not encrypted content - tags + tags: existingTags }; // Sign with NIP-07 diff --git a/src/lib/services/security/rate-limiter.ts b/src/lib/services/security/rate-limiter.ts index 552bbfb..5e6bb76 100644 --- a/src/lib/services/security/rate-limiter.ts +++ b/src/lib/services/security/rate-limiter.ts @@ -79,7 +79,7 @@ export class RateLimiter { const defaultLimits: Record = { git: 60, // Git operations: 60/min (authenticated), 30/min (anonymous) - api: 120, // API requests: 120/min (authenticated), 60/min (anonymous) + api: 300, // API requests: 300/min (authenticated), 150/min (anonymous) - increased for page loads file: 30, // File operations: 30/min (authenticated), 15/min (anonymous) search: 20 // Search requests: 20/min (authenticated), 10/min (anonymous) }; diff --git a/src/lib/utils/api-auth.ts b/src/lib/utils/api-auth.ts index 77e61e9..f141187 100644 --- a/src/lib/utils/api-auth.ts +++ b/src/lib/utils/api-auth.ts @@ -10,6 +10,9 @@ import { maintainerService } from '../services/service-registry.js'; import { fileManager } from '../services/service-registry.js'; import type { RepoContext, RequestContext, RepoRequestContext } from './api-context.js'; import { handleValidationError, handleAuthError, handleAuthorizationError, handleNotFoundError } from './error-handler.js'; +import { BookmarksService } from '../services/nostr/bookmarks-service.js'; +import { DEFAULT_NOSTR_RELAYS } from '../config.js'; +import { KIND } from '../types/nostr.js'; /** * Check if user has access to a repository (privacy check) @@ -24,18 +27,40 @@ export async function requireRepoAccess( requestContext: RequestContext, operation?: string ): Promise { + // First check if user is owner/maintainer (or repo is public) const canView = await maintainerService.canView( requestContext.userPubkeyHex || null, repoContext.repoOwnerPubkey, repoContext.repo ); - if (!canView) { - throw handleAuthorizationError( - 'This repository is private. Only owners and maintainers can view it.', - { operation, npub: repoContext.npub, repo: repoContext.repo } - ); + if (canView) { + return; // User is owner/maintainer or repo is public, allow access } + + // canView returned false, which means repo is private and user is not owner/maintainer + // Check if user has bookmarked the private repo + if (requestContext.userPubkeyHex) { + try { + const bookmarksService = new BookmarksService(DEFAULT_NOSTR_RELAYS); + const repoAddress = `${KIND.REPO_ANNOUNCEMENT}:${repoContext.repoOwnerPubkey}:${repoContext.repo}`; + const isBookmarked = await bookmarksService.isBookmarked(requestContext.userPubkeyHex, repoAddress); + + if (isBookmarked) { + return; // User has bookmarked the private repo, allow access + } + } catch (err) { + // If bookmark check fails, continue to deny access + // Log error but don't expose it to user + console.error('[API Auth] Error checking bookmarks:', err); + } + } + + // All checks failed - deny access + throw handleAuthorizationError( + 'This repository is private. Only owners, maintainers, and users who have bookmarked it can view it.', + { operation, npub: repoContext.npub, repo: repoContext.repo } + ); } /** diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 83847fe..75294d7 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -14,10 +14,14 @@ let checkingLevel = $state(false); let levelMessage = $state(null); - // React to userStore changes (e.g., when user logs out) + // React to userStore changes (e.g., when user logs in or out) $effect(() => { const currentUser = $userStore; - if (!currentUser.userPubkey) { + if (currentUser.userPubkey && currentUser.userPubkeyHex) { + // User is logged in - sync local state with store + userPubkey = currentUser.userPubkey; + userPubkeyHex = currentUser.userPubkeyHex; + } else { // User has logged out - clear local state userPubkey = null; userPubkeyHex = null; @@ -28,15 +32,18 @@ // Prevent body scroll when splash page is shown document.body.style.overflow = 'hidden'; - // Check userStore first - if user has logged out, don't check extension + // Check userStore first - if user is already logged in, use store values const currentUser = $userStore; - if (!currentUser.userPubkey) { - // User has logged out or never logged in - userPubkey = null; - userPubkeyHex = null; + if (currentUser.userPubkey && currentUser.userPubkeyHex) { + // User is already logged in - use store values + userPubkey = currentUser.userPubkey; + userPubkeyHex = currentUser.userPubkeyHex; checkingAuth = false; + // Don't redirect immediately - let the user see they're logged in + // They can click "View Repositories" or navigate away } else { - // Check auth asynchronously + // User not logged in - check if extension is available + checkingAuth = true; checkAuth(); } @@ -94,54 +101,90 @@ } async function handleLogin() { - if (isNIP07Available()) { + if (!isNIP07Available()) { + alert('Nostr extension not found. Please install a Nostr extension like nos2x or Alby to login.'); + return; + } + + try { + checkingLevel = true; + levelMessage = 'Connecting to Nostr extension...'; + + // Get public key directly from NIP-07 + let pubkey: string; try { - checkingLevel = true; - levelMessage = 'Checking authentication...'; - - await checkAuth(); - - if (userPubkey && userPubkeyHex) { - levelMessage = 'Verifying relay write access...'; - - // Determine user level (checks relay write access) - const levelResult = await determineUserLevel(userPubkey, userPubkeyHex); - - // Update user store - userStore.setUser( - levelResult.userPubkey, - levelResult.userPubkeyHex, - levelResult.level, - levelResult.error || null - ); - - // Update activity tracking on successful login - updateActivity(); - - checkingLevel = false; - levelMessage = null; - - // Show appropriate message based on level - if (levelResult.level === 'unlimited') { - levelMessage = 'Unlimited access granted!'; - } else if (levelResult.level === 'rate_limited') { - levelMessage = 'Logged in with rate-limited access.'; - } - - // User is logged in, go to repos page - goto('/repos'); - } else { - checkingLevel = false; - levelMessage = null; + pubkey = await getPublicKeyWithNIP07(); + if (!pubkey) { + throw new Error('No public key returned from extension'); } } catch (err) { - console.error('Login failed:', err); + console.error('Failed to get public key from NIP-07:', err); checkingLevel = false; levelMessage = null; - alert('Failed to login. Please make sure you have a Nostr extension installed (like nos2x or Alby).'); + alert('Failed to connect to Nostr extension. Please make sure your extension is unlocked and try again.'); + return; } - } else { - alert('Nostr extension not found. Please install a Nostr extension like nos2x or Alby to login.'); + + // Convert npub to hex for API calls + let pubkeyHex: string; + if (/^[0-9a-f]{64}$/i.test(pubkey)) { + // Already hex format + pubkeyHex = pubkey.toLowerCase(); + userPubkey = pubkey; + } else { + // Try to decode as npub + try { + const decoded = nip19.decode(pubkey); + if (decoded.type === 'npub') { + pubkeyHex = decoded.data as string; + userPubkey = pubkey; // Keep original npub format + } else { + throw new Error('Invalid pubkey format'); + } + } catch (decodeErr) { + console.error('Failed to decode pubkey:', decodeErr); + checkingLevel = false; + levelMessage = null; + alert('Invalid public key format. Please try again.'); + return; + } + } + + userPubkeyHex = pubkeyHex; + levelMessage = 'Verifying relay write access...'; + + // Determine user level (checks relay write access) + const levelResult = await determineUserLevel(userPubkey, userPubkeyHex); + + // Update user store + userStore.setUser( + levelResult.userPubkey, + levelResult.userPubkeyHex, + levelResult.level, + levelResult.error || null + ); + + // Update activity tracking on successful login + updateActivity(); + + checkingLevel = false; + levelMessage = null; + + // Show appropriate message based on level + if (levelResult.level === 'unlimited') { + levelMessage = 'Unlimited access granted!'; + } else if (levelResult.level === 'rate_limited') { + levelMessage = 'Logged in with rate-limited access.'; + } + + // User is logged in, go to repos page + goto('/repos'); + } catch (err) { + console.error('Login failed:', err); + checkingLevel = false; + levelMessage = null; + const errorMessage = err instanceof Error ? err.message : String(err); + alert(`Failed to login: ${errorMessage}. Please make sure your Nostr extension is unlocked and try again.`); } } diff --git a/src/routes/dashboard/+page.svelte b/src/routes/dashboard/+page.svelte index f09d1ba..c7c99d8 100644 --- a/src/routes/dashboard/+page.svelte +++ b/src/routes/dashboard/+page.svelte @@ -3,10 +3,21 @@ import { getPublicKeyWithNIP07, isNIP07Available } from '$lib/services/nostr/nip07-signer.js'; import { nip19 } from 'nostr-tools'; import type { ExternalIssue, ExternalPullRequest } from '$lib/services/git-platforms/git-platform-fetcher.js'; + import { userStore } from '$lib/stores/user-store.js'; let loading = $state(true); let error = $state(null); let userPubkeyHex = $state(null); + + // Sync with userStore + $effect(() => { + const currentUser = $userStore; + if (currentUser.userPubkeyHex) { + userPubkeyHex = currentUser.userPubkeyHex; + } else { + userPubkeyHex = null; + } + }); let issues = $state([]); let pullRequests = $state([]); let activeTab = $state<'issues' | 'prs' | 'all'>('all'); @@ -32,6 +43,14 @@ }); async function loadUserPubkey() { + // Check userStore first + const currentUser = $userStore; + if (currentUser.userPubkeyHex) { + userPubkeyHex = currentUser.userPubkeyHex; + return; + } + + // Fallback: try NIP-07 if store doesn't have it if (!isNIP07Available()) { return; } @@ -234,7 +253,7 @@ {item.state} - {#if isPR && item.merged_at} + {#if isPR && 'merged_at' in item && item.merged_at} ✓ Merged {/if} diff --git a/src/routes/repos/+page.svelte b/src/routes/repos/+page.svelte index 1c0493a..75e9aac 100644 --- a/src/routes/repos/+page.svelte +++ b/src/routes/repos/+page.svelte @@ -8,6 +8,7 @@ import { nip19 } from 'nostr-tools'; import { ForkCountService } from '$lib/services/nostr/fork-count-service.js'; import { getPublicKeyWithNIP07, isNIP07Available } from '$lib/services/nostr/nip07-signer.js'; + import { userStore } from '$lib/stores/user-store.js'; // Registered repos (with domain in clone URLs) let registeredRepos = $state>([]); @@ -27,6 +28,10 @@ let userPubkeyHex = $state(null); let contactPubkeys = $state>(new Set()); let deletingRepo = $state<{ npub: string; repo: string } | null>(null); + + // User's own repositories (where they are owner or maintainer) + let myRepos = $state>([]); + let loadingMyRepos = $state(false); import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js'; const forkCountService = new ForkCountService(DEFAULT_NOSTR_RELAYS); @@ -36,6 +41,7 @@ onMount(async () => { await loadRepos(); await loadUserAndContacts(); + await loadMyRepos(); }); // Reload repos when page becomes visible (e.g., after returning from another page) @@ -45,6 +51,7 @@ if (document.visibilityState === 'visible') { // Reload repos when page becomes visible to catch newly published repos loadRepos().catch(err => console.warn('Failed to reload repos on visibility change:', err)); + loadMyRepos().catch(err => console.warn('Failed to reload my repos on visibility change:', err)); } }; document.addEventListener('visibilitychange', handleVisibilityChange); @@ -52,14 +59,72 @@ } }); + // Sync with userStore - if userStore says logged out, clear local state + $effect(() => { + const currentUser = $userStore; + if (!currentUser.userPubkey || !currentUser.userPubkeyHex) { + // User is logged out according to store - clear local state + const wasLoggedIn = userPubkey !== null || userPubkeyHex !== null; + userPubkey = null; + userPubkeyHex = null; + myRepos = []; + contactPubkeys.clear(); + + // If user was logged in before, reload repos to hide private ones + if (wasLoggedIn) { + loadRepos().catch(err => console.warn('Failed to reload repos after logout:', err)); + loadLocalRepos().catch(err => console.warn('Failed to reload local repos after logout:', err)); + } + } else if (currentUser.userPubkey && currentUser.userPubkeyHex) { + // User is logged in according to store - sync local state + // Only update if different to avoid unnecessary reloads + const wasDifferent = userPubkey !== currentUser.userPubkey || userPubkeyHex !== currentUser.userPubkeyHex; + const wasLoggedOut = userPubkey === null && userPubkeyHex === null; + + if (wasDifferent) { + userPubkey = currentUser.userPubkey; + userPubkeyHex = currentUser.userPubkeyHex; + + // Reload everything when user logs in or pubkey changes + loadRepos().catch(err => console.warn('Failed to reload repos after login:', err)); + loadLocalRepos().catch(err => console.warn('Failed to reload local repos after login:', err)); + loadMyRepos().catch(err => console.warn('Failed to load my repos after store sync:', err)); + loadContacts().catch(err => console.warn('Failed to load contacts after store sync:', err)); + } + } + }); + async function loadUserAndContacts() { + // Check userStore first - if user is logged out, don't try to get pubkey + const currentUser = $userStore; + if (!currentUser.userPubkey || !currentUser.userPubkeyHex) { + userPubkey = null; + userPubkeyHex = null; + contactPubkeys.clear(); + return; + } + + // If userStore has user info, use it + if (currentUser.userPubkey && currentUser.userPubkeyHex) { + userPubkey = currentUser.userPubkey; + userPubkeyHex = currentUser.userPubkeyHex; + contactPubkeys.add(userPubkeyHex); // Include user's own repos + + // Still fetch contacts even if we have store data + await loadContacts(); + return; + } + + // Fallback: try to get from NIP-07 if store doesn't have it if (!isNIP07Available()) { return; } try { - userPubkey = await getPublicKeyWithNIP07(); - if (!userPubkey) return; + const pubkey = await getPublicKeyWithNIP07(); + if (!pubkey) return; + + userPubkey = pubkey; // Convert npub to hex for API calls // NIP-07 may return either npub or hex, so check format first @@ -82,39 +147,96 @@ } if (userPubkeyHex) { - // Fetch user's kind 3 contact list - const contactEvents = await nostrClient.fetchEvents([ - { - kinds: [KIND.CONTACT_LIST], - authors: [userPubkeyHex], - limit: 1 - } - ]); - - if (contactEvents.length > 0) { - const contactEvent = contactEvents[0]; - // Extract pubkeys from 'p' tags - for (const tag of contactEvent.tags) { - if (tag[0] === 'p' && tag[1]) { - let pubkey = tag[1]; - // Try to decode if it's an npub - try { - const decoded = nip19.decode(pubkey); - if (decoded.type === 'npub') { - pubkey = decoded.data as string; - } - } catch { - // Assume it's already a hex pubkey - } - if (pubkey) { - contactPubkeys.add(pubkey); + await loadContacts(); + } + } catch (err) { + console.warn('Failed to load user or contacts:', err); + } + } + + async function loadContacts() { + if (!userPubkeyHex) return; + + try { + // Fetch user's kind 3 contact list + const contactEvents = await nostrClient.fetchEvents([ + { + kinds: [KIND.CONTACT_LIST], + authors: [userPubkeyHex], + limit: 1 + } + ]); + + if (contactEvents.length > 0) { + const contactEvent = contactEvents[0]; + // Extract pubkeys from 'p' tags + for (const tag of contactEvent.tags) { + if (tag[0] === 'p' && tag[1]) { + let pubkey = tag[1]; + // Try to decode if it's an npub + try { + const decoded = nip19.decode(pubkey); + if (decoded.type === 'npub') { + pubkey = decoded.data as string; } + } catch { + // Assume it's already a hex pubkey + } + if (pubkey) { + contactPubkeys.add(pubkey); } } } } } catch (err) { - console.warn('Failed to load user or contacts:', err); + console.warn('Failed to load contacts:', err); + } + } + + async function loadMyRepos() { + if (!userPubkey || !userPubkeyHex) { + myRepos = []; + return; + } + + loadingMyRepos = true; + try { + // Fetch all repos where user is owner + const ownerRepos = await nostrClient.fetchEvents([ + { + kinds: [KIND.REPO_ANNOUNCEMENT], + authors: [userPubkeyHex], + limit: 100 + } + ]); + + const repos: Array<{ event: NostrEvent; npub: string; repoName: string }> = []; + + for (const event of ownerRepos) { + const dTag = event.tags.find(t => t[0] === 'd')?.[1]; + if (!dTag) continue; + + try { + const npub = nip19.npubEncode(event.pubkey); + repos.push({ + event, + npub, + repoName: dTag + }); + } catch (err) { + console.warn('Failed to encode npub for repo:', err); + } + } + + // Sort by created_at descending (newest first) + repos.sort((a, b) => b.event.created_at - a.event.created_at); + + myRepos = repos; + } catch (err) { + console.warn('Failed to load my repos:', err); + myRepos = []; + } finally { + loadingMyRepos = false; } } @@ -408,6 +530,26 @@
+ {#if userPubkey && myRepos.length > 0} +
+

My Repositories

+
+ {#each myRepos as item} + {@const repo = item.event} + {@const repoImage = getRepoImage(repo)} + + {#if repoImage} + {getRepoName(repo)} + {:else} +
📦
+ {/if} + {getRepoName(repo)} +
+ {/each} +
+
+ {/if} +

Repositories on {$page.data.gitDomain || 'localhost:6543'}

+ {#if userPubkey} + {#if canDelete} + + {:else} + + {/if} {/if} -
{#if repo} diff --git a/src/routes/repos/[npub]/[repo]/+page.svelte b/src/routes/repos/[npub]/[repo]/+page.svelte index 148dea7..e21e4af 100644 --- a/src/routes/repos/[npub]/[repo]/+page.svelte +++ b/src/routes/repos/[npub]/[repo]/+page.svelte @@ -13,6 +13,7 @@ import { BookmarksService } from '$lib/services/nostr/bookmarks-service.js'; import { KIND } from '$lib/types/nostr.js'; import { nip19 } from 'nostr-tools'; + import { userStore } from '$lib/stores/user-store.js'; // Get page data for OpenGraph metadata - use $derived to make it reactive const pageData = $derived($page.data as { @@ -50,9 +51,45 @@ let currentBranch = $state('main'); let commitMessage = $state(''); let userPubkey = $state(null); + let userPubkeyHex = $state(null); let showCommitDialog = $state(false); let activeTab = $state<'files' | 'history' | 'tags' | 'issues' | 'prs' | 'docs' | 'discussions'>('discussions'); + // Sync with userStore + $effect(() => { + const currentUser = $userStore; + const wasLoggedIn = userPubkey !== null || userPubkeyHex !== null; + + if (currentUser.userPubkey && currentUser.userPubkeyHex) { + const wasDifferent = userPubkey !== currentUser.userPubkey || userPubkeyHex !== currentUser.userPubkeyHex; + userPubkey = currentUser.userPubkey; + userPubkeyHex = currentUser.userPubkeyHex; + + // Reload data when user logs in or pubkey changes + if (wasDifferent) { + checkMaintainerStatus().catch(err => console.warn('Failed to reload maintainer status after login:', err)); + loadBookmarkStatus().catch(err => console.warn('Failed to reload bookmark status after login:', err)); + // Reload current tab data if needed + if (activeTab === 'files' && !loading) { + loadFiles().catch(err => console.warn('Failed to reload files after login:', err)); + } + } + } else { + userPubkey = null; + userPubkeyHex = null; + + // Reload data when user logs out to hide private content + if (wasLoggedIn) { + checkMaintainerStatus().catch(err => console.warn('Failed to reload maintainer status after logout:', err)); + loadBookmarkStatus().catch(err => console.warn('Failed to reload bookmark status after logout:', err)); + // If repo is private and user logged out, reload to trigger access check + if (!loading && activeTab === 'files') { + loadFiles().catch(err => console.warn('Failed to reload files after logout:', err)); + } + } + } + }); + // Navigation stack for directories let pathStack = $state([]); @@ -788,9 +825,35 @@ }); async function checkAuth() { + // Check userStore first + const currentUser = $userStore; + if (currentUser.userPubkey && currentUser.userPubkeyHex) { + userPubkey = currentUser.userPubkey; + userPubkeyHex = currentUser.userPubkeyHex; + // Recheck maintainer status and bookmark status after auth + await checkMaintainerStatus(); + await loadBookmarkStatus(); + return; + } + + // Fallback: try NIP-07 if store doesn't have it try { if (isNIP07Available()) { - userPubkey = await getPublicKeyWithNIP07(); + const pubkey = await getPublicKeyWithNIP07(); + userPubkey = pubkey; + // Convert to hex if needed + if (/^[0-9a-f]{64}$/i.test(pubkey)) { + userPubkeyHex = pubkey.toLowerCase(); + } else { + try { + const decoded = nip19.decode(pubkey); + if (decoded.type === 'npub') { + userPubkeyHex = decoded.data as string; + } + } catch { + userPubkeyHex = pubkey; + } + } // Recheck maintainer status and bookmark status after auth await checkMaintainerStatus(); await loadBookmarkStatus(); @@ -798,16 +861,43 @@ } catch (err) { console.log('NIP-07 not available or user not connected'); userPubkey = null; + userPubkeyHex = null; } } async function login() { + // Check userStore first + const currentUser = $userStore; + if (currentUser.userPubkey && currentUser.userPubkeyHex) { + userPubkey = currentUser.userPubkey; + userPubkeyHex = currentUser.userPubkeyHex; + // Re-check maintainer status and bookmark status after login + await checkMaintainerStatus(); + await loadBookmarkStatus(); + return; + } + + // Fallback: try NIP-07 try { if (!isNIP07Available()) { alert('NIP-07 extension not found. Please install a Nostr extension like Alby or nos2x.'); return; } - userPubkey = await getPublicKeyWithNIP07(); + const pubkey = await getPublicKeyWithNIP07(); + userPubkey = pubkey; + // Convert to hex if needed + if (/^[0-9a-f]{64}$/i.test(pubkey)) { + userPubkeyHex = pubkey.toLowerCase(); + } else { + try { + const decoded = nip19.decode(pubkey); + if (decoded.type === 'npub') { + userPubkeyHex = decoded.data as string; + } + } catch { + userPubkeyHex = pubkey; + } + } // Re-check maintainer status and bookmark status after login await checkMaintainerStatus(); await loadBookmarkStatus(); @@ -1624,14 +1714,18 @@ class="bookmark-button" class:bookmarked={isBookmarked} title={isBookmarked ? 'Remove bookmark' : 'Add bookmark'} + aria-label={isBookmarked ? 'Remove bookmark' : 'Add bookmark'} > - {loadingBookmark ? '...' : (isBookmarked ? '★ Bookmarked' : '☆ Bookmark')} + {loadingBookmark ? '...' : (isBookmarked ? '★' : '☆')} {#if isMaintainer} Settings {/if} {#if isMaintainer} - + {/if} Verified @@ -1738,7 +1832,10 @@ {/if} {#if userPubkey && isMaintainer} - + {/if} + {/if} {#if tags.length === 0} @@ -1845,7 +1945,10 @@

Issues

{#if userPubkey} - + {/if}
{#if loadingIssues} @@ -1879,7 +1982,10 @@

Pull Requests

{#if userPubkey} - + {/if}
{#if loadingPRs} @@ -1954,7 +2060,10 @@ ● Unsaved changes {/if} {#if isMaintainer} - {:else if userPubkey} @@ -2163,7 +2272,7 @@ - {#if showCreateFileDialog} + {#if showCreateFileDialog && userPubkey && isMaintainer}