fix: search for npub

This commit is contained in:
n 2025-04-06 23:50:18 +01:00
parent 6a5b9805bc
commit c976746b07
10 changed files with 2608 additions and 47 deletions

@ -21,9 +21,17 @@
<h2>Server Information:</h2>
<div style="margin-bottom: 15px;">
<div style="margin-bottom: 10px;">
<label for="serverPubkey">Server Pubkey:</label><br>
<input type="text" id="serverPubkey" value="npub1r6knexka25dn9w9jnf5kf8xh6gfq7n3p38zfl7nn7cjjjsp4umcqnk0aun" style="width: 100%; padding: 8px;">
<label for="serverPubkey">Server Pubkey or Search Term:</label><br>
<div class="server-input-container">
<input type="text" id="serverPubkey" placeholder="npub, username, or NIP-05 identifier" value="npub1r6knexka25dn9w9jnf5kf8xh6gfq7n3p38zfl7nn7cjjjsp4umcqnk0aun" class="server-input">
<button id="searchServerBtn" class="server-search-button">Search</button>
</div>
<div id="serverSearchResult" class="server-search-result" style="display: none;">
<!-- Search results will be shown here -->
</div>
</div>
<div>
<label for="relay">Response Relay (optional):</label><br>
<input type="text" id="relay" value="wss://relay.damus.io" style="width: 100%; padding: 8px;">
@ -42,6 +50,23 @@ User-Agent: Browser/1.0
<div id="output" hidden>
<h2>Converted Event:</h2>
<pre id="eventOutput"></pre>
<div class="publish-container">
<h2>Publish to Relay:</h2>
<div class="publish-input-container">
<input type="text" id="publishRelay" value="wss://relay.nostrdev.com" placeholder="wss://relay.example.com" class="publish-input">
<button id="publishButton" class="publish-button">Publish Event</button>
</div>
<div id="publishResult" class="publish-result" style="display: none;">
<!-- Publish results will be shown here -->
</div>
</div>
<div class="qr-container">
<h2>QR Code:</h2>
<div id="qrCode"></div>
<p><small>Scan this QR code to share the Nostr event</small></p>
</div>
</div>
</body>
</html>

1190
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -22,6 +22,8 @@
"author": "",
"license": "MIT",
"devDependencies": {
"@types/qrcode": "^1.5.5",
"@types/qrcode-generator": "^1.0.6",
"@typescript-eslint/eslint-plugin": "^8.29.0",
"@typescript-eslint/parser": "^8.29.0",
"buffer": "^6.0.3",
@ -31,6 +33,7 @@
"eslint": "^9.24.0",
"eslint-plugin-import": "^2.31.0",
"husky": "^9.1.7",
"node-polyfill-webpack-plugin": "^4.1.0",
"process": "^0.11.10",
"serve": "^14.0.0",
"stream-browserify": "^3.0.0",
@ -42,6 +45,8 @@
"webpack-dev-server": "^5.2.1"
},
"dependencies": {
"nostr-tools": "^2.12.0"
"nostr-tools": "^2.12.0",
"qrcode": "^1.5.4",
"qrcode-generator": "^1.4.4"
}
}

@ -1,16 +1,258 @@
// client.ts - External TypeScript file for HTTP to Nostr converter
// This follows strict CSP policies by avoiding inline scripts
// Import the converter function
// Import functions from other modules
import { displayConvertedEvent } from './converter';
import { lookupNip05, searchUsers } from './search';
import { publishToRelay, convertNpubToHex, verifyEvent } from './relay';
import {
setDefaultHttpRequest,
sanitizeText,
processTags,
standardizeEvent,
showError,
showSuccess,
showLoading
} from './utils';
/**
* Handle the server search button click
*/
async function handleServerSearch(): Promise<void> {
const serverPubkeyInput = document.getElementById('serverPubkey') as HTMLInputElement;
const resultDiv = document.getElementById('serverSearchResult');
if (!serverPubkeyInput || !resultDiv) {
return;
}
const searchTerm = serverPubkeyInput.value.trim();
if (!searchTerm) {
showError(resultDiv, 'Please enter a search term');
return;
}
// If it's a valid npub, no need to search
if (searchTerm.startsWith('npub')) {
try {
const hexPubkey = convertNpubToHex(searchTerm);
if (hexPubkey) {
// It's a valid npub, hide any existing results
resultDiv.style.display = 'none';
return;
}
} catch (error) {
// Not a valid npub, continue with search
}
}
// Display loading state
showLoading(resultDiv, 'Searching relays...');
try {
const results = await searchUsers(searchTerm);
if (results.length > 0) {
// If there's only one result and it's a valid npub, use it directly
if (results.length === 1 && results[0].name === 'Valid npub') {
serverPubkeyInput.value = results[0].npub;
resultDiv.style.display = 'none';
return;
}
// Create the results list
let resultsHtml = '<div class="search-results-list">';
results.forEach(result => {
const truncatedNpub = `${result.npub.substring(0, 10)}...${result.npub.substring(result.npub.length - 5)}`;
resultsHtml += `
<div class="search-result-item" data-npub="${result.npub}">
<div class="result-name">${result.name}</div>
<div class="result-npub">${truncatedNpub}</div>
${result.nip05 ? `<div class="result-nip05">${result.nip05}</div>` : ''}
<button class="use-npub-btn">Use</button>
</div>
`;
});
resultsHtml += '</div>';
resultDiv.innerHTML = resultsHtml;
// Add click handlers for the "Use" buttons
document.querySelectorAll('.use-npub-btn').forEach(button => {
button.addEventListener('click', (e) => {
const resultItem = (e.target as HTMLElement).closest('.search-result-item');
if (resultItem) {
const npub = resultItem.getAttribute('data-npub');
if (npub) {
serverPubkeyInput.value = npub;
resultDiv.innerHTML += '<br><span style="color: #008800;">✓ Applied!</span>';
}
}
});
});
} else {
showError(resultDiv, 'No users found matching your search term');
}
} catch (error) {
showError(resultDiv, String(error));
}
}
/**
* Handle the publish button click
*/
async function handlePublishEvent(): Promise<void> {
const eventOutputPre = document.getElementById('eventOutput') as HTMLElement;
const publishRelayInput = document.getElementById('publishRelay') as HTMLInputElement;
const publishResultDiv = document.getElementById('publishResult') as HTMLElement;
if (!eventOutputPre || !publishRelayInput || !publishResultDiv) {
return;
}
// Check if we have a stored event from the creation process
if ((window as any).currentSignedEvent) {
console.log("Using stored signed event from creation process");
try {
const event = (window as any).currentSignedEvent;
const relayUrl = publishRelayInput.value.trim();
if (!relayUrl || !relayUrl.startsWith('wss://')) {
showError(publishResultDiv, 'Please enter a valid relay URL (must start with wss://)');
return;
}
// Display loading state
showLoading(publishResultDiv, 'Publishing to relay...');
try {
const result = await publishToRelay(event, relayUrl);
console.log('Publish success:', result);
showSuccess(publishResultDiv, result);
} catch (publishError) {
console.error('Publish error:', publishError);
showError(publishResultDiv, String(publishError));
// Don't rethrow, just handle it here so the UI shows the error
}
return;
} catch (error) {
console.error("Error using stored event, falling back to parsed event");
// Continue with normal flow if this fails
}
}
const eventText = eventOutputPre.textContent || '';
if (!eventText) {
showError(publishResultDiv, 'No event to publish');
return;
}
console.log('Raw event text:', eventText);
let event;
try {
// Check for non-printable characters or hidden characters that might cause issues
const sanitizedEventText = sanitizeText(eventText);
console.log('Sanitized event text:', sanitizedEventText);
event = JSON.parse(sanitizedEventText);
console.log('Parsed event:', event);
// Validate that it's a proper Nostr event
if (!event || typeof event !== 'object') {
throw new Error('Invalid event object');
}
// Check if it has id, pubkey, and sig properties which are required for a valid Nostr event
if (!event.id || !event.pubkey || !event.sig) {
throw new Error('Event is missing required properties (id, pubkey, or sig)');
}
// Check if pubkey is in npub format and convert it if needed
if (event.pubkey.startsWith('npub')) {
const hexPubkey = convertNpubToHex(event.pubkey);
if (hexPubkey) {
console.log('Converting npub to hex pubkey...');
event.pubkey = hexPubkey;
} else {
throw new Error('Invalid npub format in pubkey');
}
}
// Create a clean event with exactly the fields we need
console.log("Creating a clean event for publishing...");
event = standardizeEvent(event);
console.log("Clean event created:", JSON.stringify(event, null, 2));
} catch (error) {
showError(publishResultDiv, `Invalid event: ${String(error)}`);
return;
}
const relayUrl = publishRelayInput.value.trim();
if (!relayUrl || !relayUrl.startsWith('wss://')) {
showError(publishResultDiv, 'Please enter a valid relay URL (must start with wss://)');
return;
}
// Display loading state
showLoading(publishResultDiv, 'Publishing to relay...');
try {
// Check the event structure
console.log('Full event object before publish:', JSON.stringify(event, null, 2));
// Verify event first
console.log('Verifying event...');
const isValid = verifyEvent(event);
console.log('Event verification result:', isValid);
if (!isValid) {
console.log('Event verification failed. Proceeding anyway as the relay will validate.');
}
// Proceed with publish even if verification failed - the relay will validate
publishResultDiv.innerHTML += '<br><span>Attempting to publish...</span>';
console.log('Attempting to publish event to relay:', relayUrl);
try {
const result = await publishToRelay(event, relayUrl);
console.log('Publish success:', result);
showSuccess(publishResultDiv, result);
} catch (publishError) {
console.error('Publish error:', publishError);
showError(publishResultDiv, String(publishError));
// Don't rethrow, just handle it here so the UI shows the error
}
} catch (error) {
console.error('Error preparing event for publish:', error);
showError(publishResultDiv, `Error preparing event: ${String(error)}`);
}
}
// Initialize the event handlers when the DOM is loaded
document.addEventListener('DOMContentLoaded', function(): void {
// Set up the convert button click handler
const convertButton = document.getElementById('convertButton');
const searchButton = document.getElementById('searchServerBtn');
const publishButton = document.getElementById('publishButton');
if (convertButton) {
convertButton.addEventListener('click', displayConvertedEvent);
}
if (searchButton) {
searchButton.addEventListener('click', handleServerSearch);
}
if (publishButton) {
publishButton.addEventListener('click', handlePublishEvent);
}
// Set default HTTP request
setDefaultHttpRequest();
console.log('HTTP to Nostr converter initialized');
});

@ -7,6 +7,9 @@ declare global {
}
import { defaultServerConfig, appSettings } from './config';
import * as nostrTools from 'nostr-tools';
import qrcode from 'qrcode-generator';
import { convertNpubToHex } from './relay';
import { processTags, showSuccess } from './utils';
// Generate a keypair for standalone mode (when no extension is available)
let standaloneSecretKey: Uint8Array | null = null;
@ -60,16 +63,13 @@ export function convertToEvent(
// The second argument MUST be the server's public key in hex format
let serverPubkeyHex = serverPubkey;
// Convert npub to hex if needed using nostr-tools
// Convert npub to hex if needed
if (serverPubkey.startsWith('npub')) {
try {
const decoded = nostrTools.nip19.decode(serverPubkey);
if (decoded.type === 'npub' && decoded.data) {
serverPubkeyHex = decoded.data;
console.log("Converted npub to hex format:", serverPubkeyHex);
}
} catch (decodeError) {
console.error("Failed to decode npub:", decodeError);
const hexPubkey = convertNpubToHex(serverPubkey);
if (hexPubkey) {
serverPubkeyHex = hexPubkey;
console.log("Converted npub to hex format:", serverPubkeyHex);
} else {
throw new Error("Failed to decode npub. Please use a valid npub format.");
}
}
@ -89,15 +89,31 @@ export function convertToEvent(
console.warn("NIP-44 encryption not available. Content will not be encrypted properly.");
}
// Convert serverPubkey to hex if it's an npub
let pTagValue = serverPubkey;
if (serverPubkey.startsWith('npub')) {
try {
console.log(`Converting p tag npub to hex: ${serverPubkey}`);
const decoded = nostrTools.nip19.decode(serverPubkey);
if (decoded.type === 'npub' && decoded.data) {
pTagValue = decoded.data as string;
console.log(`Converted to hex pubkey: ${pTagValue}`);
}
} catch (error) {
console.error(`Error decoding npub: ${serverPubkey}`, error);
}
}
// Create the event with the proper structure
const event = {
kind: 21120,
pubkey: pubkey,
content: encryptedContent, // Encrypted HTTP request using NIP-44
tags: [
// Required tags per README specification
["p", serverPubkey], // P tag to indicate this is a REQUEST
["p", pTagValue], // P tag with hex pubkey (converted from npub if needed)
["key", decryptkey], // Key for decryption
["expiration", Math.floor(Date.now() / 1000) + appSettings.expirationTime]
["expiration", String(Math.floor(Date.now() / 1000) + appSettings.expirationTime)]
]
};
@ -105,6 +121,9 @@ export function convertToEvent(
if (relay) {
event.tags.push(["r", relay]);
}
// Process tags to ensure proper format (convert any npub in tags to hex, etc.)
event.tags = processTags(event.tags);
return JSON.stringify(event, null, 2);
}
@ -156,6 +175,9 @@ export async function displayConvertedEvent(): Promise<void> {
);
if (convertedEvent) {
// Store the original event in case we need to reference it
(window as any).originalEvent = convertedEvent;
// Parse the event to create a proper Nostr event object for signing
const parsedEvent = JSON.parse(convertedEvent);
const nostrEvent = {
@ -165,6 +187,9 @@ export async function displayConvertedEvent(): Promise<void> {
created_at: Math.floor(Date.now() / 1000),
pubkey: parsedEvent.pubkey
};
// Log the event being signed
console.log("Event to be signed:", JSON.stringify(nostrEvent, null, 2));
let signedEvent;
@ -172,6 +197,7 @@ export async function displayConvertedEvent(): Promise<void> {
try {
// Try to sign with the NIP-07 extension
signedEvent = await window.nostr.signEvent(nostrEvent);
console.log("Event signed with extension:", signedEvent);
} catch (error) {
console.error("Error signing event with extension:", error);
// Fall back to signing with nostr-tools
@ -191,9 +217,211 @@ export async function displayConvertedEvent(): Promise<void> {
outputDiv.hidden = false;
return;
}
// Store the event in a global variable for easier access during publishing
(window as any).currentSignedEvent = signedEvent;
// Display the event JSON
eventOutputPre.textContent = JSON.stringify(signedEvent, null, 2);
// Add a helpful message about publishing the event
const publishRelayInput = document.getElementById('publishRelay') as HTMLInputElement;
if (publishRelayInput) {
const publishResult = document.getElementById('publishResult');
if (publishResult) {
showSuccess(publishResult, 'Event created successfully. Ready to publish!');
}
}
eventOutputPre.textContent = JSON.stringify(signedEvent, null, 2);
// Generate animated QR code using multiple frames
const qrCodeContainer = document.getElementById('qrCode') as HTMLElement;
if (qrCodeContainer) {
try {
// Convert the event to a JSON string
const eventJson = JSON.stringify(signedEvent);
// Calculate how many QR codes we need based on the data size
// Use a much smaller chunk size to ensure it fits in the QR code
const maxChunkSize = 500; // Significantly reduced chunk size
const chunks = [];
// Split the data into chunks
for (let i = 0; i < eventJson.length; i += maxChunkSize) {
chunks.push(eventJson.slice(i, i + maxChunkSize));
}
// Prepare container for the animated QR code
qrCodeContainer.innerHTML = '';
const qrFrameContainer = document.createElement('div');
qrFrameContainer.className = 'qr-frame-container';
qrCodeContainer.appendChild(qrFrameContainer);
// Create QR codes for each chunk with chunk number and total chunks
const qrFrames: HTMLElement[] = [];
chunks.forEach((chunk, index) => {
// Add metadata to identify part of sequence: [current/total]:[data]
const dataWithMeta = `${index + 1}/${chunks.length}:${chunk}`;
try {
// Create QR code with maximum version and lower error correction
const qr = qrcode(15, 'L'); // Version 15 (higher capacity), Low error correction
qr.addData(dataWithMeta);
qr.make();
// Create frame
const frameDiv = document.createElement('div');
frameDiv.className = 'qr-frame';
frameDiv.style.display = index === 0 ? 'block' : 'none';
frameDiv.innerHTML = qr.createSvgTag({
cellSize: 3, // Smaller cell size
margin: 2
});
qrFrameContainer.appendChild(frameDiv);
qrFrames.push(frameDiv);
} catch (qrError) {
console.error(`Error generating QR code for chunk ${index + 1}:`, qrError);
// Create an error frame instead
const errorDiv = document.createElement('div');
errorDiv.className = 'qr-frame qr-error';
errorDiv.style.display = index === 0 ? 'block' : 'none';
errorDiv.innerHTML = `
<div class="error-message">
<p>Error in frame ${index + 1}</p>
<p>Chunk too large for QR code</p>
</div>
`;
qrFrameContainer.appendChild(errorDiv);
qrFrames.push(errorDiv);
}
});
// Add information about the animated QR code
const infoElement = document.createElement('div');
infoElement.innerHTML = `
<p class="qr-info">Animated QR code: ${chunks.length} frames containing full event data</p>
<p class="qr-info current-frame">Showing frame 1 of ${chunks.length}</p>
<p class="qr-info"><small>The QR code will cycle through all frames automatically</small></p>
`;
qrCodeContainer.appendChild(infoElement);
// Animation controls
const controlsDiv = document.createElement('div');
controlsDiv.className = 'qr-controls';
controlsDiv.innerHTML = `
<button id="qrPauseBtn">Pause</button>
<button id="qrPrevBtn"> Prev</button>
<button id="qrNextBtn">Next </button>
`;
qrCodeContainer.appendChild(controlsDiv);
// Set up animation
let currentFrame = 0;
let animationInterval: number | null = null;
let isPaused = false;
const updateFrameInfo = () => {
const frameInfo = qrCodeContainer.querySelector('.current-frame');
if (frameInfo) {
frameInfo.textContent = `Showing frame ${currentFrame + 1} of ${chunks.length}`;
}
};
const showFrame = (index: number) => {
qrFrames.forEach((frame, i) => {
frame.style.display = i === index ? 'block' : 'none';
});
currentFrame = index;
updateFrameInfo();
};
const nextFrame = () => {
showFrame((currentFrame + 1) % qrFrames.length);
};
const prevFrame = () => {
showFrame((currentFrame - 1 + qrFrames.length) % qrFrames.length);
};
// Start the animation
animationInterval = window.setInterval(nextFrame, 2000); // Change frame every 2 seconds
// Set up button event handlers
const pauseBtn = document.getElementById('qrPauseBtn');
const prevBtn = document.getElementById('qrPrevBtn');
const nextBtn = document.getElementById('qrNextBtn');
if (pauseBtn) {
pauseBtn.addEventListener('click', () => {
if (isPaused) {
animationInterval = window.setInterval(nextFrame, 2000);
pauseBtn.textContent = 'Pause';
} else {
if (animationInterval !== null) {
clearInterval(animationInterval);
animationInterval = null;
}
pauseBtn.textContent = 'Play';
}
isPaused = !isPaused;
});
}
if (prevBtn) {
prevBtn.addEventListener('click', () => {
if (isPaused) {
prevFrame();
}
});
}
if (nextBtn) {
nextBtn.addEventListener('click', () => {
if (isPaused) {
nextFrame();
}
});
}
} catch (error) {
console.error("Error generating QR code:", error);
// Create a fallback display with error information and a more compact representation
qrCodeContainer.innerHTML = '';
// Create a backup QR code with just the event ID and relay
try {
const eventId = signedEvent.id || '';
const relay = encodeURIComponent(defaultServerConfig.defaultRelay);
const nostrUri = `nostr:${eventId}?relay=${relay}`;
const qr = qrcode(10, 'M');
qr.addData(nostrUri);
qr.make();
qrCodeContainer.innerHTML = `
<div class="qr-error-container">
<h3>Event Too Large for Animated QR</h3>
<p>Error: ${String(error)}</p>
<p>Using event reference instead:</p>
${qr.createSvgTag({ cellSize: 4, margin: 4 })}
<p class="qr-info">This QR code contains a reference to the event: ${eventId.substring(0, 8)}...</p>
</div>
`;
} catch (fallbackError) {
qrCodeContainer.innerHTML = `
<div class="qr-error-container">
<h3>QR Generation Failed</h3>
<p>Error: ${String(error)}</p>
<p>Try sharing the event ID manually: ${signedEvent.id || 'Unknown'}</p>
</div>
`;
}
qrCodeContainer.innerHTML = `<p>Error generating QR code: ${error}</p>
<p>Try using a URL shortener with the event ID instead.</p>`;
}
}
outputDiv.hidden = false;
}
}
@ -201,9 +429,28 @@ export async function displayConvertedEvent(): Promise<void> {
// Initialize event listeners when the DOM is fully loaded
document.addEventListener('DOMContentLoaded', () => {
// Only set up the click event handler without any automatic encryption
// Set up the click event handler without any automatic encryption
const convertButton = document.getElementById('convertButton');
const publishButton = document.getElementById('publishButton');
if (convertButton) {
convertButton.addEventListener('click', displayConvertedEvent);
}
// Add a handler for the publish button to check if an event is available
if (publishButton) {
publishButton.addEventListener('click', () => {
const eventOutput = document.getElementById('eventOutput');
const publishResult = document.getElementById('publishResult');
if (!eventOutput || !publishResult) {
return;
}
if (!eventOutput.textContent || eventOutput.textContent.trim() === '') {
publishResult.innerHTML = '<span style="color: #cc0000;">You need to convert an HTTP request first</span>';
publishResult.style.display = 'block';
}
});
}
});

258
client/src/relay.ts Normal file

@ -0,0 +1,258 @@
// relay.ts - Functions for communicating with Nostr relays
import * as nostrTools from 'nostr-tools';
/**
* Validate a hex string
* @param str The string to validate
* @param expectedLength The expected length of the hex string
* @returns true if valid, false otherwise
*/
export function isValidHexString(str: string, expectedLength?: number): boolean {
const hexRegex = /^[0-9a-f]*$/i;
if (!hexRegex.test(str)) {
return false;
}
// Check if it has even length (hex strings should have even length)
if (str.length % 2 !== 0) {
return false;
}
// Check expected length if provided
if (expectedLength !== undefined && str.length !== expectedLength) {
return false;
}
return true;
}
/**
* Publish an event to a Nostr relay
* @param event The Nostr event to publish
* @param relayUrl The URL of the relay to publish to
* @returns Promise that resolves to a success or error message
*/
export async function publishToRelay(event: any, relayUrl: string): Promise<string> {
return new Promise<string>((resolve, reject) => {
try {
// Additional validation specifically for publishing
if (!event || typeof event !== 'object') {
reject(new Error('Invalid event object'));
return;
}
// Check required fields
if (!event.id || !event.pubkey || !event.sig) {
reject(new Error('Event missing required fields (id, pubkey, sig)'));
return;
}
// Validate hex strings
if (!isValidHexString(event.id, 64)) {
reject(new Error(`Invalid event ID: ${event.id} (must be 64 hex chars)`));
return;
}
if (!isValidHexString(event.pubkey, 64)) {
reject(new Error(`Invalid pubkey: ${event.pubkey} (must be 64 hex chars)`));
return;
}
if (!isValidHexString(event.sig, 128)) {
reject(new Error(`Invalid signature: ${event.sig} (must be 128 hex chars)`));
return;
}
// Create a relay pool for publishing
const relayPool = new nostrTools.SimplePool();
// Set a timeout for the publish operation
const timeout = setTimeout(() => {
relayPool.close([relayUrl]);
reject(new Error(`Timed out connecting to relay: ${relayUrl}`));
}, 10000); // 10 second timeout
// Publish the event to the relay
console.log(`Publishing event to relay: ${relayUrl}`);
// Create a standard formatted event to ensure it follows the relay protocol
const standardEvent = {
id: event.id,
pubkey: event.pubkey,
created_at: Number(event.created_at),
kind: Number(event.kind),
tags: event.tags,
content: event.content,
sig: event.sig
};
// Make sure all fields are in the correct format
if (typeof standardEvent.created_at !== 'number') {
standardEvent.created_at = Math.floor(Date.now() / 1000);
}
if (typeof standardEvent.kind !== 'number') {
standardEvent.kind = 21120;
}
console.log('Standard event format:', JSON.stringify(standardEvent, null, 2));
// Debug log the important hex values
console.log('ID:', standardEvent.id, 'length:', standardEvent.id.length, 'valid hex?', isValidHexString(standardEvent.id, 64));
console.log('Pubkey:', standardEvent.pubkey, 'length:', standardEvent.pubkey.length, 'valid hex?', isValidHexString(standardEvent.pubkey, 64));
console.log('Sig:', standardEvent.sig, 'length:', standardEvent.sig.length, 'valid hex?', isValidHexString(standardEvent.sig, 128));
// Use the standardized event for publishing
event = standardEvent;
// Ensure all hex strings are valid (this is a sanity check beyond our validation)
if (!isValidHexString(event.id, 64)) {
reject(new Error(`ID is not a valid hex string: ${event.id}`));
return;
}
if (!isValidHexString(event.pubkey, 64)) {
reject(new Error(`Pubkey is not a valid hex string: ${event.pubkey}`));
return;
}
if (!isValidHexString(event.sig, 128)) {
reject(new Error(`Sig is not a valid hex string: ${event.sig}`));
return;
}
// Try direct WebSocket approach as a fallback
try {
// Use the WebSocket API directly
const ws = new WebSocket(relayUrl);
let wsTimeout = setTimeout(() => {
try {
ws.close();
} catch (e) {}
reject(new Error("WebSocket connection timed out"));
}, 10000);
// Create a flag to track if we've handled response
let responseHandled = false;
ws.onopen = () => {
// Send the event directly using the relay protocol format ["EVENT", event]
const messageToSend = JSON.stringify(["EVENT", event]);
console.log("Sending WebSocket message:", messageToSend);
ws.send(messageToSend);
};
ws.onmessage = (msg) => {
console.log("WebSocket message received:", msg.data);
if (responseHandled) {
return; // Skip if we've already handled a response
}
if (typeof msg.data === 'string' && msg.data.startsWith('["OK"')) {
responseHandled = true;
clearTimeout(wsTimeout);
resolve(`Event published successfully via WebSocket`);
try {
ws.close();
} catch (e) {}
} else if (typeof msg.data === 'string' && (msg.data.includes('invalid') || msg.data.includes('error'))) {
responseHandled = true;
clearTimeout(wsTimeout);
console.error("WebSocket error response:", msg.data);
reject(new Error(`Relay rejected event: ${msg.data}`));
try {
ws.close();
} catch (e) {}
} else {
console.log("Received other message, waiting for OK or error response");
}
};
ws.onerror = (error) => {
if (responseHandled) {
return; // Skip if we've already handled a response
}
responseHandled = true;
clearTimeout(wsTimeout);
console.error("WebSocket error:", error);
reject(new Error(`WebSocket error: ${String(error)}`));
try {
ws.close();
} catch (e) {}
};
ws.onclose = () => {
clearTimeout(wsTimeout);
console.log("WebSocket closed");
};
} catch (wsError) {
// If WebSocket fails, try the regular method
console.error("WebSocket approach failed:", wsError);
// Use the nostr-tools publish method as a fallback
console.log("Trying nostr-tools publish method...");
const publishPromises = relayPool.publish([relayUrl], event);
// Use Promise.all to wait for all promises to resolve
Promise.all(publishPromises)
.then((relayResults: string[]) => {
clearTimeout(timeout);
relayPool.close([relayUrl]);
if (relayResults && relayResults.length > 0) {
resolve(`Event published to ${relayResults[0]}`);
} else {
resolve(`Event published successfully`);
}
})
.catch((error: Error) => {
clearTimeout(timeout);
relayPool.close([relayUrl]);
console.error('Error details:', error);
reject(new Error(`Failed to publish event: ${error.message}`));
});
}
} catch (error) {
reject(new Error(`Error setting up relay connection: ${String(error)}`));
}
});
}
/**
* Convert an npub to a hex pubkey
* @param npub The npub to convert
* @returns The hex pubkey
*/
export function convertNpubToHex(npub: string): string | null {
if (!npub.startsWith('npub')) {
return null;
}
try {
const decoded = nostrTools.nip19.decode(npub);
if (decoded.type === 'npub') {
return decoded.data as string;
}
} catch (error) {
console.error('Error decoding npub:', error);
}
return null;
}
/**
* Verify a Nostr event
* @param event The event to verify
* @returns True if valid, false otherwise
*/
export function verifyEvent(event: any): boolean {
try {
return nostrTools.verifyEvent(event);
} catch (error) {
console.error('Error verifying event:', error);
return false;
}
}

154
client/src/search.ts Normal file

@ -0,0 +1,154 @@
// search.ts - Functions for searching for users on Nostr
import * as nostrTools from 'nostr-tools';
// Define popular relays to search for users
export const POPULAR_RELAYS = [
"wss://relay.damus.io",
"wss://relay.nostr.band",
"wss://nos.lol",
"wss://nostr.wine"
];
/**
* Look up a NIP-05 address to find the corresponding public key
* @param nip05Address The NIP-05 address to look up (e.g. user@example.com)
* @returns Promise that resolves to the public key if found, or null if not found
*/
export async function lookupNip05(nip05Address: string): Promise<{pubkey: string, relays?: string[]} | null> {
try {
// Use the NIP-05 lookup function from nostr-tools
const result = await nostrTools.nip05.queryProfile(nip05Address);
// If the result has a pubkey, return it
if (result && result.pubkey) {
return result;
}
return null;
} catch (error) {
console.error("Error looking up NIP-05 address:", error);
return null;
}
}
/**
* Search for users based on a search term across popular relays
* @param searchTerm The username or NIP-05 identifier to search for
* @returns Promise that resolves to an array of user profiles
*/
export async function searchUsers(searchTerm: string): Promise<Array<{name: string, pubkey: string, npub: string, nip05?: string}>> {
const results: Array<{name: string, pubkey: string, npub: string, nip05?: string}> = [];
const processedPubkeys = new Set<string>();
// First, check if the input might already be an npub
if (searchTerm.startsWith('npub')) {
try {
const decoded = nostrTools.nip19.decode(searchTerm);
if (decoded.type === 'npub' && decoded.data) {
// It's a valid npub, no need to search
return [{
name: 'Valid npub',
pubkey: decoded.data,
npub: searchTerm
}];
}
} catch (error) {
// Not a valid npub, continue with search
console.log("Not a valid npub, continuing with search");
}
}
// Check if the search term looks like a NIP-05 identifier
if (searchTerm.includes('@')) {
try {
const nip05Result = await lookupNip05(searchTerm);
if (nip05Result && nip05Result.pubkey) {
const npub = nostrTools.nip19.npubEncode(nip05Result.pubkey);
results.push({
name: searchTerm,
pubkey: nip05Result.pubkey,
npub: npub,
nip05: searchTerm
});
processedPubkeys.add(nip05Result.pubkey);
}
} catch (error) {
console.error("Error looking up NIP-05:", error);
}
}
// Create a pool of relays to search
const relayPool = new nostrTools.SimplePool();
try {
// Create a filter for the subscription
const filter = {
kinds: [0], // Only metadata events
limit: 20 // Limit to 20 results
};
// Set a timeout to ensure we don't wait forever
const timeoutPromise = new Promise<void>((resolve) => {
setTimeout(() => resolve(), 5000); // 5 second timeout
});
// Create an event handler
const eventHandler = (event: any) => {
try {
// Skip if we've already processed this pubkey
if (processedPubkeys.has(event.pubkey)) {
return;
}
// Parse the profile metadata from content
const profile = JSON.parse(event.content);
// Check if the profile matches our search term
const searchTermLower = searchTerm.toLowerCase();
const nameLower = (profile.name || '').toLowerCase();
const displayNameLower = (profile.display_name || '').toLowerCase();
const nip05Lower = (profile.nip05 || '').toLowerCase();
if (
nameLower.includes(searchTermLower) ||
displayNameLower.includes(searchTermLower) ||
nip05Lower.includes(searchTermLower)
) {
// Add to results
const npub = nostrTools.nip19.npubEncode(event.pubkey);
results.push({
name: profile.display_name || profile.name || 'Unknown',
pubkey: event.pubkey,
npub: npub,
nip05: profile.nip05
});
// Mark as processed
processedPubkeys.add(event.pubkey);
}
} catch (error) {
console.error("Error processing event:", error);
}
};
// Subscribe to events matching the filter
const sub = relayPool.subscribeMany(
POPULAR_RELAYS,
[filter],
{ onevent: eventHandler }
);
// Wait for timeout
await timeoutPromise;
// Close the subscription
sub.close();
relayPool.close(POPULAR_RELAYS);
} catch (error) {
console.error("Error searching relays:", error);
}
return results;
}

@ -1,3 +1,213 @@
/* Styles for the HTTP to Nostr converter */
/* General layout */
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 20px;
background-color: #f8f9fa;
color: #212529;
line-height: 1.6;
}
/* Headings */
h1 {
color: #343a40;
margin-bottom: 20px;
border-bottom: 2px solid #6c757d;
padding-bottom: 10px;
}
h2 {
color: #495057;
margin-top: 25px;
margin-bottom: 15px;
}
/* Info box */
.info-box {
background-color: #e2f0fd;
border-left: 4px solid #0d6efd;
padding: 10px 15px;
margin-bottom: 20px;
border-radius: 0 4px 4px 0;
}
/* Form elements */
input[type="text"], textarea {
width: 100%;
padding: 8px 10px;
margin-bottom: 15px;
border: 1px solid #ced4da;
border-radius: 4px;
font-size: 14px;
}
textarea {
height: 150px;
font-family: monospace;
}
button {
background-color: #0d6efd;
color: white;
border: none;
padding: 10px 20px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: background-color 0.2s;
}
button:hover {
background-color: #0b5ed7;
}
/* Server input section */
.server-input-container {
display: flex;
gap: 8px;
margin-top: 5px;
}
.server-input {
flex-grow: 1;
margin-bottom: 0;
}
.server-search-button {
padding: 8px 15px;
}
.server-search-result {
margin-top: 10px;
padding: 10px;
background-color: #ffffff;
border-radius: 4px;
border: 1px solid #dee2e6;
}
/* Output section */
#output {
margin-top: 30px;
padding: 15px;
background-color: #ffffff;
border-radius: 4px;
border: 1px solid #dee2e6;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
}
pre {
background-color: #f8f9fa;
padding: 15px;
border-radius: 4px;
overflow-x: auto;
font-family: 'Courier New', Courier, monospace;
font-size: 14px;
line-height: 1.4;
border: 1px solid #dee2e6;
}
/* Publish container */
.publish-container {
margin-top: 20px;
margin-bottom: 20px;
padding: 15px;
background-color: #f1f8ff;
border-radius: 4px;
border: 1px solid #b3d7ff;
}
.publish-input-container {
display: flex;
gap: 8px;
margin-top: 10px;
margin-bottom: 10px;
}
.publish-input {
flex-grow: 1;
padding: 8px 10px;
border: 1px solid #ced4da;
border-radius: 4px;
font-size: 14px;
}
.publish-button {
background-color: #28a745;
color: white;
border: none;
padding: 8px 15px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: background-color 0.2s;
}
.publish-button:hover {
background-color: #218838;
}
.publish-result {
margin-top: 10px;
padding: 10px;
background-color: #ffffff;
border-radius: 4px;
border: 1px solid #dee2e6;
}
/* QR code container */
.qr-container {
margin-top: 20px;
text-align: center;
}
#qrCode {
margin: 0 auto;
max-width: 100%;
}
/* Search results styling */
.search-results-list {
max-height: 300px;
overflow-y: auto;
}
.search-result-item {
padding: 10px;
border-bottom: 1px solid #dee2e6;
display: flex;
flex-direction: column;
gap: 5px;
position: relative;
}
.search-result-item:last-child {
border-bottom: none;
}
.result-name {
font-weight: bold;
}
.result-npub {
font-family: monospace;
font-size: 12px;
color: #6c757d;
}
.result-nip05 {
font-size: 12px;
color: #0d6efd;
}
.use-npub-btn {
align-self: flex-end;
padding: 5px 10px;
font-size: 12px;
margin-top: 5px;
}
body {
font-family: Arial, sans-serif;
max-width: 800px;
@ -38,6 +248,91 @@ button:hover {
background-color: #f9f9f9;
}
.qr-container {
margin-top: 30px;
text-align: center;
}
#qrCode {
margin: 0 auto;
display: block;
background-color: white;
padding: 15px;
border-radius: 4px;
text-align: center;
}
.qr-info {
margin-top: 8px;
font-size: 14px;
color: #555;
text-align: center;
}
.qr-frame-container {
width: 300px;
height: 300px;
margin: 0 auto;
position: relative;
}
.qr-frame {
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
transition: opacity 0.5s ease;
}
.qr-controls {
margin: 15px auto;
text-align: center;
}
.qr-controls button {
margin: 0 5px;
padding: 8px 15px;
background-color: #4CAF50;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.qr-controls button:hover {
background-color: #45a049;
}
.qr-error {
background-color: #ffeeee;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid #ffcccc;
border-radius: 4px;
}
.error-message {
text-align: center;
color: #cc0000;
padding: 20px;
}
.qr-error-container {
padding: 20px;
background-color: #f8f8f8;
border: 1px solid #ddd;
border-radius: 4px;
text-align: center;
}
.qr-error-container h3 {
color: #cc0000;
margin-top: 0;
}
pre {
background-color: #f5f5f5;
padding: 10px;
@ -60,4 +355,70 @@ h1 {
h2 {
color: #555;
}
/* NIP-05 lookup styles */
.nip05-section {
margin: 10px 0;
padding: 10px;
background-color: #f5f5f5;
border-radius: 4px;
border-left: 3px solid #4CAF50;
}
.nip05-input-container {
display: flex;
margin-top: 5px;
}
.nip05-input {
flex-grow: 1;
padding: 8px;
margin-right: 8px;
border: 1px solid #ddd;
border-radius: 4px;
}
.nip05-button {
white-space: nowrap;
background-color: #4CAF50;
color: white;
border: none;
border-radius: 4px;
padding: 8px 15px;
cursor: pointer;
}
.nip05-button:hover {
background-color: #45a049;
}
.nip05-result {
margin-top: 8px;
font-size: 14px;
padding: 5px;
border-radius: 4px;
}
.nip05-success {
color: #008800;
}
.nip05-error {
color: #cc0000;
}
.use-pubkey-button {
margin-left: 10px;
font-size: 12px;
padding: 3px 8px;
background-color: #4CAF50;
color: white;
border: none;
border-radius: 3px;
cursor: pointer;
}
.use-pubkey-button:hover {
background-color: #45a049;
}

124
client/src/utils.ts Normal file

@ -0,0 +1,124 @@
// utils.ts - Utility functions for the HTTP to Nostr converter
/**
* Populate the HTTP request textarea with a default example
*/
export function setDefaultHttpRequest(): void {
const httpRequestBox = document.getElementById('httpRequest') as HTMLTextAreaElement;
if (httpRequestBox) {
// Only set default if the textarea is empty
if (!httpRequestBox.value.trim()) {
const defaultRequest = `GET /index.html HTTP/1.1
Host: example.com
User-Agent: NostrClient/1.0
Accept: text/html,application/xhtml+xml,application/xml
Connection: keep-alive
`;
httpRequestBox.value = defaultRequest;
}
}
}
/**
* Sanitize text to remove non-printable characters
* @param text Text to sanitize
* @returns Sanitized text
*/
export function sanitizeText(text: string): string {
return text.replace(/[^\x20-\x7E]/g, '');
}
/**
* Process event tags to convert any npub values in "p" tags to hex pubkeys
* @param tags The event tags to process
* @returns The processed tags
*/
export function processTags(tags: string[][]): string[][] {
if (!tags || !Array.isArray(tags)) {
return tags;
}
// Make a deep copy of tags to avoid modifying the original
const processedTags = JSON.parse(JSON.stringify(tags));
for (let i = 0; i < processedTags.length; i++) {
const tag = processedTags[i];
if (!tag || !Array.isArray(tag) || tag.length < 2) {
continue;
}
// Convert npub in "p" tags to hex pubkeys
if (tag[0] === 'p' && typeof tag[1] === 'string' && tag[1].startsWith('npub')) {
try {
console.log(`Processing p tag: ${tag[1]}`);
const { convertNpubToHex } = require('./relay');
const hexPubkey = convertNpubToHex(tag[1]);
if (hexPubkey) {
processedTags[i][1] = hexPubkey;
console.log(`Converted npub to hex: ${hexPubkey}`);
}
} catch (error) {
console.error(`Error processing p tag: ${tag[1]}`, error);
}
}
// Ensure expiration tag value is a string
if (tag[0] === 'expiration' && tag[1]) {
processedTags[i][1] = String(tag[1]);
}
}
return processedTags;
}
/**
* Create a standardized event object with proper types
* @param event The event to standardize
* @returns Standardized event
*/
export function standardizeEvent(event: any): any {
if (!event || typeof event !== 'object') {
return event;
}
return {
id: event.id,
pubkey: event.pubkey,
created_at: Number(event.created_at),
kind: Number(event.kind),
tags: processTags(event.tags),
content: event.content,
sig: event.sig
};
}
/**
* Display an error message in the specified element
* @param element The element to display the error in
* @param message The error message
*/
export function showError(element: HTMLElement, message: string): void {
element.innerHTML = `<span style="color: #cc0000;">Error: ${message}</span>`;
element.style.display = 'block';
}
/**
* Display a success message in the specified element
* @param element The element to display the success message in
* @param message The success message
*/
export function showSuccess(element: HTMLElement, message: string): void {
element.innerHTML = `<span style="color: #008800;">✓ ${message}</span>`;
element.style.display = 'block';
}
/**
* Display a loading message in the specified element
* @param element The element to display the loading message in
* @param message The loading message
*/
export function showLoading(element: HTMLElement, message: string = 'Processing...'): void {
element.innerHTML = `<span>${message}</span>`;
element.style.display = 'block';
}

@ -1,6 +1,8 @@
const path = require('path');
const CopyPlugin = require('copy-webpack-plugin');
const webpack = require('webpack');
// Polyfills for Node.js core modules in the browser
const NodePolyfillPlugin = require('node-polyfill-webpack-plugin');
module.exports = {
mode: 'development',
@ -21,10 +23,7 @@ module.exports = {
],
},
plugins: [
new webpack.ProvidePlugin({
Buffer: ['buffer', 'Buffer'],
process: 'process/browser',
}),
new NodePolyfillPlugin(),
new CopyPlugin({
patterns: [
{ from: 'src/styles.css', to: 'styles.css' },
@ -33,20 +32,12 @@ module.exports = {
],
}),
],
resolve: {
extensions: ['.tsx', '.ts', '.js'],
},
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, 'dist'),
},
resolve: {
extensions: ['.tsx', '.ts', '.js'],
fallback: {
"crypto": false,
"buffer": false,
"stream": false
}
},
devServer: {
static: {