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. 22
      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. 137
      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 @@
--accent: var(--royal-plum); --accent: var(--royal-plum);
--accent-hover: #6a1f4d; --accent-hover: #6a1f4d;
--accent-light: var(--lilac); --accent-light: var(--lilac);
--accent-text: #ffffff; /* White text for accent backgrounds */
--link-color: #5a0d4f; /* Darker plum for better contrast on light bg */ --link-color: #5a0d4f; /* Darker plum for better contrast on light bg */
--link-hover: #4a0a3f; /* Even darker for hover */ --link-hover: #4a0a3f; /* Even darker for hover */
--card-bg: #ffffff; --card-bg: #ffffff;
@ -62,6 +63,7 @@
--accent: var(--royal-plum); --accent: var(--royal-plum);
--accent-hover: #b84a8a; --accent-hover: #b84a8a;
--accent-light: var(--lilac); --accent-light: var(--lilac);
--accent-text: #ffffff; /* White text for accent backgrounds */
--link-color: #d84ab8; /* Brighter plum for better contrast on dark bg */ --link-color: #d84ab8; /* Brighter plum for better contrast on dark bg */
--link-hover: #e85ac8; /* Even brighter for hover */ --link-hover: #e85ac8; /* Even brighter for hover */
--card-bg: var(--lavender-blush); --card-bg: var(--lavender-blush);
@ -750,6 +752,94 @@ input:disabled, textarea:disabled, select:disabled {
} }
/* Cards */ /* 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 { .card, .repo-card {
border: 1px solid var(--card-border); border: 1px solid var(--card-border);
border-radius: 0.5rem; border-radius: 0.5rem;
@ -783,7 +873,7 @@ input:disabled, textarea:disabled, select:disabled {
.repo-card-image, .repo-image { .repo-card-image, .repo-image {
width: 64px; width: 64px;
height: 64px; height: 64px;
border-radius: 8px; border-radius: 50%;
object-fit: cover; object-fit: cover;
flex-shrink: 0; flex-shrink: 0;
} }
@ -862,22 +952,62 @@ input:disabled, textarea:disabled, select:disabled {
color: var(--success-text); 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 { .fork-badge {
padding: 0.25rem 0.5rem; padding: 0.25rem 0.5rem;
background: var(--accent-light); background: var(--accent);
color: var(--text-primary); /* Better contrast */ color: var(--accent-text, #ffffff);
border-radius: 4px; border-radius: 4px;
font-size: 0.85rem; font-size: 0.85rem;
margin-left: 0.5rem; margin-left: 0.5rem;
font-weight: 500;
}
[data-theme="dark"] .fork-badge {
background: var(--accent);
color: #ffffff;
} }
.fork-badge a { .fork-badge a {
color: var(--link-color); /* Use link color for better visibility */ color: var(--accent-text, #ffffff);
text-decoration: none; text-decoration: none;
font-weight: 500;
}
[data-theme="dark"] .fork-badge a {
color: #ffffff;
} }
.fork-badge a:hover { .fork-badge a:hover {
text-decoration: underline; text-decoration: underline;
opacity: 0.9;
} }
.verification-status { .verification-status {

22
src/hooks.server.ts

@ -66,8 +66,26 @@ export const handle: Handle = async ({ event, resolve }) => {
const rateLimitIdentifier = userPubkey ? `user:${userPubkey}` : `ip:${clientIp}`; const rateLimitIdentifier = userPubkey ? `user:${userPubkey}` : `ip:${clientIp}`;
const isAnonymous = !userPubkey; const isAnonymous = !userPubkey;
// Check rate limit (skip for Vite internal requests) // Skip rate limiting for read-only GET requests to repo endpoints (page loads)
const rateLimitResult = isViteInternalRequest // 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() } ? { allowed: true, resetAt: Date.now() }
: rateLimiter.check(rateLimitType, rateLimitIdentifier, isAnonymous); : rateLimiter.check(rateLimitType, rateLimitIdentifier, isAnonymous);
if (!rateLimitResult.allowed) { if (!rateLimitResult.allowed) {

3
src/lib/components/NavBar.svelte

@ -96,9 +96,10 @@
</a> </a>
<nav class:mobile-open={mobileMenuOpen}> <nav class:mobile-open={mobileMenuOpen}>
<div class="nav-links"> <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="/search" class:active={isActive('/search')} onclick={closeMobileMenu}>Search</a>
<a href="/signup" class:active={isActive('/signup')} onclick={closeMobileMenu}>Register</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> <a href="/docs" class:active={isActive('/docs')} onclick={closeMobileMenu}>Docs</a>
</div> </div>
</nav> </nav>

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

@ -77,44 +77,50 @@ export class BookmarksService {
/** /**
* Add a repo to bookmarks * 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> { async addBookmark(pubkey: string, repoAddress: string, relays: string[]): Promise<boolean> {
try { try {
// Get existing bookmarks // Get existing bookmarks
const existingBookmarks = await this.getBookmarks(pubkey); const existingBookmarks = await this.getBookmarks(pubkey);
// Extract existing a-tags (for repos) // Preserve ALL existing tags (not just a-tags)
const existingATags: string[] = []; const existingTags: string[][] = [];
const seenAddresses = new Set<string>();
if (existingBookmarks) { if (existingBookmarks) {
for (const tag of existingBookmarks.tags) { for (const tag of existingBookmarks.tags) {
// For 'a' tags, deduplicate repo addresses
if (tag[0] === 'a' && tag[1]) { if (tag[0] === 'a' && tag[1]) {
// Only include repo announcement addresses
if (tag[1].startsWith(`${KIND.REPO_ANNOUNCEMENT}:`)) { 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 // Check if already bookmarked
if (existingATags.includes(repoAddress)) { if (seenAddresses.has(repoAddress)) {
logger.debug({ pubkey: truncatePubkey(pubkey), repoAddress }, 'Repo already bookmarked'); logger.debug({ pubkey: truncatePubkey(pubkey), repoAddress }, 'Repo already bookmarked');
return true; return true;
} }
// Add new bookmark to the end (chronological order per NIP-51) // Add new bookmark to the end (chronological order per NIP-51)
existingATags.push(repoAddress); existingTags.push(['a', repoAddress]);
// Create new bookmark event
const tags: string[][] = existingATags.map(addr => ['a', addr]);
const eventTemplate: Omit<NostrEvent, 'id' | 'sig'> = { const eventTemplate: Omit<NostrEvent, 'id' | 'sig'> = {
kind: KIND.BOOKMARKS, kind: KIND.BOOKMARKS,
pubkey, pubkey,
created_at: Math.floor(Date.now() / 1000), created_at: Math.floor(Date.now() / 1000),
content: '', // Public bookmarks use tags, not encrypted content content: '', // Public bookmarks use tags, not encrypted content
tags tags: existingTags
}; };
// Sign with NIP-07 // Sign with NIP-07
@ -138,7 +144,7 @@ export class BookmarksService {
/** /**
* Remove a repo from bookmarks * 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> { async removeBookmark(pubkey: string, repoAddress: string, relays: string[]): Promise<boolean> {
try { try {
@ -150,32 +156,32 @@ export class BookmarksService {
return true; return true;
} }
// Extract existing a-tags (for repos), excluding the one to remove // Preserve ALL existing tags except the one to remove
const existingATags: string[] = []; const existingTags: string[][] = [];
let found = false;
for (const tag of existingBookmarks.tags) { for (const tag of existingBookmarks.tags) {
if (tag[0] === 'a' && tag[1]) { // Skip the tag that matches the repo address to remove
// Only include repo announcement addresses, and exclude the one to remove if (tag[0] === 'a' && tag[1] === repoAddress) {
if (tag[1].startsWith(`${KIND.REPO_ANNOUNCEMENT}:`) && tag[1] !== repoAddress) { found = true;
existingATags.push(tag[1]); continue; // Skip this tag
}
} }
// Preserve all other tags
existingTags.push([...tag]);
} }
// Check if it was bookmarked // 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'); logger.debug({ pubkey: truncatePubkey(pubkey), repoAddress }, 'Repo was not bookmarked');
return true; return true;
} }
// Create new bookmark event without the removed bookmark
const tags: string[][] = existingATags.map(addr => ['a', addr]);
const eventTemplate: Omit<NostrEvent, 'id' | 'sig'> = { const eventTemplate: Omit<NostrEvent, 'id' | 'sig'> = {
kind: KIND.BOOKMARKS, kind: KIND.BOOKMARKS,
pubkey, pubkey,
created_at: Math.floor(Date.now() / 1000), created_at: Math.floor(Date.now() / 1000),
content: '', // Public bookmarks use tags, not encrypted content content: '', // Public bookmarks use tags, not encrypted content
tags tags: existingTags
}; };
// Sign with NIP-07 // Sign with NIP-07

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

@ -79,7 +79,7 @@ export class RateLimiter {
const defaultLimits: Record<string, number> = { const defaultLimits: Record<string, number> = {
git: 60, // Git operations: 60/min (authenticated), 30/min (anonymous) 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) file: 30, // File operations: 30/min (authenticated), 15/min (anonymous)
search: 20 // Search requests: 20/min (authenticated), 10/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';
import { fileManager } from '../services/service-registry.js'; import { fileManager } from '../services/service-registry.js';
import type { RepoContext, RequestContext, RepoRequestContext } from './api-context.js'; import type { RepoContext, RequestContext, RepoRequestContext } from './api-context.js';
import { handleValidationError, handleAuthError, handleAuthorizationError, handleNotFoundError } from './error-handler.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) * Check if user has access to a repository (privacy check)
@ -24,18 +27,40 @@ export async function requireRepoAccess(
requestContext: RequestContext, requestContext: RequestContext,
operation?: string operation?: string
): Promise<void> { ): Promise<void> {
// First check if user is owner/maintainer (or repo is public)
const canView = await maintainerService.canView( const canView = await maintainerService.canView(
requestContext.userPubkeyHex || null, requestContext.userPubkeyHex || null,
repoContext.repoOwnerPubkey, repoContext.repoOwnerPubkey,
repoContext.repo repoContext.repo
); );
if (!canView) { if (canView) {
throw handleAuthorizationError( return; // User is owner/maintainer or repo is public, allow access
'This repository is private. Only owners and maintainers can view it.', }
{ operation, npub: repoContext.npub, repo: repoContext.repo }
); // 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 }
);
} }
/** /**

137
src/routes/+page.svelte

@ -14,10 +14,14 @@
let checkingLevel = $state(false); let checkingLevel = $state(false);
let levelMessage = $state<string | null>(null); 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(() => { $effect(() => {
const currentUser = $userStore; 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 // User has logged out - clear local state
userPubkey = null; userPubkey = null;
userPubkeyHex = null; userPubkeyHex = null;
@ -28,15 +32,18 @@
// Prevent body scroll when splash page is shown // Prevent body scroll when splash page is shown
document.body.style.overflow = 'hidden'; 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; const currentUser = $userStore;
if (!currentUser.userPubkey) { if (currentUser.userPubkey && currentUser.userPubkeyHex) {
// User has logged out or never logged in // User is already logged in - use store values
userPubkey = null; userPubkey = currentUser.userPubkey;
userPubkeyHex = null; userPubkeyHex = currentUser.userPubkeyHex;
checkingAuth = false; checkingAuth = false;
// Don't redirect immediately - let the user see they're logged in
// They can click "View Repositories" or navigate away
} else { } else {
// Check auth asynchronously // User not logged in - check if extension is available
checkingAuth = true;
checkAuth(); checkAuth();
} }
@ -94,54 +101,90 @@
} }
async function handleLogin() { async function handleLogin() {
if (isNIP07Available()) { if (!isNIP07Available()) {
try { alert('Nostr extension not found. Please install a Nostr extension like nos2x or Alby to login.');
checkingLevel = true; return;
levelMessage = 'Checking authentication...'; }
await checkAuth(); try {
checkingLevel = true;
levelMessage = 'Connecting to Nostr extension...';
if (userPubkey && userPubkeyHex) { // Get public key directly from NIP-07
levelMessage = 'Verifying relay write access...'; let pubkey: string;
try {
pubkey = await getPublicKeyWithNIP07();
if (!pubkey) {
throw new Error('No public key returned from extension');
}
} catch (err) {
console.error('Failed to get public key from NIP-07:', err);
checkingLevel = false;
levelMessage = null;
alert('Failed to connect to Nostr extension. Please make sure your extension is unlocked and try again.');
return;
}
// Determine user level (checks relay write access) // Convert npub to hex for API calls
const levelResult = await determineUserLevel(userPubkey, userPubkeyHex); 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;
}
}
// Update user store userPubkeyHex = pubkeyHex;
userStore.setUser( levelMessage = 'Verifying relay write access...';
levelResult.userPubkey,
levelResult.userPubkeyHex,
levelResult.level,
levelResult.error || null
);
// Update activity tracking on successful login // Determine user level (checks relay write access)
updateActivity(); const levelResult = await determineUserLevel(userPubkey, userPubkeyHex);
checkingLevel = false; // Update user store
levelMessage = null; userStore.setUser(
levelResult.userPubkey,
levelResult.userPubkeyHex,
levelResult.level,
levelResult.error || null
);
// Show appropriate message based on level // Update activity tracking on successful login
if (levelResult.level === 'unlimited') { updateActivity();
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 checkingLevel = false;
goto('/repos'); levelMessage = null;
} else {
checkingLevel = false; // Show appropriate message based on level
levelMessage = null; if (levelResult.level === 'unlimited') {
} levelMessage = 'Unlimited access granted!';
} catch (err) { } else if (levelResult.level === 'rate_limited') {
console.error('Login failed:', err); levelMessage = 'Logged in with rate-limited access.';
checkingLevel = false;
levelMessage = null;
alert('Failed to login. Please make sure you have a Nostr extension installed (like nos2x or Alby).');
} }
} else {
alert('Nostr extension not found. Please install a Nostr extension like nos2x or Alby to login.'); // 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 @@
import { getPublicKeyWithNIP07, isNIP07Available } from '$lib/services/nostr/nip07-signer.js'; import { getPublicKeyWithNIP07, isNIP07Available } from '$lib/services/nostr/nip07-signer.js';
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import type { ExternalIssue, ExternalPullRequest } from '$lib/services/git-platforms/git-platform-fetcher.js'; 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 loading = $state(true);
let error = $state<string | null>(null); let error = $state<string | null>(null);
let userPubkeyHex = $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 issues = $state<ExternalIssue[]>([]);
let pullRequests = $state<ExternalPullRequest[]>([]); let pullRequests = $state<ExternalPullRequest[]>([]);
let activeTab = $state<'issues' | 'prs' | 'all'>('all'); let activeTab = $state<'issues' | 'prs' | 'all'>('all');
@ -32,6 +43,14 @@
}); });
async function loadUserPubkey() { 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()) { if (!isNIP07Available()) {
return; return;
} }
@ -234,7 +253,7 @@
<span class="status-badge" class:open={item.state === 'open'} class:closed={item.state === 'closed'} class:merged={item.state === 'merged'}> <span class="status-badge" class:open={item.state === 'open'} class:closed={item.state === 'closed'} class:merged={item.state === 'merged'}>
{item.state} {item.state}
</span> </span>
{#if isPR && item.merged_at} {#if isPR && 'merged_at' in item && item.merged_at}
<span class="merged-indicator">✓ Merged</span> <span class="merged-indicator">✓ Merged</span>
{/if} {/if}
</div> </div>

231
src/routes/repos/+page.svelte

@ -8,6 +8,7 @@
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import { ForkCountService } from '$lib/services/nostr/fork-count-service.js'; import { ForkCountService } from '$lib/services/nostr/fork-count-service.js';
import { getPublicKeyWithNIP07, isNIP07Available } from '$lib/services/nostr/nip07-signer.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) // Registered repos (with domain in clone URLs)
let registeredRepos = $state<Array<{ event: NostrEvent; npub: string; repoName: string }>>([]); let registeredRepos = $state<Array<{ event: NostrEvent; npub: string; repoName: string }>>([]);
@ -28,6 +29,10 @@
let contactPubkeys = $state<Set<string>>(new Set()); let contactPubkeys = $state<Set<string>>(new Set());
let deletingRepo = $state<{ npub: string; repo: string } | null>(null); 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'; import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js';
const forkCountService = new ForkCountService(DEFAULT_NOSTR_RELAYS); const forkCountService = new ForkCountService(DEFAULT_NOSTR_RELAYS);
@ -36,6 +41,7 @@
onMount(async () => { onMount(async () => {
await loadRepos(); await loadRepos();
await loadUserAndContacts(); await loadUserAndContacts();
await loadMyRepos();
}); });
// Reload repos when page becomes visible (e.g., after returning from another page) // Reload repos when page becomes visible (e.g., after returning from another page)
@ -45,6 +51,7 @@
if (document.visibilityState === 'visible') { if (document.visibilityState === 'visible') {
// Reload repos when page becomes visible to catch newly published repos // Reload repos when page becomes visible to catch newly published repos
loadRepos().catch(err => console.warn('Failed to reload repos on visibility change:', err)); 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); 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() { 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()) { if (!isNIP07Available()) {
return; return;
} }
try { try {
userPubkey = await getPublicKeyWithNIP07(); const pubkey = await getPublicKeyWithNIP07();
if (!userPubkey) return; if (!pubkey) return;
userPubkey = pubkey;
// Convert npub to hex for API calls // Convert npub to hex for API calls
// NIP-07 may return either npub or hex, so check format first // NIP-07 may return either npub or hex, so check format first
@ -82,39 +147,96 @@
} }
if (userPubkeyHex) { if (userPubkeyHex) {
// Fetch user's kind 3 contact list await loadContacts();
const contactEvents = await nostrClient.fetchEvents([ }
{ } catch (err) {
kinds: [KIND.CONTACT_LIST], console.warn('Failed to load user or contacts:', err);
authors: [userPubkeyHex], }
limit: 1 }
}
]); async function loadContacts() {
if (!userPubkeyHex) return;
if (contactEvents.length > 0) {
const contactEvent = contactEvents[0]; try {
// Extract pubkeys from 'p' tags // Fetch user's kind 3 contact list
for (const tag of contactEvent.tags) { const contactEvents = await nostrClient.fetchEvents([
if (tag[0] === 'p' && tag[1]) { {
let pubkey = tag[1]; kinds: [KIND.CONTACT_LIST],
// Try to decode if it's an npub authors: [userPubkeyHex],
try { limit: 1
const decoded = nip19.decode(pubkey); }
if (decoded.type === 'npub') { ]);
pubkey = decoded.data as string;
} if (contactEvents.length > 0) {
} catch { const contactEvent = contactEvents[0];
// Assume it's already a hex pubkey // Extract pubkeys from 'p' tags
} for (const tag of contactEvent.tags) {
if (pubkey) { if (tag[0] === 'p' && tag[1]) {
contactPubkeys.add(pubkey); 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) { } 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 @@
<div class="container"> <div class="container">
<main> <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"> <div class="repos-header">
<h2>Repositories on {$page.data.gitDomain || 'localhost:6543'}</h2> <h2>Repositories on {$page.data.gitDomain || 'localhost:6543'}</h2>
<button onclick={loadRepos} disabled={loading}> <button onclick={loadRepos} disabled={loading}>
@ -541,21 +683,24 @@
<a href="/repos/{item.npub}/{item.repoName}" class="register-button"> <a href="/repos/{item.npub}/{item.repoName}" class="register-button">
View & Edit → View & Edit →
</a> </a>
{#if canDelete} {#if userPubkey}
<button {#if canDelete}
class="delete-button" <button
onclick={() => deleteLocalRepo(item.npub, item.repoName)} class="delete-button"
disabled={deletingRepo?.npub === item.npub && deletingRepo?.repo === item.repoName} 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> {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} {/if}
<button
class="register-button"
onclick={() => registerRepo(item.npub, item.repoName)}
>
Register
</button>
</div> </div>
</div> </div>
{#if repo} {#if repo}

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

@ -13,6 +13,7 @@
import { BookmarksService } from '$lib/services/nostr/bookmarks-service.js'; import { BookmarksService } from '$lib/services/nostr/bookmarks-service.js';
import { KIND } from '$lib/types/nostr.js'; import { KIND } from '$lib/types/nostr.js';
import { nip19 } from 'nostr-tools'; 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 // Get page data for OpenGraph metadata - use $derived to make it reactive
const pageData = $derived($page.data as { const pageData = $derived($page.data as {
@ -50,9 +51,45 @@
let currentBranch = $state('main'); let currentBranch = $state('main');
let commitMessage = $state(''); let commitMessage = $state('');
let userPubkey = $state<string | null>(null); let userPubkey = $state<string | null>(null);
let userPubkeyHex = $state<string | null>(null);
let showCommitDialog = $state(false); let showCommitDialog = $state(false);
let activeTab = $state<'files' | 'history' | 'tags' | 'issues' | 'prs' | 'docs' | 'discussions'>('discussions'); 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 // Navigation stack for directories
let pathStack = $state<string[]>([]); let pathStack = $state<string[]>([]);
@ -788,9 +825,35 @@
}); });
async function checkAuth() { 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 { try {
if (isNIP07Available()) { 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 // Recheck maintainer status and bookmark status after auth
await checkMaintainerStatus(); await checkMaintainerStatus();
await loadBookmarkStatus(); await loadBookmarkStatus();
@ -798,16 +861,43 @@
} catch (err) { } catch (err) {
console.log('NIP-07 not available or user not connected'); console.log('NIP-07 not available or user not connected');
userPubkey = null; userPubkey = null;
userPubkeyHex = null;
} }
} }
async function login() { 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 { try {
if (!isNIP07Available()) { if (!isNIP07Available()) {
alert('NIP-07 extension not found. Please install a Nostr extension like Alby or nos2x.'); alert('NIP-07 extension not found. Please install a Nostr extension like Alby or nos2x.');
return; 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 // Re-check maintainer status and bookmark status after login
await checkMaintainerStatus(); await checkMaintainerStatus();
await loadBookmarkStatus(); await loadBookmarkStatus();
@ -1624,14 +1714,18 @@
class="bookmark-button" class="bookmark-button"
class:bookmarked={isBookmarked} class:bookmarked={isBookmarked}
title={isBookmarked ? 'Remove bookmark' : 'Add bookmark'} title={isBookmarked ? 'Remove bookmark' : 'Add bookmark'}
aria-label={isBookmarked ? 'Remove bookmark' : 'Add bookmark'}
> >
{loadingBookmark ? '...' : (isBookmarked ? '★ Bookmarked' : '☆ Bookmark')} {loadingBookmark ? '...' : (isBookmarked ? '★' : '☆')}
</button> </button>
{#if isMaintainer} {#if isMaintainer}
<a href={`/repos/${npub}/${repo}/settings`} class="settings-button">Settings</a> <a href={`/repos/${npub}/${repo}/settings`} class="settings-button">Settings</a>
{/if} {/if}
{#if isMaintainer} {#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} {/if}
<span class="auth-status"> <span class="auth-status">
<img src="/icons/check-circle.svg" alt="Verified" class="icon-inline" /> <img src="/icons/check-circle.svg" alt="Verified" class="icon-inline" />
@ -1738,7 +1832,10 @@
<button onclick={handleBack} class="back-button">← Back</button> <button onclick={handleBack} class="back-button">← Back</button>
{/if} {/if}
{#if userPubkey && isMaintainer} {#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} {/if}
<button <button
onclick={() => showFileListOnMobile = !showFileListOnMobile} onclick={() => showFileListOnMobile = !showFileListOnMobile}
@ -1818,7 +1915,10 @@
<div class="tags-header"> <div class="tags-header">
<h2>Tags</h2> <h2>Tags</h2>
{#if userPubkey && isMaintainer} {#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} {/if}
</div> </div>
{#if tags.length === 0} {#if tags.length === 0}
@ -1845,7 +1945,10 @@
<div class="issues-header"> <div class="issues-header">
<h2>Issues</h2> <h2>Issues</h2>
{#if userPubkey} {#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} {/if}
</div> </div>
{#if loadingIssues} {#if loadingIssues}
@ -1879,7 +1982,10 @@
<div class="prs-header"> <div class="prs-header">
<h2>Pull Requests</h2> <h2>Pull Requests</h2>
{#if userPubkey} {#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} {/if}
</div> </div>
{#if loadingPRs} {#if loadingPRs}
@ -1954,7 +2060,10 @@
<span class="unsaved-indicator">● Unsaved changes</span> <span class="unsaved-indicator">● Unsaved changes</span>
{/if} {/if}
{#if isMaintainer} {#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'} {saving ? 'Saving...' : 'Save'}
</button> </button>
{:else if userPubkey} {:else if userPubkey}
@ -2163,7 +2272,7 @@
</main> </main>
<!-- Create File Dialog --> <!-- Create File Dialog -->
{#if showCreateFileDialog} {#if showCreateFileDialog && userPubkey && isMaintainer}
<div <div
class="modal-overlay" class="modal-overlay"
role="dialog" role="dialog"
@ -2200,7 +2309,7 @@
{/if} {/if}
<!-- Create Branch Dialog --> <!-- Create Branch Dialog -->
{#if showCreateBranchDialog} {#if showCreateBranchDialog && userPubkey && isMaintainer}
<div <div
class="modal-overlay" class="modal-overlay"
role="dialog" role="dialog"
@ -2241,7 +2350,7 @@
{/if} {/if}
<!-- Create Tag Dialog --> <!-- Create Tag Dialog -->
{#if showCreateTagDialog} {#if showCreateTagDialog && userPubkey && isMaintainer}
<div <div
class="modal-overlay" class="modal-overlay"
role="dialog" role="dialog"
@ -2282,7 +2391,7 @@
{/if} {/if}
<!-- Create Issue Dialog --> <!-- Create Issue Dialog -->
{#if showCreateIssueDialog} {#if showCreateIssueDialog && userPubkey}
<div <div
class="modal-overlay" class="modal-overlay"
role="dialog" role="dialog"
@ -2319,7 +2428,7 @@
{/if} {/if}
<!-- Create PR Dialog --> <!-- Create PR Dialog -->
{#if showCreatePRDialog} {#if showCreatePRDialog && userPubkey}
<div <div
class="modal-overlay" class="modal-overlay"
role="dialog" role="dialog"
@ -2364,7 +2473,7 @@
{/if} {/if}
<!-- Commit Dialog --> <!-- Commit Dialog -->
{#if showCommitDialog} {#if showCommitDialog && userPubkey && isMaintainer}
<div <div
class="modal-overlay" class="modal-overlay"
role="dialog" role="dialog"
@ -2446,21 +2555,29 @@
} }
.bookmark-button { .bookmark-button {
padding: 0.5rem 1rem; padding: 0.5rem;
font-size: 0.875rem; font-size: 1.25rem;
font-weight: 500; font-weight: 500;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 0.375rem; border-radius: 0.375rem;
background: var(--bg-primary); background: var(--bg-primary);
color: var(--text-primary); color: var(--text-muted);
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: all 0.2s ease;
font-family: 'IBM Plex Serif', serif; 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) { .bookmark-button:hover:not(:disabled) {
background: var(--bg-secondary); background: var(--bg-secondary);
border-color: var(--accent); border-color: var(--accent);
color: var(--accent);
transform: scale(1.1);
} }
.bookmark-button:disabled { .bookmark-button:disabled {
@ -2470,12 +2587,18 @@
.bookmark-button.bookmarked { .bookmark-button.bookmarked {
background: var(--accent); background: var(--accent);
color: var(--accent-text, white); color: var(--accent-text, #ffffff);
border-color: var(--accent); border-color: var(--accent);
} }
[data-theme="dark"] .bookmark-button.bookmarked {
background: var(--accent);
color: #ffffff;
}
.bookmark-button.bookmarked:hover:not(:disabled) { .bookmark-button.bookmarked:hover:not(:disabled) {
opacity: 0.9; opacity: 0.9;
transform: scale(1.1);
} }
.repo-banner { .repo-banner {
@ -2753,20 +2876,32 @@
.fork-badge { .fork-badge {
padding: 0.25rem 0.5rem; padding: 0.25rem 0.5rem;
background: var(--accent-light); background: var(--accent);
color: var(--accent); color: var(--accent-text, #ffffff);
border-radius: 4px; border-radius: 4px;
font-size: 0.85rem; font-size: 0.85rem;
margin-left: 0.5rem; margin-left: 0.5rem;
font-weight: 500;
}
[data-theme="dark"] .fork-badge {
background: var(--accent);
color: #ffffff;
} }
.fork-badge a { .fork-badge a {
color: var(--accent); color: var(--accent-text, #ffffff);
text-decoration: none; text-decoration: none;
font-weight: 500;
}
[data-theme="dark"] .fork-badge a {
color: #ffffff;
} }
.fork-badge a:hover { .fork-badge a:hover {
text-decoration: underline; text-decoration: underline;
opacity: 0.9;
} }
.repo-meta-info { .repo-meta-info {
@ -2812,10 +2947,18 @@
.topic-tag { .topic-tag {
padding: 0.25rem 0.5rem; padding: 0.25rem 0.5rem;
background: var(--accent-light); background: var(--accent);
color: var(--accent); color: var(--accent-text, #ffffff);
border-radius: 0.25rem; border-radius: 0.25rem;
font-size: 0.75rem; 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 { .repo-website {
@ -2919,35 +3062,32 @@
} }
.contributor-badge.owner { .contributor-badge.owner {
/* High contrast colors that work in both light and dark modes */ /* High contrast colors for light mode */
background: #4a5568; background: #4a5568;
color: #ffffff; color: #ffffff;
border-color: #2d3748; border-color: #2d3748;
} }
/* Dark mode adjustments for owner badge */ /* Dark mode adjustments for owner badge */
@media (prefers-color-scheme: dark) { [data-theme="dark"] .contributor-badge.owner {
.contributor-badge.owner { background: #718096;
background: #718096; color: #ffffff;
color: #ffffff; border-color: #a0aec0;
border-color: #a0aec0;
}
} }
.contributor-badge.maintainer { .contributor-badge.maintainer {
/* High contrast colors that work in both light and dark modes */ /* High contrast colors for light mode */
background: #22543d; background: #22543d;
color: #ffffff; color: #ffffff;
border-color: #1a202c; border-color: #1a202c;
} }
/* Dark mode adjustments for maintainer badge */ /* Dark mode adjustments for maintainer badge */
@media (prefers-color-scheme: dark) { [data-theme="dark"] .contributor-badge.maintainer {
.contributor-badge.maintainer { background: #48bb78;
background: #48bb78; color: #1a202c;
color: #1a202c; border-color: #68d391;
border-color: #68d391; font-weight: 700;
}
} }
header h1 { header h1 {
@ -3401,7 +3541,7 @@
} }
.commit-item, .tag-item { .commit-item, .tag-item {
border-bottom: 1px solid #e5e7eb; border-bottom: 1px solid var(--border-color);
} }
.commit-button { .commit-button {
@ -3419,7 +3559,13 @@
} }
.commit-item.selected .commit-button { .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 { .commit-hash {
@ -3746,8 +3892,15 @@
} }
.issue-status.open, .pr-status.open { .issue-status.open, .pr-status.open {
background: var(--accent-light); background: var(--accent);
color: 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 { .issue-status.closed, .pr-status.closed {

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

@ -3,6 +3,7 @@
import { page } from '$app/stores'; import { page } from '$app/stores';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { getPublicKeyWithNIP07 } from '$lib/services/nostr/nip07-signer.js'; 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 npub = ($page.params as { npub?: string; repo?: string }).npub || '';
const repo = ($page.params as { npub?: string; repo?: string }).repo || ''; const repo = ($page.params as { npub?: string; repo?: string }).repo || '';
@ -12,6 +13,16 @@
let error = $state<string | null>(null); let error = $state<string | null>(null);
let userPubkey = $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 name = $state('');
let description = $state(''); let description = $state('');
let cloneUrls = $state<string[]>(['']); let cloneUrls = $state<string[]>(['']);
@ -25,6 +36,14 @@
}); });
async function checkAuth() { 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 { try {
if (typeof window !== 'undefined' && window.nostr) { if (typeof window !== 'undefined' && window.nostr) {
userPubkey = await getPublicKeyWithNIP07(); userPubkey = await getPublicKeyWithNIP07();

32
src/routes/search/+page.svelte

@ -5,11 +5,35 @@
import UserBadge from '$lib/components/UserBadge.svelte'; import UserBadge from '$lib/components/UserBadge.svelte';
import { getPublicKeyWithNIP07, isNIP07Available } from '$lib/services/nostr/nip07-signer.js'; import { getPublicKeyWithNIP07, isNIP07Available } from '$lib/services/nostr/nip07-signer.js';
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import { userStore } from '$lib/stores/user-store.js';
let query = $state(''); let query = $state('');
let searchType = $state<'repos' | 'code' | 'all'>('repos'); let searchType = $state<'repos' | 'code' | 'all'>('repos');
let loading = $state(false); let loading = $state(false);
let userPubkeyHex = $state<string | null>(null); 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<{ let results = $state<{
repos: Array<{ id: string; name: string; description: string; owner: string; npub: string }>; repos: Array<{ id: string; name: string; description: string; owner: string; npub: string }>;
code: Array<{ repo: string; npub: string; file: string; matches: number }>; code: Array<{ repo: string; npub: string; file: string; matches: number }>;
@ -22,6 +46,14 @@
}); });
async function loadUserPubkey() { 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()) { if (!isNIP07Available()) {
return; return;
} }

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

@ -13,6 +13,7 @@
import { getUserRelays } from '$lib/services/nostr/user-relays.js'; import { getUserRelays } from '$lib/services/nostr/user-relays.js';
import UserBadge from '$lib/components/UserBadge.svelte'; import UserBadge from '$lib/components/UserBadge.svelte';
import { forwardEventIfEnabled } from '$lib/services/messaging/event-forwarder.js'; 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 || ''; const npub = ($page.params as { npub?: string }).npub || '';
@ -20,6 +21,31 @@
let error = $state<string | null>(null); let error = $state<string | null>(null);
let userPubkey = $state<string | null>(null); let userPubkey = $state<string | null>(null);
let viewerPubkeyHex = $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 repos = $state<NostrEvent[]>([]);
let userProfile = $state<{ name?: string; about?: string; picture?: string } | null>(null); let userProfile = $state<{ name?: string; about?: string; picture?: string } | null>(null);
@ -48,12 +74,22 @@
}); });
async function loadViewerPubkey() { 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()) { if (!isNIP07Available()) {
return; return;
} }
try { try {
const viewerPubkey = await getPublicKeyWithNIP07(); const viewerPubkey = await getPublicKeyWithNIP07();
userPubkey = viewerPubkey;
// Convert npub to hex for API calls // Convert npub to hex for API calls
try { try {
const decoded = nip19.decode(viewerPubkey); const decoded = nip19.decode(viewerPubkey);

474
src/routes/verify/+page.svelte

@ -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