Compare commits
3 Commits
2f899341f5
...
7939eee96f
Author | SHA1 | Date | |
---|---|---|---|
|
7939eee96f | ||
|
c976746b07 | ||
|
6a5b9805bc |
5
.clinerules
Normal file
5
.clinerules
Normal file
@ -0,0 +1,5 @@
|
||||
always use Typescript where relevant
|
||||
don't try to modify files in the build folder (eg /dist)
|
||||
don't put css or JS in the index.html - we should follow appropriate CSP policies
|
||||
run the lint after every code update
|
||||
don't ever use the alert function
|
34
.gitignore
vendored
Normal file
34
.gitignore
vendored
Normal file
@ -0,0 +1,34 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# Build output
|
||||
/dist/
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.*
|
||||
|
||||
# IDE files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea/
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
4
.husky/pre-commit
Executable file
4
.husky/pre-commit
Executable file
@ -0,0 +1,4 @@
|
||||
#!/bin/sh
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
cd client && npm run validate
|
26
client/.gitignore
vendored
Normal file
26
client/.gitignore
vendored
Normal file
@ -0,0 +1,26 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.*
|
||||
|
||||
# IDE files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea/
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
73
client/eslint.config.js
Normal file
73
client/eslint.config.js
Normal file
@ -0,0 +1,73 @@
|
||||
import tseslint from '@typescript-eslint/eslint-plugin';
|
||||
import tsparser from '@typescript-eslint/parser';
|
||||
import importPlugin from 'eslint-plugin-import';
|
||||
import js from '@eslint/js';
|
||||
|
||||
export default [
|
||||
js.configs.recommended,
|
||||
{
|
||||
ignores: [
|
||||
'dist/**',
|
||||
'build/**',
|
||||
'node_modules/**',
|
||||
'*.min.js',
|
||||
'.eslintcache',
|
||||
'*.tsbuildinfo',
|
||||
'.vscode/**',
|
||||
'.idea/**'
|
||||
]
|
||||
},
|
||||
{
|
||||
files: ['**/*.ts'],
|
||||
languageOptions: {
|
||||
parser: tsparser,
|
||||
parserOptions: {
|
||||
ecmaVersion: 2020,
|
||||
sourceType: 'module',
|
||||
project: './tsconfig.json'
|
||||
},
|
||||
ecmaVersion: 2020,
|
||||
sourceType: 'module',
|
||||
globals: {
|
||||
document: 'readonly',
|
||||
navigator: 'readonly',
|
||||
window: 'readonly',
|
||||
console: 'readonly',
|
||||
alert: 'readonly'
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
'@typescript-eslint': tseslint,
|
||||
'import': importPlugin
|
||||
},
|
||||
rules: {
|
||||
// TypeScript specific rules
|
||||
'@typescript-eslint/explicit-module-boundary-types': 'warn',
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
'@typescript-eslint/no-unused-vars': ['error', { 'argsIgnorePattern': '^_' }],
|
||||
'@typescript-eslint/consistent-type-definitions': ['error', 'interface'],
|
||||
'@typescript-eslint/consistent-type-imports': 'error',
|
||||
|
||||
// Import rules
|
||||
'import/no-unresolved': 'off', // Turn off because we're using TypeScript paths
|
||||
'import/named': 'error',
|
||||
'import/default': 'error',
|
||||
'import/namespace': 'error',
|
||||
'import/order': [
|
||||
'error',
|
||||
{
|
||||
'groups': ['builtin', 'external', 'internal', 'parent', 'sibling', 'index'],
|
||||
'newlines-between': 'always',
|
||||
'alphabetize': { 'order': 'asc', 'caseInsensitive': true }
|
||||
}
|
||||
],
|
||||
|
||||
// General ESLint rules
|
||||
'no-console': 'warn',
|
||||
'eqeqeq': ['error', 'always'],
|
||||
'no-var': 'error',
|
||||
'prefer-const': 'error',
|
||||
'curly': 'error',
|
||||
}
|
||||
}
|
||||
];
|
BIN
client/http.png
Normal file
BIN
client/http.png
Normal file
Binary file not shown.
After ![]() (image error) Size: 35 KiB |
105
client/index-dist.html
Normal file
105
client/index-dist.html
Normal file
@ -0,0 +1,105 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<!-- Implement strict Content Security Policy -->
|
||||
<title>HTTP to Kind 21120 Converter</title>
|
||||
<!-- Include the Nostr libraries -->
|
||||
<script src='https://www.unpkg.com/nostr-login@latest/dist/unpkg.js'></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/nostr-tools@latest"></script>
|
||||
<!-- Load our external TypeScript file (compiled to JS) -->
|
||||
<script src="./src/client.js" type="module"></script>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
textarea {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
font-family: monospace;
|
||||
padding: 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
button {
|
||||
background-color: #4CAF50;
|
||||
color: white;
|
||||
padding: 10px 15px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
}
|
||||
button:hover {
|
||||
background-color: #45a049;
|
||||
}
|
||||
#output {
|
||||
margin-top: 20px;
|
||||
padding: 15px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
pre {
|
||||
background-color: #f5f5f5;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
.info-box {
|
||||
background-color: #e7f3fe;
|
||||
border-left: 6px solid #2196F3;
|
||||
padding: 10px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
h1 {
|
||||
color: #333;
|
||||
}
|
||||
h2 {
|
||||
color: #555;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>HTTP Request to Kind 21120 Converter</h1>
|
||||
|
||||
<div class="info-box">
|
||||
<p>This tool converts HTTP requests into a single Nostr event of kind 21120. The HTTP request is encrypted using NIP-44 in the content field with appropriate tags following the specification.</p>
|
||||
<p>The server's public key (<strong>npub1r6knexka25dn9w9jnf5kf8xh6gfq7n3p38zfl7nn7cjjjsp4umcqnk0aun</strong>) is pre-populated for your convenience.</p>
|
||||
</div>
|
||||
|
||||
<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;">
|
||||
</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;">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Enter HTTP Request:</h2>
|
||||
<textarea id="httpRequest" placeholder="GET /index.html HTTP/1.1
|
||||
Host: example.com
|
||||
User-Agent: Browser/1.0
|
||||
|
||||
"></textarea>
|
||||
|
||||
<button id="convertButton">Convert to Event</button>
|
||||
|
||||
<div id="output" hidden>
|
||||
<h2>Converted Event:</h2>
|
||||
<pre id="eventOutput"></pre>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
84
client/index.html
Normal file
84
client/index.html
Normal file
@ -0,0 +1,84 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<!-- Implement strict Content Security Policy -->
|
||||
<title>HTTP to Kind 21120 Converter</title>
|
||||
<!-- Load our CSS file -->
|
||||
<link rel="stylesheet" href="./styles.css">
|
||||
<!-- Include the Nostr extensions - these will be accessed via window.nostr -->
|
||||
<script defer src="./bundle.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Theme toggle button -->
|
||||
<div class="theme-toggle-container">
|
||||
<button id="themeToggleBtn" class="theme-toggle-btn">
|
||||
<span id="themeIcon">🌙</span>
|
||||
<span id="themeText">Dark Mode</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h1>HTTP Request to Kind 21120 Converter</h1>
|
||||
|
||||
<div class="info-box">
|
||||
<p>This tool converts HTTP requests into a single Nostr event of kind 21120. The HTTP request is encrypted using NIP-44 in the content field with appropriate tags following the specification.</p>
|
||||
</div>
|
||||
|
||||
<div class="login-container">
|
||||
<!-- NostrLogin container will be inserted here -->
|
||||
<div id="loginStatus" class="login-status"></div>
|
||||
</div>
|
||||
|
||||
<h2>Server Information:</h2>
|
||||
<div style="margin-bottom: 15px;">
|
||||
<div style="margin-bottom: 10px;">
|
||||
<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;">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Enter HTTP Request:</h2>
|
||||
<textarea id="httpRequest" placeholder="GET /index.html HTTP/1.1
|
||||
Host: example.com
|
||||
User-Agent: Browser/1.0
|
||||
|
||||
"></textarea>
|
||||
|
||||
<button id="convertButton">Convert to Event</button>
|
||||
|
||||
<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>
|
6535
client/package-lock.json
generated
Normal file
6535
client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
53
client/package.json
Normal file
53
client/package.json
Normal file
@ -0,0 +1,53 @@
|
||||
{
|
||||
"name": "http-to-nostr-client",
|
||||
"version": "1.0.0",
|
||||
"description": "A client tool to convert HTTP requests to Nostr Kind 21120 events",
|
||||
"main": "dist/index.html",
|
||||
"scripts": {
|
||||
"build": "webpack --mode production",
|
||||
"copy-assets": "mkdir -p dist && cp index.html dist/ && cp http.png dist/ 2>/dev/null || true",
|
||||
"dev": "webpack serve --open",
|
||||
"start": "npm run build && npm run copy-assets && npx serve dist",
|
||||
"clean": "rm -rf dist",
|
||||
"lint": "eslint . --ext .ts",
|
||||
"lint:fix": "eslint . --ext .ts --fix",
|
||||
"validate": "npm run lint && tsc --noEmit",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"keywords": [
|
||||
"nostr",
|
||||
"http",
|
||||
"converter"
|
||||
],
|
||||
"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",
|
||||
"copy-webpack-plugin": "^13.0.0",
|
||||
"crypto-browserify": "^3.12.1",
|
||||
"css-loader": "^7.1.2",
|
||||
"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",
|
||||
"style-loader": "^4.0.0",
|
||||
"ts-loader": "^9.5.2",
|
||||
"typescript": "^5.0.0",
|
||||
"webpack": "^5.98.0",
|
||||
"webpack-cli": "^6.0.1",
|
||||
"webpack-dev-server": "^5.2.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"nostr-login": "^1.7.11",
|
||||
"nostr-tools": "^2.12.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"qrcode-generator": "^1.4.4"
|
||||
}
|
||||
}
|
405
client/src/client.ts
Normal file
405
client/src/client.ts
Normal file
@ -0,0 +1,405 @@
|
||||
// client.ts - External TypeScript file for HTTP to Nostr converter
|
||||
// This follows strict CSP policies by avoiding inline scripts
|
||||
|
||||
console.log('client.ts loaded');
|
||||
|
||||
// Import functions from other modules
|
||||
import { displayConvertedEvent } from './converter';
|
||||
import { lookupNip05, searchUsers } from './search';
|
||||
import { publishToRelay, convertNpubToHex, verifyEvent } from './relay';
|
||||
import * as nostrTools from 'nostr-tools';
|
||||
import {
|
||||
setDefaultHttpRequest,
|
||||
sanitizeText,
|
||||
processTags,
|
||||
standardizeEvent,
|
||||
showError,
|
||||
showSuccess,
|
||||
showLoading
|
||||
} from './utils';
|
||||
|
||||
// Import nostr-login - use require since the default export might not work with import
|
||||
const NostrLogin = require('nostr-login');
|
||||
console.log('NostrLogin library imported:', NostrLogin);
|
||||
console.log('NostrLogin methods:', Object.keys(NostrLogin));
|
||||
console.log('NostrLogin init method:', NostrLogin.init);
|
||||
|
||||
// Check for encryption methods
|
||||
if (NostrLogin.nip04) console.log('NIP-04 encryption available:', NostrLogin.nip04);
|
||||
if (NostrLogin.nip44) console.log('NIP-44 encryption available:', NostrLogin.nip44);
|
||||
if (NostrLogin.encrypt) console.log('Direct encrypt method available:', NostrLogin.encrypt);
|
||||
console.log('NostrLogin init method:', NostrLogin.init);
|
||||
|
||||
/**
|
||||
* Initialize nostr-login
|
||||
*/
|
||||
function initNostrLogin() {
|
||||
console.log('Initializing NostrLogin');
|
||||
const loginContainer = document.querySelector('.login-container');
|
||||
const loginStatusDiv = document.getElementById('loginStatus');
|
||||
|
||||
if (!loginContainer || !loginStatusDiv) {
|
||||
console.error('Login elements not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a container for the NostrLogin button
|
||||
const nostrLoginContainer = document.createElement('div');
|
||||
nostrLoginContainer.id = 'nostr-login-container';
|
||||
loginContainer.appendChild(nostrLoginContainer);
|
||||
|
||||
try {
|
||||
// Initialize NostrLogin with the container
|
||||
if (NostrLogin && NostrLogin.init) {
|
||||
console.log('Initializing NostrLogin.init with container');
|
||||
NostrLogin.init({
|
||||
element: nostrLoginContainer,
|
||||
onConnect: (pubkey: string) => {
|
||||
console.log('Connected with pubkey:', pubkey);
|
||||
const npub = nostrTools.nip19.npubEncode(pubkey);
|
||||
loginStatusDiv.innerHTML = `<span style="color: #008800;">Connected as: ${npub.slice(0, 8)}...${npub.slice(-4)}</span>`;
|
||||
},
|
||||
onDisconnect: () => {
|
||||
console.log('Disconnected');
|
||||
loginStatusDiv.innerHTML = '<span>Disconnected</span>';
|
||||
}
|
||||
});
|
||||
} else {
|
||||
console.error('NostrLogin.init is not available');
|
||||
loginStatusDiv.innerHTML = '<span style="color: #cc0000;">NostrLogin initialization unavailable</span>';
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Failed to initialize NostrLogin:', error);
|
||||
loginStatusDiv.innerHTML = `<span style="color: #cc0000;">Error initializing Nostr login: ${error.message || String(error)}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle between light and dark theme
|
||||
*/
|
||||
function toggleTheme(): void {
|
||||
const body = document.body;
|
||||
const themeToggle = document.getElementById('themeToggle');
|
||||
const themeToggleBtn = document.getElementById('themeToggleBtn');
|
||||
const themeIcon = document.getElementById('themeIcon');
|
||||
const themeText = document.getElementById('themeText');
|
||||
|
||||
const isDarkMode = body.getAttribute('data-theme') === 'dark';
|
||||
|
||||
if (isDarkMode) {
|
||||
// Switch to light theme
|
||||
body.removeAttribute('data-theme');
|
||||
localStorage.setItem('theme', 'light');
|
||||
|
||||
// Update old toggle if it exists
|
||||
if (themeToggle) {
|
||||
const toggleText = themeToggle.querySelector('.theme-toggle-text');
|
||||
const toggleIcon = themeToggle.querySelector('.theme-toggle-icon');
|
||||
if (toggleText) toggleText.textContent = 'Dark Mode';
|
||||
if (toggleIcon) toggleIcon.textContent = '🌓';
|
||||
}
|
||||
|
||||
// Update new toggle button if it exists
|
||||
if (themeIcon) themeIcon.textContent = '🌙';
|
||||
if (themeText) themeText.textContent = 'Dark Mode';
|
||||
} else {
|
||||
// Switch to dark theme
|
||||
body.setAttribute('data-theme', 'dark');
|
||||
localStorage.setItem('theme', 'dark');
|
||||
|
||||
// Update old toggle if it exists
|
||||
if (themeToggle) {
|
||||
const toggleText = themeToggle.querySelector('.theme-toggle-text');
|
||||
const toggleIcon = themeToggle.querySelector('.theme-toggle-icon');
|
||||
if (toggleText) toggleText.textContent = 'Light Mode';
|
||||
if (toggleIcon) toggleIcon.textContent = '☀️';
|
||||
}
|
||||
|
||||
// Update new toggle button if it exists
|
||||
if (themeIcon) themeIcon.textContent = '☀️';
|
||||
if (themeText) themeText.textContent = 'Light Mode';
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize the event handlers when the DOM is loaded
|
||||
document.addEventListener('DOMContentLoaded', function(): void {
|
||||
console.log('DOM content loaded');
|
||||
// Set up the convert button click handler
|
||||
const convertButton = document.getElementById('convertButton');
|
||||
const searchButton = document.getElementById('searchServerBtn');
|
||||
const publishButton = document.getElementById('publishButton');
|
||||
|
||||
// Debug - list all buttons in the document
|
||||
console.log('All buttons in document:');
|
||||
document.querySelectorAll('button').forEach((button, index) => {
|
||||
console.log(`Button ${index}:`, button.id, button.textContent);
|
||||
});
|
||||
|
||||
if (convertButton) {
|
||||
convertButton.addEventListener('click', displayConvertedEvent);
|
||||
}
|
||||
|
||||
if (searchButton) {
|
||||
searchButton.addEventListener('click', handleServerSearch);
|
||||
}
|
||||
|
||||
if (publishButton) {
|
||||
publishButton.addEventListener('click', handlePublishEvent);
|
||||
}
|
||||
|
||||
// Initialize Nostr login
|
||||
initNostrLogin();
|
||||
|
||||
// Set default HTTP request
|
||||
setDefaultHttpRequest();
|
||||
|
||||
// Initialize theme toggle
|
||||
const themeToggle = document.getElementById('themeToggle');
|
||||
const themeToggleBtn = document.getElementById('themeToggleBtn');
|
||||
|
||||
// First try the new button, then fall back to the old toggle
|
||||
const toggleElement = themeToggleBtn || themeToggle;
|
||||
|
||||
if (toggleElement) {
|
||||
// Set initial theme based on local storage
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
if (savedTheme === 'dark') {
|
||||
document.body.setAttribute('data-theme', 'dark');
|
||||
|
||||
// Update UI for whichever toggle we're using
|
||||
if (themeToggleBtn) {
|
||||
const themeIcon = document.getElementById('themeIcon');
|
||||
const themeText = document.getElementById('themeText');
|
||||
if (themeIcon) themeIcon.textContent = '☀️';
|
||||
if (themeText) themeText.textContent = 'Light Mode';
|
||||
} else if (themeToggle) {
|
||||
const toggleText = themeToggle.querySelector('.theme-toggle-text');
|
||||
const toggleIcon = themeToggle.querySelector('.theme-toggle-icon');
|
||||
if (toggleText) toggleText.textContent = 'Light Mode';
|
||||
if (toggleIcon) toggleIcon.textContent = '☀️';
|
||||
}
|
||||
}
|
||||
|
||||
// Add click handler
|
||||
toggleElement.addEventListener('click', toggleTheme);
|
||||
}
|
||||
|
||||
console.log('HTTP to Nostr converter initialized');
|
||||
});
|
17
client/src/config.ts
Normal file
17
client/src/config.ts
Normal file
@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Client-side configuration for HTTP to Nostr converter
|
||||
*/
|
||||
|
||||
// Default server information
|
||||
export const defaultServerConfig = {
|
||||
// Server's npub (hard-coded to match the server's config)
|
||||
serverNpub: "npub1r6knexka25dn9w9jnf5kf8xh6gfq7n3p38zfl7nn7cjjjsp4umcqnk0aun",
|
||||
// Default relay for responses
|
||||
defaultRelay: "wss://relay.damus.io"
|
||||
};
|
||||
|
||||
// Application settings
|
||||
export const appSettings = {
|
||||
// Expiration time for events in seconds (1 hour)
|
||||
expirationTime: 3600
|
||||
};
|
581
client/src/converter.ts
Normal file
581
client/src/converter.ts
Normal file
@ -0,0 +1,581 @@
|
||||
// Type definition for the window.nostrTools object
|
||||
declare global {
|
||||
interface Window {
|
||||
nostrTools: any;
|
||||
nostr: any;
|
||||
}
|
||||
}
|
||||
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';
|
||||
|
||||
// Import nostr-login for encryption/signing
|
||||
const NostrLogin = require('nostr-login');
|
||||
|
||||
// Define interface for Nostr event
|
||||
interface NostrEvent {
|
||||
kind: number;
|
||||
content: string;
|
||||
tags: string[][];
|
||||
created_at: number;
|
||||
pubkey: string;
|
||||
id?: string;
|
||||
sig?: string;
|
||||
}
|
||||
|
||||
// Generate a keypair for standalone mode (when no extension is available)
|
||||
let standaloneSecretKey: Uint8Array | null = null;
|
||||
let standalonePublicKey: string | null = null;
|
||||
|
||||
// Initialize the keypair
|
||||
function initStandaloneKeypair(): { publicKey: string, secretKey: Uint8Array } {
|
||||
if (!standaloneSecretKey) {
|
||||
standaloneSecretKey = nostrTools.generateSecretKey();
|
||||
standalonePublicKey = nostrTools.getPublicKey(standaloneSecretKey);
|
||||
}
|
||||
|
||||
return {
|
||||
publicKey: standalonePublicKey!,
|
||||
secretKey: standaloneSecretKey
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an HTTP request string and converts it directly to a kind 21120 Nostr event
|
||||
* Following the specification in README.md
|
||||
*
|
||||
* @param httpRequest The raw HTTP request string
|
||||
* @param pubkey The user's public key
|
||||
* @param serverPubkey The public key of the remote HTTP server
|
||||
* @param decryptkey The encryption key (required per specification)
|
||||
* @param relay Optional relay for the response
|
||||
* @returns Stringified Nostr event or null if conversion fails
|
||||
*/
|
||||
export function convertToEvent(
|
||||
httpRequest: string,
|
||||
pubkey: string,
|
||||
serverPubkey: string,
|
||||
decryptkey: string,
|
||||
relay?: string
|
||||
): string | null {
|
||||
console.log("convertToEvent called with httpRequest:", httpRequest.substring(0, 50) + "...");
|
||||
|
||||
if (!httpRequest || httpRequest.trim() === '') {
|
||||
alert('Please enter an HTTP request message.');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Create a single kind 21120 event with encrypted HTTP request as content
|
||||
// Following the specification in README.md using actual NIP-44 encryption
|
||||
|
||||
// Use the nostr-login library for encryption
|
||||
let encryptedContent = httpRequest;
|
||||
|
||||
try {
|
||||
// Convert server pubkey to hex if it's an npub
|
||||
let serverPubkeyHex = serverPubkey;
|
||||
|
||||
if (serverPubkey.startsWith('npub')) {
|
||||
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.");
|
||||
}
|
||||
}
|
||||
|
||||
// Validate that we have a hex string of the right length
|
||||
if (!/^[0-9a-f]{64}$/i.test(serverPubkeyHex)) {
|
||||
throw new Error("Invalid server pubkey format. Must be a 64-character hex string.");
|
||||
}
|
||||
|
||||
// Check for the most specific NIP-44 methods first, then fall back to more general ones
|
||||
if (NostrLogin && NostrLogin.nip44 && NostrLogin.nip44.encrypt) {
|
||||
console.log("Using NostrLogin.nip44.encrypt for encryption");
|
||||
try {
|
||||
const encrypted = NostrLogin.nip44.encrypt(httpRequest, serverPubkeyHex);
|
||||
if (encrypted) {
|
||||
encryptedContent = encrypted;
|
||||
console.log("Successfully encrypted content with NostrLogin.nip44");
|
||||
} else {
|
||||
console.warn("NostrLogin.nip44 encryption returned null or undefined");
|
||||
}
|
||||
} catch (encryptError) {
|
||||
console.error("Encryption error with NostrLogin.nip44:", encryptError);
|
||||
}
|
||||
} else if (NostrLogin && NostrLogin.nip04 && NostrLogin.nip04.encrypt) {
|
||||
console.log("Using NostrLogin.nip04.encrypt for encryption");
|
||||
try {
|
||||
const encrypted = NostrLogin.nip04.encrypt(httpRequest, serverPubkeyHex);
|
||||
if (encrypted) {
|
||||
encryptedContent = encrypted;
|
||||
console.log("Successfully encrypted content with NostrLogin.nip04");
|
||||
} else {
|
||||
console.warn("NostrLogin.nip04 encryption returned null or undefined");
|
||||
}
|
||||
} catch (encryptError) {
|
||||
console.error("Encryption error with NostrLogin.nip04:", encryptError);
|
||||
}
|
||||
} else if (NostrLogin && NostrLogin.encrypt) {
|
||||
console.log("Using NostrLogin.encrypt for encryption");
|
||||
try {
|
||||
const encrypted = NostrLogin.encrypt(httpRequest, serverPubkeyHex);
|
||||
if (encrypted) {
|
||||
encryptedContent = encrypted;
|
||||
console.log("Successfully encrypted content with NostrLogin.encrypt");
|
||||
} else {
|
||||
console.warn("NostrLogin.encrypt returned null or undefined");
|
||||
}
|
||||
} catch (encryptError) {
|
||||
console.error("Encryption error with NostrLogin.encrypt:", encryptError);
|
||||
}
|
||||
} else if (window.nostr && window.nostr.nip44) {
|
||||
console.log("Falling back to window.nostr.nip44.encrypt");
|
||||
try {
|
||||
encryptedContent = window.nostr.nip44.encrypt(httpRequest, serverPubkeyHex);
|
||||
console.log("Successfully encrypted content with window.nostr.nip44");
|
||||
} catch (encryptError) {
|
||||
console.error("Encryption error with window.nostr.nip44:", encryptError);
|
||||
}
|
||||
} else {
|
||||
console.warn("No encryption method available. Using unencrypted content.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error in encryption setup:", error);
|
||||
// Continue with unencrypted content
|
||||
}
|
||||
|
||||
// Debug log the content
|
||||
console.log("Final encryptedContent before creating event:", encryptedContent);
|
||||
|
||||
// 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", pTagValue], // P tag with hex pubkey (converted from npub if needed)
|
||||
["key", decryptkey], // Key for decryption
|
||||
["expiration", String(Math.floor(Date.now() / 1000) + appSettings.expirationTime)]
|
||||
]
|
||||
};
|
||||
|
||||
console.log("Created event object:", JSON.stringify(event, null, 2));
|
||||
|
||||
// Add optional relay tag if provided
|
||||
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);
|
||||
}
|
||||
|
||||
// No need to define nostrTools if it's not being used
|
||||
|
||||
export async function displayConvertedEvent(): Promise<void> {
|
||||
const httpRequestBox = document.getElementById('httpRequest') as HTMLTextAreaElement;
|
||||
const eventOutputPre = document.getElementById('eventOutput') as HTMLElement;
|
||||
const outputDiv = document.getElementById('output') as HTMLElement;
|
||||
const serverPubkeyInput = document.getElementById('serverPubkey') as HTMLInputElement;
|
||||
const relayInput = document.getElementById('relay') as HTMLInputElement;
|
||||
|
||||
if (httpRequestBox && eventOutputPre && outputDiv) {
|
||||
// Get server pubkey and relay values from inputs
|
||||
const serverPubkey = serverPubkeyInput && serverPubkeyInput.value ?
|
||||
serverPubkeyInput.value : defaultServerConfig.serverNpub;
|
||||
const relay = relayInput && relayInput.value ?
|
||||
relayInput.value : defaultServerConfig.defaultRelay;
|
||||
// Get or create a keypair
|
||||
let pubkey: string;
|
||||
let secretKey: Uint8Array | null = null;
|
||||
|
||||
// Try to get the pubkey from the NIP-07 extension
|
||||
if (window.nostr) {
|
||||
try {
|
||||
pubkey = await window.nostr.getPublicKey();
|
||||
} catch (error) {
|
||||
console.error("Failed to get pubkey from extension:", error);
|
||||
// Fall back to standalone keypair
|
||||
const keypair = initStandaloneKeypair();
|
||||
pubkey = keypair.publicKey;
|
||||
secretKey = keypair.secretKey;
|
||||
}
|
||||
} else {
|
||||
// No extension, use standalone keypair
|
||||
const keypair = initStandaloneKeypair();
|
||||
pubkey = keypair.publicKey;
|
||||
secretKey = keypair.secretKey;
|
||||
}
|
||||
|
||||
// Convert directly to a single kind 21120 event
|
||||
// Debug data - log the full HTTP request value
|
||||
const httpRequestValue = httpRequestBox.value;
|
||||
console.log("HTTP request textarea value:", httpRequestValue);
|
||||
console.log("HTTP request length:", httpRequestValue.length);
|
||||
console.log("HTTP request first 50 chars:", httpRequestValue.substring(0, 50));
|
||||
|
||||
// If the request is empty, try to use the placeholder value
|
||||
let requestToUse = httpRequestValue;
|
||||
if (!requestToUse || requestToUse.trim() === '') {
|
||||
// Try to get the placeholder value as a fallback
|
||||
const placeholder = httpRequestBox.getAttribute('placeholder');
|
||||
if (placeholder) {
|
||||
requestToUse = placeholder;
|
||||
console.log("Using placeholder as request value:", placeholder);
|
||||
} else {
|
||||
// Use a very simple default request if all else fails
|
||||
requestToUse = "GET / HTTP/1.1\nHost: example.com\n\n";
|
||||
console.log("Using hardcoded default request");
|
||||
}
|
||||
}
|
||||
|
||||
const convertedEvent = convertToEvent(
|
||||
requestToUse,
|
||||
pubkey,
|
||||
serverPubkey,
|
||||
"$decryptkey",
|
||||
relay
|
||||
);
|
||||
|
||||
if (convertedEvent) {
|
||||
// Store the original event in case we need to reference it
|
||||
(window as any).originalEvent = convertedEvent;
|
||||
// Variable to hold the Nostr event
|
||||
let nostrEvent: NostrEvent;
|
||||
|
||||
|
||||
try {
|
||||
// Parse the event to create a proper Nostr event object for signing
|
||||
const parsedEvent = JSON.parse(convertedEvent);
|
||||
|
||||
// Debug the content field
|
||||
console.log("Event content from parsedEvent:", typeof parsedEvent.content, parsedEvent.content);
|
||||
|
||||
// IMPORTANT: Create the nostrEvent with the raw HTTP request as content
|
||||
// This bypasses any issues with JSON parsing or encryption
|
||||
nostrEvent = {
|
||||
kind: 21120,
|
||||
tags: parsedEvent.tags,
|
||||
content: requestToUse, // Use the original HTTP request directly
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
pubkey: parsedEvent.pubkey
|
||||
};
|
||||
|
||||
// Log the event being signed
|
||||
console.log("Content field of nostrEvent:", typeof nostrEvent.content, nostrEvent.content.substring(0, 50) + "...");
|
||||
console.log("Event to be signed:", JSON.stringify(nostrEvent, null, 2));
|
||||
} catch (parseError) {
|
||||
console.error("Error parsing event:", parseError);
|
||||
eventOutputPre.textContent = "Error: Could not parse event JSON. Please try again.";
|
||||
outputDiv.hidden = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!nostrEvent) {
|
||||
eventOutputPre.textContent = "Error: Could not create event for signing";
|
||||
outputDiv.hidden = false;
|
||||
return;
|
||||
}
|
||||
|
||||
let signedEvent;
|
||||
|
||||
try {
|
||||
// Try to sign with NostrLogin in preferred order
|
||||
if (NostrLogin && NostrLogin.signEvent) {
|
||||
console.log("Using NostrLogin.signEvent to sign event");
|
||||
signedEvent = await NostrLogin.signEvent(nostrEvent);
|
||||
} else if (NostrLogin && NostrLogin.sign) {
|
||||
console.log("Using NostrLogin.sign to sign event");
|
||||
signedEvent = await NostrLogin.sign(nostrEvent);
|
||||
} else if (window.nostr) {
|
||||
// Fall back to NIP-07 extension
|
||||
console.log("Using NIP-07 extension to sign event");
|
||||
// When using the extension directly, ensure content is a string
|
||||
if (typeof nostrEvent.content !== 'string') {
|
||||
nostrEvent.content = String(nostrEvent.content);
|
||||
}
|
||||
signedEvent = await window.nostr.signEvent(nostrEvent);
|
||||
} else if (secretKey) {
|
||||
// Fall back to nostr-tools
|
||||
console.log("Using nostr-tools to sign event");
|
||||
signedEvent = nostrTools.finalizeEvent(nostrEvent, secretKey);
|
||||
} else {
|
||||
throw new Error("No signing method available");
|
||||
}
|
||||
|
||||
console.log("Event signed successfully");
|
||||
console.log("Event ID:", signedEvent.id);
|
||||
console.log("Content field type:", typeof signedEvent.content);
|
||||
if (typeof signedEvent.content === 'string') {
|
||||
console.log("Content length:", signedEvent.content.length);
|
||||
console.log("Content preview:", signedEvent.content.length > 50 ?
|
||||
signedEvent.content.substring(0, 50) + "..." :
|
||||
signedEvent.content);
|
||||
} else {
|
||||
console.log("Content is not a string:", signedEvent.content);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error signing event:", error);
|
||||
eventOutputPre.textContent = "Error: Could not sign event. " + String(error);
|
||||
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!');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize event listeners when the DOM is fully loaded
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// 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
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
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;
|
||||
}
|
387
client/src/styles.css
Normal file
387
client/src/styles.css
Normal file
@ -0,0 +1,387 @@
|
||||
/* Styles for the HTTP to Nostr converter */
|
||||
|
||||
/* CSS Variables for themes */
|
||||
:root {
|
||||
/* Light theme (default) */
|
||||
--bg-primary: #f8f9fa;
|
||||
--bg-secondary: #ffffff;
|
||||
--bg-tertiary: #f1f8ff;
|
||||
--bg-info: #e2f0fd;
|
||||
--text-primary: #212529;
|
||||
--text-secondary: #495057;
|
||||
--text-tertiary: #6c757d;
|
||||
--border-color: #dee2e6;
|
||||
--accent-color: #0d6efd;
|
||||
--button-primary: #0d6efd;
|
||||
--button-hover: #0b5ed7;
|
||||
--button-success: #28a745;
|
||||
--button-success-hover: #218838;
|
||||
--button-login: #6c3483;
|
||||
--button-login-hover: #5b2c6f;
|
||||
--info-border: #0d6efd;
|
||||
--code-bg: #f8f9fa;
|
||||
}
|
||||
|
||||
/* Dark theme */
|
||||
[data-theme="dark"] {
|
||||
--bg-primary: #121212;
|
||||
--bg-secondary: #1e1e1e;
|
||||
--bg-tertiary: #252836;
|
||||
--bg-info: #1a2634;
|
||||
--text-primary: #e0e0e0;
|
||||
--text-secondary: #b0b0b0;
|
||||
--text-tertiary: #909090;
|
||||
--border-color: #333333;
|
||||
--accent-color: #3f87ff;
|
||||
--button-primary: #3f87ff;
|
||||
--button-hover: #2970e3;
|
||||
--button-success: #2a9745;
|
||||
--button-success-hover: #218838;
|
||||
--button-login: #7c44a3;
|
||||
--button-login-hover: #6c378f;
|
||||
--info-border: #3f87ff;
|
||||
--code-bg: #252525;
|
||||
}
|
||||
|
||||
/* General layout */
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
background-color: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
transition: background-color 0.3s ease, color 0.3s ease;
|
||||
}
|
||||
|
||||
/* Headings */
|
||||
h1 {
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 20px;
|
||||
border-bottom: 2px solid var(--border-color);
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
color: var(--text-secondary);
|
||||
margin-top: 25px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
/* Theme toggle */
|
||||
.theme-toggle {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 30px;
|
||||
padding: 5px 10px;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.theme-toggle-icon {
|
||||
font-size: 18px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
/* Newer button style toggle */
|
||||
.theme-toggle-container {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.theme-toggle-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 30px;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.theme-toggle-btn:hover {
|
||||
background-color: var(--accent-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.theme-toggle-text {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Info box */
|
||||
.info-box {
|
||||
background-color: var(--bg-info);
|
||||
border-left: 4px solid var(--info-border);
|
||||
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 var(--border-color);
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
background-color: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
textarea {
|
||||
height: 150px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
/* Login container */
|
||||
.login-container {
|
||||
margin: 20px 0;
|
||||
padding: 15px;
|
||||
background-color: var(--bg-secondary);
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border-color);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-status {
|
||||
margin-top: 10px;
|
||||
min-height: 20px;
|
||||
}
|
||||
|
||||
#nostr-login-container {
|
||||
margin: 10px auto;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
button {
|
||||
background-color: var(--button-primary);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background-color: var(--button-hover);
|
||||
}
|
||||
|
||||
/* 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: var(--bg-secondary);
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
/* Output section */
|
||||
#output {
|
||||
margin-top: 30px;
|
||||
padding: 15px;
|
||||
background-color: var(--bg-secondary);
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
pre {
|
||||
background-color: var(--code-bg);
|
||||
padding: 15px;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
/* Publish container */
|
||||
.publish-container {
|
||||
margin-top: 20px;
|
||||
margin-bottom: 20px;
|
||||
padding: 15px;
|
||||
background-color: var(--bg-tertiary);
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--accent-color);
|
||||
}
|
||||
|
||||
.publish-input-container {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.publish-input {
|
||||
flex-grow: 1;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
background-color: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.publish-button {
|
||||
background-color: var(--button-success);
|
||||
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: var(--button-success-hover);
|
||||
}
|
||||
|
||||
.publish-result {
|
||||
margin-top: 10px;
|
||||
padding: 10px;
|
||||
background-color: var(--bg-secondary);
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
/* QR code container */
|
||||
.qr-container {
|
||||
margin-top: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#qrCode {
|
||||
margin: 0 auto;
|
||||
max-width: 100%;
|
||||
background-color: var(--bg-secondary);
|
||||
padding: 15px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.qr-info {
|
||||
margin-top: 8px;
|
||||
font-size: 14px;
|
||||
color: var(--text-tertiary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.qr-frame-container {
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
margin: 0 auto;
|
||||
position: relative;
|
||||
background-color: white; /* QR codes need white background */
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
/* Search results styling */
|
||||
.search-results-list {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
background-color: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.search-result-item {
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
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: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.result-nip05 {
|
||||
font-size: 12px;
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.use-npub-btn {
|
||||
align-self: flex-end;
|
||||
padding: 5px 10px;
|
||||
font-size: 12px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
/* Error and success messages */
|
||||
.error-message {
|
||||
color: #cc0000;
|
||||
padding: 10px;
|
||||
background-color: rgba(204, 0, 0, 0.1);
|
||||
border-radius: 4px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.success-message {
|
||||
color: #008800;
|
||||
padding: 10px;
|
||||
background-color: rgba(0, 136, 0, 0.1);
|
||||
border-radius: 4px;
|
||||
margin: 10px 0;
|
||||
}
|
124
client/src/utils.ts
Normal file
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';
|
||||
}
|
16
client/tsconfig.json
Normal file
16
client/tsconfig.json
Normal file
@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ES2020",
|
||||
"moduleResolution": "node",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"outDir": "./dist/src",
|
||||
"sourceMap": true,
|
||||
"lib": ["DOM", "ES2020", "DOM.Iterable", "ScriptHost"],
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
49
client/webpack.config.js
Normal file
49
client/webpack.config.js
Normal file
@ -0,0 +1,49 @@
|
||||
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',
|
||||
entry: './src/client.ts',
|
||||
// Ensure webpack creates browser-compatible output
|
||||
target: 'web',
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.tsx?$/,
|
||||
use: 'ts-loader',
|
||||
exclude: /node_modules/,
|
||||
},
|
||||
{
|
||||
test: /\.css$/,
|
||||
use: ['style-loader', 'css-loader'],
|
||||
},
|
||||
],
|
||||
},
|
||||
plugins: [
|
||||
new NodePolyfillPlugin(),
|
||||
new CopyPlugin({
|
||||
patterns: [
|
||||
{ from: 'src/styles.css', to: 'styles.css' },
|
||||
{ from: 'http.png', to: 'http.png' },
|
||||
{ from: 'index.html', to: 'index.html' },
|
||||
],
|
||||
}),
|
||||
],
|
||||
output: {
|
||||
filename: 'bundle.js',
|
||||
path: path.resolve(__dirname, 'dist'),
|
||||
},
|
||||
resolve: {
|
||||
extensions: ['.tsx', '.ts', '.js'],
|
||||
},
|
||||
devServer: {
|
||||
static: {
|
||||
directory: path.join(__dirname, 'dist'),
|
||||
},
|
||||
compress: true,
|
||||
port: 3000,
|
||||
},
|
||||
};
|
102
index.html
Normal file
102
index.html
Normal file
@ -0,0 +1,102 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>HTTP to Kind 21120 Converter</title>
|
||||
<script src='https://www.unpkg.com/nostr-login@latest/dist/unpkg.js'></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/nostr-tools@latest"></script>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
textarea {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
font-family: monospace;
|
||||
padding: 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
button {
|
||||
background-color: #4CAF50;
|
||||
color: white;
|
||||
padding: 10px 15px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
}
|
||||
button:hover {
|
||||
background-color: #45a049;
|
||||
}
|
||||
#output {
|
||||
margin-top: 20px;
|
||||
padding: 15px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
pre {
|
||||
background-color: #f5f5f5;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
.info-box {
|
||||
background-color: #e7f3fe;
|
||||
border-left: 6px solid #2196F3;
|
||||
padding: 10px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
h1 {
|
||||
color: #333;
|
||||
}
|
||||
h2 {
|
||||
color: #555;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>HTTP Request to Kind 21120 Converter</h1>
|
||||
|
||||
<div class="info-box">
|
||||
<p>This tool converts HTTP requests into a single Nostr event of kind 21120. The HTTP request is placed directly in the content field with appropriate tags following the specification.</p>
|
||||
</div>
|
||||
|
||||
<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" placeholder="Enter server pubkey" style="width: 100%; padding: 8px;">
|
||||
</div>
|
||||
<div>
|
||||
<label for="relay">Response Relay (optional):</label><br>
|
||||
<input type="text" id="relay" placeholder="wss://relay.example.com" style="width: 100%; padding: 8px;">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Enter HTTP Request:</h2>
|
||||
<textarea id="httpRequest" placeholder="GET /index.html HTTP/1.1
|
||||
Host: example.com
|
||||
User-Agent: Browser/1.0
|
||||
|
||||
"></textarea>
|
||||
|
||||
<button id="convertButton">Convert to Event</button>
|
||||
|
||||
<div id="output" hidden>
|
||||
<h2>Converted Event:</h2>
|
||||
<pre id="eventOutput"></pre>
|
||||
</div>
|
||||
|
||||
<script src="./src/converter.js" type="module"></script>
|
||||
</body>
|
||||
</html>
|
321
package-lock.json
generated
Normal file
321
package-lock.json
generated
Normal file
@ -0,0 +1,321 @@
|
||||
{
|
||||
"name": "http-to-nostr-project",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "http-to-nostr-project",
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"qrcode": "^1.5.4"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/camelcase": {
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
|
||||
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^4.2.0",
|
||||
"strip-ansi": "^6.0.0",
|
||||
"wrap-ansi": "^6.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color-name": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/decamelize": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
||||
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dijkstrajs": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
||||
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/find-up": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
||||
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"locate-path": "^5.0.0",
|
||||
"path-exists": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/get-caller-file": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
}
|
||||
},
|
||||
"node_modules/is-fullwidth-code-point": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/locate-path": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-locate": "^4.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/p-limit": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
||||
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-try": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/p-locate": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
|
||||
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-limit": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/p-try": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/path-exists": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/pngjs": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dijkstrajs": "^1.0.1",
|
||||
"pngjs": "^5.0.0",
|
||||
"yargs": "^15.3.1"
|
||||
},
|
||||
"bin": {
|
||||
"qrcode": "bin/qrcode"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-main-filename": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/set-blocking": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/which-module": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
||||
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yargs": {
|
||||
"version": "15.4.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
|
||||
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^6.0.0",
|
||||
"decamelize": "^1.2.0",
|
||||
"find-up": "^4.1.0",
|
||||
"get-caller-file": "^2.0.1",
|
||||
"require-directory": "^2.1.1",
|
||||
"require-main-filename": "^2.0.0",
|
||||
"set-blocking": "^2.0.0",
|
||||
"string-width": "^4.2.0",
|
||||
"which-module": "^2.0.0",
|
||||
"y18n": "^4.0.0",
|
||||
"yargs-parser": "^18.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs-parser": {
|
||||
"version": "18.1.3",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
|
||||
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"camelcase": "^5.0.0",
|
||||
"decamelize": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
29
package.json
Normal file
29
package.json
Normal file
@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "http-to-nostr-project",
|
||||
"version": "1.0.0",
|
||||
"description": "HTTP to Nostr kind 21120 converter and server",
|
||||
"scripts": {
|
||||
"install:all": "npm run install:client && npm run install:server",
|
||||
"install:client": "cd client && npm install",
|
||||
"install:server": "cd server && npm install",
|
||||
"build:all": "npm run build:client && npm run build:server",
|
||||
"build:client": "cd client && npm run build",
|
||||
"build:server": "cd server && npm run build",
|
||||
"start:client": "cd client && npm run start",
|
||||
"start:server": "cd server && npm run start",
|
||||
"dev:server": "cd server && npm run dev",
|
||||
"clean:all": "npm run clean:client && npm run clean:server",
|
||||
"clean:client": "cd client && npm run clean",
|
||||
"clean:server": "cd server && npm run clean"
|
||||
},
|
||||
"keywords": [
|
||||
"nostr",
|
||||
"http",
|
||||
"converter"
|
||||
],
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"qrcode": "^1.5.4"
|
||||
}
|
||||
}
|
31
server/.gitignore
vendored
Normal file
31
server/.gitignore
vendored
Normal file
@ -0,0 +1,31 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.*
|
||||
|
||||
# IDE files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea/
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
36
server/package.json
Normal file
36
server/package.json
Normal file
@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "http-to-nostr-server",
|
||||
"version": "1.0.0",
|
||||
"description": "A server for processing HTTP request events from Nostr Kind 21120",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"dev": "ts-node src/index.ts",
|
||||
"watch": "nodemon --exec ts-node src/index.ts",
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"keywords": [
|
||||
"nostr",
|
||||
"http",
|
||||
"server"
|
||||
],
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@nostr-dev-kit/ndk": "^2.0.0",
|
||||
"express": "^4.18.2",
|
||||
"node-fetch": "^3.3.2",
|
||||
"websocket-polyfill": "^0.0.3",
|
||||
"ws": "^8.14.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.18",
|
||||
"@types/node": "^20.8.4",
|
||||
"@types/ws": "^8.5.6",
|
||||
"nodemon": "^3.0.1",
|
||||
"ts-node": "^10.9.1",
|
||||
"typescript": "^5.2.2"
|
||||
},
|
||||
"type": "module"
|
||||
}
|
21
server/src/config.ts
Normal file
21
server/src/config.ts
Normal file
@ -0,0 +1,21 @@
|
||||
// Hard-coded Nostr keys for the server
|
||||
export const serverKeys = {
|
||||
// These are test keys - DO NOT USE IN PRODUCTION
|
||||
privateKeyHex: "d5d49f5d14c8e59fafb5858dd27712674b8f8c53f1d72dae4ee69bd201068c68",
|
||||
publicKeyHex: "f3c75b9243135a548bc64545dcfd7e8725e20faeacb5c18ac581d7a010036f40",
|
||||
npub: "npub1r6knexka25dn9w9jnf5kf8xh6gfq7n3p38zfl7nn7cjjjsp4umcqnk0aun",
|
||||
nsec: "nsec1hsdfy8ch8y0zw8g0np0mzk3chg94y2t5sek7hh9xpv565h5ucqpqgf4t93"
|
||||
};
|
||||
|
||||
// Relay configuration
|
||||
export const relayUrls = [
|
||||
'wss://relay.damus.io',
|
||||
'wss://relay.nostr.info',
|
||||
'wss://nostr.fmt.wiz.biz'
|
||||
];
|
||||
|
||||
// Server configuration
|
||||
export const serverConfig = {
|
||||
port: process.env.PORT || 3000,
|
||||
expirationTime: 3600 // 1 hour in seconds
|
||||
};
|
39
server/src/generate-keys.ts
Normal file
39
server/src/generate-keys.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import * as crypto from 'crypto';
|
||||
import * as nostrTools from 'nostr-tools';
|
||||
|
||||
// Generate a new random private key
|
||||
function generatePrivateKey(): string {
|
||||
return nostrTools.generatePrivateKey();
|
||||
}
|
||||
|
||||
// Derive public key from private key
|
||||
function getPublicKey(privateKey: string): string {
|
||||
return nostrTools.getPublicKey(privateKey);
|
||||
}
|
||||
|
||||
// Convert hex public key to npub format
|
||||
function convertToNpub(publicKey: string): string {
|
||||
return nostrTools.nip19.npubEncode(publicKey);
|
||||
}
|
||||
|
||||
// Convert hex private key to nsec format
|
||||
function convertToNsec(privateKey: string): string {
|
||||
return nostrTools.nip19.nsecEncode(privateKey);
|
||||
}
|
||||
|
||||
// Generate and display keys
|
||||
function main() {
|
||||
const privateKey = generatePrivateKey();
|
||||
const publicKey = getPublicKey(privateKey);
|
||||
const npub = convertToNpub(publicKey);
|
||||
const nsec = convertToNsec(privateKey);
|
||||
|
||||
console.log('Generated Nostr Key Pair:');
|
||||
console.log('------------------------');
|
||||
console.log('Private Key (hex):', privateKey);
|
||||
console.log('Public Key (hex):', publicKey);
|
||||
console.log('NPUB:', npub);
|
||||
console.log('NSEC:', nsec);
|
||||
}
|
||||
|
||||
main();
|
272
server/src/index.ts
Normal file
272
server/src/index.ts
Normal file
@ -0,0 +1,272 @@
|
||||
import express from 'express';
|
||||
import { WebSocket, WebSocketServer } from 'ws';
|
||||
import { NDKEvent, NDK, NDKPrivateKeySigner, NDKFilter, NDKRelay } from '@nostr-dev-kit/ndk';
|
||||
import { createServer } from 'http';
|
||||
import fetch from 'node-fetch';
|
||||
import { serverKeys, relayUrls, serverConfig } from './config';
|
||||
import * as crypto from 'crypto';
|
||||
// Configuration
|
||||
const config = {
|
||||
port: serverConfig.port,
|
||||
relayUrls: relayUrls,
|
||||
privateKey: serverKeys.privateKeyHex,
|
||||
pubkey: serverKeys.publicKeyHex,
|
||||
npub: serverKeys.npub
|
||||
};
|
||||
|
||||
console.log(`Server running with npub: ${config.npub}`);
|
||||
|
||||
// Create NDK instance with the private key signer
|
||||
const signer = new NDKPrivateKeySigner(config.privateKey);
|
||||
const ndk = new NDK({
|
||||
explicitRelayUrls: config.relayUrls,
|
||||
signer
|
||||
});
|
||||
|
||||
// Initialize NDK
|
||||
(async () => {
|
||||
try {
|
||||
await ndk.connect();
|
||||
console.log('NDK Connected to relays');
|
||||
} catch (error) {
|
||||
console.error('Failed to connect to relays:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
})();
|
||||
|
||||
// Set up Express server
|
||||
const app = express();
|
||||
const server = createServer(app);
|
||||
|
||||
app.use(express.json());
|
||||
|
||||
// Basic route to display server info
|
||||
app.get('/', (req, res) => {
|
||||
res.send(`
|
||||
<h1>HTTP Request Server</h1>
|
||||
<p>This server listens for Nostr kind 21120 events and processes HTTP requests.</p>
|
||||
<p>Server pubkey: ${config.pubkey}</p>
|
||||
`);
|
||||
});
|
||||
|
||||
// Start the HTTP server
|
||||
server.listen(config.port, () => {
|
||||
console.log(`Server running on http://localhost:${config.port}`);
|
||||
});
|
||||
|
||||
// Subscribe to kind 21120 events
|
||||
async function subscribeToEvents() {
|
||||
try {
|
||||
// Create filter for kind 21120 events addressed to us
|
||||
const filter: NDKFilter = {
|
||||
kinds: [21120],
|
||||
'#p': [config.pubkey]
|
||||
};
|
||||
|
||||
// Subscribe to events
|
||||
ndk.subscribe(filter, {
|
||||
closeOnEose: false,
|
||||
// Handle each received event
|
||||
callback: (event: NDKEvent) => {
|
||||
processEvent(event);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('Subscribed to events with filter:', filter);
|
||||
} catch (error) {
|
||||
console.error('Error subscribing to events:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Start subscriptions
|
||||
(async () => {
|
||||
try {
|
||||
await subscribeToEvents();
|
||||
console.log('Event subscriptions started');
|
||||
} catch (error) {
|
||||
console.error('Failed to start subscriptions:', error);
|
||||
}
|
||||
})();
|
||||
|
||||
// Process incoming HTTP request events
|
||||
async function processEvent(event: NDKEvent) {
|
||||
console.log('Received event:', event.id);
|
||||
|
||||
// Verify this is a request (has p tag pointing to us)
|
||||
const pTag = event.getMatchingTags('p').find(tag => tag[1] === config.pubkey);
|
||||
if (!pTag) {
|
||||
console.log('Not a request for this server, ignoring');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Extract the encrypted HTTP request from content
|
||||
const encryptedContent = event.content;
|
||||
|
||||
// Use NDK's built-in NIP-44 decryption with our private key
|
||||
let httpRequest: string;
|
||||
try {
|
||||
// NDK provides NIP-44 decryption through the event object or signer
|
||||
httpRequest = await ndk.signer?.decrypt(encryptedContent) || '';
|
||||
console.log('Successfully decrypted content with NIP-44');
|
||||
} catch (decryptError) {
|
||||
console.error('Failed to decrypt content with NIP-44:', decryptError);
|
||||
// Fallback in case of decryption failure
|
||||
httpRequest = encryptedContent;
|
||||
}
|
||||
|
||||
console.log('Decrypted HTTP request:', httpRequest);
|
||||
|
||||
// Parse HTTP request
|
||||
const parsedRequest = parseHttpRequest(httpRequest);
|
||||
if (!parsedRequest) {
|
||||
throw new Error('Failed to parse HTTP request');
|
||||
}
|
||||
|
||||
// Execute the HTTP request
|
||||
const response = await executeHttpRequest(parsedRequest);
|
||||
|
||||
// Send response event
|
||||
await sendResponseEvent(event.id, response);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error processing event:', error);
|
||||
|
||||
// Send error response
|
||||
const errorResponse = `HTTP/1.1 500 Internal Server Error
|
||||
Content-Type: text/plain
|
||||
Content-Length: ${error.toString().length}
|
||||
|
||||
${error.toString()}`;
|
||||
|
||||
await sendResponseEvent(event.id, errorResponse);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse an HTTP request string into components
|
||||
function parseHttpRequest(requestStr: string): any {
|
||||
try {
|
||||
const lines = requestStr.split('\n');
|
||||
const firstLine = lines[0].trim().split(' ');
|
||||
|
||||
if (firstLine.length < 3) {
|
||||
throw new Error('Invalid request line');
|
||||
}
|
||||
|
||||
const method = firstLine[0];
|
||||
const path = firstLine[1];
|
||||
const protocol = firstLine[2];
|
||||
|
||||
// Parse headers
|
||||
const headers: Record<string, string> = {};
|
||||
let i = 1;
|
||||
while (i < lines.length && lines[i].trim() !== '') {
|
||||
const headerLine = lines[i].trim();
|
||||
const separatorIndex = headerLine.indexOf(':');
|
||||
|
||||
if (separatorIndex > 0) {
|
||||
const key = headerLine.substring(0, separatorIndex).trim();
|
||||
const value = headerLine.substring(separatorIndex + 1).trim();
|
||||
headers[key] = value;
|
||||
}
|
||||
|
||||
i++;
|
||||
}
|
||||
|
||||
// Parse body (if any)
|
||||
let body = '';
|
||||
if (i < lines.length - 1) {
|
||||
body = lines.slice(i + 1).join('\n');
|
||||
}
|
||||
|
||||
return { method, path, protocol, headers, body };
|
||||
} catch (error) {
|
||||
console.error('Error parsing HTTP request:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Execute an HTTP request
|
||||
async function executeHttpRequest(request: any): Promise<string> {
|
||||
try {
|
||||
// Build URL from path
|
||||
const url = new URL(request.path, 'http://localhost');
|
||||
|
||||
// Prepare fetch options
|
||||
const options: any = {
|
||||
method: request.method,
|
||||
headers: request.headers
|
||||
};
|
||||
|
||||
// Add body if present
|
||||
if (request.body && request.method !== 'GET' && request.method !== 'HEAD') {
|
||||
options.body = request.body;
|
||||
}
|
||||
|
||||
console.log(`Executing HTTP request to ${url}`);
|
||||
|
||||
// Execute the request
|
||||
const response = await fetch(url.toString(), options);
|
||||
|
||||
// Build response string
|
||||
let responseStr = `HTTP/1.1 ${response.status} ${response.statusText}\n`;
|
||||
|
||||
// Add headers
|
||||
response.headers.forEach((value, key) => {
|
||||
responseStr += `${key}: ${value}\n`;
|
||||
});
|
||||
|
||||
// Add empty line between headers and body
|
||||
responseStr += '\n';
|
||||
|
||||
// Add body
|
||||
const body = await response.text();
|
||||
responseStr += body;
|
||||
|
||||
return responseStr;
|
||||
} catch (error) {
|
||||
console.error('Error executing HTTP request:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Send response event using NDK
|
||||
async function sendResponseEvent(requestId: string, responseContent: string) {
|
||||
try {
|
||||
// For a valid Nostr event, we need to manually set all required fields
|
||||
const rawEvent = {
|
||||
kind: 21120,
|
||||
content: responseContent,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
pubkey: config.pubkey,
|
||||
tags: [
|
||||
['e', requestId], // E tag points to request event
|
||||
['expiration', (Math.floor(Date.now() / 1000) + 3600).toString()] // 1 hour from now
|
||||
],
|
||||
id: '', // Will be generated during signing
|
||||
sig: '' // Will be generated during signing
|
||||
};
|
||||
|
||||
// Create a new NDK event from the raw event
|
||||
const event = new NDKEvent(ndk, rawEvent);
|
||||
|
||||
try {
|
||||
// Sign the event with our private key
|
||||
await event.sign();
|
||||
console.log('Response event signed successfully');
|
||||
|
||||
// Publish to relays
|
||||
await event.publish();
|
||||
console.log(`Response sent for request ${requestId}`);
|
||||
} catch (signError) {
|
||||
console.error('Error signing/publishing event:', signError);
|
||||
console.log('Event data:', rawEvent);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error sending response event:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// The server is already started when NDK connects to relays earlier
|
||||
console.log('HTTP to Nostr server is ready and listening for events');
|
15
server/tsconfig.json
Normal file
15
server/tsconfig.json
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ES2020",
|
||||
"moduleResolution": "node",
|
||||
"esModuleInterop": true,
|
||||
"outDir": "./dist",
|
||||
"strict": true,
|
||||
"sourceMap": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
134
src/converter.ts
Normal file
134
src/converter.ts
Normal file
@ -0,0 +1,134 @@
|
||||
// Type definition for the window.nostrTools object
|
||||
declare global {
|
||||
interface Window {
|
||||
nostrTools: any;
|
||||
nostr: any;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an HTTP request string and converts it directly to a kind 21120 Nostr event
|
||||
* Following the specification in README.md
|
||||
*
|
||||
* @param httpRequest The raw HTTP request string
|
||||
* @param pubkey The user's public key
|
||||
* @param serverPubkey The public key of the remote HTTP server
|
||||
* @param decryptkey The encryption key (required per specification)
|
||||
* @param relay Optional relay for the response
|
||||
* @returns Stringified Nostr event or null if conversion fails
|
||||
*/
|
||||
export function convertToEvent(
|
||||
httpRequest: string,
|
||||
pubkey: string,
|
||||
serverPubkey: string,
|
||||
decryptkey: string,
|
||||
relay?: string
|
||||
): string | null {
|
||||
if (!httpRequest) {
|
||||
alert('Please enter an HTTP request message.');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Create a single kind 21120 event with the HTTP request as content
|
||||
// Following the specification in README.md
|
||||
const event = {
|
||||
kind: 21120,
|
||||
pubkey: pubkey,
|
||||
content: httpRequest, // Content directly contains the HTTP request
|
||||
tags: [
|
||||
// Required tags per README specification
|
||||
["p", serverPubkey], // P tag to indicate this is a REQUEST
|
||||
["key", `nip44Encrypt(${decryptkey})`],
|
||||
["expiration", Math.floor(Date.now() / 1000) + 3600] // 1 hour from now
|
||||
]
|
||||
};
|
||||
|
||||
// Add optional relay tag if provided
|
||||
if (relay) {
|
||||
event.tags.push(["r", relay]);
|
||||
}
|
||||
|
||||
return JSON.stringify(event, null, 2);
|
||||
}
|
||||
|
||||
// Get nostrTools from window object
|
||||
const nostrTools = window.nostrTools || {};
|
||||
|
||||
export async function displayConvertedEvent() {
|
||||
const httpRequestBox = document.getElementById('httpRequest') as HTMLTextAreaElement;
|
||||
const eventOutputPre = document.getElementById('eventOutput') as HTMLElement;
|
||||
const outputDiv = document.getElementById('output') as HTMLElement;
|
||||
const serverPubkeyInput = document.getElementById('serverPubkey') as HTMLInputElement;
|
||||
const relayInput = document.getElementById('relay') as HTMLInputElement;
|
||||
|
||||
if (httpRequestBox && eventOutputPre && outputDiv) {
|
||||
// Get server pubkey and relay values from inputs
|
||||
const serverPubkey = serverPubkeyInput && serverPubkeyInput.value ?
|
||||
serverPubkeyInput.value : "<server-pubkey>";
|
||||
const relay = relayInput && relayInput.value ?
|
||||
relayInput.value : undefined;
|
||||
|
||||
// Convert directly to a single kind 21120 event
|
||||
const convertedEvent = convertToEvent(
|
||||
httpRequestBox.value,
|
||||
"<pubkey>",
|
||||
serverPubkey,
|
||||
"$decryptkey",
|
||||
relay
|
||||
);
|
||||
|
||||
if (convertedEvent) {
|
||||
// Parse the event to create a proper Nostr event object for signing
|
||||
const parsedEvent = JSON.parse(convertedEvent);
|
||||
const nostrEvent = {
|
||||
kind: 21120,
|
||||
tags: parsedEvent.tags,
|
||||
content: parsedEvent.content,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
pubkey: parsedEvent.pubkey
|
||||
};
|
||||
|
||||
if (window.nostr) {
|
||||
try {
|
||||
const signedEvent = await window.nostr.signEvent(nostrEvent);
|
||||
eventOutputPre.textContent = JSON.stringify(signedEvent, null, 2);
|
||||
} catch (error) {
|
||||
console.error("Error signing event:", error);
|
||||
eventOutputPre.textContent = "Error signing event: " + error;
|
||||
}
|
||||
} else {
|
||||
// If no NIP-07 extension, just show the unsigned event
|
||||
eventOutputPre.textContent = JSON.stringify(nostrEvent, null, 2);
|
||||
}
|
||||
|
||||
outputDiv.hidden = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize event listeners when the DOM is fully loaded
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const convertButton = document.getElementById('convertButton');
|
||||
if (convertButton) {
|
||||
convertButton.addEventListener('click', displayConvertedEvent);
|
||||
}
|
||||
|
||||
// Example usage for console logging
|
||||
const exampleHttpRequest = 'GET /index.html HTTP/1.1\nHost: example.com\nUser-Agent: Browser/1.0\n\n';
|
||||
const examplePubkey = "<pubkey>";
|
||||
const exampleServerPubkey = "<server-pubkey>"; // This should be the remote server's pubkey
|
||||
const exampleDecryptkey = "$decryptkey";
|
||||
const exampleRelay = "wss://relay.example.com";
|
||||
|
||||
const convertedEvent = convertToEvent(
|
||||
exampleHttpRequest,
|
||||
examplePubkey,
|
||||
exampleServerPubkey,
|
||||
exampleDecryptkey,
|
||||
exampleRelay
|
||||
);
|
||||
|
||||
if (convertedEvent) {
|
||||
console.log("Converted Event:", convertedEvent);
|
||||
}
|
||||
});
|
14
tsconfig.json
Normal file
14
tsconfig.json
Normal file
@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES6",
|
||||
"module": "ES6",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"outDir": "./dist/src",
|
||||
"sourceMap": true,
|
||||
"lib": ["DOM", "ES6", "DOM.Iterable", "ScriptHost"]
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user