Browse Source

more bug-fixes

add verification-file generator for off-server repos
main
Silberengel 4 weeks ago
parent
commit
7cca952ea8
  1. 138
      src/app.css
  2. 24
      src/hooks.server.ts
  3. 3
      src/lib/components/NavBar.svelte
  4. 54
      src/lib/services/nostr/bookmarks-service.ts
  5. 2
      src/lib/services/security/rate-limiter.ts
  6. 35
      src/lib/utils/api-auth.ts
  7. 143
      src/routes/+page.svelte
  8. 21
      src/routes/dashboard/+page.svelte
  9. 231
      src/routes/repos/+page.svelte
  10. 237
      src/routes/repos/[npub]/[repo]/+page.svelte
  11. 19
      src/routes/repos/[npub]/[repo]/settings/+page.svelte
  12. 32
      src/routes/search/+page.svelte
  13. 36
      src/routes/users/[npub]/+page.svelte
  14. 474
      src/routes/verify/+page.svelte

138
src/app.css

@ -23,6 +23,7 @@ @@ -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 @@ @@ -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 { @@ -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 { @@ -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 { @@ -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 {

24
src/hooks.server.ts

@ -53,7 +53,7 @@ export const handle: Handle = async ({ event, resolve }) => { @@ -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 }) => { @@ -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) {

3
src/lib/components/NavBar.svelte

@ -96,9 +96,10 @@ @@ -96,9 +96,10 @@
</a>
<nav class:mobile-open={mobileMenuOpen}>
<div class="nav-links">
<a href="/" class:active={isActive('/') && $page.url.pathname === '/'} onclick={closeMobileMenu}>Repositories</a>
<a href="/repos" class:active={isActive('/repos')} onclick={closeMobileMenu}>Repositories</a>
<a href="/search" class:active={isActive('/search')} onclick={closeMobileMenu}>Search</a>
<a href="/signup" class:active={isActive('/signup')} onclick={closeMobileMenu}>Register</a>
<a href="/verify" class:active={isActive('/verify')} onclick={closeMobileMenu}>Verify Repo</a>
<a href="/docs" class:active={isActive('/docs')} onclick={closeMobileMenu}>Docs</a>
</div>
</nav>

54
src/lib/services/nostr/bookmarks-service.ts

@ -77,44 +77,50 @@ export class BookmarksService { @@ -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<boolean> {
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<string>();
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<NostrEvent, 'id' | 'sig'> = {
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 { @@ -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<boolean> {
try {
@ -150,32 +156,32 @@ export class BookmarksService { @@ -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<NostrEvent, 'id' | 'sig'> = {
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

2
src/lib/services/security/rate-limiter.ts

@ -79,7 +79,7 @@ export class RateLimiter { @@ -79,7 +79,7 @@ export class RateLimiter {
const defaultLimits: Record<string, number> = {
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)
};

35
src/lib/utils/api-auth.ts

@ -10,6 +10,9 @@ import { maintainerService } from '../services/service-registry.js'; @@ -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( @@ -24,18 +27,40 @@ export async function requireRepoAccess(
requestContext: RequestContext,
operation?: string
): Promise<void> {
// 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 }
);
}
/**

143
src/routes/+page.svelte

@ -14,10 +14,14 @@ @@ -14,10 +14,14 @@
let checkingLevel = $state(false);
let levelMessage = $state<string | null>(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 @@ @@ -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 @@ @@ -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.`);
}
}

21
src/routes/dashboard/+page.svelte

@ -3,10 +3,21 @@ @@ -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<string | null>(null);
let userPubkeyHex = $state<string | null>(null);
// Sync with userStore
$effect(() => {
const currentUser = $userStore;
if (currentUser.userPubkeyHex) {
userPubkeyHex = currentUser.userPubkeyHex;
} else {
userPubkeyHex = null;
}
});
let issues = $state<ExternalIssue[]>([]);
let pullRequests = $state<ExternalPullRequest[]>([]);
let activeTab = $state<'issues' | 'prs' | 'all'>('all');
@ -32,6 +43,14 @@ @@ -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 @@ @@ -234,7 +253,7 @@
<span class="status-badge" class:open={item.state === 'open'} class:closed={item.state === 'closed'} class:merged={item.state === 'merged'}>
{item.state}
</span>
{#if isPR && item.merged_at}
{#if isPR && 'merged_at' in item && item.merged_at}
<span class="merged-indicator">✓ Merged</span>
{/if}
</div>

231
src/routes/repos/+page.svelte

@ -8,6 +8,7 @@ @@ -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<Array<{ event: NostrEvent; npub: string; repoName: string }>>([]);
@ -27,6 +28,10 @@ @@ -27,6 +28,10 @@
let userPubkeyHex = $state<string | null>(null);
let contactPubkeys = $state<Set<string>>(new Set());
let deletingRepo = $state<{ npub: string; repo: string } | null>(null);
// User's own repositories (where they are owner or maintainer)
let myRepos = $state<Array<{ event: NostrEvent; npub: string; repoName: string }>>([]);
let loadingMyRepos = $state(false);
import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js';
const forkCountService = new ForkCountService(DEFAULT_NOSTR_RELAYS);
@ -36,6 +41,7 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -408,6 +530,26 @@
<div class="container">
<main>
{#if userPubkey && myRepos.length > 0}
<div class="my-repos-section">
<h3>My Repositories</h3>
<div class="my-repos-badges">
{#each myRepos as item}
{@const repo = item.event}
{@const repoImage = getRepoImage(repo)}
<a href="/repos/{item.npub}/{item.repoName}" class="repo-badge">
{#if repoImage}
<img src={repoImage} alt={getRepoName(repo)} class="repo-badge-image" />
{:else}
<div class="repo-badge-icon">📦</div>
{/if}
<span class="repo-badge-name">{getRepoName(repo)}</span>
</a>
{/each}
</div>
</div>
{/if}
<div class="repos-header">
<h2>Repositories on {$page.data.gitDomain || 'localhost:6543'}</h2>
<button onclick={loadRepos} disabled={loading}>
@ -541,21 +683,24 @@ @@ -541,21 +683,24 @@
<a href="/repos/{item.npub}/{item.repoName}" class="register-button">
View & Edit →
</a>
{#if canDelete}
<button
class="delete-button"
onclick={() => deleteLocalRepo(item.npub, item.repoName)}
disabled={deletingRepo?.npub === item.npub && deletingRepo?.repo === item.repoName}
>
{deletingRepo?.npub === item.npub && deletingRepo?.repo === item.repoName ? 'Deleting...' : 'Delete'}
</button>
{#if userPubkey}
{#if canDelete}
<button
class="delete-button"
onclick={() => deleteLocalRepo(item.npub, item.repoName)}
disabled={deletingRepo?.npub === item.npub && deletingRepo?.repo === item.repoName}
>
{deletingRepo?.npub === item.npub && deletingRepo?.repo === item.repoName ? 'Deleting...' : 'Delete'}
</button>
{:else}
<button
class="register-button"
onclick={() => registerRepo(item.npub, item.repoName)}
>
Register
</button>
{/if}
{/if}
<button
class="register-button"
onclick={() => registerRepo(item.npub, item.repoName)}
>
Register
</button>
</div>
</div>
{#if repo}

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

@ -13,6 +13,7 @@ @@ -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 @@ @@ -50,9 +51,45 @@
let currentBranch = $state('main');
let commitMessage = $state('');
let userPubkey = $state<string | null>(null);
let userPubkeyHex = $state<string | null>(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<string[]>([]);
@ -788,9 +825,35 @@ @@ -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 @@ @@ -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 @@ @@ -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 ? '★' : '☆')}
</button>
{#if isMaintainer}
<a href={`/repos/${npub}/${repo}/settings`} class="settings-button">Settings</a>
{/if}
{#if isMaintainer}
<button onclick={() => showCreateBranchDialog = true} class="create-branch-button">+ New Branch</button>
<button onclick={() => {
if (!userPubkey || !isMaintainer) return;
showCreateBranchDialog = true;
}} class="create-branch-button">+ New Branch</button>
{/if}
<span class="auth-status">
<img src="/icons/check-circle.svg" alt="Verified" class="icon-inline" />
@ -1738,7 +1832,10 @@ @@ -1738,7 +1832,10 @@
<button onclick={handleBack} class="back-button">← Back</button>
{/if}
{#if userPubkey && isMaintainer}
<button onclick={() => showCreateFileDialog = true} class="create-file-button">+ New File</button>
<button onclick={() => {
if (!userPubkey || !isMaintainer) return;
showCreateFileDialog = true;
}} class="create-file-button">+ New File</button>
{/if}
<button
onclick={() => showFileListOnMobile = !showFileListOnMobile}
@ -1818,7 +1915,10 @@ @@ -1818,7 +1915,10 @@
<div class="tags-header">
<h2>Tags</h2>
{#if userPubkey && isMaintainer}
<button onclick={() => showCreateTagDialog = true} class="create-tag-button">+ New Tag</button>
<button onclick={() => {
if (!userPubkey || !isMaintainer) return;
showCreateTagDialog = true;
}} class="create-tag-button">+ New Tag</button>
{/if}
</div>
{#if tags.length === 0}
@ -1845,7 +1945,10 @@ @@ -1845,7 +1945,10 @@
<div class="issues-header">
<h2>Issues</h2>
{#if userPubkey}
<button onclick={() => showCreateIssueDialog = true} class="create-issue-button">+ New Issue</button>
<button onclick={() => {
if (!userPubkey) return;
showCreateIssueDialog = true;
}} class="create-issue-button">+ New Issue</button>
{/if}
</div>
{#if loadingIssues}
@ -1879,7 +1982,10 @@ @@ -1879,7 +1982,10 @@
<div class="prs-header">
<h2>Pull Requests</h2>
{#if userPubkey}
<button onclick={() => showCreatePRDialog = true} class="create-pr-button">+ New PR</button>
<button onclick={() => {
if (!userPubkey) return;
showCreatePRDialog = true;
}} class="create-pr-button">+ New PR</button>
{/if}
</div>
{#if loadingPRs}
@ -1954,7 +2060,10 @@ @@ -1954,7 +2060,10 @@
<span class="unsaved-indicator">● Unsaved changes</span>
{/if}
{#if isMaintainer}
<button onclick={() => showCommitDialog = true} disabled={!hasChanges || saving} class="save-button">
<button onclick={() => {
if (!userPubkey || !isMaintainer) return;
showCommitDialog = true;
}} disabled={!hasChanges || saving} class="save-button">
{saving ? 'Saving...' : 'Save'}
</button>
{:else if userPubkey}
@ -2163,7 +2272,7 @@ @@ -2163,7 +2272,7 @@
</main>
<!-- Create File Dialog -->
{#if showCreateFileDialog}
{#if showCreateFileDialog && userPubkey && isMaintainer}
<div
class="modal-overlay"
role="dialog"
@ -2200,7 +2309,7 @@ @@ -2200,7 +2309,7 @@
{/if}
<!-- Create Branch Dialog -->
{#if showCreateBranchDialog}
{#if showCreateBranchDialog && userPubkey && isMaintainer}
<div
class="modal-overlay"
role="dialog"
@ -2241,7 +2350,7 @@ @@ -2241,7 +2350,7 @@
{/if}
<!-- Create Tag Dialog -->
{#if showCreateTagDialog}
{#if showCreateTagDialog && userPubkey && isMaintainer}
<div
class="modal-overlay"
role="dialog"
@ -2282,7 +2391,7 @@ @@ -2282,7 +2391,7 @@
{/if}
<!-- Create Issue Dialog -->
{#if showCreateIssueDialog}
{#if showCreateIssueDialog && userPubkey}
<div
class="modal-overlay"
role="dialog"
@ -2319,7 +2428,7 @@ @@ -2319,7 +2428,7 @@
{/if}
<!-- Create PR Dialog -->
{#if showCreatePRDialog}
{#if showCreatePRDialog && userPubkey}
<div
class="modal-overlay"
role="dialog"
@ -2364,7 +2473,7 @@ @@ -2364,7 +2473,7 @@
{/if}
<!-- Commit Dialog -->
{#if showCommitDialog}
{#if showCommitDialog && userPubkey && isMaintainer}
<div
class="modal-overlay"
role="dialog"
@ -2446,21 +2555,29 @@ @@ -2446,21 +2555,29 @@
}
.bookmark-button {
padding: 0.5rem 1rem;
font-size: 0.875rem;
padding: 0.5rem;
font-size: 1.25rem;
font-weight: 500;
border: 1px solid var(--border-color);
border-radius: 0.375rem;
background: var(--bg-primary);
color: var(--text-primary);
color: var(--text-muted);
cursor: pointer;
transition: all 0.2s ease;
font-family: 'IBM Plex Serif', serif;
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 2.5rem;
min-height: 2.5rem;
line-height: 1;
}
.bookmark-button:hover:not(:disabled) {
background: var(--bg-secondary);
border-color: var(--accent);
color: var(--accent);
transform: scale(1.1);
}
.bookmark-button:disabled {
@ -2470,12 +2587,18 @@ @@ -2470,12 +2587,18 @@
.bookmark-button.bookmarked {
background: var(--accent);
color: var(--accent-text, white);
color: var(--accent-text, #ffffff);
border-color: var(--accent);
}
[data-theme="dark"] .bookmark-button.bookmarked {
background: var(--accent);
color: #ffffff;
}
.bookmark-button.bookmarked:hover:not(:disabled) {
opacity: 0.9;
transform: scale(1.1);
}
.repo-banner {
@ -2753,20 +2876,32 @@ @@ -2753,20 +2876,32 @@
.fork-badge {
padding: 0.25rem 0.5rem;
background: var(--accent-light);
color: var(--accent);
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(--accent);
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;
}
.repo-meta-info {
@ -2812,10 +2947,18 @@ @@ -2812,10 +2947,18 @@
.topic-tag {
padding: 0.25rem 0.5rem;
background: var(--accent-light);
color: var(--accent);
background: var(--accent);
color: var(--accent-text, #ffffff);
border-radius: 0.25rem;
font-size: 0.75rem;
font-weight: 500;
border: 1px solid transparent;
}
/* Ensure high contrast in both themes */
[data-theme="dark"] .topic-tag {
background: var(--accent);
color: #ffffff;
}
.repo-website {
@ -2919,35 +3062,32 @@ @@ -2919,35 +3062,32 @@
}
.contributor-badge.owner {
/* High contrast colors that work in both light and dark modes */
/* High contrast colors for light mode */
background: #4a5568;
color: #ffffff;
border-color: #2d3748;
}
/* Dark mode adjustments for owner badge */
@media (prefers-color-scheme: dark) {
.contributor-badge.owner {
background: #718096;
color: #ffffff;
border-color: #a0aec0;
}
[data-theme="dark"] .contributor-badge.owner {
background: #718096;
color: #ffffff;
border-color: #a0aec0;
}
.contributor-badge.maintainer {
/* High contrast colors that work in both light and dark modes */
/* High contrast colors for light mode */
background: #22543d;
color: #ffffff;
border-color: #1a202c;
}
/* Dark mode adjustments for maintainer badge */
@media (prefers-color-scheme: dark) {
.contributor-badge.maintainer {
background: #48bb78;
color: #1a202c;
border-color: #68d391;
}
[data-theme="dark"] .contributor-badge.maintainer {
background: #48bb78;
color: #1a202c;
border-color: #68d391;
font-weight: 700;
}
header h1 {
@ -3401,7 +3541,7 @@ @@ -3401,7 +3541,7 @@
}
.commit-item, .tag-item {
border-bottom: 1px solid #e5e7eb;
border-bottom: 1px solid var(--border-color);
}
.commit-button {
@ -3419,7 +3559,13 @@ @@ -3419,7 +3559,13 @@
}
.commit-item.selected .commit-button {
background: var(--accent-light);
background: var(--accent);
color: var(--accent-text, #ffffff);
}
[data-theme="dark"] .commit-item.selected .commit-button {
background: var(--accent);
color: #ffffff;
}
.commit-hash {
@ -3746,8 +3892,15 @@ @@ -3746,8 +3892,15 @@
}
.issue-status.open, .pr-status.open {
background: var(--accent-light);
color: var(--accent);
background: var(--accent);
color: var(--accent-text, #ffffff);
font-weight: 600;
}
[data-theme="dark"] .issue-status.open,
[data-theme="dark"] .pr-status.open {
background: var(--accent);
color: #ffffff;
}
.issue-status.closed, .pr-status.closed {

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

@ -3,6 +3,7 @@ @@ -3,6 +3,7 @@
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { getPublicKeyWithNIP07 } from '$lib/services/nostr/nip07-signer.js';
import { userStore } from '$lib/stores/user-store.js';
const npub = ($page.params as { npub?: string; repo?: string }).npub || '';
const repo = ($page.params as { npub?: string; repo?: string }).repo || '';
@ -11,6 +12,16 @@ @@ -11,6 +12,16 @@
let saving = $state(false);
let error = $state<string | null>(null);
let userPubkey = $state<string | null>(null);
// Sync with userStore
$effect(() => {
const currentUser = $userStore;
if (currentUser.userPubkey) {
userPubkey = currentUser.userPubkey;
} else {
userPubkey = null;
}
});
let name = $state('');
let description = $state('');
@ -25,6 +36,14 @@ @@ -25,6 +36,14 @@
});
async function checkAuth() {
// Check userStore first
const currentUser = $userStore;
if (currentUser.userPubkey) {
userPubkey = currentUser.userPubkey;
return;
}
// Fallback: try NIP-07 if store doesn't have it
try {
if (typeof window !== 'undefined' && window.nostr) {
userPubkey = await getPublicKeyWithNIP07();

32
src/routes/search/+page.svelte

@ -5,11 +5,35 @@ @@ -5,11 +5,35 @@
import UserBadge from '$lib/components/UserBadge.svelte';
import { getPublicKeyWithNIP07, isNIP07Available } from '$lib/services/nostr/nip07-signer.js';
import { nip19 } from 'nostr-tools';
import { userStore } from '$lib/stores/user-store.js';
let query = $state('');
let searchType = $state<'repos' | 'code' | 'all'>('repos');
let loading = $state(false);
let userPubkeyHex = $state<string | null>(null);
// Sync with userStore
$effect(() => {
const currentUser = $userStore;
const wasLoggedIn = userPubkeyHex !== null;
if (currentUser.userPubkeyHex) {
const wasDifferent = userPubkeyHex !== currentUser.userPubkeyHex;
userPubkeyHex = currentUser.userPubkeyHex;
// If user just logged in and we have search results, reload to show private repos
if (wasDifferent && results && query.trim()) {
performSearch().catch(err => console.warn('Failed to reload search after login:', err));
}
} else {
userPubkeyHex = null;
// If user just logged out and we have search results, reload to hide private repos
if (wasLoggedIn && results && query.trim()) {
performSearch().catch(err => console.warn('Failed to reload search after logout:', err));
}
}
});
let results = $state<{
repos: Array<{ id: string; name: string; description: string; owner: string; npub: string }>;
code: Array<{ repo: string; npub: string; file: string; matches: number }>;
@ -22,6 +46,14 @@ @@ -22,6 +46,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;
}

36
src/routes/users/[npub]/+page.svelte

@ -13,6 +13,7 @@ @@ -13,6 +13,7 @@
import { getUserRelays } from '$lib/services/nostr/user-relays.js';
import UserBadge from '$lib/components/UserBadge.svelte';
import { forwardEventIfEnabled } from '$lib/services/messaging/event-forwarder.js';
import { userStore } from '$lib/stores/user-store.js';
const npub = ($page.params as { npub?: string }).npub || '';
@ -20,6 +21,31 @@ @@ -20,6 +21,31 @@
let error = $state<string | null>(null);
let userPubkey = $state<string | null>(null);
let viewerPubkeyHex = $state<string | null>(null);
// Sync with userStore
$effect(() => {
const currentUser = $userStore;
const wasLoggedIn = userPubkey !== null || viewerPubkeyHex !== null;
if (currentUser.userPubkey && currentUser.userPubkeyHex) {
const wasDifferent = userPubkey !== currentUser.userPubkey || viewerPubkeyHex !== currentUser.userPubkeyHex;
userPubkey = currentUser.userPubkey;
viewerPubkeyHex = currentUser.userPubkeyHex;
// Reload profile and repos when user logs in or pubkey changes
if (wasDifferent) {
loadUserProfile().catch(err => console.warn('Failed to reload user profile after login:', err));
}
} else {
userPubkey = null;
viewerPubkeyHex = null;
// Reload profile when user logs out to hide private repos
if (wasLoggedIn) {
loadUserProfile().catch(err => console.warn('Failed to reload user profile after logout:', err));
}
}
});
let repos = $state<NostrEvent[]>([]);
let userProfile = $state<{ name?: string; about?: string; picture?: string } | null>(null);
@ -48,12 +74,22 @@ @@ -48,12 +74,22 @@
});
async function loadViewerPubkey() {
// Check userStore first
const currentUser = $userStore;
if (currentUser.userPubkey && currentUser.userPubkeyHex) {
userPubkey = currentUser.userPubkey;
viewerPubkeyHex = currentUser.userPubkeyHex;
return;
}
// Fallback: try NIP-07 if store doesn't have it
if (!isNIP07Available()) {
return;
}
try {
const viewerPubkey = await getPublicKeyWithNIP07();
userPubkey = viewerPubkey;
// Convert npub to hex for API calls
try {
const decoded = nip19.decode(viewerPubkey);

474
src/routes/verify/+page.svelte

@ -0,0 +1,474 @@ @@ -0,0 +1,474 @@
<script lang="ts">
import { onMount } from 'svelte';
import { NostrClient } from '$lib/services/nostr/nostr-client.js';
import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js';
import { KIND } from '$lib/types/nostr.js';
import { generateVerificationFile, VERIFICATION_FILE_PATH } from '$lib/services/nostr/repo-verification.js';
import { nip19 } from 'nostr-tools';
import type { NostrEvent } from '$lib/types/nostr.js';
let npub = $state('');
let repoName = $state('');
let loading = $state(false);
let error = $state<string | null>(null);
let verificationContent = $state<string | null>(null);
let announcementEvent = $state<NostrEvent | null>(null);
const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS);
async function generateVerification() {
if (!npub.trim() || !repoName.trim()) {
error = 'Please enter both npub and repository name';
return;
}
loading = true;
error = null;
verificationContent = null;
announcementEvent = null;
try {
// Decode npub to get pubkey
let ownerPubkey: string;
try {
const decoded = nip19.decode(npub.trim());
if (decoded.type !== 'npub') {
error = 'Invalid npub format';
loading = false;
return;
}
ownerPubkey = decoded.data as string;
} catch (err) {
error = `Failed to decode npub: ${err instanceof Error ? err.message : String(err)}`;
loading = false;
return;
}
// Fetch repository announcement from Nostr
const events = await nostrClient.fetchEvents([
{
kinds: [KIND.REPO_ANNOUNCEMENT],
authors: [ownerPubkey],
'#d': [repoName.trim()],
limit: 1
}
]);
if (events.length === 0) {
error = `No repository announcement found for ${npub}/${repoName.trim()}. Make sure you've published the repository announcement to Nostr.`;
loading = false;
return;
}
const announcement = events[0] as NostrEvent;
announcementEvent = announcement;
// Generate verification file content
verificationContent = generateVerificationFile(announcement, ownerPubkey);
} catch (err) {
error = `Failed to generate verification file: ${err instanceof Error ? err.message : String(err)}`;
console.error('Error generating verification:', err);
} finally {
loading = false;
}
}
function copyToClipboard() {
if (!verificationContent) return;
navigator.clipboard.writeText(verificationContent).then(() => {
alert('Verification file content copied to clipboard!');
}).catch((err) => {
console.error('Failed to copy:', err);
alert('Failed to copy to clipboard. Please select and copy manually.');
});
}
function downloadFile() {
if (!verificationContent) return;
const blob = new Blob([verificationContent], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = VERIFICATION_FILE_PATH;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
</script>
<svelte:head>
<title>Generate Repository Verification File - GitRepublic</title>
</svelte:head>
<div class="verify-container">
<div class="verify-content">
<h1>Generate Repository Verification File</h1>
<p class="description">
This tool helps you generate a verification file for a repository that isn't saved to the server yet.
The verification file proves ownership by linking your Nostr repository announcement to your git repository.
</p>
<div class="form-section">
<h2>Repository Information</h2>
<form onsubmit={(e) => { e.preventDefault(); generateVerification(); }}>
<div class="form-group">
<label for="npub">Repository Owner (npub):</label>
<input
id="npub"
type="text"
bind:value={npub}
placeholder="npub1..."
disabled={loading}
required
/>
<small>Your Nostr public key in npub format</small>
</div>
<div class="form-group">
<label for="repo">Repository Name:</label>
<input
id="repo"
type="text"
bind:value={repoName}
placeholder="my-repo"
disabled={loading}
required
/>
<small>The repository identifier (d-tag) from your announcement</small>
</div>
<button type="submit" disabled={loading || !npub.trim() || !repoName.trim()} class="generate-button">
{loading ? 'Generating...' : 'Generate Verification File'}
</button>
</form>
</div>
{#if error}
<div class="error-message">
<strong>Error:</strong> {error}
</div>
{/if}
{#if verificationContent}
<div class="verification-section">
<h2>Verification File Generated</h2>
<p class="instructions">
<strong>Next steps:</strong>
</p>
<ol class="steps">
<li>Copy the verification file content below</li>
<li>Create a file named <code>{VERIFICATION_FILE_PATH}</code> in the root of your git repository</li>
<li>Paste the content into that file</li>
<li>Commit and push the file to your repository</li>
<li>Once the repository is saved to the server, verification will be automatic</li>
</ol>
{#if announcementEvent}
<div class="announcement-info">
<h3>Announcement Details</h3>
<ul>
<li><strong>Event ID:</strong> <code>{announcementEvent.id}</code></li>
<li><strong>Created:</strong> {new Date(announcementEvent.created_at * 1000).toLocaleString()}</li>
</ul>
</div>
{/if}
<div class="verification-file">
<div class="file-header">
<span class="filename">{VERIFICATION_FILE_PATH}</span>
<div class="file-actions">
<button onclick={copyToClipboard} class="copy-button">Copy</button>
<button onclick={downloadFile} class="download-button">Download</button>
</div>
</div>
<pre class="file-content"><code>{verificationContent}</code></pre>
</div>
</div>
{/if}
<div class="info-section">
<h2>About Verification</h2>
<p>
Repository verification proves that you own both the Nostr repository announcement and the git repository.
There are two methods:
</p>
<ul>
<li><strong>Self-transfer event</strong> (preferred): A Nostr event that transfers ownership to yourself, proving you control the private key.</li>
<li><strong>Verification file</strong> (this method): A file in your repository containing the announcement event ID and signature.</li>
</ul>
<p>
The verification file method is useful when your repository isn't on the server yet, as you can generate
the file and commit it to your repository before the server fetches it.
</p>
</div>
</div>
</div>
<style>
.verify-container {
max-width: 900px;
margin: 2rem auto;
padding: 0 1rem;
}
.verify-content {
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 0.5rem;
padding: 2rem;
}
h1 {
margin-top: 0;
color: var(--text-primary);
}
.description {
color: var(--text-secondary);
margin-bottom: 2rem;
line-height: 1.6;
}
.form-section {
margin-bottom: 2rem;
}
.form-section h2 {
margin-top: 0;
margin-bottom: 1rem;
color: var(--text-primary);
font-size: 1.25rem;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
color: var(--text-primary);
font-weight: 500;
}
.form-group input {
width: 100%;
padding: 0.75rem;
border: 1px solid var(--border-color);
border-radius: 0.375rem;
background: var(--bg-secondary);
color: var(--text-primary);
font-size: 1rem;
font-family: monospace;
}
.form-group input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.form-group input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(0, 0, 0, 0.1);
}
.form-group small {
display: block;
margin-top: 0.25rem;
color: var(--text-secondary);
font-size: 0.875rem;
}
.generate-button {
padding: 0.75rem 1.5rem;
background: var(--accent);
color: var(--accent-text, white);
border: none;
border-radius: 0.375rem;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.generate-button:hover:not(:disabled) {
opacity: 0.9;
transform: translateY(-1px);
}
.generate-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.error-message {
padding: 1rem;
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: 0.375rem;
color: #dc2626;
margin-bottom: 2rem;
}
.verification-section {
margin-top: 2rem;
padding-top: 2rem;
border-top: 1px solid var(--border-color);
}
.verification-section h2 {
margin-top: 0;
color: var(--text-primary);
}
.instructions {
color: var(--text-primary);
margin-bottom: 1rem;
}
.steps {
color: var(--text-primary);
margin-bottom: 2rem;
padding-left: 1.5rem;
}
.steps li {
margin-bottom: 0.5rem;
line-height: 1.6;
}
.steps code {
background: var(--bg-secondary);
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
font-family: monospace;
color: var(--accent);
}
.announcement-info {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 0.375rem;
padding: 1rem;
margin-bottom: 1.5rem;
}
.announcement-info h3 {
margin-top: 0;
margin-bottom: 0.75rem;
color: var(--text-primary);
font-size: 1rem;
}
.announcement-info ul {
margin: 0;
padding-left: 1.5rem;
color: var(--text-primary);
}
.announcement-info li {
margin-bottom: 0.5rem;
}
.announcement-info code {
background: var(--bg-primary);
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
font-family: monospace;
font-size: 0.875rem;
word-break: break-all;
}
.verification-file {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 0.375rem;
overflow: hidden;
}
.file-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
background: var(--bg-tertiary);
border-bottom: 1px solid var(--border-color);
}
.filename {
font-family: monospace;
font-weight: 500;
color: var(--text-primary);
}
.file-actions {
display: flex;
gap: 0.5rem;
}
.copy-button,
.download-button {
padding: 0.375rem 0.75rem;
background: var(--bg-primary);
color: var(--text-primary);
border: 1px solid var(--border-color);
border-radius: 0.25rem;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.2s ease;
}
.copy-button:hover,
.download-button:hover {
background: var(--bg-secondary);
border-color: var(--accent);
}
.file-content {
margin: 0;
padding: 1rem;
overflow-x: auto;
background: var(--bg-primary);
}
.file-content code {
font-family: 'Courier New', monospace;
font-size: 0.875rem;
color: var(--text-primary);
white-space: pre;
}
.info-section {
margin-top: 3rem;
padding-top: 2rem;
border-top: 1px solid var(--border-color);
}
.info-section h2 {
margin-top: 0;
color: var(--text-primary);
}
.info-section p,
.info-section ul {
color: var(--text-secondary);
line-height: 1.6;
}
.info-section ul {
margin-top: 0.5rem;
padding-left: 1.5rem;
}
.info-section li {
margin-bottom: 0.5rem;
}
.info-section strong {
color: var(--text-primary);
}
</style>
Loading…
Cancel
Save