parent
07ffd05fe2
commit
2d70707062
59
README.md
59
README.md
@ -66,7 +66,44 @@ sequenceDiagram
|
||||
|
||||
The remote server should periodically scan for expired RESPONSE events (and associated blossom blobs) and delete them.
|
||||
|
||||
## Event Structure
|
||||
|
||||
## Server Advertisement Event (Kind 11120)
|
||||
|
||||
To facilitate discovery of HTTP-over-Nostr servers, a dedicated event kind is used to advertise server availability.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"kind": 11120,
|
||||
"pubkey": "<pubkey of server operator>",
|
||||
"content": "HTTP-over-Nostr server", // Optional description
|
||||
"tags": [
|
||||
["name", "My HTTP Server"], // Optional server name
|
||||
["server", "<pubkey of server>"], // Server pubkey that will be listening for requests
|
||||
["relay", "wss://relay.one"], // Relay where server is listening (can have multiple)
|
||||
["relay", "wss://relay.two"],
|
||||
["expiry", "<unix timestamp>"], // How long this server will be online
|
||||
["p", "<allowed client pubkey>"], // Clients allowed to use this server (can have multiple)
|
||||
["p", "<allowed client pubkey>"]
|
||||
],
|
||||
// other fields...
|
||||
}
|
||||
```
|
||||
|
||||
Explanations:
|
||||
|
||||
* `kind:1120` - BIP39 word #1120 ([message](https://github.com/bitcoin/bips/blob/master/bip-0039/english.txt#L1120)) plus 10000 to make it replaceable.
|
||||
* `"content"` - Optional description of the server
|
||||
* `"server"` - The pubkey of the HTTP server that will be processing requests
|
||||
* `"relay"` - Relays where this server is listening for kind 21120 events (can have multiple)
|
||||
* `"expiry"` - Timestamp after which this server advertisement should be considered expired
|
||||
* `"p"` - Pubkeys allowed to send requests to this server (if none specified, server is public)
|
||||
|
||||
Clients looking to use HTTP over Nostr can query for these kind 1120 events to discover available servers and determine if they have permission to use them.
|
||||
|
||||
|
||||
|
||||
|
||||
## HTTP Requests - Kind 21120
|
||||
|
||||
Example **request** with a small payload. Payload is in `content` and `P` tag is the npub of the remote HTTP server.
|
||||
|
||||
@ -85,11 +122,22 @@ Example **request** with a small payload. Payload is in `content` and `P` tag i
|
||||
}
|
||||
```
|
||||
|
||||
Explanations:
|
||||
|
||||
* `kind:21120` - BIP39 word #1120 ([message](https://github.com/bitcoin/bips/blob/master/bip-0039/english.txt#L1120)), plus 20,000 to be treated as ephemeral (not stored by relays).
|
||||
* `"content"` - encrypted JSON with location of blob **OR** the content itself (if under a threshold). NIP-44 is NOT used as the payload may be large, affecting bunker signing stability.
|
||||
* `"p"` - the pubkey of the remote HTTP server.
|
||||
* `"key"` - the decryption key for the `content` field, also the key for the blossom blob (if used).
|
||||
* `"expiration"` - remote servers should not process requests after this time. Relays SHOULD delete events after this time.
|
||||
* `"r"` - (optional) relay on which the response should be sent.
|
||||
|
||||
### HTTP Response - Kind 21121
|
||||
|
||||
Example **response** with a large payload. Valid JSON is in `content` and `E` tag is populated. For privacy, the requestor npub is NOT shown - the requestor instead should be fetching the response using the `E` tag.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"kind": 21120,
|
||||
"kind": 21121,
|
||||
"pubkey": "<pubkey>",
|
||||
"content": "encrypt({'url':'blossom.one','hash':'xx'},$decryptkey)",
|
||||
"tags": [
|
||||
@ -103,13 +151,13 @@ Example **response** with a large payload. Valid JSON is in `content` and `E` t
|
||||
|
||||
Explanations:
|
||||
|
||||
* `kind:21120` - BIP39 word #1120 ([message](https://github.com/bitcoin/bips/blob/master/bip-0039/english.txt#L1120)), plus 20,000 to be treated as ephemeral (not stored by relays).
|
||||
* `kind:21121` - A different kind is used for responses, to help with filtering and other use cases.
|
||||
* `"content"` - encrypted JSON with location of blob **OR** the content itself (if under a threshold). NIP-44 is NOT used as the payload may be large, affecting bunker signing stability.
|
||||
* `"p"` - the pubkey of the remote HTTP server. Indicates that this is a REQUEST.
|
||||
* `"key"` - the decryption key for the `content` field, also the key for the blossom blob (if used).
|
||||
* `"E"` - ID of the request event. Enables a response to be identified, and fetched.
|
||||
* `"expiration"` - remote servers should not process requests after this time. Relays SHOULD delete events after this time.
|
||||
* `"r"` - (optional) relay on which the response should be sent. For Requests only.
|
||||
|
||||
There is no "p" tag as the "E" tag already identifies the request.
|
||||
|
||||
|
||||
## Considerations
|
||||
@ -145,4 +193,3 @@ So why would you use it? Several reasons:
|
||||
|
||||
|
||||
|
||||
|
||||
|
@ -1,105 +0,0 @@
|
||||
<!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>
|
@ -41,6 +41,17 @@
|
||||
<div id="relay-connection-section" class="active">
|
||||
<h2>Relay Connection</h2>
|
||||
<div class="relay-connection">
|
||||
<!-- Server npub display -->
|
||||
<div class="server-info-container">
|
||||
<div class="server-npub-container">
|
||||
<label>Server NPUB:</label>
|
||||
<input type="text" id="serverNpub" readonly class="server-npub-input">
|
||||
<button id="copyServerNpubBtn" class="copy-btn" title="Copy NPUB">
|
||||
<span id="copyBtnText">Copy</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relay-input-container">
|
||||
<label for="relayUrl">Relay URL:</label>
|
||||
<input type="text" id="relayUrl" value="wss://relay.degmods.com" placeholder="wss://relay.example.com">
|
||||
@ -49,7 +60,7 @@
|
||||
<div class="filter-options">
|
||||
<label class="filter-checkbox">
|
||||
<input type="checkbox" id="showAllEvents" checked>
|
||||
Show all kind 21120 events (not just addressed to me)
|
||||
Show all kind 21120 events
|
||||
</label>
|
||||
</div>
|
||||
<div id="relayStatus" class="relay-status">Not connected</div>
|
||||
|
@ -4,8 +4,6 @@
|
||||
|
||||
// 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.degmods.com"
|
||||
};
|
||||
|
@ -299,7 +299,7 @@ export async function displayConvertedEvent(): Promise<void> {
|
||||
if (httpRequestBox && eventOutputPre && outputDiv) {
|
||||
// Get server pubkey and relay values from inputs
|
||||
const serverPubkey = serverPubkeyInput && serverPubkeyInput.value ?
|
||||
serverPubkeyInput.value : defaultServerConfig.serverNpub;
|
||||
serverPubkeyInput.value : "";
|
||||
const relay = relayInput && relayInput.value ?
|
||||
relayInput.value : defaultServerConfig.defaultRelay;
|
||||
// Get or create a keypair
|
||||
|
File diff suppressed because it is too large
Load Diff
226
client/src/services/HttpService.ts
Normal file
226
client/src/services/HttpService.ts
Normal file
@ -0,0 +1,226 @@
|
||||
/**
|
||||
* HttpService.ts
|
||||
* Handles HTTP-related operations and cryptography functions for the HTTP-to-Nostr application
|
||||
*/
|
||||
|
||||
// Interface for HTTP request options
|
||||
export interface HttpRequestOptions {
|
||||
method: string;
|
||||
headers: Record<string, string>;
|
||||
body?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Class for handling HTTP requests and responses
|
||||
*/
|
||||
export class HttpService {
|
||||
/**
|
||||
* Parse a raw HTTP request text into its components
|
||||
* @param httpRequestText The raw HTTP request text
|
||||
* @returns Parsed HTTP request details or null if invalid
|
||||
*/
|
||||
public parseHttpRequest(httpRequestText: string): { url: string; options: HttpRequestOptions } | null {
|
||||
try {
|
||||
// Parse the HTTP request
|
||||
const lines = httpRequestText.split('\n');
|
||||
|
||||
// Extract the first line (request line)
|
||||
const requestLine = lines[0].trim().split(' ');
|
||||
if (requestLine.length < 2) {
|
||||
throw new Error('Invalid HTTP request: Missing request line');
|
||||
}
|
||||
|
||||
const method = requestLine[0];
|
||||
let url = requestLine[1];
|
||||
|
||||
// If it's a relative URL, make it absolute
|
||||
if (url.startsWith('/')) {
|
||||
url = `${window.location.origin}${url}`;
|
||||
} else if (!url.startsWith('http')) {
|
||||
url = `http://${url}`; // Default to http if no protocol specified
|
||||
}
|
||||
|
||||
// Parse headers
|
||||
const headers: Record<string, string> = {};
|
||||
let i = 1;
|
||||
let bodyStartIndex = lines.length;
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i].trim();
|
||||
if (line === '') {
|
||||
bodyStartIndex = i + 1;
|
||||
break;
|
||||
}
|
||||
|
||||
const colonIndex = line.indexOf(':');
|
||||
if (colonIndex > 0) {
|
||||
const key = line.substring(0, colonIndex).trim();
|
||||
const value = line.substring(colonIndex + 1).trim();
|
||||
headers[key] = value;
|
||||
}
|
||||
|
||||
i++;
|
||||
}
|
||||
|
||||
// Extract body if present
|
||||
let body = '';
|
||||
if (bodyStartIndex < lines.length) {
|
||||
body = lines.slice(bodyStartIndex).join('\n');
|
||||
}
|
||||
|
||||
// Return the parsed request
|
||||
return {
|
||||
url,
|
||||
options: {
|
||||
method,
|
||||
headers,
|
||||
...(body && method !== 'GET' && method !== 'HEAD' ? { body } : {})
|
||||
}
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute an HTTP request
|
||||
* @param httpRequestText The raw HTTP request text
|
||||
* @returns A promise that resolves to the HTTP response as text
|
||||
*/
|
||||
public async executeHttpRequest(httpRequestText: string): Promise<string> {
|
||||
try {
|
||||
const parsedRequest = this.parseHttpRequest(httpRequestText);
|
||||
if (!parsedRequest) {
|
||||
return 'Error: Could not parse HTTP request';
|
||||
}
|
||||
|
||||
const { url, options } = parsedRequest;
|
||||
|
||||
// Use the fetch API to execute the request - explicitly using window.fetch
|
||||
const response = await window.fetch(url, options);
|
||||
|
||||
// Prepare the response text
|
||||
let responseText = `HTTP/1.1 ${response.status} ${response.statusText}\n`;
|
||||
|
||||
// Add response headers
|
||||
response.headers.forEach((value: string, key: string) => {
|
||||
responseText += `${key}: ${value}\n`;
|
||||
});
|
||||
|
||||
// Add empty line to separate headers from body
|
||||
responseText += '\n';
|
||||
|
||||
// Add response body
|
||||
const responseBody = await response.text();
|
||||
responseText += responseBody;
|
||||
|
||||
return responseText;
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
return `Error executing HTTP request: ${error.message}`;
|
||||
} else {
|
||||
return `Error executing HTTP request: ${String(error)}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt data using Web Crypto API
|
||||
* @param plaintext The plaintext to encrypt
|
||||
* @param key The encryption key
|
||||
* @returns A promise that resolves to a base64-encoded encrypted string
|
||||
*/
|
||||
public async encryptWithWebCrypto(plaintext: string, key: string): Promise<string> {
|
||||
try {
|
||||
// Generate a random IV
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
|
||||
// Create key material from the encryption key
|
||||
const keyMaterial = await crypto.subtle.digest(
|
||||
'SHA-256',
|
||||
new TextEncoder().encode(key)
|
||||
);
|
||||
|
||||
// Import the key for AES-GCM encryption
|
||||
const cryptoKey = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
keyMaterial,
|
||||
{ name: 'AES-GCM' },
|
||||
false,
|
||||
['encrypt']
|
||||
);
|
||||
|
||||
// Encrypt the data
|
||||
const encryptedData = await crypto.subtle.encrypt(
|
||||
{
|
||||
name: 'AES-GCM',
|
||||
iv: iv
|
||||
},
|
||||
cryptoKey,
|
||||
new TextEncoder().encode(plaintext)
|
||||
);
|
||||
|
||||
// Combine IV and ciphertext
|
||||
const encryptedBytes = new Uint8Array(iv.length + new Uint8Array(encryptedData).length);
|
||||
encryptedBytes.set(iv, 0);
|
||||
encryptedBytes.set(new Uint8Array(encryptedData), iv.length);
|
||||
|
||||
// Convert to base64
|
||||
return btoa(String.fromCharCode(...encryptedBytes));
|
||||
} catch (error) {
|
||||
throw new Error(`WebCrypto encryption failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt data using Web Crypto API
|
||||
* @param encryptedBase64 The base64-encoded encrypted data
|
||||
* @param key The decryption key
|
||||
* @returns A promise that resolves to the decrypted plaintext
|
||||
*/
|
||||
public async decryptWithWebCrypto(encryptedBase64: string, key: string): Promise<string> {
|
||||
try {
|
||||
// Convert base64 to byte array
|
||||
const encryptedBytes = new Uint8Array(
|
||||
atob(encryptedBase64)
|
||||
.split('')
|
||||
.map(char => char.charCodeAt(0))
|
||||
);
|
||||
|
||||
// Extract IV (first 12 bytes)
|
||||
const iv = encryptedBytes.slice(0, 12);
|
||||
// Extract ciphertext (remaining bytes)
|
||||
const ciphertext = encryptedBytes.slice(12);
|
||||
|
||||
// Create key material from the decryption key
|
||||
const keyMaterial = await crypto.subtle.digest(
|
||||
'SHA-256',
|
||||
new TextEncoder().encode(key)
|
||||
);
|
||||
|
||||
// Import the key for AES-GCM decryption
|
||||
const cryptoKey = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
keyMaterial,
|
||||
{ name: 'AES-GCM' },
|
||||
false,
|
||||
['decrypt']
|
||||
);
|
||||
|
||||
// Decrypt the data
|
||||
const decryptedData = await crypto.subtle.decrypt(
|
||||
{
|
||||
name: 'AES-GCM',
|
||||
iv: iv
|
||||
},
|
||||
cryptoKey,
|
||||
ciphertext
|
||||
);
|
||||
|
||||
// Convert decrypted data to string
|
||||
return new TextDecoder().decode(decryptedData);
|
||||
} catch (error) {
|
||||
throw new Error(`WebCrypto decryption failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
}
|
351
client/src/services/NostrService.ts
Normal file
351
client/src/services/NostrService.ts
Normal file
@ -0,0 +1,351 @@
|
||||
/**
|
||||
* NostrService.ts
|
||||
* Handles Nostr-specific functionality like events, subscriptions, and filtering
|
||||
*/
|
||||
|
||||
// External imports
|
||||
import * as nostrTools from 'nostr-tools';
|
||||
|
||||
// Project imports
|
||||
import type { NostrEvent } from '../relay';
|
||||
import { convertNpubToHex } from '../relay';
|
||||
|
||||
import { WebSocketManager } from './WebSocketManager';
|
||||
|
||||
// Interface for profile data
|
||||
export interface ProfileData {
|
||||
name?: string;
|
||||
about?: string;
|
||||
picture?: string;
|
||||
nip05?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
// Interface for a Nostr subscription
|
||||
export interface NostrSubscription {
|
||||
unsub: () => void;
|
||||
}
|
||||
|
||||
// Interface for a received event
|
||||
export interface ReceivedEvent {
|
||||
id: string;
|
||||
event: NostrEvent;
|
||||
receivedAt: number;
|
||||
decrypted: boolean;
|
||||
decryptedContent?: string;
|
||||
}
|
||||
|
||||
// Interface for Nostr filter
|
||||
export interface NostrFilter {
|
||||
kinds: number[];
|
||||
'#p'?: string[];
|
||||
authors?: string[];
|
||||
since?: number;
|
||||
until?: number;
|
||||
limit?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Class for managing Nostr functionality
|
||||
*/
|
||||
export class NostrService {
|
||||
private profileCache = new Map<string, ProfileData>();
|
||||
private relayPool: nostrTools.SimplePool | null = null;
|
||||
private wsManager = new WebSocketManager();
|
||||
private activeRelayUrl: string | null = null;
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
private eventHandler: ((event: NostrEvent) => void) | null = null;
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
private statusCallback: ((message: string, className: string) => void) | null = null;
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
* @param statusCallback Callback for status updates
|
||||
*/
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
constructor(statusCallback?: ((message: string, className: string) => void)) {
|
||||
this.statusCallback = statusCallback || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the event handler for received events
|
||||
* @param handler The handler function for received events
|
||||
*/
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
public setEventHandler(handler: ((event: NostrEvent) => void)): void {
|
||||
this.eventHandler = handler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to a relay
|
||||
* @param relayUrl The relay URL to connect to
|
||||
* @returns A promise that resolves to true if connected successfully
|
||||
*/
|
||||
public async connectToRelay(relayUrl: string): Promise<boolean> {
|
||||
try {
|
||||
this.updateStatus('Connecting to relay...', 'connecting');
|
||||
|
||||
// Test the connection first
|
||||
const connectionSuccess = await this.wsManager.testConnection(relayUrl);
|
||||
if (!connectionSuccess) {
|
||||
this.updateStatus('Connection failed', 'error');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Close existing relay pool if any
|
||||
if (this.relayPool && this.activeRelayUrl) {
|
||||
try {
|
||||
await this.relayPool.close([this.activeRelayUrl]);
|
||||
} catch {
|
||||
// Ignore errors when closing
|
||||
}
|
||||
this.relayPool = null;
|
||||
}
|
||||
|
||||
// Create new relay pool
|
||||
this.relayPool = new nostrTools.SimplePool();
|
||||
this.activeRelayUrl = relayUrl;
|
||||
|
||||
this.updateStatus('Connected', 'connected');
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.updateStatus(
|
||||
`Error: ${error instanceof Error ? error.message : String(error)}`,
|
||||
'error'
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to events with the given filter
|
||||
* @param filter The filter to use for the subscription
|
||||
* @returns A promise that resolves to a NostrSubscription
|
||||
*/
|
||||
public async subscribeToEvents(filter: NostrFilter): Promise<NostrSubscription> {
|
||||
if (!this.activeRelayUrl) {
|
||||
throw new Error('No active relay URL');
|
||||
}
|
||||
|
||||
this.updateStatus('Creating subscription...', 'connecting');
|
||||
|
||||
try {
|
||||
await this.wsManager.connect(this.activeRelayUrl, {
|
||||
timeout: 5000,
|
||||
onOpen: (ws) => {
|
||||
// Send a REQ message to subscribe
|
||||
const reqId = `req-${Date.now()}`;
|
||||
const reqMsg = JSON.stringify(["REQ", reqId, filter]);
|
||||
ws.send(reqMsg);
|
||||
|
||||
this.updateStatus('Subscription active ✓', 'connected');
|
||||
},
|
||||
onMessage: (data) => {
|
||||
// Type assertion for the received data
|
||||
const nostrData = data as unknown[];
|
||||
|
||||
// Handle different message types
|
||||
if (Array.isArray(nostrData) && nostrData[0] === "EVENT" && nostrData.length >= 3) {
|
||||
const event = nostrData[2] as NostrEvent;
|
||||
|
||||
// Process the event if we have a handler
|
||||
if (this.eventHandler && event.id) {
|
||||
const callback = this.eventHandler;
|
||||
callback(event);
|
||||
}
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
this.updateStatus('WebSocket error', 'error');
|
||||
},
|
||||
onClose: () => {
|
||||
this.updateStatus('Connection closed', 'error');
|
||||
}
|
||||
});
|
||||
|
||||
// Return a subscription object
|
||||
return {
|
||||
unsub: () => {
|
||||
this.wsManager.close();
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
this.updateStatus(
|
||||
`Subscription error: ${error instanceof Error ? error.message : String(error)}`,
|
||||
'error'
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the service is connected to a relay
|
||||
* @returns true if connected, false otherwise
|
||||
*/
|
||||
public isConnected(): boolean {
|
||||
return this.relayPool !== null && this.activeRelayUrl !== null && this.wsManager.isConnected();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a filter for kind 21120 events, optionally filtered for a specific user
|
||||
* @param showAllEvents Whether to show all events or only those for the logged-in user
|
||||
* @returns A NostrFilter for the subscription
|
||||
*/
|
||||
public createKind21120Filter(showAllEvents: boolean): NostrFilter {
|
||||
// Create filter for kind 21120 events
|
||||
const filter: NostrFilter = {
|
||||
kinds: [21120], // HTTP Messages event kind
|
||||
};
|
||||
|
||||
// If "Show all events" is not checked and the user is logged in, filter for events addressed to them
|
||||
if (!showAllEvents) {
|
||||
const loggedInPubkey = this.getLoggedInPubkey();
|
||||
|
||||
if (loggedInPubkey) {
|
||||
let pubkeyHex = loggedInPubkey;
|
||||
|
||||
// Convert npub to hex if needed
|
||||
if (loggedInPubkey.startsWith('npub')) {
|
||||
try {
|
||||
const hexPubkey = convertNpubToHex(loggedInPubkey);
|
||||
if (hexPubkey) {
|
||||
pubkeyHex = hexPubkey;
|
||||
}
|
||||
} catch {
|
||||
// Ignore conversion errors
|
||||
}
|
||||
}
|
||||
|
||||
// Add p-tag filter for events addressed to the logged-in user
|
||||
filter['#p'] = [pubkeyHex];
|
||||
}
|
||||
}
|
||||
|
||||
return filter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the logged-in user's public key from localStorage
|
||||
* @returns The public key or null if not found
|
||||
*/
|
||||
public getLoggedInPubkey(): string | null {
|
||||
const pubkey = localStorage.getItem('userPublicKey');
|
||||
|
||||
// If no pubkey in localStorage, try to get it from window.nostr directly
|
||||
if (!pubkey && window.nostr && typeof window.nostr.getPublicKey === 'function') {
|
||||
// Note: This returns a promise, so we can't use it synchronously
|
||||
// But we'll trigger the fetch so it might be available next time
|
||||
window.nostr.getPublicKey()
|
||||
.then(directPubkey => {
|
||||
localStorage.setItem('userPublicKey', directPubkey);
|
||||
return directPubkey;
|
||||
})
|
||||
.catch(() => {
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
return pubkey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch profile data for a pubkey
|
||||
* @param pubkey The public key to fetch profile data for
|
||||
* @returns A promise that resolves to ProfileData or null
|
||||
*/
|
||||
public async fetchProfileData(pubkey: string): Promise<ProfileData | null> {
|
||||
// Return from cache if available
|
||||
if (this.profileCache.has(pubkey)) {
|
||||
return this.profileCache.get(pubkey) || null;
|
||||
}
|
||||
|
||||
try {
|
||||
// List of relays to try, in order of preference
|
||||
const relays = ['wss://relay.damus.io', 'wss://relay.nostr.band', 'wss://nos.lol'];
|
||||
|
||||
// Prepare the filter for kind-0 events (profile metadata)
|
||||
const filter = {
|
||||
kinds: [0],
|
||||
authors: [pubkey],
|
||||
limit: 1
|
||||
};
|
||||
|
||||
// Create unique request ID
|
||||
const requestId = `profile-${Math.random().toString(36).substring(2, 10)}`;
|
||||
const subRequest = ["REQ", requestId, filter];
|
||||
|
||||
// Store events received
|
||||
const events: NostrEvent[] = [];
|
||||
let connected = false;
|
||||
|
||||
// Try each relay until we get a response
|
||||
for (const relayUrl of relays) {
|
||||
if (connected) {
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
const wsManager = new WebSocketManager();
|
||||
await wsManager.connect(relayUrl, {
|
||||
timeout: 3000,
|
||||
onOpen: (ws) => {
|
||||
// Send subscription request for profile
|
||||
ws.send(JSON.stringify(subRequest));
|
||||
},
|
||||
onMessage: (data) => {
|
||||
// Type assertion for the received data
|
||||
const nostrData = data as unknown[];
|
||||
|
||||
// If we receive an EVENT message with our request ID
|
||||
if (Array.isArray(nostrData) && nostrData[0] === 'EVENT' && nostrData[1] === requestId) {
|
||||
events.push(nostrData[2] as NostrEvent);
|
||||
connected = true;
|
||||
wsManager.close();
|
||||
}
|
||||
// If we receive EOSE (End of Stored Events)
|
||||
else if (Array.isArray(nostrData) && nostrData[0] === 'EOSE' && nostrData[1] === requestId) {
|
||||
wsManager.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
// If connection fails, just continue to next relay
|
||||
}
|
||||
}
|
||||
|
||||
// Process the events if we found any
|
||||
if (events.length > 0) {
|
||||
const profileEvent = events[0];
|
||||
try {
|
||||
// Parse the content as JSON to get profile data
|
||||
const profileData = JSON.parse(profileEvent.content) as ProfileData;
|
||||
|
||||
// Store in cache
|
||||
this.profileCache.set(pubkey, profileData);
|
||||
|
||||
return profileData;
|
||||
} catch {
|
||||
// If parsing fails, return null
|
||||
return null;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Any error, return null
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the status via callback if set
|
||||
* @param message The status message
|
||||
* @param className The CSS class name for styling
|
||||
*/
|
||||
private updateStatus(message: string, className: string): void {
|
||||
if (this.statusCallback) {
|
||||
const callback = this.statusCallback;
|
||||
callback(message, className);
|
||||
}
|
||||
}
|
||||
}
|
686
client/src/services/UiService.ts
Normal file
686
client/src/services/UiService.ts
Normal file
@ -0,0 +1,686 @@
|
||||
/**
|
||||
* UiService.ts
|
||||
* Handles UI-related operations and DOM manipulation
|
||||
*/
|
||||
|
||||
import jsQR from 'jsqr';
|
||||
|
||||
import type { NostrEvent } from '../relay';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||
import { HttpService } from './HttpService';
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||
import { NostrService } from './NostrService';
|
||||
import type { ProfileData, ReceivedEvent } from './NostrService';
|
||||
|
||||
/**
|
||||
* Class for managing UI operations
|
||||
*/
|
||||
export class UiService {
|
||||
private nostrService: NostrService;
|
||||
private httpService: HttpService;
|
||||
private receivedEvents = new Map<string, ReceivedEvent>();
|
||||
|
||||
// DOM Elements
|
||||
private relayStatus: HTMLElement | null = null;
|
||||
private eventsList: HTMLElement | null = null;
|
||||
private eventDetails: HTMLElement | null = null;
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
* @param nostrService An instance of NostrService
|
||||
* @param httpService An instance of HttpService
|
||||
*/
|
||||
constructor(nostrService: NostrService, httpService: HttpService) {
|
||||
this.nostrService = nostrService;
|
||||
this.httpService = httpService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the UI elements and event listeners
|
||||
*/
|
||||
public initialize(): void {
|
||||
// Get DOM elements
|
||||
this.relayStatus = document.getElementById('relayStatus');
|
||||
this.eventsList = document.getElementById('eventsList');
|
||||
this.eventDetails = document.getElementById('eventDetails');
|
||||
|
||||
// Initialize event handlers
|
||||
this.setupTabNavigation();
|
||||
this.setupQRScanner();
|
||||
this.setupRawTextInput();
|
||||
this.setupEventHandlers();
|
||||
|
||||
// Set event handler for Nostr events
|
||||
this.nostrService.setEventHandler((event) => this.processEvent(event));
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up tab navigation for the UI
|
||||
*/
|
||||
private setupTabNavigation(): void {
|
||||
const tabs = document.querySelectorAll('.tab-content');
|
||||
const tabButtons = document.querySelectorAll('.tab-btn');
|
||||
|
||||
tabButtons.forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
// Remove active class from all buttons and tabs
|
||||
tabButtons.forEach(b => b.classList.remove('active'));
|
||||
tabs.forEach(tab => tab.classList.remove('active'));
|
||||
|
||||
// Add active class to clicked button and corresponding tab
|
||||
button.classList.add('active');
|
||||
const tabId = button.getAttribute('data-tab');
|
||||
|
||||
if (tabId) {
|
||||
const activeTab = document.getElementById(tabId);
|
||||
if (activeTab) {
|
||||
activeTab.classList.add('active');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up QR scanner functionality
|
||||
*/
|
||||
private setupQRScanner(): void {
|
||||
const scannerContainer = document.getElementById('qrScannerContainer');
|
||||
const startScanBtn = document.getElementById('startScanBtn');
|
||||
const stopScanBtn = document.getElementById('stopScanBtn');
|
||||
const videoElement = document.getElementById('qrVideo') as HTMLVideoElement;
|
||||
const qrResultInput = document.getElementById('qrResultInput') as HTMLTextAreaElement;
|
||||
|
||||
if (!scannerContainer || !startScanBtn || !stopScanBtn || !videoElement || !qrResultInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Start QR scanner
|
||||
async function startQRScanner(): Promise<void> {
|
||||
try {
|
||||
if (scannerContainer && startScanBtn && stopScanBtn) {
|
||||
scannerContainer.classList.remove('hidden');
|
||||
startScanBtn.classList.add('hidden');
|
||||
stopScanBtn.classList.remove('hidden');
|
||||
}
|
||||
|
||||
// Get user media
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: { facingMode: 'environment' }
|
||||
});
|
||||
|
||||
videoElement.srcObject = stream;
|
||||
videoElement.play();
|
||||
|
||||
// Start scanning for QR codes
|
||||
scanQRCode();
|
||||
} catch (error) {
|
||||
alert(`Error accessing camera: ${error instanceof Error ? error.message : String(error)}`);
|
||||
stopQRScanner();
|
||||
}
|
||||
}
|
||||
|
||||
// Stop QR scanner
|
||||
function stopQRScanner(): void {
|
||||
if (scannerContainer && startScanBtn && stopScanBtn) {
|
||||
scannerContainer.classList.add('hidden');
|
||||
startScanBtn.classList.remove('hidden');
|
||||
stopScanBtn.classList.add('hidden');
|
||||
}
|
||||
|
||||
// Stop the video stream
|
||||
if (videoElement.srcObject) {
|
||||
const stream = videoElement.srcObject as MediaStream;
|
||||
const tracks = stream.getTracks();
|
||||
|
||||
tracks.forEach(track => {
|
||||
track.stop();
|
||||
});
|
||||
|
||||
videoElement.srcObject = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Add event listeners
|
||||
startScanBtn.addEventListener('click', () => {
|
||||
startQRScanner().catch(error => {
|
||||
alert(`Error starting QR scanner: ${error instanceof Error ? error.message : String(error)}`);
|
||||
});
|
||||
});
|
||||
|
||||
stopScanBtn.addEventListener('click', stopQRScanner);
|
||||
|
||||
// Function to scan QR code from video feed
|
||||
function scanQRCode(): void {
|
||||
if (videoElement.readyState !== videoElement.HAVE_ENOUGH_DATA) {
|
||||
// Not enough data yet, retry after a short delay
|
||||
setTimeout(scanQRCode, 100);
|
||||
return;
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const width = videoElement.videoWidth;
|
||||
const height = videoElement.videoHeight;
|
||||
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
setTimeout(scanQRCode, 100);
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.drawImage(videoElement, 0, 0, width, height);
|
||||
const imageData = ctx.getImageData(0, 0, width, height);
|
||||
|
||||
// Use jsQR to detect QR code
|
||||
const code = jsQR(imageData.data, width, height, {
|
||||
inversionAttempts: 'dontInvert'
|
||||
});
|
||||
|
||||
if (code) {
|
||||
// QR code detected
|
||||
qrResultInput.value = code.data;
|
||||
stopQRScanner();
|
||||
} else {
|
||||
// No QR code found, retry after a short delay
|
||||
setTimeout(scanQRCode, 100);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up raw text input functionality
|
||||
*/
|
||||
private setupRawTextInput(): void {
|
||||
const rawEventInput = document.getElementById('rawEventInput') as HTMLTextAreaElement;
|
||||
const parseRawEventBtn = document.getElementById('parseRawEventBtn');
|
||||
|
||||
if (!rawEventInput || !parseRawEventBtn) {
|
||||
return;
|
||||
}
|
||||
|
||||
parseRawEventBtn.addEventListener('click', () => {
|
||||
try {
|
||||
const rawText = rawEventInput.value.trim();
|
||||
if (!rawText) {
|
||||
alert('Please enter a raw Nostr event.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to parse as JSON
|
||||
const eventData = JSON.parse(rawText);
|
||||
|
||||
// Validate as a Nostr event
|
||||
if (!eventData.id || !eventData.pubkey || !eventData.created_at ||
|
||||
!eventData.kind || !Array.isArray(eventData.tags) ||
|
||||
typeof eventData.content !== 'string' || !eventData.sig) {
|
||||
alert('Invalid Nostr event format.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Process the event
|
||||
this.processEvent(eventData as NostrEvent);
|
||||
|
||||
// Clear the input
|
||||
rawEventInput.value = '';
|
||||
|
||||
// Show notification
|
||||
alert('Event processed successfully!');
|
||||
} catch (error) {
|
||||
alert(`Error parsing event: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up event handlers for the UI
|
||||
*/
|
||||
private setupEventHandlers(): void {
|
||||
// Connect to relay button
|
||||
const connectRelayBtn = document.getElementById('connectRelayBtn');
|
||||
const relayUrlInput = document.getElementById('relayUrlInput') as HTMLInputElement;
|
||||
|
||||
if (connectRelayBtn && relayUrlInput) {
|
||||
connectRelayBtn.addEventListener('click', async () => {
|
||||
const relayUrl = relayUrlInput.value.trim();
|
||||
if (!relayUrl) {
|
||||
alert('Please enter a relay URL');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Connect to the relay
|
||||
const success = await this.nostrService.connectToRelay(relayUrl);
|
||||
|
||||
if (success) {
|
||||
// Subscribe to events
|
||||
const showAllEventsCheckbox = document.getElementById('showAllEvents') as HTMLInputElement;
|
||||
const showAllEvents = showAllEventsCheckbox ? showAllEventsCheckbox.checked : false;
|
||||
|
||||
const filter = this.nostrService.createKind21120Filter(showAllEvents);
|
||||
await this.nostrService.subscribeToEvents(filter);
|
||||
}
|
||||
} catch (error) {
|
||||
alert(`Error connecting to relay: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Show all events checkbox
|
||||
const showAllEventsCheckbox = document.getElementById('showAllEvents') as HTMLInputElement;
|
||||
if (showAllEventsCheckbox) {
|
||||
showAllEventsCheckbox.addEventListener('change', async () => {
|
||||
try {
|
||||
// Resubscribe with new filter
|
||||
const filter = this.nostrService.createKind21120Filter(showAllEventsCheckbox.checked);
|
||||
await this.nostrService.subscribeToEvents(filter);
|
||||
} catch (error) {
|
||||
alert(`Error updating subscription: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Connect to default relay on page load
|
||||
setTimeout(async () => {
|
||||
if (relayUrlInput && relayUrlInput.value && connectRelayBtn) {
|
||||
// Trigger click on the connect button
|
||||
connectRelayBtn.click();
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a received Nostr event
|
||||
* @param event The Nostr event to process
|
||||
*/
|
||||
private processEvent(event: NostrEvent): void {
|
||||
// Ensure event has an ID
|
||||
if (!event.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this is a new event
|
||||
if (this.receivedEvents.has(event.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Store the event
|
||||
const receivedEvent: ReceivedEvent = {
|
||||
id: event.id,
|
||||
event: event,
|
||||
receivedAt: Date.now(),
|
||||
decrypted: false
|
||||
};
|
||||
|
||||
this.receivedEvents.set(event.id, receivedEvent);
|
||||
|
||||
// Add event to UI
|
||||
this.addEventToUI(receivedEvent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an event to the UI
|
||||
* @param receivedEvent The received event to add to the UI
|
||||
*/
|
||||
private addEventToUI(receivedEvent: ReceivedEvent): void {
|
||||
if (!this.eventsList) {
|
||||
return;
|
||||
}
|
||||
|
||||
const event = receivedEvent.event;
|
||||
|
||||
// Create event item with improved styling
|
||||
const eventItem = document.createElement('div');
|
||||
eventItem.className = 'event-item';
|
||||
eventItem.dataset.id = event.id;
|
||||
|
||||
// Get event ID for display
|
||||
const eventIdForDisplay = event.id ? event.id.substring(0, 8) : 'unknown';
|
||||
|
||||
// Determine if it's a request or response
|
||||
const hasP = event.tags.some(tag => tag[0] === 'p');
|
||||
const hasE = event.tags.some(tag => tag[0] === 'e');
|
||||
const eventType = hasP ? 'HTTP Request' : (hasE ? 'HTTP Response' : 'Unknown');
|
||||
|
||||
// Find recipient if available
|
||||
let recipient = '';
|
||||
const pTag = event.tags.find(tag => tag[0] === 'p');
|
||||
if (pTag && pTag.length > 1) {
|
||||
recipient = `| To: ${pTag[1].substring(0, 8)}...`;
|
||||
}
|
||||
|
||||
// Format the event item
|
||||
eventItem.innerHTML = `
|
||||
<div class="event-item-container">
|
||||
<div class="event-avatar" data-pubkey="${event.pubkey}">
|
||||
<div class="avatar-placeholder">👤</div>
|
||||
</div>
|
||||
<div class="event-content-wrapper">
|
||||
<div class="event-header">
|
||||
<div class="event-type ${eventType === 'HTTP Request' ? 'request' : 'response'}">${eventType}</div>
|
||||
<div class="event-time">${new Date(event.created_at * 1000).toLocaleTimeString()}</div>
|
||||
</div>
|
||||
<div class="event-id">ID: ${eventIdForDisplay}... ${recipient}</div>
|
||||
<div class="event-pubkey">From: ${event.pubkey.substring(0, 8)}...</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Add to list at the top
|
||||
if (this.eventsList.firstChild) {
|
||||
this.eventsList.insertBefore(eventItem, this.eventsList.firstChild);
|
||||
} else {
|
||||
this.eventsList.appendChild(eventItem);
|
||||
}
|
||||
|
||||
// Fetch profile for avatar after a short delay
|
||||
const avatarElement = eventItem.querySelector('.event-avatar') as HTMLElement;
|
||||
if (avatarElement) {
|
||||
setTimeout(() => {
|
||||
this.nostrService.fetchProfileData(event.pubkey)
|
||||
.then(profile => this.updateAvatarElement(avatarElement, profile))
|
||||
.catch(() => this.updateAvatarElement(avatarElement, null));
|
||||
}, 10);
|
||||
}
|
||||
|
||||
// Add click event to show details
|
||||
eventItem.addEventListener('click', () => {
|
||||
if (event.id) {
|
||||
this.showEventDetails(event.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an avatar element with profile data
|
||||
* @param element The element to update
|
||||
* @param profile The profile data to use
|
||||
*/
|
||||
private updateAvatarElement(element: HTMLElement, profile: ProfileData | null): void {
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (profile && profile.picture) {
|
||||
// Use the profile picture URL
|
||||
element.innerHTML = `<img src="${profile.picture}" alt="Avatar" class="avatar-img" />`;
|
||||
} else {
|
||||
// Use default avatar
|
||||
element.innerHTML = `<div class="avatar-placeholder">👤</div>`;
|
||||
}
|
||||
|
||||
// Make sure it's visible
|
||||
element.style.opacity = '1';
|
||||
}
|
||||
|
||||
/**
|
||||
* Show event details in the UI
|
||||
* @param eventId The ID of the event to show details for
|
||||
*/
|
||||
public showEventDetails(eventId: string): void {
|
||||
if (!this.eventDetails) {
|
||||
console.error('Event details element not found!');
|
||||
return;
|
||||
}
|
||||
|
||||
const receivedEvent = this.receivedEvents.get(eventId);
|
||||
if (!receivedEvent) {
|
||||
console.error(`Event with ID ${eventId} not found in receivedEvents Map!`);
|
||||
return;
|
||||
}
|
||||
|
||||
const event = receivedEvent.event;
|
||||
|
||||
// Ensure event has an ID (should already be verified)
|
||||
const eventIdForDisplay = event.id ? event.id.substring(0, 8) : 'unknown';
|
||||
|
||||
// Determine if this is a request or response
|
||||
const isRequest = event.tags.some(tag => tag[0] === 'p');
|
||||
const isResponse = event.tags.some(tag => tag[0] === 'e');
|
||||
const eventTypeLabel = isRequest ? 'HTTP Request' : (isResponse ? 'HTTP Response' : 'Unknown Type');
|
||||
|
||||
// Get the decrypted or original content
|
||||
const httpContent = receivedEvent.decrypted ?
|
||||
(receivedEvent.decryptedContent || event.content) :
|
||||
event.content;
|
||||
|
||||
// Create raw JSON representation of the event
|
||||
const rawJson = JSON.stringify(event, null, 2);
|
||||
|
||||
// Display the event details with a tabbed interface
|
||||
this.eventDetails.innerHTML = `
|
||||
<div class="event-detail-header">
|
||||
<h3>${eventTypeLabel} (ID: ${eventIdForDisplay}...)</h3>
|
||||
<div class="event-timestamp">${new Date(event.created_at * 1000).toLocaleString()}</div>
|
||||
</div>
|
||||
|
||||
<div class="event-detail-tabs">
|
||||
<button class="tab-btn" data-tab="raw-json">Raw JSON</button>
|
||||
<button class="tab-btn active" data-tab="http-content">HTTP Content</button>
|
||||
</div>
|
||||
|
||||
<div class="event-detail-content">
|
||||
<div class="tab-content" id="raw-json">
|
||||
<pre class="json-content">${rawJson}</pre>
|
||||
</div>
|
||||
<div class="tab-content active" id="http-content">
|
||||
${isRequest ?
|
||||
`<div class="http-content-header">
|
||||
<button class="execute-http-request-btn">Execute HTTP Request</button>
|
||||
</div>` :
|
||||
''
|
||||
}
|
||||
<pre class="http-content">${httpContent}</pre>
|
||||
${!receivedEvent.decrypted ? '<div class="decryption-status">Attempting decryption...</div>' : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Add event listeners for tab buttons
|
||||
const tabButtons = this.eventDetails.querySelectorAll('.tab-btn');
|
||||
tabButtons.forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
// Get the target tab
|
||||
const targetTabId = (button as HTMLElement).dataset.tab;
|
||||
if (!targetTabId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove active class from all buttons and content sections
|
||||
tabButtons.forEach(btn => btn.classList.remove('active'));
|
||||
// Use non-null assertion since we already checked eventDetails isn't null at the beginning
|
||||
this.eventDetails!.querySelectorAll('.tab-content').forEach(section => section.classList.remove('active'));
|
||||
|
||||
// Add active class to clicked button and corresponding content section
|
||||
button.classList.add('active');
|
||||
const targetSection = document.getElementById(targetTabId);
|
||||
if (targetSection) {
|
||||
targetSection.classList.add('active');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Add event listener for the execute HTTP request button if it exists
|
||||
const executeButton = this.eventDetails.querySelector('.execute-http-request-btn');
|
||||
if (executeButton && isRequest) {
|
||||
executeButton.addEventListener('click', async () => {
|
||||
// Change button state to indicate processing
|
||||
executeButton.textContent = 'Executing...';
|
||||
executeButton.setAttribute('disabled', 'true');
|
||||
|
||||
try {
|
||||
// Execute the HTTP request
|
||||
const response = await this.httpService.executeHttpRequest(httpContent);
|
||||
|
||||
// Display the response
|
||||
this.displayHttpResponse(response);
|
||||
} catch (error) {
|
||||
// Display error in a modal
|
||||
this.displayHttpResponse(`Error executing request: ${error instanceof Error ? error.message : String(error)}`);
|
||||
} finally {
|
||||
// Restore button state
|
||||
executeButton.textContent = 'Execute HTTP Request';
|
||||
executeButton.removeAttribute('disabled');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display an HTTP response in a modal
|
||||
* @param responseText The response text to display
|
||||
*/
|
||||
private displayHttpResponse(responseText: string): void {
|
||||
// Create a modal dialog element
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'http-response-modal';
|
||||
|
||||
// Add the response content
|
||||
modal.innerHTML = `
|
||||
<div class="http-response-container">
|
||||
<div class="http-response-header">
|
||||
<h3>HTTP Response</h3>
|
||||
<button class="close-modal-btn">×</button>
|
||||
</div>
|
||||
<div class="http-response-content">
|
||||
<pre>${responseText}</pre>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Add the modal to the DOM
|
||||
document.body.appendChild(modal);
|
||||
|
||||
// Add event listener to close the modal
|
||||
const closeBtn = modal.querySelector('.close-modal-btn');
|
||||
if (closeBtn) {
|
||||
closeBtn.addEventListener('click', () => {
|
||||
document.body.removeChild(modal);
|
||||
});
|
||||
}
|
||||
|
||||
// Also close the modal when clicking outside the content
|
||||
modal.addEventListener('click', (event) => {
|
||||
if (event.target === modal) {
|
||||
document.body.removeChild(modal);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to decrypt an event using NIP-44
|
||||
* @param eventId The ID of the event to decrypt
|
||||
*/
|
||||
public async decryptEvent(eventId: string): Promise<void> {
|
||||
const receivedEvent = this.receivedEvents.get(eventId);
|
||||
if (!receivedEvent || receivedEvent.decrypted) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const event = receivedEvent.event;
|
||||
let decryptedContent: string;
|
||||
|
||||
// Look for a "key" tag in the event
|
||||
const keyTag = event.tags.find(tag => tag[0] === 'key');
|
||||
|
||||
if (!keyTag || keyTag.length < 2) {
|
||||
decryptedContent = `[No key tag found for decryption]\n${event.content}`;
|
||||
} else {
|
||||
try {
|
||||
// Extract the encrypted key from the tag
|
||||
const encryptedKey = keyTag[1];
|
||||
|
||||
// Check if window.nostr and nip44.decrypt are available
|
||||
if (!window.nostr) {
|
||||
throw new Error("window.nostr is not available - ensure a NIP-07 extension is installed");
|
||||
}
|
||||
|
||||
if (!window.nostr.nip44 || !window.nostr.nip44.decrypt) {
|
||||
console.warn("NIP-44 decryption not available - trying to connect to a compatible extension");
|
||||
|
||||
// Check if we can trigger a connection via NostrLogin
|
||||
if (typeof window.nostr.getPublicKey === 'function') {
|
||||
try {
|
||||
await window.nostr.getPublicKey();
|
||||
// Check again after getting public key
|
||||
if (typeof window.nostr.nip44 === 'object' &&
|
||||
typeof window.nostr.nip44.decrypt === 'function') {
|
||||
console.log("NIP-44 is now available after connecting");
|
||||
} else {
|
||||
throw new Error("NIP-44 decryption is still not available after connecting");
|
||||
}
|
||||
} catch (e) {
|
||||
throw new Error(`Failed to connect to extension: ${e}`);
|
||||
}
|
||||
} else {
|
||||
throw new Error("window.nostr.nip44.decrypt is not available");
|
||||
}
|
||||
}
|
||||
|
||||
// Declare decryptedKey outside the try block so it's available in the outer scope
|
||||
let decryptedKey: string;
|
||||
|
||||
try {
|
||||
// Use window.nostr to decrypt the key tag with NIP-44
|
||||
decryptedKey = await window.nostr.nip44.decrypt(
|
||||
event.pubkey, // The pubkey that encrypted the key
|
||||
encryptedKey // The encrypted key from the "key" tag
|
||||
);
|
||||
} catch (decryptKeyError) {
|
||||
console.error("Error in direct decryption call:", decryptKeyError);
|
||||
throw decryptKeyError; // Re-throw to be caught by the outer catch block
|
||||
}
|
||||
|
||||
// Now use the decrypted key to decrypt the content using Web Crypto API
|
||||
try {
|
||||
// Decrypt the content using Web Crypto API and the decrypted key
|
||||
const decryptedEventContent = await this.httpService.decryptWithWebCrypto(
|
||||
event.content,
|
||||
decryptedKey
|
||||
);
|
||||
|
||||
// The decrypted content is the direct HTTP request/response
|
||||
decryptedContent = decryptedEventContent;
|
||||
} catch (contentDecryptError) {
|
||||
decryptedContent = `Key decryption successful, but content decryption failed: ${contentDecryptError instanceof Error ? contentDecryptError.message : String(contentDecryptError)}
|
||||
|
||||
Encrypted content: ${event.content.substring(0, 50)}...
|
||||
Decrypted key: ${decryptedKey}`;
|
||||
}
|
||||
} catch (decryptError) {
|
||||
console.error("Failed to decrypt with window.nostr.nip44:", decryptError instanceof Error ? decryptError.message : String(decryptError));
|
||||
decryptedContent = `[NIP-44 decryption failed: ${decryptError instanceof Error ? decryptError.message : String(decryptError)}]\n${event.content}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Update the event
|
||||
receivedEvent.decrypted = true;
|
||||
receivedEvent.decryptedContent = decryptedContent;
|
||||
this.receivedEvents.set(eventId, receivedEvent);
|
||||
|
||||
// Update UI if this event is currently being viewed
|
||||
const selectedEventId = this.eventDetails?.querySelector('h3')?.textContent?.match(/(.+)\.\.\./)?.[1];
|
||||
if (selectedEventId && `${selectedEventId}...` === `${eventId.substring(0, 8)}...`) {
|
||||
this.showEventDetails(eventId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to process event:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update relay status in UI
|
||||
* @param message The status message
|
||||
* @param className The CSS class name
|
||||
*/
|
||||
public updateRelayStatus(message: string, className: string): void {
|
||||
if (this.relayStatus) {
|
||||
this.relayStatus.textContent = message;
|
||||
this.relayStatus.className = `relay-status ${className}`;
|
||||
}
|
||||
}
|
||||
}
|
179
client/src/services/WebSocketManager.ts
Normal file
179
client/src/services/WebSocketManager.ts
Normal file
@ -0,0 +1,179 @@
|
||||
/**
|
||||
* WebSocketManager.ts
|
||||
* Handles WebSocket connections and abstracts the WebSocket API
|
||||
*/
|
||||
|
||||
export interface WebSocketOptions {
|
||||
timeout?: number;
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
onOpen?: (socket: WebSocket) => void;
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
onMessage?: (data: unknown) => void;
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
onError?: (error: Event) => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export class WebSocketManager {
|
||||
private ws: WebSocket | null = null;
|
||||
private connected = false;
|
||||
private url: string | null = null;
|
||||
|
||||
/**
|
||||
* Create a WebSocket connection
|
||||
* @param url The WebSocket URL to connect to
|
||||
* @param options Options for the WebSocket connection
|
||||
* @returns A promise that resolves when the connection is established
|
||||
*/
|
||||
public async connect(url: string, options: WebSocketOptions = {}): Promise<WebSocket> {
|
||||
return new Promise<WebSocket>((resolve, reject) => {
|
||||
// Close existing connection if any
|
||||
this.close();
|
||||
|
||||
this.url = url;
|
||||
this.ws = new WebSocket(url);
|
||||
this.connected = false;
|
||||
|
||||
// Set a timeout to avoid hanging
|
||||
const timeout = setTimeout(() => {
|
||||
if (!this.connected) {
|
||||
this.close();
|
||||
reject(new Error(`Connection timeout after ${options.timeout || 5000}ms`));
|
||||
}
|
||||
}, options.timeout || 5000);
|
||||
|
||||
// Set up event handlers
|
||||
this.ws.onopen = () => {
|
||||
clearTimeout(timeout);
|
||||
this.connected = true;
|
||||
|
||||
if (options.onOpen) {
|
||||
const callback = options.onOpen;
|
||||
callback(this.ws as WebSocket);
|
||||
}
|
||||
|
||||
resolve(this.ws as WebSocket);
|
||||
};
|
||||
|
||||
this.ws.onmessage = (msg) => {
|
||||
if (options.onMessage && typeof msg.data === 'string') {
|
||||
try {
|
||||
const parsedData = JSON.parse(msg.data);
|
||||
const callback = options.onMessage;
|
||||
callback(parsedData);
|
||||
} catch {
|
||||
// Ignore parsing errors
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onerror = (errorEvt) => {
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (options.onError) {
|
||||
const callback = options.onError;
|
||||
callback(errorEvt);
|
||||
}
|
||||
|
||||
if (!this.connected) {
|
||||
reject(new Error(`WebSocket error: ${errorEvt.toString()}`));
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onclose = () => {
|
||||
clearTimeout(timeout);
|
||||
this.connected = false;
|
||||
|
||||
if (options.onClose) {
|
||||
options.onClose();
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Test a WebSocket connection without creating a persistent connection
|
||||
* @param url The WebSocket URL to test
|
||||
* @param timeout Timeout in milliseconds
|
||||
* @returns A promise that resolves when the connection test is complete
|
||||
*/
|
||||
public async testConnection(url: string, timeout = 5000): Promise<boolean> {
|
||||
try {
|
||||
const ws = new WebSocket(url);
|
||||
let connected = false;
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (!connected) {
|
||||
ws.close();
|
||||
reject(new Error('Connection timeout'));
|
||||
}
|
||||
}, timeout);
|
||||
|
||||
ws.onopen = () => {
|
||||
clearTimeout(timeoutId);
|
||||
connected = true;
|
||||
resolve();
|
||||
};
|
||||
|
||||
ws.onerror = (err) => {
|
||||
clearTimeout(timeoutId);
|
||||
reject(new Error(`WebSocket error: ${err.toString()}`));
|
||||
};
|
||||
});
|
||||
|
||||
ws.close();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send data through the WebSocket
|
||||
* @param data The data to send
|
||||
* @returns true if sent successfully, false otherwise
|
||||
*/
|
||||
public send(data: string | ArrayBufferLike | Blob | ArrayBufferView): boolean {
|
||||
if (!this.ws || !this.connected) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
this.ws.send(data);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the WebSocket connection
|
||||
*/
|
||||
public close(): void {
|
||||
if (this.ws) {
|
||||
try {
|
||||
this.ws.close();
|
||||
} catch {
|
||||
// Ignore errors when closing WebSocket
|
||||
}
|
||||
this.ws = null;
|
||||
this.connected = false;
|
||||
this.url = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the WebSocket is connected
|
||||
*/
|
||||
public isConnected(): boolean {
|
||||
return this.connected && this.ws !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current WebSocket URL
|
||||
*/
|
||||
public getUrl(): string | null {
|
||||
return this.url;
|
||||
}
|
||||
}
|
@ -454,6 +454,59 @@ footer {
|
||||
margin-bottom: 20px;
|
||||
padding: 15px;
|
||||
background-color: var(--bg-secondary);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
/* Server info styles */
|
||||
.server-info-container {
|
||||
margin-bottom: 10px;
|
||||
padding-bottom: 15px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.server-npub-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.server-npub-container label {
|
||||
font-weight: 600;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.server-npub-input {
|
||||
flex: 1;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
background-color: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
min-width: 250px;
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
padding: 8px 12px;
|
||||
background-color: var(--button-primary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.copy-btn:hover {
|
||||
background-color: var(--button-hover);
|
||||
}
|
||||
|
||||
.copy-btn.copied {
|
||||
background-color: var(--button-success);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
@ -1238,6 +1291,133 @@ footer {
|
||||
background-color: #c82333;
|
||||
}
|
||||
|
||||
/* HTTP Content Header for the Execute button */
|
||||
.http-content-header {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.execute-http-request-btn {
|
||||
padding: 8px 15px;
|
||||
background-color: var(--button-primary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.execute-http-request-btn:hover {
|
||||
background-color: var(--button-hover);
|
||||
}
|
||||
|
||||
.execute-http-request-btn:disabled {
|
||||
background-color: var(--bg-tertiary);
|
||||
color: var(--text-tertiary);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* HTTP Response Modal */
|
||||
.http-response-modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.http-response-container {
|
||||
width: 80%;
|
||||
max-width: 800px;
|
||||
max-height: 80vh;
|
||||
background-color: var(--bg-secondary);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.http-response-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 15px 20px;
|
||||
background-color: var(--bg-tertiary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.http-response-header h3 {
|
||||
margin: 0;
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.close-modal-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.close-modal-btn:hover {
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.http-response-content {
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
max-height: calc(80vh - 60px);
|
||||
}
|
||||
|
||||
.http-response-content pre {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
/* Event item with avatar layout */
|
||||
.event-item-container {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.event-avatar {
|
||||
flex-shrink: 0;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
background-color: var(--bg-tertiary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.event-content-wrapper {
|
||||
flex: 1;
|
||||
min-width: 0; /* Prevent flex item from overflowing */
|
||||
}
|
||||
|
||||
.avatar-placeholder {
|
||||
font-size: 20px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.avatar-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
/* Tab Interface Styles */
|
||||
.tabs {
|
||||
display: flex;
|
||||
|
Loading…
x
Reference in New Issue
Block a user