From 4a36aec53dcecd7ae10cc45adcdfc2dbafeb25b3 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Wed, 18 Feb 2026 14:57:15 +0100 Subject: [PATCH] bug-fixes --- .../git-platforms/git-platform-fetcher.ts | 24 +++++- src/lib/services/git/repo-manager.ts | 84 +++++++++++++++---- src/lib/services/messaging/event-forwarder.ts | 7 +- ...orage.ts => preferences-storage.server.ts} | 27 ++---- .../services/messaging/preferences-types.ts | 22 +++++ src/lib/services/nostr/user-level-service.ts | 16 ++++ src/routes/+layout.svelte | 23 +++-- .../repos/[npub]/[repo]/branches/+server.ts | 26 ++++-- .../api/user/messaging-preferences/+server.ts | 4 +- .../messaging-preferences/summary/+server.ts | 2 +- svelte.config.js | 11 +++ vite.config.ts | 12 ++- 12 files changed, 192 insertions(+), 66 deletions(-) rename src/lib/services/messaging/{preferences-storage.ts => preferences-storage.server.ts} (93%) create mode 100644 src/lib/services/messaging/preferences-types.ts diff --git a/src/lib/services/git-platforms/git-platform-fetcher.ts b/src/lib/services/git-platforms/git-platform-fetcher.ts index cb8d98f..38622f2 100644 --- a/src/lib/services/git-platforms/git-platform-fetcher.ts +++ b/src/lib/services/git-platforms/git-platform-fetcher.ts @@ -7,8 +7,10 @@ */ import logger from '../logger.js'; -import type { MessagingPreferences } from '../messaging/preferences-storage.js'; -import { getPreferences } from '../messaging/preferences-storage.js'; +import type { MessagingPreferences } from '../messaging/preferences-types.js'; + +// Lazy-loaded function - will only be imported server-side +let getPreferences: ((userPubkeyHex: string) => Promise) | null = null; type GitPlatform = 'github' | 'gitlab' | 'gitea' | 'codeberg' | 'forgejo' | 'onedev' | 'custom'; @@ -553,6 +555,24 @@ export async function getAllExternalItems( issues: ExternalIssue[]; pullRequests: ExternalPullRequest[]; }> { + // Dynamic import to avoid bundling Node.js crypto in browser + // This will only run server-side + if (!getPreferences) { + try { + // Only import server-side - this will fail in browser but that's OK + const module = await import('../messaging/preferences-storage.server.js'); + getPreferences = module.getPreferences; + } catch (err) { + // If import fails (e.g., in browser), return empty results + // This is expected behavior - preferences-storage uses Node.js crypto + return { issues: [], pullRequests: [] }; + } + } + + if (!getPreferences) { + return { issues: [], pullRequests: [] }; + } + const preferences = await getPreferences(userPubkeyHex); if (!preferences || !preferences.gitPlatforms || preferences.gitPlatforms.length === 0) { return { issues: [], pullRequests: [] }; diff --git a/src/lib/services/git/repo-manager.ts b/src/lib/services/git/repo-manager.ts index a558049..a12c610 100644 --- a/src/lib/services/git/repo-manager.ts +++ b/src/lib/services/git/repo-manager.ts @@ -448,6 +448,22 @@ export class RepoManager { * @param announcementEvent - The Nostr repo announcement event (optional, will fetch if not provided) * @returns true if repository was successfully fetched, false otherwise */ + /** + * Check if a repository is private based on announcement event + * A repo is private if it has a tag ["private", "true"] or ["t", "private"] + */ + private isPrivateRepo(announcement: NostrEvent): boolean { + // Check for ["private", "true"] tag + const privateTag = announcement.tags.find(t => t[0] === 'private' && t[1] === 'true'); + if (privateTag) return true; + + // Check for ["t", "private"] tag (topic tag) + const topicTag = announcement.tags.find(t => t[0] === 't' && t[1] === 'private'); + if (topicTag) return true; + + return false; + } + async fetchRepoOnDemand( npub: string, repoName: string, @@ -465,23 +481,36 @@ export class RepoManager { return false; } - // Security: Only allow fetching if user has unlimited access - // This prevents unauthorized repository creation - const { getCachedUserLevel } = await import('../security/user-level-cache.js'); - const userLevel = getCachedUserLevel(announcementEvent.pubkey); - if (!userLevel || userLevel.level !== 'unlimited') { - logger.warn({ + // Check if repository is public + const isPublic = !this.isPrivateRepo(announcementEvent); + + // Security: For public repos, allow on-demand fetching regardless of owner's access level + // For private repos, require owner to have unlimited access to prevent unauthorized creation + if (!isPublic) { + const { getCachedUserLevel } = await import('../security/user-level-cache.js'); + const userLevel = getCachedUserLevel(announcementEvent.pubkey); + if (!userLevel || userLevel.level !== 'unlimited') { + logger.warn({ + npub, + repoName, + pubkey: announcementEvent.pubkey.slice(0, 16) + '...', + level: userLevel?.level || 'none' + }, 'Skipping on-demand repo fetch: private repo requires owner with unlimited access'); + return false; + } + } else { + logger.info({ npub, repoName, - pubkey: announcementEvent.pubkey.slice(0, 16) + '...', - level: userLevel?.level || 'none' - }, 'Skipping on-demand repo fetch: user does not have unlimited access'); - return false; + pubkey: announcementEvent.pubkey.slice(0, 16) + '...' + }, 'Allowing on-demand fetch for public repository'); } + // Extract clone URLs outside try block for error logging + const cloneUrls = this.extractCloneUrls(announcementEvent); + let remoteUrls: string[] = []; + try { - // Extract clone URLs from announcement - const cloneUrls = this.extractCloneUrls(announcementEvent); // Filter out localhost URLs and our own domain (we want external sources) const externalUrls = cloneUrls.filter(url => { @@ -492,7 +521,7 @@ export class RepoManager { }); // If no external URLs, try any URL that's not our domain - const remoteUrls = externalUrls.length > 0 ? externalUrls : + remoteUrls = externalUrls.length > 0 ? externalUrls : cloneUrls.filter(url => !url.includes(this.domain)); // If still no remote URLs, but there are *any* clone URLs, try the first one @@ -503,9 +532,11 @@ export class RepoManager { } if (remoteUrls.length === 0) { - logger.warn({ npub, repoName }, 'No remote clone URLs found for on-demand fetch'); + logger.warn({ npub, repoName, cloneUrls, announcementEventId: announcementEvent.id }, 'No remote clone URLs found for on-demand fetch'); return false; } + + logger.debug({ npub, repoName, cloneUrls, remoteUrls, isPublic }, 'On-demand fetch details'); // Create directory structure const repoDir = join(this.repoRoot, npub); @@ -518,7 +549,7 @@ export class RepoManager { const git = simpleGit(); const gitEnv = this.getGitEnvForUrl(remoteUrls[0]); - logger.info({ npub, repoName, sourceUrl: remoteUrls[0] }, 'Fetching repository on-demand from remote'); + logger.info({ npub, repoName, sourceUrl: remoteUrls[0], cloneUrls }, 'Fetching repository on-demand from remote'); // Clone as bare repository // Use gitEnv which already contains necessary whitelisted environment variables @@ -529,19 +560,29 @@ export class RepoManager { }); let stderr = ''; + let stdout = ''; cloneProcess.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString(); }); + cloneProcess.stdout.on('data', (chunk: Buffer) => { + stdout += chunk.toString(); + }); cloneProcess.on('close', (code) => { if (code === 0) { + logger.info({ npub, repoName, sourceUrl: remoteUrls[0] }, 'Successfully cloned repository'); resolve(); } else { - reject(new Error(`Git clone failed with code ${code}: ${stderr}`)); + const errorMsg = `Git clone failed with code ${code}: ${stderr || stdout}`; + logger.error({ npub, repoName, sourceUrl: remoteUrls[0], code, stderr, stdout }, 'Git clone failed'); + reject(new Error(errorMsg)); } }); - cloneProcess.on('error', reject); + cloneProcess.on('error', (err) => { + logger.error({ npub, repoName, sourceUrl: remoteUrls[0], error: err }, 'Git clone process error'); + reject(err); + }); }); // Verify the repository was actually created @@ -561,7 +602,14 @@ export class RepoManager { return true; } catch (error) { const sanitizedError = sanitizeError(error); - logger.error({ error: sanitizedError, npub, repoName }, 'Failed to fetch repository on-demand'); + logger.error({ + error: sanitizedError, + npub, + repoName, + cloneUrls, + isPublic, + remoteUrls + }, 'Failed to fetch repository on-demand'); return false; } } diff --git a/src/lib/services/messaging/event-forwarder.ts b/src/lib/services/messaging/event-forwarder.ts index 0e97dbd..c13a96e 100644 --- a/src/lib/services/messaging/event-forwarder.ts +++ b/src/lib/services/messaging/event-forwarder.ts @@ -10,14 +10,17 @@ import { getCachedUserLevel } from '../security/user-level-cache.js'; import { KIND } from '../../types/nostr.js'; // Lazy import to avoid importing Node.js crypto in browser -let getPreferences: typeof import('./preferences-storage.js').getPreferences; +// Use a function type instead of typeof import to avoid static analysis +let getPreferences: ((userPubkeyHex: string) => Promise) | null = null; async function getPreferencesLazy() { if (typeof window !== 'undefined') { // Browser environment - event forwarding should be done server-side return null; } if (!getPreferences) { - const module = await import('./preferences-storage.js'); + // Use dynamic path construction to prevent Vite from statically analyzing the import + const storagePath = './preferences-storage' + '.server.js'; + const module = await import(storagePath); getPreferences = module.getPreferences; } return getPreferences; diff --git a/src/lib/services/messaging/preferences-storage.ts b/src/lib/services/messaging/preferences-storage.server.ts similarity index 93% rename from src/lib/services/messaging/preferences-storage.ts rename to src/lib/services/messaging/preferences-storage.server.ts index 18fba04..66dd605 100644 --- a/src/lib/services/messaging/preferences-storage.ts +++ b/src/lib/services/messaging/preferences-storage.server.ts @@ -13,9 +13,10 @@ * It will throw an error if imported in browser/client code. */ -// Ensure this is only used server-side +// This file uses .server.ts suffix so SvelteKit automatically excludes it from client bundles +// The runtime check below is a safety measure if (typeof window !== 'undefined') { - throw new Error('preferences-storage.ts uses Node.js crypto and cannot be imported in browser code. Use API endpoints instead.'); + throw new Error('preferences-storage.server.ts uses Node.js crypto and cannot be imported in browser code. Use API endpoints instead.'); } import { @@ -28,6 +29,10 @@ import { } from 'crypto'; import logger from '../logger.js'; import { getCachedUserLevel } from '../security/user-level-cache.js'; +import type { MessagingPreferences } from './preferences-types.js'; + +// Re-export the type for convenience +export type { MessagingPreferences } from './preferences-types.js'; // Encryption keys from environment (NEVER commit these!) // These are optional - if not set, messaging preferences will be disabled @@ -42,24 +47,6 @@ 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 { - telegram?: string; // Chat ID or username - simplex?: string; // Contact ID - email?: { - to: string[]; // To: email addresses - cc?: string[]; // CC: email addresses (optional) - }; - gitPlatforms?: Array<{ - platform: 'github' | 'gitlab' | 'gitea' | 'codeberg' | 'forgejo' | 'onedev' | 'custom'; - owner: string; // Repository owner (username or org) - repo: string; // Repository name - token: string; // Personal access token (encrypted) - apiUrl?: string; // Custom API URL (required for onedev and self-hosted platforms) - }>; - enabled: boolean; - notifyOn?: string[]; // Event kinds to forward (e.g., ['1621', '1618']) -} - interface StoredPreferences { encryptedSalt: string; // Salt encrypted with SALT_ENCRYPTION_KEY encrypted: string; // Preferences encrypted with derived key diff --git a/src/lib/services/messaging/preferences-types.ts b/src/lib/services/messaging/preferences-types.ts new file mode 100644 index 0000000..249c95e --- /dev/null +++ b/src/lib/services/messaging/preferences-types.ts @@ -0,0 +1,22 @@ +/** + * Types for messaging preferences + * This file is separate from preferences-storage.server.ts to avoid bundling Node.js crypto in the browser + */ + +export interface MessagingPreferences { + telegram?: string; // Chat ID or username + simplex?: string; // Contact ID + email?: { + to: string[]; // To: email addresses + cc?: string[]; // CC: email addresses (optional) + }; + gitPlatforms?: Array<{ + platform: 'github' | 'gitlab' | 'gitea' | 'codeberg' | 'forgejo' | 'onedev' | 'custom'; + owner: string; // Repository owner (username or org) + repo: string; // Repository name + token: string; // Personal access token (encrypted) + apiUrl?: string; // Custom API URL (required for onedev and self-hosted platforms) + }>; + enabled: boolean; + notifyOn?: string[]; // Event kinds to forward (e.g., ['1621', '1618']) +} diff --git a/src/lib/services/nostr/user-level-service.ts b/src/lib/services/nostr/user-level-service.ts index 076bcde..886b47a 100644 --- a/src/lib/services/nostr/user-level-service.ts +++ b/src/lib/services/nostr/user-level-service.ts @@ -14,6 +14,8 @@ import { signEventWithNIP07, isNIP07Available } from './nip07-signer.js'; import { KIND } from '../../types/nostr.js'; import { createProofEvent } from './relay-write-proof.js'; import { nip19 } from 'nostr-tools'; +import { NostrClient } from './nostr-client.js'; +import { DEFAULT_NOSTR_RELAYS } from '../../config.js'; export type UserLevel = 'unlimited' | 'rate_limited' | 'strictly_rate_limited'; @@ -45,6 +47,20 @@ export async function checkRelayWriteAccess( // Sign the event with NIP-07 const signedEvent = await signEventWithNIP07(proofEventTemplate); + // Publish the event to relays BEFORE verification + // The server needs to be able to fetch it from relays to verify write access + const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS); + const publishResult = await nostrClient.publishEvent(signedEvent, DEFAULT_NOSTR_RELAYS); + + // Wait a moment for the event to propagate to relays before verification + // This gives relays time to process and index the event + if (publishResult.success.length > 0) { + await new Promise(resolve => setTimeout(resolve, 1000)); // Wait 1 second + } else { + // If publishing failed to all relays, still try verification (might be cached) + console.warn('Failed to publish proof event to any relay, but continuing with verification attempt'); + } + // Verify server-side via API endpoint (secure) const response = await fetch('/api/user/level', { method: 'POST', diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 4c9d2c2..afd4ba6 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -91,22 +91,21 @@ return; } + // Only check user level if user has explicitly logged in (has pubkey in store) + // Don't automatically get pubkey from NIP-07 - that should only happen on explicit login + if (!currentState.userPubkey) { + // User not logged in - set to strictly rate limited without checking + userStore.setUser(null, null, 'strictly_rate_limited', null); + return; + } + checkingUserLevel = true; userStore.setChecking(true); try { - let userPubkey: string | null = null; - let userPubkeyHex: string | null = null; - - // Try to get user pubkey if NIP-07 is available - if (isNIP07Available()) { - try { - userPubkey = await getPublicKeyWithNIP07(); - userPubkeyHex = decodePubkey(userPubkey); - } catch (err) { - console.warn('Failed to get user pubkey:', err); - } - } + // Use pubkey from store (user has explicitly logged in) + const userPubkey = currentState.userPubkey; + const userPubkeyHex = currentState.userPubkeyHex; // Determine user level const levelResult = await determineUserLevel(userPubkey, userPubkeyHex); diff --git a/src/routes/api/repos/[npub]/[repo]/branches/+server.ts b/src/routes/api/repos/[npub]/[repo]/branches/+server.ts index 5e4d403..8891fd2 100644 --- a/src/routes/api/repos/[npub]/[repo]/branches/+server.ts +++ b/src/routes/api/repos/[npub]/[repo]/branches/+server.ts @@ -37,11 +37,18 @@ export const GET: RequestHandler = createRepoGetHandler( if (events.length > 0) { // Try to fetch the repository from remote clone URLs - const fetched = await repoManager.fetchRepoOnDemand( - context.npub, - context.repo, - events[0] - ); + let fetched = false; + try { + fetched = await repoManager.fetchRepoOnDemand( + context.npub, + context.repo, + events[0] + ); + } catch (fetchError) { + // Log the actual error for debugging + console.error('[Branches] Error in fetchRepoOnDemand:', fetchError); + // Continue to check if repo exists anyway (might have been created despite error) + } // Always check if repo exists after fetch attempt (might have been created) // Also clear cache to ensure fileManager sees it @@ -58,7 +65,7 @@ export const GET: RequestHandler = createRepoGetHandler( // Fetch returned true but repo doesn't exist - this shouldn't happen, but clear cache anyway repoCache.delete(RepoCache.repoExistsKey(context.npub, context.repo)); // Wait a moment for filesystem to sync, then check again - await new Promise(resolve => setTimeout(resolve, 100)); + await new Promise(resolve => setTimeout(resolve, 500)); if (!existsSync(repoPath)) { throw handleNotFoundError( 'Repository fetch completed but repository is not accessible', @@ -78,9 +85,12 @@ export const GET: RequestHandler = createRepoGetHandler( // Repo exists now, clear cache and continue with normal flow repoCache.delete(RepoCache.repoExistsKey(context.npub, context.repo)); } else { - // If fetching fails, return 404 + // Log the error for debugging + console.error('[Branches] Error fetching repository:', err); + // If fetching fails, return 404 with more context + const errorMessage = err instanceof Error ? err.message : 'Repository not found'; throw handleNotFoundError( - 'Repository not found', + errorMessage, { operation: 'getBranches', npub: context.npub, repo: context.repo } ); } diff --git a/src/routes/api/user/messaging-preferences/+server.ts b/src/routes/api/user/messaging-preferences/+server.ts index ab188fb..1cac542 100644 --- a/src/routes/api/user/messaging-preferences/+server.ts +++ b/src/routes/api/user/messaging-preferences/+server.ts @@ -10,12 +10,12 @@ import { json, error } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { verifyEvent } from 'nostr-tools'; -import { storePreferences, getPreferences, deletePreferences, hasPreferences, getRateLimitStatus } from '$lib/services/messaging/preferences-storage.js'; +import { storePreferences, getPreferences, deletePreferences, hasPreferences, getRateLimitStatus } from '$lib/services/messaging/preferences-storage.server.js'; import { getCachedUserLevel } from '$lib/services/security/user-level-cache.js'; import { extractRequestContext } from '$lib/utils/api-context.js'; import { auditLogger } from '$lib/services/security/audit-logger.js'; import logger from '$lib/services/logger.js'; -import type { MessagingPreferences } from '$lib/services/messaging/preferences-storage.js'; +import type { MessagingPreferences } from '$lib/services/messaging/preferences-storage.server.js'; /** * POST - Save messaging preferences diff --git a/src/routes/api/user/messaging-preferences/summary/+server.ts b/src/routes/api/user/messaging-preferences/summary/+server.ts index 988b7a0..4361cbf 100644 --- a/src/routes/api/user/messaging-preferences/summary/+server.ts +++ b/src/routes/api/user/messaging-preferences/summary/+server.ts @@ -5,7 +5,7 @@ import { json, error } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; -import { getPreferencesSummary } from '$lib/services/messaging/preferences-storage.js'; +import { getPreferencesSummary } from '$lib/services/messaging/preferences-storage.server.js'; import { getCachedUserLevel } from '$lib/services/security/user-level-cache.js'; import { extractRequestContext } from '$lib/utils/api-context.js'; import logger from '$lib/services/logger.js'; diff --git a/svelte.config.js b/svelte.config.js index b233db3..a3fd861 100644 --- a/svelte.config.js +++ b/svelte.config.js @@ -4,6 +4,17 @@ import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; /** @type {import('@sveltejs/kit').Config} */ const config = { preprocess: vitePreprocess(), + compilerOptions: { + css: 'external' + }, + onwarn(warning, handler) { + // Suppress CSS unused selector warnings + // These are false positives for dark theme selectors that are conditionally applied + if (warning.code === 'css-unused-selector') { + return; + } + handler(warning); + }, kit: { adapter: adapter() } diff --git a/vite.config.ts b/vite.config.ts index 74a22e3..07c40d1 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -11,7 +11,8 @@ if (process.env.NODE_ENV === 'production' || process.argv.includes('build')) { const shouldSuppress = (message: string): boolean => { return ( message.includes('externalized for browser compatibility') || - message.includes('[plugin:vite:resolve]') && message.includes('has been externalized') + message.includes('[plugin:vite:resolve]') && message.includes('has been externalized') || + message.includes('[vite-plugin-svelte]') && message.includes('Unused CSS selector') ); }; @@ -42,6 +43,15 @@ if (process.env.NODE_ENV === 'production' || process.argv.includes('build')) { export default defineConfig({ plugins: [sveltekit()], + ssr: { + // Exclude Node.js-only modules from client bundle + noExternal: [], + external: [] + }, + optimizeDeps: { + // Exclude server-only modules from pre-bundling + exclude: ['src/lib/services/messaging/preferences-storage.ts'] + }, build: { rollupOptions: { onwarn(warning, warn) {