diff --git a/nostr/commit-signatures.jsonl b/nostr/commit-signatures.jsonl index 2384215..ad80533 100644 --- a/nostr/commit-signatures.jsonl +++ b/nostr/commit-signatures.jsonl @@ -62,3 +62,4 @@ {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771840654,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","bug-fixes"]],"content":"Signed commit: bug-fixes","id":"0580e0df8000275817f040bbd6c04dfdfbff08a366df7a1686f227d8b7310053","sig":"9a238266f989c0664dc5c9743675907477e2fcb5311e8edeb505dec97027f619f6dc6742ee5f3887ff6a864274b45005fc7dd4432f8e2772dfe0bb7e2d8a449c"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771840660,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","bug-fixes"]],"content":"Signed commit: bug-fixes","id":"e96c955f550a94c9c6d1228d2a7e479ced331334aaa4eea84525b362b8484d6e","sig":"1218bd9e449404ccc56c5727e8bdff5db31e37c2053a2d91ba02d214c0988173ba480010e53401661cb439884308a575230a7a12124f8e6d8f058c8a804a42f6"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771845583,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","fix search and relay connections"]],"content":"Signed commit: fix search and relay connections","id":"24db15027960b244eb4c8664a3642c64684ebfef8c200250093dd047cd119e7d","sig":"561d15ae39b3bf7a5b8a67539a5cfa19d53cbaca9f904589ab7cb69e568ddf056d0d83ced4830cdfdc0b386f13c4bab930264a0f6144cbb833b187b5d452c4ae"} +{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771847704,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","bug-fixes"]],"content":"Signed commit: bug-fixes","id":"9566e4e2964d0a7b80cce1889092c4db333f89843b5d68906b3c3c568e4ba57d","sig":"8cf9166c630a8dc21bbc3dfaea4330c80c93bf7bc9e8d5d3be182fb11a3b96ea2e5969f452d3e2b309103b3e7fea8fc1aa6e5908d499d0696e9bfcd3859a8e32"} diff --git a/src/lib/services/git/api-repo-fetcher.ts b/src/lib/services/git/api-repo-fetcher.ts index 6150406..7728fb3 100644 --- a/src/lib/services/git/api-repo-fetcher.ts +++ b/src/lib/services/git/api-repo-fetcher.ts @@ -187,25 +187,99 @@ async function fetchFromGitHub(owner: string, repo: string): Promise ({})); + if (errorData.message) { + errorMessage = errorData.message; + } + } catch { + // Ignore JSON parse errors + } + + logger.warn({ + status: repoResponse.status, + owner, + repo, + rateLimitInfo, + errorMessage + }, 'GitHub API rate limit or forbidden error'); + + // If we have a token but still got 403, it might be an auth issue + if (repoResponse.status === 403 && githubToken) { + logger.warn({ owner, repo }, 'GitHub API returned 403 with token - token may be invalid or lack permissions'); + } + + return null; + } + + logger.warn({ + status: repoResponse.status, + owner, + repo, + rateLimitRemaining, + hasToken: !!githubToken + }, 'GitHub API error'); return null; } const repoData = await repoResponse.json(); const defaultBranch = repoData.default_branch || 'main'; + + // Get the default branch SHA for tree API (tree endpoint needs SHA, not branch name) + let defaultBranchSha: string | null = null; + try { + const branchResponse = await fetch(`https://api.github.com/repos/${owner}/${repo}/branches/${defaultBranch}`, { headers }); + if (branchResponse.ok) { + const branchData = await branchResponse.json(); + defaultBranchSha = branchData.commit?.sha || null; + } + } catch (err) { + logger.debug({ error: err, owner, repo, branch: defaultBranch }, 'Failed to get default branch SHA, will try branch name'); + } // Fetch branches, commits, and tree in parallel + // Use SHA if available, otherwise fall back to branch name + const treeRef = defaultBranchSha || defaultBranch; const [branchesResponse, commitsResponse, treeResponse] = await Promise.all([ - fetch(`https://api.github.com/repos/${owner}/${repo}/branches`, { headers }).catch(() => null), - fetch(`https://api.github.com/repos/${owner}/${repo}/commits?per_page=10`, { headers }).catch(() => null), - fetch(`https://api.github.com/repos/${owner}/${repo}/git/trees/${defaultBranch}?recursive=1`, { headers }).catch(() => null) + fetch(`https://api.github.com/repos/${owner}/${repo}/branches`, { headers }).catch((err) => { + logger.debug({ error: err, owner, repo }, 'Failed to fetch branches from GitHub'); + return null; + }), + fetch(`https://api.github.com/repos/${owner}/${repo}/commits?per_page=10`, { headers }).catch((err) => { + logger.debug({ error: err, owner, repo }, 'Failed to fetch commits from GitHub'); + return null; + }), + fetch(`https://api.github.com/repos/${owner}/${repo}/git/trees/${treeRef}?recursive=1`, { headers }).catch((err) => { + logger.debug({ error: err, owner, repo, treeRef }, 'Failed to fetch tree from GitHub'); + return null; + }) ]); const branches: ApiBranch[] = branchesResponse?.ok @@ -231,20 +305,29 @@ async function fetchFromGitHub(owner: string, repo: string): Promise item.type === 'blob' || item.type === 'tree') + .map((item: any) => ({ + name: item.path.split('/').pop(), + path: item.path, + type: item.type === 'tree' ? 'dir' : 'file', + size: item.size + })) || []; + } catch (err) { + logger.warn({ error: err, owner, repo, treeRef }, 'Failed to parse GitHub tree response'); + files = []; } - files = treeData.tree - ?.filter((item: any) => item.type === 'blob' || item.type === 'tree') - .map((item: any) => ({ - name: item.path.split('/').pop(), - path: item.path, - type: item.type === 'tree' ? 'dir' : 'file', - size: item.size - })) || []; + } else if (treeResponse) { + // Tree response exists but not OK - log the error + const status = treeResponse.status; + logger.debug({ status, owner, repo, treeRef }, 'GitHub tree API returned non-OK status'); } // Try to fetch README diff --git a/src/lib/utils/api-repo-helper.ts b/src/lib/utils/api-repo-helper.ts index 306baa3..7953077 100644 --- a/src/lib/utils/api-repo-helper.ts +++ b/src/lib/utils/api-repo-helper.ts @@ -142,7 +142,11 @@ export async function tryApiFetch( total: sortedUrls.length }, `[${i + 1}/${sortedUrls.length}] API fetch threw error for URL, trying next`); // Continue to next URL - continue; + } + + // Add a small delay between attempts to avoid hammering APIs (except after the last attempt) + if (i < sortedUrls.length - 1) { + await new Promise(resolve => setTimeout(resolve, 500)); // 500ms delay } } diff --git a/src/routes/api/config/+server.ts b/src/routes/api/config/+server.ts new file mode 100644 index 0000000..6645745 --- /dev/null +++ b/src/routes/api/config/+server.ts @@ -0,0 +1,90 @@ +/** + * API endpoint for checking server configuration status + * Returns configuration status without exposing sensitive values + */ + +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async () => { + // Helper to check if env var is set + const isSet = (key: string): boolean => { + return typeof process !== 'undefined' && !!process.env?.[key]; + }; + + // Helper to get env var with default + const getEnv = (key: string, defaultValue: string): string => { + return typeof process !== 'undefined' && process.env?.[key] + ? process.env[key]! + : defaultValue; + }; + + // Helper to get env var as number with default + const getEnvNum = (key: string, defaultValue: number): number => { + if (typeof process === 'undefined' || !process.env?.[key]) { + return defaultValue; + } + const parsed = parseInt(process.env[key]!, 10); + return isNaN(parsed) ? defaultValue : parsed; + }; + + // Helper to get env var as boolean + const getEnvBool = (key: string, defaultValue: boolean): boolean => { + if (typeof process === 'undefined' || !process.env?.[key]) { + return defaultValue; + } + return process.env[key] === 'true'; + }; + + return json({ + github: { + tokenConfigured: isSet('GITHUB_TOKEN'), + }, + git: { + repoRoot: getEnv('GIT_REPO_ROOT', '/repos'), + domain: getEnv('GIT_DOMAIN', 'localhost:6543'), + defaultBranch: getEnv('DEFAULT_BRANCH', 'master'), + operationTimeoutMs: getEnvNum('GIT_OPERATION_TIMEOUT_MS', 300000), + cloneTimeoutMs: getEnvNum('GIT_CLONE_TIMEOUT_MS', 300000), + allowForcePush: getEnvBool('ALLOW_FORCE_PUSH', false), + }, + nostr: { + relays: getEnv('NOSTR_RELAYS', '').split(',').filter(r => r.trim()).length > 0 + ? getEnv('NOSTR_RELAYS', '').split(',').map(r => r.trim()).filter(r => r.length > 0) + : ['wss://theforest.nostr1.com', 'wss://nostr.land'], + searchRelays: getEnv('NOSTR_SEARCH_RELAYS', '').split(',').filter(r => r.trim()).length > 0 + ? getEnv('NOSTR_SEARCH_RELAYS', '').split(',').map(r => r.trim()).filter(r => r.length > 0) + : [], + nip98AuthWindowSeconds: getEnvNum('NIP98_AUTH_WINDOW_SECONDS', 60), + }, + tor: { + enabled: isSet('TOR_SOCKS_PROXY') && getEnv('TOR_SOCKS_PROXY', '') !== '', + socksProxy: getEnv('TOR_SOCKS_PROXY', '127.0.0.1:9050'), + hostnameFile: getEnv('TOR_HOSTNAME_FILE', ''), + onionAddress: getEnv('TOR_ONION_ADDRESS', ''), + }, + security: { + adminPubkeysConfigured: isSet('ADMIN_PUBKEYS'), + auditLoggingEnabled: getEnvBool('AUDIT_LOGGING_ENABLED', true), + auditLogFile: getEnv('AUDIT_LOG_FILE', ''), + auditLogRetentionDays: getEnvNum('AUDIT_LOG_RETENTION_DAYS', 90), + rateLimitEnabled: getEnvBool('RATE_LIMIT_ENABLED', true), + rateLimitWindowMs: getEnvNum('RATE_LIMIT_WINDOW_MS', 60000), + }, + resources: { + maxReposPerUser: getEnvNum('MAX_REPOS_PER_USER', 100), + maxDiskQuotaPerUser: getEnvNum('MAX_DISK_QUOTA_PER_USER', 10737418240), // 10GB + }, + messaging: { + encryptionKeyConfigured: isSet('MESSAGING_PREFS_ENCRYPTION_KEY'), + saltEncryptionKeyConfigured: isSet('MESSAGING_SALT_ENCRYPTION_KEY'), + lookupSecretConfigured: isSet('MESSAGING_LOOKUP_SECRET'), + }, + enterprise: { + enabled: getEnvBool('ENTERPRISE_MODE', false), + }, + docker: { + container: getEnvBool('DOCKER_CONTAINER', false), + }, + }); +}; diff --git a/src/routes/api/repos/[npub]/[repo]/issues/+server.ts b/src/routes/api/repos/[npub]/[repo]/issues/+server.ts index 1ba0b32..f669ca7 100644 --- a/src/routes/api/repos/[npub]/[repo]/issues/+server.ts +++ b/src/routes/api/repos/[npub]/[repo]/issues/+server.ts @@ -15,10 +15,26 @@ import { maintainerService } from '$lib/services/service-registry.js'; import { KIND, type NostrEvent } from '$lib/types/nostr.js'; import { verifyEvent } from 'nostr-tools'; import { validatePubkey } from '$lib/utils/input-validation.js'; +import { fetchRepoAnnouncementsWithCache, findRepoAnnouncement } from '$lib/utils/nostr-utils.js'; +import { eventCache } from '$lib/services/nostr/event-cache.js'; export const GET: RequestHandler = createRepoGetHandler( async (context: RepoRequestContext) => { - const issues = await issuesService.getIssues(context.repoOwnerPubkey, context.repo); + // Fetch the announcement to get the actual repo name (case-sensitive from d tag) + // This ensures we match issues that use the exact repo name from the announcement + const allEvents = await fetchRepoAnnouncementsWithCache(nostrClient, context.repoOwnerPubkey, eventCache); + const announcement = findRepoAnnouncement(allEvents, context.repo); + + // Use the repo name from the announcement's d tag if found, otherwise fall back to URL parameter + let actualRepoName = context.repo; + if (announcement) { + const dTag = announcement.tags.find(t => t[0] === 'd')?.[1]; + if (dTag) { + actualRepoName = dTag; + } + } + + const issues = await issuesService.getIssues(context.repoOwnerPubkey, actualRepoName); return json(issues); }, { operation: 'getIssues', requireRepoExists: false, requireRepoAccess: false } // Issues are stored in Nostr, don't require local repo diff --git a/src/routes/repos/[npub]/[repo]/+page.svelte b/src/routes/repos/[npub]/[repo]/+page.svelte index c859e0f..5aa6039 100644 --- a/src/routes/repos/[npub]/[repo]/+page.svelte +++ b/src/routes/repos/[npub]/[repo]/+page.svelte @@ -4010,9 +4010,30 @@ created_at: issue.created_at, kind: issue.kind || KIND.ISSUE })); + } else { + // Handle non-OK responses + const errorText = await response.text().catch(() => response.statusText); + let errorMessage = `Failed to load issues: ${response.status} ${response.statusText}`; + try { + const errorData = JSON.parse(errorText); + if (errorData.message) { + errorMessage = errorData.message; + } + } catch { + // If parsing fails, use the text as-is + if (errorText) { + errorMessage = errorText; + } + } + console.error('[Issues] Failed to load:', errorMessage); + error = errorMessage; + // Don't clear issues array - keep existing issues if any + // issues = []; // Only clear if you want to show empty state on error } } catch (err) { - error = err instanceof Error ? err.message : 'Failed to load issues'; + const errorMessage = err instanceof Error ? err.message : 'Failed to load issues'; + console.error('[Issues] Error loading issues:', err); + error = errorMessage; } finally { loadingIssues = false; } @@ -5334,7 +5355,16 @@ Show list - {#if issues.length === 0} + {#if loadingIssues} +
+

Loading issues...

+
+ {:else if error} +
+

Error loading issues: {error}

+ +
+ {:else if issues.length === 0}

No issues found. Create one to get started!

diff --git a/src/routes/settings/[[tab]]/+page.svelte b/src/routes/settings/[[tab]]/+page.svelte index 225c943..7cd1f80 100644 --- a/src/routes/settings/[[tab]]/+page.svelte +++ b/src/routes/settings/[[tab]]/+page.svelte @@ -32,6 +32,9 @@ let saving = $state(false); let loadingPresets = $state(false); let settingsLoaded = $state(false); + let configStatus = $state(null); + let loadingConfig = $state(false); + let expandedSections = $state>(new Set(['github', 'git'])); // Preset values that will be used if user doesn't override let presetUserName = $state(''); @@ -167,6 +170,20 @@ } } + async function loadConfigStatus() { + loadingConfig = true; + try { + const response = await fetch('/api/config'); + if (response.ok) { + configStatus = await response.json(); + } + } catch (err) { + console.error('Failed to load config status:', err); + } finally { + loadingConfig = false; + } + } + // Load settings and presets on mount onMount(async () => { // Redirect to /settings/general if no tab is specified @@ -177,6 +194,7 @@ await loadSettings(); await loadPresets(); + await loadConfigStatus(); }); // Sync activeTab with URL param when it changes @@ -355,6 +373,350 @@ {#if activeTab === 'connections'} + +
+

Server Configuration

+

+ Environment variables and server settings. Configure these in your environment or process manager. +

+ + {#if loadingConfig} +

Loading configuration status...

+ {:else if configStatus} + +
+ + {#if expandedSections.has('github')} +
+
+ GITHUB_TOKEN: + + {configStatus.github.tokenConfigured ? '✓ Configured' : '✗ Not configured'} + +
+
+

Purpose: GitHub Personal Access Token for API authentication

+

Why needed: Without a token, you're limited to 60 requests/hour per IP. With a token, you get 5,000 requests/hour.

+

How to set: export GITHUB_TOKEN=your_token_here

+

How to create: GitHub Settings → Developer settings → Personal access tokens → Tokens (classic) → Generate new token (classic) with public_repo scope

+
+
+ {/if} +
+ + +
+ + {#if expandedSections.has('git')} +
+
+ GIT_REPO_ROOT: + {configStatus.git.repoRoot} +
+
+ GIT_DOMAIN: + {configStatus.git.domain} +
+
+ DEFAULT_BRANCH: + {configStatus.git.defaultBranch} +
+
+ GIT_OPERATION_TIMEOUT_MS: + {configStatus.git.operationTimeoutMs}ms ({Math.round(configStatus.git.operationTimeoutMs / 1000 / 60)} min) +
+
+ GIT_CLONE_TIMEOUT_MS: + {configStatus.git.cloneTimeoutMs}ms ({Math.round(configStatus.git.cloneTimeoutMs / 1000 / 60)} min) +
+
+ ALLOW_FORCE_PUSH: + {configStatus.git.allowForcePush ? '✓ Enabled' : '✗ Disabled'} +
+
+

GIT_REPO_ROOT: Directory where repositories are stored (default: /repos)

+

GIT_DOMAIN: Domain for git clone URLs (default: localhost:6543)

+

DEFAULT_BRANCH: Default branch name for new repositories (default: master)

+

GIT_OPERATION_TIMEOUT_MS: Timeout for git operations in milliseconds (default: 300000 = 5 minutes)

+

GIT_CLONE_TIMEOUT_MS: Timeout for git clone operations (default: 300000 = 5 minutes)

+

ALLOW_FORCE_PUSH: Allow force push operations (default: false)

+
+
+ {/if} +
+ + +
+ + {#if expandedSections.has('nostr')} +
+
+ NOSTR_RELAYS: + {configStatus.nostr.relays.length} relay(s) configured +
+
+ NOSTR_SEARCH_RELAYS: + {configStatus.nostr.searchRelays.length > 0 ? configStatus.nostr.searchRelays.length + ' relay(s)' : 'Using defaults'} +
+
+ NIP98_AUTH_WINDOW_SECONDS: + {configStatus.nostr.nip98AuthWindowSeconds}s +
+
+

NOSTR_RELAYS: Comma-separated list of Nostr relays for publishing/fetching (default: wss://theforest.nostr1.com,wss://nostr.land)

+

NOSTR_SEARCH_RELAYS: Comma-separated list of relays for searching (uses extended default list if not set)

+

NIP98_AUTH_WINDOW_SECONDS: Authentication window for NIP-98 HTTP auth (default: 60 seconds)

+
+
+ {/if} +
+ + +
+ + {#if expandedSections.has('tor')} +
+
+ TOR_SOCKS_PROXY: + {configStatus.tor.enabled ? configStatus.tor.socksProxy : 'Disabled'} +
+
+ TOR_HOSTNAME_FILE: + {configStatus.tor.hostnameFile || 'Not set'} +
+
+ TOR_ONION_ADDRESS: + {configStatus.tor.onionAddress || 'Not set'} +
+
+

TOR_SOCKS_PROXY: Tor SOCKS proxy address (format: host:port, default: 127.0.0.1:9050, set to empty to disable)

+

TOR_HOSTNAME_FILE: Path to file containing Tor hidden service hostname

+

TOR_ONION_ADDRESS: Tor .onion address for the service

+
+
+ {/if} +
+ + +
+ + {#if expandedSections.has('security')} +
+
+ ADMIN_PUBKEYS: + + {configStatus.security.adminPubkeysConfigured ? '✓ Configured' : '✗ Not configured'} + +
+
+ AUDIT_LOGGING_ENABLED: + {configStatus.security.auditLoggingEnabled ? '✓ Enabled' : '✗ Disabled'} +
+
+ AUDIT_LOG_FILE: + {configStatus.security.auditLogFile || 'Default location'} +
+
+ AUDIT_LOG_RETENTION_DAYS: + {configStatus.security.auditLogRetentionDays} days +
+
+ RATE_LIMIT_ENABLED: + {configStatus.security.rateLimitEnabled ? '✓ Enabled' : '✗ Disabled'} +
+
+ RATE_LIMIT_WINDOW_MS: + {configStatus.security.rateLimitWindowMs}ms ({Math.round(configStatus.security.rateLimitWindowMs / 1000)}s) +
+
+

ADMIN_PUBKEYS: Comma-separated list of admin pubkeys (hex format) with elevated privileges

+

AUDIT_LOGGING_ENABLED: Enable audit logging (default: true, set to false to disable)

+

AUDIT_LOG_FILE: Path to audit log file (uses default if not set)

+

AUDIT_LOG_RETENTION_DAYS: Number of days to retain audit logs (default: 90)

+

RATE_LIMIT_ENABLED: Enable rate limiting (default: true, set to false to disable)

+

RATE_LIMIT_WINDOW_MS: Rate limit window in milliseconds (default: 60000 = 1 minute)

+
+
+ {/if} +
+ + +
+ + {#if expandedSections.has('resources')} +
+
+ MAX_REPOS_PER_USER: + {configStatus.resources.maxReposPerUser} repositories +
+
+ MAX_DISK_QUOTA_PER_USER: + {Math.round(configStatus.resources.maxDiskQuotaPerUser / 1024 / 1024 / 1024)} GB ({configStatus.resources.maxDiskQuotaPerUser} bytes) +
+
+

MAX_REPOS_PER_USER: Maximum number of repositories per user (default: 100)

+

MAX_DISK_QUOTA_PER_USER: Maximum disk quota per user in bytes (default: 10737418240 = 10 GB)

+
+
+ {/if} +
+ + +
+ + {#if expandedSections.has('messaging')} +
+
+ MESSAGING_PREFS_ENCRYPTION_KEY: + + {configStatus.messaging.encryptionKeyConfigured ? '✓ Configured' : '✗ Not configured'} + +
+
+ MESSAGING_SALT_ENCRYPTION_KEY: + + {configStatus.messaging.saltEncryptionKeyConfigured ? '✓ Configured' : '✗ Not configured'} + +
+
+ MESSAGING_LOOKUP_SECRET: + + {configStatus.messaging.lookupSecretConfigured ? '✓ Configured' : '✗ Not configured'} + +
+
+

MESSAGING_PREFS_ENCRYPTION_KEY: Encryption key for messaging preferences

+

MESSAGING_SALT_ENCRYPTION_KEY: Encryption key for salt values

+

MESSAGING_LOOKUP_SECRET: Secret for message lookup operations

+
+
+ {/if} +
+ + +
+ + {#if expandedSections.has('enterprise')} +
+
+ ENTERPRISE_MODE: + {configStatus.enterprise.enabled ? '✓ Enabled' : '✗ Disabled (Lightweight mode)'} +
+
+

ENTERPRISE_MODE: Enable enterprise mode for Kubernetes container-per-tenant architecture (default: false, set to true to enable)

+
+
+ {/if} +
+ {:else} +

Failed to load configuration status.

+ {/if} +
+