Browse Source

refactor 13

Nostr-Signature: f41c8662dcbf1be408c560d11eda0890c40582a8ea8bb3220116e645cc6a2bb5 573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc 2b7b70089cecfa4652fe236fa586a6fe1b05c1c95434a160717cbf5ee2f37382cdd8e8f31d7b3a7576ee5264e9e70c7a8651591caaea0cd311d1be4c561d282f
main
Silberengel 2 weeks ago
parent
commit
0532541a5c
  1. 1
      nostr/commit-signatures.jsonl
  2. 22
      src/app.html
  3. 10
      src/lib/components/RepoHeaderEnhanced.svelte
  4. 128
      src/lib/components/UserBadge.svelte
  5. 170
      src/lib/services/git/repo-manager.ts
  6. 48
      src/lib/services/git/repo-url-parser.ts
  7. 77
      src/lib/services/nostr/nostr-client.ts
  8. 34
      src/routes/api/repos/[npub]/[repo]/clone/+server.ts
  9. 7
      src/routes/api/repos/[npub]/[repo]/verify/+server.ts
  10. 10
      src/routes/repos/+page.svelte
  11. 52
      src/routes/repos/[npub]/[repo]/+page.svelte
  12. 21
      src/routes/repos/[npub]/[repo]/components/dialogs/CloneUrlVerificationDialog.svelte
  13. 20
      src/routes/repos/[npub]/[repo]/components/dialogs/CommitDialog.svelte
  14. 20
      src/routes/repos/[npub]/[repo]/components/dialogs/CreateBranchDialog.svelte
  15. 20
      src/routes/repos/[npub]/[repo]/components/dialogs/CreateFileDialog.svelte
  16. 20
      src/routes/repos/[npub]/[repo]/components/dialogs/CreateIssueDialog.svelte
  17. 20
      src/routes/repos/[npub]/[repo]/components/dialogs/CreatePRDialog.svelte
  18. 20
      src/routes/repos/[npub]/[repo]/components/dialogs/CreatePatchDialog.svelte
  19. 20
      src/routes/repos/[npub]/[repo]/components/dialogs/CreateReleaseDialog.svelte
  20. 20
      src/routes/repos/[npub]/[repo]/components/dialogs/CreateTagDialog.svelte
  21. 20
      src/routes/repos/[npub]/[repo]/components/dialogs/CreateThreadDialog.svelte
  22. 20
      src/routes/repos/[npub]/[repo]/components/dialogs/PatchCommentDialog.svelte
  23. 20
      src/routes/repos/[npub]/[repo]/components/dialogs/PatchHighlightDialog.svelte
  24. 20
      src/routes/repos/[npub]/[repo]/components/dialogs/ReplyDialog.svelte
  25. 55
      src/routes/repos/[npub]/[repo]/components/dialogs/VerificationDialog.svelte
  26. 198
      src/routes/repos/[npub]/[repo]/services/repo-operations.ts

1
nostr/commit-signatures.jsonl

@ -107,3 +107,4 @@
{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772141183,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","bug-fixes"]],"content":"Signed commit: bug-fixes","id":"b92b203686c0629409fef055e7f3189cf9f26be5cca0253ab00cf7e8498e1115","sig":"06a13aac9d2f794e52b0416044db6ebf9dd248d254d2166d7e7f3fefd2b7d37d1a85072c3e92316898c31068e25cf37bc5afd2fcd8ae2050d0a30b1bc1973678"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772141183,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","bug-fixes"]],"content":"Signed commit: bug-fixes","id":"b92b203686c0629409fef055e7f3189cf9f26be5cca0253ab00cf7e8498e1115","sig":"06a13aac9d2f794e52b0416044db6ebf9dd248d254d2166d7e7f3fefd2b7d37d1a85072c3e92316898c31068e25cf37bc5afd2fcd8ae2050d0a30b1bc1973678"}
{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772142448,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","refactor 11"]],"content":"Signed commit: refactor 11","id":"bb9d5c56a291e48221df96868fb925e309cb560aa350c2cf5f9c4ddd5e5c4a6b","sig":"75662c916bf4d8bb3d70cdae4e4882382692c6f1ca67598a69abe3dc96069ef6f2bda5a1b8f91b724aa43b3cb3c6b8ad6cbce286b5d165377a34a881e7275d2a"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772142448,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","refactor 11"]],"content":"Signed commit: refactor 11","id":"bb9d5c56a291e48221df96868fb925e309cb560aa350c2cf5f9c4ddd5e5c4a6b","sig":"75662c916bf4d8bb3d70cdae4e4882382692c6f1ca67598a69abe3dc96069ef6f2bda5a1b8f91b724aa43b3cb3c6b8ad6cbce286b5d165377a34a881e7275d2a"}
{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772142558,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","remove redundancy"]],"content":"Signed commit: remove redundancy","id":"11ac91151bebd4dd49b91bcdef7b0b7157f0afd8ce710f7231be4860fb073d08","sig":"a7efcafa5ea83a0c37eae4562a84a7581c3d5c5dd1416f8f3e2bd2633d8523ae0eb7cc56dc4292c127ea16fb2dd5bc639483cb096263a850956b47312ed7ff6f"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772142558,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","remove redundancy"]],"content":"Signed commit: remove redundancy","id":"11ac91151bebd4dd49b91bcdef7b0b7157f0afd8ce710f7231be4860fb073d08","sig":"a7efcafa5ea83a0c37eae4562a84a7581c3d5c5dd1416f8f3e2bd2633d8523ae0eb7cc56dc4292c127ea16fb2dd5bc639483cb096263a850956b47312ed7ff6f"}
{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772182112,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","refactor 12"]],"content":"Signed commit: refactor 12","id":"73671ae6535309f9eae164f7a3ec403b1bc818ef811b9692fd0122d0b72c2774","sig":"0df56b009f5afb77de334225ab30cff55586ac0cf48f5ee435428201a1e72dc357a0fb5e80ef196f5bd76d6d448056d25f0feab0b1bcbe45f9af1a2a0d5453ca"}

22
src/app.html

@ -17,6 +17,28 @@
document.documentElement.setAttribute('data-theme', 'dark'); document.documentElement.setAttribute('data-theme', 'dark');
} }
})(); })();
// Handle unhandled promise rejections from relay errors
// This prevents console errors from relay payment/restriction messages
window.addEventListener('unhandledrejection', (event) => {
const reason = event.reason;
const errorMessage = reason instanceof Error ? reason.message : String(reason);
// Handle relay-specific errors gracefully (payment requirements, restrictions, etc.)
if (errorMessage.includes('restricted') ||
errorMessage.includes('Pay on') ||
errorMessage.includes('payment required') ||
errorMessage.includes('rate limit') ||
errorMessage.includes('bad req')) {
// These are expected relay errors - prevent them from showing as uncaught errors
event.preventDefault();
// Optionally log for debugging (but don't spam console)
if (typeof console !== 'undefined' && console.debug) {
console.debug('[Relay]', errorMessage);
}
}
// Other unhandled rejections will still be logged by the browser
});
</script> </script>
%sveltekit.head% %sveltekit.head%
</head> </head>

10
src/lib/components/RepoHeaderEnhanced.svelte

@ -42,6 +42,7 @@
needsClone?: boolean; needsClone?: boolean;
allMaintainers?: Array<{ pubkey: string; isOwner: boolean }>; allMaintainers?: Array<{ pubkey: string; isOwner: boolean }>;
onCopyEventId?: () => void; onCopyEventId?: () => void;
onRemoveFromServer?: () => void;
topics?: string[]; topics?: string[];
} }
@ -84,6 +85,7 @@
needsClone = false, needsClone = false,
allMaintainers = [], allMaintainers = [],
onCopyEventId, onCopyEventId,
onRemoveFromServer,
topics = [] topics = []
}: Props = $props(); }: Props = $props();
@ -268,6 +270,14 @@
{deletingAnnouncement ? 'Deleting...' : 'Delete Announcement'} {deletingAnnouncement ? 'Deleting...' : 'Delete Announcement'}
</button> </button>
{/if} {/if}
{#if onRemoveFromServer}
<button
class="menu-item menu-item-danger"
onclick={() => { onRemoveFromServer(); showMoreMenu = false; }}
>
Remove this repo from the server
</button>
{/if}
</div> </div>
{/if} {/if}
</div> </div>

128
src/lib/components/UserBadge.svelte

@ -5,6 +5,8 @@
import { KIND } from '../types/nostr.js'; import { KIND } from '../types/nostr.js';
import { eventCache } from '../services/nostr/event-cache.js'; import { eventCache } from '../services/nostr/event-cache.js';
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import { userStore } from '../stores/user-store.js';
import { hasUnlimitedAccess } from '../utils/user-access.js';
interface Props { interface Props {
pubkey: string; pubkey: string;
@ -14,6 +16,38 @@
let { pubkey, disableLink = false, inline = false }: Props = $props(); let { pubkey, disableLink = false, inline = false }: Props = $props();
// Check if this user has unlimited access (verified)
const isVerified = $derived.by(() => {
const currentUser = $userStore;
// Check if the pubkey matches the current user's pubkey
try {
// Convert pubkey to hex for comparison
let pubkeyHex: string;
if (/^[0-9a-f]{64}$/i.test(pubkey)) {
pubkeyHex = pubkey.toLowerCase();
} else {
try {
const decoded = nip19.decode(pubkey);
if (decoded.type === 'npub') {
pubkeyHex = decoded.data as string;
} else {
return false;
}
} catch {
return false;
}
}
// Compare with current user's pubkey
if (currentUser?.userPubkeyHex === pubkeyHex) {
return hasUnlimitedAccess(currentUser.userLevel);
}
} catch {
// If comparison fails, not verified
}
return false;
});
// Convert pubkey to npub for navigation (reactive) // Convert pubkey to npub for navigation (reactive)
const profileUrl = $derived.by(() => { const profileUrl = $derived.by(() => {
try { try {
@ -200,11 +234,16 @@
{/if} {/if}
{:else if disableLink} {:else if disableLink}
<div class="user-badge"> <div class="user-badge">
{#if userProfile?.picture} <div class="user-badge-avatar-wrapper" title={isVerified ? 'Verified.' : undefined}>
<img src={userProfile.picture} alt="Profile" class="user-badge-avatar" /> {#if userProfile?.picture}
{:else} <img src={userProfile.picture} alt="Profile" class="user-badge-avatar" />
<img src="/favicon.png" alt="Profile" class="user-badge-avatar user-badge-avatar-fallback" /> {:else}
{/if} <img src="/favicon.png" alt="Profile" class="user-badge-avatar user-badge-avatar-fallback" />
{/if}
{#if isVerified}
<div class="verification-wreath" aria-label="Verified"></div>
{/if}
</div>
<span class="user-badge-name">{truncateHandle(userProfile?.name)}</span> <span class="user-badge-name">{truncateHandle(userProfile?.name)}</span>
</div> </div>
{:else} {:else}
@ -215,11 +254,16 @@
e.stopPropagation(); e.stopPropagation();
}} }}
> >
{#if userProfile?.picture} <div class="user-badge-avatar-wrapper" title={isVerified ? 'Verified. This user has write-access to the server because it has write-access to at least one default Nostr relay.' : undefined}>
<img src={userProfile.picture} alt="Profile" class="user-badge-avatar" /> {#if userProfile?.picture}
{:else} <img src={userProfile.picture} alt="Profile" class="user-badge-avatar" />
<img src="/favicon.png" alt="Profile" class="user-badge-avatar user-badge-avatar-fallback" /> {:else}
{/if} <img src="/favicon.png" alt="Profile" class="user-badge-avatar user-badge-avatar-fallback" />
{/if}
{#if isVerified}
<div class="verification-wreath" aria-label="Verified"></div>
{/if}
</div>
<span class="user-badge-name">{truncateHandle(userProfile?.name)}</span> <span class="user-badge-name">{truncateHandle(userProfile?.name)}</span>
</a> </a>
{/if} {/if}
@ -244,12 +288,18 @@
background: var(--bg-secondary); background: var(--bg-secondary);
} }
.user-badge-avatar-wrapper {
position: relative;
display: inline-block;
flex-shrink: 0;
}
.user-badge-avatar { .user-badge-avatar {
width: 24px; width: 24px;
height: 24px; height: 24px;
border-radius: 50%; border-radius: 50%;
object-fit: cover; object-fit: cover;
flex-shrink: 0; display: block;
} }
.user-badge-avatar-fallback { .user-badge-avatar-fallback {
@ -257,6 +307,62 @@
opacity: 0.7; opacity: 0.7;
} }
/* Theme-aware laurel wreath verification indicator */
.verification-wreath {
position: absolute;
top: -3px;
left: -3px;
right: -3px;
bottom: -3px;
border-radius: 50%;
border: 2.5px solid var(--accent); /* Theme-aware accent color */
pointer-events: none;
/* Create laurel wreath effect with theme-aware glow */
box-shadow:
0 0 0 1px var(--accent),
inset 0 0 0 1px var(--accent),
/* Decorative shadows for laurel wreath effect */
2px -2px 0 -1px var(--accent),
-2px 2px 0 -1px var(--accent),
2px 2px 0 -1px var(--accent),
-2px -2px 0 -1px var(--accent),
/* Subtle outer glow */
0 0 4px var(--accent);
filter: opacity(0.85);
animation: verification-pulse 2s ease-in-out infinite;
}
.user-badge-avatar-wrapper {
cursor: help; /* Show help cursor to indicate tooltip */
}
.user-badge-avatar-wrapper:hover .verification-wreath {
border-color: var(--accent-hover); /* Theme-aware hover color */
filter: opacity(1);
box-shadow:
0 0 0 1px var(--accent-hover),
inset 0 0 0 1px var(--accent-hover),
/* Enhanced laurel wreath effect on hover */
2px -2px 0 -1px var(--accent-hover),
-2px 2px 0 -1px var(--accent-hover),
2px 2px 0 -1px var(--accent-hover),
-2px -2px 0 -1px var(--accent-hover),
/* Enhanced glow on hover */
0 0 8px var(--accent-hover),
0 0 12px var(--accent);
}
@keyframes verification-pulse {
0%, 100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.85;
transform: scale(1.02);
}
}
.user-badge-name { .user-badge-name {
font-size: 0.875rem; font-size: 0.875rem;
color: var(--text-primary); color: var(--text-primary);

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

@ -70,10 +70,28 @@ export class RepoManager {
* @param event - The repo announcement event * @param event - The repo announcement event
* @param selfTransferEvent - Optional self-transfer event to include in initial commit * @param selfTransferEvent - Optional self-transfer event to include in initial commit
* @param isExistingRepo - Whether this is an existing repo being added to the server * @param isExistingRepo - Whether this is an existing repo being added to the server
* @param allowMissingDomainUrl - In development, allow provisioning even if domain URL isn't in announcement
*/ */
async provisionRepo(event: NostrEvent, selfTransferEvent?: NostrEvent, isExistingRepo: boolean = false): Promise<void> { async provisionRepo(event: NostrEvent, selfTransferEvent?: NostrEvent, isExistingRepo: boolean = false, allowMissingDomainUrl: boolean = false): Promise<void> {
const cloneUrls = this.urlParser.extractCloneUrls(event); const cloneUrls = this.urlParser.extractCloneUrls(event);
const domainUrl = cloneUrls.find(url => url.includes(this.domain)); let domainUrl = cloneUrls.find(url => url.includes(this.domain));
// In development, if domain URL not found and allowed, construct it from the event
if (!domainUrl && allowMissingDomainUrl) {
const isLocalhost = this.domain.includes('localhost') || this.domain.includes('127.0.0.1');
if (isLocalhost) {
// Extract npub and repo name from event
const dTag = event.tags.find(t => t[0] === 'd')?.[1];
if (dTag) {
const protocol = this.domain.startsWith('localhost') || this.domain.startsWith('127.0.0.1') ? 'http' : 'https';
// Get npub from event pubkey
const { nip19 } = await import('nostr-tools');
const npub = nip19.npubEncode(event.pubkey);
domainUrl = `${protocol}://${this.domain}/${npub}/${dTag}.git`;
logger.info({ domain: this.domain, npub, repo: dTag, constructedUrl: domainUrl }, 'Constructed domain URL for development provisioning');
}
}
}
if (!domainUrl) { if (!domainUrl) {
throw new Error(`No ${this.domain} URL found in repo announcement`); throw new Error(`No ${this.domain} URL found in repo announcement`);
@ -125,16 +143,29 @@ export class RepoManager {
const git = simpleGit(); const git = simpleGit();
await git.init(['--bare', repoPath.fullPath]); await git.init(['--bare', repoPath.fullPath]);
// Ensure announcement event is saved to nostr/repo-events.jsonl in the repository
await this.announcementManager.ensureAnnouncementInRepo(repoPath.fullPath, event, selfTransferEvent);
// If there are other clone URLs, sync from them after creating the repo // If there are other clone URLs, sync from them after creating the repo
if (otherUrls.length > 0) { if (otherUrls.length > 0) {
const remoteUrls = this.urlParser.prepareRemoteUrls(otherUrls); const remoteUrls = this.urlParser.prepareRemoteUrls(otherUrls);
await this.remoteSync.syncFromRemotes(repoPath.fullPath, remoteUrls); await this.remoteSync.syncFromRemotes(repoPath.fullPath, remoteUrls);
} else { }
// No external URLs - this is a brand new repo, create initial branch and README
// Check if branches exist after sync (if any)
const repoGit = simpleGit(repoPath.fullPath);
let hasBranches = false;
try {
const branches = await repoGit.branch(['-a']);
hasBranches = branches.all.length > 0;
} catch {
hasBranches = false;
}
if (!hasBranches) {
// No branches exist - create initial branch and README (which includes announcement)
await this.createInitialBranchAndReadme(repoPath.fullPath, repoPath.npub, repoPath.repoName, event); await this.createInitialBranchAndReadme(repoPath.fullPath, repoPath.npub, repoPath.repoName, event);
} else {
// Branches exist (from sync) - ensure announcement is committed to the default branch
// This must happen after syncing so we can commit it to the existing default branch
await this.announcementManager.ensureAnnouncementInRepo(repoPath.fullPath, event, selfTransferEvent);
} }
} else { } else {
// For existing repos, check if announcement exists in repo // For existing repos, check if announcement exists in repo
@ -169,8 +200,48 @@ export class RepoManager {
announcementEvent: NostrEvent announcementEvent: NostrEvent
): Promise<void> { ): Promise<void> {
try { try {
// Get default branch from environment or use 'master' // Get default branch from git config, environment, or use 'master'
const defaultBranch = process.env.DEFAULT_BRANCH || 'master'; // Check git's init.defaultBranch config first (respects user's git settings)
let defaultBranch = process.env.DEFAULT_BRANCH || 'master';
try {
const git = simpleGit();
// Try to get git's default branch setting
const defaultBranchConfig = await git.raw(['config', '--get', 'init.defaultBranch']).catch(() => null);
if (defaultBranchConfig && defaultBranchConfig.trim()) {
defaultBranch = defaultBranchConfig.trim();
}
} catch {
// If git config fails, use environment or fallback to 'master'
}
// Check if any branches already exist (e.g., from a remote sync)
const repoGit = simpleGit(repoPath);
let existingBranches: string[] = [];
try {
const branches = await repoGit.branch(['-a']);
existingBranches = branches.all.map(b => b.replace(/^remotes\/origin\//, '').replace(/^remotes\//, '').replace(/^refs\/heads\//, ''));
// Remove duplicates
existingBranches = [...new Set(existingBranches)];
// If branches exist, check if one matches our default branch preference
if (existingBranches.length > 0) {
// Prefer existing branches that match common defaults
const preferredBranches = [defaultBranch, 'main', 'master', 'dev'];
for (const preferred of preferredBranches) {
if (existingBranches.includes(preferred)) {
defaultBranch = preferred;
break;
}
}
// If no match, use the first existing branch
if (!existingBranches.includes(defaultBranch)) {
defaultBranch = existingBranches[0];
}
}
} catch {
// No branches exist, use the determined default
}
// Get repo name from d-tag or use repoName from path // Get repo name from d-tag or use repoName from path
const dTag = announcementEvent.tags.find(t => t[0] === 'd')?.[1] || repoName; const dTag = announcementEvent.tags.find(t => t[0] === 'd')?.[1] || repoName;
@ -203,19 +274,9 @@ Your commits will all be signed by your Nostr keys and saved to the event files
const { FileManager } = await import('./file-manager.js'); const { FileManager } = await import('./file-manager.js');
const fileManager = new FileManager(this.repoRoot); const fileManager = new FileManager(this.repoRoot);
// For a new repo with no branches, we need to create an orphan branch first // If no branches exist, create an orphan branch
// Check if repo has any branches // We already checked for existing branches above, so if existingBranches is empty, create one
const git = simpleGit(repoPath); if (existingBranches.length === 0) {
let hasBranches = false;
try {
const branches = await git.branch(['-a']);
hasBranches = branches.all.length > 0;
} catch {
// No branches exist
hasBranches = false;
}
if (!hasBranches) {
// Create orphan branch first (pass undefined for fromBranch to create orphan) // Create orphan branch first (pass undefined for fromBranch to create orphan)
await fileManager.createBranch(npub, repoName, defaultBranch, undefined); await fileManager.createBranch(npub, repoName, defaultBranch, undefined);
} }
@ -359,17 +420,25 @@ Your commits will all be signed by your Nostr keys and saved to the event files
let remoteUrls: string[] = []; let remoteUrls: string[] = [];
try { try {
// Prepare remote URLs (filters out localhost/our domain, converts SSH to HTTPS) // Check if we're in development mode (localhost)
remoteUrls = this.urlParser.prepareRemoteUrls(cloneUrls); const isLocalhost = this.domain.includes('localhost') || this.domain.includes('127.0.0.1');
if (remoteUrls.length === 0) { // If in development, prefer localhost URLs from GIT_DOMAIN
logger.warn({ npub, repoName, cloneUrls, announcementEventId: announcementEvent.id }, 'No remote clone URLs found for on-demand fetch'); if (isLocalhost) {
return { success: false, needsAnnouncement: false }; // Construct localhost URL from GIT_DOMAIN
const protocol = this.domain.startsWith('localhost') || this.domain.startsWith('127.0.0.1') ? 'http' : 'https';
const localhostUrl = `${protocol}://${this.domain}/${npub}/${repoName}.git`;
// Always use localhost URL in development, even if it's not in the clone URLs
// This allows cloning from the local server during development
remoteUrls = [localhostUrl];
logger.info({ npub, repoName, url: localhostUrl, domain: this.domain }, 'Using localhost URL for development clone');
} else {
// Prepare remote URLs (filters out localhost/our domain, converts SSH to HTTPS)
remoteUrls = this.urlParser.prepareRemoteUrls(cloneUrls);
} }
logger.debug({ npub, repoName, cloneUrls, remoteUrls, isPublic }, 'On-demand fetch details'); // Check if repoRoot exists and is writable (needed for both provisioning and cloning)
// Check if repoRoot exists and is writable
if (!existsSync(this.repoRoot)) { if (!existsSync(this.repoRoot)) {
try { try {
mkdirSync(this.repoRoot, { recursive: true }); mkdirSync(this.repoRoot, { recursive: true });
@ -417,6 +486,42 @@ Your commits will all be signed by your Nostr keys and saved to the event files
} }
} }
if (remoteUrls.length === 0) {
// No remote URLs - this is an empty repo, provision it instead
logger.info({ npub, repoName, cloneUrls, announcementEventId: announcementEvent.id }, 'No remote clone URLs found - provisioning empty repository');
try {
await this.provisionRepo(announcementEvent, undefined, false);
logger.info({ npub, repoName }, 'Empty repository provisioned successfully');
return { success: true, cloneUrls, remoteUrls: [] };
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
logger.error({ npub, repoName, error: error.message }, 'Failed to provision empty repository');
return { success: false, error: error.message, cloneUrls, remoteUrls: [] };
}
}
logger.debug({ npub, repoName, cloneUrls, remoteUrls, isPublic }, 'On-demand fetch details');
// In development mode, if using localhost URL, check if repo exists locally first
// If it doesn't exist, provision it instead of trying to clone from non-existent URL
if (isLocalhost && remoteUrls[0].includes(this.domain)) {
const localRepoPath = join(this.repoRoot, npub, `${repoName}.git`);
if (!existsSync(localRepoPath)) {
// Repo doesn't exist on localhost - provision it instead
logger.info({ npub, repoName, url: remoteUrls[0] }, 'Localhost URL specified but repo does not exist locally - provisioning instead');
try {
// In development, allow provisioning even if domain URL isn't in announcement
await this.provisionRepo(announcementEvent, undefined, false, true);
logger.info({ npub, repoName }, 'Repository provisioned successfully on localhost');
return { success: true, cloneUrls, remoteUrls: [] };
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
logger.error({ npub, repoName, error: error.message }, 'Failed to provision repository on localhost');
return { success: false, error: error.message, cloneUrls, remoteUrls: [] };
}
}
}
// Get git environment for URL (handles Tor proxy, etc.) // Get git environment for URL (handles Tor proxy, etc.)
const gitEnv = this.remoteSync.getGitEnvForUrl(remoteUrls[0]); const gitEnv = this.remoteSync.getGitEnvForUrl(remoteUrls[0]);
@ -430,7 +535,8 @@ Your commits will all be signed by your Nostr keys and saved to the event files
repoName, repoName,
sourceUrl: remoteUrls[0], sourceUrl: remoteUrls[0],
cloneUrls, cloneUrls,
authenticated: isAuthenticated authenticated: isAuthenticated,
isLocalhost
}, 'Fetching repository on-demand from remote'); }, 'Fetching repository on-demand from remote');
// Clone as bare repository with timeout // Clone as bare repository with timeout

48
src/lib/services/git/repo-url-parser.ts

@ -90,8 +90,50 @@ export class RepoUrlParser {
/** /**
* Filter and prepare remote URLs from clone URLs * Filter and prepare remote URLs from clone URLs
* Respects the repo owner's order in the clone list * Respects the repo owner's order in the clone list
* In development (localhost), prefers localhost URLs over remote URLs
*/ */
prepareRemoteUrls(cloneUrls: string[]): string[] { prepareRemoteUrls(cloneUrls: string[]): string[] {
// Check if we're in development mode (localhost)
const isLocalhost = this.domain.includes('localhost') || this.domain.includes('127.0.0.1');
// In development, prefer localhost URLs
if (isLocalhost) {
const localhostUrls: string[] = [];
const otherUrls: string[] = [];
for (const url of cloneUrls) {
const lowerUrl = url.toLowerCase();
if (lowerUrl.includes('localhost') ||
lowerUrl.includes('127.0.0.1') ||
url.includes(this.domain)) {
localhostUrls.push(url);
} else {
otherUrls.push(url);
}
}
// Prefer localhost URLs in development
if (localhostUrls.length > 0) {
return localhostUrls;
}
// Fall back to other URLs if no localhost URLs found
if (otherUrls.length > 0) {
return this.prepareRemoteUrlsFromList(otherUrls);
}
// If no URLs at all, return empty
return [];
}
// Production mode: filter out localhost/our domain
return this.prepareRemoteUrlsFromList(cloneUrls);
}
/**
* Helper method to prepare remote URLs from a list (filters out localhost/our domain)
*/
private prepareRemoteUrlsFromList(cloneUrls: string[]): string[] {
const httpsUrls: string[] = []; const httpsUrls: string[] = [];
const sshUrls: string[] = []; const sshUrls: string[] = [];
@ -132,12 +174,6 @@ export class RepoUrlParser {
remoteUrls = cloneUrls.filter(url => !url.includes(this.domain)); remoteUrls = cloneUrls.filter(url => !url.includes(this.domain));
} }
// If still no remote URLs, but there are *any* clone URLs, try the first one
// This handles cases where the only clone URL is our own domain, but the repo doesn't exist locally yet
if (remoteUrls.length === 0 && cloneUrls.length > 0) {
remoteUrls.push(cloneUrls[0]);
}
return remoteUrls; return remoteUrls;
} }

77
src/lib/services/nostr/nostr-client.ts

@ -436,19 +436,54 @@ export class NostrClient {
const failed: Array<{ relay: string; error: string }> = []; const failed: Array<{ relay: string; error: string }> = [];
// Use nostr-tools SimplePool to publish to all relays // Use nostr-tools SimplePool to publish to all relays
// Wrap in Promise.resolve().then() to catch any synchronous errors and ensure all async errors are caught // SimplePool.publish can throw errors from WebSocket handlers that aren't caught by normal try-catch
// We need to wrap it carefully to catch all errors
try { try {
// Create a promise that will catch all errors, including those from WebSocket event handlers // Wrap publish in a promise that catches all errors, including unhandled promise rejections
const publishPromise = Promise.resolve().then(async () => { const publishPromise = new Promise<string[]>((resolve, reject) => {
// Set up a timeout to prevent hanging
const timeout = setTimeout(() => {
reject(new Error('Publish timeout after 30 seconds'));
}, 30000);
// Publish to relays - wrap in try-catch to catch synchronous errors
try { try {
await this.pool.publish(targetRelays, event); // SimplePool.publish returns a promise, but errors from individual relays
// If publish succeeded, all relays succeeded // may not be properly caught. We'll handle them at multiple levels.
// Note: SimplePool.publish doesn't return per-relay results, so we assume all succeeded const poolPublishPromise = this.pool.publish(targetRelays, event);
return targetRelays;
} catch (error) { // Handle the promise result
// If publish failed, mark all as failed poolPublishPromise
// In a more sophisticated implementation, we could check individual relays .then(() => {
throw error; clearTimeout(timeout);
// If publish succeeded, all relays succeeded
// Note: SimplePool.publish doesn't return per-relay results, so we assume all succeeded
resolve(targetRelays);
})
.catch((error: unknown) => {
clearTimeout(timeout);
// Handle specific relay errors gracefully
const errorMessage = error instanceof Error ? error.message : String(error);
// Check for common relay error messages that shouldn't be fatal
if (errorMessage.includes('restricted') ||
errorMessage.includes('Pay on') ||
errorMessage.includes('payment required') ||
errorMessage.includes('rate limit')) {
// These are relay-specific restrictions, not fatal errors
// Log but don't fail - we'll mark relays as failed below
logger.debug({ error: errorMessage, eventId: event.id }, 'Relay restriction encountered (payment/rate limit)');
// Resolve with empty success - we'll mark all as failed below
resolve([]);
} else {
// Other errors should be rejected
reject(error);
}
});
} catch (syncError) {
// Catch any synchronous errors
clearTimeout(timeout);
reject(syncError);
} }
}); });
@ -458,14 +493,21 @@ export class NostrClient {
new Promise<string[]>((_, reject) => new Promise<string[]>((_, reject) =>
setTimeout(() => reject(new Error('Publish timeout')), 30000) setTimeout(() => reject(new Error('Publish timeout')), 30000)
) )
]).catch(error => { ]).catch((error: unknown) => {
// Log error but don't throw - we'll mark relays as failed below // Log error but don't throw - we'll mark relays as failed below
logger.debug({ error, eventId: event.id }, 'Error publishing event to relays'); const errorMessage = error instanceof Error ? error.message : String(error);
return null; logger.debug({ error: errorMessage, eventId: event.id }, 'Error publishing event to relays');
return [];
}); });
if (publishedRelays) { if (publishedRelays && publishedRelays.length > 0) {
success.push(...publishedRelays); success.push(...publishedRelays);
// Mark any relays not in success as failed
targetRelays.forEach(relay => {
if (!publishedRelays.includes(relay)) {
failed.push({ relay, error: 'Relay did not accept event' });
}
});
} else { } else {
// If publish failed or timed out, mark all as failed // If publish failed or timed out, mark all as failed
targetRelays.forEach(relay => { targetRelays.forEach(relay => {
@ -474,9 +516,10 @@ export class NostrClient {
} }
} catch (error) { } catch (error) {
// Catch any synchronous errors // Catch any synchronous errors
logger.debug({ error, eventId: event.id }, 'Synchronous error in publishEvent'); const errorMessage = error instanceof Error ? error.message : String(error);
logger.debug({ error: errorMessage, eventId: event.id }, 'Synchronous error in publishEvent');
targetRelays.forEach(relay => { targetRelays.forEach(relay => {
failed.push({ relay, error: String(error) }); failed.push({ relay, error: errorMessage });
}); });
} }

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

@ -105,18 +105,28 @@ export const POST: RequestHandler = async (event) => {
logger.info({ userPubkeyHex: userPubkeyHex.slice(0, 16) + '...' }, 'Verified unlimited access from proof event'); logger.info({ userPubkeyHex: userPubkeyHex.slice(0, 16) + '...' }, 'Verified unlimited access from proof event');
userLevel = getCachedUserLevel(userPubkeyHex); // Get the cached value userLevel = getCachedUserLevel(userPubkeyHex); // Get the cached value
} else { } else {
// Check if relays are down // Verification failed - check cache before denying access
if (verification.relayDown) { // Cache exists for exactly this reason: to allow access when verification temporarily fails
// Relays are down - check cache again (might have been cached from previous request) userLevel = getCachedUserLevel(userPubkeyHex);
userLevel = getCachedUserLevel(userPubkeyHex);
if (!userLevel || !hasUnlimitedAccess(userLevel.level)) { if (userLevel && hasUnlimitedAccess(userLevel.level)) {
logger.warn({ userPubkeyHex: userPubkeyHex.slice(0, 16) + '...', error: verification.error }, 'Relays down and no cached unlimited access'); // User has cached unlimited access - use it even though verification failed
throw error(503, 'Relays are temporarily unavailable and no cached access level found. Please verify your access level first by visiting your profile page.'); // This handles cases where relays are down or proof event hasn't propagated yet
} logger.info({
userPubkeyHex: userPubkeyHex.slice(0, 16) + '...',
error: verification.error,
cachedLevel: userLevel.level,
cachedAt: new Date(userLevel.cachedAt).toISOString()
}, 'Verification failed but using cached unlimited access');
} else if (verification.relayDown) {
// Relays are down and no cache - temporary issue
logger.warn({ userPubkeyHex: userPubkeyHex.slice(0, 16) + '...', error: verification.error }, 'Relays down and no cached unlimited access');
throw error(503, 'Relays are temporarily unavailable and no cached access level found. Please verify your access level first by visiting your profile page.');
} else { } else {
// Verification failed - user doesn't have write access // Verification failed and no cache - user doesn't have write access
logger.warn({ userPubkeyHex: userPubkeyHex.slice(0, 16) + '...', error: verification.error }, 'User does not have unlimited access'); logger.warn({ userPubkeyHex: userPubkeyHex.slice(0, 16) + '...', error: verification.error }, 'User does not have unlimited access');
throw error(403, `Only users with unlimited access can clone repositories to the server. ${verification.error || 'Please verify you can write to at least one default Nostr relay.'}`); const errorMsg = verification.error || 'Please verify you can write to at least one default Nostr relay.';
throw error(403, `Only users with unlimited access can clone repositories to the server. ${errorMsg} Note: You only need write access to ONE default relay, not all of them.`);
} }
} }
} catch (err) { } catch (err) {
@ -132,7 +142,7 @@ export const POST: RequestHandler = async (event) => {
// No proof event or auth header - check if we have any cached level // No proof event or auth header - check if we have any cached level
if (!userLevel) { if (!userLevel) {
logger.warn({ userPubkeyHex: userPubkeyHex.slice(0, 16) + '...' }, 'No cached user level and no proof event or NIP-98 auth header'); logger.warn({ userPubkeyHex: userPubkeyHex.slice(0, 16) + '...' }, 'No cached user level and no proof event or NIP-98 auth header');
throw error(403, 'Only users with unlimited access can clone repositories to the server. Please verify your access level first by visiting your profile page or ensuring you can write to at least one default Nostr relay.'); throw error(403, 'Only users with unlimited access can clone repositories to the server. Please verify your access level first by visiting your profile page or ensuring you can write to at least one default Nostr relay. Note: You only need write access to ONE default relay, not all of them.');
} }
} }
} }
@ -143,7 +153,7 @@ export const POST: RequestHandler = async (event) => {
userPubkeyHex: userPubkeyHex.slice(0, 16) + '...', userPubkeyHex: userPubkeyHex.slice(0, 16) + '...',
cachedLevel: userLevel?.level || 'none' cachedLevel: userLevel?.level || 'none'
}, 'User does not have unlimited access'); }, 'User does not have unlimited access');
throw error(403, 'Only users with unlimited access can clone repositories to the server. Please verify you can write to at least one default Nostr relay.'); throw error(403, 'Only users with unlimited access can clone repositories to the server. Please verify you can write to at least one default Nostr relay. Note: You only need write access to ONE default relay, not all of them.');
} }
try { try {

7
src/routes/api/repos/[npub]/[repo]/verify/+server.ts

@ -183,10 +183,11 @@ export const POST: RequestHandler = createRepoPostHandler(
return error(401, 'Authentication required. Please provide userPubkey.'); return error(401, 'Authentication required. Please provide userPubkey.');
} }
// Check if user is a maintainer // Check if user is a maintainer or the repository owner
const isMaintainer = await maintainerService.isMaintainer(userPubkeyHex, context.repoOwnerPubkey, context.repo); const isMaintainer = await maintainerService.isMaintainer(userPubkeyHex, context.repoOwnerPubkey, context.repo);
if (!isMaintainer) { const isOwner = userPubkeyHex === context.repoOwnerPubkey;
return error(403, 'Only repository maintainers can verify clone URLs.'); if (!isMaintainer && !isOwner) {
return error(403, 'Only repository owners and maintainers can save announcements.');
} }
// Check if repository is cloned // Check if repository is cloned

10
src/routes/repos/+page.svelte

@ -847,16 +847,6 @@
<a href="/repos/{item.npub}/{item.repoName}" class="view-button" title="View repository"> <a href="/repos/{item.npub}/{item.repoName}" class="view-button" title="View repository">
<img src="/icons/arrow-right.svg" alt="View" /> <img src="/icons/arrow-right.svg" alt="View" />
</a> </a>
{#if userPubkey && canDelete}
<button
class="delete-button"
onclick={() => deleteLocalRepo(item.npub, item.repoName)}
disabled={deletingRepo?.npub === item.npub && deletingRepo?.repo === item.repoName}
title="Delete repository"
>
{deletingRepo?.npub === item.npub && deletingRepo?.repo === item.repoName ? 'Deleting...' : 'Delete'}
</button>
{/if}
</div> </div>
</div> </div>
<div class="repo-meta"> <div class="repo-meta">

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

@ -192,6 +192,7 @@
generateAnnouncementFileForRepo as generateAnnouncementFileForRepoService, generateAnnouncementFileForRepo as generateAnnouncementFileForRepoService,
copyVerificationToClipboard as copyVerificationToClipboardService, copyVerificationToClipboard as copyVerificationToClipboardService,
downloadVerificationFile as downloadVerificationFileService, downloadVerificationFile as downloadVerificationFileService,
saveAnnouncementToRepo as saveAnnouncementToRepoService,
verifyCloneUrl as verifyCloneUrlService, verifyCloneUrl as verifyCloneUrlService,
deleteAnnouncement as deleteAnnouncementService, deleteAnnouncement as deleteAnnouncementService,
copyEventId as copyEventIdService copyEventId as copyEventIdService
@ -598,13 +599,47 @@
await generateAnnouncementFileForRepoService(state, repoOwnerPubkeyDerived); await generateAnnouncementFileForRepoService(state, repoOwnerPubkeyDerived);
} }
const copyVerificationToClipboard = () => copyVerificationToClipboardService(state); const copyVerificationToClipboard = () => copyVerificationToClipboardService(state);
const downloadVerificationFile = () => downloadVerificationFileService(state);
async function saveAnnouncementToRepo() {
await saveAnnouncementToRepoService(state, repoOwnerPubkeyDerived);
// Reload branches and files to show the new commit
if (state.clone.isCloned) {
await loadBranches();
await loadFiles();
}
}
async function verifyCloneUrl() { async function verifyCloneUrl() {
await verifyCloneUrlService(state, repoOwnerPubkeyDerived, { checkVerification }); await verifyCloneUrlService(state, repoOwnerPubkeyDerived, { checkVerification });
} }
async function deleteAnnouncement() { async function deleteAnnouncement() {
await deleteAnnouncementService(state, repoOwnerPubkeyDerived, announcementEventId); await deleteAnnouncementService(state, repoOwnerPubkeyDerived, announcementEventId);
} }
const downloadVerificationFile = () => downloadVerificationFileService(state);
async function removeRepoFromServer() {
if (!confirm(`Are you sure you want to remove "${state.repo}" from this server?\n\nThis will permanently delete the local clone of the repository. The announcement on Nostr will NOT be deleted.\n\nThis action cannot be undone.\n\nClick OK to delete, or Cancel to abort.`)) {
return;
}
try {
const headers = buildApiHeaders();
const response = await fetch(`/api/repos/${state.npub}/${state.repo}/delete`, {
method: 'DELETE',
headers
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'Failed to delete repository');
}
// Redirect to repos list after successful deletion
alert('Repository removed from server successfully');
goto('/repos');
} catch (err) {
alert(`Failed to remove repository: ${err instanceof Error ? err.message : String(err)}`);
}
}
const downloadRepository = (ref?: string, filename?: string) => downloadRepoUtil({ npub: state.npub, repo: state.repo, ref, filename }); const downloadRepository = (ref?: string, filename?: string) => downloadRepoUtil({ npub: state.npub, repo: state.repo, ref, filename });
// Safe wrapper functions for SSR // Safe wrapper functions for SSR
@ -613,6 +648,7 @@
const safeToggleBookmark = () => safeAsync(() => toggleBookmark()); const safeToggleBookmark = () => safeAsync(() => toggleBookmark());
const safeForkRepository = () => safeAsync(() => forkRepository()); const safeForkRepository = () => safeAsync(() => forkRepository());
const safeCloneRepository = () => safeAsync(() => cloneRepository()); const safeCloneRepository = () => safeAsync(() => cloneRepository());
const safeRemoveRepoFromServer = () => safeAsync(removeRepoFromServer);
const safeHandleBranchChange = (branch: string) => safeSync(() => handleBranchChangeDirect(branch)); const safeHandleBranchChange = (branch: string) => safeSync(() => handleBranchChangeDirect(branch));
// Initialize activeTab from URL query parameter // Initialize activeTab from URL query parameter
@ -855,6 +891,18 @@
await checkVerification(); await checkVerification();
if (!state.isMounted) return; if (!state.isMounted) return;
// Log verification status for maintenance (after check completes)
if (state.verification.status) {
const status = state.verification.status;
console.log('[Page Load] Verification Status:', {
verified: status.verified,
error: status.error || null,
message: status.message || null,
cloneCount: status.cloneVerifications?.length || 0,
verifiedClones: status.cloneVerifications?.filter(cv => cv.verified).length || 0
});
}
await loadReadme(); await loadReadme();
if (!state.isMounted) return; if (!state.isMounted) return;
@ -1055,6 +1103,7 @@
needsClone={needsClone} needsClone={needsClone}
allMaintainers={state.maintainers.all} allMaintainers={state.maintainers.all}
onCopyEventId={copyEventId} onCopyEventId={copyEventId}
onRemoveFromServer={repoOwnerPubkeyDerived && state.user.pubkeyHex === repoOwnerPubkeyDerived && state.clone.isCloned ? safeRemoveRepoFromServer : undefined}
/> />
{/if} {/if}
@ -1905,6 +1954,7 @@
{state} {state}
onCopy={copyVerificationToClipboard} onCopy={copyVerificationToClipboard}
onDownload={downloadVerificationFile} onDownload={downloadVerificationFile}
onSave={state.clone.isCloned && (state.maintainers.isMaintainer || state.user.pubkeyHex === repoOwnerPubkeyDerived) ? saveAnnouncementToRepo : undefined}
onClose={() => state.openDialog = null} onClose={() => state.openDialog = null}
/> />

21
src/routes/repos/[npub]/[repo]/components/dialogs/CloneUrlVerificationDialog.svelte

@ -73,6 +73,7 @@
.verification-code { .verification-code {
background: var(--bg-secondary, #f5f5f5); background: var(--bg-secondary, #f5f5f5);
color: var(--text-primary);
padding: 0.2rem 0.4rem; padding: 0.2rem 0.4rem;
border-radius: 3px; border-radius: 3px;
font-family: monospace; font-family: monospace;
@ -112,8 +113,14 @@
} }
.primary-button { .primary-button {
background: var(--primary-color, #2196f3); background: var(--button-primary);
color: white; color: var(--accent-text, #ffffff);
font-family: 'IBM Plex Serif', serif;
transition: background 0.2s ease;
}
.primary-button:hover:not(:disabled) {
background: var(--button-primary-hover);
} }
.primary-button:disabled { .primary-button:disabled {
@ -122,7 +129,15 @@
} }
.cancel-button { .cancel-button {
background: var(--cancel-bg, #e0e0e0); background: var(--bg-tertiary);
border: 1px solid var(--border-color);
color: var(--text-primary);
font-family: 'IBM Plex Serif', serif;
transition: background 0.2s ease, color 0.2s ease;
}
.cancel-button:hover:not(:disabled) {
background: var(--bg-secondary);
} }
.cancel-button:disabled { .cancel-button:disabled {

20
src/routes/repos/[npub]/[repo]/components/dialogs/CommitDialog.svelte

@ -84,12 +84,26 @@
} }
.cancel-button { .cancel-button {
background: var(--cancel-bg, #e0e0e0); background: var(--bg-tertiary);
border: 1px solid var(--border-color);
color: var(--text-primary);
font-family: 'IBM Plex Serif', serif;
transition: background 0.2s ease, color 0.2s ease;
}
.cancel-button:hover {
background: var(--bg-secondary);
} }
.save-button { .save-button {
background: var(--primary-color, #2196f3); background: var(--button-primary);
color: white; color: var(--accent-text, #ffffff);
font-family: 'IBM Plex Serif', serif;
transition: background 0.2s ease;
}
.save-button:hover:not(:disabled) {
background: var(--button-primary-hover);
} }
.save-button:disabled { .save-button:disabled {

20
src/routes/repos/[npub]/[repo]/components/dialogs/CreateBranchDialog.svelte

@ -81,12 +81,26 @@
} }
.cancel-button { .cancel-button {
background: var(--cancel-bg, #e0e0e0); background: var(--bg-tertiary);
border: 1px solid var(--border-color);
color: var(--text-primary);
font-family: 'IBM Plex Serif', serif;
transition: background 0.2s ease, color 0.2s ease;
}
.cancel-button:hover {
background: var(--bg-secondary);
} }
.save-button { .save-button {
background: var(--primary-color, #2196f3); background: var(--button-primary);
color: white; color: var(--accent-text, #ffffff);
font-family: 'IBM Plex Serif', serif;
transition: background 0.2s ease;
}
.save-button:hover:not(:disabled) {
background: var(--button-primary-hover);
} }
.save-button:disabled { .save-button:disabled {

20
src/routes/repos/[npub]/[repo]/components/dialogs/CreateFileDialog.svelte

@ -86,12 +86,26 @@
} }
.cancel-button { .cancel-button {
background: var(--cancel-bg, #e0e0e0); background: var(--bg-tertiary);
border: 1px solid var(--border-color);
color: var(--text-primary);
font-family: 'IBM Plex Serif', serif;
transition: background 0.2s ease, color 0.2s ease;
}
.cancel-button:hover {
background: var(--bg-secondary);
} }
.save-button { .save-button {
background: var(--primary-color, #2196f3); background: var(--button-primary);
color: white; color: var(--accent-text, #ffffff);
font-family: 'IBM Plex Serif', serif;
transition: background 0.2s ease;
}
.save-button:hover:not(:disabled) {
background: var(--button-primary-hover);
} }
.save-button:disabled { .save-button:disabled {

20
src/routes/repos/[npub]/[repo]/components/dialogs/CreateIssueDialog.svelte

@ -75,18 +75,26 @@
} }
.cancel-button { .cancel-button {
background: var(--cancel-bg, var(--bg-secondary, #2a2a2a)); background: var(--bg-tertiary);
color: var(--text-primary, #e0e0e0); border: 1px solid var(--border-color);
border: 1px solid var(--border-color, #333); color: var(--text-primary);
font-family: 'IBM Plex Serif', serif;
transition: background 0.2s ease, color 0.2s ease;
} }
.cancel-button:hover { .cancel-button:hover {
background: var(--bg-hover, #3a3a3a); background: var(--bg-secondary);
} }
.save-button { .save-button {
background: var(--primary-color, var(--accent-color, #2196f3)); background: var(--button-primary);
color: white; color: var(--accent-text, #ffffff);
font-family: 'IBM Plex Serif', serif;
transition: background 0.2s ease;
}
.save-button:hover:not(:disabled) {
background: var(--button-primary-hover);
} }
.save-button:hover { .save-button:hover {

20
src/routes/repos/[npub]/[repo]/components/dialogs/CreatePRDialog.svelte

@ -83,18 +83,26 @@
} }
.cancel-button { .cancel-button {
background: var(--cancel-bg, var(--bg-secondary, #2a2a2a)); background: var(--bg-tertiary);
color: var(--text-primary, #e0e0e0); border: 1px solid var(--border-color);
border: 1px solid var(--border-color, #333); color: var(--text-primary);
font-family: 'IBM Plex Serif', serif;
transition: background 0.2s ease, color 0.2s ease;
} }
.cancel-button:hover { .cancel-button:hover {
background: var(--bg-hover, #3a3a3a); background: var(--bg-secondary);
} }
.save-button { .save-button {
background: var(--primary-color, var(--accent-color, #2196f3)); background: var(--button-primary);
color: white; color: var(--accent-text, #ffffff);
font-family: 'IBM Plex Serif', serif;
transition: background 0.2s ease;
}
.save-button:hover:not(:disabled) {
background: var(--button-primary-hover);
} }
.save-button:hover { .save-button:hover {

20
src/routes/repos/[npub]/[repo]/components/dialogs/CreatePatchDialog.svelte

@ -79,18 +79,26 @@
} }
.cancel-button { .cancel-button {
background: var(--cancel-bg, var(--bg-secondary, #2a2a2a)); background: var(--bg-tertiary);
color: var(--text-primary, #e0e0e0); border: 1px solid var(--border-color);
border: 1px solid var(--border-color, #333); color: var(--text-primary);
font-family: 'IBM Plex Serif', serif;
transition: background 0.2s ease, color 0.2s ease;
} }
.cancel-button:hover { .cancel-button:hover {
background: var(--bg-hover, #3a3a3a); background: var(--bg-secondary);
} }
.save-button { .save-button {
background: var(--primary-color, var(--accent-color, #2196f3)); background: var(--button-primary);
color: white; color: var(--accent-text, #ffffff);
font-family: 'IBM Plex Serif', serif;
transition: background 0.2s ease;
}
.save-button:hover:not(:disabled) {
background: var(--button-primary-hover);
} }
.save-button:hover { .save-button:hover {

20
src/routes/repos/[npub]/[repo]/components/dialogs/CreateReleaseDialog.svelte

@ -81,12 +81,26 @@
} }
.cancel-button { .cancel-button {
background: var(--cancel-bg, #e0e0e0); background: var(--bg-tertiary);
border: 1px solid var(--border-color);
color: var(--text-primary);
font-family: 'IBM Plex Serif', serif;
transition: background 0.2s ease, color 0.2s ease;
}
.cancel-button:hover {
background: var(--bg-secondary);
} }
.save-button { .save-button {
background: var(--primary-color, #2196f3); background: var(--button-primary);
color: white; color: var(--accent-text, #ffffff);
font-family: 'IBM Plex Serif', serif;
transition: background 0.2s ease;
}
.save-button:hover:not(:disabled) {
background: var(--button-primary-hover);
} }
.save-button:disabled { .save-button:disabled {

20
src/routes/repos/[npub]/[repo]/components/dialogs/CreateTagDialog.svelte

@ -71,12 +71,26 @@
} }
.cancel-button { .cancel-button {
background: var(--cancel-bg, #e0e0e0); background: var(--bg-tertiary);
border: 1px solid var(--border-color);
color: var(--text-primary);
font-family: 'IBM Plex Serif', serif;
transition: background 0.2s ease, color 0.2s ease;
}
.cancel-button:hover {
background: var(--bg-secondary);
} }
.save-button { .save-button {
background: var(--primary-color, #2196f3); background: var(--button-primary);
color: white; color: var(--accent-text, #ffffff);
font-family: 'IBM Plex Serif', serif;
transition: background 0.2s ease;
}
.save-button:hover:not(:disabled) {
background: var(--button-primary-hover);
} }
.save-button:disabled { .save-button:disabled {

20
src/routes/repos/[npub]/[repo]/components/dialogs/CreateThreadDialog.svelte

@ -64,12 +64,26 @@
} }
.cancel-button { .cancel-button {
background: var(--cancel-bg, #e0e0e0); background: var(--bg-tertiary);
border: 1px solid var(--border-color);
color: var(--text-primary);
font-family: 'IBM Plex Serif', serif;
transition: background 0.2s ease, color 0.2s ease;
}
.cancel-button:hover {
background: var(--bg-secondary);
} }
.save-button { .save-button {
background: var(--primary-color, #2196f3); background: var(--button-primary);
color: white; color: var(--accent-text, #ffffff);
font-family: 'IBM Plex Serif', serif;
transition: background 0.2s ease;
}
.save-button:hover:not(:disabled) {
background: var(--button-primary-hover);
} }
.save-button:disabled { .save-button:disabled {

20
src/routes/repos/[npub]/[repo]/components/dialogs/PatchCommentDialog.svelte

@ -69,12 +69,26 @@
} }
.cancel-button { .cancel-button {
background: var(--cancel-bg, #e0e0e0); background: var(--bg-tertiary);
border: 1px solid var(--border-color);
color: var(--text-primary);
font-family: 'IBM Plex Serif', serif;
transition: background 0.2s ease, color 0.2s ease;
}
.cancel-button:hover {
background: var(--bg-secondary);
} }
.save-button { .save-button {
background: var(--primary-color, #2196f3); background: var(--button-primary);
color: white; color: var(--accent-text, #ffffff);
font-family: 'IBM Plex Serif', serif;
transition: background 0.2s ease;
}
.save-button:hover:not(:disabled) {
background: var(--button-primary-hover);
} }
.save-button:disabled { .save-button:disabled {

20
src/routes/repos/[npub]/[repo]/components/dialogs/PatchHighlightDialog.svelte

@ -79,12 +79,26 @@
} }
.cancel-button { .cancel-button {
background: var(--cancel-bg, #e0e0e0); background: var(--bg-tertiary);
border: 1px solid var(--border-color);
color: var(--text-primary);
font-family: 'IBM Plex Serif', serif;
transition: background 0.2s ease, color 0.2s ease;
}
.cancel-button:hover {
background: var(--bg-secondary);
} }
.save-button { .save-button {
background: var(--primary-color, #2196f3); background: var(--button-primary);
color: white; color: var(--accent-text, #ffffff);
font-family: 'IBM Plex Serif', serif;
transition: background 0.2s ease;
}
.save-button:hover:not(:disabled) {
background: var(--button-primary-hover);
} }
.save-button:disabled { .save-button:disabled {

20
src/routes/repos/[npub]/[repo]/components/dialogs/ReplyDialog.svelte

@ -71,12 +71,26 @@
} }
.cancel-button { .cancel-button {
background: var(--cancel-bg, #e0e0e0); background: var(--bg-tertiary);
border: 1px solid var(--border-color);
color: var(--text-primary);
font-family: 'IBM Plex Serif', serif;
transition: background 0.2s ease, color 0.2s ease;
}
.cancel-button:hover {
background: var(--bg-secondary);
} }
.save-button { .save-button {
background: var(--primary-color, #2196f3); background: var(--button-primary);
color: white; color: var(--accent-text, #ffffff);
font-family: 'IBM Plex Serif', serif;
transition: background 0.2s ease;
}
.save-button:hover:not(:disabled) {
background: var(--button-primary-hover);
} }
.save-button:disabled { .save-button:disabled {

55
src/routes/repos/[npub]/[repo]/components/dialogs/VerificationDialog.svelte

@ -7,10 +7,11 @@
state: RepoState; state: RepoState;
onCopy: () => void; onCopy: () => void;
onDownload: () => void; onDownload: () => void;
onSave?: () => void;
onClose: () => void; onClose: () => void;
} }
let { open, state, onCopy, onDownload, onClose }: Props = $props(); let { open, state, onCopy, onDownload, onSave, onClose }: Props = $props();
</script> </script>
<Modal {open} title="Repository Verification File" ariaLabel="Repository verification file" {onClose}> <Modal {open} title="Repository Verification File" ariaLabel="Repository verification file" {onClose}>
@ -31,7 +32,12 @@
</div> </div>
</div> </div>
<div class="modal-actions"> <div class="modal-actions">
<button onclick={onClose} class="cancel-button">Close</button> {#if onSave}
<button onclick={onSave} class="save-button" disabled={state.creating.announcement || !state.clone.isCloned}>
{state.creating.announcement ? 'Saving...' : 'Save to Repo'}
</button>
{/if}
<button onclick={onClose} class="cancel-button">Cancel</button>
</div> </div>
</Modal> </Modal>
@ -47,6 +53,7 @@
.verification-code { .verification-code {
background: var(--bg-secondary, #f5f5f5); background: var(--bg-secondary, #f5f5f5);
color: var(--text-primary);
padding: 0.2rem 0.4rem; padding: 0.2rem 0.4rem;
border-radius: 3px; border-radius: 3px;
font-family: monospace; font-family: monospace;
@ -70,6 +77,7 @@
.filename { .filename {
font-weight: bold; font-weight: bold;
font-family: monospace; font-family: monospace;
color: var(--text-primary);
} }
.file-actions { .file-actions {
@ -82,8 +90,17 @@
padding: 0.25rem 0.75rem; padding: 0.25rem 0.75rem;
border: 1px solid var(--border-color, #e0e0e0); border: 1px solid var(--border-color, #e0e0e0);
border-radius: 4px; border-radius: 4px;
background: white; background: var(--button-secondary, var(--bg-tertiary));
color: var(--text-primary);
cursor: pointer; cursor: pointer;
font-family: 'IBM Plex Serif', serif;
transition: background 0.2s ease, color 0.2s ease;
}
.copy-button:hover,
.download-button:hover {
background: var(--button-secondary-hover, var(--bg-tertiary));
opacity: 0.9;
} }
.file-content { .file-content {
@ -92,12 +109,15 @@
overflow-x: auto; overflow-x: auto;
max-height: 400px; max-height: 400px;
overflow-y: auto; overflow-y: auto;
background: var(--bg-primary);
color: var(--text-primary);
} }
.file-content code { .file-content code {
font-family: monospace; font-family: monospace;
font-size: 0.85rem; font-size: 0.85rem;
white-space: pre; white-space: pre;
color: var(--text-primary);
} }
.modal-actions { .modal-actions {
@ -108,10 +128,37 @@
} }
.cancel-button { .cancel-button {
padding: 0.5rem 1rem;
border: 1px solid var(--border-color);
border-radius: 4px;
cursor: pointer;
background: var(--bg-tertiary);
color: var(--text-primary);
font-family: 'IBM Plex Serif', serif;
transition: background 0.2s ease, color 0.2s ease;
}
.cancel-button:hover {
background: var(--bg-secondary);
}
.save-button {
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
border: none; border: none;
border-radius: 4px; border-radius: 4px;
cursor: pointer; cursor: pointer;
background: var(--cancel-bg, #e0e0e0); background: var(--button-primary);
color: var(--accent-text, #ffffff);
font-family: 'IBM Plex Serif', serif;
transition: background 0.2s ease;
}
.save-button:hover:not(:disabled) {
background: var(--button-primary-hover);
}
.save-button:disabled {
opacity: 0.5;
cursor: not-allowed;
} }
</style> </style>

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

@ -13,6 +13,8 @@ import { getUserRelays } from '$lib/services/nostr/user-relays.js';
import { KIND } from '$lib/types/nostr.js'; import { KIND } from '$lib/types/nostr.js';
import type { NostrEvent } from '$lib/types/nostr.js'; import type { NostrEvent } from '$lib/types/nostr.js';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import logger from '$lib/services/logger.js';
import { isNIP07Available } from '$lib/services/nostr/nip07-signer.js';
interface RepoOperationsCallbacks { interface RepoOperationsCallbacks {
checkCloneStatus: (force: boolean) => Promise<void>; checkCloneStatus: (force: boolean) => Promise<void>;
@ -110,9 +112,94 @@ export async function cloneRepository(
): Promise<void> { ): Promise<void> {
if (state.clone.cloning) return; if (state.clone.cloning) return;
if (!state.user.pubkeyHex) {
alert('Please log in to clone repositories.');
return;
}
state.clone.cloning = true; state.clone.cloning = true;
try { try {
const data = await apiPost<{ alreadyExists?: boolean }>(`/api/repos/${state.npub}/${state.repo}/clone`, {}); // Create and send proof event to verify write access
// This ensures the clone endpoint can verify access even if cache is empty
let proofEvent: NostrEvent | null = null;
try {
if (isNIP07Available() && state.user.pubkeyHex) {
const { createProofEvent } = await import('$lib/services/nostr/relay-write-proof.js');
const { signEventWithNIP07 } = await import('$lib/services/nostr/nip07-signer.js');
// Create proof event template
const proofEventTemplate = createProofEvent(
state.user.pubkeyHex,
`gitrepublic-clone-proof-${Date.now()}`
);
// Sign with NIP-07
proofEvent = await signEventWithNIP07(proofEventTemplate);
// Publish to relays so server can verify it
// User only needs write access to ONE relay, not all
const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS);
let publishResult;
try {
publishResult = await nostrClient.publishEvent(proofEvent, DEFAULT_NOSTR_RELAYS).catch((err: unknown) => {
// Catch any unhandled errors from publishEvent
const errorMessage = err instanceof Error ? err.message : String(err);
logger.debug({ error: errorMessage }, '[Clone] Error in publishEvent, marking all relays as failed');
// Return a result with all relays failed
return {
success: [],
failed: DEFAULT_NOSTR_RELAYS.map(relay => ({ relay, error: errorMessage }))
};
});
} catch (err) {
// Catch synchronous errors
const errorMessage = err instanceof Error ? err.message : String(err);
logger.debug({ error: errorMessage }, '[Clone] Synchronous error publishing proof event');
publishResult = {
success: [],
failed: DEFAULT_NOSTR_RELAYS.map(relay => ({ relay, error: errorMessage }))
};
}
// If at least one relay accepted the event, wait for propagation
// If all relays failed, still try (might be cached or server can retry)
if (publishResult && publishResult.success.length > 0) {
logger.debug({
successCount: publishResult.success.length,
successfulRelays: publishResult.success,
failedRelays: publishResult.failed.map(f => f.relay)
}, '[Clone] Proof event published to at least one relay, waiting for propagation');
// Wait longer for event to propagate to relays (3 seconds should be enough)
await new Promise(resolve => setTimeout(resolve, 3000));
} else {
const failedDetails = publishResult?.failed.map(f => `${f.relay}: ${f.error}`) || ['Unknown error'];
logger.warn({
failedRelays: failedDetails
}, '[Clone] Proof event failed to publish to all relays, but continuing (server may retry or use cache)');
// Still wait a bit in case some relays are slow
await new Promise(resolve => setTimeout(resolve, 2000));
}
// Clean up client
try {
nostrClient.close();
} catch (closeErr) {
// Ignore close errors
logger.debug({ error: closeErr }, '[Clone] Error closing NostrClient');
}
}
} catch (proofErr) {
// If proof creation fails, continue anyway - clone endpoint will check cache
logger.debug({ error: proofErr }, '[Clone] Failed to create proof event, will rely on cache');
}
// Send clone request with proof event in body (if available)
const requestBody: { proofEvent?: NostrEvent } = {};
if (proofEvent) {
requestBody.proofEvent = proofEvent;
}
const data = await apiPost<{ alreadyExists?: boolean }>(`/api/repos/${state.npub}/${state.repo}/clone`, requestBody);
if (data.alreadyExists) { if (data.alreadyExists) {
alert('Repository already exists locally.'); alert('Repository already exists locally.');
@ -382,7 +469,25 @@ export async function checkVerification(
state.verification.status = { verified: false, error: 'Failed to check verification' }; state.verification.status = { verified: false, error: 'Failed to check verification' };
} finally { } finally {
state.loading.verification = false; state.loading.verification = false;
console.log('[Verification] Status after check:', state.verification.status);
// Log verification status for maintenance
if (state.verification.status) {
const status = state.verification.status;
console.log('[Verification Status]', {
verified: status.verified,
error: status.error || null,
message: status.message || null,
cloneCount: status.cloneVerifications?.length || 0,
verifiedClones: status.cloneVerifications?.filter(cv => cv.verified).length || 0,
cloneDetails: status.cloneVerifications?.map(cv => ({
url: cv.url.substring(0, 50) + (cv.url.length > 50 ? '...' : ''),
verified: cv.verified,
error: cv.error || null
})) || []
});
} else {
console.log('[Verification Status] Not available');
}
} }
} }
@ -593,26 +698,36 @@ export async function generateAnnouncementFileForRepo(
} }
try { try {
// Fetch the repository announcement event // First, try to use the announcement from pageData (already loaded)
const { NostrClient } = await import('$lib/services/nostr/nostr-client.js'); let announcement: NostrEvent | null = null;
const { DEFAULT_NOSTR_RELAYS, DEFAULT_NOSTR_SEARCH_RELAYS } = await import('$lib/config.js');
const { KIND } = await import('$lib/types/nostr.js'); if (state.pageData?.announcement) {
const nostrClient = new NostrClient([...new Set([...DEFAULT_NOSTR_RELAYS, ...DEFAULT_NOSTR_SEARCH_RELAYS])]); announcement = state.pageData.announcement as NostrEvent;
const events = await nostrClient.fetchEvents([ }
{
kinds: [KIND.REPO_ANNOUNCEMENT], // If not available in pageData, fetch from Nostr
authors: [repoOwnerPubkeyDerived], if (!announcement) {
'#d': [state.repo], const { NostrClient } = await import('$lib/services/nostr/nostr-client.js');
limit: 1 const { DEFAULT_NOSTR_RELAYS, DEFAULT_NOSTR_SEARCH_RELAYS } = await import('$lib/config.js');
const { KIND } = await import('$lib/types/nostr.js');
const nostrClient = new NostrClient([...new Set([...DEFAULT_NOSTR_RELAYS, ...DEFAULT_NOSTR_SEARCH_RELAYS])]);
const events = await nostrClient.fetchEvents([
{
kinds: [KIND.REPO_ANNOUNCEMENT],
authors: [repoOwnerPubkeyDerived],
'#d': [state.repo],
limit: 1
}
]);
if (events.length === 0) {
state.error = 'Repository announcement not found. Please ensure the repository is registered on Nostr.';
return;
} }
]);
if (events.length === 0) { announcement = events[0] as NostrEvent;
state.error = 'Repository announcement not found. Please ensure the repository is registered on Nostr.';
return;
} }
const announcement = events[0] as NostrEvent;
// Generate announcement event JSON (for download/reference) // Generate announcement event JSON (for download/reference)
state.verification.fileContent = JSON.stringify(announcement, null, 2) + '\n'; state.verification.fileContent = JSON.stringify(announcement, null, 2) + '\n';
state.openDialog = 'verification'; state.openDialog = 'verification';
@ -636,6 +751,53 @@ export function copyVerificationToClipboard(state: RepoState): void {
}); });
} }
/**
* Save announcement to repository
* Uses the existing verify endpoint which saves and commits the announcement
*/
export async function saveAnnouncementToRepo(
state: RepoState,
repoOwnerPubkeyDerived: string | null
): Promise<void> {
if (!repoOwnerPubkeyDerived || !state.user.pubkeyHex) {
state.error = 'Unable to save announcement: missing repository or user information';
return;
}
if (!state.clone.isCloned) {
state.error = 'Repository must be cloned first. Please clone the repository before saving the announcement.';
return;
}
// Check if user is owner or maintainer
if (!state.maintainers.isMaintainer && state.user.pubkeyHex !== repoOwnerPubkeyDerived) {
state.error = 'Only repository owners and maintainers can save announcements.';
return;
}
try {
state.creating.announcement = true;
state.error = null;
// Use the existing verify endpoint which saves and commits the announcement
const data = await apiRequest<{ message?: string; announcementId?: string }>(`/api/repos/${state.npub}/${state.repo}/verify`, {
method: 'POST'
} as RequestInit);
// Close dialog and show success
state.openDialog = null;
alert(data.message || 'Announcement saved to repository successfully!');
// Reload branches and files to show the new commit
// The callbacks will be passed from the component
} catch (err) {
console.error('Failed to save announcement:', err);
state.error = `Failed to save announcement: ${err instanceof Error ? err.message : String(err)}`;
} finally {
state.creating.announcement = false;
}
}
/** /**
* Download verification file * Download verification file
*/ */

Loading…
Cancel
Save