You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
3049 lines
112 KiB
3049 lines
112 KiB
<script lang="ts"> |
|
import { onMount } from 'svelte'; |
|
import { goto } from '$app/navigation'; |
|
import { page } from '$app/stores'; |
|
import { isNIP07Available, getPublicKeyWithNIP07, signEventWithNIP07 } from '../../lib/services/nostr/nip07-signer.js'; |
|
import { decodeNostrAddress } from '../../lib/services/nostr/nip19-utils.js'; |
|
import { NostrClient } from '../../lib/services/nostr/nostr-client.js'; |
|
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, isLoggedIn } from '../../lib/utils/user-access.js'; |
|
|
|
let nip07Available = $state(false); |
|
let loading = $state(false); |
|
let error = $state<string | null>(null); |
|
let success = $state(false); |
|
|
|
// Form fields |
|
let repoName = $state(''); |
|
let description = $state(''); |
|
let cloneUrls = $state<string[]>(['']); |
|
let webUrls = $state<string[]>(['']); |
|
let maintainers = $state<string[]>(['']); |
|
let relays = $state<string[]>(['']); |
|
let blossoms = $state<string[]>(['']); |
|
let tags = $state<string[]>(['']); |
|
let documentation = $state<string[]>(['']); |
|
let alt = $state(''); |
|
let imageUrl = $state(''); |
|
let bannerUrl = $state(''); |
|
let earliestCommit = $state(''); |
|
let visibility = $state<'public' | 'unlisted' | 'restricted' | 'private'>('public'); |
|
let projectRelays = $state<string[]>(['']); |
|
let isFork = $state(false); |
|
let forkOriginalRepo = $state(''); // Original repo identifier: npub/repo, naddr, or 30617:owner:repo format |
|
let addClientTag = $state(true); // Add ["client", "gitrepublic-web"] tag |
|
let existingRepoRef = $state(''); // hex, nevent, or naddr |
|
let loadingExisting = $state(false); |
|
|
|
// URL preview state |
|
let previewingUrlIndex = $state<number | null>(null); |
|
let previewUrl = $state<string | null>(null); |
|
let previewError = $state<string | null>(null); |
|
let previewLoading = $state(false); |
|
let previewTimeout: ReturnType<typeof setTimeout> | null = null; |
|
|
|
// Lookup state |
|
let lookupLoading = $state<{ [key: string]: boolean }>({}); |
|
let lookupError = $state<{ [key: string]: string | null }>({}); |
|
type ProfileData = { pubkey: string; npub: string; name?: string; about?: string; picture?: string }; |
|
let lookupResults = $state<{ [key: string]: Array<ProfileData | NostrEvent> | null }>({}); |
|
|
|
import { DEFAULT_NOSTR_RELAYS, DEFAULT_NOSTR_SEARCH_RELAYS, combineRelays } from '../../lib/config.js'; |
|
|
|
const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS); |
|
const searchClient = new NostrClient(DEFAULT_NOSTR_SEARCH_RELAYS); |
|
|
|
onMount(async () => { |
|
nip07Available = isNIP07Available(); |
|
|
|
// Check for query params to pre-fill form (for registering local clones or transfers) |
|
const urlParams = $page.url.searchParams; |
|
const transferParam = urlParams.get('transfer'); |
|
const transferEventId = urlParams.get('transferEventId'); |
|
const originalOwnerParam = urlParams.get('originalOwner'); |
|
const repoParam = urlParams.get('repo'); |
|
const repoTagParam = urlParams.get('repoTag'); |
|
const npubParam = urlParams.get('npub') || originalOwnerParam; |
|
|
|
// Handle transfer flow (step 4) |
|
if (transferParam === 'true' && originalOwnerParam && repoParam && repoTagParam) { |
|
try { |
|
// Fetch the original repo announcement to preload data |
|
const decoded = nip19.decode(originalOwnerParam); |
|
if (decoded.type === 'npub') { |
|
const pubkey = decoded.data as string; |
|
const events = await nostrClient.fetchEvents([ |
|
{ |
|
kinds: [KIND.REPO_ANNOUNCEMENT], |
|
authors: [pubkey], |
|
'#d': [repoParam], |
|
limit: 1 |
|
} |
|
]); |
|
|
|
if (events.length > 0) { |
|
const event = events[0]; |
|
|
|
// Pre-fill repo name |
|
repoName = repoParam; |
|
|
|
// Pre-fill description |
|
const descTag = event.tags.find(t => t[0] === 'description')?.[1]; |
|
if (descTag) description = descTag; |
|
|
|
// Pre-fill clone URLs (add current domain URL) |
|
const existingCloneUrls = event.tags |
|
.filter(t => t[0] === 'clone') |
|
.flatMap(t => t.slice(1)) |
|
.filter(url => url && typeof url === 'string'); |
|
|
|
const gitDomain = $page.data.gitDomain || 'localhost:6543'; |
|
const isLocalhost = gitDomain.startsWith('localhost') || gitDomain.startsWith('127.0.0.1'); |
|
|
|
// Only add clone URL if not localhost |
|
if (!isLocalhost) { |
|
const protocol = 'https'; |
|
const currentDomainUrl = `${protocol}://${gitDomain}/${originalOwnerParam}/${repoParam}.git`; |
|
|
|
// Check if current domain URL already exists |
|
const hasCurrentDomain = existingCloneUrls.some(url => url.includes(gitDomain)); |
|
|
|
if (!hasCurrentDomain) { |
|
cloneUrls = [...existingCloneUrls, currentDomainUrl]; |
|
} else { |
|
cloneUrls = existingCloneUrls.length > 0 ? existingCloneUrls : [currentDomainUrl]; |
|
} |
|
} else { |
|
// Localhost: just use existing clone URLs |
|
cloneUrls = existingCloneUrls.length > 0 ? existingCloneUrls : ['']; |
|
} |
|
|
|
// Pre-fill other fields |
|
const nameTag = event.tags.find(t => t[0] === 'name')?.[1]; |
|
if (nameTag && !repoName) repoName = nameTag; |
|
|
|
const imageTag = event.tags.find(t => t[0] === 'image')?.[1]; |
|
if (imageTag) imageUrl = imageTag; |
|
|
|
const bannerTag = event.tags.find(t => t[0] === 'banner')?.[1]; |
|
if (bannerTag) bannerUrl = bannerTag; |
|
|
|
const webTags = event.tags.filter(t => t[0] === 'web'); |
|
if (webTags.length > 0) { |
|
webUrls = webTags.flatMap(t => t.slice(1)).filter(url => url && typeof url === 'string'); |
|
} |
|
|
|
const maintainerTags = event.tags.filter(t => t[0] === 'maintainers'); |
|
if (maintainerTags.length > 0) { |
|
maintainers = maintainerTags.flatMap(t => t.slice(1)).filter(m => m && typeof m === 'string'); |
|
} |
|
|
|
const relayTags = event.tags.filter(t => t[0] === 'relays'); |
|
if (relayTags.length > 0) { |
|
relays = relayTags.flatMap(t => t.slice(1)).filter(r => r && typeof r === 'string'); |
|
} |
|
|
|
// Extract blossoms |
|
const blossomsTags = event.tags.filter(t => t[0] === 'blossoms'); |
|
if (blossomsTags.length > 0) { |
|
blossoms = blossomsTags.flatMap(t => t.slice(1)).filter(b => b && typeof b === 'string'); |
|
} |
|
|
|
// Extract tags/labels (excluding 'private' and 'fork') |
|
const tagsList: string[] = []; |
|
for (const tag of event.tags) { |
|
if (tag[0] === 't' && tag[1] && tag[1] !== 'private' && tag[1] !== 'fork') { |
|
tagsList.push(tag[1]); |
|
} |
|
} |
|
tags = tagsList.length > 0 ? tagsList : ['']; |
|
|
|
// Extract documentation - handle relay hints correctly |
|
const docsList: string[] = []; |
|
const isRelayUrl = (value: string): boolean => { |
|
return typeof value === 'string' && (value.startsWith('wss://') || value.startsWith('ws://')); |
|
}; |
|
|
|
const getDocFormat = (value: string): string | null => { |
|
if (value.startsWith('naddr1')) return 'naddr'; |
|
if (/^\d+:[0-9a-f]{64}:[a-zA-Z0-9_-]+$/.test(value)) return 'kind:pubkey:identifier'; |
|
return null; |
|
}; |
|
|
|
for (const tag of event.tags) { |
|
if (tag[0] === 'documentation') { |
|
let i = 1; |
|
|
|
while (i < tag.length) { |
|
const value = tag[i]; |
|
if (!value || typeof value !== 'string' || !value.trim()) { |
|
i++; |
|
continue; |
|
} |
|
|
|
const trimmed = value.trim(); |
|
|
|
if (isRelayUrl(trimmed)) { |
|
i++; |
|
continue; |
|
} |
|
|
|
const format = getDocFormat(trimmed); |
|
if (!format) { |
|
i++; |
|
continue; |
|
} |
|
|
|
const nextValue = i + 1 < tag.length ? tag[i + 1] : null; |
|
if (nextValue && typeof nextValue === 'string' && isRelayUrl(nextValue.trim())) { |
|
docsList.push(trimmed); |
|
i += 2; |
|
continue; |
|
} |
|
|
|
const sameFormatEntries: string[] = [trimmed]; |
|
let j = i + 1; |
|
while (j < tag.length) { |
|
const nextVal = tag[j]; |
|
if (!nextVal || typeof nextVal !== 'string' || !nextVal.trim()) { |
|
j++; |
|
continue; |
|
} |
|
|
|
const nextTrimmed = nextVal.trim(); |
|
|
|
if (isRelayUrl(nextTrimmed)) { |
|
break; |
|
} |
|
|
|
const nextFormat = getDocFormat(nextTrimmed); |
|
if (nextFormat === format) { |
|
sameFormatEntries.push(nextTrimmed); |
|
j++; |
|
} else { |
|
break; |
|
} |
|
} |
|
|
|
docsList.push(...sameFormatEntries); |
|
i = j; |
|
} |
|
} |
|
} |
|
documentation = docsList.length > 0 ? docsList : ['']; |
|
|
|
// Extract alt tag |
|
const altTag = event.tags.find(t => t[0] === 'alt'); |
|
alt = altTag?.[1] || ''; |
|
|
|
// Extract fork information |
|
const aTag = event.tags.find(t => t[0] === 'a' && t[1]?.startsWith('30617:')); |
|
if (aTag?.[1]) { |
|
forkOriginalRepo = aTag[1]; |
|
isFork = true; |
|
} else { |
|
isFork = event.tags.some(t => t[0] === 't' && t[1] === 'fork'); |
|
if (isFork) { |
|
const pTag = event.tags.find(t => t[0] === 'p' && t[1] && t[1] !== event.pubkey); |
|
const dTag = event.tags.find(t => t[0] === 'd')?.[1]; |
|
if (pTag?.[1] && dTag) { |
|
forkOriginalRepo = `${KIND.REPO_ANNOUNCEMENT}:${pTag[1]}:${dTag}`; |
|
} |
|
} |
|
} |
|
|
|
// Extract earliest unique commit |
|
const rTag = event.tags.find(t => t[0] === 'r' && t[2] === 'euc'); |
|
earliestCommit = rTag?.[1] || ''; |
|
|
|
// Check if client tag exists |
|
addClientTag = !event.tags.some(t => t[0] === 'client' && t[1] === 'gitrepublic-web'); |
|
|
|
// Read visibility tag (defaults to 'public') |
|
const visibilityTag = event.tags.find(t => t[0] === 'visibility' && t[1]); |
|
if (visibilityTag && visibilityTag[1]) { |
|
const vis = visibilityTag[1].toLowerCase(); |
|
if (['public', 'unlisted', 'restricted', 'private'].includes(vis)) { |
|
visibility = vis as typeof visibility; |
|
} |
|
} |
|
|
|
// Read project-relay tags |
|
const projectRelayTags = event.tags.filter(t => t[0] === 'project-relay'); |
|
if (projectRelayTags.length > 0) { |
|
projectRelays = projectRelayTags.flatMap(t => t.slice(1)).filter(r => r && typeof r === 'string'); |
|
if (projectRelays.length === 0) projectRelays = ['']; |
|
} |
|
|
|
// Backward compatibility: check for old private tag |
|
const isPrivateTag = event.tags.find(t => |
|
(t[0] === 'private' && t[1] === 'true') || |
|
(t[0] === 't' && t[1] === 'private') |
|
); |
|
if (isPrivateTag && !visibilityTag) { |
|
visibility = 'restricted'; // Map old private to restricted |
|
} |
|
|
|
// Set existing repo ref for updating |
|
existingRepoRef = event.id; |
|
} else { |
|
// No announcement found |
|
repoName = repoParam; |
|
const gitDomain = $page.data.gitDomain || 'localhost:6543'; |
|
const isLocalhost = gitDomain.startsWith('localhost') || gitDomain.startsWith('127.0.0.1'); |
|
|
|
// Only add clone URL if not localhost |
|
if (!isLocalhost) { |
|
cloneUrls = [`https://${gitDomain}/${originalOwnerParam}/${repoParam}.git`]; |
|
} else { |
|
cloneUrls = ['']; |
|
} |
|
} |
|
} |
|
} catch (err) { |
|
console.warn('Failed to pre-fill form from transfer data:', err); |
|
// Still set basic info |
|
repoName = repoParam; |
|
const gitDomain = $page.data.gitDomain || 'localhost:6543'; |
|
const isLocalhost = gitDomain.startsWith('localhost') || gitDomain.startsWith('127.0.0.1'); |
|
|
|
// Only add clone URL if not localhost |
|
if (!isLocalhost) { |
|
cloneUrls = [`https://${gitDomain}/${originalOwnerParam}/${repoParam}.git`]; |
|
} else { |
|
cloneUrls = ['']; |
|
} |
|
} |
|
} else if (npubParam && repoParam) { |
|
// Pre-fill repo name |
|
repoName = repoParam; |
|
|
|
// Try to fetch existing announcement to pre-fill other fields |
|
try { |
|
const decoded = nip19.decode(npubParam); |
|
if (decoded.type === 'npub') { |
|
const pubkey = decoded.data as string; |
|
const events = await nostrClient.fetchEvents([ |
|
{ |
|
kinds: [KIND.REPO_ANNOUNCEMENT], |
|
authors: [pubkey], |
|
'#d': [repoParam], |
|
limit: 1 |
|
} |
|
]); |
|
|
|
if (events.length > 0) { |
|
const event = events[0]; |
|
|
|
// Pre-fill description |
|
const descTag = event.tags.find(t => t[0] === 'description')?.[1]; |
|
if (descTag) description = descTag; |
|
|
|
// Pre-fill clone URLs (add current domain URL) |
|
const existingCloneUrls = event.tags |
|
.filter(t => t[0] === 'clone') |
|
.flatMap(t => t.slice(1)) |
|
.filter(url => url && typeof url === 'string'); |
|
|
|
const gitDomain = $page.data.gitDomain || 'localhost:6543'; |
|
const isLocalhost = gitDomain.startsWith('localhost') || gitDomain.startsWith('127.0.0.1'); |
|
|
|
// Only add clone URL if not localhost |
|
if (!isLocalhost) { |
|
const protocol = 'https'; |
|
const currentDomainUrl = `${protocol}://${gitDomain}/${npubParam}/${repoParam}.git`; |
|
|
|
// Check if current domain URL already exists |
|
const hasCurrentDomain = existingCloneUrls.some(url => url.includes(gitDomain)); |
|
|
|
if (!hasCurrentDomain) { |
|
cloneUrls = [...existingCloneUrls, currentDomainUrl]; |
|
} else { |
|
cloneUrls = existingCloneUrls.length > 0 ? existingCloneUrls : [currentDomainUrl]; |
|
} |
|
} else { |
|
// Localhost: just use existing clone URLs |
|
cloneUrls = existingCloneUrls.length > 0 ? existingCloneUrls : ['']; |
|
} |
|
|
|
// Pre-fill other fields |
|
const nameTag = event.tags.find(t => t[0] === 'name')?.[1]; |
|
if (nameTag && !repoName) repoName = nameTag; |
|
|
|
const imageTag = event.tags.find(t => t[0] === 'image')?.[1]; |
|
if (imageTag) imageUrl = imageTag; |
|
|
|
const bannerTag = event.tags.find(t => t[0] === 'banner')?.[1]; |
|
if (bannerTag) bannerUrl = bannerTag; |
|
|
|
const webTags = event.tags.filter(t => t[0] === 'web'); |
|
if (webTags.length > 0) { |
|
webUrls = webTags.flatMap(t => t.slice(1)).filter(url => url && typeof url === 'string'); |
|
} |
|
|
|
const maintainerTags = event.tags.filter(t => t[0] === 'maintainers'); |
|
if (maintainerTags.length > 0) { |
|
maintainers = maintainerTags.flatMap(t => t.slice(1)).filter(m => m && typeof m === 'string'); |
|
} |
|
|
|
const relayTags = event.tags.filter(t => t[0] === 'relays'); |
|
if (relayTags.length > 0) { |
|
relays = relayTags.flatMap(t => t.slice(1)).filter(r => r && typeof r === 'string'); |
|
} |
|
|
|
// Extract blossoms |
|
const blossomsTags = event.tags.filter(t => t[0] === 'blossoms'); |
|
if (blossomsTags.length > 0) { |
|
blossoms = blossomsTags.flatMap(t => t.slice(1)).filter(b => b && typeof b === 'string'); |
|
} |
|
|
|
// Extract tags/labels (excluding 'private' and 'fork') |
|
const tagsList: string[] = []; |
|
for (const tag of event.tags) { |
|
if (tag[0] === 't' && tag[1] && tag[1] !== 'private' && tag[1] !== 'fork') { |
|
tagsList.push(tag[1]); |
|
} |
|
} |
|
tags = tagsList.length > 0 ? tagsList : ['']; |
|
|
|
// Extract documentation - handle relay hints correctly |
|
const docsList: string[] = []; |
|
const isRelayUrl = (value: string): boolean => { |
|
return typeof value === 'string' && (value.startsWith('wss://') || value.startsWith('ws://')); |
|
}; |
|
|
|
const getDocFormat = (value: string): string | null => { |
|
if (value.startsWith('naddr1')) return 'naddr'; |
|
if (/^\d+:[0-9a-f]{64}:[a-zA-Z0-9_-]+$/.test(value)) return 'kind:pubkey:identifier'; |
|
return null; |
|
}; |
|
|
|
for (const tag of event.tags) { |
|
if (tag[0] === 'documentation') { |
|
let i = 1; |
|
|
|
while (i < tag.length) { |
|
const value = tag[i]; |
|
if (!value || typeof value !== 'string' || !value.trim()) { |
|
i++; |
|
continue; |
|
} |
|
|
|
const trimmed = value.trim(); |
|
|
|
if (isRelayUrl(trimmed)) { |
|
i++; |
|
continue; |
|
} |
|
|
|
const format = getDocFormat(trimmed); |
|
if (!format) { |
|
i++; |
|
continue; |
|
} |
|
|
|
const nextValue = i + 1 < tag.length ? tag[i + 1] : null; |
|
if (nextValue && typeof nextValue === 'string' && isRelayUrl(nextValue.trim())) { |
|
docsList.push(trimmed); |
|
i += 2; |
|
continue; |
|
} |
|
|
|
const sameFormatEntries: string[] = [trimmed]; |
|
let j = i + 1; |
|
while (j < tag.length) { |
|
const nextVal = tag[j]; |
|
if (!nextVal || typeof nextVal !== 'string' || !nextVal.trim()) { |
|
j++; |
|
continue; |
|
} |
|
|
|
const nextTrimmed = nextVal.trim(); |
|
|
|
if (isRelayUrl(nextTrimmed)) { |
|
break; |
|
} |
|
|
|
const nextFormat = getDocFormat(nextTrimmed); |
|
if (nextFormat === format) { |
|
sameFormatEntries.push(nextTrimmed); |
|
j++; |
|
} else { |
|
break; |
|
} |
|
} |
|
|
|
docsList.push(...sameFormatEntries); |
|
i = j; |
|
} |
|
} |
|
} |
|
documentation = docsList.length > 0 ? docsList : ['']; |
|
|
|
// Extract alt tag |
|
const altTag = event.tags.find(t => t[0] === 'alt'); |
|
alt = altTag?.[1] || ''; |
|
|
|
// Extract fork information |
|
const aTag = event.tags.find(t => t[0] === 'a' && t[1]?.startsWith('30617:')); |
|
if (aTag?.[1]) { |
|
forkOriginalRepo = aTag[1]; |
|
isFork = true; |
|
} else { |
|
isFork = event.tags.some(t => t[0] === 't' && t[1] === 'fork'); |
|
if (isFork) { |
|
const pTag = event.tags.find(t => t[0] === 'p' && t[1] && t[1] !== event.pubkey); |
|
const dTag = event.tags.find(t => t[0] === 'd')?.[1]; |
|
if (pTag?.[1] && dTag) { |
|
forkOriginalRepo = `${KIND.REPO_ANNOUNCEMENT}:${pTag[1]}:${dTag}`; |
|
} |
|
} |
|
} |
|
|
|
// Extract earliest unique commit |
|
const rTag = event.tags.find(t => t[0] === 'r' && t[2] === 'euc'); |
|
earliestCommit = rTag?.[1] || ''; |
|
|
|
// Check if client tag exists |
|
addClientTag = !event.tags.some(t => t[0] === 'client' && t[1] === 'gitrepublic-web'); |
|
|
|
// Read visibility tag (defaults to 'public') |
|
const visibilityTag = event.tags.find(t => t[0] === 'visibility' && t[1]); |
|
if (visibilityTag && visibilityTag[1]) { |
|
const vis = visibilityTag[1].toLowerCase(); |
|
if (['public', 'unlisted', 'restricted', 'private'].includes(vis)) { |
|
visibility = vis as typeof visibility; |
|
} |
|
} |
|
|
|
// Read project-relay tags |
|
const projectRelayTags = event.tags.filter(t => t[0] === 'project-relay'); |
|
if (projectRelayTags.length > 0) { |
|
projectRelays = projectRelayTags.flatMap(t => t.slice(1)).filter(r => r && typeof r === 'string'); |
|
if (projectRelays.length === 0) projectRelays = ['']; |
|
} |
|
|
|
// Backward compatibility: check for old private tag |
|
const isPrivateTag = event.tags.find(t => |
|
(t[0] === 'private' && t[1] === 'true') || |
|
(t[0] === 't' && t[1] === 'private') |
|
); |
|
if (isPrivateTag && !visibilityTag) { |
|
visibility = 'restricted'; // Map old private to restricted |
|
} |
|
|
|
// Set existing repo ref for updating |
|
existingRepoRef = event.id; |
|
} else { |
|
// No announcement found |
|
const gitDomain = $page.data.gitDomain || 'localhost:6543'; |
|
const isLocalhost = gitDomain.startsWith('localhost') || gitDomain.startsWith('127.0.0.1'); |
|
|
|
// Only add clone URL if not localhost |
|
if (!isLocalhost) { |
|
cloneUrls = [`https://${gitDomain}/${npubParam}/${repoParam}.git`]; |
|
} else { |
|
cloneUrls = ['']; |
|
} |
|
} |
|
} |
|
} catch (err) { |
|
console.warn('Failed to pre-fill form from query params:', err); |
|
// Still set basic info |
|
const gitDomain = $page.data.gitDomain || 'localhost:6543'; |
|
const isLocalhost = gitDomain.startsWith('localhost') || gitDomain.startsWith('127.0.0.1'); |
|
|
|
// Only add clone URL if not localhost |
|
if (!isLocalhost) { |
|
cloneUrls = [`https://${gitDomain}/${npubParam}/${repoParam}.git`]; |
|
} else { |
|
cloneUrls = ['']; |
|
} |
|
} |
|
} |
|
}); |
|
|
|
function addCloneUrl() { |
|
cloneUrls = [...cloneUrls, '']; |
|
} |
|
|
|
function removeCloneUrl(index: number) { |
|
cloneUrls = cloneUrls.filter((_, i) => i !== index); |
|
} |
|
|
|
function updateCloneUrl(index: number, value: string) { |
|
const newUrls = [...cloneUrls]; |
|
newUrls[index] = value; |
|
cloneUrls = newUrls; |
|
} |
|
|
|
function addWebUrl() { |
|
webUrls = [...webUrls, '']; |
|
} |
|
|
|
function removeWebUrl(index: number) { |
|
webUrls = webUrls.filter((_, i) => i !== index); |
|
} |
|
|
|
function updateWebUrl(index: number, value: string) { |
|
const newUrls = [...webUrls]; |
|
newUrls[index] = value; |
|
webUrls = newUrls; |
|
} |
|
|
|
function addMaintainer() { |
|
maintainers = [...maintainers, '']; |
|
} |
|
|
|
function removeMaintainer(index: number) { |
|
maintainers = maintainers.filter((_, i) => i !== index); |
|
} |
|
|
|
function updateMaintainer(index: number, value: string) { |
|
const newMaintainers = [...maintainers]; |
|
newMaintainers[index] = value; |
|
maintainers = newMaintainers; |
|
} |
|
|
|
function addRelay() { |
|
relays = [...relays, '']; |
|
} |
|
|
|
function removeRelay(index: number) { |
|
relays = relays.filter((_, i) => i !== index); |
|
} |
|
|
|
function updateRelay(index: number, value: string) { |
|
const newRelays = [...relays]; |
|
newRelays[index] = value; |
|
relays = newRelays; |
|
} |
|
|
|
function addBlossom() { |
|
blossoms = [...blossoms, '']; |
|
} |
|
|
|
function removeBlossom(index: number) { |
|
blossoms = blossoms.filter((_, i) => i !== index); |
|
} |
|
|
|
function updateBlossom(index: number, value: string) { |
|
const newBlossoms = [...blossoms]; |
|
newBlossoms[index] = value; |
|
blossoms = newBlossoms; |
|
} |
|
|
|
function addTag() { |
|
tags = [...tags, '']; |
|
} |
|
|
|
function removeTag(index: number) { |
|
tags = tags.filter((_, i) => i !== index); |
|
} |
|
|
|
function updateTag(index: number, value: string) { |
|
const newTags = [...tags]; |
|
newTags[index] = value; |
|
tags = newTags; |
|
} |
|
|
|
function addDocumentation() { |
|
documentation = [...documentation, '']; |
|
} |
|
|
|
function removeDocumentation(index: number) { |
|
documentation = documentation.filter((_, i) => i !== index); |
|
} |
|
|
|
function updateDocumentation(index: number, value: string) { |
|
const newDocs = [...documentation]; |
|
newDocs[index] = value; |
|
documentation = newDocs; |
|
} |
|
|
|
async function lookupDocumentation(index: number) { |
|
const doc = documentation[index]?.trim(); |
|
if (!doc) { |
|
lookupError[`doc-${index}`] = 'Please enter a naddr to lookup'; |
|
return; |
|
} |
|
|
|
const lookupKey = `doc-${index}`; |
|
lookupLoading[lookupKey] = true; |
|
lookupError[lookupKey] = null; |
|
lookupResults[lookupKey] = null; |
|
|
|
try { |
|
// Try to decode as naddr |
|
let decoded; |
|
try { |
|
decoded = nip19.decode(doc); |
|
} catch { |
|
lookupError[lookupKey] = 'Invalid naddr format'; |
|
return; |
|
} |
|
|
|
if (decoded.type !== 'naddr') { |
|
lookupError[lookupKey] = 'Please enter a valid naddr (naddr1...)'; |
|
return; |
|
} |
|
|
|
// Extract the components from the decoded naddr |
|
const { pubkey, kind, identifier, relays } = decoded.data as { |
|
pubkey: string; |
|
kind: number; |
|
identifier: string; |
|
relays?: string[]; |
|
}; |
|
|
|
if (!pubkey || !kind || !identifier) { |
|
lookupError[lookupKey] = 'Invalid naddr: missing required components'; |
|
return; |
|
} |
|
|
|
// Convert to the format needed for documentation tag: kind:pubkey:identifier |
|
// If there's a relay hint, we can optionally add it, but the standard format is kind:pubkey:identifier |
|
const docFormat = `${kind}:${pubkey}:${identifier}`; |
|
|
|
// Update the documentation field with the converted format |
|
const newDocs = [...documentation]; |
|
newDocs[index] = docFormat; |
|
documentation = newDocs; |
|
|
|
// Also fetch the event to show some info |
|
const relaysToUse = relays && relays.length > 0 ? relays : getSearchRelays(); |
|
const client = new NostrClient(relaysToUse); |
|
|
|
const events = await client.fetchEvents([ |
|
{ |
|
kinds: [kind], |
|
authors: [pubkey], |
|
'#d': [identifier], |
|
limit: 1 |
|
} |
|
]); |
|
|
|
if (events.length > 0) { |
|
lookupResults[lookupKey] = events; |
|
lookupError[lookupKey] = null; |
|
} else { |
|
// Still update the field even if we can't fetch the event |
|
lookupError[lookupKey] = 'Documentation address converted, but event not found on relays'; |
|
} |
|
} catch (err) { |
|
lookupError[lookupKey] = `Lookup failed: ${String(err)}`; |
|
} finally { |
|
lookupLoading[lookupKey] = false; |
|
} |
|
} |
|
|
|
/** |
|
* Publish event with retry logic |
|
* Retries failed relays up to maxRetries times |
|
*/ |
|
async function publishWithRetry( |
|
client: NostrClient, |
|
event: NostrEvent, |
|
relays: string[], |
|
maxRetries: number = 2 |
|
): Promise<{ success: string[]; failed: Array<{ relay: string; error: string }> }> { |
|
let result = await client.publishEvent(event, relays); |
|
|
|
// If we have some successes, return early |
|
if (result.success.length > 0) { |
|
return result; |
|
} |
|
|
|
// If all failed and we have retries left, retry only the failed relays |
|
if (result.failed.length > 0 && maxRetries > 0) { |
|
console.log(`Retrying publish to ${result.failed.length} failed relay(s)...`); |
|
const failedRelays = result.failed.map((f: { relay: string; error: string }) => f.relay); |
|
|
|
// Wait a bit before retry |
|
await new Promise(resolve => setTimeout(resolve, 1000)); |
|
|
|
const retryResult = await client.publishEvent(event, failedRelays); |
|
|
|
// Merge results |
|
return { |
|
success: [...result.success, ...retryResult.success], |
|
failed: retryResult.failed |
|
}; |
|
} |
|
|
|
return result; |
|
} |
|
|
|
async function handleWebUrlHover(index: number, url: string) { |
|
// Clear any existing timeout |
|
if (previewTimeout) { |
|
clearTimeout(previewTimeout); |
|
} |
|
|
|
// Only preview if URL looks valid |
|
if (!url.trim() || !isValidUrl(url.trim())) { |
|
return; |
|
} |
|
|
|
// Delay preview to avoid showing on quick mouse movements |
|
previewTimeout = setTimeout(async () => { |
|
previewingUrlIndex = index; |
|
previewUrl = url.trim(); |
|
previewError = null; |
|
previewLoading = true; |
|
|
|
// Try to verify the URL exists by attempting to fetch it |
|
// Note: CORS may prevent this, but we'll still show the iframe preview |
|
try { |
|
const controller = new AbortController(); |
|
const timeoutId = setTimeout(() => controller.abort(), 3000); // 3 second timeout |
|
|
|
const response = await fetch(url.trim(), { |
|
method: 'HEAD', |
|
mode: 'no-cors', |
|
cache: 'no-cache', |
|
signal: controller.signal |
|
}); |
|
|
|
clearTimeout(timeoutId); |
|
// With no-cors mode, we can't read the status, but if it doesn't throw, proceed |
|
previewError = null; |
|
} catch (err) { |
|
// If fetch fails, it might be CORS, network error, or 404 |
|
// The iframe will show the actual error to the user |
|
if (err instanceof Error && err.name === 'AbortError') { |
|
previewError = 'Request timed out - URL may be slow or unreachable'; |
|
} else { |
|
previewError = 'Unable to verify URL - preview may show an error if URL is invalid'; |
|
} |
|
} finally { |
|
previewLoading = false; |
|
} |
|
}, 500); // 500ms delay before showing preview |
|
} |
|
|
|
function handleWebUrlLeave() { |
|
if (previewTimeout) { |
|
clearTimeout(previewTimeout); |
|
} |
|
previewingUrlIndex = null; |
|
previewUrl = null; |
|
previewError = null; |
|
previewLoading = false; |
|
} |
|
|
|
function isValidUrl(url: string): boolean { |
|
try { |
|
const urlObj = new URL(url); |
|
return urlObj.protocol === 'http:' || urlObj.protocol === 'https:'; |
|
} catch { |
|
return false; |
|
} |
|
} |
|
|
|
// Validation functions |
|
function validateCloneUrl(url: string): string | null { |
|
if (!url.trim()) return null; // Empty is OK |
|
const trimmed = url.trim(); |
|
|
|
// Allow Tor .onion URLs (they use http:// not https://) |
|
if (trimmed.includes('.onion')) { |
|
if (!trimmed.startsWith('http://')) { |
|
return 'Tor .onion URLs must use http:// (not https://)'; |
|
} |
|
// .onion URLs are valid if they contain a path (they don't need to end with .git) |
|
if (!trimmed.includes('/')) { |
|
return 'Tor .onion URL must include a path'; |
|
} |
|
return null; |
|
} |
|
|
|
// Validate regular URLs |
|
if (!isValidUrl(trimmed)) { |
|
return 'Invalid URL format. Must start with http:// or https://'; |
|
} |
|
|
|
// For regular URLs, check if it ends with .git or contains a path |
|
if (!trimmed.endsWith('.git') && !trimmed.includes('/')) { |
|
return 'Clone URL should end with .git or be a valid repository URL'; |
|
} |
|
|
|
return null; |
|
} |
|
|
|
function validateWebUrl(url: string): string | null { |
|
if (!url.trim()) return null; // Empty is OK |
|
if (!isValidUrl(url.trim())) { |
|
return 'Invalid URL format. Must start with http:// or https://'; |
|
} |
|
return null; |
|
} |
|
|
|
function validateMaintainer(maintainer: string): string | null { |
|
if (!maintainer.trim()) return null; // Empty is OK |
|
// Check if it's a valid npub or hex pubkey |
|
try { |
|
if (maintainer.startsWith('npub')) { |
|
nip19.decode(maintainer); |
|
return null; |
|
} else if (maintainer.length === 64 && /^[0-9a-f]+$/i.test(maintainer)) { |
|
return null; // Valid hex pubkey |
|
} else { |
|
return 'Invalid maintainer format. Use npub1... or 64-character hex pubkey'; |
|
} |
|
} catch { |
|
return 'Invalid maintainer format. Use npub1... or 64-character hex pubkey'; |
|
} |
|
} |
|
|
|
function validateDocumentation(doc: string): string | null { |
|
if (!doc.trim()) return null; // Empty is OK |
|
// Check if it's in naddr format or 30618:pubkey:identifier format |
|
if (doc.startsWith('naddr')) { |
|
try { |
|
const decoded = nip19.decode(doc); |
|
if (decoded.type === 'naddr') return null; |
|
} catch { |
|
return 'Invalid naddr format'; |
|
} |
|
} else if (/^\d+:[0-9a-f]{64}:[a-zA-Z0-9_-]+$/.test(doc)) { |
|
return null; // Valid kind:pubkey:identifier format |
|
} |
|
return 'Invalid documentation format. Use naddr1... or kind:pubkey:identifier'; |
|
} |
|
|
|
function validateImageUrl(url: string): string | null { |
|
if (!url.trim()) return null; // Empty is OK |
|
if (!isValidUrl(url.trim())) { |
|
return 'Invalid URL format. Must start with http:// or https://'; |
|
} |
|
// Check if it's likely an image URL |
|
const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg']; |
|
const lowerUrl = url.toLowerCase(); |
|
if (!imageExtensions.some(ext => lowerUrl.includes(ext)) && !lowerUrl.includes('image') && !lowerUrl.includes('img')) { |
|
return 'Warning: URL does not appear to be an image'; |
|
} |
|
return null; |
|
} |
|
|
|
// Lookup functions |
|
// Use only default search relays for lookups to avoid connecting to random/unreachable user relays |
|
function getSearchRelays(): string[] { |
|
return DEFAULT_NOSTR_SEARCH_RELAYS; |
|
} |
|
|
|
async function lookupRepoAnnouncement(query: string, fieldName: string) { |
|
const lookupKey = `repo-${fieldName}`; |
|
lookupLoading[lookupKey] = true; |
|
lookupError[lookupKey] = null; |
|
lookupResults[lookupKey] = null; |
|
|
|
try { |
|
const relays = await getSearchRelays(); |
|
const client = new NostrClient(relays); |
|
|
|
// Try to decode as naddr, nevent, or hex |
|
const decoded = decodeNostrAddress(query.trim()); |
|
let events: NostrEvent[] = []; |
|
|
|
if (decoded) { |
|
if (decoded.type === 'note' && decoded.id) { |
|
events = await client.fetchEvents([{ ids: [decoded.id], limit: 10 }]); |
|
} else if (decoded.type === 'nevent' && decoded.id) { |
|
events = await client.fetchEvents([{ ids: [decoded.id], limit: 10 }]); |
|
} else if (decoded.type === 'naddr' && decoded.pubkey && decoded.kind && decoded.identifier) { |
|
events = await client.fetchEvents([ |
|
{ |
|
kinds: [decoded.kind], |
|
authors: [decoded.pubkey], |
|
'#d': [decoded.identifier], |
|
limit: 10 |
|
} |
|
]); |
|
} |
|
} |
|
|
|
// Also search by name or d-tag if query doesn't look like an address |
|
if (events.length === 0 && !query.startsWith('naddr') && !query.startsWith('nevent') && !/^[0-9a-f]{64}$/i.test(query)) { |
|
const repoEvents = await client.fetchEvents([ |
|
{ |
|
kinds: [KIND.REPO_ANNOUNCEMENT], |
|
limit: 20 |
|
} |
|
]); |
|
|
|
const searchLower = query.toLowerCase(); |
|
let filteredEvents = repoEvents.filter(event => { |
|
const name = event.tags.find(t => t[0] === 'name')?.[1] || ''; |
|
const dTag = event.tags.find(t => t[0] === 'd')?.[1] || ''; |
|
return name.toLowerCase().includes(searchLower) || dTag.toLowerCase().includes(searchLower); |
|
}); |
|
|
|
// Filter out private repos unless user is authenticated and has access |
|
// For signup page, we'll only show public repos or repos the user owns |
|
let userPubkeyHex: string | null = null; |
|
if (nip07Available) { |
|
try { |
|
const userPubkey = await getPublicKeyWithNIP07(); |
|
try { |
|
const decoded = nip19.decode(userPubkey); |
|
if (decoded.type === 'npub') { |
|
userPubkeyHex = decoded.data as string; |
|
} else { |
|
userPubkeyHex = userPubkey; |
|
} |
|
} catch { |
|
userPubkeyHex = userPubkey; |
|
} |
|
} catch { |
|
// User not authenticated, continue with filtering |
|
} |
|
} |
|
|
|
// Filter repos by visibility |
|
const filteredPrivateEvents = await Promise.all( |
|
filteredEvents.map(async (event): Promise<NostrEvent | null> => { |
|
// Check visibility tag |
|
const visibilityTag = event.tags.find(t => t[0] === 'visibility' && t[1]); |
|
let repoVisibility: 'public' | 'unlisted' | 'restricted' | 'private' = 'public'; |
|
if (visibilityTag && visibilityTag[1]) { |
|
const vis = visibilityTag[1].toLowerCase(); |
|
if (['public', 'unlisted', 'restricted', 'private'].includes(vis)) { |
|
repoVisibility = vis as typeof repoVisibility; |
|
} |
|
} |
|
|
|
// Backward compatibility: check for old private tag |
|
if (!visibilityTag) { |
|
const isPrivate = event.tags.some(t => |
|
(t[0] === 'private' && t[1] === 'true') || |
|
(t[0] === 't' && t[1] === 'private') |
|
); |
|
if (isPrivate) repoVisibility = 'restricted'; |
|
} |
|
|
|
// Public and unlisted repos are always visible |
|
// Use array includes to avoid TypeScript narrowing issues |
|
if (['public', 'unlisted'].includes(repoVisibility)) return event; |
|
|
|
// Restricted and private repos: only show if user is owner |
|
if (userPubkeyHex && event.pubkey === userPubkeyHex) { |
|
return event; |
|
} |
|
|
|
// For other private repos, check access via API |
|
if (userPubkeyHex) { |
|
try { |
|
const dTag = event.tags.find(t => t[0] === 'd')?.[1]; |
|
if (!dTag) return null; |
|
|
|
// Get npub from pubkey |
|
const ownerNpub = nip19.npubEncode(event.pubkey); |
|
const accessResponse = await fetch(`/api/repos/${ownerNpub}/${dTag}/access`, { |
|
headers: { 'X-User-Pubkey': userPubkeyHex } |
|
}); |
|
|
|
if (accessResponse.ok) { |
|
const accessData = await accessResponse.json(); |
|
if (accessData.canView) { |
|
return event; |
|
} |
|
} |
|
} catch { |
|
// Access check failed, don't show |
|
} |
|
} |
|
|
|
return null; |
|
}) |
|
); |
|
events = filteredPrivateEvents.filter((e): e is NostrEvent => e !== null); |
|
} |
|
|
|
if (events.length === 0) { |
|
lookupError[lookupKey] = 'No repository announcements found'; |
|
} else { |
|
lookupResults[lookupKey] = events; |
|
} |
|
} catch (err) { |
|
lookupError[lookupKey] = `Lookup failed: ${String(err)}`; |
|
} finally { |
|
lookupLoading[lookupKey] = false; |
|
} |
|
} |
|
|
|
async function lookupNpub(query: string, fieldName: string, index?: number) { |
|
const lookupKey = index !== undefined ? `npub-${fieldName}-${index}` : `npub-${fieldName}`; |
|
lookupLoading[lookupKey] = true; |
|
lookupError[lookupKey] = null; |
|
lookupResults[lookupKey] = null; |
|
|
|
try { |
|
// Try to decode as npub |
|
let pubkey: string | null = null; |
|
try { |
|
if (query.startsWith('npub')) { |
|
const decoded = nip19.decode(query); |
|
if (decoded.type === 'npub') { |
|
pubkey = decoded.data as string; |
|
} |
|
} else if (query.length === 64 && /^[0-9a-f]+$/i.test(query)) { |
|
pubkey = query; |
|
} |
|
} catch { |
|
// Invalid format |
|
} |
|
|
|
if (!pubkey) { |
|
// Search for npubs by name (requires kind 0 metadata) |
|
const relays = getSearchRelays(); |
|
const client = new NostrClient(relays); |
|
|
|
// Search for profiles |
|
const profileEvents = await client.fetchEvents([ |
|
{ |
|
kinds: [0], // Metadata events |
|
limit: 20 |
|
} |
|
]); |
|
|
|
const searchLower = query.toLowerCase(); |
|
const matches = profileEvents.filter(event => { |
|
try { |
|
const content = JSON.parse(event.content); |
|
const name = content.name || content.display_name || ''; |
|
return name.toLowerCase().includes(searchLower); |
|
} catch { |
|
return false; |
|
} |
|
}); |
|
|
|
if (matches.length > 0) { |
|
lookupResults[lookupKey] = matches.map(e => { |
|
try { |
|
const content = JSON.parse(e.content); |
|
return { |
|
pubkey: e.pubkey, |
|
npub: nip19.npubEncode(e.pubkey), |
|
name: content.name || content.display_name || 'Unknown', |
|
about: content.about || '', |
|
picture: content.picture || '' |
|
}; |
|
} catch { |
|
return { |
|
pubkey: e.pubkey, |
|
npub: nip19.npubEncode(e.pubkey), |
|
name: 'Unknown' |
|
}; |
|
} |
|
}); |
|
} else { |
|
lookupError[lookupKey] = 'No profiles found matching the query'; |
|
} |
|
} else { |
|
// Valid pubkey, try to fetch profile |
|
const relays = getSearchRelays(); |
|
const client = new NostrClient(relays); |
|
const profileEvents = await client.fetchEvents([ |
|
{ |
|
kinds: [0], |
|
authors: [pubkey], |
|
limit: 1 |
|
} |
|
]); |
|
|
|
let profileData: { |
|
pubkey: string; |
|
npub: string; |
|
name?: string; |
|
about?: string; |
|
picture?: string; |
|
} = { |
|
pubkey, |
|
npub: query.startsWith('npub') ? query : nip19.npubEncode(pubkey) |
|
}; |
|
|
|
if (profileEvents.length > 0) { |
|
try { |
|
const content = JSON.parse(profileEvents[0].content); |
|
profileData.name = content.name || content.display_name || ''; |
|
profileData.about = content.about || ''; |
|
profileData.picture = content.picture || ''; |
|
} catch { |
|
// Invalid JSON, use defaults |
|
} |
|
} |
|
|
|
lookupResults[lookupKey] = [profileData]; |
|
} |
|
} catch (err) { |
|
lookupError[lookupKey] = `Lookup failed: ${String(err)}`; |
|
} finally { |
|
lookupLoading[lookupKey] = false; |
|
} |
|
} |
|
|
|
async function lookupNevent(query: string, fieldName: string) { |
|
const lookupKey = `nevent-${fieldName}`; |
|
lookupLoading[lookupKey] = true; |
|
lookupError[lookupKey] = null; |
|
lookupResults[lookupKey] = null; |
|
|
|
try { |
|
const relays = await getSearchRelays(); |
|
const client = new NostrClient(relays); |
|
|
|
let events: NostrEvent[] = []; |
|
const decoded = decodeNostrAddress(query.trim()); |
|
|
|
if (decoded && decoded.id) { |
|
events = await client.fetchEvents([{ ids: [decoded.id], limit: 10 }]); |
|
} else if (/^[0-9a-f]{64}$/i.test(query.trim())) { |
|
// Hex event ID |
|
events = await client.fetchEvents([{ ids: [query.trim()], limit: 10 }]); |
|
} |
|
|
|
if (events.length === 0) { |
|
lookupError[lookupKey] = 'No events found'; |
|
} else { |
|
lookupResults[lookupKey] = events; |
|
} |
|
} catch (err) { |
|
lookupError[lookupKey] = `Lookup failed: ${String(err)}`; |
|
} finally { |
|
lookupLoading[lookupKey] = false; |
|
} |
|
} |
|
|
|
function selectRepoResult(result: NostrEvent, fieldName: string) { |
|
if (fieldName === 'existingRepoRef') { |
|
existingRepoRef = result.id; |
|
loadExistingRepo(); |
|
} else if (fieldName === 'forkOriginalRepo') { |
|
// Convert to naddr format if possible |
|
const dTag = result.tags.find(t => t[0] === 'd')?.[1]; |
|
if (dTag) { |
|
try { |
|
const naddr = nip19.naddrEncode({ |
|
pubkey: result.pubkey, |
|
kind: result.kind, |
|
identifier: dTag, |
|
relays: [] |
|
}); |
|
forkOriginalRepo = naddr; |
|
} catch { |
|
forkOriginalRepo = `${result.kind}:${result.pubkey}:${dTag}`; |
|
} |
|
} |
|
} |
|
lookupResults[`repo-${fieldName}`] = null; |
|
} |
|
|
|
function selectNpubResult(result: { pubkey: string; npub: string; name?: string; about?: string; picture?: string }, fieldName: string, index?: number) { |
|
if (fieldName === 'maintainers' && index !== undefined) { |
|
// Store hex pubkey instead of npub for maintainers |
|
updateMaintainer(index, result.pubkey); |
|
} |
|
const lookupKey = index !== undefined ? `npub-${fieldName}-${index}` : `npub-${fieldName}`; |
|
lookupResults[lookupKey] = null; |
|
} |
|
|
|
function clearLookupResults(key: string) { |
|
lookupResults[key] = null; |
|
lookupError[key] = null; |
|
} |
|
|
|
async function loadExistingRepo() { |
|
if (!existingRepoRef.trim()) return; |
|
|
|
loadingExisting = true; |
|
error = null; |
|
|
|
try { |
|
const decoded = decodeNostrAddress(existingRepoRef.trim()); |
|
if (!decoded) { |
|
error = 'Invalid format. Please provide a hex event ID, nevent, or naddr.'; |
|
loadingExisting = false; |
|
return; |
|
} |
|
|
|
let event: NostrEvent | null = null; |
|
|
|
if (decoded.type === 'note' && decoded.id) { |
|
// Fetch by event ID |
|
const events = await nostrClient.fetchEvents([{ ids: [decoded.id], limit: 1 }]); |
|
event = events[0] || null; |
|
} else if (decoded.type === 'nevent' && decoded.id) { |
|
// Fetch by event ID |
|
const events = await nostrClient.fetchEvents([{ ids: [decoded.id], limit: 1 }]); |
|
event = events[0] || null; |
|
} else if (decoded.type === 'naddr' && decoded.pubkey && decoded.kind && decoded.identifier) { |
|
// Fetch parameterized replaceable event |
|
const events = await nostrClient.fetchEvents([ |
|
{ |
|
kinds: [decoded.kind], |
|
authors: [decoded.pubkey], |
|
'#d': [decoded.identifier], |
|
limit: 1 |
|
} |
|
]); |
|
event = events[0] || null; |
|
} |
|
|
|
if (!event) { |
|
error = 'Repository announcement not found. Make sure it exists on the relays.'; |
|
loadingExisting = false; |
|
return; |
|
} |
|
|
|
if (event.kind !== KIND.REPO_ANNOUNCEMENT) { |
|
error = `The provided event is not a repository announcement (kind ${KIND.REPO_ANNOUNCEMENT}).`; |
|
loadingExisting = false; |
|
return; |
|
} |
|
|
|
// Populate form with existing data |
|
const dTag = event.tags.find(t => t[0] === 'd')?.[1] || ''; |
|
const nameTag = event.tags.find(t => t[0] === 'name')?.[1] || ''; |
|
const descTag = event.tags.find(t => t[0] === 'description')?.[1] || ''; |
|
const imageTag = event.tags.find(t => t[0] === 'image')?.[1] || ''; |
|
const bannerTag = event.tags.find(t => t[0] === 'banner')?.[1] || ''; |
|
// Read visibility tag (defaults to 'public') |
|
const visibilityTag = event.tags.find(t => t[0] === 'visibility' && t[1]); |
|
if (visibilityTag && visibilityTag[1]) { |
|
const vis = visibilityTag[1].toLowerCase(); |
|
if (['public', 'unlisted', 'restricted', 'private'].includes(vis)) { |
|
visibility = vis as typeof visibility; |
|
} |
|
} |
|
|
|
// Read project-relay tags |
|
const projectRelayTags = event.tags.filter(t => t[0] === 'project-relay'); |
|
if (projectRelayTags.length > 0) { |
|
projectRelays = projectRelayTags.flatMap(t => t.slice(1)).filter(r => r && typeof r === 'string'); |
|
if (projectRelays.length === 0) projectRelays = ['']; |
|
} |
|
|
|
// Backward compatibility: check for old private tag |
|
const privateTag = event.tags.find(t => (t[0] === 'private' && t[1] === 'true') || (t[0] === 't' && t[1] === 'private')); |
|
if (privateTag && !visibilityTag) { |
|
visibility = 'restricted'; // Map old private to restricted |
|
} |
|
|
|
repoName = nameTag || dTag; |
|
description = descTag; |
|
imageUrl = imageTag; |
|
bannerUrl = bannerTag; |
|
|
|
// Extract clone URLs - handle both formats: separate tags and multiple values in one tag |
|
const urls: string[] = []; |
|
for (const tag of event.tags) { |
|
if (tag[0] === 'clone') { |
|
for (let i = 1; i < tag.length; i++) { |
|
const url = tag[i]; |
|
if (url && typeof url === 'string' && url.trim()) { |
|
urls.push(url.trim()); |
|
} |
|
} |
|
} |
|
} |
|
cloneUrls = urls.length > 0 ? urls : ['']; |
|
|
|
// Extract web URLs - handle both formats |
|
const webUrlsList: string[] = []; |
|
for (const tag of event.tags) { |
|
if (tag[0] === 'web') { |
|
for (let i = 1; i < tag.length; i++) { |
|
const url = tag[i]; |
|
if (url && typeof url === 'string' && url.trim()) { |
|
webUrlsList.push(url.trim()); |
|
} |
|
} |
|
} |
|
} |
|
webUrls = webUrlsList.length > 0 ? webUrlsList : ['']; |
|
|
|
// Extract maintainers - handle both formats |
|
const maintainersList: string[] = []; |
|
for (const tag of event.tags) { |
|
if (tag[0] === 'maintainers') { |
|
for (let i = 1; i < tag.length; i++) { |
|
const maintainer = tag[i]; |
|
if (maintainer && typeof maintainer === 'string' && maintainer.trim()) { |
|
maintainersList.push(maintainer.trim()); |
|
} |
|
} |
|
} |
|
} |
|
maintainers = maintainersList.length > 0 ? maintainersList : ['']; |
|
|
|
// Extract relays |
|
const relaysList: string[] = []; |
|
for (const tag of event.tags) { |
|
if (tag[0] === 'relays') { |
|
for (let i = 1; i < tag.length; i++) { |
|
const relay = tag[i]; |
|
if (relay && typeof relay === 'string' && relay.trim()) { |
|
relaysList.push(relay.trim()); |
|
} |
|
} |
|
} |
|
} |
|
relays = relaysList.length > 0 ? relaysList : ['']; |
|
|
|
// Extract blossoms |
|
const blossomsList: string[] = []; |
|
for (const tag of event.tags) { |
|
if (tag[0] === 'blossoms') { |
|
for (let i = 1; i < tag.length; i++) { |
|
const blossom = tag[i]; |
|
if (blossom && typeof blossom === 'string' && blossom.trim()) { |
|
blossomsList.push(blossom.trim()); |
|
} |
|
} |
|
} |
|
} |
|
blossoms = blossomsList.length > 0 ? blossomsList : ['']; |
|
|
|
// Extract tags/labels |
|
const tagsList: string[] = []; |
|
for (const tag of event.tags) { |
|
if (tag[0] === 't' && tag[1] && tag[1] !== 'private' && tag[1] !== 'fork') { |
|
tagsList.push(tag[1]); |
|
} |
|
} |
|
tags = tagsList.length > 0 ? tagsList : ['']; |
|
|
|
// Extract documentation - handle relay hints correctly |
|
// Only treat values as multiple entries if they are in the same format |
|
// If a value looks like a relay URL (wss:// or ws://), it's a relay hint for the previous value |
|
const docsList: string[] = []; |
|
const isRelayUrl = (value: string): boolean => { |
|
return typeof value === 'string' && (value.startsWith('wss://') || value.startsWith('ws://')); |
|
}; |
|
|
|
const getDocFormat = (value: string): string | null => { |
|
// Check if it's naddr format (starts with naddr1) |
|
if (value.startsWith('naddr1')) return 'naddr'; |
|
// Check if it's kind:pubkey:identifier format |
|
if (/^\d+:[0-9a-f]{64}:[a-zA-Z0-9_-]+$/.test(value)) return 'kind:pubkey:identifier'; |
|
return null; |
|
}; |
|
|
|
for (const tag of event.tags) { |
|
if (tag[0] === 'documentation') { |
|
let i = 1; |
|
|
|
while (i < tag.length) { |
|
const value = tag[i]; |
|
if (!value || typeof value !== 'string' || !value.trim()) { |
|
i++; |
|
continue; |
|
} |
|
|
|
const trimmed = value.trim(); |
|
|
|
// Skip relay URLs (they're hints, not entries) |
|
if (isRelayUrl(trimmed)) { |
|
i++; |
|
continue; |
|
} |
|
|
|
// Check if this is a documentation reference |
|
const format = getDocFormat(trimmed); |
|
if (!format) { |
|
i++; |
|
continue; // Skip invalid formats |
|
} |
|
|
|
// Check if next value is a relay URL (hint for this entry) |
|
const nextValue = i + 1 < tag.length ? tag[i + 1] : null; |
|
if (nextValue && typeof nextValue === 'string' && isRelayUrl(nextValue.trim())) { |
|
// Current value has a relay hint - store just the doc reference, skip the relay |
|
docsList.push(trimmed); |
|
i += 2; // Skip both the doc and the relay hint |
|
continue; |
|
} |
|
|
|
// Check if we have multiple entries in the same format |
|
// Collect all consecutive entries of the same format |
|
const sameFormatEntries: string[] = [trimmed]; |
|
let j = i + 1; |
|
while (j < tag.length) { |
|
const nextVal = tag[j]; |
|
if (!nextVal || typeof nextVal !== 'string' || !nextVal.trim()) { |
|
j++; |
|
continue; |
|
} |
|
|
|
const nextTrimmed = nextVal.trim(); |
|
|
|
// Stop if we hit a relay URL (it's a hint for the previous entry) |
|
if (isRelayUrl(nextTrimmed)) { |
|
break; |
|
} |
|
|
|
// Check if it's the same format |
|
const nextFormat = getDocFormat(nextTrimmed); |
|
if (nextFormat === format) { |
|
sameFormatEntries.push(nextTrimmed); |
|
j++; |
|
} else { |
|
// Different format - stop collecting |
|
break; |
|
} |
|
} |
|
|
|
// If we have multiple entries in the same format, add them all |
|
// Otherwise, just add the single entry |
|
docsList.push(...sameFormatEntries); |
|
i = j; // Move to the next unprocessed value |
|
} |
|
} |
|
} |
|
documentation = docsList.length > 0 ? docsList : ['']; |
|
|
|
// Extract alt tag |
|
const altTag = event.tags.find(t => t[0] === 'alt'); |
|
alt = altTag?.[1] || ''; |
|
|
|
// Extract fork information |
|
const aTag = event.tags.find(t => t[0] === 'a' && t[1]?.startsWith('30617:')); |
|
if (aTag?.[1]) { |
|
forkOriginalRepo = aTag[1]; |
|
isFork = true; |
|
} else { |
|
// Check if marked as fork via tag |
|
isFork = event.tags.some(t => t[0] === 't' && t[1] === 'fork'); |
|
if (isFork) { |
|
// Try to construct from p tag if available |
|
const pTag = event.tags.find(t => t[0] === 'p' && t[1] && t[1] !== event.pubkey); |
|
if (pTag?.[1] && dTag) { |
|
// Construct a tag format: 30617:owner:repo |
|
forkOriginalRepo = `${KIND.REPO_ANNOUNCEMENT}:${pTag[1]}:${dTag}`; |
|
} |
|
} |
|
} |
|
|
|
// Extract earliest unique commit |
|
const rTag = event.tags.find(t => t[0] === 'r' && t[2] === 'euc'); |
|
earliestCommit = rTag?.[1] || ''; |
|
|
|
// Check if client tag exists |
|
addClientTag = !event.tags.some(t => t[0] === 'client' && t[1] === 'gitrepublic-web'); |
|
|
|
} catch (e) { |
|
error = `Failed to load repository: ${String(e)}`; |
|
} finally { |
|
loadingExisting = false; |
|
} |
|
} |
|
|
|
async function submit() { |
|
if (!nip07Available) { |
|
error = 'NIP-07 extension is required. Please install a Nostr browser extension.'; |
|
return; |
|
} |
|
|
|
if (!repoName.trim()) { |
|
error = 'Repository name is required.'; |
|
return; |
|
} |
|
|
|
// Validate repo name format (alphanumeric, hyphens, underscores, spaces - will be normalized to d-tag) |
|
const repoNameTrimmed = repoName.trim(); |
|
if (repoNameTrimmed.length === 0) { |
|
error = 'Repository name cannot be empty.'; |
|
return; |
|
} |
|
if (repoNameTrimmed.length > 100) { |
|
error = 'Repository name is too long (maximum 100 characters).'; |
|
return; |
|
} |
|
// Check for invalid characters that can't be normalized |
|
if (!/^[\w\s-]+$/.test(repoNameTrimmed)) { |
|
error = 'Repository name contains invalid characters. Use only letters, numbers, spaces, hyphens, and underscores.'; |
|
return; |
|
} |
|
|
|
// Validate all fields |
|
const validationErrors: string[] = []; |
|
|
|
// Validate clone URLs |
|
for (let i = 0; i < cloneUrls.length; i++) { |
|
const urlError = validateCloneUrl(cloneUrls[i]); |
|
if (urlError) { |
|
validationErrors.push(`Clone URL ${i + 1}: ${urlError}`); |
|
} |
|
} |
|
|
|
// Validate web URLs |
|
for (let i = 0; i < webUrls.length; i++) { |
|
const urlError = validateWebUrl(webUrls[i]); |
|
if (urlError) { |
|
validationErrors.push(`Web URL ${i + 1}: ${urlError}`); |
|
} |
|
} |
|
|
|
// Validate maintainers |
|
for (let i = 0; i < maintainers.length; i++) { |
|
const maintainerError = validateMaintainer(maintainers[i]); |
|
if (maintainerError) { |
|
validationErrors.push(`Maintainer ${i + 1}: ${maintainerError}`); |
|
} |
|
} |
|
|
|
// Validate documentation |
|
for (let i = 0; i < documentation.length; i++) { |
|
const docError = validateDocumentation(documentation[i]); |
|
if (docError) { |
|
validationErrors.push(`Documentation ${i + 1}: ${docError}`); |
|
} |
|
} |
|
|
|
// Validate image URLs |
|
if (imageUrl.trim()) { |
|
const imageError = validateImageUrl(imageUrl); |
|
if (imageError) { |
|
validationErrors.push(`Image URL: ${imageError}`); |
|
} |
|
} |
|
|
|
if (bannerUrl.trim()) { |
|
const bannerError = validateImageUrl(bannerUrl); |
|
if (bannerError) { |
|
validationErrors.push(`Banner URL: ${bannerError}`); |
|
} |
|
} |
|
|
|
if (validationErrors.length > 0) { |
|
error = 'Validation errors:\n' + validationErrors.join('\n'); |
|
return; |
|
} |
|
|
|
loading = true; |
|
error = null; |
|
|
|
try { |
|
const pubkey = await getPublicKeyWithNIP07(); |
|
const npub = nip19.npubEncode(pubkey); |
|
|
|
// Normalize repo name to d-tag format |
|
const dTag = repoName |
|
.toLowerCase() |
|
.trim() |
|
.replace(/[^\w\s-]/g, '') |
|
.replace(/\s+/g, '-') |
|
.replace(/-+/g, '-') |
|
.replace(/^-+|-+$/g, ''); |
|
|
|
// Get git domain from layout data |
|
const gitDomain = $page.data.gitDomain || 'localhost:6543'; |
|
const isLocalhost = gitDomain.startsWith('localhost') || gitDomain.startsWith('127.0.0.1'); |
|
const protocol = isLocalhost ? 'http' : 'https'; |
|
const gitUrl = `${protocol}://${gitDomain}/${npub}/${dTag}.git`; |
|
|
|
// Try to get Tor .onion address and add it to clone URLs |
|
let torOnionUrl: string | null = null; |
|
try { |
|
const torResponse = await fetch('/api/tor/onion'); |
|
if (torResponse.ok) { |
|
const torData = await torResponse.json(); |
|
if (torData.available && torData.onion) { |
|
torOnionUrl = `http://${torData.onion}/${npub}/${dTag}.git`; |
|
} |
|
} |
|
} catch { |
|
// Tor not available, continue without it |
|
} |
|
|
|
// Filter user-provided clone URLs (exclude localhost and .onion duplicates) |
|
const userCloneUrls = cloneUrls.filter(url => { |
|
const trimmed = url.trim(); |
|
if (!trimmed) return false; |
|
// Exclude if it's our domain or already a .onion |
|
if (trimmed.includes(gitDomain)) return false; |
|
if (trimmed.includes('.onion')) return false; |
|
return true; |
|
}); |
|
|
|
// Validate: If using localhost, require either Tor .onion URL or at least one other clone URL |
|
if (isLocalhost && !torOnionUrl && userCloneUrls.length === 0) { |
|
error = 'Cannot publish with only localhost. You need either:\n' + |
|
'• A Tor .onion address (configure Tor hidden service and set TOR_ONION_ADDRESS)\n' + |
|
'• At least one other clone URL (e.g., GitHub, GitLab, or another GitRepublic instance)'; |
|
loading = false; |
|
return; |
|
} |
|
|
|
// ============================================ |
|
// COMPREHENSIVE VALIDATION, NORMALIZATION, AND DEDUPLICATION |
|
// ============================================ |
|
|
|
// Normalize and deduplicate clone URLs |
|
const normalizedCloneUrls: string[] = []; |
|
const seenCloneUrls = new Set<string>(); |
|
|
|
// Add our domain URL only if it's NOT localhost (explicitly check the URL) |
|
if (!isLocalhost && !gitUrl.includes('localhost') && !gitUrl.includes('127.0.0.1')) { |
|
const normalized = gitUrl.trim().toLowerCase(); |
|
if (!seenCloneUrls.has(normalized)) { |
|
normalizedCloneUrls.push(gitUrl); // Keep original case for display |
|
seenCloneUrls.add(normalized); |
|
} |
|
} |
|
|
|
// Add Tor .onion URL if available (skip validation - it's system-generated and already valid) |
|
if (torOnionUrl) { |
|
const normalized = torOnionUrl.trim().toLowerCase(); |
|
if (!seenCloneUrls.has(normalized)) { |
|
normalizedCloneUrls.push(torOnionUrl); |
|
seenCloneUrls.add(normalized); |
|
} |
|
} |
|
|
|
// Add and deduplicate user-provided clone URLs |
|
for (const url of userCloneUrls) { |
|
const trimmed = url.trim(); |
|
if (!trimmed) continue; |
|
|
|
// Skip localhost URLs in user input (they should have been filtered, but double-check) |
|
if (trimmed.includes('localhost') || trimmed.includes('127.0.0.1')) { |
|
continue; |
|
} |
|
|
|
// Normalize for comparison (lowercase, remove trailing slashes) |
|
// For .onion URLs, be careful with normalization to preserve the .onion domain |
|
const normalized = trimmed.toLowerCase().replace(/\/+$/, ''); |
|
if (!seenCloneUrls.has(normalized)) { |
|
// Validate format |
|
const urlError = validateCloneUrl(trimmed); |
|
if (urlError) { |
|
error = `Invalid clone URL: ${trimmed}\n${urlError}`; |
|
loading = false; |
|
return; |
|
} |
|
normalizedCloneUrls.push(trimmed); |
|
seenCloneUrls.add(normalized); |
|
} |
|
} |
|
|
|
// Final validation: Ensure we have at least one clone URL |
|
if (normalizedCloneUrls.length === 0) { |
|
error = 'At least one clone URL is required.'; |
|
loading = false; |
|
return; |
|
} |
|
|
|
// Final validation for localhost: If we only have localhost URLs, that's an error |
|
const hasNonLocalhost = normalizedCloneUrls.some(url => |
|
!url.includes('localhost') && !url.includes('127.0.0.1') |
|
); |
|
if (isLocalhost && !hasNonLocalhost && !torOnionUrl) { |
|
error = 'Cannot publish with only localhost URLs. You need either:\n' + |
|
'• A Tor .onion address (configure Tor hidden service and set TOR_ONION_ADDRESS)\n' + |
|
'• At least one other public clone URL (e.g., GitHub, GitLab, or another GitRepublic instance)'; |
|
loading = false; |
|
return; |
|
} |
|
|
|
// Normalize and deduplicate web URLs |
|
const normalizedWebUrls: string[] = []; |
|
const seenWebUrls = new Set<string>(); |
|
for (const url of webUrls) { |
|
const trimmed = url.trim(); |
|
if (!trimmed) continue; |
|
|
|
// Normalize for comparison |
|
const normalized = trimmed.toLowerCase().replace(/\/+$/, ''); |
|
if (!seenWebUrls.has(normalized)) { |
|
// Validate format |
|
const urlError = validateWebUrl(trimmed); |
|
if (urlError) { |
|
error = `Invalid web URL: ${trimmed}\n${urlError}`; |
|
loading = false; |
|
return; |
|
} |
|
normalizedWebUrls.push(trimmed); |
|
seenWebUrls.add(normalized); |
|
} |
|
} |
|
|
|
// Normalize, convert, and deduplicate maintainers (convert npubs to hex pubkeys) |
|
const normalizedMaintainers: string[] = []; |
|
const seenMaintainers = new Set<string>(); |
|
for (const maintainer of maintainers) { |
|
const trimmed = maintainer.trim(); |
|
if (!trimmed) continue; |
|
|
|
let hexPubkey: string; |
|
// Convert npub to hex if needed |
|
if (trimmed.startsWith('npub')) { |
|
try { |
|
const decoded = nip19.decode(trimmed); |
|
if (decoded.type === 'npub') { |
|
hexPubkey = decoded.data as string; |
|
} else { |
|
error = `Invalid maintainer format: ${trimmed}`; |
|
loading = false; |
|
return; |
|
} |
|
} catch { |
|
error = `Invalid maintainer npub format: ${trimmed}`; |
|
loading = false; |
|
return; |
|
} |
|
} else if (trimmed.length === 64 && /^[0-9a-f]+$/i.test(trimmed)) { |
|
hexPubkey = trimmed.toLowerCase(); |
|
} else { |
|
error = `Invalid maintainer format: ${trimmed}. Must be npub1... or 64-character hex pubkey`; |
|
loading = false; |
|
return; |
|
} |
|
|
|
// Deduplicate by hex pubkey |
|
if (!seenMaintainers.has(hexPubkey)) { |
|
normalizedMaintainers.push(hexPubkey); |
|
seenMaintainers.add(hexPubkey); |
|
} |
|
} |
|
|
|
// Normalize and deduplicate relays |
|
const normalizedRelays: string[] = []; |
|
const seenRelays = new Set<string>(); |
|
|
|
// Add user relays first |
|
for (const relay of relays) { |
|
const trimmed = relay.trim().toLowerCase(); |
|
if (!trimmed) continue; |
|
|
|
// Validate relay URL format |
|
if (!trimmed.startsWith('ws://') && !trimmed.startsWith('wss://')) { |
|
error = `Invalid relay URL format: ${relay}. Must start with ws:// or wss://`; |
|
loading = false; |
|
return; |
|
} |
|
|
|
if (!seenRelays.has(trimmed)) { |
|
normalizedRelays.push(relay.trim()); // Keep original case |
|
seenRelays.add(trimmed); |
|
} |
|
} |
|
|
|
// Add default relays that aren't already included |
|
for (const defaultRelay of DEFAULT_NOSTR_RELAYS) { |
|
const normalized = defaultRelay.toLowerCase(); |
|
if (!seenRelays.has(normalized)) { |
|
normalizedRelays.push(defaultRelay); |
|
seenRelays.add(normalized); |
|
} |
|
} |
|
|
|
// Normalize and deduplicate blossoms |
|
const normalizedBlossoms: string[] = []; |
|
const seenBlossoms = new Set<string>(); |
|
for (const blossom of blossoms) { |
|
const trimmed = blossom.trim(); |
|
if (!trimmed) continue; |
|
|
|
// Validate blossom format (should be a URL or identifier) |
|
if (!isValidUrl(trimmed) && !/^[a-zA-Z0-9_-]+$/.test(trimmed)) { |
|
error = `Invalid blossom format: ${trimmed}. Must be a valid URL or identifier`; |
|
loading = false; |
|
return; |
|
} |
|
|
|
const normalized = trimmed.toLowerCase(); |
|
if (!seenBlossoms.has(normalized)) { |
|
normalizedBlossoms.push(trimmed); // Keep original case |
|
seenBlossoms.add(normalized); |
|
} |
|
} |
|
|
|
// Normalize and deduplicate documentation |
|
const normalizedDocumentation: string[] = []; |
|
const seenDocumentation = new Set<string>(); |
|
for (const doc of documentation) { |
|
const trimmed = doc.trim(); |
|
if (!trimmed) continue; |
|
|
|
// Validate format |
|
const docError = validateDocumentation(trimmed); |
|
if (docError) { |
|
error = `Invalid documentation format: ${trimmed}\n${docError}`; |
|
loading = false; |
|
return; |
|
} |
|
|
|
// Normalize for comparison |
|
const normalized = trimmed.toLowerCase(); |
|
if (!seenDocumentation.has(normalized)) { |
|
normalizedDocumentation.push(trimmed); // Keep original case |
|
seenDocumentation.add(normalized); |
|
} |
|
} |
|
|
|
// Normalize and deduplicate tags/labels (excluding 'private' and 'fork') |
|
const normalizedTags: string[] = []; |
|
const seenTags = new Set<string>(); |
|
for (const tag of tags) { |
|
const trimmed = tag.trim().toLowerCase(); |
|
if (!trimmed) continue; |
|
if (trimmed === 'private' || trimmed === 'fork') continue; // Handled separately |
|
|
|
// Validate tag format (alphanumeric, hyphens, underscores) |
|
if (!/^[a-z0-9_-]+$/.test(trimmed)) { |
|
error = `Invalid tag format: ${tag}. Tags can only contain lowercase letters, numbers, hyphens, and underscores`; |
|
loading = false; |
|
return; |
|
} |
|
|
|
if (!seenTags.has(trimmed)) { |
|
normalizedTags.push(trimmed); |
|
seenTags.add(trimmed); |
|
} |
|
} |
|
|
|
// Normalize description, alt, and other text fields |
|
const normalizedDescription = description.trim(); |
|
const normalizedAlt = alt.trim(); |
|
const normalizedImageUrl = imageUrl.trim(); |
|
const normalizedBannerUrl = bannerUrl.trim(); |
|
const normalizedEarliestCommit = earliestCommit.trim(); |
|
|
|
// Validate description length |
|
if (normalizedDescription.length > 1000) { |
|
error = 'Description is too long (maximum 1000 characters).'; |
|
loading = false; |
|
return; |
|
} |
|
|
|
// Validate alt text length |
|
if (normalizedAlt.length > 500) { |
|
error = 'Alt text is too long (maximum 500 characters).'; |
|
loading = false; |
|
return; |
|
} |
|
|
|
// Validate image URLs if provided |
|
if (normalizedImageUrl) { |
|
const imageError = validateImageUrl(normalizedImageUrl); |
|
if (imageError) { |
|
error = `Invalid image URL: ${imageError}`; |
|
loading = false; |
|
return; |
|
} |
|
} |
|
|
|
if (normalizedBannerUrl) { |
|
const bannerError = validateImageUrl(normalizedBannerUrl); |
|
if (bannerError) { |
|
error = `Invalid banner URL: ${bannerError}`; |
|
loading = false; |
|
return; |
|
} |
|
} |
|
|
|
// Validate earliest commit format if provided |
|
if (normalizedEarliestCommit && !/^[0-9a-f]{40}$/i.test(normalizedEarliestCommit)) { |
|
error = `Invalid earliest commit format: ${normalizedEarliestCommit}. Must be a 40-character hex SHA-1 hash`; |
|
loading = false; |
|
return; |
|
} |
|
|
|
// Use normalized and deduplicated data |
|
const allCloneUrls = normalizedCloneUrls; |
|
const allWebUrls = normalizedWebUrls; |
|
const allMaintainers = normalizedMaintainers; |
|
const allRelays = normalizedRelays; |
|
const allBlossoms = normalizedBlossoms; |
|
const allDocumentation = normalizedDocumentation; |
|
const allTags = normalizedTags; |
|
|
|
// Build event tags - use single tag with multiple values (NIP-34 format) |
|
// All data has been normalized, deduplicated, and validated above |
|
const eventTags: string[][] = [ |
|
['d', dTag], |
|
['name', repoName.trim()], |
|
...(normalizedDescription ? [['description', normalizedDescription]] : []), |
|
...(allCloneUrls.length > 0 ? [['clone', ...allCloneUrls]] : []), // Single tag with all clone URLs |
|
...(allWebUrls.length > 0 ? [['web', ...allWebUrls]] : []), // Single tag with all web URLs |
|
...(allMaintainers.length > 0 ? [['maintainers', ...allMaintainers]] : []), // Single tag with all maintainers (hex pubkeys) |
|
...(allRelays.length > 0 ? [['relays', ...allRelays]] : []), // Single tag with all relays |
|
...(allBlossoms.length > 0 ? [['blossoms', ...allBlossoms]] : []), // Single tag with all blossoms |
|
...allDocumentation.map(d => ['documentation', d]), // Documentation can have relay hints, so keep separate |
|
...allTags.map(t => ['t', t]), |
|
...(normalizedImageUrl ? [['image', normalizedImageUrl]] : []), |
|
...(normalizedBannerUrl ? [['banner', normalizedBannerUrl]] : []), |
|
...(normalizedAlt ? [['alt', normalizedAlt]] : []), |
|
...(normalizedEarliestCommit ? [['r', normalizedEarliestCommit, 'euc']] : []) |
|
]; |
|
|
|
// Add fork tags if this is a fork |
|
if (isFork && forkOriginalRepo.trim()) { |
|
let forkAddress = forkOriginalRepo.trim(); |
|
let forkOwnerPubkey: string | null = null; |
|
let isValidFormat = false; |
|
|
|
// Parse the fork identifier - could be: |
|
// 1. naddr format (decode to get pubkey and repo) |
|
// 2. npub/repo format (need to construct a tag) |
|
// 3. Already in 30617:owner:repo format |
|
if (forkAddress.startsWith('naddr')) { |
|
try { |
|
const decoded = nip19.decode(forkAddress); |
|
if (decoded.type === 'naddr') { |
|
const data = decoded.data as { pubkey: string; kind: number; identifier: string }; |
|
if (data.pubkey && data.identifier) { |
|
forkAddress = `${KIND.REPO_ANNOUNCEMENT}:${data.pubkey}:${data.identifier}`; |
|
forkOwnerPubkey = data.pubkey; |
|
isValidFormat = true; |
|
} |
|
} |
|
} catch { |
|
// Invalid naddr, will be caught by validation below |
|
} |
|
} else if (forkAddress.includes('/') && !forkAddress.startsWith('30617:')) { |
|
// Assume npub/repo format |
|
const parts = forkAddress.split('/'); |
|
if (parts.length === 2 && parts[1].trim()) { |
|
try { |
|
const decoded = nip19.decode(parts[0]); |
|
if (decoded.type === 'npub') { |
|
forkOwnerPubkey = decoded.data as string; |
|
forkAddress = `${KIND.REPO_ANNOUNCEMENT}:${forkOwnerPubkey}:${parts[1].trim()}`; |
|
isValidFormat = true; |
|
} |
|
} catch { |
|
// Invalid npub, try as hex pubkey |
|
if (parts[0].length === 64 && /^[0-9a-f]+$/i.test(parts[0])) { |
|
forkOwnerPubkey = parts[0]; |
|
forkAddress = `${KIND.REPO_ANNOUNCEMENT}:${forkOwnerPubkey}:${parts[1].trim()}`; |
|
isValidFormat = true; |
|
} |
|
} |
|
} |
|
} else if (forkAddress.startsWith('30617:')) { |
|
// Already in correct format, validate and extract owner pubkey |
|
const parts = forkAddress.split(':'); |
|
if (parts.length >= 3 && parts[1] && parts[2]) { |
|
forkOwnerPubkey = parts[1]; |
|
isValidFormat = true; |
|
} |
|
} |
|
|
|
// Validate the final format: must be 30617:pubkey:repo |
|
// Always validate regardless of parsing success to catch any edge cases |
|
const parts = forkAddress.split(':'); |
|
if (parts.length >= 3) { |
|
const kind = parts[0]; |
|
const pubkey = parts[1]; |
|
const repo = parts[2]; |
|
|
|
// Validate format |
|
if (kind !== String(KIND.REPO_ANNOUNCEMENT)) { |
|
isValidFormat = false; |
|
} else if (!pubkey || pubkey.length !== 64 || !/^[0-9a-f]+$/i.test(pubkey)) { |
|
isValidFormat = false; |
|
} else if (!repo || !repo.trim()) { |
|
isValidFormat = false; |
|
} else { |
|
// Format is valid, ensure isValidFormat is true |
|
isValidFormat = true; |
|
} |
|
} else { |
|
isValidFormat = false; |
|
} |
|
|
|
if (!isValidFormat) { |
|
error = 'Invalid fork repository format. Please use one of:\n' + |
|
'• naddr format: naddr1...\n' + |
|
'• npub/repo format: npub1abc.../repo-name\n' + |
|
'• Repository address: 30617:owner-pubkey:repo-name'; |
|
loading = false; |
|
return; |
|
} |
|
|
|
// Add a tag (required for fork identification) |
|
eventTags.push(['a', forkAddress]); |
|
|
|
// Add p tag if we have the owner pubkey |
|
if (forkOwnerPubkey) { |
|
eventTags.push(['p', forkOwnerPubkey]); |
|
} |
|
|
|
// Add 'fork' tag if not already in tags |
|
if (!allTags.includes('fork')) { |
|
eventTags.push(['t', 'fork']); |
|
} |
|
} |
|
|
|
// Add visibility tag |
|
if (visibility !== 'public') { |
|
eventTags.push(['visibility', visibility]); |
|
} |
|
|
|
// Add project-relay tags (required for unlisted/restricted, optional for others) |
|
const normalizedProjectRelays = projectRelays |
|
.map(r => r.trim()) |
|
.filter(r => r && (r.startsWith('ws://') || r.startsWith('wss://'))); |
|
|
|
for (const relay of normalizedProjectRelays) { |
|
eventTags.push(['project-relay', relay]); |
|
} |
|
|
|
// Warn if unlisted/restricted but no project-relay |
|
if ((visibility === 'unlisted' || visibility === 'restricted') && normalizedProjectRelays.length === 0) { |
|
error = 'Project relay is required for unlisted and restricted repositories. Please add at least one project-relay.'; |
|
loading = false; |
|
return; |
|
} |
|
|
|
// Remove any existing client tags (from other clients) and ensure only our client tag exists |
|
// Filter out any client tags |
|
const filteredEventTags = eventTags.filter(tag => tag[0] !== 'client'); |
|
|
|
// Add our client tag if enabled (ensuring only one client tag exists) |
|
if (addClientTag) { |
|
filteredEventTags.push(['client', 'gitrepublic-web']); |
|
} |
|
|
|
// Replace eventTags with filtered version |
|
eventTags.length = 0; |
|
eventTags.push(...filteredEventTags); |
|
|
|
// We'll generate the announcement file content after signing (it's just the full event JSON) |
|
|
|
// Build event |
|
const eventTemplate: Omit<NostrEvent, 'sig' | 'id'> = { |
|
kind: KIND.REPO_ANNOUNCEMENT, |
|
pubkey, |
|
created_at: Math.floor(Date.now() / 1000), |
|
content: '', // Empty per NIP-34 |
|
tags: eventTags |
|
}; |
|
|
|
// Sign with NIP-07 |
|
console.log('Signing repository announcement event...'); |
|
const signedEvent = await signEventWithNIP07(eventTemplate); |
|
console.log('Event signed successfully, event ID:', signedEvent.id); |
|
|
|
// Generate announcement file content (just the full signed event JSON) |
|
// The server will commit this to prove ownership - no need for a separate verification file event |
|
const { generateVerificationFile } = await import('../../lib/services/nostr/repo-verification.js'); |
|
const announcementFileContent = generateVerificationFile(signedEvent, pubkey); |
|
|
|
// Create a signed event (kind 1642) with the announcement file content |
|
// This allows the server to fetch and commit the client-signed announcement |
|
const announcementFileEventTemplate: Omit<NostrEvent, 'sig' | 'id'> = { |
|
kind: 1642, // Custom kind for announcement file |
|
pubkey, |
|
created_at: Math.floor(Date.now() / 1000), |
|
content: announcementFileContent, |
|
tags: [ |
|
['e', signedEvent.id, '', 'announcement'], |
|
['d', dTag] |
|
] |
|
}; |
|
|
|
const signedAnnouncementFileEvent = await signEventWithNIP07(announcementFileEventTemplate); |
|
|
|
// Get user's inbox/outbox relays (from kind 10002) using comprehensive relay set |
|
console.log('Fetching user relays from comprehensive relay set...'); |
|
const { getUserRelays } = await import('../../lib/services/nostr/user-relays.js'); |
|
|
|
// Use comprehensive relay set including ALL search relays and default relays |
|
// This ensures we can find the user's kind 10002 event even if it's on a less common relay |
|
const allSearchRelays = [...new Set([...DEFAULT_NOSTR_SEARCH_RELAYS, ...DEFAULT_NOSTR_RELAYS])]; |
|
console.log('Querying kind 10002 from relays:', allSearchRelays); |
|
const fullRelayClient = new NostrClient(allSearchRelays); |
|
|
|
let userRelays: string[] = []; |
|
try { |
|
const { inbox, outbox } = await getUserRelays(pubkey, fullRelayClient); |
|
console.log('User relays fetched - inbox:', inbox.length, 'outbox:', outbox.length); |
|
if (inbox.length > 0) console.log('Inbox relays:', inbox); |
|
if (outbox.length > 0) console.log('Outbox relays:', outbox); |
|
|
|
// If we found user relays, use them; otherwise fall back to defaults |
|
if (outbox.length > 0) { |
|
// Use user's outbox relays (these are the relays the user prefers for publishing) |
|
// Combine with defaults as fallback |
|
userRelays = combineRelays(outbox, DEFAULT_NOSTR_RELAYS); |
|
console.log('Using user outbox relays for publishing:', outbox); |
|
} else if (inbox.length > 0) { |
|
// If no outbox but have inbox, use inbox relays (some users only set inbox) |
|
userRelays = combineRelays(inbox, DEFAULT_NOSTR_RELAYS); |
|
console.log('Using user inbox relays for publishing (no outbox found):', inbox); |
|
} else { |
|
// No user relays found, use defaults |
|
userRelays = DEFAULT_NOSTR_RELAYS; |
|
console.warn('No user relays found in kind 10002, using default relays only'); |
|
} |
|
} catch (err) { |
|
console.warn('Failed to fetch user relays, using defaults:', err); |
|
// Fall back to default relays if user relay fetch fails |
|
userRelays = DEFAULT_NOSTR_RELAYS; |
|
} |
|
|
|
// Ensure we have at least some relays to publish to |
|
if (userRelays.length === 0) { |
|
console.warn('No relays available, using default relays'); |
|
userRelays = DEFAULT_NOSTR_RELAYS; |
|
} |
|
|
|
console.log('Final relay set for publishing:', userRelays); |
|
|
|
console.log('Using relays for publishing:', userRelays); |
|
|
|
// Publish announcement file event first (so it's available when server provisions) |
|
console.log('Publishing announcement file event...'); |
|
await publishWithRetry(nostrClient, signedAnnouncementFileEvent, userRelays, 2).catch(err => { |
|
console.warn('Failed to publish announcement file event:', err); |
|
// Continue anyway - server can generate it as fallback |
|
}); |
|
|
|
// Publish repository announcement with retry logic |
|
let publishResult = await publishWithRetry(nostrClient, signedEvent, userRelays, 2); |
|
console.log('Publish result:', publishResult); |
|
|
|
if (publishResult.success.length > 0) { |
|
console.log(`Successfully published to ${publishResult.success.length} relay(s):`, publishResult.success); |
|
// Create and publish initial ownership proof (self-transfer event) |
|
const { OwnershipTransferService } = await import('../../lib/services/nostr/ownership-transfer-service.js'); |
|
const ownershipService = new OwnershipTransferService(userRelays); |
|
|
|
const initialOwnershipEvent = ownershipService.createInitialOwnershipEvent(pubkey, dTag); |
|
const signedOwnershipEvent = await signEventWithNIP07(initialOwnershipEvent); |
|
|
|
// Publish initial ownership event (don't fail if this fails, announcement is already published) |
|
await nostrClient.publishEvent(signedOwnershipEvent, userRelays).catch(err => { |
|
console.warn('Failed to publish initial ownership event:', err); |
|
}); |
|
|
|
success = true; |
|
// Redirect to the newly created repository page |
|
// Use invalidateAll to ensure the repos list refreshes |
|
const userNpub = nip19.npubEncode(pubkey); |
|
|
|
// Check if this is a transfer completion (from query params) |
|
const urlParams = $page.url.searchParams; |
|
const isTransfer = urlParams.get('transfer') === 'true'; |
|
|
|
setTimeout(() => { |
|
// Invalidate all caches and redirect |
|
if (isTransfer) { |
|
// After transfer, redirect to repos page to see updated state |
|
goto('/repos', { invalidateAll: true, replaceState: false }); |
|
} else { |
|
goto(`/repos/${userNpub}/${dTag}`, { invalidateAll: true, replaceState: false }); |
|
} |
|
}, 2000); |
|
} else { |
|
// Show detailed error information |
|
const errorDetails = publishResult.failed.map(f => `${f.relay}: ${f.error}`).join('\n'); |
|
error = `Failed to publish to any relays after retries.\n\nRelays attempted: ${userRelays.length}\n\nErrors:\n${errorDetails || 'Unknown error'}\n\nPlease check:\n• Your internet connection\n• Relay availability\n• Try again in a few moments`; |
|
console.error('Failed to publish repository announcement:', publishResult); |
|
} |
|
|
|
} catch (e) { |
|
error = `Failed to create repository announcement: ${String(e)}`; |
|
} finally { |
|
loading = false; |
|
} |
|
} |
|
</script> |
|
|
|
<div class="container"> |
|
<header> |
|
<h1>Create or Update Repository Announcement</h1> |
|
</header> |
|
|
|
<main> |
|
{#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 this server's associated 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> |
|
</div> |
|
{/if} |
|
|
|
{#if error} |
|
<div class="error">{error}</div> |
|
{/if} |
|
|
|
{#if success} |
|
<div class="success"> |
|
Repository announcement published successfully! Redirecting... |
|
</div> |
|
{/if} |
|
|
|
<form onsubmit={(e) => { e.preventDefault(); submit(); }}> |
|
<div class="form-group"> |
|
<label for="existing-repo-ref"> |
|
Load Existing Repository (optional) |
|
<small>Enter hex event ID, nevent, or naddr to update an existing announcement</small> |
|
</label> |
|
<div class="input-group"> |
|
<input |
|
id="existing-repo-ref" |
|
type="text" |
|
bind:value={existingRepoRef} |
|
placeholder="hex event ID, nevent1..., or naddr1..." |
|
disabled={loading || loadingExisting} |
|
/> |
|
<button |
|
type="button" |
|
onclick={() => lookupRepoAnnouncement(existingRepoRef || '', 'existingRepoRef')} |
|
disabled={loading || loadingExisting || !existingRepoRef.trim()} |
|
class="lookup-button" |
|
title="Search for repository announcements (supports hex ID, nevent, naddr, or search by name)" |
|
> |
|
{#if lookupLoading['repo-existingRepoRef']} |
|
<span class="loading-text">Loading...</span> |
|
{:else} |
|
<img src="/icons/search.svg" alt="Search" class="icon-small" /> |
|
{/if} |
|
</button> |
|
<button |
|
type="button" |
|
onclick={loadExistingRepo} |
|
disabled={loading || loadingExisting || !existingRepoRef.trim()} |
|
> |
|
{loadingExisting ? 'Loading...' : 'Load'} |
|
</button> |
|
</div> |
|
{#if lookupError['repo-existingRepoRef']} |
|
<div class="lookup-error">{lookupError['repo-existingRepoRef']}</div> |
|
{/if} |
|
{#if lookupResults['repo-existingRepoRef']} |
|
<div class="lookup-results"> |
|
<div class="lookup-results-header"> |
|
<span>Found {lookupResults['repo-existingRepoRef'].length} repository announcement(s):</span> |
|
<button |
|
type="button" |
|
onclick={() => clearLookupResults('repo-existingRepoRef')} |
|
class="clear-lookup-button" |
|
title="Clear results" |
|
> |
|
<img src="/icons/x.svg" alt="Clear" class="icon-small" /> |
|
</button> |
|
</div> |
|
{#each (lookupResults['repo-existingRepoRef'] || []) as result} |
|
{#if 'tags' in result} |
|
{@const event = result as NostrEvent} |
|
{@const nameTag = event.tags.find((t: string[]) => t[0] === 'name')?.[1]} |
|
{@const dTag = event.tags.find((t: string[]) => t[0] === 'd')?.[1]} |
|
{@const descTag = event.tags.find((t: string[]) => t[0] === 'description')?.[1]} |
|
{@const imageTag = event.tags.find((t: string[]) => t[0] === 'image')?.[1]} |
|
{@const ownerNpub = nip19.npubEncode(event.pubkey)} |
|
{@const tags = event.tags.filter((t: string[]) => t[0] === 't' && t[1] && t[1] !== 'private' && t[1] !== 'fork').map((t: string[]) => t[1])} |
|
<div |
|
class="lookup-result-item repo-result" |
|
role="button" |
|
tabindex="0" |
|
onclick={() => selectRepoResult(event, 'existingRepoRef')} |
|
onkeydown={(e) => { |
|
if (e.key === 'Enter' || e.key === ' ') { |
|
e.preventDefault(); |
|
selectRepoResult(event, 'existingRepoRef'); |
|
} |
|
}} |
|
> |
|
<div class="result-header"> |
|
{#if imageTag} |
|
<img src={imageTag} alt="" class="result-image" /> |
|
{/if} |
|
<div class="result-info"> |
|
<strong>{nameTag || dTag || 'Unnamed'}</strong> |
|
{#if dTag} |
|
<small class="d-tag">d-tag: {dTag}</small> |
|
{/if} |
|
{#if descTag} |
|
<p class="result-description">{descTag}</p> |
|
{/if} |
|
<div class="result-meta"> |
|
<small>Owner: {ownerNpub.slice(0, 16)}...</small> |
|
<small>Event: {event.id.slice(0, 16)}...</small> |
|
</div> |
|
{#if tags.length > 0} |
|
<div class="result-tags"> |
|
{#each tags as tag} |
|
<span class="tag-badge">#{tag}</span> |
|
{/each} |
|
</div> |
|
{/if} |
|
</div> |
|
</div> |
|
</div> |
|
{/if} |
|
{/each} |
|
</div> |
|
{/if} |
|
</div> |
|
|
|
<div class="form-group"> |
|
<label for="repo-name"> |
|
Repository Name * |
|
<small>Enter a normal name (e.g., "My Awesome Repo"). It will be automatically converted to a d-tag format (lowercase with hyphens, such as my-awesome-repo).</small> |
|
</label> |
|
<input |
|
id="repo-name" |
|
type="text" |
|
bind:value={repoName} |
|
placeholder="My Awesome Repo" |
|
required |
|
disabled={loading} |
|
/> |
|
</div> |
|
|
|
<div class="form-group"> |
|
<label for="description"> |
|
Description |
|
</label> |
|
<textarea |
|
id="description" |
|
bind:value={description} |
|
placeholder="Repository description" |
|
rows={3} |
|
disabled={loading} |
|
></textarea> |
|
</div> |
|
|
|
<div class="form-group"> |
|
<div class="label"> |
|
Clone URLs |
|
<small> |
|
{#if ($page.data.gitDomain || 'localhost:6543').startsWith('localhost') || ($page.data.gitDomain || 'localhost:6543').startsWith('127.0.0.1')} |
|
<strong>Note:</strong> Your server is using localhost, which won't work for others. You must add at least one public clone URL (e.g., GitHub, GitLab) or configure a Tor .onion address. |
|
{:else} |
|
{$page.data.gitDomain || 'localhost:6543'} will be added automatically, but you can add any existing ones here. |
|
{/if} |
|
</small> |
|
</div> |
|
{#each cloneUrls as url, index} |
|
<div class="input-group"> |
|
<input |
|
type="text" |
|
value={url} |
|
oninput={(e) => updateCloneUrl(index, e.currentTarget.value)} |
|
placeholder="https://github.com/user/repo.git" |
|
disabled={loading} |
|
/> |
|
{#if cloneUrls.length > 1} |
|
<button |
|
type="button" |
|
onclick={() => removeCloneUrl(index)} |
|
disabled={loading} |
|
> |
|
Remove |
|
</button> |
|
{/if} |
|
</div> |
|
{/each} |
|
<button |
|
type="button" |
|
onclick={addCloneUrl} |
|
disabled={loading} |
|
class="add-button" |
|
> |
|
+ Add Clone URL |
|
</button> |
|
</div> |
|
|
|
<div class="form-group"> |
|
<div class="label"> |
|
Web URLs (optional) |
|
<small>Webpage URLs for browsing the repository (e.g., GitHub/GitLab web interface). Hover over a URL to preview it.</small> |
|
</div> |
|
{#each webUrls as url, index} |
|
<div class="input-group url-preview-container"> |
|
<input |
|
type="text" |
|
value={url} |
|
oninput={(e) => updateWebUrl(index, e.currentTarget.value)} |
|
onmouseenter={() => handleWebUrlHover(index, url)} |
|
onmouseleave={handleWebUrlLeave} |
|
placeholder="https://github.com/user/repo" |
|
disabled={loading} |
|
/> |
|
{#if webUrls.length > 1} |
|
<button |
|
type="button" |
|
onclick={() => removeWebUrl(index)} |
|
disabled={loading} |
|
> |
|
Remove |
|
</button> |
|
{/if} |
|
{#if previewingUrlIndex === index && previewUrl} |
|
<div class="url-preview" role="tooltip"> |
|
{#if previewLoading} |
|
<div class="preview-loading">Loading preview...</div> |
|
{:else if previewError} |
|
<div class="preview-error"> |
|
<strong> |
|
<img src="/icons/alert-triangle.svg" alt="Warning" class="icon-inline" /> |
|
Warning: |
|
</strong> {previewError} |
|
</div> |
|
{/if} |
|
<iframe |
|
src={previewUrl} |
|
title="URL Preview" |
|
class="preview-iframe" |
|
sandbox="allow-same-origin allow-scripts allow-forms allow-popups" |
|
></iframe> |
|
<div class="preview-url-display">{previewUrl}</div> |
|
</div> |
|
{/if} |
|
</div> |
|
{/each} |
|
<button |
|
type="button" |
|
onclick={addWebUrl} |
|
disabled={loading} |
|
class="add-button" |
|
> |
|
+ Add Web URL |
|
</button> |
|
</div> |
|
|
|
<div class="form-group"> |
|
<div class="label"> |
|
Maintainers (optional) |
|
<small>Other maintainer pubkeys (npub or hex format). Example: npub1abc... or hex pubkey</small> |
|
</div> |
|
{#each maintainers as maintainer, index} |
|
<div class="input-group"> |
|
<input |
|
type="text" |
|
value={maintainer} |
|
oninput={(e) => updateMaintainer(index, e.currentTarget.value)} |
|
placeholder="npub1abc... or hex pubkey" |
|
disabled={loading} |
|
/> |
|
<button |
|
type="button" |
|
onclick={() => lookupNpub(maintainer || '', 'maintainers', index)} |
|
disabled={loading || !maintainer.trim()} |
|
class="lookup-button" |
|
title="Lookup npub or search by name" |
|
> |
|
{#if lookupLoading[`npub-maintainers-${index}`]} |
|
<span class="loading-text">Loading...</span> |
|
{:else} |
|
<img src="/icons/search.svg" alt="Search" class="icon-small" /> |
|
{/if} |
|
</button> |
|
{#if maintainers.length > 1} |
|
<button |
|
type="button" |
|
onclick={() => removeMaintainer(index)} |
|
disabled={loading} |
|
> |
|
Remove |
|
</button> |
|
{/if} |
|
</div> |
|
{#if lookupError[`npub-maintainers-${index}`]} |
|
<div class="lookup-error">{lookupError[`npub-maintainers-${index}`]}</div> |
|
{/if} |
|
{#if lookupResults[`npub-maintainers-${index}`]} |
|
<div class="lookup-results"> |
|
<div class="lookup-results-header"> |
|
<span>Found {(lookupResults[`npub-maintainers-${index}`] || []).length} profile(s):</span> |
|
<button |
|
type="button" |
|
onclick={() => clearLookupResults(`npub-maintainers-${index}`)} |
|
class="clear-lookup-button" |
|
title="Clear results" |
|
> |
|
<img src="/icons/x.svg" alt="Clear" class="icon-small" /> |
|
</button> |
|
</div> |
|
{#each (lookupResults[`npub-maintainers-${index}`] || []) as result} |
|
{#if 'npub' in result} |
|
{@const profile = result as ProfileData} |
|
<div |
|
class="lookup-result-item profile-result" |
|
role="button" |
|
tabindex="0" |
|
onclick={() => selectNpubResult(profile, 'maintainers', index)} |
|
onkeydown={(e) => { |
|
if (e.key === 'Enter' || e.key === ' ') { |
|
e.preventDefault(); |
|
selectNpubResult(profile, 'maintainers', index); |
|
} |
|
}} |
|
> |
|
<div class="result-header"> |
|
{#if profile.picture} |
|
<img src={profile.picture} alt="" class="result-avatar" /> |
|
{:else} |
|
<div class="result-avatar-placeholder"> |
|
{(profile.name || profile.npub).slice(0, 2).toUpperCase()} |
|
</div> |
|
{/if} |
|
<div class="result-info"> |
|
<strong>{profile.name || 'Unknown'}</strong> |
|
{#if profile.about} |
|
<p class="result-description">{profile.about}</p> |
|
{/if} |
|
<small class="npub-display">{profile.npub}</small> |
|
</div> |
|
</div> |
|
</div> |
|
{/if} |
|
{/each} |
|
</div> |
|
{/if} |
|
{/each} |
|
<button |
|
type="button" |
|
onclick={addMaintainer} |
|
disabled={loading} |
|
class="add-button" |
|
> |
|
+ Add Maintainer |
|
</button> |
|
</div> |
|
|
|
<div class="form-group"> |
|
<div class="label"> |
|
Relays (optional) |
|
<small>Nostr relays that this repository will monitor for patches and issues. Default relays will be added automatically.</small> |
|
</div> |
|
{#each relays as relay, index} |
|
<div class="input-group"> |
|
<input |
|
type="text" |
|
value={relay} |
|
oninput={(e) => updateRelay(index, e.currentTarget.value)} |
|
placeholder="wss://relay.example.com" |
|
disabled={loading} |
|
/> |
|
{#if relays.length > 1} |
|
<button |
|
type="button" |
|
onclick={() => removeRelay(index)} |
|
disabled={loading} |
|
> |
|
Remove |
|
</button> |
|
{/if} |
|
</div> |
|
{/each} |
|
<button |
|
type="button" |
|
onclick={addRelay} |
|
disabled={loading} |
|
class="add-button" |
|
> |
|
+ Add Relay |
|
</button> |
|
</div> |
|
|
|
<div class="form-group"> |
|
<div class="label"> |
|
Blossoms (optional) |
|
<small>Blossom URLs for this repository. These are preserved but not actively used by GitRepublic.</small> |
|
</div> |
|
{#each blossoms as blossom, index} |
|
<div class="input-group"> |
|
<input |
|
type="text" |
|
value={blossom} |
|
oninput={(e) => updateBlossom(index, e.currentTarget.value)} |
|
placeholder="https://example.com" |
|
disabled={loading} |
|
/> |
|
{#if blossoms.length > 1} |
|
<button |
|
type="button" |
|
onclick={() => removeBlossom(index)} |
|
disabled={loading} |
|
> |
|
Remove |
|
</button> |
|
{/if} |
|
</div> |
|
{/each} |
|
<button |
|
type="button" |
|
onclick={addBlossom} |
|
disabled={loading} |
|
class="add-button" |
|
> |
|
+ Add Blossom |
|
</button> |
|
</div> |
|
|
|
<div class="form-group"> |
|
<label for="image-url"> |
|
Repository Image URL (optional) |
|
<small>URL to a repository image/logo. Example: https://example.com/repo-logo.png</small> |
|
</label> |
|
<input |
|
id="image-url" |
|
type="url" |
|
bind:value={imageUrl} |
|
placeholder="https://example.com/repo-logo.png" |
|
disabled={loading} |
|
/> |
|
</div> |
|
|
|
<div class="form-group"> |
|
<label for="banner-url"> |
|
Repository Banner URL (optional) |
|
<small>URL to a repository banner image. Example: https://example.com/repo-banner.png</small> |
|
</label> |
|
<input |
|
id="banner-url" |
|
type="url" |
|
bind:value={bannerUrl} |
|
placeholder="https://example.com/repo-banner.png" |
|
disabled={loading} |
|
/> |
|
</div> |
|
|
|
<div class="form-group"> |
|
<label for="earliest-commit"> |
|
Earliest Unique Commit ID (optional) |
|
<small>Root commit ID or first commit after a permanent fork. Used to identify forks. Example: abc123def456...</small> |
|
</label> |
|
<input |
|
id="earliest-commit" |
|
type="text" |
|
bind:value={earliestCommit} |
|
placeholder="abc123def456..." |
|
disabled={loading} |
|
/> |
|
</div> |
|
|
|
<div class="form-group"> |
|
<div class="label"> |
|
Tags/Labels (optional) |
|
<small>Hashtags or labels for the repository. Examples: "javascript", "web-app", "personal-fork" (indicates author isn't a maintainer)</small> |
|
</div> |
|
{#each tags as tag, index} |
|
<div class="input-group"> |
|
<input |
|
type="text" |
|
value={tag} |
|
oninput={(e) => updateTag(index, e.currentTarget.value)} |
|
placeholder="javascript" |
|
disabled={loading} |
|
/> |
|
{#if tags.length > 1} |
|
<button |
|
type="button" |
|
onclick={() => removeTag(index)} |
|
disabled={loading} |
|
> |
|
Remove |
|
</button> |
|
{/if} |
|
</div> |
|
{/each} |
|
<button |
|
type="button" |
|
onclick={addTag} |
|
disabled={loading} |
|
class="add-button" |
|
> |
|
+ Add Tag |
|
</button> |
|
</div> |
|
|
|
<div class="form-group"> |
|
<label for="alt"> |
|
Alt Text (optional) |
|
<small>Alternative text/description for the repository. Example: "git repository: Alexandria"</small> |
|
</label> |
|
<input |
|
id="alt" |
|
type="text" |
|
bind:value={alt} |
|
placeholder="git repository: My Awesome Repo" |
|
disabled={loading} |
|
/> |
|
</div> |
|
|
|
<div class="form-group"> |
|
<div class="label"> |
|
Documentation (optional) |
|
<small>Documentation event addresses (naddr format). Example: 30818:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:nkbip-01</small> |
|
</div> |
|
{#each documentation as doc, index} |
|
<div class="input-group"> |
|
<input |
|
type="text" |
|
value={doc} |
|
oninput={(e) => updateDocumentation(index, e.currentTarget.value)} |
|
placeholder="naddr1... or 30818:pubkey:d-tag" |
|
disabled={loading} |
|
/> |
|
<button |
|
type="button" |
|
onclick={() => lookupDocumentation(index)} |
|
disabled={loading || lookupLoading[`doc-${index}`]} |
|
class="lookup-button" |
|
title="Lookup naddr and convert to documentation format" |
|
> |
|
{lookupLoading[`doc-${index}`] ? '...' : 'Lookup'} |
|
</button> |
|
{#if documentation.length > 1} |
|
<button |
|
type="button" |
|
onclick={() => removeDocumentation(index)} |
|
disabled={loading} |
|
> |
|
Remove |
|
</button> |
|
{/if} |
|
</div> |
|
{#if lookupError[`doc-${index}`]} |
|
<div class="error-text">{lookupError[`doc-${index}`]}</div> |
|
{/if} |
|
{#if lookupResults[`doc-${index}`]} |
|
<div class="lookup-results"> |
|
<small><img src="/icons/check.svg" alt="Success" class="icon-inline" style="width: 14px; height: 14px; vertical-align: middle; margin-right: 4px;" /> Documentation address converted successfully</small> |
|
</div> |
|
{/if} |
|
{/each} |
|
<button |
|
type="button" |
|
onclick={addDocumentation} |
|
disabled={loading} |
|
class="add-button" |
|
> |
|
+ Add Documentation |
|
</button> |
|
</div> |
|
|
|
<div class="form-group"> |
|
<label class="checkbox-label"> |
|
<input |
|
type="checkbox" |
|
bind:checked={isFork} |
|
disabled={loading} |
|
/> |
|
<div> |
|
<span>This is a Fork</span> |
|
<small>Check if this repository is a fork of another repository</small> |
|
</div> |
|
</label> |
|
</div> |
|
|
|
{#if isFork} |
|
<div class="form-group"> |
|
<label for="fork-original-repo"> |
|
Original Repository * |
|
<small>Identify the repository this is forked from. You can enter:<br/> |
|
• naddr format: naddr1...<br/> |
|
• npub/repo format: npub1abc.../repo-name<br/> |
|
• Repository address: 30617:owner-pubkey:repo-name</small> |
|
</label> |
|
<div class="input-group"> |
|
<input |
|
id="fork-original-repo" |
|
type="text" |
|
bind:value={forkOriginalRepo} |
|
placeholder="npub1abc.../original-repo or naddr1..." |
|
required={isFork} |
|
disabled={loading} |
|
/> |
|
<button |
|
type="button" |
|
onclick={() => lookupRepoAnnouncement(forkOriginalRepo || '', 'forkOriginalRepo')} |
|
disabled={loading || !forkOriginalRepo.trim()} |
|
class="lookup-button" |
|
title="Search for repository announcements" |
|
> |
|
{#if lookupLoading['repo-forkOriginalRepo']} |
|
<span class="loading-text">Loading...</span> |
|
{:else} |
|
<img src="/icons/search.svg" alt="Search" class="icon-small" /> |
|
{/if} |
|
</button> |
|
</div> |
|
{#if lookupError['repo-forkOriginalRepo']} |
|
<div class="lookup-error">{lookupError['repo-forkOriginalRepo']}</div> |
|
{/if} |
|
{#if lookupResults['repo-forkOriginalRepo']} |
|
<div class="lookup-results"> |
|
<div class="lookup-results-header"> |
|
<span>Found {lookupResults['repo-forkOriginalRepo'].length} repository announcement(s):</span> |
|
<button |
|
type="button" |
|
onclick={() => clearLookupResults('repo-forkOriginalRepo')} |
|
class="clear-lookup-button" |
|
title="Clear results" |
|
> |
|
<img src="/icons/x.svg" alt="Clear" class="icon-small" /> |
|
</button> |
|
</div> |
|
{#each (lookupResults['repo-forkOriginalRepo'] || []) as result} |
|
{#if 'tags' in result} |
|
{@const event = result as NostrEvent} |
|
{@const nameTag = event.tags.find((t: string[]) => t[0] === 'name')?.[1]} |
|
{@const dTag = event.tags.find((t: string[]) => t[0] === 'd')?.[1]} |
|
{@const descTag = event.tags.find((t: string[]) => t[0] === 'description')?.[1]} |
|
{@const imageTag = event.tags.find((t: string[]) => t[0] === 'image')?.[1]} |
|
{@const ownerNpub = nip19.npubEncode(event.pubkey)} |
|
{@const tags = event.tags.filter((t: string[]) => t[0] === 't' && t[1] && t[1] !== 'private' && t[1] !== 'fork').map((t: string[]) => t[1])} |
|
<div |
|
class="lookup-result-item repo-result" |
|
role="button" |
|
tabindex="0" |
|
onclick={() => selectRepoResult(event, 'forkOriginalRepo')} |
|
onkeydown={(e) => { |
|
if (e.key === 'Enter' || e.key === ' ') { |
|
e.preventDefault(); |
|
selectRepoResult(event, 'forkOriginalRepo'); |
|
} |
|
}} |
|
> |
|
<div class="result-header"> |
|
{#if imageTag} |
|
<img src={imageTag} alt="" class="result-image" /> |
|
{/if} |
|
<div class="result-info"> |
|
<strong>{nameTag || dTag || 'Unnamed'}</strong> |
|
{#if dTag} |
|
<small class="d-tag">d-tag: {dTag}</small> |
|
{/if} |
|
{#if descTag} |
|
<p class="result-description">{descTag}</p> |
|
{/if} |
|
<div class="result-meta"> |
|
<small>Owner: {ownerNpub.slice(0, 16)}...</small> |
|
<small>Event: {event.id.slice(0, 16)}...</small> |
|
</div> |
|
{#if tags.length > 0} |
|
<div class="result-tags"> |
|
{#each tags as tag} |
|
<span class="tag-badge">#{tag}</span> |
|
{/each} |
|
</div> |
|
{/if} |
|
</div> |
|
</div> |
|
</div> |
|
{/if} |
|
{/each} |
|
</div> |
|
{/if} |
|
</div> |
|
{/if} |
|
|
|
<div class="form-group"> |
|
<label for="visibility"> |
|
Repository Visibility * |
|
<small> |
|
<strong>Public:</strong> Repository and events are published to all relays and project relay.<br/> |
|
<strong>Unlisted:</strong> Repository is public but events are only published to project relay.<br/> |
|
<strong>Restricted:</strong> Repository is private, events are only published to project relay.<br/> |
|
<strong>Private:</strong> Repository is private, events are not published to relays (git-only). |
|
</small> |
|
</label> |
|
<select |
|
id="visibility" |
|
bind:value={visibility} |
|
disabled={loading} |
|
required |
|
> |
|
<option value="public">Public</option> |
|
<option value="unlisted">Unlisted</option> |
|
<option value="restricted">Restricted</option> |
|
<option value="private">Private</option> |
|
</select> |
|
</div> |
|
|
|
{#if visibility === 'unlisted' || visibility === 'restricted' || visibility === 'private'} |
|
<div class="form-group"> |
|
<div class="label"> |
|
Project Relay(s) {#if visibility === 'unlisted' || visibility === 'restricted'}*{/if} |
|
<small> |
|
{#if visibility === 'unlisted' || visibility === 'restricted'} |
|
Required for unlisted and restricted repositories. Events will be published to these relays only. |
|
{:else} |
|
Optional for private repositories. If provided, events will be published to these relays (otherwise git-only). |
|
{/if} |
|
</small> |
|
</div> |
|
{#each projectRelays as projectRelay, index} |
|
<div class="input-group"> |
|
<input |
|
type="text" |
|
value={projectRelay} |
|
oninput={(e) => { |
|
projectRelays[index] = e.currentTarget.value; |
|
projectRelays = [...projectRelays]; |
|
}} |
|
placeholder="wss://relay.example.com" |
|
disabled={loading} |
|
required={visibility === 'unlisted' || visibility === 'restricted'} |
|
/> |
|
{#if projectRelays.length > 1} |
|
<button |
|
type="button" |
|
onclick={() => { |
|
projectRelays = projectRelays.filter((_, i) => i !== index); |
|
}} |
|
disabled={loading} |
|
> |
|
Remove |
|
</button> |
|
{/if} |
|
</div> |
|
{/each} |
|
<button |
|
type="button" |
|
onclick={() => { |
|
projectRelays = [...projectRelays, '']; |
|
}} |
|
disabled={loading} |
|
class="add-button" |
|
> |
|
+ Add Project Relay |
|
</button> |
|
</div> |
|
{/if} |
|
|
|
<div class="form-group"> |
|
<label class="checkbox-label"> |
|
<input |
|
type="checkbox" |
|
bind:checked={addClientTag} |
|
disabled={loading} |
|
/> |
|
<div> |
|
<span>Add Client Tag</span> |
|
<small>Add a client tag to identify this repository as created with GitRepublic (checked by default)</small> |
|
</div> |
|
</label> |
|
</div> |
|
|
|
<div class="form-actions"> |
|
<button |
|
type="submit" |
|
disabled={loading || !nip07Available || !isLoggedIn($userStore.userLevel) || !hasUnlimitedAccess($userStore.userLevel)} |
|
> |
|
{loading ? 'Publishing...' : 'Publish Repository Announcement'} |
|
</button> |
|
<a href="/" class="cancel-link">Cancel</a> |
|
</div> |
|
</form> |
|
</main> |
|
</div> |
|
|
|
|