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

4
src/lib/components/NavBar.svelte

@ -98,7 +98,7 @@ @@ -98,7 +98,7 @@
<div class="nav-links">
<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="/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>
</div>
</nav>
@ -148,6 +148,8 @@ @@ -148,6 +148,8 @@
border-bottom: 1px solid var(--border-color);
margin-bottom: 2rem;
background: var(--bg-primary);
position: relative;
z-index: 100;
}
.header-container {

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

@ -128,27 +128,52 @@ const GIT_PLATFORM_CONFIGS: Record<string, Omit<GitPlatformConfig, 'baseUrl'>> = @@ -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 = {
telegram: {
botToken: process.env.TELEGRAM_BOT_TOKEN || '',
enabled: process.env.TELEGRAM_ENABLED === 'true'
botToken: getEnv('TELEGRAM_BOT_TOKEN'),
enabled: getEnvBool('TELEGRAM_ENABLED')
},
simplex: {
apiUrl: process.env.SIMPLEX_API_URL || '',
apiKey: process.env.SIMPLEX_API_KEY || '',
enabled: process.env.SIMPLEX_ENABLED === 'true'
apiUrl: getEnv('SIMPLEX_API_URL'),
apiKey: getEnv('SIMPLEX_API_KEY'),
enabled: getEnvBool('SIMPLEX_ENABLED')
},
email: {
smtpHost: process.env.SMTP_HOST || '',
smtpPort: parseInt(process.env.SMTP_PORT || '587', 10),
smtpUser: process.env.SMTP_USER || '',
smtpPassword: process.env.SMTP_PASSWORD || '',
fromAddress: process.env.SMTP_FROM_ADDRESS || '',
fromName: process.env.SMTP_FROM_NAME || 'GitRepublic',
enabled: process.env.EMAIL_ENABLED === 'true'
smtpHost: getEnv('SMTP_HOST'),
smtpPort: getEnvInt('SMTP_PORT', 587),
smtpUser: getEnv('SMTP_USER'),
smtpPassword: getEnv('SMTP_PASSWORD'),
fromAddress: getEnv('SMTP_FROM_ADDRESS'),
fromName: getEnv('SMTP_FROM_NAME', 'GitRepublic'),
enabled: getEnvBool('EMAIL_ENABLED')
},
gitPlatforms: {
enabled: process.env.GIT_PLATFORMS_ENABLED === 'true'
enabled: getEnvBool('GIT_PLATFORMS_ENABLED')
}
};
@ -441,7 +466,7 @@ async function sendEmail( @@ -441,7 +466,7 @@ async function sendEmail(
}
try {
const smtpUrl = process.env.SMTP_API_URL;
const smtpUrl = getEnv('SMTP_API_URL');
if (smtpUrl) {
// Use SMTP API if provided
@ -449,7 +474,7 @@ async function sendEmail( @@ -449,7 +474,7 @@ async function sendEmail(
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${process.env.SMTP_API_KEY || ''}`
'Authorization': `Bearer ${getEnv('SMTP_API_KEY')}`
},
body: JSON.stringify({
from: MESSAGING_CONFIG.email.fromAddress,

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

@ -30,15 +30,16 @@ import logger from '../logger.js'; @@ -30,15 +30,16 @@ import logger from '../logger.js';
import { getCachedUserLevel } from '../security/user-level-cache.js';
// 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 SALT_ENCRYPTION_KEY = process.env.MESSAGING_SALT_ENCRYPTION_KEY;
const LOOKUP_SECRET = process.env.MESSAGING_LOOKUP_SECRET;
if (!ENCRYPTION_KEY || !SALT_ENCRYPTION_KEY || !LOOKUP_SECRET) {
throw new Error(
'Missing required environment variables: ' +
'MESSAGING_PREFS_ENCRYPTION_KEY, MESSAGING_SALT_ENCRYPTION_KEY, MESSAGING_LOOKUP_SECRET'
);
// Check if messaging preferences are configured
const isMessagingConfigured = !!(ENCRYPTION_KEY && SALT_ENCRYPTION_KEY && 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 {
@ -90,17 +91,19 @@ setInterval(() => { @@ -90,17 +91,19 @@ setInterval(() => {
*/
function getLookupKey(userPubkeyHex: string): string {
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)
.update(userPubkeyHex)
.digest('hex');
}
/**
* Check and enforce rate limiting on decryption attempts
*/
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 now = Date.now();
@ -125,6 +128,7 @@ function checkRateLimit(userPubkeyHex: string): { allowed: boolean; remaining: n @@ -125,6 +128,7 @@ function checkRateLimit(userPubkeyHex: string): { allowed: boolean; remaining: n
return { allowed: true, remaining: MAX_DECRYPTION_ATTEMPTS - attempt.count };
}
/**
* Encrypt data with AES-256-GCM
*/
@ -216,6 +220,10 @@ export async function storePreferences( @@ -216,6 +220,10 @@ export async function storePreferences(
userPubkeyHex: string,
preferences: MessagingPreferences
): 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
const cached = getCachedUserLevel(userPubkeyHex);
if (!cached || cached.level !== 'unlimited') {
@ -257,6 +265,11 @@ export async function storePreferences( @@ -257,6 +265,11 @@ export async function storePreferences(
export async function getPreferences(
userPubkeyHex: string
): Promise<MessagingPreferences | null> {
if (!isMessagingConfigured) {
// If not configured, return null (no preferences stored)
return null;
}
// Check rate limit
const rateLimit = checkRateLimit(userPubkeyHex);
if (!rateLimit.allowed) {
@ -365,27 +378,39 @@ export async function getPreferencesSummary(userPubkeyHex: string): Promise<{ @@ -365,27 +378,39 @@ export async function getPreferencesSummary(userPubkeyHex: string): Promise<{
};
notifyOn?: string[];
} | null> {
const preferences = await getPreferences(userPubkeyHex);
try {
// If not configured, return null (not configured)
if (!isMessagingConfigured) {
return null;
}
const preferences = await getPreferences(userPubkeyHex);
if (!preferences) {
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 {
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 @@ @@ -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( @@ -17,18 +17,32 @@ export async function getUserRelays(
try {
// 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([
{
kinds: [KIND.RELAY_LIST],
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) {
// Sort by created_at descending to get the newest event first
relayListEvents.sort((a, b) => b.created_at - a.created_at);
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) {
if (tag[0] === 'relay' && tag[1]) {
const relay = tag[1];
@ -39,6 +53,14 @@ export async function getUserRelays( @@ -39,6 +53,14 @@ export async function getUserRelays(
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

1
src/lib/types/nostr.ts

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

39
src/routes/+page.svelte

@ -14,12 +14,31 @@ @@ -14,12 +14,31 @@
let checkingLevel = $state(false);
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(() => {
// Prevent body scroll when splash page is shown
document.body.style.overflow = 'hidden';
// Check auth asynchronously
checkAuth();
// Check userStore first - if user has logged out, don't check extension
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 () => {
@ -30,6 +49,16 @@ @@ -30,6 +49,16 @@
async function checkAuth() {
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()) {
try {
userPubkey = await getPublicKeyWithNIP07();
@ -53,7 +82,13 @@ @@ -53,7 +82,13 @@
}
} catch (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;
}

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

@ -105,9 +105,18 @@ export const GET: RequestHandler = async (event) => { @@ -105,9 +105,18 @@ export const GET: RequestHandler = async (event) => {
if (!isPrivate) {
canView = true; // Public repos are viewable by anyone
} else if (userPubkey) {
// Private repos require authentication
// Private repos require authentication - check if user owns, maintains, or has bookmarked
try {
// Check if user is owner or maintainer
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) {
logger.warn({ error: err, pubkey: event.pubkey, repo: repoId }, 'Failed to check repo access in search');
canView = false;

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

@ -53,6 +53,24 @@ export const GET: RequestHandler = async (event) => { @@ -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'; @@ -7,17 +7,20 @@ import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { NostrClient } from '$lib/services/nostr/nostr-client.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 { nip19 } from 'nostr-tools';
import { handleApiError, handleValidationError } from '$lib/utils/error-handler.js';
import { extractRequestContext } from '$lib/utils/api-context.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 { RequestEvent } from '@sveltejs/kit';
const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS);
const maintainerService = new MaintainerService(DEFAULT_NOSTR_RELAYS);
const bookmarksService = new BookmarksService(DEFAULT_NOSTR_SEARCH_RELAYS);
export const GET: RequestHandler = async (event) => {
try {
@ -51,6 +54,16 @@ export const GET: RequestHandler = async (event) => { @@ -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[] = [];
// Process each announcement with privacy filtering
@ -60,10 +73,6 @@ export const GET: RequestHandler = async (event) => { @@ -60,10 +73,6 @@ export const GET: RequestHandler = async (event) => {
.flatMap(t => t.slice(1))
.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
const dTag = event.tags.find(t => t[0] === 'd')?.[1];
if (!dTag) continue;
@ -79,9 +88,16 @@ export const GET: RequestHandler = async (event) => { @@ -79,9 +88,16 @@ export const GET: RequestHandler = async (event) => {
if (!isPrivate) {
canView = true; // Public repos are viewable by anyone
} else if (viewerPubkey) {
// Private repos require authentication
// Private repos require authentication - check if viewer owns, maintains, or has bookmarked
try {
// Check if viewer is owner or maintainer
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) {
logger.warn({ error: err, pubkey: userPubkey, repo: dTag }, 'Failed to check repo access');
canView = false;

14
src/routes/repos/+page.svelte

@ -38,6 +38,20 @@ @@ -38,6 +38,20 @@
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() {
if (!isNIP07Available()) {
return;

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

@ -8,8 +8,9 @@ @@ -8,8 +8,9 @@
import ForwardingConfig from '$lib/components/ForwardingConfig.svelte';
import { getPublicKeyWithNIP07, isNIP07Available } from '$lib/services/nostr/nip07-signer.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 { BookmarksService } from '$lib/services/nostr/bookmarks-service.js';
import { KIND } from '$lib/types/nostr.js';
import { nip19 } from 'nostr-tools';
@ -127,6 +128,12 @@ @@ -127,6 +128,12 @@
let forkInfo = $state<{ isFork: boolean; originalRepo: { npub: string; repo: string } | null } | null>(null);
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
let repoImage = $state<string | null>(null);
let repoBanner = $state<string | null>(null);
@ -745,6 +752,20 @@ @@ -745,6 +752,20 @@
});
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();
// Skip other API calls if repository doesn't exist
if (repoNotFound) {
@ -759,6 +780,7 @@ @@ -759,6 +780,7 @@
await checkAuth();
await loadTags();
await checkMaintainerStatus();
await loadBookmarkStatus();
await checkVerification();
await loadReadme();
await loadForkInfo();
@ -769,8 +791,9 @@ @@ -769,8 +791,9 @@
try {
if (isNIP07Available()) {
userPubkey = await getPublicKeyWithNIP07();
// Recheck maintainer status after auth
// Recheck maintainer status and bookmark status after auth
await checkMaintainerStatus();
await loadBookmarkStatus();
}
} catch (err) {
console.log('NIP-07 not available or user not connected');
@ -785,8 +808,9 @@ @@ -785,8 +808,9 @@
return;
}
userPubkey = await getPublicKeyWithNIP07();
// Re-check maintainer status after login
// Re-check maintainer status and bookmark status after login
await checkMaintainerStatus();
await loadBookmarkStatus();
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to connect';
console.error('Login error:', err);
@ -794,6 +818,48 @@ @@ -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() {
if (repoNotFound || !userPubkey) {
isMaintainer = false;
@ -1552,6 +1618,15 @@ @@ -1552,6 +1618,15 @@
<button onclick={forkRepository} disabled={forking} class="fork-button">
{forking ? 'Forking...' : 'Fork'}
</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}
<a href={`/repos/${npub}/${repo}/settings`} class="settings-button">Settings</a>
{/if}
@ -2370,6 +2445,39 @@ @@ -2370,6 +2445,39 @@
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 {
width: 100%;
height: 200px;

211
src/routes/signup/+page.svelte

@ -253,6 +253,119 @@ @@ -253,6 +253,119 @@
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) {
// Clear any existing timeout
if (previewTimeout) {
@ -462,8 +575,8 @@ @@ -462,8 +575,8 @@
}
// Filter private repos
events = await Promise.all(
filteredEvents.map(async (event) => {
const filteredPrivateEvents = await Promise.all(
filteredEvents.map(async (event): Promise<NostrEvent | null> => {
const isPrivate = event.tags.some(t =>
(t[0] === 'private' && t[1] === 'true') ||
(t[0] === 't' && t[1] === 'private')
@ -503,7 +616,7 @@ @@ -503,7 +616,7 @@
return null;
})
);
events = events.filter(e => e !== null) as NostrEvent[];
events = filteredPrivateEvents.filter((e): e is NostrEvent => e !== null);
}
if (events.length === 0) {
@ -1260,22 +1373,64 @@ @@ -1260,22 +1373,64 @@
};
// Sign with NIP-07
console.log('Signing repository announcement event...');
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');
// 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
const userRelays = combineRelays(outbox);
// Use comprehensive relay set including ALL search relays and default relays
// 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);
// Publish repository announcement
const result = await nostrClient.publishEvent(signedEvent, userRelays);
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);
if (result.success.length > 0) {
console.log('Using relays for publishing:', userRelays);
// Publish repository announcement with retry logic
let publishResult = await publishWithRetry(nostrClient, signedEvent, userRelays, 2);
console.log('Publish result:', publishResult);
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)
const { OwnershipTransferService } = await import('../../lib/services/nostr/ownership-transfer-service.js');
const ownershipService = new OwnershipTransferService(userRelays);
@ -1289,11 +1444,18 @@ @@ -1289,11 +1444,18 @@
});
success = true;
// Redirect to the newly created repository page
// Use invalidateAll to ensure the repos list refreshes
const userNpub = nip19.npubEncode(pubkey);
setTimeout(() => {
goto('/');
// Invalidate all caches and redirect
goto(`/repos/${userNpub}/${dTag}`, { invalidateAll: true, replaceState: false });
}, 2000);
} 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) {
@ -1837,9 +1999,18 @@ @@ -1837,9 +1999,18 @@
type="text"
value={doc}
oninput={(e) => updateDocumentation(index, e.currentTarget.value)}
placeholder="30818:pubkey:d-tag"
placeholder="naddr1... or 30818:pubkey:d-tag"
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}
<button
type="button"
@ -1850,6 +2021,14 @@ @@ -1850,6 +2021,14 @@
</button>
{/if}
</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}
<button
type="button"

Loading…
Cancel
Save