diff --git a/src/hooks.server.ts b/src/hooks.server.ts
index d8ebb3f..076dd7b 100644
--- a/src/hooks.server.ts
+++ b/src/hooks.server.ts
@@ -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 }) => {
"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'"
diff --git a/src/lib/components/NavBar.svelte b/src/lib/components/NavBar.svelte
index 716aad9..ad5ee09 100644
--- a/src/lib/components/NavBar.svelte
+++ b/src/lib/components/NavBar.svelte
@@ -98,7 +98,7 @@
@@ -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 {
diff --git a/src/lib/services/messaging/event-forwarder.ts b/src/lib/services/messaging/event-forwarder.ts
index b477206..0e97dbd 100644
--- a/src/lib/services/messaging/event-forwarder.ts
+++ b/src/lib/services/messaging/event-forwarder.ts
@@ -128,27 +128,52 @@ const GIT_PLATFORM_CONFIGS: Record> =
}
};
+// 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(
}
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(
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,
diff --git a/src/lib/services/messaging/preferences-storage.ts b/src/lib/services/messaging/preferences-storage.ts
index 4a0cb9d..18fba04 100644
--- a/src/lib/services/messaging/preferences-storage.ts
+++ b/src/lib/services/messaging/preferences-storage.ts
@@ -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(() => {
*/
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
return { allowed: true, remaining: MAX_DECRYPTION_ATTEMPTS - attempt.count };
}
+
/**
* Encrypt data with AES-256-GCM
*/
@@ -216,6 +220,10 @@ export async function storePreferences(
userPubkeyHex: string,
preferences: MessagingPreferences
): Promise {
+ 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(
export async function getPreferences(
userPubkeyHex: string
): Promise {
+ 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<{
};
notifyOn?: string[];
} | null> {
- const preferences = await getPreferences(userPubkeyHex);
-
- if (!preferences) {
+ try {
+ // If not configured, return null (not configured)
+ 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 {
- 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
- };
}
diff --git a/src/lib/services/nostr/bookmarks-service.ts b/src/lib/services/nostr/bookmarks-service.ts
new file mode 100644
index 0000000..a9d8d6b
--- /dev/null
+++ b/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 {
+ 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> {
+ const bookmarks = await this.getBookmarks(pubkey);
+ if (!bookmarks) {
+ return new Set();
+ }
+
+ const repoAddresses = new Set();
+ 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 {
+ 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 {
+ 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 = {
+ 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 {
+ 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 = {
+ 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;
+ }
+ }
+}
diff --git a/src/lib/services/nostr/user-relays.ts b/src/lib/services/nostr/user-relays.ts
index e0c4c4c..d2421cb 100644
--- a/src/lib/services/nostr/user-relays.ts
+++ b/src/lib/services/nostr/user-relays.ts
@@ -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(
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
diff --git a/src/lib/types/nostr.ts b/src/lib/types/nostr.ts
index b4aba9e..5406c7c 100644
--- a/src/lib/types/nostr.ts
+++ b/src/lib/types/nostr.ts
@@ -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)
diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte
index 7cb54e0..83847fe 100644
--- a/src/routes/+page.svelte
+++ b/src/routes/+page.svelte
@@ -14,12 +14,31 @@
let checkingLevel = $state(false);
let levelMessage = $state(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 @@
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 @@
}
} 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;
}
diff --git a/src/routes/api/search/+server.ts b/src/routes/api/search/+server.ts
index 2e8d4e0..728cf6f 100644
--- a/src/routes/api/search/+server.ts
+++ b/src/routes/api/search/+server.ts
@@ -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;
diff --git a/src/routes/api/user/messaging-preferences/summary/+server.ts b/src/routes/api/user/messaging-preferences/summary/+server.ts
index ef275bc..988b7a0 100644
--- a/src/routes/api/user/messaging-preferences/summary/+server.ts
+++ b/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: {}
+ });
}
};
diff --git a/src/routes/api/users/[npub]/repos/+server.ts b/src/routes/api/users/[npub]/repos/+server.ts
index 96947e3..9a9bc79 100644
--- a/src/routes/api/users/[npub]/repos/+server.ts
+++ b/src/routes/api/users/[npub]/repos/+server.ts
@@ -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) => {
}
]);
+ // Get viewer's bookmarked repos if authenticated
+ let bookmarkedRepos: Set = 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) => {
.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) => {
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;
diff --git a/src/routes/repos/+page.svelte b/src/routes/repos/+page.svelte
index c277116..1c0493a 100644
--- a/src/routes/repos/+page.svelte
+++ b/src/routes/repos/+page.svelte
@@ -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;
diff --git a/src/routes/repos/[npub]/[repo]/+page.svelte b/src/routes/repos/[npub]/[repo]/+page.svelte
index a3c0e3e..148dea7 100644
--- a/src/routes/repos/[npub]/[repo]/+page.svelte
+++ b/src/routes/repos/[npub]/[repo]/+page.svelte
@@ -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 @@
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(null);
+
// Repository images
let repoImage = $state(null);
let repoBanner = $state(null);
@@ -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 @@
await checkAuth();
await loadTags();
await checkMaintainerStatus();
+ await loadBookmarkStatus();
await checkVerification();
await loadReadme();
await loadForkInfo();
@@ -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 @@
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 @@
}
+ 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 @@
+
{#if isMaintainer}
Settings
{/if}
@@ -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;
diff --git a/src/routes/signup/+page.svelte b/src/routes/signup/+page.svelte
index 9d4de3e..cc617af 100644
--- a/src/routes/signup/+page.svelte
+++ b/src/routes/signup/+page.svelte
@@ -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 @@
}
// Filter private repos
- events = await Promise.all(
- filteredEvents.map(async (event) => {
+ const filteredPrivateEvents = await Promise.all(
+ filteredEvents.map(async (event): Promise => {
const isPrivate = event.tags.some(t =>
(t[0] === 'private' && t[1] === 'true') ||
(t[0] === 't' && t[1] === 'private')
@@ -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 @@
};
// 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);
+
+ 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
- const result = await nostrClient.publishEvent(signedEvent, userRelays);
+ // Publish repository announcement with retry logic
+ 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)
const { OwnershipTransferService } = await import('../../lib/services/nostr/ownership-transfer-service.js');
const ownershipService = new OwnershipTransferService(userRelays);
@@ -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 @@
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}
/>
+
{#if documentation.length > 1}