Browse Source

administer the repos

Nostr-Signature: 8825fb9bd01e099c1369f0c9ea1429dedd0a0116d103b4a640752c0a830fbc61 573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc 676f0817f817204ad910a70540399f71743a54453ae209535dcb30356d042b049138d9cfdeec08c4b7da03bb6bb51c71477bbf8d2f58bd4b602b9f69af4b3405
main
Silberengel 2 weeks ago
parent
commit
6f38dc9e20
  1. 1
      docker-compose.yml
  2. 1
      nostr/commit-signatures.jsonl
  3. 90
      src/lib/components/NavBar.svelte
  4. 106
      src/lib/utils/admin-check.ts
  5. 423
      src/routes/admin/repos/+page.svelte
  6. 47
      src/routes/api/admin/check/+server.ts
  7. 146
      src/routes/api/admin/repos/+server.ts
  8. 26
      src/routes/api/repos/[npub]/[repo]/delete/+server.ts
  9. 25
      src/routes/repos/[npub]/[repo]/services/commit-operations.ts
  10. 6
      src/routes/repos/[npub]/[repo]/utils/api-client.ts

1
docker-compose.yml

@ -26,6 +26,7 @@ services: @@ -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

1
nostr/commit-signatures.jsonl

@ -121,3 +121,4 @@ @@ -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"}

90
src/lib/components/NavBar.svelte

@ -11,9 +11,11 @@ @@ -11,9 +11,11 @@
import { determineUserLevel, decodePubkey } from '../services/nostr/user-level-service.js';
let userPubkey = $state<string | null>(null);
let userPubkeyHex = $state<string | null>(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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -348,6 +435,9 @@
<a href="/signup" class:active={isActive('/signup')} onclick={() => closeMobileMenu()}>Register</a>
<a href="/docs" class:active={isActive('/docs')} onclick={() => closeMobileMenu()}>Docs</a>
<a href="/api-docs" class:active={isActive('/api-docs')} onclick={() => closeMobileMenu()}>API Docs</a>
{#if isUserAdmin}
<a href="/admin/repos" class:active={isActive('/admin/repos')} onclick={() => closeMobileMenu()}>Admin</a>
{/if}
</div>
</nav>
<div class="auth-section">

106
src/lib/utils/admin-check.ts

@ -0,0 +1,106 @@ @@ -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;
}

423
src/routes/admin/repos/+page.svelte

@ -0,0 +1,423 @@ @@ -0,0 +1,423 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { userStore } from '$lib/stores/user-store.js';
interface AdminRepo {
npub: string;
repoName: string;
fullPath: string;
size: number;
lastModified: number;
createdAt: number;
}
let repos = $state<AdminRepo[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
let deleting = $state<Set<string>>(new Set());
let totalSize = $state(0);
// Check if user has admin access via API
let accessChecked = $state(false);
let hasAccess = $state(false);
async function checkAdminAccess() {
if (typeof window === 'undefined') return;
const user = $userStore;
if (!user || !user.userPubkeyHex) {
hasAccess = false;
accessChecked = true;
// Redirect to repos page (not splash) if not logged in
setTimeout(() => {
goto('/repos');
}, 100);
return;
}
try {
const response = await fetch('/api/admin/check', {
headers: {
'X-User-Pubkey': user.userPubkeyHex
}
});
if (response.ok) {
const data = await response.json();
hasAccess = data.isAdmin === true;
console.log('[Admin] Admin check result:', data.isAdmin, 'for user:', user.userPubkeyHex.substring(0, 16) + '...');
} else {
hasAccess = false;
console.warn('[Admin] Admin check failed:', response.status);
}
} catch (err) {
console.warn('[Admin] Failed to check admin status:', err);
hasAccess = false;
} finally {
accessChecked = true;
// Redirect to repos page (not splash) if user doesn't have admin access
// But only if they're logged in - if not logged in, already redirected above
if (!hasAccess && user && user.userPubkeyHex) {
setTimeout(() => {
goto('/repos');
}, 100);
}
}
}
// Check admin access and load repos on mount
onMount(() => {
checkAdminAccess().then(() => {
// Only load repos if user has access
if (hasAccess) {
loadRepos();
}
});
});
async function loadRepos() {
loading = true;
error = null;
try {
const user = $userStore;
if (!user?.userPubkeyHex) {
throw new Error('Not authenticated');
}
const response = await fetch('/api/admin/repos', {
headers: {
'X-User-Pubkey': user.userPubkeyHex
}
});
if (!response.ok) {
const data = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(data.error || `Failed to load repositories: ${response.statusText}`);
}
const data = await response.json();
repos = data.repos || [];
totalSize = data.totalSize || 0;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load repositories';
console.error('[Admin] Failed to load repos:', e);
} finally {
loading = false;
}
}
async function deleteRepo(npub: string, repoName: string) {
const repoKey = `${npub}/${repoName}`;
if (!confirm(`Are you sure you want to delete ${repoName}? This action cannot be undone.`)) {
return;
}
deleting.add(repoKey);
error = null;
try {
const user = $userStore;
if (!user?.userPubkeyHex) {
throw new Error('Not authenticated');
}
const response = await fetch(`/api/repos/${npub}/${repoName}/delete`, {
method: 'DELETE',
headers: {
'X-User-Pubkey': user.userPubkeyHex
}
});
if (!response.ok) {
const data = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(data.error || `Failed to delete repository: ${response.statusText}`);
}
// Remove from list
repos = repos.filter(r => !(r.npub === npub && r.repoName === repoName));
totalSize = repos.reduce((sum, repo) => sum + repo.size, 0);
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to delete repository';
console.error('[Admin] Failed to delete repo:', e);
alert(error);
} finally {
deleting.delete(repoKey);
}
}
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
}
function formatDate(timestamp: number): string {
return new Date(timestamp).toLocaleString();
}
</script>
<svelte:head>
<title>Admin - Repositories</title>
</svelte:head>
<div class="admin-page">
<div class="admin-header">
<h1>Repository Administration</h1>
<button onclick={loadRepos} disabled={loading} class="refresh-button">
{loading ? 'Loading...' : 'Refresh'}
</button>
</div>
{#if !accessChecked}
<div class="loading">Checking access...</div>
{:else if !hasAccess}
<div class="error-message">Access denied. Admin privileges required.</div>
{:else}
{#if error}
<div class="error-message">
{error}
</div>
{/if}
{#if loading}
<div class="loading">Loading repositories...</div>
{:else}
<div class="stats">
<div class="stat">
<span class="stat-label">Total Repositories:</span>
<span class="stat-value">{repos.length}</span>
</div>
<div class="stat">
<span class="stat-label">Total Size:</span>
<span class="stat-value">{formatBytes(totalSize)}</span>
</div>
</div>
<div class="repos-table-container">
<table class="repos-table">
<thead>
<tr>
<th>Owner (npub)</th>
<th>Repository Name</th>
<th>Size</th>
<th>Last Modified</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{#each repos as repo (repo.npub + repo.repoName)}
<tr>
<td class="npub-cell">
<code>{repo.npub.substring(0, 20)}...</code>
</td>
<td class="repo-name-cell">
<a href="/repos/{repo.npub}/{repo.repoName}" target="_blank">
{repo.repoName}
</a>
</td>
<td>{formatBytes(repo.size)}</td>
<td>{formatDate(repo.lastModified)}</td>
<td>{formatDate(repo.createdAt)}</td>
<td class="actions-cell">
<button
onclick={() => deleteRepo(repo.npub, repo.repoName)}
disabled={deleting.has(`${repo.npub}/${repo.repoName}`)}
class="delete-button"
title="Delete repository"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
<line x1="10" y1="11" x2="10" y2="17"/>
<line x1="14" y1="11" x2="14" y2="17"/>
</svg>
</button>
</td>
</tr>
{:else}
<tr>
<td colspan="6" class="empty-message">No repositories found</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
{/if}
</div>
<style>
.admin-page {
max-width: 1400px;
margin: 0 auto;
padding: 2rem;
}
.admin-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
}
.admin-header h1 {
margin: 0;
font-size: 2rem;
}
.refresh-button {
padding: 0.5rem 1rem;
background: var(--button-primary);
color: var(--accent-text, #ffffff);
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
font-family: 'IBM Plex Serif', serif;
transition: background 0.2s ease;
}
.refresh-button:hover:not(:disabled) {
background: var(--button-primary-hover);
}
.refresh-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.error-message {
background: var(--error-bg);
color: var(--error-text);
padding: 1rem;
border-radius: 4px;
margin-bottom: 1rem;
border: 1px solid var(--error-text);
}
.loading {
text-align: center;
padding: 2rem;
color: var(--text-muted);
}
.stats {
display: flex;
gap: 2rem;
margin-bottom: 2rem;
padding: 1rem;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 4px;
}
.stat {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.stat-label {
font-size: 0.875rem;
color: var(--text-muted);
}
.stat-value {
font-size: 1.25rem;
font-weight: bold;
color: var(--text-primary);
}
.repos-table-container {
overflow-x: auto;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 4px;
box-shadow: 0 2px 4px var(--shadow-color-light);
}
.repos-table {
width: 100%;
border-collapse: collapse;
}
.repos-table thead {
background: var(--bg-secondary);
}
.repos-table th {
padding: 1rem;
text-align: left;
font-weight: 600;
border-bottom: 2px solid var(--border-color);
color: var(--text-primary);
}
.repos-table td {
padding: 1rem;
border-bottom: 1px solid var(--border-light);
color: var(--text-primary);
}
.repos-table tbody tr:hover {
background: var(--bg-tertiary);
}
.npub-cell code {
font-size: 0.875rem;
color: var(--text-secondary);
background: var(--bg-secondary);
padding: 0.25rem 0.5rem;
border-radius: 3px;
border: 1px solid var(--border-color);
}
.repo-name-cell a {
color: var(--link-color);
text-decoration: none;
font-weight: 500;
}
.repo-name-cell a:hover {
color: var(--link-hover);
text-decoration: underline;
}
.actions-cell {
text-align: center;
}
.delete-button {
background: transparent;
border: none;
color: var(--error-text);
cursor: pointer;
padding: 0.5rem;
border-radius: 4px;
display: inline-flex;
align-items: center;
justify-content: center;
transition: background 0.2s;
}
.delete-button:hover:not(:disabled) {
background: var(--error-bg);
}
.delete-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.empty-message {
text-align: center;
padding: 2rem;
color: var(--text-muted);
}
</style>

47
src/routes/api/admin/check/+server.ts

@ -0,0 +1,47 @@ @@ -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 });
};

146
src/routes/api/admin/repos/+server.ts

@ -0,0 +1,146 @@ @@ -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<AdminRepoItem[]> {
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<number> => {
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');
}
};

26
src/routes/api/repos/[npub]/[repo]/delete/+server.ts

@ -12,38 +12,14 @@ import { createRepoGetHandler } from '$lib/utils/api-handlers.js'; @@ -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
*/

25
src/routes/repos/[npub]/[repo]/services/commit-operations.ts

@ -17,6 +17,15 @@ export async function loadCommitHistory( @@ -17,6 +17,15 @@ export async function loadCommitHistory(
state: RepoState,
callbacks: CommitOperationsCallbacks
): Promise<void> {
// 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( @@ -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;
}

6
src/routes/repos/[npub]/[repo]/utils/api-client.ts

@ -62,7 +62,13 @@ export async function apiRequest<T>( @@ -62,7 +62,13 @@ export async function apiRequest<T>(
// Ignore parsing errors
}
}
// 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);
}

Loading…
Cancel
Save