Browse Source

bug-fixes

main
Silberengel 4 weeks ago
parent
commit
a6b7577bc3
  1. 2
      src/hooks.server.ts
  2. 4
      src/lib/components/NavBar.svelte
  3. 55
      src/lib/services/messaging/event-forwarder.ts
  4. 85
      src/lib/services/messaging/preferences-storage.ts
  5. 199
      src/lib/services/nostr/bookmarks-service.ts
  6. 24
      src/lib/services/nostr/user-relays.ts
  7. 1
      src/lib/types/nostr.ts
  8. 39
      src/routes/+page.svelte
  9. 11
      src/routes/api/search/+server.ts
  10. 20
      src/routes/api/user/messaging-preferences/summary/+server.ts
  11. 28
      src/routes/api/users/[npub]/repos/+server.ts
  12. 14
      src/routes/repos/+page.svelte
  13. 114
      src/routes/repos/[npub]/[repo]/+page.svelte
  14. 211
      src/routes/signup/+page.svelte

2
src/hooks.server.ts

@ -100,6 +100,7 @@ export const handle: Handle = async ({ event, resolve }) => {
response.headers.set('Permissions-Policy', 'geolocation=(), microphone=(), camera=()'); response.headers.set('Permissions-Policy', 'geolocation=(), microphone=(), camera=()');
// Add CSP header (Content Security Policy) // Add CSP header (Content Security Policy)
// Allow frames from common git hosting platforms for web URL previews
const csp = [ const csp = [
"default-src 'self'", "default-src 'self'",
"script-src 'self' 'unsafe-inline' 'unsafe-eval'", // unsafe-eval needed for Svelte "script-src 'self' 'unsafe-inline' 'unsafe-eval'", // unsafe-eval needed for Svelte
@ -107,6 +108,7 @@ export const handle: Handle = async ({ event, resolve }) => {
"img-src 'self' data: https:", "img-src 'self' data: https:",
"font-src 'self' data: https://fonts.gstatic.com", "font-src 'self' data: https://fonts.gstatic.com",
"connect-src 'self' wss: https:", "connect-src 'self' wss: https:",
"frame-src 'self' https:", // Allow iframes from same origin and HTTPS URLs (for web URL previews)
"frame-ancestors 'none'", "frame-ancestors 'none'",
"base-uri 'self'", "base-uri 'self'",
"form-action 'self'" "form-action 'self'"

4
src/lib/components/NavBar.svelte

@ -98,7 +98,7 @@
<div class="nav-links"> <div class="nav-links">
<a href="/" class:active={isActive('/') && $page.url.pathname === '/'} onclick={closeMobileMenu}>Repositories</a> <a href="/" class:active={isActive('/') && $page.url.pathname === '/'} 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}>Sign Up</a> <a href="/signup" class:active={isActive('/signup')} onclick={closeMobileMenu}>Register</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>
@ -148,6 +148,8 @@
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
margin-bottom: 2rem; margin-bottom: 2rem;
background: var(--bg-primary); background: var(--bg-primary);
position: relative;
z-index: 100;
} }
.header-container { .header-container {

55
src/lib/services/messaging/event-forwarder.ts

@ -128,27 +128,52 @@ const GIT_PLATFORM_CONFIGS: Record<string, Omit<GitPlatformConfig, 'baseUrl'>> =
} }
}; };
// Only access process.env server-side (not in browser)
const getEnv = (key: string, defaultValue: string = ''): string => {
if (typeof window !== 'undefined') {
// Browser environment - return default
return defaultValue;
}
// Server-side - access process.env
return (typeof process !== 'undefined' && process.env?.[key]) || defaultValue;
};
const getEnvBool = (key: string, defaultValue: boolean = false): boolean => {
if (typeof window !== 'undefined') {
return defaultValue;
}
return (typeof process !== 'undefined' && process.env?.[key]) === 'true';
};
const getEnvInt = (key: string, defaultValue: number): number => {
if (typeof window !== 'undefined') {
return defaultValue;
}
const value = typeof process !== 'undefined' ? process.env?.[key] : undefined;
return value ? parseInt(value, 10) : defaultValue;
};
const MESSAGING_CONFIG: MessagingConfig = { const MESSAGING_CONFIG: MessagingConfig = {
telegram: { telegram: {
botToken: process.env.TELEGRAM_BOT_TOKEN || '', botToken: getEnv('TELEGRAM_BOT_TOKEN'),
enabled: process.env.TELEGRAM_ENABLED === 'true' enabled: getEnvBool('TELEGRAM_ENABLED')
}, },
simplex: { simplex: {
apiUrl: process.env.SIMPLEX_API_URL || '', apiUrl: getEnv('SIMPLEX_API_URL'),
apiKey: process.env.SIMPLEX_API_KEY || '', apiKey: getEnv('SIMPLEX_API_KEY'),
enabled: process.env.SIMPLEX_ENABLED === 'true' enabled: getEnvBool('SIMPLEX_ENABLED')
}, },
email: { email: {
smtpHost: process.env.SMTP_HOST || '', smtpHost: getEnv('SMTP_HOST'),
smtpPort: parseInt(process.env.SMTP_PORT || '587', 10), smtpPort: getEnvInt('SMTP_PORT', 587),
smtpUser: process.env.SMTP_USER || '', smtpUser: getEnv('SMTP_USER'),
smtpPassword: process.env.SMTP_PASSWORD || '', smtpPassword: getEnv('SMTP_PASSWORD'),
fromAddress: process.env.SMTP_FROM_ADDRESS || '', fromAddress: getEnv('SMTP_FROM_ADDRESS'),
fromName: process.env.SMTP_FROM_NAME || 'GitRepublic', fromName: getEnv('SMTP_FROM_NAME', 'GitRepublic'),
enabled: process.env.EMAIL_ENABLED === 'true' enabled: getEnvBool('EMAIL_ENABLED')
}, },
gitPlatforms: { gitPlatforms: {
enabled: process.env.GIT_PLATFORMS_ENABLED === 'true' enabled: getEnvBool('GIT_PLATFORMS_ENABLED')
} }
}; };
@ -441,7 +466,7 @@ async function sendEmail(
} }
try { try {
const smtpUrl = process.env.SMTP_API_URL; const smtpUrl = getEnv('SMTP_API_URL');
if (smtpUrl) { if (smtpUrl) {
// Use SMTP API if provided // Use SMTP API if provided
@ -449,7 +474,7 @@ async function sendEmail(
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Authorization': `Bearer ${process.env.SMTP_API_KEY || ''}` 'Authorization': `Bearer ${getEnv('SMTP_API_KEY')}`
}, },
body: JSON.stringify({ body: JSON.stringify({
from: MESSAGING_CONFIG.email.fromAddress, from: MESSAGING_CONFIG.email.fromAddress,

85
src/lib/services/messaging/preferences-storage.ts

@ -30,15 +30,16 @@ import logger from '../logger.js';
import { getCachedUserLevel } from '../security/user-level-cache.js'; import { getCachedUserLevel } from '../security/user-level-cache.js';
// Encryption keys from environment (NEVER commit these!) // Encryption keys from environment (NEVER commit these!)
// These are optional - if not set, messaging preferences will be disabled
const ENCRYPTION_KEY = process.env.MESSAGING_PREFS_ENCRYPTION_KEY; const ENCRYPTION_KEY = process.env.MESSAGING_PREFS_ENCRYPTION_KEY;
const SALT_ENCRYPTION_KEY = process.env.MESSAGING_SALT_ENCRYPTION_KEY; const SALT_ENCRYPTION_KEY = process.env.MESSAGING_SALT_ENCRYPTION_KEY;
const LOOKUP_SECRET = process.env.MESSAGING_LOOKUP_SECRET; const LOOKUP_SECRET = process.env.MESSAGING_LOOKUP_SECRET;
if (!ENCRYPTION_KEY || !SALT_ENCRYPTION_KEY || !LOOKUP_SECRET) { // Check if messaging preferences are configured
throw new Error( const isMessagingConfigured = !!(ENCRYPTION_KEY && SALT_ENCRYPTION_KEY && LOOKUP_SECRET);
'Missing required environment variables: ' +
'MESSAGING_PREFS_ENCRYPTION_KEY, MESSAGING_SALT_ENCRYPTION_KEY, MESSAGING_LOOKUP_SECRET' if (!isMessagingConfigured) {
); logger.warn('Messaging preferences storage is not configured. Missing environment variables: MESSAGING_PREFS_ENCRYPTION_KEY, MESSAGING_SALT_ENCRYPTION_KEY, MESSAGING_LOOKUP_SECRET');
} }
export interface MessagingPreferences { export interface MessagingPreferences {
@ -90,17 +91,19 @@ setInterval(() => {
*/ */
function getLookupKey(userPubkeyHex: string): string { function getLookupKey(userPubkeyHex: string): string {
if (!LOOKUP_SECRET) { if (!LOOKUP_SECRET) {
throw new Error('LOOKUP_SECRET not configured'); throw new Error('Messaging preferences are not configured. LOOKUP_SECRET environment variable is missing.');
} }
return createHmac('sha256', LOOKUP_SECRET) return createHmac('sha256', LOOKUP_SECRET)
.update(userPubkeyHex) .update(userPubkeyHex)
.digest('hex'); .digest('hex');
} }
/**
* Check and enforce rate limiting on decryption attempts
*/
function checkRateLimit(userPubkeyHex: string): { allowed: boolean; remaining: number } { function checkRateLimit(userPubkeyHex: string): { allowed: boolean; remaining: number } {
// If not configured, allow all (no rate limiting)
if (!isMessagingConfigured) {
return { allowed: true, remaining: MAX_DECRYPTION_ATTEMPTS };
}
const lookupKey = getLookupKey(userPubkeyHex); const lookupKey = getLookupKey(userPubkeyHex);
const now = Date.now(); const now = Date.now();
@ -125,6 +128,7 @@ function checkRateLimit(userPubkeyHex: string): { allowed: boolean; remaining: n
return { allowed: true, remaining: MAX_DECRYPTION_ATTEMPTS - attempt.count }; return { allowed: true, remaining: MAX_DECRYPTION_ATTEMPTS - attempt.count };
} }
/** /**
* Encrypt data with AES-256-GCM * Encrypt data with AES-256-GCM
*/ */
@ -216,6 +220,10 @@ export async function storePreferences(
userPubkeyHex: string, userPubkeyHex: string,
preferences: MessagingPreferences preferences: MessagingPreferences
): Promise<void> { ): Promise<void> {
if (!isMessagingConfigured) {
throw new Error('Messaging preferences are not configured. Please set MESSAGING_PREFS_ENCRYPTION_KEY, MESSAGING_SALT_ENCRYPTION_KEY, and MESSAGING_LOOKUP_SECRET environment variables.');
}
// Verify user has unlimited access // Verify user has unlimited access
const cached = getCachedUserLevel(userPubkeyHex); const cached = getCachedUserLevel(userPubkeyHex);
if (!cached || cached.level !== 'unlimited') { if (!cached || cached.level !== 'unlimited') {
@ -257,6 +265,11 @@ export async function storePreferences(
export async function getPreferences( export async function getPreferences(
userPubkeyHex: string userPubkeyHex: string
): Promise<MessagingPreferences | null> { ): Promise<MessagingPreferences | null> {
if (!isMessagingConfigured) {
// If not configured, return null (no preferences stored)
return null;
}
// Check rate limit // Check rate limit
const rateLimit = checkRateLimit(userPubkeyHex); const rateLimit = checkRateLimit(userPubkeyHex);
if (!rateLimit.allowed) { if (!rateLimit.allowed) {
@ -365,27 +378,39 @@ export async function getPreferencesSummary(userPubkeyHex: string): Promise<{
}; };
notifyOn?: string[]; notifyOn?: string[];
} | null> { } | null> {
const preferences = await getPreferences(userPubkeyHex); try {
// If not configured, return null (not configured)
if (!preferences) { if (!isMessagingConfigured) {
return null;
}
const preferences = await getPreferences(userPubkeyHex);
if (!preferences) {
return null;
}
return {
configured: true,
enabled: preferences.enabled,
platforms: {
telegram: !!preferences.telegram,
simplex: !!preferences.simplex,
email: !!preferences.email,
gitPlatforms: preferences.gitPlatforms?.map(gp => ({
platform: gp.platform,
owner: gp.owner,
repo: gp.repo,
apiUrl: gp.apiUrl
// token is intentionally omitted
}))
},
notifyOn: preferences.notifyOn
};
} catch (err) {
// If any error occurs (e.g., decryption fails, not configured, etc.), return null
logger.warn({ error: err, userPubkeyHex: userPubkeyHex.slice(0, 16) + '...' },
'Failed to get preferences summary');
return null; return null;
} }
return {
configured: true,
enabled: preferences.enabled,
platforms: {
telegram: !!preferences.telegram,
simplex: !!preferences.simplex,
email: !!preferences.email,
gitPlatforms: preferences.gitPlatforms?.map(gp => ({
platform: gp.platform,
owner: gp.owner,
repo: gp.repo,
apiUrl: gp.apiUrl
// token is intentionally omitted
}))
},
notifyOn: preferences.notifyOn
};
} }

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

@ -0,0 +1,199 @@
/**
* Service for managing user bookmarks (kind 10003)
* NIP-51: Lists - Bookmarks
*/
import { NostrClient } from './nostr-client.js';
import type { NostrEvent } from '../../types/nostr.js';
import { KIND } from '../../types/nostr.js';
import { getPublicKeyWithNIP07, signEventWithNIP07 } from './nip07-signer.js';
import logger from '../logger.js';
import { truncatePubkey } from '../../utils/security.js';
export class BookmarksService {
private nostrClient: NostrClient;
constructor(relays: string[]) {
this.nostrClient = new NostrClient(relays);
}
/**
* Fetch user's bookmarks (kind 10003)
* Returns the most recent bookmark list event
*/
async getBookmarks(pubkey: string): Promise<NostrEvent | null> {
try {
const events = await this.nostrClient.fetchEvents([
{
kinds: [KIND.BOOKMARKS],
authors: [pubkey],
limit: 1
}
]);
if (events.length === 0) {
return null;
}
// Sort by created_at descending and return the newest
events.sort((a, b) => b.created_at - a.created_at);
return events[0];
} catch (error) {
logger.error({ error, pubkey: truncatePubkey(pubkey) }, 'Failed to fetch bookmarks');
return null;
}
}
/**
* Get all bookmarked repo addresses (a-tags) from user's bookmarks
*/
async getBookmarkedRepos(pubkey: string): Promise<Set<string>> {
const bookmarks = await this.getBookmarks(pubkey);
if (!bookmarks) {
return new Set();
}
const repoAddresses = new Set<string>();
for (const tag of bookmarks.tags) {
if (tag[0] === 'a' && tag[1]) {
// Check if it's a repo announcement address (kind 30617)
const address = tag[1];
if (address.startsWith(`${KIND.REPO_ANNOUNCEMENT}:`)) {
repoAddresses.add(address);
}
}
}
return repoAddresses;
}
/**
* Check if a repo is bookmarked
*/
async isBookmarked(pubkey: string, repoAddress: string): Promise<boolean> {
const bookmarkedRepos = await this.getBookmarkedRepos(pubkey);
return bookmarkedRepos.has(repoAddress);
}
/**
* Add a repo to bookmarks
* Creates or updates the bookmark list event
*/
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[] = [];
if (existingBookmarks) {
for (const tag of existingBookmarks.tags) {
if (tag[0] === 'a' && tag[1]) {
// Only include repo announcement addresses
if (tag[1].startsWith(`${KIND.REPO_ANNOUNCEMENT}:`)) {
existingATags.push(tag[1]);
}
}
}
}
// Check if already bookmarked
if (existingATags.includes(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]);
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
};
// Sign with NIP-07
const signedEvent = await signEventWithNIP07(eventTemplate);
// Publish to relays
const result = await this.nostrClient.publishEvent(signedEvent, relays);
if (result.success.length > 0) {
logger.debug({ pubkey: truncatePubkey(pubkey), repoAddress }, 'Bookmark added successfully');
return true;
} else {
logger.error({ pubkey: truncatePubkey(pubkey), repoAddress, errors: result.failed }, 'Failed to publish bookmark');
return false;
}
} catch (error) {
logger.error({ error, pubkey: truncatePubkey(pubkey), repoAddress }, 'Failed to add bookmark');
return false;
}
}
/**
* Remove a repo from bookmarks
* Creates a new bookmark list event without the specified repo
*/
async removeBookmark(pubkey: string, repoAddress: string, relays: string[]): Promise<boolean> {
try {
// Get existing bookmarks
const existingBookmarks = await this.getBookmarks(pubkey);
if (!existingBookmarks) {
logger.debug({ pubkey: truncatePubkey(pubkey), repoAddress }, 'No bookmarks to remove from');
return true;
}
// Extract existing a-tags (for repos), excluding the one to remove
const existingATags: string[] = [];
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]);
}
}
}
// Check if it was bookmarked
if (existingATags.length === existingBookmarks.tags.filter(t => t[0] === 'a' && t[1]?.startsWith(`${KIND.REPO_ANNOUNCEMENT}:`)).length) {
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
};
// Sign with NIP-07
const signedEvent = await signEventWithNIP07(eventTemplate);
// Publish to relays
const result = await this.nostrClient.publishEvent(signedEvent, relays);
if (result.success.length > 0) {
logger.debug({ pubkey: truncatePubkey(pubkey), repoAddress }, 'Bookmark removed successfully');
return true;
} else {
logger.error({ pubkey: truncatePubkey(pubkey), repoAddress, errors: result.failed }, 'Failed to publish bookmark removal');
return false;
}
} catch (error) {
logger.error({ error, pubkey: truncatePubkey(pubkey), repoAddress }, 'Failed to remove bookmark');
return false;
}
}
}

24
src/lib/services/nostr/user-relays.ts

@ -17,18 +17,32 @@ export async function getUserRelays(
try { try {
// Fetch kind 10002 (relay list) - get multiple to find the newest // Fetch kind 10002 (relay list) - get multiple to find the newest
// Use a higher limit to ensure we get all relay list events
const relayListEvents = await nostrClient.fetchEvents([ const relayListEvents = await nostrClient.fetchEvents([
{ {
kinds: [KIND.RELAY_LIST], kinds: [KIND.RELAY_LIST],
authors: [pubkey], authors: [pubkey],
limit: 10 // Get multiple to ensure we find the newest limit: 20 // Get more events to ensure we find the newest
} }
]); ]);
logger.debug({
pubkey: truncatePubkey(pubkey),
eventCount: relayListEvents.length,
eventIds: relayListEvents.map(e => e.id)
}, 'Fetched relay list events');
if (relayListEvents.length > 0) { if (relayListEvents.length > 0) {
// Sort by created_at descending to get the newest event first // Sort by created_at descending to get the newest event first
relayListEvents.sort((a, b) => b.created_at - a.created_at); relayListEvents.sort((a, b) => b.created_at - a.created_at);
const event = relayListEvents[0]; const event = relayListEvents[0];
logger.debug({
pubkey: truncatePubkey(pubkey),
eventId: event.id,
tagCount: event.tags.length,
createdAt: new Date(event.created_at * 1000).toISOString()
}, 'Found kind 10002 relay list event');
for (const tag of event.tags) { for (const tag of event.tags) {
if (tag[0] === 'relay' && tag[1]) { if (tag[0] === 'relay' && tag[1]) {
const relay = tag[1]; const relay = tag[1];
@ -39,6 +53,14 @@ export async function getUserRelays(
if (write) outbox.push(relay); if (write) outbox.push(relay);
} }
} }
logger.debug({
pubkey: truncatePubkey(pubkey),
inboxCount: inbox.length,
outboxCount: outbox.length
}, 'Extracted relays from kind 10002 event');
} else {
logger.debug({ pubkey: truncatePubkey(pubkey) }, 'No kind 10002 relay list events found');
} }
// Fallback to kind 3 (contacts) for older clients // Fallback to kind 3 (contacts) for older clients

1
src/lib/types/nostr.ts

@ -52,6 +52,7 @@ export const KIND = {
THREAD: 11, // NIP-7D: Discussion thread THREAD: 11, // NIP-7D: Discussion thread
BRANCH_PROTECTION: 30620, // Custom: Branch protection rules BRANCH_PROTECTION: 30620, // Custom: Branch protection rules
RELAY_LIST: 10002, // NIP-65: Relay list metadata RELAY_LIST: 10002, // NIP-65: Relay list metadata
BOOKMARKS: 10003, // NIP-51: Bookmarks list
NIP98_AUTH: 27235, // NIP-98: HTTP authentication event NIP98_AUTH: 27235, // NIP-98: HTTP authentication event
HIGHLIGHT: 9802, // NIP-84: Highlight event HIGHLIGHT: 9802, // NIP-84: Highlight event
PUBLIC_MESSAGE: 24, // NIP-24: Public message (direct chat) PUBLIC_MESSAGE: 24, // NIP-24: Public message (direct chat)

39
src/routes/+page.svelte

@ -14,12 +14,31 @@
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)
$effect(() => {
const currentUser = $userStore;
if (!currentUser.userPubkey) {
// User has logged out - clear local state
userPubkey = null;
userPubkeyHex = null;
}
});
onMount(() => { onMount(() => {
// 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 auth asynchronously // Check userStore first - if user has logged out, don't check extension
checkAuth(); const currentUser = $userStore;
if (!currentUser.userPubkey) {
// User has logged out or never logged in
userPubkey = null;
userPubkeyHex = null;
checkingAuth = false;
} else {
// Check auth asynchronously
checkAuth();
}
// Return cleanup function // Return cleanup function
return () => { return () => {
@ -30,6 +49,16 @@
async function checkAuth() { async function checkAuth() {
checkingAuth = true; checkingAuth = true;
// Check userStore first - if user has logged out, clear state
const currentUser = $userStore;
if (!currentUser.userPubkey) {
userPubkey = null;
userPubkeyHex = null;
checkingAuth = false;
return;
}
if (isNIP07Available()) { if (isNIP07Available()) {
try { try {
userPubkey = await getPublicKeyWithNIP07(); userPubkey = await getPublicKeyWithNIP07();
@ -53,7 +82,13 @@
} }
} catch (err) { } catch (err) {
console.warn('Failed to load user pubkey:', err); console.warn('Failed to load user pubkey:', err);
userPubkey = null;
userPubkeyHex = null;
} }
} else {
// Extension not available, clear state
userPubkey = null;
userPubkeyHex = null;
} }
checkingAuth = false; checkingAuth = false;
} }

11
src/routes/api/search/+server.ts

@ -105,9 +105,18 @@ export const GET: RequestHandler = async (event) => {
if (!isPrivate) { if (!isPrivate) {
canView = true; // Public repos are viewable by anyone canView = true; // Public repos are viewable by anyone
} else if (userPubkey) { } else if (userPubkey) {
// Private repos require authentication // Private repos require authentication - check if user owns, maintains, or has bookmarked
try { try {
// Check if user is owner or maintainer
canView = await maintainerService.canView(userPubkey, event.pubkey, repoId); canView = await maintainerService.canView(userPubkey, event.pubkey, repoId);
// If not owner/maintainer, check if user has bookmarked it
if (!canView) {
const { BookmarksService } = await import('$lib/services/nostr/bookmarks-service.js');
const bookmarksService = new BookmarksService(DEFAULT_NOSTR_SEARCH_RELAYS);
const repoAddress = `${KIND.REPO_ANNOUNCEMENT}:${event.pubkey}:${repoId}`;
canView = await bookmarksService.isBookmarked(userPubkey, repoAddress);
}
} catch (err) { } catch (err) {
logger.warn({ error: err, pubkey: event.pubkey, repo: repoId }, 'Failed to check repo access in search'); logger.warn({ error: err, pubkey: event.pubkey, repo: repoId }, 'Failed to check repo access in search');
canView = false; canView = false;

20
src/routes/api/user/messaging-preferences/summary/+server.ts

@ -53,6 +53,24 @@ export const GET: RequestHandler = async (event) => {
}); });
} }
return error(500, 'Failed to get messaging preferences summary'); // If messaging is not configured, return not configured
if (err instanceof Error && (
err.message.includes('not configured') ||
err.message.includes('environment variable') ||
err.message.includes('LOOKUP_SECRET')
)) {
return json({
configured: false,
enabled: false,
platforms: {}
});
}
// For any other error, return not configured (graceful degradation)
return json({
configured: false,
enabled: false,
platforms: {}
});
} }
}; };

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

@ -7,17 +7,20 @@ import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { NostrClient } from '$lib/services/nostr/nostr-client.js'; import { NostrClient } from '$lib/services/nostr/nostr-client.js';
import { MaintainerService } from '$lib/services/nostr/maintainer-service.js'; import { MaintainerService } from '$lib/services/nostr/maintainer-service.js';
import { DEFAULT_NOSTR_RELAYS, GIT_DOMAIN } from '$lib/config.js'; import { BookmarksService } from '$lib/services/nostr/bookmarks-service.js';
import { DEFAULT_NOSTR_RELAYS, DEFAULT_NOSTR_SEARCH_RELAYS, GIT_DOMAIN } from '$lib/config.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 { handleApiError, handleValidationError } from '$lib/utils/error-handler.js'; import { handleApiError, handleValidationError } from '$lib/utils/error-handler.js';
import { extractRequestContext } from '$lib/utils/api-context.js'; import { extractRequestContext } from '$lib/utils/api-context.js';
import logger from '$lib/services/logger.js'; import logger from '$lib/services/logger.js';
import { truncatePubkey } from '$lib/utils/security.js';
import type { NostrEvent } from '$lib/types/nostr.js'; import type { NostrEvent } from '$lib/types/nostr.js';
import type { RequestEvent } from '@sveltejs/kit'; import type { RequestEvent } from '@sveltejs/kit';
const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS); const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS);
const maintainerService = new MaintainerService(DEFAULT_NOSTR_RELAYS); const maintainerService = new MaintainerService(DEFAULT_NOSTR_RELAYS);
const bookmarksService = new BookmarksService(DEFAULT_NOSTR_SEARCH_RELAYS);
export const GET: RequestHandler = async (event) => { export const GET: RequestHandler = async (event) => {
try { try {
@ -51,6 +54,16 @@ export const GET: RequestHandler = async (event) => {
} }
]); ]);
// Get viewer's bookmarked repos if authenticated
let bookmarkedRepos: Set<string> = new Set();
if (viewerPubkey) {
try {
bookmarkedRepos = await bookmarksService.getBookmarkedRepos(viewerPubkey);
} catch (err) {
logger.warn({ error: err, viewerPubkey: truncatePubkey(viewerPubkey) }, 'Failed to fetch bookmarked repos');
}
}
const repos: NostrEvent[] = []; const repos: NostrEvent[] = [];
// Process each announcement with privacy filtering // Process each announcement with privacy filtering
@ -60,10 +73,6 @@ export const GET: RequestHandler = async (event) => {
.flatMap(t => t.slice(1)) .flatMap(t => t.slice(1))
.filter(url => url && typeof url === 'string'); .filter(url => url && typeof url === 'string');
// Filter for repos that list our domain
const hasDomain = cloneUrls.some(url => url.includes(gitDomain));
if (!hasDomain) continue;
// Extract repo name from d-tag // Extract repo name from d-tag
const dTag = event.tags.find(t => t[0] === 'd')?.[1]; const dTag = event.tags.find(t => t[0] === 'd')?.[1];
if (!dTag) continue; if (!dTag) continue;
@ -79,9 +88,16 @@ export const GET: RequestHandler = async (event) => {
if (!isPrivate) { if (!isPrivate) {
canView = true; // Public repos are viewable by anyone canView = true; // Public repos are viewable by anyone
} else if (viewerPubkey) { } else if (viewerPubkey) {
// Private repos require authentication // Private repos require authentication - check if viewer owns, maintains, or has bookmarked
try { try {
// Check if viewer is owner or maintainer
canView = await maintainerService.canView(viewerPubkey, userPubkey, dTag); canView = await maintainerService.canView(viewerPubkey, userPubkey, dTag);
// If not owner/maintainer, check if viewer has bookmarked it
if (!canView) {
const repoAddress = `${KIND.REPO_ANNOUNCEMENT}:${userPubkey}:${dTag}`;
canView = bookmarkedRepos.has(repoAddress);
}
} catch (err) { } catch (err) {
logger.warn({ error: err, pubkey: userPubkey, repo: dTag }, 'Failed to check repo access'); logger.warn({ error: err, pubkey: userPubkey, repo: dTag }, 'Failed to check repo access');
canView = false; canView = false;

14
src/routes/repos/+page.svelte

@ -38,6 +38,20 @@
await loadUserAndContacts(); await loadUserAndContacts();
}); });
// Reload repos when page becomes visible (e.g., after returning from another page)
$effect(() => {
if (typeof document !== 'undefined') {
const handleVisibilityChange = () => {
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));
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => document.removeEventListener('visibilitychange', handleVisibilityChange);
}
});
async function loadUserAndContacts() { async function loadUserAndContacts() {
if (!isNIP07Available()) { if (!isNIP07Available()) {
return; return;

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

@ -8,8 +8,9 @@
import ForwardingConfig from '$lib/components/ForwardingConfig.svelte'; import ForwardingConfig from '$lib/components/ForwardingConfig.svelte';
import { getPublicKeyWithNIP07, isNIP07Available } from '$lib/services/nostr/nip07-signer.js'; import { getPublicKeyWithNIP07, isNIP07Available } from '$lib/services/nostr/nip07-signer.js';
import { NostrClient } from '$lib/services/nostr/nostr-client.js'; import { NostrClient } from '$lib/services/nostr/nostr-client.js';
import { DEFAULT_NOSTR_RELAYS, combineRelays } from '$lib/config.js'; import { DEFAULT_NOSTR_RELAYS, DEFAULT_NOSTR_SEARCH_RELAYS, combineRelays } from '$lib/config.js';
import { getUserRelays } from '$lib/services/nostr/user-relays.js'; import { getUserRelays } from '$lib/services/nostr/user-relays.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';
@ -127,6 +128,12 @@
let forkInfo = $state<{ isFork: boolean; originalRepo: { npub: string; repo: string } | null } | null>(null); let forkInfo = $state<{ isFork: boolean; originalRepo: { npub: string; repo: string } | null } | null>(null);
let forking = $state(false); let forking = $state(false);
// Bookmarks
let isBookmarked = $state(false);
let loadingBookmark = $state(false);
let bookmarksService: BookmarksService | null = null;
let repoAddress = $state<string | null>(null);
// Repository images // Repository images
let repoImage = $state<string | null>(null); let repoImage = $state<string | null>(null);
let repoBanner = $state<string | null>(null); let repoBanner = $state<string | null>(null);
@ -745,6 +752,20 @@
}); });
onMount(async () => { onMount(async () => {
// Initialize bookmarks service
bookmarksService = new BookmarksService(DEFAULT_NOSTR_SEARCH_RELAYS);
// Decode npub to get repo owner pubkey for bookmark address
try {
const decoded = nip19.decode(npub);
if (decoded.type === 'npub') {
const repoOwnerPubkey = decoded.data as string;
repoAddress = `${KIND.REPO_ANNOUNCEMENT}:${repoOwnerPubkey}:${repo}`;
}
} catch (err) {
console.warn('Failed to decode npub for bookmark address:', err);
}
await loadBranches(); await loadBranches();
// Skip other API calls if repository doesn't exist // Skip other API calls if repository doesn't exist
if (repoNotFound) { if (repoNotFound) {
@ -759,6 +780,7 @@
await checkAuth(); await checkAuth();
await loadTags(); await loadTags();
await checkMaintainerStatus(); await checkMaintainerStatus();
await loadBookmarkStatus();
await checkVerification(); await checkVerification();
await loadReadme(); await loadReadme();
await loadForkInfo(); await loadForkInfo();
@ -769,8 +791,9 @@
try { try {
if (isNIP07Available()) { if (isNIP07Available()) {
userPubkey = await getPublicKeyWithNIP07(); userPubkey = await getPublicKeyWithNIP07();
// Recheck maintainer status after auth // Recheck maintainer status and bookmark status after auth
await checkMaintainerStatus(); await checkMaintainerStatus();
await loadBookmarkStatus();
} }
} catch (err) { } catch (err) {
console.log('NIP-07 not available or user not connected'); console.log('NIP-07 not available or user not connected');
@ -785,8 +808,9 @@
return; return;
} }
userPubkey = await getPublicKeyWithNIP07(); userPubkey = await getPublicKeyWithNIP07();
// Re-check maintainer status after login // Re-check maintainer status and bookmark status after login
await checkMaintainerStatus(); await checkMaintainerStatus();
await loadBookmarkStatus();
} catch (err) { } catch (err) {
error = err instanceof Error ? err.message : 'Failed to connect'; error = err instanceof Error ? err.message : 'Failed to connect';
console.error('Login error:', err); console.error('Login error:', err);
@ -794,6 +818,48 @@
} }
async function loadBookmarkStatus() {
if (!userPubkey || !repoAddress || !bookmarksService) return;
try {
isBookmarked = await bookmarksService.isBookmarked(userPubkey, repoAddress);
} catch (err) {
console.warn('Failed to load bookmark status:', err);
}
}
async function toggleBookmark() {
if (!userPubkey || !repoAddress || !bookmarksService || loadingBookmark) return;
loadingBookmark = true;
try {
// Get user's relays for publishing
const { getUserRelays } = await import('$lib/services/nostr/user-relays.js');
const allSearchRelays = [...new Set([...DEFAULT_NOSTR_SEARCH_RELAYS, ...DEFAULT_NOSTR_RELAYS])];
const fullRelayClient = new NostrClient(allSearchRelays);
const { outbox, inbox } = await getUserRelays(userPubkey, fullRelayClient);
const userRelays = combineRelays(outbox.length > 0 ? outbox : inbox, DEFAULT_NOSTR_RELAYS);
let success = false;
if (isBookmarked) {
success = await bookmarksService.removeBookmark(userPubkey, repoAddress, userRelays);
} else {
success = await bookmarksService.addBookmark(userPubkey, repoAddress, userRelays);
}
if (success) {
isBookmarked = !isBookmarked;
} else {
alert(`Failed to ${isBookmarked ? 'remove' : 'add'} bookmark. Please try again.`);
}
} catch (err) {
console.error('Failed to toggle bookmark:', err);
alert(`Failed to ${isBookmarked ? 'remove' : 'add'} bookmark: ${String(err)}`);
} finally {
loadingBookmark = false;
}
}
async function checkMaintainerStatus() { async function checkMaintainerStatus() {
if (repoNotFound || !userPubkey) { if (repoNotFound || !userPubkey) {
isMaintainer = false; isMaintainer = false;
@ -1552,6 +1618,15 @@
<button onclick={forkRepository} disabled={forking} class="fork-button"> <button onclick={forkRepository} disabled={forking} class="fork-button">
{forking ? 'Forking...' : 'Fork'} {forking ? 'Forking...' : 'Fork'}
</button> </button>
<button
onclick={toggleBookmark}
disabled={loadingBookmark || !repoAddress}
class="bookmark-button"
class:bookmarked={isBookmarked}
title={isBookmarked ? 'Remove bookmark' : 'Add bookmark'}
>
{loadingBookmark ? '...' : (isBookmarked ? '★ Bookmarked' : '☆ Bookmark')}
</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}
@ -2370,6 +2445,39 @@
align-self: flex-end; align-self: flex-end;
} }
.bookmark-button {
padding: 0.5rem 1rem;
font-size: 0.875rem;
font-weight: 500;
border: 1px solid var(--border-color);
border-radius: 0.375rem;
background: var(--bg-primary);
color: var(--text-primary);
cursor: pointer;
transition: all 0.2s ease;
font-family: 'IBM Plex Serif', serif;
}
.bookmark-button:hover:not(:disabled) {
background: var(--bg-secondary);
border-color: var(--accent);
}
.bookmark-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.bookmark-button.bookmarked {
background: var(--accent);
color: var(--accent-text, white);
border-color: var(--accent);
}
.bookmark-button.bookmarked:hover:not(:disabled) {
opacity: 0.9;
}
.repo-banner { .repo-banner {
width: 100%; width: 100%;
height: 200px; height: 200px;

211
src/routes/signup/+page.svelte

@ -253,6 +253,119 @@
documentation = newDocs; documentation = newDocs;
} }
async function lookupDocumentation(index: number) {
const doc = documentation[index]?.trim();
if (!doc) {
lookupError[`doc-${index}`] = 'Please enter a naddr to lookup';
return;
}
const lookupKey = `doc-${index}`;
lookupLoading[lookupKey] = true;
lookupError[lookupKey] = null;
lookupResults[lookupKey] = null;
try {
// Try to decode as naddr
let decoded;
try {
decoded = nip19.decode(doc);
} catch {
lookupError[lookupKey] = 'Invalid naddr format';
return;
}
if (decoded.type !== 'naddr') {
lookupError[lookupKey] = 'Please enter a valid naddr (naddr1...)';
return;
}
// Extract the components from the decoded naddr
const { pubkey, kind, identifier, relays } = decoded.data as {
pubkey: string;
kind: number;
identifier: string;
relays?: string[];
};
if (!pubkey || !kind || !identifier) {
lookupError[lookupKey] = 'Invalid naddr: missing required components';
return;
}
// Convert to the format needed for documentation tag: kind:pubkey:identifier
// If there's a relay hint, we can optionally add it, but the standard format is kind:pubkey:identifier
const docFormat = `${kind}:${pubkey}:${identifier}`;
// Update the documentation field with the converted format
const newDocs = [...documentation];
newDocs[index] = docFormat;
documentation = newDocs;
// Also fetch the event to show some info
const relaysToUse = relays && relays.length > 0 ? relays : getSearchRelays();
const client = new NostrClient(relaysToUse);
const events = await client.fetchEvents([
{
kinds: [kind],
authors: [pubkey],
'#d': [identifier],
limit: 1
}
]);
if (events.length > 0) {
lookupResults[lookupKey] = events;
lookupError[lookupKey] = null;
} else {
// Still update the field even if we can't fetch the event
lookupError[lookupKey] = 'Documentation address converted, but event not found on relays';
}
} catch (err) {
lookupError[lookupKey] = `Lookup failed: ${String(err)}`;
} finally {
lookupLoading[lookupKey] = false;
}
}
/**
* Publish event with retry logic
* Retries failed relays up to maxRetries times
*/
async function publishWithRetry(
client: NostrClient,
event: NostrEvent,
relays: string[],
maxRetries: number = 2
): Promise<{ success: string[]; failed: Array<{ relay: string; error: string }> }> {
let result = await client.publishEvent(event, relays);
// If we have some successes, return early
if (result.success.length > 0) {
return result;
}
// If all failed and we have retries left, retry only the failed relays
if (result.failed.length > 0 && maxRetries > 0) {
console.log(`Retrying publish to ${result.failed.length} failed relay(s)...`);
const failedRelays = result.failed.map((f: { relay: string; error: string }) => f.relay);
// Wait a bit before retry
await new Promise(resolve => setTimeout(resolve, 1000));
const retryResult = await client.publishEvent(event, failedRelays);
// Merge results
return {
success: [...result.success, ...retryResult.success],
failed: retryResult.failed
};
}
return result;
}
async function handleWebUrlHover(index: number, url: string) { async function handleWebUrlHover(index: number, url: string) {
// Clear any existing timeout // Clear any existing timeout
if (previewTimeout) { if (previewTimeout) {
@ -462,8 +575,8 @@
} }
// Filter private repos // Filter private repos
events = await Promise.all( const filteredPrivateEvents = await Promise.all(
filteredEvents.map(async (event) => { filteredEvents.map(async (event): Promise<NostrEvent | null> => {
const isPrivate = event.tags.some(t => const isPrivate = event.tags.some(t =>
(t[0] === 'private' && t[1] === 'true') || (t[0] === 'private' && t[1] === 'true') ||
(t[0] === 't' && t[1] === 'private') (t[0] === 't' && t[1] === 'private')
@ -503,7 +616,7 @@
return null; return null;
}) })
); );
events = events.filter(e => e !== null) as NostrEvent[]; events = filteredPrivateEvents.filter((e): e is NostrEvent => e !== null);
} }
if (events.length === 0) { if (events.length === 0) {
@ -1260,22 +1373,64 @@
}; };
// Sign with NIP-07 // Sign with NIP-07
console.log('Signing repository announcement event...');
const signedEvent = await signEventWithNIP07(eventTemplate); const signedEvent = await signEventWithNIP07(eventTemplate);
console.log('Event signed successfully, event ID:', signedEvent.id);
// Get user's inbox/outbox relays (from kind 10002) using full relay set to find newest // Get user's inbox/outbox relays (from kind 10002) using comprehensive relay set
console.log('Fetching user relays from comprehensive relay set...');
const { getUserRelays } = await import('../../lib/services/nostr/user-relays.js'); const { getUserRelays } = await import('../../lib/services/nostr/user-relays.js');
// Use comprehensive relay set to ensure we get the newest kind 10002 event
const fullRelaySet = combineRelays([], [...DEFAULT_NOSTR_SEARCH_RELAYS, ...DEFAULT_NOSTR_RELAYS]);
const fullRelayClient = new NostrClient(fullRelaySet);
const { inbox, outbox } = await getUserRelays(pubkey, fullRelayClient);
// Combine user's outbox with default relays // Use comprehensive relay set including ALL search relays and default relays
const userRelays = combineRelays(outbox); // This ensures we can find the user's kind 10002 event even if it's on a less common relay
const allSearchRelays = [...new Set([...DEFAULT_NOSTR_SEARCH_RELAYS, ...DEFAULT_NOSTR_RELAYS])];
console.log('Querying kind 10002 from relays:', allSearchRelays);
const fullRelayClient = new NostrClient(allSearchRelays);
let userRelays: string[] = [];
try {
const { inbox, outbox } = await getUserRelays(pubkey, fullRelayClient);
console.log('User relays fetched - inbox:', inbox.length, 'outbox:', outbox.length);
if (inbox.length > 0) console.log('Inbox relays:', inbox);
if (outbox.length > 0) console.log('Outbox relays:', outbox);
// If we found user relays, use them; otherwise fall back to defaults
if (outbox.length > 0) {
// Use user's outbox relays (these are the relays the user prefers for publishing)
// Combine with defaults as fallback
userRelays = combineRelays(outbox, DEFAULT_NOSTR_RELAYS);
console.log('Using user outbox relays for publishing:', outbox);
} else if (inbox.length > 0) {
// If no outbox but have inbox, use inbox relays (some users only set inbox)
userRelays = combineRelays(inbox, DEFAULT_NOSTR_RELAYS);
console.log('Using user inbox relays for publishing (no outbox found):', inbox);
} else {
// No user relays found, use defaults
userRelays = DEFAULT_NOSTR_RELAYS;
console.warn('No user relays found in kind 10002, using default relays only');
}
} catch (err) {
console.warn('Failed to fetch user relays, using defaults:', err);
// Fall back to default relays if user relay fetch fails
userRelays = DEFAULT_NOSTR_RELAYS;
}
// Ensure we have at least some relays to publish to
if (userRelays.length === 0) {
console.warn('No relays available, using default relays');
userRelays = DEFAULT_NOSTR_RELAYS;
}
console.log('Final relay set for publishing:', userRelays);
console.log('Using relays for publishing:', userRelays);
// Publish repository announcement // Publish repository announcement with retry logic
const result = await nostrClient.publishEvent(signedEvent, userRelays); let publishResult = await publishWithRetry(nostrClient, signedEvent, userRelays, 2);
console.log('Publish result:', publishResult);
if (result.success.length > 0) { if (publishResult.success.length > 0) {
console.log(`Successfully published to ${publishResult.success.length} relay(s):`, publishResult.success);
// Create and publish initial ownership proof (self-transfer event) // Create and publish initial ownership proof (self-transfer event)
const { OwnershipTransferService } = await import('../../lib/services/nostr/ownership-transfer-service.js'); const { OwnershipTransferService } = await import('../../lib/services/nostr/ownership-transfer-service.js');
const ownershipService = new OwnershipTransferService(userRelays); const ownershipService = new OwnershipTransferService(userRelays);
@ -1289,11 +1444,18 @@
}); });
success = true; success = true;
// Redirect to the newly created repository page
// Use invalidateAll to ensure the repos list refreshes
const userNpub = nip19.npubEncode(pubkey);
setTimeout(() => { setTimeout(() => {
goto('/'); // Invalidate all caches and redirect
goto(`/repos/${userNpub}/${dTag}`, { invalidateAll: true, replaceState: false });
}, 2000); }, 2000);
} else { } else {
error = 'Failed to publish to any relays.'; // Show detailed error information
const errorDetails = publishResult.failed.map(f => `${f.relay}: ${f.error}`).join('\n');
error = `Failed to publish to any relays after retries.\n\nRelays attempted: ${userRelays.length}\n\nErrors:\n${errorDetails || 'Unknown error'}\n\nPlease check:\n• Your internet connection\n• Relay availability\n• Try again in a few moments`;
console.error('Failed to publish repository announcement:', publishResult);
} }
} catch (e) { } catch (e) {
@ -1837,9 +1999,18 @@
type="text" type="text"
value={doc} value={doc}
oninput={(e) => updateDocumentation(index, e.currentTarget.value)} oninput={(e) => updateDocumentation(index, e.currentTarget.value)}
placeholder="30818:pubkey:d-tag" placeholder="naddr1... or 30818:pubkey:d-tag"
disabled={loading} disabled={loading}
/> />
<button
type="button"
onclick={() => lookupDocumentation(index)}
disabled={loading || lookupLoading[`doc-${index}`]}
class="lookup-button"
title="Lookup naddr and convert to documentation format"
>
{lookupLoading[`doc-${index}`] ? '...' : 'Lookup'}
</button>
{#if documentation.length > 1} {#if documentation.length > 1}
<button <button
type="button" type="button"
@ -1850,6 +2021,14 @@
</button> </button>
{/if} {/if}
</div> </div>
{#if lookupError[`doc-${index}`]}
<div class="error-text">{lookupError[`doc-${index}`]}</div>
{/if}
{#if lookupResults[`doc-${index}`]}
<div class="lookup-results">
<small>✓ Documentation address converted successfully</small>
</div>
{/if}
{/each} {/each}
<button <button
type="button" type="button"

Loading…
Cancel
Save