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

<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>