diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..f5ce7d1 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "cSpell.words": ["Nostr"] +} diff --git a/package-lock.json b/package-lock.json index dd77697..b936b30 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,13 +29,15 @@ "gatsby-source-filesystem": "^3.3.0", "gatsby-transformer-remark": "^4.0.0", "gatsby-transformer-sharp": "^3.3.0", + "nostr-tools": "^2.7.0", "react": "^17.0.1", "react-dom": "^17.0.1", "react-helmet": "^6.1.0", "react-icons": "^4.2.0", "react-share": "^4.4.0", "react-use-flexsearch": "^0.1.1", - "styled-components": "^5.2.3" + "styled-components": "^5.2.3", + "tseep": "^1.2.1" }, "devDependencies": { "@popperjs/core": "^2.9.2", @@ -3114,6 +3116,47 @@ "eslint-scope": "5.1.1" } }, + "node_modules/@noble/ciphers": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-0.5.3.tgz", + "integrity": "sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w==", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz", + "integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==", + "dependencies": { + "@noble/hashes": "1.3.2" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves/node_modules/@noble/hashes": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz", + "integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.1.tgz", + "integrity": "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -3215,6 +3258,53 @@ "url": "https://opencollective.com/popperjs" } }, + "node_modules/@scure/base": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.1.tgz", + "integrity": "sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA==", + "funding": [ + { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + ] + }, + "node_modules/@scure/bip32": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.3.1.tgz", + "integrity": "sha512-osvveYtyzdEVbt3OfwwXFr4P2iVBL5u1Q3q4ONBfDY/UpOuXmOlbgwc1xECEboY8wIays8Yt6onaWMUdUbfl0A==", + "dependencies": { + "@noble/curves": "~1.1.0", + "@noble/hashes": "~1.3.1", + "@scure/base": "~1.1.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32/node_modules/@noble/curves": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.1.0.tgz", + "integrity": "sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA==", + "dependencies": { + "@noble/hashes": "1.3.1" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip39": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.2.1.tgz", + "integrity": "sha512-Z3/Fsz1yr904dduJD0NpiyRHhRYHdcnyh73FZWiV+/qhWi83wNJ3NWolYqCEN+ZWsUz2TWwajJggcRE9r1zUYg==", + "dependencies": { + "@noble/hashes": "~1.3.0", + "@scure/base": "~1.1.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@sideway/address": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz", @@ -15854,6 +15944,36 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/nostr-tools": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.7.0.tgz", + "integrity": "sha512-jJoL2J1CBiKDxaXZww27nY/Wsuxzx7AULxmGKFce4sskDu1tohNyfnzYQ8BvDyvkstU8kNZUAXPL32tre33uig==", + "dependencies": { + "@noble/ciphers": "^0.5.1", + "@noble/curves": "1.2.0", + "@noble/hashes": "1.3.1", + "@scure/base": "1.1.1", + "@scure/bip32": "1.3.1", + "@scure/bip39": "1.2.1" + }, + "optionalDependencies": { + "nostr-wasm": "v0.1.0" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/nostr-wasm": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/nostr-wasm/-/nostr-wasm-0.1.0.tgz", + "integrity": "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==", + "optional": true + }, "node_modules/not": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/not/-/not-0.1.0.tgz", @@ -21030,6 +21150,11 @@ "json5": "lib/cli.js" } }, + "node_modules/tseep": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/tseep/-/tseep-1.2.1.tgz", + "integrity": "sha512-VFnsNcPGC4qFJ1nxbIPSjTmtRZOhlqLmtwRqtLVos8mbRHki8HO9cy9Z1e89EiWyxFmq6LBviI9TQjijxw/mEw==" + }, "node_modules/tslib": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", diff --git a/package.json b/package.json index e53685f..5983aec 100644 --- a/package.json +++ b/package.json @@ -38,13 +38,15 @@ "gatsby-source-filesystem": "^3.3.0", "gatsby-transformer-remark": "^4.0.0", "gatsby-transformer-sharp": "^3.3.0", + "nostr-tools": "^2.7.0", "react": "^17.0.1", "react-dom": "^17.0.1", "react-helmet": "^6.1.0", "react-icons": "^4.2.0", "react-share": "^4.4.0", "react-use-flexsearch": "^0.1.1", - "styled-components": "^5.2.3" + "styled-components": "^5.2.3", + "tseep": "^1.2.1" }, "devDependencies": { "@popperjs/core": "^2.9.2", diff --git a/src/controllers/NostrController.ts b/src/controllers/NostrController.ts new file mode 100644 index 0000000..678adf8 --- /dev/null +++ b/src/controllers/NostrController.ts @@ -0,0 +1,205 @@ +import { + Event, + EventTemplate, + SimplePool, + UnsignedEvent, + finalizeEvent, + nip04, + nip19, + verifyEvent, + generateSecretKey, + getPublicKey +} from 'nostr-tools' +import { EventEmitter } from 'tseep' +import { SignedEvent, Keys } from '../types' + +export class NostrController extends EventEmitter { + private static instance: NostrController + + private generatedKeys: Keys | undefined + + private constructor() { + super() + + this.generatedKeys = this.generateKeys() + } + + public static getInstance(): NostrController { + if (!NostrController.instance) { + NostrController.instance = new NostrController() + } + return NostrController.instance + } + + /** + * Function will publish provided event to the provided relays + */ + publishEvent = async (event: Event, relays: string[]) => { + const simplePool = new SimplePool() + const promises = simplePool.publish(relays, event) + + const results = await Promise.allSettled(promises) + + const publishedRelays: string[] = [] + + results.forEach((result, index) => { + if (result.status === 'fulfilled') publishedRelays.push(relays[index]) + }) + + if (publishedRelays.length === 0) { + const failedPublishes: any[] = [] + const fallbackRejectionReason = + 'Attempt to publish an event has been rejected with unknown reason.' + + results.forEach((res, index) => { + if (res.status === 'rejected') { + failedPublishes.push({ + relay: relays[index], + error: res.reason + ? res.reason.message || fallbackRejectionReason + : fallbackRejectionReason + }) + } + }) + + throw failedPublishes + } + + return publishedRelays + } + + /** + * Signs an event with private key (if it is present in local storage) or + * with browser extension (if it is present) or + * with nSecBunker instance. + * @param event - unsigned nostr event. + * @returns - a promised that is resolved with signed nostr event. + */ + signEvent = async ( + event: UnsignedEvent | EventTemplate + ): Promise => { + if (!this.generatedKeys) { + throw new Error(`Private & public key pair is not found.`) + } + + const { private: nsec } = this.generatedKeys + const privateKey = nip19.decode(nsec).data as Uint8Array + + const signedEvent = finalizeEvent(event, privateKey) + + this.verifySignedEvent(signedEvent) + + return Promise.resolve(signedEvent) + } + + nip04Encrypt = async (receiver: string, content: string) => { + if (!this.generatedKeys) { + throw new Error(`Private & public key pair is not found.`) + } + + const { private: nsec } = this.generatedKeys + const privateKey = nip19.decode(nsec).data as Uint8Array + + const encrypted = await nip04.encrypt(privateKey, receiver, content) + + return encrypted + } + + /** + * Sends a Direct Message (DM) to a recipient, encrypting the content and handling authentication. + * @param fileUrl The URL of the encrypted zip file to be included in the DM. + * @param encryptionKey The encryption key used to decrypt the zip file to be included in the DM. + * @param pubkey The public key of the recipient. + * @param isSigner Boolean indicating whether the recipient is a signer or viewer. + * @param setAuthUrl Function to set the authentication URL in the component state. + */ + sendDM = async (pubkey: string, message: string) => { + // Set up timeout promise to handle encryption timeout + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => { + reject(new Error('Timeout occurred')) + }, 60000) // Timeout duration = 60 seconds + }) + + // Encrypt the DM content, with timeout + const encrypted = await Promise.race([ + this.nip04Encrypt(this.npubToHex(pubkey) as string, message), + timeoutPromise + ]) + + // Return if encryption failed + if (!encrypted) throw new Error('Message was not encrypted.') + + // Construct event metadata for the DM + const event: EventTemplate = { + kind: 4, // DM event type + content: encrypted, // Encrypted DM content + created_at: Math.floor(Date.now() / 1000), // Current timestamp + tags: [['p', this.npubToHex(pubkey) as string]] // Tag with recipient's public key + } + + // Sign the DM event + const signedEvent = await this.signEvent(event) + + // Return if event signing failed + if (!signedEvent) throw new Error('Message was not signed.') + + // These relay will be used to send a DM. Recipient has to read from these relays to receive a DM. + const relays = [ + 'wss://relay.damus.io/', + 'wss://nos.lol/', + 'wss://relay.snort.social' + ] + + // Publish the signed DM event to the recipient's read relays + return await this.publishEvent(signedEvent, relays) + } + + /** + * @param hexKey hex private or public key + * @returns whether or not is key valid + */ + validateHex = (hexKey: string) => { + return hexKey.match(/^[a-f0-9]{64}$/) + } + + /** + * NPUB provided - it will convert NPUB to HEX + * HEX provided - it will return HEX + * + * @param pubKey in NPUB, HEX format + * @returns HEX format + */ + npubToHex = (pubKey: string): string | null => { + // If key is NPUB + if (pubKey.startsWith('npub1')) { + try { + return nip19.decode(pubKey).data as string + } catch (error) { + return null + } + } + + // valid hex key + if (this.validateHex(pubKey)) return pubKey + + // Not a valid hex key + return null + } + + generateKeys = (): Keys => { + const nsec = generateSecretKey() + + return { private: nip19.nsecEncode(nsec), public: getPublicKey(nsec) } + } + + verifySignedEvent = (event: SignedEvent) => { + const isGood = verifyEvent(event) + + if (!isGood) { + throw new Error( + 'Signed event did not pass verification. Check sig, id and pubkey.' + ) + } + } +} diff --git a/src/controllers/index.ts b/src/controllers/index.ts new file mode 100644 index 0000000..e7302ce --- /dev/null +++ b/src/controllers/index.ts @@ -0,0 +1 @@ +export * from './NostrController' diff --git a/src/pages/contact.tsx b/src/pages/contact.tsx index 74cd0b2..11f0930 100644 --- a/src/pages/contact.tsx +++ b/src/pages/contact.tsx @@ -1,5 +1,5 @@ import { PageProps, Link, graphql } from 'gatsby' -import React from 'react' +import React, { useState } from 'react' import Layout from '../components/layout' import Seo from '../components/seo' @@ -20,6 +20,7 @@ import { import contactBg from '../images/contact_bg.jpg' import '../styledComponents/contact.css' +import { NostrController } from '../controllers' type DataProps = { site: { @@ -32,6 +33,17 @@ type DataProps = { } const Contact: React.FC> = ({ data, location }) => { + const nostrController = NostrController.getInstance() + + const [name, setName] = useState() + const [email, setEmail] = useState() + const [subject, setSubject] = useState() + const [message, setMessage] = useState() + const [notification, setNotification] = useState() + + const getBorderStyle = (value: string | undefined) => + value === undefined ? {} : value ? {} : { border: '1px solid red' } + return ( > = ({ data, location }) => {
{ + evt.preventDefault() + + if (name && email && subject && message) { + const res = await nostrController + .sendDM( + 'npub1dc0000002dtkw7et06sztc9nvk79r6yju8gk69sr88rgrg0e8cvsnptgyv', + `Name: ${name} +Email: ${email} +Subject: ${subject} +Message: ${message}` + ) + .catch((err) => { + setNotification( + `Something went wrong. Please check the console for more information. Please try one more time.` + ) + + console.log(`Sending message error: `, err) + }) + + if (res && res.length) { + setNotification(`Message sent. We'll contact you shortly.`) + } else { + setNotification( + `Something went wrong. Please try one more time.` + ) + } + } + }} >
@@ -65,6 +104,11 @@ const Contact: React.FC> = ({ data, location }) => { id="name" name="name" rules="required|max:50" + onChange={(evt) => { + setName(evt.target.value) + setNotification(undefined) + }} + style={getBorderStyle(name)} />
@@ -78,6 +122,11 @@ const Contact: React.FC> = ({ data, location }) => { name="email" rules="required|email" aria-describedby="emailHelp" + onChange={(evt) => { + setEmail(evt.target.value) + setNotification(undefined) + }} + style={getBorderStyle(email)} />
We'll never share your email with anyone else. @@ -93,6 +142,11 @@ const Contact: React.FC> = ({ data, location }) => { id="subject" name="subject" rules="required|max:50" + onChange={(evt) => { + setSubject(evt.target.value) + setNotification(undefined) + }} + style={getBorderStyle(subject)} />
@@ -105,12 +159,18 @@ const Contact: React.FC> = ({ data, location }) => { name="message" rows="5" rules="required|max:200" + onChange={(evt) => { + setMessage(evt.target.value) + setNotification(undefined) + }} + style={getBorderStyle(message)} >
Submit
+ {notification && {notification}}
diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..e69da0f --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1 @@ +export * from './nostr' diff --git a/src/types/nostr.ts b/src/types/nostr.ts new file mode 100644 index 0000000..883ccb6 --- /dev/null +++ b/src/types/nostr.ts @@ -0,0 +1,14 @@ +export interface SignedEvent { + kind: number + tags: string[][] + content: string + created_at: number + pubkey: string + id: string + sig: string +} + +export interface Keys { + private: string + public: string +}