Browse Source

validate URLS

main
Silberengel 4 weeks ago
parent
commit
5147078400
  1. 77
      src/app.css
  2. 17
      src/lib/config.ts
  3. 245
      src/routes/signup/+page.svelte

77
src/app.css

@ -390,6 +390,83 @@ input:disabled, textarea:disabled, select:disabled {
flex: 1; flex: 1;
} }
/* URL Preview Styles */
.url-preview-container {
position: relative;
}
.url-preview {
position: absolute;
top: 100%;
left: 0;
margin-top: 0.5rem;
z-index: 1000;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
width: 600px;
max-width: 90vw;
max-height: 500px;
display: flex;
flex-direction: column;
overflow: hidden;
pointer-events: auto;
}
/* Position preview above if near bottom of viewport */
@media (min-width: 769px) {
.url-preview-container:last-child .url-preview {
bottom: 100%;
top: auto;
margin-top: 0;
margin-bottom: 0.5rem;
}
}
.preview-loading,
.preview-error {
padding: 1rem;
text-align: center;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
}
.preview-error {
background: var(--warning-bg);
color: var(--warning-text);
font-size: 0.9rem;
}
.preview-iframe {
width: 100%;
height: 400px;
border: none;
flex: 1;
background: white;
}
.preview-url-display {
padding: 0.5rem 1rem;
background: var(--bg-secondary);
border-top: 1px solid var(--border-color);
font-size: 0.85rem;
color: var(--text-secondary);
font-family: 'IBM Plex Mono', monospace;
word-break: break-all;
}
@media (max-width: 768px) {
.url-preview {
width: calc(100vw - 2rem);
max-height: 400px;
}
.preview-iframe {
height: 300px;
}
}
.search-input { .search-input {
width: 100%; width: 100%;
padding: 0.75rem; padding: 0.75rem;

17
src/lib/config.ts

@ -22,7 +22,22 @@ export const DEFAULT_NOSTR_RELAYS =
: [ : [
'wss://theforest.nostr1.com', 'wss://theforest.nostr1.com',
'wss://nostr.land', 'wss://nostr.land',
'wss://relay.damus.io' ];
/**
* Nostr relays to use for searching for repositories, profiles, or other events
* Can be overridden by NOSTR_RELAYS env var (comma-separated list)
*/
export const DEFAULT_NOSTR_SEARCH_RELAYS =
typeof process !== 'undefined' && process.env?.NOSTR_SEARCH_RELAYS
? process.env.NOSTR_SEARCH_RELAYS.split(',').map(r => r.trim()).filter(r => r.length > 0)
: [
'wss://relay.damus.io',
'wss://thecitadel.nostr1.com',
'wss://nostr21.com',
'wss://profiles.nostr1.com',
"wss://relay.primal.net",
...DEFAULT_NOSTR_RELAYS,
]; ];
/** /**

245
src/routes/signup/+page.svelte

@ -28,12 +28,18 @@
let earliestCommit = $state(''); let earliestCommit = $state('');
let isPrivate = $state(false); let isPrivate = $state(false);
let isFork = $state(false); let isFork = $state(false);
let forkRepoAddress = $state(''); // a tag: 30617:owner:repo let forkOriginalRepo = $state(''); // Original repo identifier: npub/repo, naddr, or 30617:owner:repo format
let forkOwnerPubkey = $state(''); // p tag: original owner pubkey
let addClientTag = $state(true); // Add ["client", "gitrepublic-web"] tag let addClientTag = $state(true); // Add ["client", "gitrepublic-web"] tag
let existingRepoRef = $state(''); // hex, nevent, or naddr let existingRepoRef = $state(''); // hex, nevent, or naddr
let loadingExisting = $state(false); 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;
import { DEFAULT_NOSTR_RELAYS, combineRelays } from '../../lib/config.js'; import { DEFAULT_NOSTR_RELAYS, combineRelays } from '../../lib/config.js';
const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS); const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS);
@ -112,6 +118,73 @@
documentation = newDocs; documentation = newDocs;
} }
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;
}
}
async function loadExistingRepo() { async function loadExistingRepo() {
if (!existingRepoRef.trim()) return; if (!existingRepoRef.trim()) return;
@ -246,10 +319,21 @@
// Extract fork information // Extract fork information
const aTag = event.tags.find(t => t[0] === 'a' && t[1]?.startsWith('30617:')); const aTag = event.tags.find(t => t[0] === 'a' && t[1]?.startsWith('30617:'));
forkRepoAddress = aTag?.[1] || ''; 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); const pTag = event.tags.find(t => t[0] === 'p' && t[1] && t[1] !== event.pubkey);
forkOwnerPubkey = pTag?.[1] || ''; if (pTag?.[1] && dTag) {
isFork = !!(forkRepoAddress || forkOwnerPubkey || event.tags.some(t => t[0] === 't' && t[1] === 'fork')); // Construct a tag format: 30617:owner:repo
forkOriginalRepo = `${KIND.REPO_ANNOUNCEMENT}:${pTag[1]}:${dTag}`;
}
}
}
// Extract earliest unique commit // Extract earliest unique commit
const rTag = event.tags.find(t => t[0] === 'r' && t[2] === 'euc'); const rTag = event.tags.find(t => t[0] === 'r' && t[2] === 'euc');
@ -333,13 +417,98 @@
]; ];
// Add fork tags if this is a fork // Add fork tags if this is a fork
if (isFork) { if (isFork && forkOriginalRepo.trim()) {
if (forkRepoAddress.trim()) { let forkAddress = forkOriginalRepo.trim();
eventTags.push(['a', forkRepoAddress.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;
} }
if (forkOwnerPubkey.trim()) {
eventTags.push(['p', forkOwnerPubkey.trim()]); // 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 // Add 'fork' tag if not already in tags
if (!allTags.includes('fork')) { if (!allTags.includes('fork')) {
eventTags.push(['t', 'fork']); eventTags.push(['t', 'fork']);
@ -521,14 +690,16 @@
<div class="form-group"> <div class="form-group">
<div class="label"> <div class="label">
Web URLs (optional) Web URLs (optional)
<small>Webpage URLs for browsing the repository (e.g., GitHub/GitLab web interface)</small> <small>Webpage URLs for browsing the repository (e.g., GitHub/GitLab web interface). Hover over a URL to preview it.</small>
</div> </div>
{#each webUrls as url, index} {#each webUrls as url, index}
<div class="input-group"> <div class="input-group url-preview-container">
<input <input
type="text" type="text"
value={url} value={url}
oninput={(e) => updateWebUrl(index, e.currentTarget.value)} oninput={(e) => updateWebUrl(index, e.currentTarget.value)}
onmouseenter={() => handleWebUrlHover(index, url)}
onmouseleave={handleWebUrlLeave}
placeholder="https://github.com/user/repo" placeholder="https://github.com/user/repo"
disabled={loading} disabled={loading}
/> />
@ -541,6 +712,24 @@
Remove Remove
</button> </button>
{/if} {/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> 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> </div>
{/each} {/each}
<button <button
@ -730,29 +919,19 @@
{#if isFork} {#if isFork}
<div class="form-group"> <div class="form-group">
<label for="fork-repo-address"> <label for="fork-original-repo">
Original Repository Address (optional) Original Repository *
<small>Repository address of the original repo. Format: 30617:owner-pubkey:repo-name. Example: 30617:abc123...:original-repo</small> <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> </label>
<input <input
id="fork-repo-address" id="fork-original-repo"
type="text" type="text"
bind:value={forkRepoAddress} bind:value={forkOriginalRepo}
placeholder="30617:abc123...:original-repo" placeholder="npub1abc.../original-repo or naddr1..."
disabled={loading} required={isFork}
/>
</div>
<div class="form-group">
<label for="fork-owner-pubkey">
Original Owner Pubkey (optional)
<small>Pubkey (hex or npub) of the original repository owner. Example: npub1abc... or hex pubkey</small>
</label>
<input
id="fork-owner-pubkey"
type="text"
bind:value={forkOwnerPubkey}
placeholder="npub1abc... or hex pubkey"
disabled={loading} disabled={loading}
/> />
</div> </div>
@ -767,7 +946,7 @@
/> />
<div> <div>
<span>Private Repository</span> <span>Private Repository</span>
<small>Mark this repository as private (will be hidden from public listings)</small> <small>Private repositories are hidden from public listings and can only be accessed by the owner and maintainers. Git clone/fetch operations require authentication.</small>
</div> </div>
</label> </label>
</div> </div>

Loading…
Cancel
Save