Browse Source

bug-fix

main
Silberengel 4 weeks ago
parent
commit
c639a7eae2
  1. 9
      src/hooks.server.ts
  2. 412
      src/lib/services/git/api-repo-fetcher.ts
  3. 86
      src/lib/services/git/repo-manager.ts
  4. 20
      src/lib/utils/user-access.ts
  5. 30
      src/routes/api/repos/[npub]/[repo]/branches/+server.ts
  6. 124
      src/routes/api/repos/[npub]/[repo]/clone/+server.ts
  7. 3
      src/routes/repos/+page.svelte
  8. 73
      src/routes/repos/[npub]/[repo]/+page.svelte
  9. 11
      src/routes/signup/+page.svelte
  10. 56
      src/routes/users/[npub]/+page.svelte

9
src/hooks.server.ts

@ -84,8 +84,13 @@ export const handle: Handle = async ({ event, resolve }) => { @@ -84,8 +84,13 @@ export const handle: Handle = async ({ event, resolve }) => {
url.pathname.endsWith('/access') || // GET /access is read-only
url.pathname.endsWith('/maintainers')); // GET /maintainers is read-only
// Check rate limit (skip for Vite internal requests and read-only repo requests)
const rateLimitResult = (isViteInternalRequest || isReadOnlyRepoRequest)
// Skip rate limiting for read-only GET requests to user endpoints (profile pages)
const isReadOnlyUserRequest = event.request.method === 'GET' &&
url.pathname.startsWith('/api/users/') &&
(url.pathname.endsWith('/repos')); // GET /users/[npub]/repos is read-only
// Check rate limit (skip for Vite internal requests and read-only requests)
const rateLimitResult = (isViteInternalRequest || isReadOnlyRepoRequest || isReadOnlyUserRequest)
? { allowed: true, resetAt: Date.now() }
: rateLimiter.check(rateLimitType, rateLimitIdentifier, isAnonymous);
if (!rateLimitResult.allowed) {

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

@ -0,0 +1,412 @@ @@ -0,0 +1,412 @@
/**
* API-based repository fetcher service
* Fetches repository metadata from external platforms without cloning
* Supports GitHub, GitLab, Gitea, GRASP, and other git hosting services
*
* This is used by default for displaying repos. Only privileged users
* can explicitly clone repos to the server.
*/
import logger from '../logger.js';
export interface ApiRepoInfo {
name: string;
description?: string;
url: string;
defaultBranch: string;
branches: ApiBranch[];
commits: ApiCommit[];
files: ApiFile[];
readme?: {
path: string;
content: string;
format: 'markdown' | 'asciidoc';
};
platform: 'github' | 'gitlab' | 'gitea' | 'grasp' | 'unknown';
isCloned: boolean; // Whether repo exists locally
}
export interface ApiBranch {
name: string;
commit: {
sha: string;
message: string;
author: string;
date: string;
};
}
export interface ApiCommit {
sha: string;
message: string;
author: string;
date: string;
}
export interface ApiFile {
name: string;
path: string;
type: 'file' | 'dir';
size?: number;
}
type GitPlatform = 'github' | 'gitlab' | 'gitea' | 'grasp' | 'unknown';
/**
* Check if a URL is a GRASP (Git Repository Access via Secure Protocol) URL
* GRASP URLs contain npub (Nostr public key) in the path: https://host/npub.../repo.git
*/
export function isGraspUrl(url: string): boolean {
return /\/npub1[a-z0-9]+/i.test(url);
}
/**
* Parse git URL to extract platform, owner, and repo
*/
function parseGitUrl(url: string): { platform: GitPlatform; owner: string; repo: string; baseUrl: string } | null {
// Handle GRASP URLs - they use Gitea-compatible API but with npub as owner
if (isGraspUrl(url)) {
const graspMatch = url.match(/(https?:\/\/[^/]+)\/(npub1[a-z0-9]+)\/([^/]+?)(?:\.git)?\/?$/i);
if (graspMatch) {
const [, baseHost, npub, repo] = graspMatch;
return {
platform: 'grasp',
owner: npub,
repo: repo.replace(/\.git$/, ''),
baseUrl: `${baseHost}/api/v1`
};
}
return null;
}
// GitHub
const githubMatch = url.match(/github\.com[/:]([^/]+)\/([^/]+?)(?:\.git)?\/?$/);
if (githubMatch) {
return {
platform: 'github',
owner: githubMatch[1],
repo: githubMatch[2].replace(/\.git$/, ''),
baseUrl: 'https://api.github.com'
};
}
// GitLab (both gitlab.com and self-hosted instances)
const gitlabMatch = url.match(/(https?:\/\/[^/]*gitlab[^/]*)\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/);
if (gitlabMatch) {
const baseHost = gitlabMatch[1];
const baseUrl = baseHost.includes('gitlab.com')
? 'https://gitlab.com/api/v4'
: `${baseHost}/api/v4`;
return {
platform: 'gitlab',
owner: gitlabMatch[2],
repo: gitlabMatch[3].replace(/\.git$/, ''),
baseUrl
};
}
// Gitea and other Git hosting services (generic pattern)
const giteaMatch = url.match(/(https?:\/\/[^/]+)\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/);
if (giteaMatch) {
// Double-check it's not a GRASP URL (npub in owner position)
if (giteaMatch[2].startsWith('npub1')) {
return null;
}
return {
platform: 'gitea',
owner: giteaMatch[2],
repo: giteaMatch[3].replace(/\.git$/, ''),
baseUrl: `${giteaMatch[1]}/api/v1`
};
}
return null;
}
/**
* Check if a repository exists locally
*/
async function checkLocalRepo(npub: string, repoName: string): Promise<boolean> {
try {
// Dynamic import to avoid bundling Node.js fs in browser
const { existsSync } = await import('fs');
const { join } = await import('path');
const repoRoot = process.env.GIT_REPO_ROOT || '/repos';
const repoPath = join(repoRoot, npub, `${repoName}.git`);
return existsSync(repoPath);
} catch {
// If we can't check (e.g., in browser), assume not cloned
return false;
}
}
/**
* Fetch repository metadata from GitHub API
*/
async function fetchFromGitHub(owner: string, repo: string): Promise<Partial<ApiRepoInfo> | null> {
try {
const githubToken = process.env.GITHUB_TOKEN;
const headers: Record<string, string> = {
'Accept': 'application/vnd.github.v3+json',
'User-Agent': 'GitRepublic'
};
if (githubToken) {
headers['Authorization'] = `Bearer ${githubToken}`;
}
const repoResponse = await fetch(`https://api.github.com/repos/${owner}/${repo}`, { headers });
if (!repoResponse.ok) {
if (repoResponse.status === 404) {
return null;
}
logger.warn({ status: repoResponse.status, owner, repo }, 'GitHub API error');
return null;
}
const repoData = await repoResponse.json();
const defaultBranch = repoData.default_branch || 'main';
// Fetch branches, commits, and tree in parallel
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)
]);
const branches: ApiBranch[] = branchesResponse?.ok
? (await branchesResponse.json()).map((b: any) => ({
name: b.name,
commit: {
sha: b.commit.sha,
message: b.commit.commit?.message?.split('\n')[0] || 'No commit message',
author: b.commit.commit?.author?.name || 'Unknown',
date: b.commit.commit?.author?.date || new Date().toISOString()
}
}))
: [];
const commits: ApiCommit[] = commitsResponse?.ok
? (await commitsResponse.json()).map((c: any) => ({
sha: c.sha,
message: c.commit?.message?.split('\n')[0] || 'No commit message',
author: c.commit?.author?.name || 'Unknown',
date: c.commit?.author?.date || new Date().toISOString()
}))
: [];
const files: ApiFile[] = treeResponse?.ok
? (await treeResponse.json()).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
})) || []
: [];
// Try to fetch README
let readme: { path: string; content: string; format: 'markdown' | 'asciidoc' } | undefined;
const readmeFiles = ['README.adoc', 'README.md', 'README.rst', 'README.txt'];
for (const readmeFile of readmeFiles) {
try {
const readmeResponse = await fetch(
`https://api.github.com/repos/${owner}/${repo}/contents/${readmeFile}?ref=${defaultBranch}`,
{ headers }
);
if (readmeResponse.ok) {
const readmeData = await readmeResponse.json();
if (readmeData.content) {
const content = atob(readmeData.content.replace(/\s/g, ''));
readme = {
path: readmeFile,
content,
format: readmeFile.toLowerCase().endsWith('.adoc') ? 'asciidoc' : 'markdown'
};
break;
}
}
} catch {
continue;
}
}
return {
name: repoData.name,
description: repoData.description,
url: repoData.html_url,
defaultBranch,
branches,
commits,
files,
readme,
platform: 'github'
};
} catch (error) {
logger.error({ error, owner, repo }, 'Error fetching from GitHub');
return null;
}
}
/**
* Fetch repository metadata from GitLab API
* Note: This is a simplified version. For full implementation, see aitherboard's git-repo-fetcher.ts
*/
async function fetchFromGitLab(owner: string, repo: string, baseUrl: string): Promise<Partial<ApiRepoInfo> | null> {
try {
const projectPath = encodeURIComponent(`${owner}/${repo}`);
const repoResponse = await fetch(`${baseUrl}/projects/${projectPath}`);
if (!repoResponse.ok) {
if (repoResponse.status === 404) {
return null;
}
return null;
}
const repoData = await repoResponse.json();
const defaultBranch = repoData.default_branch || 'master';
// For now, return basic info. Full implementation would fetch branches, commits, files
return {
name: repoData.name,
description: repoData.description,
url: repoData.web_url,
defaultBranch,
branches: [],
commits: [],
files: [],
platform: 'gitlab'
};
} catch (error) {
logger.error({ error, owner, repo }, 'Error fetching from GitLab');
return null;
}
}
/**
* Fetch repository metadata from Gitea API
*/
async function fetchFromGitea(owner: string, repo: string, baseUrl: string): Promise<Partial<ApiRepoInfo> | null> {
try {
const encodedOwner = encodeURIComponent(owner);
const encodedRepo = encodeURIComponent(repo);
const repoResponse = await fetch(`${baseUrl}/repos/${encodedOwner}/${encodedRepo}`);
if (!repoResponse.ok) {
if (repoResponse.status === 404) {
return null;
}
return null;
}
const repoData = await repoResponse.json();
const defaultBranch = repoData.default_branch || 'master';
return {
name: repoData.name,
description: repoData.description,
url: repoData.html_url || repoData.clone_url,
defaultBranch,
branches: [],
commits: [],
files: [],
platform: 'gitea'
};
} catch (error) {
logger.error({ error, owner, repo }, 'Error fetching from Gitea');
return null;
}
}
/**
* Fetch repository metadata from GRASP
* GRASP repos use git protocol, so we can't easily fetch metadata via API
* For now, return minimal info indicating it's a GRASP repo
*/
async function fetchFromGrasp(npub: string, repo: string, baseUrl: string, originalUrl: string): Promise<Partial<ApiRepoInfo> | null> {
// GRASP repos typically don't have REST APIs
// Full implementation would use git protocol (info/refs, git-upload-pack)
// For now, return basic structure
return {
name: repo,
description: undefined,
url: originalUrl,
defaultBranch: 'main',
branches: [],
commits: [],
files: [],
platform: 'grasp'
};
}
/**
* Fetch repository metadata from a git URL
* This is the main entry point for API-based fetching
*/
export async function fetchRepoMetadata(
url: string,
npub: string,
repoName: string
): Promise<ApiRepoInfo | null> {
const parsed = parseGitUrl(url);
if (!parsed) {
logger.warn({ url }, 'Unable to parse git URL');
return null;
}
const { platform, owner, repo, baseUrl } = parsed;
const isCloned = await checkLocalRepo(npub, repoName);
let metadata: Partial<ApiRepoInfo> | null = null;
switch (platform) {
case 'github':
metadata = await fetchFromGitHub(owner, repo);
break;
case 'gitlab':
metadata = await fetchFromGitLab(owner, repo, baseUrl);
break;
case 'gitea':
metadata = await fetchFromGitea(owner, repo, baseUrl);
break;
case 'grasp':
metadata = await fetchFromGrasp(owner, repo, baseUrl, url);
break;
default:
logger.warn({ platform, url }, 'Unsupported platform');
return null;
}
if (!metadata) {
return null;
}
return {
...metadata,
isCloned,
platform
} as ApiRepoInfo;
}
/**
* Extract git URLs from a Nostr repo announcement event
*/
export function extractGitUrls(event: { tags: string[][] }): string[] {
const urls: string[] = [];
for (const tag of event.tags) {
if (tag[0] === 'clone') {
// Clone tags can have multiple URLs: ["clone", "url1", "url2", "url3"]
for (let i = 1; i < tag.length; i++) {
const url = tag[i];
if (url && typeof url === 'string' && (url.startsWith('http') || url.startsWith('git@'))) {
urls.push(url);
}
}
}
}
return [...new Set(urls)]; // Deduplicate
}

86
src/lib/services/git/repo-manager.ts

@ -163,6 +163,41 @@ export class RepoManager { @@ -163,6 +163,41 @@ export class RepoManager {
* Get git environment variables with Tor proxy if needed for .onion addresses
* Security: Only whitelist necessary environment variables
*/
/**
* Inject authentication token into a git URL if needed
* Supports GitHub tokens via GITHUB_TOKEN environment variable
* Returns the original URL if no token is needed or available
*/
private injectAuthToken(url: string): string {
try {
const urlObj = new URL(url);
// If URL already has credentials, don't modify it
if (urlObj.username) {
return url;
}
// Check for GitHub token
if (urlObj.hostname === 'github.com' || urlObj.hostname.endsWith('.github.com')) {
const githubToken = process.env.GITHUB_TOKEN;
if (githubToken) {
// Inject token into URL: https://token@github.com/user/repo.git
urlObj.username = githubToken;
urlObj.password = ''; // GitHub uses token as username, password is empty
return urlObj.toString();
}
}
// Add support for other git hosting services here if needed
// e.g., GitLab: GITLAB_TOKEN, Gitea: GITEA_TOKEN, etc.
return url;
} catch {
// URL parsing failed, return original URL
return url;
}
}
private getGitEnvForUrl(url: string): Record<string, string> {
// Whitelist only necessary environment variables for security
const env: Record<string, string> = {
@ -213,14 +248,23 @@ export class RepoManager { @@ -213,14 +248,23 @@ export class RepoManager {
private async syncFromSingleRemote(repoPath: string, url: string, index: number): Promise<void> {
const remoteName = `remote-${index}`;
const git = simpleGit(repoPath);
const gitEnv = this.getGitEnvForUrl(url);
// Inject authentication token if available (e.g., GITHUB_TOKEN)
const authenticatedUrl = this.injectAuthToken(url);
const gitEnv = this.getGitEnvForUrl(authenticatedUrl);
try {
// Add remote if not exists (ignore error if already exists)
// Use authenticated URL so git can access private repos
try {
await git.addRemote(remoteName, url);
await git.addRemote(remoteName, authenticatedUrl);
} catch {
// Remote might already exist, that's okay
// Remote might already exist, that's okay - try to update it
try {
await git.removeRemote(remoteName);
await git.addRemote(remoteName, authenticatedUrl);
} catch {
// If update fails, continue - might be using old URL
}
}
// Configure git proxy for this remote if it's a .onion address
@ -545,16 +589,25 @@ export class RepoManager { @@ -545,16 +589,25 @@ export class RepoManager {
}
// Try to clone from the first available remote URL
// Use simple-git for safer cloning
// Inject authentication token if available (e.g., GITHUB_TOKEN)
const authenticatedUrl = this.injectAuthToken(remoteUrls[0]);
const git = simpleGit();
const gitEnv = this.getGitEnvForUrl(remoteUrls[0]);
const gitEnv = this.getGitEnvForUrl(authenticatedUrl);
logger.info({ npub, repoName, sourceUrl: remoteUrls[0], cloneUrls }, 'Fetching repository on-demand from remote');
// Log if we're using authentication (but don't log the token)
const isAuthenticated = authenticatedUrl !== remoteUrls[0];
logger.info({
npub,
repoName,
sourceUrl: remoteUrls[0],
cloneUrls,
authenticated: isAuthenticated
}, 'Fetching repository on-demand from remote');
// Clone as bare repository
// Use gitEnv which already contains necessary whitelisted environment variables
await new Promise<void>((resolve, reject) => {
const cloneProcess = spawn('git', ['clone', '--bare', remoteUrls[0], repoPath], {
const cloneProcess = spawn('git', ['clone', '--bare', authenticatedUrl, repoPath], {
env: gitEnv,
stdio: ['ignore', 'pipe', 'pipe']
});
@ -574,13 +627,28 @@ export class RepoManager { @@ -574,13 +627,28 @@ export class RepoManager {
resolve();
} else {
const errorMsg = `Git clone failed with code ${code}: ${stderr || stdout}`;
logger.error({ npub, repoName, sourceUrl: remoteUrls[0], code, stderr, stdout }, 'Git clone failed');
// Don't log the authenticated URL (might contain token)
logger.error({
npub,
repoName,
sourceUrl: remoteUrls[0],
code,
stderr,
stdout,
authenticated: isAuthenticated
}, 'Git clone failed');
reject(new Error(errorMsg));
}
});
cloneProcess.on('error', (err) => {
logger.error({ npub, repoName, sourceUrl: remoteUrls[0], error: err }, 'Git clone process error');
logger.error({
npub,
repoName,
sourceUrl: remoteUrls[0],
error: err,
authenticated: isAuthenticated
}, 'Git clone process error');
reject(err);
});
});

20
src/lib/utils/user-access.ts

@ -0,0 +1,20 @@ @@ -0,0 +1,20 @@
/**
* Utility functions for checking user access levels
*/
import type { UserLevel } from '../services/nostr/user-level-service.js';
/**
* Check if a user has unlimited/write access
* Only unlimited users can clone repos and register new repos
*/
export function hasUnlimitedAccess(userLevel: UserLevel | null | undefined): boolean {
return userLevel === 'unlimited';
}
/**
* Check if a user is logged in (has any access level)
*/
export function isLoggedIn(userLevel: UserLevel | null | undefined): boolean {
return userLevel !== null && userLevel !== undefined && userLevel !== 'strictly_rate_limited';
}

30
src/routes/api/repos/[npub]/[repo]/branches/+server.ts

@ -13,6 +13,9 @@ import { KIND } from '$lib/types/nostr.js'; @@ -13,6 +13,9 @@ import { KIND } from '$lib/types/nostr.js';
import { join } from 'path';
import { existsSync } from 'fs';
import { repoCache, RepoCache } from '$lib/services/git/repo-cache.js';
import { DEFAULT_NOSTR_RELAYS, DEFAULT_NOSTR_SEARCH_RELAYS } from '$lib/config.js';
import { NostrClient } from '$lib/services/nostr/nostr-client.js';
import { eventCache } from '$lib/services/nostr/event-cache.js';
const repoRoot = typeof process !== 'undefined' && process.env?.GIT_REPO_ROOT
? process.env.GIT_REPO_ROOT
@ -25,15 +28,30 @@ export const GET: RequestHandler = createRepoGetHandler( @@ -25,15 +28,30 @@ export const GET: RequestHandler = createRepoGetHandler(
// If repo doesn't exist, try to fetch it on-demand
if (!existsSync(repoPath)) {
try {
// Fetch repository announcement from Nostr
const events = await nostrClient.fetchEvents([
// Try cached client first (cache-first lookup)
const filters = [
{
kinds: [KIND.REPO_ANNOUNCEMENT],
authors: [context.repoOwnerPubkey],
'#d': [context.repo],
limit: 1
}
]);
];
let events = await nostrClient.fetchEvents(filters);
// If no events found in cache/default relays, try all relays (default + search)
// But first invalidate the cache entry so we don't get the same cached empty result
if (events.length === 0) {
const allRelays = [...new Set([...DEFAULT_NOSTR_RELAYS, ...DEFAULT_NOSTR_SEARCH_RELAYS])];
// Only create new client if we have additional relays to try
if (allRelays.length > DEFAULT_NOSTR_RELAYS.length) {
// Invalidate the cache entry so we can try fresh with all relays
eventCache.invalidate(filters);
const allRelaysClient = new NostrClient(allRelays);
events = await allRelaysClient.fetchEvents(filters);
}
}
if (events.length > 0) {
// Try to fetch the repository from remote clone URLs
@ -74,8 +92,12 @@ export const GET: RequestHandler = createRepoGetHandler( @@ -74,8 +92,12 @@ export const GET: RequestHandler = createRepoGetHandler(
}
}
} else {
// No events found - could be because:
// 1. Repository doesn't exist
// 2. Relays are unreachable
// 3. Repository is on different relays
throw handleNotFoundError(
'Repository announcement not found in Nostr',
'Repository announcement not found in Nostr. This could mean: (1) the repository does not exist, (2) the configured Nostr relays are unreachable, or (3) the repository is published on different relays. Try configuring additional relays via the NOSTR_RELAYS environment variable.',
{ operation: 'getBranches', npub: context.npub, repo: context.repo }
);
}

124
src/routes/api/repos/[npub]/[repo]/clone/+server.ts

@ -0,0 +1,124 @@ @@ -0,0 +1,124 @@
/**
* Clone repository endpoint
* Only privileged users (unlimited access) can clone repos to the server
*/
import { error, json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { RepoManager } from '$lib/services/git/repo-manager.js';
import { requireNpubHex } from '$lib/utils/npub-utils.js';
import { existsSync } from 'fs';
import { join } from 'path';
import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js';
import { NostrClient } from '$lib/services/nostr/nostr-client.js';
import { KIND } from '$lib/types/nostr.js';
import { extractRequestContext } from '$lib/utils/api-context.js';
import { getCachedUserLevel } from '$lib/services/security/user-level-cache.js';
import logger from '$lib/services/logger.js';
import { handleApiError, handleValidationError } from '$lib/utils/error-handler.js';
const repoRoot = process.env.GIT_REPO_ROOT || '/repos';
const repoManager = new RepoManager(repoRoot);
const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS);
export const POST: RequestHandler = async (event) => {
const { npub, repo } = event.params;
if (!npub || !repo) {
throw handleValidationError('Missing npub or repo parameter', { operation: 'cloneRepo', npub, repo });
}
// Extract user context
const requestContext = extractRequestContext(event);
const userPubkeyHex = requestContext.userPubkeyHex;
if (!userPubkeyHex) {
throw error(401, 'Authentication required. Please log in to clone repositories.');
}
// Check if user has unlimited access
const userLevel = getCachedUserLevel(userPubkeyHex);
if (!userLevel || userLevel.level !== 'unlimited') {
throw error(403, 'Only users with unlimited access can clone repositories to the server.');
}
try {
// Decode npub to get pubkey
const repoOwnerPubkey = requireNpubHex(npub);
const repoPath = join(repoRoot, npub, `${repo}.git`);
// Check if repo already exists
if (existsSync(repoPath)) {
return json({
success: true,
message: 'Repository already exists locally',
alreadyExists: true
});
}
// Fetch repository announcement
const events = await nostrClient.fetchEvents([
{
kinds: [KIND.REPO_ANNOUNCEMENT],
authors: [repoOwnerPubkey],
'#d': [repo],
limit: 1
}
]);
if (events.length === 0) {
throw handleValidationError(
'Repository announcement not found in Nostr',
{ operation: 'cloneRepo', npub, repo }
);
}
const announcementEvent = events[0];
// Attempt to clone the repository
const cloned = await repoManager.fetchRepoOnDemand(npub, repo, announcementEvent);
if (!cloned) {
throw handleApiError(
new Error('Failed to clone repository from remote URLs'),
{ operation: 'cloneRepo', npub, repo },
'Could not clone repository. Please check that the repository has valid clone URLs and is accessible.'
);
}
// Verify repo exists after cloning
if (!existsSync(repoPath)) {
// Wait a moment for filesystem to sync
await new Promise(resolve => setTimeout(resolve, 500));
if (!existsSync(repoPath)) {
throw handleApiError(
new Error('Repository clone completed but repository is not accessible'),
{ operation: 'cloneRepo', npub, repo },
'Repository clone completed but repository is not accessible'
);
}
}
logger.info({ npub, repo, userPubkeyHex: userPubkeyHex.slice(0, 16) + '...' }, 'Repository cloned successfully');
return json({
success: true,
message: 'Repository cloned successfully',
alreadyExists: false
});
} catch (err) {
logger.error({ error: err, npub, repo }, 'Error cloning repository');
// Re-throw auth errors as-is
if (err instanceof Error && (err.message.includes('401') || err.message.includes('403'))) {
throw err;
}
const error = err instanceof Error ? err : new Error(String(err));
throw handleApiError(
error,
{ operation: 'cloneRepo', npub, repo },
'Failed to clone repository'
);
}
};

3
src/routes/repos/+page.svelte

@ -9,6 +9,7 @@ @@ -9,6 +9,7 @@
import { ForkCountService } from '$lib/services/nostr/fork-count-service.js';
import { getPublicKeyWithNIP07, isNIP07Available } from '$lib/services/nostr/nip07-signer.js';
import { userStore } from '$lib/stores/user-store.js';
import { hasUnlimitedAccess } from '$lib/utils/user-access.js';
// Registered repos (with domain in clone URLs)
let registeredRepos = $state<Array<{ event: NostrEvent; npub: string; repoName: string }>>([]);
@ -692,7 +693,7 @@ @@ -692,7 +693,7 @@
>
{deletingRepo?.npub === item.npub && deletingRepo?.repo === item.repoName ? 'Deleting...' : 'Delete'}
</button>
{:else}
{:else if hasUnlimitedAccess($userStore.userLevel)}
<button
class="register-button"
onclick={() => registerRepo(item.npub, item.repoName)}

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

@ -16,6 +16,7 @@ @@ -16,6 +16,7 @@
import { userStore } from '$lib/stores/user-store.js';
import { generateVerificationFile, VERIFICATION_FILE_PATH } from '$lib/services/nostr/repo-verification.js';
import type { NostrEvent } from '$lib/types/nostr.js';
import { hasUnlimitedAccess } from '$lib/utils/user-access.js';
// Get page data for OpenGraph metadata - use $derived to make it reactive
const pageData = $derived($page.data as {
@ -123,6 +124,11 @@ @@ -123,6 +124,11 @@
let isMaintainer = $state(false);
let loadingMaintainerStatus = $state(false);
// Clone status
let isRepoCloned = $state<boolean | null>(null); // null = unknown, true = cloned, false = not cloned
let checkingCloneStatus = $state(false);
let cloning = $state(false);
// Verification status
let verificationStatus = $state<{ verified: boolean; error?: string; message?: string } | null>(null);
let showVerificationDialog = $state(false);
@ -473,6 +479,58 @@ @@ -473,6 +479,58 @@
}
}
async function checkCloneStatus() {
if (checkingCloneStatus || isRepoCloned !== null) return;
checkingCloneStatus = true;
try {
// Check if repo exists locally by trying to fetch branches
// If it returns 404, repo is not cloned
const response = await fetch(`/api/repos/${npub}/${repo}/branches`);
isRepoCloned = response.ok;
} catch (err) {
isRepoCloned = false;
} finally {
checkingCloneStatus = false;
}
}
async function cloneRepository() {
if (cloning) return;
cloning = true;
try {
const response = await fetch(`/api/repos/${npub}/${repo}/clone`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({ message: response.statusText }));
throw new Error(errorData.message || `Failed to clone repository: ${response.statusText}`);
}
const data = await response.json();
isRepoCloned = true;
if (data.alreadyExists) {
alert('Repository already exists locally.');
} else {
alert('Repository cloned successfully! The repository is now available on this server.');
// Reload the page to show the cloned repo
window.location.reload();
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to clone repository';
alert(`Error: ${errorMessage}`);
console.error('Error cloning repository:', err);
} finally {
cloning = false;
}
}
async function forkRepository() {
if (!userPubkey) {
alert('Please connect your NIP-07 extension');
@ -822,6 +880,11 @@ @@ -822,6 +880,11 @@
await loadTags();
await checkMaintainerStatus();
await loadBookmarkStatus();
// Check clone status if user has unlimited access
if (hasUnlimitedAccess($userStore.userLevel)) {
await checkCloneStatus();
}
await checkVerification();
await loadReadme();
await loadForkInfo();
@ -1779,6 +1842,16 @@ @@ -1779,6 +1842,16 @@
>
{loadingBookmark ? '...' : (isBookmarked ? '★' : '☆')}
</button>
{#if hasUnlimitedAccess($userStore.userLevel) && (isRepoCloned === false || (isRepoCloned === null && !checkingCloneStatus))}
<button
onclick={cloneRepository}
disabled={cloning || checkingCloneStatus}
class="clone-button"
title="Clone this repository to the server (privileged users only)"
>
{cloning ? 'Cloning...' : (checkingCloneStatus ? 'Checking...' : 'Clone to Server')}
</button>
{/if}
{#if isMaintainer}
<a href={`/repos/${npub}/${repo}/settings`} class="settings-button">Settings</a>
{/if}

11
src/routes/signup/+page.svelte

@ -8,6 +8,8 @@ @@ -8,6 +8,8 @@
import { KIND } from '../../lib/types/nostr.js';
import type { NostrEvent } from '../../lib/types/nostr.js';
import { nip19 } from 'nostr-tools';
import { userStore } from '../../lib/stores/user-store.js';
import { hasUnlimitedAccess } from '../../lib/utils/user-access.js';
let nip07Available = $state(false);
let loading = $state(false);
@ -1472,8 +1474,13 @@ @@ -1472,8 +1474,13 @@
</header>
<main>
{#if !nip07Available}
{#if !hasUnlimitedAccess($userStore.userLevel)}
<div class="warning">
<p>Only users with unlimited access can create or register repositories.</p>
<p>Please log in with an account that has write access to Nostr relays.</p>
<button onclick={() => goto('/')} class="button-primary">Go to Home</button>
</div>
{:else if !nip07Available}
<div class="warning">
<p>NIP-07 browser extension is required to sign repository announcements.</p>
<p>Please install a Nostr browser extension (like Alby, nos2x, or similar).</p>

56
src/routes/users/[npub]/+page.svelte

@ -21,28 +21,32 @@ @@ -21,28 +21,32 @@
let error = $state<string | null>(null);
let userPubkey = $state<string | null>(null);
let viewerPubkeyHex = $state<string | null>(null);
let lastViewerPubkeyHex = $state<string | null>(null); // Track last viewer pubkey to detect changes
let isReloading = $state(false); // Guard to prevent concurrent reloads
// Sync with userStore
// Sync with userStore - only reload if viewer pubkey actually changed
$effect(() => {
const currentUser = $userStore;
const wasLoggedIn = userPubkey !== null || viewerPubkeyHex !== null;
if (currentUser.userPubkey && currentUser.userPubkeyHex) {
const wasDifferent = userPubkey !== currentUser.userPubkey || viewerPubkeyHex !== currentUser.userPubkeyHex;
userPubkey = currentUser.userPubkey;
viewerPubkeyHex = currentUser.userPubkeyHex;
// Reload profile and repos when user logs in or pubkey changes
if (wasDifferent) {
loadUserProfile().catch(err => console.warn('Failed to reload user profile after login:', err));
}
} else {
userPubkey = null;
viewerPubkeyHex = null;
// Reload profile when user logs out to hide private repos
if (wasLoggedIn) {
loadUserProfile().catch(err => console.warn('Failed to reload user profile after logout:', err));
const newViewerPubkeyHex = currentUser.userPubkeyHex;
// Only update if viewer pubkey actually changed (not just any store change)
if (newViewerPubkeyHex !== lastViewerPubkeyHex) {
const wasLoggedIn = viewerPubkeyHex !== null;
const isNowLoggedIn = newViewerPubkeyHex !== null;
// Update viewer pubkey
viewerPubkeyHex = newViewerPubkeyHex;
lastViewerPubkeyHex = newViewerPubkeyHex;
// Only reload if login state actually changed (logged in -> logged out or vice versa)
// AND we're not already loading/reloading
if ((wasLoggedIn !== isNowLoggedIn) && !loading && !isReloading) {
isReloading = true;
loadUserProfile()
.catch(err => console.warn('Failed to reload user profile after login state change:', err))
.finally(() => {
isReloading = false;
});
}
}
});
@ -105,17 +109,27 @@ @@ -105,17 +109,27 @@
}
async function loadUserProfile() {
// Prevent concurrent loads
if (loading && !isReloading) {
return;
}
loading = true;
error = null;
try {
// Decode npub to get pubkey
// Decode npub to get pubkey (this is the profile owner, not the viewer)
const decoded = nip19.decode(npub);
if (decoded.type !== 'npub') {
error = 'Invalid npub format';
return;
}
userPubkey = decoded.data as string;
const profileOwnerPubkey = decoded.data as string;
// Only update userPubkey if it's different (avoid triggering effects)
if (userPubkey !== profileOwnerPubkey) {
userPubkey = profileOwnerPubkey;
}
// Fetch user's repositories via API (with privacy filtering)
const url = `/api/users/${npub}/repos?domain=${encodeURIComponent(gitDomain)}`;

Loading…
Cancel
Save