diff --git a/.gitignore b/.gitignore index 6635cf5..b8bf853 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,8 @@ node_modules /build /.svelte-kit /package +package-lock.json +pnpm-lock.yaml .env .env.* !.env.example diff --git a/package.json b/package.json index 0636ce9..99ae03a 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,8 @@ "@sveltejs/kit": "^2.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0", "@types/eslint": "^8.56.7", + "@types/markdown-it": "^14.1.1", + "@types/sanitize-html": "^2.11.0", "eslint": "^9.0.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-svelte": "^2.36.0", @@ -33,8 +35,11 @@ }, "type": "module", "dependencies": { + "@noble/hashes": "^1.4.0", "@nostr-dev-kit/ndk": "^2.8.2", "@nostr-dev-kit/ndk-svelte": "^2.2.15", - "@nostr-dev-kit/ndk-svelte-components": "^2.2.16" + "@nostr-dev-kit/ndk-svelte-components": "^2.2.16", + "markdown-it": "^14.1.0", + "sanitize-html": "^2.13.0" } } diff --git a/src/app.html b/src/app.html index 77a5ff5..703e4fc 100644 --- a/src/app.html +++ b/src/app.html @@ -1,12 +1,18 @@ - - - - - - %sveltekit.head% - - + + + + + + + %sveltekit.head% + + + + +
%sveltekit.body%
- - +
+ + + \ No newline at end of file diff --git a/src/lib/ndk-kinds/index.ts b/src/lib/ndk-kinds/index.ts new file mode 100644 index 0000000..7114375 --- /dev/null +++ b/src/lib/ndk-kinds/index.ts @@ -0,0 +1,15 @@ +export enum NDKKind { + Highlight = 9802, + UserList = 30000, + GenericList = 30001, + HighlightList = 39802, + RelayList = 30022, + LongForm = 30023, +} + +export const NDKListKinds = [ + NDKKind.UserList, + NDKKind.GenericList, + NDKKind.RelayList, + NDKKind.HighlightList, +]; \ No newline at end of file diff --git a/src/lib/ndk-kinds/lists/index.ts b/src/lib/ndk-kinds/lists/index.ts new file mode 100644 index 0000000..4268501 --- /dev/null +++ b/src/lib/ndk-kinds/lists/index.ts @@ -0,0 +1,184 @@ +import NDK, { NDKEvent, NDKUser, type NDKTag, type NostrEvent, NDKRelay, type NDKSigner, type NDKFilter, mergeFilters } from '@nostr-dev-kit/ndk'; +import { NDKKind } from '../index'; +import { filterForId } from '$lib/utils/index'; + +/** + * Represents any NIP-33 list kind. + */ +class NDKList extends NDKEvent { + public _encryptedTags: NDKTag[] | undefined; + + /** + * Stores the number of bytes the content was before decryption + * to expire the cache when the content changes. + */ + private encryptedTagsLength: number | undefined; + + constructor(ndk?: NDK, rawEvent?: NostrEvent) { + super(ndk, rawEvent); + if (!this.kind) this.kind = NDKKind.GenericList; + } + + static from(ndkEvent: NDKEvent): NDKList { + return new NDKList(ndkEvent.ndk, ndkEvent.rawEvent()); + } + + get name(): string | undefined { + return this.tagValue('name') ?? this.tagValue('d'); + } + + set name(name: string | undefined) { + this.removeTag('name'); + + if (name) { + this.tags.push(['name', name]); + } else { + throw new Error('Name cannot be empty'); + } + } + + get description(): string | undefined { + return this.tagValue('description'); + } + + set description(name: string | undefined) { + if (name) { + this.tags.push(['description', name]); + } else { + this.removeTag('description'); + } + } + + /** + * Returns the decrypted content of the list. + */ + async encryptedTags(useCache = true): Promise { + if (this._encryptedTags && this.encryptedTagsLength === this.content.length && useCache) return this._encryptedTags; + + if (!this.ndk) throw new Error('NDK instance not set'); + if (!this.ndk.signer) throw new Error('NDK signer not set'); + + const user = await this.ndk.signer.user(); + + try { + if (this.content.length > 0) { + try { + console.log(`decrypting ${this.content}`); + const decryptedContent = await this.ndk.signer.decrypt(user, this.content); + const a = JSON.parse(decryptedContent); + if (a && a[0]) { + this.encryptedTagsLength = this.content.length; + return this._encryptedTags = a; + } + this.encryptedTagsLength = this.content.length; + return this._encryptedTags = []; + } catch (e) { + console.log(`error decrypting ${this.content}`); + } + } + } catch (e) { + // console.trace(e); + // throw e; + } + + return []; + } + + public validateTag(tagValue: string): boolean | string { + return true; + } + + get items(): NDKTag[] { + return this.tags.filter((t) => { + return !['d', 'name', 'description'].includes(t[0]); + }); + } + + async fetchItems(): Promise> { + let filters: NDKFilter[] = [] + + if (!this.ndk) throw new Error('NDK instance not set'); + + filters = this.items.map(i => filterForId(i[1])); + + console.log(`filters: ${JSON.stringify(filters)}`); + console.log(mergeFilters(filters)); + + return this.ndk.fetchEvents( + mergeFilters(filters) + ); + } + + async addItem(relay: NDKRelay, mark?: string, encrypted?: boolean): Promise; + async addItem(event: NDKEvent, mark?: string, encrypted?: boolean): Promise; + async addItem(user: NDKUser, mark?: string, encrypted?: boolean): Promise; + async addItem(tag: NDKTag, mark?: string, encrypted?: boolean): Promise; + async addItem(obj: NDKUser | NDKEvent | NDKRelay | NDKTag, mark: string | undefined = undefined, encrypted: boolean = false): Promise { + if (!this.ndk) throw new Error('NDK instance not set'); + if (!this.ndk.signer) throw new Error('NDK signer not set'); + + let tag; + + if (obj instanceof NDKEvent) { + tag = obj.tagReference(); + } else if (obj instanceof NDKUser) { + tag = obj.tagReference(); + } else if (obj instanceof NDKRelay) { + tag = ['r', (obj as NDKRelay).url]; + } else if (Array.isArray(obj)) { + // NDKTag + tag = obj; + } else { + throw new Error('Invalid object type'); + } + + if (mark) tag.push(mark); + + if (encrypted) { + const user = await this.ndk.signer.user(); + const currentList = await this.encryptedTags(); + + console.log(`current list: ${JSON.stringify(currentList)}`) + + currentList.push(tag); + this._encryptedTags = currentList; + this.encryptedTagsLength = this.content.length; + this.content = JSON.stringify(currentList); + await this.encrypt(user); + } else { + this.tags.push(tag); + } + + this.created_at = Math.floor(Date.now() / 1000); + } + + /** + * Removes an item from the list. + * + * @param index The index of the item to remove. + * @param encrypted Whether to remove from the encrypted list or not. + */ + async removeItem(index: number, encrypted: boolean): Promise { + if (!this.ndk) throw new Error('NDK instance not set'); + if (!this.ndk.signer) throw new Error('NDK signer not set'); + + if (encrypted) { + const user = await this.ndk.signer.user(); + const currentList = await this.encryptedTags(); + + currentList.splice(index, 1); + this._encryptedTags = currentList; + this.encryptedTagsLength = this.content.length; + this.content = JSON.stringify(currentList); + await this.encrypt(user); + } else { + this.tags.splice(index, 1); + } + + this.created_at = Math.floor(Date.now() / 1000); + + return this; + } +} + +export default NDKList; \ No newline at end of file diff --git a/src/lib/ndk-kinds/lists/relay-list.ts b/src/lib/ndk-kinds/lists/relay-list.ts new file mode 100644 index 0000000..1ffb00a --- /dev/null +++ b/src/lib/ndk-kinds/lists/relay-list.ts @@ -0,0 +1,31 @@ +import type { NDKEvent, NostrEvent } from '@nostr-dev-kit/ndk'; +import NDKList from './index.js'; +import type NDK from '@nostr-dev-kit/ndk'; + +class NDKRelayList extends NDKList { + constructor(ndk?: NDK, rawEvent?: NostrEvent) { + super(ndk, rawEvent); + } + + static from(ndkEvent: NDKEvent) { + return new NDKRelayList(ndkEvent.ndk, ndkEvent.rawEvent()); + } + + public validateTag(tagValue: string): boolean | string { + if (tagValue.startsWith('wss://')) return true; + if (tagValue.startsWith('nrelay')) return true; + + return 'Invalid relay URL'; + } + + /** + * Get the list of relays + */ + get relays(): string[] { + console.log(this.getMatchingTags('r')); + + return this.getMatchingTags('r').map((tag) => tag[1]); + } +} + +export default NDKRelayList; \ No newline at end of file diff --git a/src/lib/signers/ephemeral.ts b/src/lib/signers/ephemeral.ts new file mode 100644 index 0000000..c893781 --- /dev/null +++ b/src/lib/signers/ephemeral.ts @@ -0,0 +1,142 @@ +import NDK, { NDKEvent, NDKPrivateKeySigner, type NDKFilter, type NDKSigner, type NDKUserProfile } from "@nostr-dev-kit/ndk"; +import type { NDKTag, NostrEvent} from "@nostr-dev-kit/ndk"; +import { sha256 } from "@noble/hashes/sha256"; +import { bytesToHex } from "@noble/hashes/utils"; + +const CURRENT_PRIVATE_NOTE_VERSION = '2'; + +interface IFindEphemeralSignerLookups { + name?: string, + associatedEventNip19?: string; +} + +/** + * Find a named ephemeral signer from a self-DM. + */ +export async function findEphemeralSigner( + ndk: NDK, + mainSigner: NDKSigner, + opts: IFindEphemeralSignerLookups +): Promise { + const mainUser = await mainSigner.user(); + const filter: NDKFilter = { kinds: [2600 as number]}; + + if (opts.name) { + const hashedName = await getHashedKeyName(opts.name); + filter['#e'] = [hashedName]; + } else if (opts.associatedEventNip19) { + const hashedEventReference = await getHashedKeyName(opts.associatedEventNip19); + filter['#e'] = [hashedEventReference]; + } + + const event = await ndk.fetchEvent(filter); + + if (event) { + const decryptEventFunction = async (event: NDKEvent) => { + await event.decrypt(await mainSigner.user()); + const content = JSON.parse(event.content); + return new NDKPrivateKeySigner(content.key); + }; + + const promise = new Promise((resolve, reject) => { + let decryptionAttempts = 0; + try { + decryptionAttempts++; + resolve(decryptEventFunction(event)); + } catch(e) { + if (decryptionAttempts> 5) { + console.error(`Failed to decrypt ephemeral signer event after ${decryptionAttempts} attempts`); + reject(e); + return; + } + setTimeout(() => { decryptEventFunction(event);}, 1000 * Math.random()); + } + }); + return promise; + } +} + +type EphemeralKeyEventContent = { + key: string; + event?: string; + version: string; + metadata?: object; +} + +interface ISaveOpts { + associatedEvent?: NDKEvent; + name?: string; + metadata?: object; + keyProfile?: NDKUserProfile; + mainSigner?: NDKSigner; +} + +function generateContent(targetSigner: NDKPrivateKeySigner, opts: ISaveOpts = {}) { + const content: EphemeralKeyEventContent = { + key: targetSigner.privateKey!, + version: CURRENT_PRIVATE_NOTE_VERSION, + ...opts.metadata, + }; + if (opts.associatedEvent) content.event = opts.associatedEvent.encode(); + + return JSON.stringify(content); +} + +async function getHashedKeyName(name:string) { + let eventHash = sha256(name); + return bytesToHex(eventHash); +} + +async function generateTags(mainSigner:NDKSigner, opts: ISaveOpts = {}) { + const mainUser = await mainSigner.user(); + const tags = [ + ['p', mainUser.pubkey], + ['client', 'cofabricate'], + ] + + if (opts.associatedEvent) { + const hashedEventReference = await getHashedKeyName(opts.associatedEvent.encode()); + tags.push(['e', hashedEventReference]); + } + + if (opts.name) { + const hashedName = await getHashedKeyName(opts.name); + tags.push(['e', hashedName]); + } + + return tags; +} + +export async function saveEphemeralSigner(ndk: NDK, targetSigner: NDKPrivateKeySigner, opts: ISaveOpts = {}) { + const mainSigner = opts.mainSigner || ndk.signer; + + if (!mainSigner) throw new Error('No main signer provided'); + + const mainUser = await mainSigner.user(); + const event = new NDKEvent(ndk, { + kind: 2600, + content: generateContent(targetSigner, opts), + tags: await generateTags(mainSigner, opts), + } as NostrEvent); + + event.pubkey = mainUser.pubkey; + await event.encrypt(mainUser, mainSigner); + await event.publish(); + + if (opts.keyProfile) { + const user = await targetSigner.user(); + const event = new NDKEvent(ndk, { + kind: 0, + content: JSON.stringify(opts.keyProfile), + tags: [] as NDKTag[], + } as NostrEvent) + event.pubkey = user.pubkey; + await event.sign(targetSigner); + await event.publish(); + } +} + +export function generateEphemeralSigner(): NDKPrivateKeySigner { + const signer = NDKPrivateKeySigner.generate(); + return signer; +} \ No newline at end of file diff --git a/src/lib/store.ts b/src/lib/store.ts new file mode 100644 index 0000000..1800c2e --- /dev/null +++ b/src/lib/store.ts @@ -0,0 +1,24 @@ +import { writable } from "svelte/store"; +import { NDKUser } from "@nostr-dev-kit/ndk"; + +export const currentUser = writable(null); +export const currentUserFollowPubkeys = writable(undefined); +export const backgroundBanner = writable(null); + +export type ScopeSelection = { + label: string; + id: string; + pubkeys: string[] | undefined; +}; +export const currentScope = writable({ + label: 'global', + id: 'global', + pubkeys: undefined, +}); + +let zapEvent: any; + +export const zap = writable(zapEvent); + +export const pageTitle = writable(null); +export const pageSubtitle = writable(null); \ No newline at end of file diff --git a/src/lib/stores/nostr.ts b/src/lib/stores/nostr.ts new file mode 100644 index 0000000..d08ac1d --- /dev/null +++ b/src/lib/stores/nostr.ts @@ -0,0 +1,35 @@ +import { writable } from "svelte/store"; +import NDK from "@nostr-dev-kit/ndk"; +import NDKSvelte from "@nostr-dev-kit/ndk-svelte"; +import { RelayList } from "@nostr-dev-kit/ndk-svelte-components"; + +let relays; + +try { + relays = localStorage.getItem('relays'); +} catch (e) {} + +let relayList: string[] = []; + +if (relays) { + relayList = JSON.parse(relays); +} + +export const defaultRelays = [ + 'wss://purplepag.es', + 'wss://relay.damus.io' +] + +if (!relayList || !Array.isArray(relayList) || relayList.length === 0) { + relayList = defaultRelays; +} + +const _ndk: NDKSvelte = new NDKSvelte({ + devWriteRelayUrls: ['wss://relay.strfront.com'], + explicitRelayUrls: relayList, + enableOutboxModel: true, +}) as NDKSvelte; + +const ndk = writable(_ndk); + +export default ndk; \ No newline at end of file diff --git a/src/lib/stores/signer.ts b/src/lib/stores/signer.ts new file mode 100644 index 0000000..9f39b49 --- /dev/null +++ b/src/lib/stores/signer.ts @@ -0,0 +1,82 @@ +import { findEphemeralSigner } from "$lib/signers/ephemeral"; +import { NDKPrivateKeySigner, type NDKSigner, type NDKUser } from "@nostr-dev-kit/ndk"; +import { writable, get as getStore, derived } from "svelte/store"; +import ndkStore from "./nostr"; +import { currentUser as currentUserStore } from "../store"; +import type NDKList from "$lib/ndk-kinds/lists"; + +export type SignerStoreItem = { + signer: NDKPrivateKeySigner; + user: NDKUser; + saved: boolean; + name?: string; + id: string; +}; + +type SignerItems = Map; + +export const signers = writable(new Map()); +export const npubSigners = derived(signers, ($signers) => { + const npubs = new Map(); + + for (const entry of $signers) { + const { user, signer } = entry[1]; + + npubs.set(user.npub, signer); + } + + return npubs; +}); + +async function getDelegatedSignerName(list: NDKList) { + let name = ''; + const currentUser: NDKUser = getStore(currentUserStore); + + if (!currentUser?.profile) { + currentUser.ndk = getStore(ndkStore); + await currentUser?.fetchProfile(); + } + + if (currentUser?.profile?.name) { + name = currentUser.profile.displayName + `'s `; + } + + return name + list.name; +} + +export async function getSigner(list: NDKList): Promise { + const store = getStore(signers); + const id = list.encode(); + let item = store.get(id); + + if (item) return item; + + const ndk = getStore(ndkStore); + let signer = await findEphemeralSigner(ndk, ndk.signer!, { + associatedEventNip19: list.encode(), + }); + + if (signer) { + console.log(`found a signer for list ${list.name}`); + item = { + signer: signer!, + user: await signer.user(), + saved: true, + id, + }; + } else { + signer = NDKPrivateKeySigner.generate(); + item = { + signer, + user: await signer.user(), + saved: false, + name: await getDelegatedSignerName(list), + id, + }; + } + item.user.ndk = ndk; + + store.set(id, item); + + return item; +} \ No newline at end of file diff --git a/src/lib/utils/index.ts b/src/lib/utils/index.ts new file mode 100644 index 0000000..2b6c075 --- /dev/null +++ b/src/lib/utils/index.ts @@ -0,0 +1,190 @@ +import sanitizeHtml from 'sanitize-html'; +import MarkdownIt from 'markdown-it'; +import { nip19 } from 'nostr-tools'; +import { filterFromId, type NDKFilter, type NDKTag } from '@nostr-dev-kit/ndk'; + +export function tagToNip19(tag: NDKTag): string | undefined { + switch (tag[0]) { + case 'a': + const [ kind, pubkey, identifier ] = tag[1].split(':'); + return nip19.naddrEncode({ + kind: parseInt(kind), + pubkey, + identifier + }) + case 'e': + return nip19.noteEncode(tag[1]); + case 'r': + return tag[1]; + } +} + +export function nicelyFormattedMilliSatNumber(amount: number) { + return nicelyFormattedSatNumber( + Math.floor(amount / 1000) + ); +} + +export function nicelyFormattedSatNumber(amount: number) { + // if the number is less than 1000, just return it + if (amount < 1000) return amount; + + // if the number is less than 1 million, return it with a k, if the comma is not needed remove it + if (amount < 1000000) return `${(amount / 1000).toFixed(0)}k`; + + // if the number is less than 1 billion, return it with an m + if (amount < 1000000000) return `${(amount / 1000000).toFixed(1)}m`; + + return `${(amount / 100_000_000).toFixed(2)} btc`; +} + +export function filterForId(id: string): NDKFilter { + if (!!id.match(/:/)) { + const [kind, pubkey, identifier] = id.split(':'); + return { kinds: [parseInt(kind)], authors: [pubkey], '#d': [identifier] }; + } else { + return { ids: [id] }; + } +} + +export function filterFromNaddr(naddr: string): NDKFilter { + return filterFromId(naddr); +} + +export function idFromNaddr(naddr: string) { + const ndecode = nip19.decode(naddr).data as any; + return `${ndecode.kind}:${ndecode.pubkey}:${ndecode.identifier}`; +} + +export function naddrFromTagValue(value: string) { + const [kind, pubkey, identifier] = value.split(':'); + + return nip19.naddrEncode({ + kind: parseInt(kind), + pubkey, + identifier + }); +} + +export function prettifyContent(content: string) { + const bitcoinImage = + ''; + + content = content.replace(/#bitcoin/i, `#bitcoin${bitcoinImage}`); + + const md = new MarkdownIt({ + html: true, + linkify: true, + typographer: true, + autolink: true, + image: true, + }); + md.linkify?.set(); + content = md.render(content); + + return sanitizeHtml(content); +} + +function flattenText(node: string) { + const texts = []; + const walker = document.createTreeWalker(node, NodeFilter.SHOW_TEXT, null, false); + + while (walker.nextNode()) { + texts.push(walker.currentNode); + } + + return texts; +} + +export function highlightText(targetText: string, highlightId: string) { + const regex = new RegExp(escapeRegExp(targetText), 'g'); + const textNodes = flattenText(document.body); + const marks: HTMLElement[] = []; + + textNodes.forEach((textNode) => { + let match; + let lastIndex = 0; + + while ((match = regex.exec(textNode.data)) !== null) { + const mark = document.createElement('mark'); + mark.setAttribute('data-highlight-id', highlightId); + const range = document.createRange(); + const startOffset = match.index; + const endOffset = startOffset + targetText.length; + + if (lastIndex < startOffset) { + const precedingTextNode = document.createTextNode(textNode.data.slice(lastIndex, startOffset)); + textNode.parentNode.insertBefore(precedingTextNode, textNode); + } + + range.setStart(textNode, startOffset); + range.setEnd(textNode, endOffset); + range.surroundContents(mark); + marks.push(mark); + + lastIndex = endOffset; + } + + if (lastIndex < textNode.length) { + const remainingTextNode = document.createTextNode(textNode.data.slice(lastIndex)); + textNode.parentNode.insertBefore(remainingTextNode, textNode); + } + + textNode.remove(); + }); + + return marks; + + function escapeRegExp(string) { + return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string + } +} + +// Usage example: highlightText("your target text here"); + +// export function modifyDocument(content: string) { +// const regex = new RegExp(content, 'gi'); + +// // get all the text nodes in the current page +// const textNodes = document.evaluate("//text()", document, null, XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE, null); + +// // loop through all the text nodes and replace the search string with the marked up version +// for (let i = 0; i < textNodes.snapshotLength; i++) { +// const node = textNodes.snapshotItem(i) as Text; +// const parent = node.parentNode; + +// // check if the text node contains the search string +// if (!node || !node.textContent || !node.textContent.match(regex)) { +// continue; +// } + +// const fragment = document.createDocumentFragment(); +// let match; + +// if (!node) continue; + +// // loop through all the matches in the text node +// while ((match = regex.exec(node.textContent)) !== null) { +// console.log({match, node}); + +// const before = node.splitText(match.index); +// const after = node.splitText(match[0].length); + +// console.log({before, after}); + +// const mark = document.createElement("mark"); + +// // append the matched text to the mark element +// mark.appendChild(document.createTextNode(match[0])); + +// // append the mark element to the document fragment +// fragment.appendChild(before); +// fragment.appendChild(mark); + +// node = after; +// } + +// // replace the original text node with the marked up version +// parent.replaceChild(fragment, node); +// } +// } \ No newline at end of file diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte new file mode 100644 index 0000000..9fbb9ca --- /dev/null +++ b/src/routes/+layout.svelte @@ -0,0 +1,11 @@ + + diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 16f03a0..2b6124f 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1 +1,5 @@ -

Welcome to CoFabricate

\ No newline at end of file + + Decentralized Manufacturing + + +

Home

\ No newline at end of file diff --git a/src/routes/about/+page.svelte b/src/routes/about/+page.svelte new file mode 100644 index 0000000..cdb72a8 --- /dev/null +++ b/src/routes/about/+page.svelte @@ -0,0 +1,5 @@ + + About + + +

About page

\ No newline at end of file diff --git a/src/routes/login/+page.svelte b/src/routes/login/+page.svelte new file mode 100644 index 0000000..fc2f17d --- /dev/null +++ b/src/routes/login/+page.svelte @@ -0,0 +1,5 @@ + + Log In + + +

Log In

n \ No newline at end of file