Browse Source

bug-fixes

Nostr-Signature: 1d4e6ff4059b064d7cdd465d623a606cfcc5d0565681a34f6384463d40cc8c71 573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc f5fe3547289e994ff1a3b191607e76d778d318ca4538e70253406867ecef214c1be437dca373f9a461c9cf2ca2978a581b54a9d323baeb2c91851e9cc6ffbfd6
main
Silberengel 3 weeks ago
parent
commit
9294f0f61b
  1. 1
      nostr/commit-signatures.jsonl
  2. 117
      src/lib/services/git/api-repo-fetcher.ts
  3. 6
      src/lib/utils/api-repo-helper.ts
  4. 90
      src/routes/api/config/+server.ts
  5. 18
      src/routes/api/repos/[npub]/[repo]/issues/+server.ts
  6. 34
      src/routes/repos/[npub]/[repo]/+page.svelte
  7. 478
      src/routes/settings/[[tab]]/+page.svelte

1
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":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":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":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"}

117
src/lib/services/git/api-repo-fetcher.ts

@ -187,25 +187,99 @@ async function fetchFromGitHub(owner: string, repo: string): Promise<Partial<Api
if (githubToken) { if (githubToken) {
headers['Authorization'] = `Bearer ${githubToken}`; headers['Authorization'] = `Bearer ${githubToken}`;
logger.debug({ owner, repo, hasToken: true }, 'Using GitHub token for API request');
} else {
logger.debug({ owner, repo, hasToken: false }, 'No GitHub token found - using unauthenticated requests (rate limited)');
} }
const repoResponse = await fetch(`https://api.github.com/repos/${owner}/${repo}`, { headers }); const repoResponse = await fetch(`https://api.github.com/repos/${owner}/${repo}`, { headers });
// Check rate limit headers
const rateLimitRemaining = repoResponse.headers.get('X-RateLimit-Remaining');
const rateLimitReset = repoResponse.headers.get('X-RateLimit-Reset');
if (!repoResponse.ok) { if (!repoResponse.ok) {
if (repoResponse.status === 404) { if (repoResponse.status === 404) {
logger.debug({ owner, repo }, 'GitHub repository not found (404)');
return null; return null;
} }
logger.warn({ status: repoResponse.status, owner, repo }, 'GitHub API error');
// Handle rate limiting (403 or 429)
if (repoResponse.status === 403 || repoResponse.status === 429) {
const rateLimitInfo = {
remaining: rateLimitRemaining ? parseInt(rateLimitRemaining, 10) : null,
reset: rateLimitReset ? new Date(parseInt(rateLimitReset, 10) * 1000).toISOString() : null,
hasToken: !!githubToken
};
// Try to get error message from response
let errorMessage = 'Rate limit exceeded or forbidden';
try {
const errorData = await repoResponse.json().catch(() => ({}));
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; return null;
} }
const repoData = await repoResponse.json(); const repoData = await repoResponse.json();
const defaultBranch = repoData.default_branch || 'main'; 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 // 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([ 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}/branches`, { headers }).catch((err) => {
fetch(`https://api.github.com/repos/${owner}/${repo}/commits?per_page=10`, { headers }).catch(() => null), logger.debug({ error: err, owner, repo }, 'Failed to fetch branches from GitHub');
fetch(`https://api.github.com/repos/${owner}/${repo}/git/trees/${defaultBranch}?recursive=1`, { headers }).catch(() => null) 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 const branches: ApiBranch[] = branchesResponse?.ok
@ -231,20 +305,29 @@ async function fetchFromGitHub(owner: string, repo: string): Promise<Partial<Api
let files: ApiFile[] = []; let files: ApiFile[] = [];
if (treeResponse?.ok) { if (treeResponse?.ok) {
const treeData = await treeResponse.json(); try {
// Check if the tree was truncated (GitHub API limitation) const treeData = await treeResponse.json();
if (treeData.truncated) { // Check if the tree was truncated (GitHub API limitation)
logger.warn({ owner, repo }, 'GitHub tree response was truncated, some files may be missing'); if (treeData.truncated) {
// For truncated trees, we could make additional requests, but for now just log a warning logger.warn({ owner, repo }, 'GitHub tree response was truncated, some files may be missing');
// For truncated trees, we could make additional requests, but for now just log a warning
}
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
})) || [];
} catch (err) {
logger.warn({ error: err, owner, repo, treeRef }, 'Failed to parse GitHub tree response');
files = [];
} }
files = treeData.tree } else if (treeResponse) {
?.filter((item: any) => item.type === 'blob' || item.type === 'tree') // Tree response exists but not OK - log the error
.map((item: any) => ({ const status = treeResponse.status;
name: item.path.split('/').pop(), logger.debug({ status, owner, repo, treeRef }, 'GitHub tree API returned non-OK status');
path: item.path,
type: item.type === 'tree' ? 'dir' : 'file',
size: item.size
})) || [];
} }
// Try to fetch README // Try to fetch README

6
src/lib/utils/api-repo-helper.ts

@ -142,7 +142,11 @@ export async function tryApiFetch(
total: sortedUrls.length total: sortedUrls.length
}, `[${i + 1}/${sortedUrls.length}] API fetch threw error for URL, trying next`); }, `[${i + 1}/${sortedUrls.length}] API fetch threw error for URL, trying next`);
// Continue to next URL // 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
} }
} }

90
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),
},
});
};

18
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 { KIND, type NostrEvent } from '$lib/types/nostr.js';
import { verifyEvent } from 'nostr-tools'; import { verifyEvent } from 'nostr-tools';
import { validatePubkey } from '$lib/utils/input-validation.js'; 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( export const GET: RequestHandler = createRepoGetHandler(
async (context: RepoRequestContext) => { 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); return json(issues);
}, },
{ operation: 'getIssues', requireRepoExists: false, requireRepoAccess: false } // Issues are stored in Nostr, don't require local repo { operation: 'getIssues', requireRepoExists: false, requireRepoAccess: false } // Issues are stored in Nostr, don't require local repo

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

@ -4010,9 +4010,30 @@
created_at: issue.created_at, created_at: issue.created_at,
kind: issue.kind || KIND.ISSUE 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) { } 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 { } finally {
loadingIssues = false; loadingIssues = false;
} }
@ -5334,7 +5355,16 @@
<img src="/icons/arrow-right.svg" alt="Show list" class="icon-inline mobile-toggle-left" /> <img src="/icons/arrow-right.svg" alt="Show list" class="icon-inline mobile-toggle-left" />
</button> </button>
</div> </div>
{#if issues.length === 0} {#if loadingIssues}
<div class="empty-state">
<p>Loading issues...</p>
</div>
{:else if error}
<div class="empty-state error-state">
<p>Error loading issues: {error}</p>
<button onclick={loadIssues} class="retry-button">Retry</button>
</div>
{:else if issues.length === 0}
<div class="empty-state"> <div class="empty-state">
<p>No issues found. Create one to get started!</p> <p>No issues found. Create one to get started!</p>
</div> </div>

478
src/routes/settings/[[tab]]/+page.svelte

@ -32,6 +32,9 @@
let saving = $state(false); let saving = $state(false);
let loadingPresets = $state(false); let loadingPresets = $state(false);
let settingsLoaded = $state(false); let settingsLoaded = $state(false);
let configStatus = $state<any>(null);
let loadingConfig = $state(false);
let expandedSections = $state<Set<string>>(new Set(['github', 'git']));
// Preset values that will be used if user doesn't override // Preset values that will be used if user doesn't override
let presetUserName = $state(''); 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 // Load settings and presets on mount
onMount(async () => { onMount(async () => {
// Redirect to /settings/general if no tab is specified // Redirect to /settings/general if no tab is specified
@ -177,6 +194,7 @@
await loadSettings(); await loadSettings();
await loadPresets(); await loadPresets();
await loadConfigStatus();
}); });
// Sync activeTab with URL param when it changes // Sync activeTab with URL param when it changes
@ -355,6 +373,350 @@
<!-- Connections Tab --> <!-- Connections Tab -->
{#if activeTab === 'connections'} {#if activeTab === 'connections'}
<!-- Server Configuration Status -->
<div class="setting-group">
<h3 class="setting-section-title">Server Configuration</h3>
<p class="setting-description">
Environment variables and server settings. Configure these in your environment or process manager.
</p>
{#if loadingConfig}
<p class="setting-description">Loading configuration status...</p>
{:else if configStatus}
<!-- GitHub Configuration -->
<div class="config-section">
<button
class="config-section-header"
onclick={() => {
if (expandedSections.has('github')) {
expandedSections.delete('github');
} else {
expandedSections.add('github');
}
expandedSections = expandedSections; // Trigger reactivity
}}
>
<span class="section-title">GitHub Integration</span>
<span class="section-toggle">{expandedSections.has('github') ? '▼' : '▶'}</span>
</button>
{#if expandedSections.has('github')}
<div class="config-status">
<div class="config-item">
<span class="config-label">GITHUB_TOKEN:</span>
<span class="config-value" class:configured={configStatus.github.tokenConfigured}>
{configStatus.github.tokenConfigured ? '✓ Configured' : '✗ Not configured'}
</span>
</div>
<div class="config-docs">
<p><strong>Purpose:</strong> GitHub Personal Access Token for API authentication</p>
<p><strong>Why needed:</strong> Without a token, you're limited to 60 requests/hour per IP. With a token, you get 5,000 requests/hour.</p>
<p><strong>How to set:</strong> <code>export GITHUB_TOKEN=your_token_here</code></p>
<p><strong>How to create:</strong> GitHub Settings → Developer settings → Personal access tokens → Tokens (classic) → Generate new token (classic) with <code>public_repo</code> scope</p>
</div>
</div>
{/if}
</div>
<!-- Git Configuration -->
<div class="config-section">
<button
class="config-section-header"
onclick={() => {
if (expandedSections.has('git')) {
expandedSections.delete('git');
} else {
expandedSections.add('git');
}
expandedSections = expandedSections;
}}
>
<span class="section-title">Git Configuration</span>
<span class="section-toggle">{expandedSections.has('git') ? '▼' : '▶'}</span>
</button>
{#if expandedSections.has('git')}
<div class="config-status">
<div class="config-item">
<span class="config-label">GIT_REPO_ROOT:</span>
<span class="config-value">{configStatus.git.repoRoot}</span>
</div>
<div class="config-item">
<span class="config-label">GIT_DOMAIN:</span>
<span class="config-value">{configStatus.git.domain}</span>
</div>
<div class="config-item">
<span class="config-label">DEFAULT_BRANCH:</span>
<span class="config-value">{configStatus.git.defaultBranch}</span>
</div>
<div class="config-item">
<span class="config-label">GIT_OPERATION_TIMEOUT_MS:</span>
<span class="config-value">{configStatus.git.operationTimeoutMs}ms ({Math.round(configStatus.git.operationTimeoutMs / 1000 / 60)} min)</span>
</div>
<div class="config-item">
<span class="config-label">GIT_CLONE_TIMEOUT_MS:</span>
<span class="config-value">{configStatus.git.cloneTimeoutMs}ms ({Math.round(configStatus.git.cloneTimeoutMs / 1000 / 60)} min)</span>
</div>
<div class="config-item">
<span class="config-label">ALLOW_FORCE_PUSH:</span>
<span class="config-value">{configStatus.git.allowForcePush ? '✓ Enabled' : '✗ Disabled'}</span>
</div>
<div class="config-docs">
<p><strong>GIT_REPO_ROOT:</strong> Directory where repositories are stored (default: <code>/repos</code>)</p>
<p><strong>GIT_DOMAIN:</strong> Domain for git clone URLs (default: <code>localhost:6543</code>)</p>
<p><strong>DEFAULT_BRANCH:</strong> Default branch name for new repositories (default: <code>master</code>)</p>
<p><strong>GIT_OPERATION_TIMEOUT_MS:</strong> Timeout for git operations in milliseconds (default: 300000 = 5 minutes)</p>
<p><strong>GIT_CLONE_TIMEOUT_MS:</strong> Timeout for git clone operations (default: 300000 = 5 minutes)</p>
<p><strong>ALLOW_FORCE_PUSH:</strong> Allow force push operations (default: <code>false</code>)</p>
</div>
</div>
{/if}
</div>
<!-- Nostr Configuration -->
<div class="config-section">
<button
class="config-section-header"
onclick={() => {
if (expandedSections.has('nostr')) {
expandedSections.delete('nostr');
} else {
expandedSections.add('nostr');
}
expandedSections = expandedSections;
}}
>
<span class="section-title">Nostr Configuration</span>
<span class="section-toggle">{expandedSections.has('nostr') ? '▼' : '▶'}</span>
</button>
{#if expandedSections.has('nostr')}
<div class="config-status">
<div class="config-item">
<span class="config-label">NOSTR_RELAYS:</span>
<span class="config-value">{configStatus.nostr.relays.length} relay(s) configured</span>
</div>
<div class="config-item">
<span class="config-label">NOSTR_SEARCH_RELAYS:</span>
<span class="config-value">{configStatus.nostr.searchRelays.length > 0 ? configStatus.nostr.searchRelays.length + ' relay(s)' : 'Using defaults'}</span>
</div>
<div class="config-item">
<span class="config-label">NIP98_AUTH_WINDOW_SECONDS:</span>
<span class="config-value">{configStatus.nostr.nip98AuthWindowSeconds}s</span>
</div>
<div class="config-docs">
<p><strong>NOSTR_RELAYS:</strong> Comma-separated list of Nostr relays for publishing/fetching (default: <code>wss://theforest.nostr1.com,wss://nostr.land</code>)</p>
<p><strong>NOSTR_SEARCH_RELAYS:</strong> Comma-separated list of relays for searching (uses extended default list if not set)</p>
<p><strong>NIP98_AUTH_WINDOW_SECONDS:</strong> Authentication window for NIP-98 HTTP auth (default: 60 seconds)</p>
</div>
</div>
{/if}
</div>
<!-- Tor Configuration -->
<div class="config-section">
<button
class="config-section-header"
onclick={() => {
if (expandedSections.has('tor')) {
expandedSections.delete('tor');
} else {
expandedSections.add('tor');
}
expandedSections = expandedSections;
}}
>
<span class="section-title">Tor Support</span>
<span class="section-toggle">{expandedSections.has('tor') ? '▼' : '▶'}</span>
</button>
{#if expandedSections.has('tor')}
<div class="config-status">
<div class="config-item">
<span class="config-label">TOR_SOCKS_PROXY:</span>
<span class="config-value">{configStatus.tor.enabled ? configStatus.tor.socksProxy : 'Disabled'}</span>
</div>
<div class="config-item">
<span class="config-label">TOR_HOSTNAME_FILE:</span>
<span class="config-value">{configStatus.tor.hostnameFile || 'Not set'}</span>
</div>
<div class="config-item">
<span class="config-label">TOR_ONION_ADDRESS:</span>
<span class="config-value">{configStatus.tor.onionAddress || 'Not set'}</span>
</div>
<div class="config-docs">
<p><strong>TOR_SOCKS_PROXY:</strong> Tor SOCKS proxy address (format: <code>host:port</code>, default: <code>127.0.0.1:9050</code>, set to empty to disable)</p>
<p><strong>TOR_HOSTNAME_FILE:</strong> Path to file containing Tor hidden service hostname</p>
<p><strong>TOR_ONION_ADDRESS:</strong> Tor .onion address for the service</p>
</div>
</div>
{/if}
</div>
<!-- Security Configuration -->
<div class="config-section">
<button
class="config-section-header"
onclick={() => {
if (expandedSections.has('security')) {
expandedSections.delete('security');
} else {
expandedSections.add('security');
}
expandedSections = expandedSections;
}}
>
<span class="section-title">Security Settings</span>
<span class="section-toggle">{expandedSections.has('security') ? '▼' : '▶'}</span>
</button>
{#if expandedSections.has('security')}
<div class="config-status">
<div class="config-item">
<span class="config-label">ADMIN_PUBKEYS:</span>
<span class="config-value" class:configured={configStatus.security.adminPubkeysConfigured}>
{configStatus.security.adminPubkeysConfigured ? '✓ Configured' : '✗ Not configured'}
</span>
</div>
<div class="config-item">
<span class="config-label">AUDIT_LOGGING_ENABLED:</span>
<span class="config-value">{configStatus.security.auditLoggingEnabled ? '✓ Enabled' : '✗ Disabled'}</span>
</div>
<div class="config-item">
<span class="config-label">AUDIT_LOG_FILE:</span>
<span class="config-value">{configStatus.security.auditLogFile || 'Default location'}</span>
</div>
<div class="config-item">
<span class="config-label">AUDIT_LOG_RETENTION_DAYS:</span>
<span class="config-value">{configStatus.security.auditLogRetentionDays} days</span>
</div>
<div class="config-item">
<span class="config-label">RATE_LIMIT_ENABLED:</span>
<span class="config-value">{configStatus.security.rateLimitEnabled ? '✓ Enabled' : '✗ Disabled'}</span>
</div>
<div class="config-item">
<span class="config-label">RATE_LIMIT_WINDOW_MS:</span>
<span class="config-value">{configStatus.security.rateLimitWindowMs}ms ({Math.round(configStatus.security.rateLimitWindowMs / 1000)}s)</span>
</div>
<div class="config-docs">
<p><strong>ADMIN_PUBKEYS:</strong> Comma-separated list of admin pubkeys (hex format) with elevated privileges</p>
<p><strong>AUDIT_LOGGING_ENABLED:</strong> Enable audit logging (default: <code>true</code>, set to <code>false</code> to disable)</p>
<p><strong>AUDIT_LOG_FILE:</strong> Path to audit log file (uses default if not set)</p>
<p><strong>AUDIT_LOG_RETENTION_DAYS:</strong> Number of days to retain audit logs (default: 90)</p>
<p><strong>RATE_LIMIT_ENABLED:</strong> Enable rate limiting (default: <code>true</code>, set to <code>false</code> to disable)</p>
<p><strong>RATE_LIMIT_WINDOW_MS:</strong> Rate limit window in milliseconds (default: 60000 = 1 minute)</p>
</div>
</div>
{/if}
</div>
<!-- Resource Limits -->
<div class="config-section">
<button
class="config-section-header"
onclick={() => {
if (expandedSections.has('resources')) {
expandedSections.delete('resources');
} else {
expandedSections.add('resources');
}
expandedSections = expandedSections;
}}
>
<span class="section-title">Resource Limits</span>
<span class="section-toggle">{expandedSections.has('resources') ? '▼' : '▶'}</span>
</button>
{#if expandedSections.has('resources')}
<div class="config-status">
<div class="config-item">
<span class="config-label">MAX_REPOS_PER_USER:</span>
<span class="config-value">{configStatus.resources.maxReposPerUser} repositories</span>
</div>
<div class="config-item">
<span class="config-label">MAX_DISK_QUOTA_PER_USER:</span>
<span class="config-value">{Math.round(configStatus.resources.maxDiskQuotaPerUser / 1024 / 1024 / 1024)} GB ({configStatus.resources.maxDiskQuotaPerUser} bytes)</span>
</div>
<div class="config-docs">
<p><strong>MAX_REPOS_PER_USER:</strong> Maximum number of repositories per user (default: 100)</p>
<p><strong>MAX_DISK_QUOTA_PER_USER:</strong> Maximum disk quota per user in bytes (default: 10737418240 = 10 GB)</p>
</div>
</div>
{/if}
</div>
<!-- Messaging Configuration -->
<div class="config-section">
<button
class="config-section-header"
onclick={() => {
if (expandedSections.has('messaging')) {
expandedSections.delete('messaging');
} else {
expandedSections.add('messaging');
}
expandedSections = expandedSections;
}}
>
<span class="section-title">Messaging Configuration</span>
<span class="section-toggle">{expandedSections.has('messaging') ? '▼' : '▶'}</span>
</button>
{#if expandedSections.has('messaging')}
<div class="config-status">
<div class="config-item">
<span class="config-label">MESSAGING_PREFS_ENCRYPTION_KEY:</span>
<span class="config-value" class:configured={configStatus.messaging.encryptionKeyConfigured}>
{configStatus.messaging.encryptionKeyConfigured ? '✓ Configured' : '✗ Not configured'}
</span>
</div>
<div class="config-item">
<span class="config-label">MESSAGING_SALT_ENCRYPTION_KEY:</span>
<span class="config-value" class:configured={configStatus.messaging.saltEncryptionKeyConfigured}>
{configStatus.messaging.saltEncryptionKeyConfigured ? '✓ Configured' : '✗ Not configured'}
</span>
</div>
<div class="config-item">
<span class="config-label">MESSAGING_LOOKUP_SECRET:</span>
<span class="config-value" class:configured={configStatus.messaging.lookupSecretConfigured}>
{configStatus.messaging.lookupSecretConfigured ? '✓ Configured' : '✗ Not configured'}
</span>
</div>
<div class="config-docs">
<p><strong>MESSAGING_PREFS_ENCRYPTION_KEY:</strong> Encryption key for messaging preferences</p>
<p><strong>MESSAGING_SALT_ENCRYPTION_KEY:</strong> Encryption key for salt values</p>
<p><strong>MESSAGING_LOOKUP_SECRET:</strong> Secret for message lookup operations</p>
</div>
</div>
{/if}
</div>
<!-- Enterprise Mode -->
<div class="config-section">
<button
class="config-section-header"
onclick={() => {
if (expandedSections.has('enterprise')) {
expandedSections.delete('enterprise');
} else {
expandedSections.add('enterprise');
}
expandedSections = expandedSections;
}}
>
<span class="section-title">Enterprise Mode</span>
<span class="section-toggle">{expandedSections.has('enterprise') ? '▼' : '▶'}</span>
</button>
{#if expandedSections.has('enterprise')}
<div class="config-status">
<div class="config-item">
<span class="config-label">ENTERPRISE_MODE:</span>
<span class="config-value">{configStatus.enterprise.enabled ? '✓ Enabled' : '✗ Disabled (Lightweight mode)'}</span>
</div>
<div class="config-docs">
<p><strong>ENTERPRISE_MODE:</strong> Enable enterprise mode for Kubernetes container-per-tenant architecture (default: <code>false</code>, set to <code>true</code> to enable)</p>
</div>
</div>
{/if}
</div>
{:else}
<p class="setting-description">Failed to load configuration status.</p>
{/if}
</div>
<div class="setting-group"> <div class="setting-group">
<ForwardingConfig <ForwardingConfig
userPubkeyHex={$userStore.userPubkeyHex} userPubkeyHex={$userStore.userPubkeyHex}
@ -626,4 +988,120 @@
opacity: 0.6; opacity: 0.6;
cursor: not-allowed; cursor: not-allowed;
} }
.setting-section-title {
font-size: 1.25rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--border-color);
}
.config-status {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 0.375rem;
padding: 1rem;
}
.config-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0;
border-bottom: 1px solid var(--border-color);
}
.config-item:last-child {
border-bottom: none;
}
.config-label {
font-weight: 500;
color: var(--text-primary);
}
.config-value {
color: var(--text-secondary);
font-family: monospace;
font-size: 0.875rem;
}
.config-value.configured {
color: var(--success-color, #10b981);
font-weight: 600;
}
.config-section {
margin-bottom: 1rem;
border: 1px solid var(--border-color);
border-radius: 0.375rem;
overflow: hidden;
}
.config-section-header {
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
background: var(--bg-secondary);
border: none;
cursor: pointer;
font-size: 1rem;
font-weight: 600;
color: var(--text-primary);
transition: background 0.2s ease;
}
.config-section-header:hover {
background: var(--bg-tertiary);
}
.section-title {
flex: 1;
text-align: left;
}
.section-toggle {
font-size: 0.875rem;
color: var(--text-secondary);
margin-left: 1rem;
}
.config-docs {
margin-top: 0.75rem;
padding: 0.75rem;
background: var(--bg-tertiary);
border-radius: 0.25rem;
font-size: 0.875rem;
color: var(--text-secondary);
}
.config-docs p {
margin: 0.5rem 0;
}
.config-docs p:first-child {
margin-top: 0;
}
.config-docs p:last-child {
margin-bottom: 0;
}
.config-docs code {
background: var(--bg-primary);
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
font-family: monospace;
font-size: 0.8125rem;
color: var(--text-primary);
}
.config-docs strong {
color: var(--text-primary);
font-weight: 600;
}
</style> </style>

Loading…
Cancel
Save