From ade65a0b9c0607c80f735231bdad904684582e6c Mon Sep 17 00:00:00 2001 From: Silberengel Date: Fri, 20 Feb 2026 11:41:46 +0100 Subject: [PATCH] pubkey lookup for maintainer include all tags in the r.a. preset update client tags on publish add verification/correction step Nostr-Signature: cc27d54e23cecca7e126e7a1b9e0881ee9c9addf39a97841992ac35422221e5d 573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc 7c5e7173e4bfc17a71cec49c8ac2fad15ecab3a84ef53ac90ba7ab6f1c051e2e6d108cecfa075917b6be8a9d1d54d3995595a0b95c004995ec89fe8a621315cd --- nostr/commit-signatures.jsonl | 1 + src/routes/signup/+page.svelte | 687 ++++++++++++++++++++++++++++++--- 2 files changed, 626 insertions(+), 62 deletions(-) diff --git a/nostr/commit-signatures.jsonl b/nostr/commit-signatures.jsonl index 4e14f00..8c13ec4 100644 --- a/nostr/commit-signatures.jsonl +++ b/nostr/commit-signatures.jsonl @@ -14,3 +14,4 @@ {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771532033,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","fixing themes"]],"content":"Signed commit: fixing themes","id":"b415f46b54a30f022ece43f9acc4e13ffddaa56abfd6febe447a852c54ace23c","sig":"acec0d1ea91d8c77b7ac98f0837eae225eca1272d7f871c3c5ccefc744706cb933d2f20732d9a1e42dee4f978c2ca7d17d0bc4033088a8db0a39e66cf982cb62"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771532649,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","adjust responsiveness"]],"content":"Signed commit: adjust responsiveness","id":"b585b4ee5862b2593c0e469974f94b16a1a60e9f57df988cf9ed157acba1c921","sig":"7daeaea11600c77d015448d293f8d7c7500c65d87cd4b496c13ba0fa9922fe5330353a3082eb4f5b540208630e668f163981cdb5e35f027191fb6abd6d0d380f"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771533104,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","add more api help"]],"content":"Signed commit: add more api help","id":"165d9bb66132123e1ac956f442e13f2ffb784e204ecdd1d3643152a5274cdd5a","sig":"deb8866643413806ec43e30faa8a47a78f0ede64616d6304e3b0a87ee3e267122e2308ed67131b73290a3ec10124c19198b05d2b5f142a3ff3e44858d1dff4fe"} +{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771581869,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","gix build and publish CLI to npm"]],"content":"Signed commit: gix build and publish CLI to npm","id":"7515d5ecd835df785a5e896062818b469bcad83a22efa84499d1736e73ae4844","sig":"b4bb7849515c545a609df14939a0a2ddfcd08ee2160cdc01c932a4b0b55668a54fa3fe1d15ad55fe74cfdb23e6c357cf581ab0aaef44da8c64dc098202a7383f"} diff --git a/src/routes/signup/+page.svelte b/src/routes/signup/+page.svelte index 4de76e8..2d49045 100644 --- a/src/routes/signup/+page.svelte +++ b/src/routes/signup/+page.svelte @@ -100,16 +100,24 @@ .filter(url => url && typeof url === 'string'); const gitDomain = $page.data.gitDomain || 'localhost:6543'; - const protocol = gitDomain.startsWith('localhost') ? 'http' : 'https'; - const currentDomainUrl = `${protocol}://${gitDomain}/${originalOwnerParam}/${repoParam}.git`; + const isLocalhost = gitDomain.startsWith('localhost') || gitDomain.startsWith('127.0.0.1'); - // Check if current domain URL already exists - const hasCurrentDomain = existingCloneUrls.some(url => url.includes(gitDomain)); - - if (!hasCurrentDomain) { - cloneUrls = [...existingCloneUrls, currentDomainUrl]; + // 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 { - cloneUrls = existingCloneUrls.length > 0 ? existingCloneUrls : [currentDomainUrl]; + // Localhost: just use existing clone URLs + cloneUrls = existingCloneUrls.length > 0 ? existingCloneUrls : ['']; } // Pre-fill other fields @@ -137,6 +145,122 @@ 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'); + const isPrivateTag = event.tags.find(t => (t[0] === 'private' && t[1] === 'true') || (t[0] === 't' && t[1] === 'private') @@ -146,11 +270,17 @@ // Set existing repo ref for updating existingRepoRef = event.id; } else { - // No announcement found, just set the clone URL with current domain + // No announcement found repoName = repoParam; const gitDomain = $page.data.gitDomain || 'localhost:6543'; - const protocol = gitDomain.startsWith('localhost') ? 'http' : 'https'; - cloneUrls = [`${protocol}://${gitDomain}/${originalOwnerParam}/${repoParam}.git`]; + 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) { @@ -158,8 +288,14 @@ // Still set basic info repoName = repoParam; const gitDomain = $page.data.gitDomain || 'localhost:6543'; - const protocol = gitDomain.startsWith('localhost') ? 'http' : 'https'; - cloneUrls = [`${protocol}://${originalOwnerParam}/${repoParam}.git`]; + 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 @@ -193,16 +329,24 @@ .filter(url => url && typeof url === 'string'); const gitDomain = $page.data.gitDomain || 'localhost:6543'; - const protocol = gitDomain.startsWith('localhost') ? 'http' : 'https'; - const currentDomainUrl = `${protocol}://${gitDomain}/${npubParam}/${repoParam}.git`; + const isLocalhost = gitDomain.startsWith('localhost') || gitDomain.startsWith('127.0.0.1'); - // Check if current domain URL already exists - const hasCurrentDomain = existingCloneUrls.some(url => url.includes(gitDomain)); - - if (!hasCurrentDomain) { - cloneUrls = [...existingCloneUrls, currentDomainUrl]; + // 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 { - cloneUrls = existingCloneUrls.length > 0 ? existingCloneUrls : [currentDomainUrl]; + // Localhost: just use existing clone URLs + cloneUrls = existingCloneUrls.length > 0 ? existingCloneUrls : ['']; } // Pre-fill other fields @@ -230,6 +374,122 @@ 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'); + const isPrivateTag = event.tags.find(t => (t[0] === 'private' && t[1] === 'true') || (t[0] === 't' && t[1] === 'private') @@ -239,18 +499,30 @@ // Set existing repo ref for updating existingRepoRef = event.id; } else { - // No announcement found, just set the clone URL with current domain + // No announcement found const gitDomain = $page.data.gitDomain || 'localhost:6543'; - const protocol = gitDomain.startsWith('localhost') ? 'http' : 'https'; - cloneUrls = [`${protocol}://${gitDomain}/${npubParam}/${repoParam}.git`]; + 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 clone URL + // Still set basic info const gitDomain = $page.data.gitDomain || 'localhost:6543'; - const protocol = gitDomain.startsWith('localhost') ? 'http' : 'https'; - cloneUrls = [`${protocol}://${gitDomain}/${npubParam}/${repoParam}.git`]; + 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 = ['']; + } } } }); @@ -536,12 +808,30 @@ // Validation functions function validateCloneUrl(url: string): string | null { if (!url.trim()) return null; // Empty is OK - if (!isValidUrl(url.trim())) { + 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://'; } - if (!url.trim().endsWith('.git') && !url.trim().includes('/')) { + + // 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; } @@ -900,7 +1190,8 @@ function selectNpubResult(result: { pubkey: string; npub: string; name?: string; about?: string; picture?: string }, fieldName: string, index?: number) { if (fieldName === 'maintainers' && index !== undefined) { - updateMaintainer(index, result.npub); + // Store hex pubkey instead of npub for maintainers + updateMaintainer(index, result.pubkey); } const lookupKey = index !== undefined ? `npub-${fieldName}-${index}` : `npub-${fieldName}`; lookupResults[lookupKey] = null; @@ -1189,6 +1480,22 @@ 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[] = []; @@ -1299,59 +1606,307 @@ return; } - // Build clone URLs - NEVER include localhost, only include public domain or Tor .onion - const allCloneUrls: string[] = []; + // ============================================ + // COMPREHENSIVE VALIDATION, NORMALIZATION, AND DEDUPLICATION + // ============================================ + + // Normalize and deduplicate clone URLs + const normalizedCloneUrls: string[] = []; + const seenCloneUrls = new Set(); // 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')) { - allCloneUrls.push(gitUrl); + 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 (always useful, even with localhost) + // Add Tor .onion URL if available (skip validation - it's system-generated and already valid) if (torOnionUrl) { - allCloneUrls.push(torOnionUrl); + const normalized = torOnionUrl.trim().toLowerCase(); + if (!seenCloneUrls.has(normalized)) { + normalizedCloneUrls.push(torOnionUrl); + seenCloneUrls.add(normalized); + } } - // Add user-provided clone URLs - allCloneUrls.push(...userCloneUrls); + // 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(); + 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); + } + } - // Build web URLs - const allWebUrls = webUrls.filter(url => url.trim()); + // Normalize, convert, and deduplicate maintainers (convert npubs to hex pubkeys) + const normalizedMaintainers: string[] = []; + const seenMaintainers = new Set(); + 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); + } + } - // Build maintainers list - const allMaintainers = maintainers.filter(m => m.trim()); + // Normalize and deduplicate relays + const normalizedRelays: string[] = []; + const seenRelays = new Set(); + + // 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); + } + } - // Build relays list - combine user relays with default relays - const allRelays = [ - ...relays.filter(r => r.trim()), - ...DEFAULT_NOSTR_RELAYS.filter(r => !relays.includes(r)) - ]; + // Normalize and deduplicate blossoms + const normalizedBlossoms: string[] = []; + const seenBlossoms = new Set(); + 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(); + 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(); + 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; + } + } - // Build blossoms list - const allBlossoms = blossoms.filter(b => b.trim()); + if (normalizedBannerUrl) { + const bannerError = validateImageUrl(normalizedBannerUrl); + if (bannerError) { + error = `Invalid banner URL: ${bannerError}`; + loading = false; + return; + } + } - // Build documentation list - const allDocumentation = documentation.filter(d => d.trim()); + // 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; + } - // Build tags/labels (excluding 'private' and 'fork' which are handled separately) - const allTags = tags.filter(t => t.trim() && t !== 'private' && t !== 'fork'); + // 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], - ...(description ? [['description', description]] : []), + ['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 + ...(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]), - ...(imageUrl.trim() ? [['image', imageUrl.trim()]] : []), - ...(bannerUrl.trim() ? [['banner', bannerUrl.trim()]] : []), - ...(alt.trim() ? [['alt', alt.trim()]] : []), - ...(earliestCommit.trim() ? [['r', earliestCommit.trim(), 'euc']] : []) + ...(normalizedImageUrl ? [['image', normalizedImageUrl]] : []), + ...(normalizedBannerUrl ? [['banner', normalizedBannerUrl]] : []), + ...(normalizedAlt ? [['alt', normalizedAlt]] : []), + ...(normalizedEarliestCommit ? [['r', normalizedEarliestCommit, 'euc']] : []) ]; // Add fork tags if this is a fork @@ -1458,10 +2013,18 @@ eventTags.push(['private', 'true']); } - // Add client tag if enabled + // 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) { - eventTags.push(['client', 'gitrepublic-web']); + 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) @@ -1614,7 +2177,7 @@ {#if !hasUnlimitedAccess($userStore.userLevel)}

Only users with unlimited access can create or register repositories.

-

Please log in with an account that has write access to Nostr relays.

+

Please log in with an account that has write access to this server's associated Nostr relays.

{:else if !nip07Available}