diff --git a/docker-compose.yml b/docker-compose.yml index 10d327c..66be3ce 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -26,6 +26,7 @@ services: - GIT_DOMAIN=${GIT_DOMAIN:-gitrepublic.imwald.eu} # Set to your domain for production (without https://) - NOSTR_RELAYS=${NOSTR_RELAYS:-wss://theforest.nostr1.com} - NOSTRGIT_SECRET_KEY=${NOSTRGIT_SECRET_KEY:-} + - ADMIN_NPUB=${ADMIN_NPUB:-npub12umrfdjgvdxt45g0y3ghwcyfagssjrv5qlm3t6pu2aa5vydwdmwq8q0z04} - PORT=6543 volumes: # Persist git repositories diff --git a/nostr/commit-signatures.jsonl b/nostr/commit-signatures.jsonl index 5f36c57..4852667 100644 --- a/nostr/commit-signatures.jsonl +++ b/nostr/commit-signatures.jsonl @@ -121,3 +121,4 @@ {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772270859,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","bug-fixes and fallback relay"]],"content":"Signed commit: bug-fixes and fallback relay","id":"1d85d0c5e1451c90bca5d59e08043f29adeaad4db4ac5495c8e9a4247775780f","sig":"a1960b76c78db9f64dad20378d26f500ffc09f1f6d137314db548470202712222a1d391f682146ba281fd23355c574fcbb260310db61b3458bba3dec0c724a18"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772271656,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","bug-fixes"]],"content":"Signed commit: bug-fixes","id":"f4a5e0d3e2aa7d0d99803f26008ab68e40551e36362bb6d04acf639c5b78d959","sig":"59da9e59a6fb5648f4c889e0045b571e0d2d66a555100d60dec373455309a640bea89e4bb3a42a0e502aa4d2091e4b698203721e79b346ff30e6b2bcdc5f48b3"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772274086,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","bug-fixes"]],"content":"Signed commit: bug-fixes","id":"32794e047f06902ad610f918834efb113f41eace26a53a3f0fad083b9d8323dc","sig":"3859f0de3de0f8a742b6fbe7709c5a5625f4d5612a936fd81f38a7e1231ee810b50a69c1ed5d23c8a6670b4cbc9ea3d4bd39d6fa9e6207802f45995689b924a9"} +{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772293551,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","remove polling"]],"content":"Signed commit: remove polling","id":"40f01e84f96661bb7fea13aa63c7da428118061b0a1470a11890d4f9cd6d685b","sig":"dbb6947defac6c7f92a3cf6f72352a94ffe2c4b33e65f8410518a40406c93f1f5a3e13e81f2f04f676d826e6cf03ec802328f5228300f80a8114fa3fd26eaeff"} diff --git a/src/lib/components/NavBar.svelte b/src/lib/components/NavBar.svelte index 74acf81..f352f32 100644 --- a/src/lib/components/NavBar.svelte +++ b/src/lib/components/NavBar.svelte @@ -11,9 +11,11 @@ import { determineUserLevel, decodePubkey } from '../services/nostr/user-level-service.js'; let userPubkey = $state(null); + let userPubkeyHex = $state(null); let mobileMenuOpen = $state(false); let nip07Available = $state(false); // Track NIP-07 availability (client-side only) let isClient = $state(false); // Track if we're on the client + let isUserAdmin = $state(false); // Component mount tracking to prevent state updates after destruction let isMounted = $state(true); @@ -35,13 +37,22 @@ if (isMounted) { userStore.reset(); userPubkey = null; + userPubkeyHex = null; + isUserAdmin = false; } } else if (isMounted) { userPubkey = currentUser.userPubkey; + userPubkeyHex = currentUser.userPubkeyHex; + // Check admin status asynchronously + if (currentUser.userPubkeyHex) { + checkAdminStatus(currentUser.userPubkeyHex); + } updateActivity(); } } else if (isMounted) { userPubkey = null; + userPubkeyHex = null; + isUserAdmin = false; } } catch (err) { // Ignore errors during destruction @@ -66,10 +77,17 @@ if (currentState && currentState.userPubkey && currentState.userPubkeyHex && isMounted) { // User is logged in - restore state (already synced by $effect, but ensure it's set) userPubkey = currentState.userPubkey; + userPubkeyHex = currentState.userPubkeyHex; + // Check admin status asynchronously + if (currentState.userPubkeyHex) { + checkAdminStatus(currentState.userPubkeyHex); + } // Update activity to extend session updateActivity(); } else if (isMounted) { // User not logged in - check auth + userPubkeyHex = null; + isUserAdmin = false; checkAuth(); } } catch (err) { @@ -172,19 +190,76 @@ if (!currentState || !currentState.userPubkey) { if (isMounted) { userPubkey = null; + userPubkeyHex = null; + isUserAdmin = false; } return; } if (isNIP07Available() && isMounted) { userPubkey = await getPublicKeyWithNIP07(); + // Convert to hex if needed + if (userPubkey) { + if (/^[0-9a-f]{64}$/i.test(userPubkey)) { + userPubkeyHex = userPubkey.toLowerCase(); + } else { + try { + const decoded = nip19.decode(userPubkey); + if (decoded.type === 'npub') { + userPubkeyHex = decoded.data as string; + } + } catch { + userPubkeyHex = null; + } + } + if (userPubkeyHex && isMounted) { + checkAdminStatus(userPubkeyHex); + } + } } else if (isMounted) { userPubkey = null; + userPubkeyHex = null; + isUserAdmin = false; } } catch (err) { if (isMounted) { console.log('NIP-07 not available or user not connected'); userPubkey = null; + userPubkeyHex = null; + isUserAdmin = false; + } + } + } + + async function checkAdminStatus(pubkeyHex: string) { + if (!isMounted || typeof window === 'undefined' || !pubkeyHex) { + if (isMounted) { + isUserAdmin = false; + } + return; + } + + try { + console.log('[NavBar] Checking admin status for:', pubkeyHex.substring(0, 16) + '...'); + const response = await fetch('/api/admin/check', { + headers: { + 'X-User-Pubkey': pubkeyHex + } + }); + + if (response.ok && isMounted) { + const data = await response.json(); + console.log('[NavBar] Admin check result:', data); + isUserAdmin = data.isAdmin === true; + console.log('[NavBar] isUserAdmin set to:', isUserAdmin); + } else if (isMounted) { + console.warn('[NavBar] Admin check failed:', response.status, response.statusText); + isUserAdmin = false; + } + } catch (err) { + if (isMounted) { + console.warn('[NavBar] Failed to check admin status:', err); + isUserAdmin = false; } } } @@ -256,6 +331,16 @@ levelResult.error || null ); + // Update local state + if (isMounted) { + userPubkey = levelResult.userPubkey; + userPubkeyHex = levelResult.userPubkeyHex; + // Check admin status after login + if (levelResult.userPubkeyHex) { + checkAdminStatus(levelResult.userPubkeyHex); + } + } + // Update activity tracking on successful login if (isMounted) { updateActivity(); @@ -309,6 +394,8 @@ if (typeof window === 'undefined' || !isMounted) return; if (isMounted) { userPubkey = null; + userPubkeyHex = null; + isUserAdmin = false; // Reset user store userStore.reset(); // Clear activity tracking @@ -348,6 +435,9 @@ closeMobileMenu()}>Register closeMobileMenu()}>Docs closeMobileMenu()}>API Docs + {#if isUserAdmin} + closeMobileMenu()}>Admin + {/if}
diff --git a/src/lib/utils/admin-check.ts b/src/lib/utils/admin-check.ts new file mode 100644 index 0000000..fd2a0c8 --- /dev/null +++ b/src/lib/utils/admin-check.ts @@ -0,0 +1,106 @@ +/** + * Utility for checking admin access + * Admin is determined by ADMIN_NPUB environment variable + */ + +import { nip19 } from 'nostr-tools'; + +/** + * Get admin npub from environment variable + * Defaults to the npub set in docker-compose.yml if not explicitly set + */ +function getAdminNpub(): string | null { + if (typeof process === 'undefined') return null; + const adminNpub = process.env?.ADMIN_NPUB; + + // If not set, use the default from docker-compose.yml + if (!adminNpub || adminNpub.trim().length === 0) { + const defaultAdminNpub = 'npub12umrfdjgvdxt45g0y3ghwcyfagssjrv5qlm3t6pu2aa5vydwdmwq8q0z04'; + console.log('[admin-check] ADMIN_NPUB not set, using default:', defaultAdminNpub); + return defaultAdminNpub; + } + + return adminNpub.trim(); +} + +/** + * Get admin pubkey hex from environment variable + */ +function getAdminPubkeyHex(): string | null { + const adminNpub = getAdminNpub(); + if (!adminNpub) { + if (typeof process !== 'undefined') { + console.log('[admin-check] No ADMIN_NPUB environment variable set'); + } + return null; + } + + try { + const decoded = nip19.decode(adminNpub); + if (decoded.type === 'npub') { + const hex = decoded.data as string; + if (typeof process !== 'undefined') { + console.log('[admin-check] Admin npub decoded to hex:', hex.substring(0, 16) + '...'); + } + return hex; + } + } catch (err) { + // Invalid npub format + if (typeof process !== 'undefined') { + console.warn('[admin-check] Failed to decode admin npub:', err); + } + } + + return null; +} + +/** + * Check if a user is an admin + * @param userPubkey - The user's pubkey in hex format or npub format + * @returns true if the user is an admin + */ +export function isAdmin(userPubkey: string | null): boolean { + if (!userPubkey) return false; + + const adminPubkeyHex = getAdminPubkeyHex(); + if (!adminPubkeyHex) return false; + + // Convert user pubkey to hex if it's an npub + let userPubkeyHex: string | null = null; + + // Check if it's already hex format + if (/^[0-9a-f]{64}$/i.test(userPubkey)) { + userPubkeyHex = userPubkey.toLowerCase(); + } else { + // Try to decode as npub + try { + const decoded = nip19.decode(userPubkey); + if (decoded.type === 'npub') { + userPubkeyHex = (decoded.data as string).toLowerCase(); + } + } catch { + // Invalid format + return false; + } + } + + if (!userPubkeyHex) return false; + + const isAdminUser = userPubkeyHex === adminPubkeyHex.toLowerCase(); + + if (typeof process !== 'undefined') { + console.log('[admin-check] Checking admin status:'); + console.log('[admin-check] User pubkey hex:', userPubkeyHex); + console.log('[admin-check] Admin pubkey hex:', adminPubkeyHex.toLowerCase()); + console.log('[admin-check] Match:', isAdminUser); + } + + return isAdminUser; +} + +/** + * Check if admin is configured + */ +export function isAdminConfigured(): boolean { + return getAdminNpub() !== null; +} diff --git a/src/routes/admin/repos/+page.svelte b/src/routes/admin/repos/+page.svelte new file mode 100644 index 0000000..00187fb --- /dev/null +++ b/src/routes/admin/repos/+page.svelte @@ -0,0 +1,423 @@ + + + + Admin - Repositories + + +
+
+

Repository Administration

+ +
+ + {#if !accessChecked} +
Checking access...
+ {:else if !hasAccess} +
Access denied. Admin privileges required.
+ {:else} + {#if error} +
+ {error} +
+ {/if} + + {#if loading} +
Loading repositories...
+ {:else} +
+
+ Total Repositories: + {repos.length} +
+
+ Total Size: + {formatBytes(totalSize)} +
+
+ +
+ + + + + + + + + + + + + {#each repos as repo (repo.npub + repo.repoName)} + + + + + + + + + {:else} + + + + {/each} + +
Owner (npub)Repository NameSizeLast ModifiedCreatedActions
+ {repo.npub.substring(0, 20)}... + + + {repo.repoName} + + {formatBytes(repo.size)}{formatDate(repo.lastModified)}{formatDate(repo.createdAt)} + +
No repositories found
+
+ {/if} + {/if} +
+ + diff --git a/src/routes/api/admin/check/+server.ts b/src/routes/api/admin/check/+server.ts new file mode 100644 index 0000000..43d6b60 --- /dev/null +++ b/src/routes/api/admin/check/+server.ts @@ -0,0 +1,47 @@ +/** + * API endpoint to check if current user is admin + */ + +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from '@sveltejs/kit'; +import { extractRequestContext } from '$lib/utils/api-context.js'; +import { isAdmin } from '$lib/utils/admin-check.js'; +import { nip19 } from 'nostr-tools'; +import logger from '$lib/services/logger.js'; + +export const GET: RequestHandler = async (event) => { + const requestContext = extractRequestContext(event); + let userPubkeyHex = requestContext.userPubkeyHex; + + // If we don't have hex, try to get from header and decode if it's an npub + if (!userPubkeyHex) { + const userPubkey = event.request.headers.get('X-User-Pubkey') || + event.request.headers.get('x-user-pubkey'); + + if (userPubkey) { + // Check if it's already hex + if (/^[0-9a-f]{64}$/i.test(userPubkey)) { + userPubkeyHex = userPubkey.toLowerCase(); + } else { + // Try to decode as npub + try { + const decoded = nip19.decode(userPubkey); + if (decoded.type === 'npub') { + userPubkeyHex = decoded.data as string; + } + } catch (err) { + logger.debug({ error: err, userPubkey }, 'Failed to decode user pubkey as npub'); + } + } + } + } + + if (!userPubkeyHex) { + return json({ isAdmin: false }); + } + + const adminStatus = isAdmin(userPubkeyHex); + logger.debug({ userPubkeyHex: userPubkeyHex.substring(0, 16) + '...', isAdmin: adminStatus }, 'Admin check result'); + + return json({ isAdmin: adminStatus }); +}; diff --git a/src/routes/api/admin/repos/+server.ts b/src/routes/api/admin/repos/+server.ts new file mode 100644 index 0000000..c444cb8 --- /dev/null +++ b/src/routes/api/admin/repos/+server.ts @@ -0,0 +1,146 @@ +/** + * Admin API endpoint for listing all repositories + * Only accessible to users with unlimited access + */ + +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from '@sveltejs/kit'; +import { readdir, stat } from 'fs/promises'; +import { join, resolve } from 'path'; +import { existsSync } from 'fs'; +import { extractRequestContext } from '$lib/utils/api-context.js'; +import { isAdmin } from '$lib/utils/admin-check.js'; +import { handleApiError, handleAuthorizationError } from '$lib/utils/error-handler.js'; +import logger from '$lib/services/logger.js'; + +const repoRoot = typeof process !== 'undefined' && process.env?.GIT_REPO_ROOT + ? process.env.GIT_REPO_ROOT + : '/repos'; + +interface AdminRepoItem { + npub: string; + repoName: string; + fullPath: string; + size: number; + lastModified: number; + createdAt: number; +} + +/** + * Scan filesystem for all repositories (admin view) + */ +async function scanAllRepos(): Promise { + const repos: AdminRepoItem[] = []; + + if (!existsSync(repoRoot)) { + return repos; + } + + try { + // Read all user directories + const userDirs = await readdir(repoRoot); + + for (const userDir of userDirs) { + const userPath = join(repoRoot, userDir); + + // Skip if not a directory or doesn't look like an npub + if (!userDir.startsWith('npub') || userDir.length < 60) continue; + + try { + const stats = await stat(userPath); + if (!stats.isDirectory()) continue; + + // Read repos for this user + const repoFiles = await readdir(userPath); + + for (const repoFile of repoFiles) { + if (!repoFile.endsWith('.git')) continue; + + const repoName = repoFile.replace(/\.git$/, ''); + const repoPath = join(userPath, repoFile); + + try { + const repoStats = await stat(repoPath); + if (!repoStats.isDirectory()) continue; + + // Calculate directory size (approximate - just count files) + let size = 0; + try { + const calculateSize = async (dir: string): Promise => { + let total = 0; + const entries = await readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = join(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name !== '.' && entry.name !== '..') { + total += await calculateSize(fullPath); + } + } else { + try { + const fileStats = await stat(fullPath); + total += fileStats.size; + } catch { + // Ignore errors for individual files + } + } + } + return total; + }; + size = await calculateSize(repoPath); + } catch { + // If size calculation fails, just use 0 + size = 0; + } + + repos.push({ + npub: userDir, + repoName, + fullPath: repoPath, + size, + lastModified: repoStats.mtime.getTime(), + createdAt: repoStats.birthtime.getTime() || repoStats.ctime.getTime() + }); + } catch (err) { + logger.warn({ error: err, repoPath }, 'Failed to stat repo'); + } + } + } catch (err) { + logger.warn({ error: err, userPath }, 'Failed to read user directory'); + } + } + } catch (err) { + logger.error({ error: err }, 'Failed to scan repos'); + throw err; + } + + // Sort by last modified (most recent first) + repos.sort((a, b) => b.lastModified - a.lastModified); + + return repos; +} + +export const GET: RequestHandler = async (event) => { + try { + const requestContext = extractRequestContext(event); + const userPubkeyHex = requestContext.userPubkeyHex; + + if (!userPubkeyHex) { + return handleAuthorizationError('Authentication required'); + } + + // Check if user is admin + if (!isAdmin(userPubkeyHex)) { + return handleAuthorizationError('Admin access required'); + } + + const repos = await scanAllRepos(); + + return json({ + repos, + total: repos.length, + totalSize: repos.reduce((sum, repo) => sum + repo.size, 0) + }); + } catch (err) { + return handleApiError(err, { operation: 'listAdminRepos' }, 'Failed to list repositories'); + } +}; diff --git a/src/routes/api/repos/[npub]/[repo]/delete/+server.ts b/src/routes/api/repos/[npub]/[repo]/delete/+server.ts index 87031a1..971bc28 100644 --- a/src/routes/api/repos/[npub]/[repo]/delete/+server.ts +++ b/src/routes/api/repos/[npub]/[repo]/delete/+server.ts @@ -12,38 +12,14 @@ import { createRepoGetHandler } from '$lib/utils/api-handlers.js'; import type { RepoRequestContext } from '$lib/utils/api-context.js'; import { handleApiError, handleAuthorizationError } from '$lib/utils/error-handler.js'; import { auditLogger } from '$lib/services/security/audit-logger.js'; -import { nip19 } from 'nostr-tools'; import logger from '$lib/services/logger.js'; import { repoCache, RepoCache } from '$lib/services/git/repo-cache.js'; +import { isAdmin } from '$lib/utils/admin-check.js'; const repoRoot = typeof process !== 'undefined' && process.env?.GIT_REPO_ROOT ? process.env.GIT_REPO_ROOT : '/repos'; -// Admin pubkeys (can be set via environment variable) -const ADMIN_PUBKEYS = (typeof process !== 'undefined' && process.env?.ADMIN_PUBKEYS - ? process.env.ADMIN_PUBKEYS.split(',').map(p => p.trim()).filter(p => p.length > 0) - : []) as string[]; - -/** - * Check if user is admin - */ -function isAdmin(userPubkeyHex: string | null): boolean { - if (!userPubkeyHex) return false; - return ADMIN_PUBKEYS.some(adminPubkey => { - // Support both hex and npub formats - try { - const decoded = nip19.decode(adminPubkey); - if (decoded.type === 'npub') { - return decoded.data === userPubkeyHex; - } - } catch { - // Not an npub, compare as hex - } - return adminPubkey.toLowerCase() === userPubkeyHex.toLowerCase(); - }); -} - /** * Check if user is repo owner */ diff --git a/src/routes/repos/[npub]/[repo]/services/commit-operations.ts b/src/routes/repos/[npub]/[repo]/services/commit-operations.ts index 1e2c531..a0273b7 100644 --- a/src/routes/repos/[npub]/[repo]/services/commit-operations.ts +++ b/src/routes/repos/[npub]/[repo]/services/commit-operations.ts @@ -17,6 +17,15 @@ export async function loadCommitHistory( state: RepoState, callbacks: CommitOperationsCallbacks ): Promise { + // Skip if repo is not cloned and no API fallback available + if (state.clone.isCloned === false && !state.clone.apiFallbackAvailable) { + state.loading.commits = false; + state.error = null; + state.git.commits = []; + console.log('[loadCommitHistory] Skipping - repo not cloned and no API fallback available'); + return; + } + state.loading.commits = true; state.error = null; try { @@ -74,7 +83,21 @@ export async function loadCommitHistory( } } catch (err) { console.error('[loadCommitHistory] Error loading commits:', err); - state.error = err instanceof Error ? err.message : 'Failed to load commit history'; + const errorMessage = err instanceof Error ? err.message : 'Failed to load commit history'; + + // Handle 404 gracefully - repo not cloned + if (errorMessage.includes('404') || errorMessage.includes('not found') || errorMessage.includes('Repository not found')) { + // If repo is not cloned, this is expected - don't set error + if (state.clone.isCloned === false) { + state.error = null; + state.git.commits = []; + console.log('[loadCommitHistory] Repo not cloned - commits unavailable'); + } else { + state.error = errorMessage; + } + } else { + state.error = errorMessage; + } } finally { state.loading.commits = false; } diff --git a/src/routes/repos/[npub]/[repo]/utils/api-client.ts b/src/routes/repos/[npub]/[repo]/utils/api-client.ts index 141a743..179f860 100644 --- a/src/routes/repos/[npub]/[repo]/utils/api-client.ts +++ b/src/routes/repos/[npub]/[repo]/utils/api-client.ts @@ -62,7 +62,13 @@ export async function apiRequest( // Ignore parsing errors } } - logger.error({ url, status: response.status, error: errorMessage }, '[API] Request failed'); + + // 404s are expected when repo isn't cloned - log as debug, not error + if (response.status === 404) { + logger.debug({ url, status: response.status, error: errorMessage }, '[API] Request failed (404 - expected for uncloned repos)'); + } else { + logger.error({ url, status: response.status, error: errorMessage }, '[API] Request failed'); + } throw new Error(errorMessage); }