This commit is contained in:
Kieran 2023-07-22 19:37:46 +01:00
parent 74a3cd7754
commit b1f93c9fd8
Signed by: Kieran
GPG Key ID: DE71CEB3925BE941
65 changed files with 1115 additions and 1029 deletions

View File

@ -6,7 +6,7 @@ concurrency:
limit: 1
trigger:
branch:
- main
- main
metadata:
namespace: git
steps:
@ -34,9 +34,9 @@ steps:
- img build -t voidic/snort:latest --platform linux/amd64,linux/arm64 -f Dockerfile.prebuilt .
- img push voidic/snort:latest
volumes:
- name: cache
claim:
name: docker-cache
- name: cache
claim:
name: docker-cache
---
kind: pipeline
type: kubernetes
@ -60,9 +60,9 @@ steps:
- yarn workspace @snort/app eslint
- yarn workspace @snort/app prettier --check .
volumes:
- name: cache
claim:
name: docker-cache
- name: cache
claim:
name: docker-cache
---
kind: pipeline
type: kubernetes
@ -71,7 +71,7 @@ concurrency:
limit: 1
trigger:
branch:
- main
- main
metadata:
namespace: git
steps:
@ -98,9 +98,9 @@ steps:
- 'git commit -a -m "chore: Update translations"'
- git push -u origin main
volumes:
- name: cache
claim:
name: docker-cache
- name: cache
claim:
name: docker-cache
---
kind: pipeline
type: kubernetes
@ -109,7 +109,7 @@ concurrency:
limit: 1
trigger:
event:
- tag
- tag
metadata:
namespace: git
steps:
@ -137,6 +137,6 @@ steps:
- img build -t voidic/snort:$DRONE_TAG --platform linux/amd64,linux/arm64 -f Dockerfile.prebuilt .
- img push voidic/snort:$DRONE_TAG
volumes:
- name: cache
claim:
name: docker-cache
- name: cache
claim:
name: docker-cache

View File

@ -29,13 +29,13 @@ jobs:
- name: Rust cache
uses: swatinem/rust-cache@v2
with:
workspaces: './src-tauri -> target'
workspaces: "./src-tauri -> target"
- name: Sync node version and setup cache
uses: actions/setup-node@v3
with:
node-version: '16'
cache: 'yarn'
node-version: "16"
cache: "yarn"
- name: Install frontend dependencies
run: yarn install
- name: Build the app
@ -44,7 +44,7 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tagName: ${{ github.ref_name }}
releaseName: 'Snort v__VERSION__'
releaseBody: 'See the assets to download and install this version.'
releaseName: "Snort v__VERSION__"
releaseBody: "See the assets to download and install this version."
releaseDraft: true
prerelease: false
prerelease: false

20
.vscode/settings.json vendored
View File

@ -1,11 +1,11 @@
{
"files.exclude": {
"**/.git": true,
"**/.svn": true,
"**/.hg": true,
"**/CVS": true,
"**/.DS_Store": true,
"**/Thumbs.db": true,
"**/node_modules": true
}
}
"files.exclude": {
"**/.git": true,
"**/.svn": true,
"**/.hg": true,
"**/CVS": true,
"**/.DS_Store": true,
"**/Thumbs.db": true,
"**/node_modules": true
}
}

View File

@ -63,6 +63,7 @@ $ yarn build
Translations are managed on [Crowdin](https://crowdin.com/project/snort)
To extract translations run:
```bash
yarn workspace @snort/app intl-extract
yarn workspace @snort/app intl-compile

View File

@ -1,7 +1,6 @@
interface Env {
}
interface Env {}
export const onRequest: PagesFunction<Env> = async (context) => {
export const onRequest: PagesFunction<Env> = async context => {
const id = context.params.id as string;
const next = await context.next();
@ -11,16 +10,16 @@ export const onRequest: PagesFunction<Env> = async (context) => {
body: await next.arrayBuffer(),
headers: {
"user-agent": "Snort-Functions/1.0 (https://snort.social)",
"content-type": "text/plain"
}
"content-type": "text/plain",
},
});
if (rsp.ok) {
const body = await rsp.text();
if (body.length > 0) {
return new Response(body, {
headers: {
"content-type": "text/html"
}
"content-type": "text/html",
},
});
}
}
@ -28,4 +27,4 @@ export const onRequest: PagesFunction<Env> = async (context) => {
// ignore
}
return next;
}
};

View File

@ -1,7 +1,6 @@
interface Env {
}
interface Env {}
export const onRequest: PagesFunction<Env> = async (context) => {
export const onRequest: PagesFunction<Env> = async context => {
const id = context.params.id as string;
const next = await context.next();
@ -11,16 +10,16 @@ export const onRequest: PagesFunction<Env> = async (context) => {
body: await next.arrayBuffer(),
headers: {
"user-agent": "Snort-Functions/1.0 (https://snort.social)",
"content-type": "text/plain"
}
"content-type": "text/plain",
},
});
if (rsp.ok) {
const body = await rsp.text();
if (body.length > 0) {
return new Response(body, {
headers: {
"content-type": "text/html"
}
"content-type": "text/html",
},
});
}
}
@ -28,4 +27,4 @@ export const onRequest: PagesFunction<Env> = async (context) => {
// ignore
}
return next;
}
};

View File

@ -1,8 +1,8 @@
{
"compilerOptions": {
"target": "esnext",
"module": "esnext",
"lib": ["esnext"],
"types": ["@cloudflare/workers-types"]
}
}
"compilerOptions": {
"target": "esnext",
"module": "esnext",
"lib": ["esnext"],
"types": ["@cloudflare/workers-types"]
}
}

View File

@ -11,5 +11,10 @@
"devDependencies": {
"@tauri-apps/cli": "^1.2.3",
"@cloudflare/workers-types": "^4.20230307.0"
},
"prettier": {
"printWidth": 120,
"bracketSameLine": true,
"arrowParens": "avoid"
}
}
}

View File

@ -1,5 +0,0 @@
{
"printWidth": 120,
"bracketSameLine": true,
"arrowParens": "avoid"
}

View File

@ -65,4 +65,4 @@
"author": "",
"license": "ISC",
"description": ""
}
}

View File

@ -1,7 +1,7 @@
import { NostrError } from "../common"
import { RawEvent, parseEvent } from "../event"
import { Conn } from "./conn"
import * as utils from "@noble/curves/abstract/utils";
import * as utils from "@noble/curves/abstract/utils"
import { EventEmitter } from "./emitter"
import { fetchRelayInfo, ReadyState, Relay } from "./relay"
import { Filters } from "../filters"
@ -71,9 +71,9 @@ export class Nostr extends EventEmitter {
opts?.fetchInfo === false
? Promise.resolve({})
: fetchRelayInfo(relayUrl).catch((e) => {
this.#error(e)
return {}
})
this.#error(e)
return {}
})
// If there is no existing connection, open a new one.
const conn = new Conn({
@ -128,7 +128,8 @@ export class Nostr extends EventEmitter {
if (conn.relay.readyState !== ReadyState.CONNECTING) {
this.#error(
new NostrError(
`bug: expected connection to ${relayUrl.toString()} to have readyState CONNECTING, got ${conn.relay.readyState
`bug: expected connection to ${relayUrl.toString()} to have readyState CONNECTING, got ${
conn.relay.readyState
}`
)
)
@ -293,7 +294,7 @@ export class Nostr extends EventEmitter {
relay.info === undefined
? undefined
: // Deep copy of the info.
JSON.parse(JSON.stringify(relay.info))
JSON.parse(JSON.stringify(relay.info))
return { ...relay, info }
}
})

View File

@ -1,6 +1,6 @@
import * as secp from "@noble/curves/secp256k1"
import * as utils from "@noble/curves/abstract/utils";
import {sha256 as sha} from "@noble/hashes/sha256";
import * as utils from "@noble/curves/abstract/utils"
import { sha256 as sha } from "@noble/hashes/sha256"
import base64 from "base64-js"
import { bech32 } from "bech32"
@ -92,11 +92,7 @@ export function schnorrSign(data: Hex, priv: PrivateKey): Hex {
/**
* Verify that the elliptic curve signature is correct.
*/
export function schnorrVerify(
sig: Hex,
data: Hex,
key: PublicKey
): boolean {
export function schnorrVerify(sig: Hex, data: Hex, key: PublicKey): boolean {
return secp.schnorr.verify(sig.toString(), data.toString(), key.toString())
}

View File

@ -159,14 +159,14 @@ export async function signEvent<T extends RawEvent>(
* Parse an event from its raw format.
*/
export function parseEvent(event: RawEvent): Event {
if (event.id !== (serializeEventId(event))) {
if (event.id !== serializeEventId(event)) {
throw new NostrError(
`invalid id ${event.id} for event ${JSON.stringify(
event
)}, expected ${serializeEventId(event)}`
)
}
if (!(schnorrVerify(event.sig, event.id, event.pubkey))) {
if (!schnorrVerify(event.sig, event.id, event.pubkey)) {
throw new NostrError(`invalid signature for event ${JSON.stringify(event)}`)
}
@ -221,9 +221,7 @@ export function parseEvent(event: RawEvent): Event {
}
}
function serializeEventId(
event: UnsignedWithPubkey<RawEvent>
): EventId {
function serializeEventId(event: UnsignedWithPubkey<RawEvent>): EventId {
const serialized = JSON.stringify([
0,
event.pubkey,

View File

@ -1,6 +1,6 @@
const fs = require("fs")
const isProduction = process.env.NODE_ENV == "production";
const isProduction = process.env.NODE_ENV == "production"
const entry = {
lib: "./src/index.ts",

View File

@ -1,8 +1,6 @@
/**
* Regex to match email address
*/
export const EmailRegex =
// eslint-disable-next-line no-useless-escape
/^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
export const EmailRegex =
// eslint-disable-next-line no-useless-escape
/^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;

View File

@ -1,14 +1,13 @@
declare module "light-bolt11-decoder" {
export function decode(pr?: string): ParsedInvoice;
export function decode(pr?: string): ParsedInvoice;
export interface ParsedInvoice {
paymentRequest: string;
sections: Section[];
}
export interface ParsedInvoice {
paymentRequest: string;
sections: Section[];
}
export interface Section {
name: string;
value: string | Uint8Array | number | undefined;
}
export interface Section {
name: string;
value: string | Uint8Array | number | undefined;
}
}

View File

@ -38,7 +38,7 @@ export abstract class FeedCache<TCached> {
}
async preload() {
const keys = await this.table?.toCollection().primaryKeys() ?? [];
const keys = (await this.table?.toCollection().primaryKeys()) ?? [];
this.onTable = new Set<string>(keys.map(a => a as string));
}

View File

@ -3,4 +3,4 @@ export * from "./lnurl";
export * from "./utils";
export * from "./work-queue";
export * from "./feed-cache";
export * from "./invoices";
export * from "./invoices";

View File

@ -1,48 +1,55 @@
import { bytesToHex } from "@noble/hashes/utils";
import { decode as invoiceDecode } from "light-bolt11-decoder";
export interface InvoiceDetails {
amount?: number;
expire?: number;
timestamp?: number;
description?: string;
descriptionHash?: string;
paymentHash?: string;
expired: boolean;
pr: string;
amount?: number;
expire?: number;
timestamp?: number;
description?: string;
descriptionHash?: string;
paymentHash?: string;
expired: boolean;
pr: string;
}
export function decodeInvoice(pr: string): InvoiceDetails | undefined {
try {
const parsed = invoiceDecode(pr);
try {
const parsed = invoiceDecode(pr);
const amountSection = parsed.sections.find(a => a.name === "amount");
const amount = amountSection ? Number(amountSection.value as number | string) : undefined;
const amountSection = parsed.sections.find(a => a.name === "amount");
const amount = amountSection ? Number(amountSection.value as number | string) : undefined;
const timestampSection = parsed.sections.find(a => a.name === "timestamp");
const timestamp = timestampSection ? Number(timestampSection.value as number | string) : undefined;
const timestampSection = parsed.sections.find(a => a.name === "timestamp");
const timestamp = timestampSection ? Number(timestampSection.value as number | string) : undefined;
const expirySection = parsed.sections.find(a => a.name === "expiry");
const expire = expirySection ? Number(expirySection.value as number | string) : undefined;
const descriptionSection = parsed.sections.find(a => a.name === "description")?.value;
const descriptionHashSection = parsed.sections.find(a => a.name === "description_hash")?.value;
const paymentHashSection = parsed.sections.find(a => a.name === "payment_hash")?.value;
const ret = {
pr,
amount: amount,
expire: timestamp && expire ? timestamp + expire : undefined,
timestamp: timestamp,
description: descriptionSection as string | undefined,
descriptionHash: descriptionHashSection ? (typeof descriptionHashSection === "string" ? descriptionHashSection as string : bytesToHex(descriptionHashSection as Uint8Array)) : undefined,
paymentHash: paymentHashSection ? (typeof paymentHashSection === "string" ? paymentHashSection as string : bytesToHex(paymentHashSection as Uint8Array)) : undefined,
expired: false,
};
if (ret.expire) {
ret.expired = ret.expire < new Date().getTime() / 1000;
}
return ret;
} catch (e) {
console.error(e);
const expirySection = parsed.sections.find(a => a.name === "expiry");
const expire = expirySection ? Number(expirySection.value as number | string) : undefined;
const descriptionSection = parsed.sections.find(a => a.name === "description")?.value;
const descriptionHashSection = parsed.sections.find(a => a.name === "description_hash")?.value;
const paymentHashSection = parsed.sections.find(a => a.name === "payment_hash")?.value;
const ret = {
pr,
amount: amount,
expire: timestamp && expire ? timestamp + expire : undefined,
timestamp: timestamp,
description: descriptionSection as string | undefined,
descriptionHash: descriptionHashSection
? typeof descriptionHashSection === "string"
? (descriptionHashSection as string)
: bytesToHex(descriptionHashSection as Uint8Array)
: undefined,
paymentHash: paymentHashSection
? typeof paymentHashSection === "string"
? (paymentHashSection as string)
: bytesToHex(paymentHashSection as Uint8Array)
: undefined,
expired: false,
};
if (ret.expire) {
ret.expired = ret.expire < new Date().getTime() / 1000;
}
return ret;
} catch (e) {
console.error(e);
}
}

View File

@ -205,26 +205,26 @@ export class LNURL {
}
export interface LNURLService {
tag: string
nostrPubkey?: string
minSendable?: number
maxSendable?: number
metadata: string
callback: string
commentAllowed?: number
tag: string;
nostrPubkey?: string;
minSendable?: number;
maxSendable?: number;
metadata: string;
callback: string;
commentAllowed?: number;
}
export interface LNURLStatus {
status: "SUCCESS" | "ERROR"
reason?: string
status: "SUCCESS" | "ERROR";
reason?: string;
}
export interface LNURLInvoice extends LNURLStatus {
pr?: string
successAction?: LNURLSuccessAction
pr?: string;
successAction?: LNURLSuccessAction;
}
export interface LNURLSuccessAction {
description?: string
url?: string
description?: string;
url?: string;
}

View File

@ -71,7 +71,10 @@ export function countMembers(a: any) {
return ret;
}
export function equalProp(a: string | number | Array<string | number> | undefined, b: string | number | Array<string | number> | undefined) {
export function equalProp(
a: string | number | Array<string | number> | undefined,
b: string | number | Array<string | number> | undefined
) {
if ((a !== undefined && b === undefined) || (a === undefined && b !== undefined)) {
return false;
}
@ -130,7 +133,7 @@ export function appendDedupe<T>(a?: Array<T>, b?: Array<T>) {
export const sha256 = (str: string | Uint8Array): string => {
return utils.bytesToHex(sha2(str));
}
};
export function getPublicKey(privKey: string) {
return utils.bytesToHex(secp.schnorr.getPublicKey(privKey));

View File

@ -3,54 +3,49 @@
React hooks for @snort/system
Sample:
```js
import { useMemo } from "react"
import { useMemo } from "react";
import { useRequestBuilder, useUserProfile } from "@snort/system-react";
import { FlatNoteStore, NostrSystem, RequestBuilder, TaggedRawEvent } from "@snort/system"
import { FlatNoteStore, NostrSystem, RequestBuilder, TaggedRawEvent } from "@snort/system";
// singleton nostr system class
const System = new NostrSystem({});
// some bootstrap relays
[
"wss://relay.snort.social",
"wss://nos.lol"
].forEach(r => System.ConnectToRelay(r, { read: true, write: false }));
["wss://relay.snort.social", "wss://nos.lol"].forEach(r => System.ConnectToRelay(r, { read: true, write: false }));
export function Note({ ev }: { ev: TaggedRawEvent }) {
// get profile from cache or request a profile from relays
const profile = useUserProfile(System, ev.pubkey);
// get profile from cache or request a profile from relays
const profile = useUserProfile(System, ev.pubkey);
return <div>
Post by: {profile.name ?? profile.display_name}
<p>
{ev.content}
</p>
return (
<div>
Post by: {profile.name ?? profile.display_name}
<p>{ev.content}</p>
</div>
);
}
export function UserPosts(props: { pubkey: string }) {
const sub = useMemo(() => {
const rb = new RequestBuilder("get-posts");
rb.withFilter()
.authors([props.pubkey])
.kinds([1])
.limit(10);
const sub = useMemo(() => {
const rb = new RequestBuilder("get-posts");
rb.withFilter().authors([props.pubkey]).kinds([1]).limit(10);
return rb;
}, [props.pubkey]);
return rb;
}, [props.pubkey]);
const data = useRequestBuilder<FlatNoteStore>(System, FlatNoteStore, sub);
return (
<>
{data.data.map(a => <Note ev={a} />)}
</>
)
const data = useRequestBuilder < FlatNoteStore > (System, FlatNoteStore, sub);
return (
<>
{data.data.map(a => (
<Note ev={a} />
))}
</>
);
}
export function MyApp() {
return (
<UserPosts pubkey="63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed" />
)
return <UserPosts pubkey="63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed" />;
}
```
```

View File

@ -1,48 +1,42 @@
import { useMemo } from "react"
import { useMemo } from "react";
import { useRequestBuilder, useUserProfile } from "../src";
import { FlatNoteStore, NostrSystem, RequestBuilder, TaggedRawEvent } from "@snort/system"
import { FlatNoteStore, NostrSystem, RequestBuilder, TaggedRawEvent } from "@snort/system";
const System = new NostrSystem({});
// some bootstrap relays
[
"wss://relay.snort.social",
"wss://nos.lol"
].forEach(r => System.ConnectToRelay(r, { read: true, write: false }));
["wss://relay.snort.social", "wss://nos.lol"].forEach(r => System.ConnectToRelay(r, { read: true, write: false }));
export function Note({ ev }: { ev: TaggedRawEvent }) {
const profile = useUserProfile(System, ev.pubkey);
const profile = useUserProfile(System, ev.pubkey);
return <div>
Post by: {profile.name ?? profile.display_name}
<p>
{ev.content}
</p>
return (
<div>
Post by: {profile.name ?? profile.display_name}
<p>{ev.content}</p>
</div>
);
}
export function UserPosts(props: { pubkey: string }) {
const sub = useMemo(() => {
const rb = new RequestBuilder("get-posts");
rb.withFilter()
.authors([props.pubkey])
.kinds([1])
.limit(10);
const sub = useMemo(() => {
const rb = new RequestBuilder("get-posts");
rb.withFilter().authors([props.pubkey]).kinds([1]).limit(10);
return rb;
}, [props.pubkey]);
return rb;
}, [props.pubkey]);
const data = useRequestBuilder<FlatNoteStore>(System, FlatNoteStore, sub);
return (
<>
{data.data.map(a => <Note ev={a} />)}
</>
)
const data = useRequestBuilder<FlatNoteStore>(System, FlatNoteStore, sub);
return (
<>
{data.data.map(a => (
<Note ev={a} />
))}
</>
);
}
export function MyApp() {
return (
<UserPosts pubkey="63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed" />
)
}
return <UserPosts pubkey="63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed" />;
}

View File

@ -20,4 +20,4 @@
"@snort/system": "^1.0.16",
"@snort/shared": "^1.0.4"
}
}
}

View File

@ -1,3 +1,3 @@
export * from "./useRequestBuilder";
export * from "./useSystemState";
export * from "./useUserProfile";
export * from "./useUserProfile";

View File

@ -7,7 +7,7 @@ import { unwrap } from "@snort/shared";
*/
const useRequestBuilder = <TStore extends NoteStore, TSnapshot = ReturnType<TStore["getSnapshotData"]>>(
system: SystemInterface,
type: { new(): TStore },
type: { new (): TStore },
rb: RequestBuilder | null
) => {
const subscribe = (onChanged: () => void) => {
@ -37,4 +37,4 @@ const useRequestBuilder = <TStore extends NoteStore, TSnapshot = ReturnType<TSto
);
};
export { useRequestBuilder };
export { useRequestBuilder };

View File

@ -16,7 +16,7 @@ export function useUserProfile(system: NostrSystem, pubKey?: HexKey): MetadataCa
if (pubKey) {
system.ProfileLoader.UntrackMetadata(pubKey);
}
}
};
},
() => system.ProfileLoader.Cache.getFromCache(pubKey)
);

View File

@ -3,14 +3,15 @@
A collection of caching and querying techniquies used by https://snort.social to serve all content from the nostr protocol.
Simple example:
```js
import {
NostrSystem,
EventPublisher,
UserRelaysCache,
RequestBuilder,
FlatNoteStore,
StoreSnapshot
import {
NostrSystem,
EventPublisher,
UserRelaysCache,
RequestBuilder,
FlatNoteStore,
StoreSnapshot
} from "@snort/system"
// Provided in-memory / indexedDb cache for relays
@ -63,4 +64,4 @@ const System = new NostrSystem({
// these patterns will be managed in @snort/system-react to make it easier to use react or other UI frameworks
// release();
})();
```
```

View File

@ -1,4 +1,4 @@
import { NostrSystem, EventPublisher, UserRelaysCache, RequestBuilder, FlatNoteStore, StoreSnapshot } from "../src"
import { NostrSystem, EventPublisher, UserRelaysCache, RequestBuilder, FlatNoteStore, StoreSnapshot } from "../src";
// Provided in-memory / indexedDb cache for relays
// You can also implement your own with "RelayCache" interface
@ -6,47 +6,47 @@ const RelaysCache = new UserRelaysCache();
// example auth handler using NIP-07
const AuthHandler = async (challenge: string, relay: string) => {
const pub = await EventPublisher.nip7();
if (pub) {
return await pub.nip42Auth(challenge, relay);
}
}
const pub = await EventPublisher.nip7();
if (pub) {
return await pub.nip42Auth(challenge, relay);
}
};
// Singleton instance to store all connections and access query fetching system
const System = new NostrSystem({
relayCache: RelaysCache,
authHandler: AuthHandler // can be left undefined if you dont care about NIP-42 Auth
relayCache: RelaysCache,
authHandler: AuthHandler, // can be left undefined if you dont care about NIP-42 Auth
});
(async () => {
// connec to one "bootstrap" relay to pull profiles/relay lists from
// also used as a fallback relay when gossip model doesnt know which relays to pick, or "authors" are not provided in the request
await System.ConnectToRelay("wss://relay.snort.social", { read: true, write: false });
// connec to one "bootstrap" relay to pull profiles/relay lists from
// also used as a fallback relay when gossip model doesnt know which relays to pick, or "authors" are not provided in the request
await System.ConnectToRelay("wss://relay.snort.social", { read: true, write: false });
// ID should be unique to the use case, this is important as all data fetched from this ID will be merged into the same NoteStore
const rb = new RequestBuilder("get-posts");
rb.withFilter()
.authors(["63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed"]) // Kieran pubkey
.kinds([1])
.limit(10);
// ID should be unique to the use case, this is important as all data fetched from this ID will be merged into the same NoteStore
const rb = new RequestBuilder("get-posts");
rb.withFilter()
.authors(["63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed"]) // Kieran pubkey
.kinds([1])
.limit(10);
const q = System.Query<FlatNoteStore>(FlatNoteStore, rb);
// basic usage using "onEvent", fired for every event added to the store
q.onEvent = (sub, e) => {
console.debug(sub, e);
}
const q = System.Query<FlatNoteStore>(FlatNoteStore, rb);
// basic usage using "onEvent", fired for every event added to the store
q.onEvent = (sub, e) => {
console.debug(sub, e);
};
// Hookable type using change notification, limited to every 500ms
const release = q.feed.hook(() => {
// since we use the FlatNoteStore we expect NostrEvent[]
// other stores provide different data, like a single event instead of an array (latest version)
const state = q.feed.snapshot as StoreSnapshot<ReturnType<FlatNoteStore["getSnapshotData"]>>;
// Hookable type using change notification, limited to every 500ms
const release = q.feed.hook(() => {
// since we use the FlatNoteStore we expect NostrEvent[]
// other stores provide different data, like a single event instead of an array (latest version)
const state = q.feed.snapshot as StoreSnapshot<ReturnType<FlatNoteStore["getSnapshotData"]>>;
// do something with snapshot of store
console.log(`We have ${state.data.length} events now!`)
});
// do something with snapshot of store
console.log(`We have ${state.data.length} events now!`);
});
// release the hook when its not needed anymore
// these patterns will be managed in @snort/system-react to make it easier to use react or other UI frameworks
// release();
})();
// release the hook when its not needed anymore
// these patterns will be managed in @snort/system-react to make it easier to use react or other UI frameworks
// release();
})();

View File

@ -33,4 +33,4 @@
"dexie": "^3.2.4",
"uuid": "^9.0.0"
}
}
}

View File

@ -6,37 +6,37 @@ const NAME = "snort-system";
const VERSION = 2;
const STORES = {
users: "++pubkey, name, display_name, picture, nip05, npub",
relayMetrics: "++addr",
userRelays: "++pubkey",
events: "++id, pubkey, created_at"
users: "++pubkey, name, display_name, picture, nip05, npub",
relayMetrics: "++addr",
userRelays: "++pubkey",
events: "++id, pubkey, created_at",
};
export class SnortSystemDb extends Dexie {
ready = false;
users!: Table<MetadataCache>;
relayMetrics!: Table<RelayMetrics>;
userRelays!: Table<UsersRelays>;
events!: Table<NostrEvent>;
dms!: Table<NostrEvent>;
ready = false;
users!: Table<MetadataCache>;
relayMetrics!: Table<RelayMetrics>;
userRelays!: Table<UsersRelays>;
events!: Table<NostrEvent>;
dms!: Table<NostrEvent>;
constructor() {
super(NAME);
this.version(VERSION).stores(STORES);
}
constructor() {
super(NAME);
this.version(VERSION).stores(STORES);
}
isAvailable() {
if ("indexedDB" in window) {
return new Promise<boolean>(resolve => {
const req = window.indexedDB.open("dummy", 1);
req.onsuccess = () => {
resolve(true);
};
req.onerror = () => {
resolve(false);
};
});
}
return Promise.resolve(false);
isAvailable() {
if ("indexedDB" in window) {
return new Promise<boolean>(resolve => {
const req = window.indexedDB.open("dummy", 1);
req.onsuccess = () => {
resolve(true);
};
req.onerror = () => {
resolve(false);
};
});
}
}
return Promise.resolve(false);
}
}

View File

@ -70,4 +70,4 @@ export function mapEventToProfile(ev: NostrEvent) {
} catch (e) {
console.error("Failed to parse JSON", ev, e);
}
}
}

View File

@ -2,21 +2,21 @@ import { db, RelayMetrics } from ".";
import { FeedCache } from "@snort/shared";
export class RelayMetricCache extends FeedCache<RelayMetrics> {
constructor() {
super("RelayMetrics", db.relayMetrics);
}
constructor() {
super("RelayMetrics", db.relayMetrics);
}
key(of: RelayMetrics): string {
return of.addr;
}
key(of: RelayMetrics): string {
return of.addr;
}
override async preload(): Promise<void> {
await super.preload();
// load everything
await this.buffer([...this.onTable]);
}
override async preload(): Promise<void> {
await super.preload();
// load everything
await this.buffer([...this.onTable]);
}
takeSnapshot(): Array<RelayMetrics> {
return [...this.cache.values()];
}
}
takeSnapshot(): Array<RelayMetrics> {
return [...this.cache.values()];
}
}

View File

@ -145,4 +145,4 @@ export class UserProfileCache extends FeedCache<MetadataCache> {
}
}
}
}
}

View File

@ -26,4 +26,4 @@ export class UserRelaysCache extends FeedCache<UsersRelays> {
takeSnapshot(): Array<UsersRelays> {
return [...this.cache.values()];
}
}
}

View File

@ -421,7 +421,12 @@ export class Connection extends ExternalStore<ConnectionStateSnapshot> {
const lastActivity = unixNowMs() - this.#activity;
if (lastActivity > 30_000 && !this.IsClosed) {
if (this.ActiveRequests.size > 0) {
this.#log("%s Inactive connection has %d active requests! %O", this.Address, this.ActiveRequests.size, this.ActiveRequests);
this.#log(
"%s Inactive connection has %d active requests! %O",
this.Address,
this.ActiveRequests.size,
this.ActiveRequests
);
} else {
this.Close();
}

View File

@ -9,7 +9,6 @@ export const DefaultConnectTimeout = 2000;
// eslint-disable-next-line no-useless-escape
export const HashtagRegex = /(#[^\s!@#$%^&*()=+.\/,\[{\]};:'"?><]+)/g;
/**
* How long profile cache should be considered valid for
*/

View File

@ -6,17 +6,17 @@ import { EventKind, HexKey, NostrEvent } from ".";
import { Nip4WebCryptoEncryptor } from "./impl/nip4";
export interface Tag {
key: string
value?: string
relay?: string
marker?: string // NIP-10
key: string;
value?: string;
relay?: string;
marker?: string; // NIP-10
}
export interface Thread {
root?: Tag
replyTo?: Tag
mentions: Array<Tag>
pubKeys: Array<HexKey>
root?: Tag;
replyTo?: Tag;
mentions: Array<Tag>;
pubKeys: Array<HexKey>;
}
export abstract class EventExt {
@ -41,7 +41,7 @@ export abstract class EventExt {
const sig = secp.schnorr.sign(e.id, key);
e.sig = utils.bytesToHex(sig);
if (!(secp.schnorr.verify(e.sig, e.id, e.pubkey))) {
if (!secp.schnorr.verify(e.sig, e.id, e.pubkey)) {
throw new Error("Signing failed");
}
}
@ -84,12 +84,12 @@ export abstract class EventExt {
static parseTag(tag: Array<string>) {
if (tag.length < 1) {
throw new Error("Invalid tag, must have more than 2 items")
throw new Error("Invalid tag, must have more than 2 items");
}
const ret = {
key: tag[0],
value: tag[1]
value: tag[1],
} as Tag;
switch (ret.key) {
case "e": {

View File

@ -95,7 +95,7 @@ export class EventPublisher {
* Create an EventPublisher for a private key
*/
static privateKey(privateKey: string) {
const signer = new PrivateKeySigner(privateKey)
const signer = new PrivateKeySigner(privateKey);
return new EventPublisher(signer, signer.getPubKey());
}

View File

@ -1,6 +1,7 @@
import { ReqFilter, UsersRelays } from ".";
import { unwrap } from "@snort/shared";
import { dedupe, unwrap } from "@snort/shared";
import debug from "debug";
import { FlatReqFilter } from "request-expander";
const PickNRelays = 2;
@ -9,6 +10,11 @@ export interface RelayTaggedFilter {
filter: ReqFilter;
}
export interface RelayTaggedFlatFilters {
relay: string;
filters: Array<FlatReqFilter>;
}
export interface RelayTaggedFilters {
relay: string;
filters: Array<ReqFilter>;
@ -43,11 +49,10 @@ export function splitAllByWriteRelays(cache: RelayCache, filters: Array<ReqFilte
/**
* Split filters by authors
* @param filter
* @returns
*/
export function splitByWriteRelays(cache: RelayCache, filter: ReqFilter): Array<RelayTaggedFilter> {
if ((filter.authors?.length ?? 0) === 0) {
const authors = filter.authors;
if ((authors?.length ?? 0) === 0) {
return [
{
relay: "",
@ -56,10 +61,13 @@ export function splitByWriteRelays(cache: RelayCache, filter: ReqFilter): Array<
];
}
const allRelays = unwrap(filter.authors).map(a => {
const allRelays = unwrap(authors).map(a => {
return {
key: a,
relays: cache.getFromCache(a)?.relays?.filter(a => a.settings.write).sort(() => Math.random() < 0.5 ? 1 : -1),
relays: cache
.getFromCache(a)
?.relays?.filter(a => a.settings.write)
.sort(() => (Math.random() < 0.5 ? 1 : -1)),
};
});
@ -83,7 +91,7 @@ export function splitByWriteRelays(cache: RelayCache, filter: ReqFilter): Array<
// <key, relay[]> - pick n top relays
// <relay, key[]> - map keys per relay (for subscription filter)
const userPickedRelays = unwrap(filter.authors).map(k => {
const userPickedRelays = unwrap(authors).map(k => {
// pick top 3 relays for this key
const relaysForKey = topRelays
.filter(([, v]) => v.has(k))
@ -116,3 +124,98 @@ export function splitByWriteRelays(cache: RelayCache, filter: ReqFilter): Array<
debug("GOSSIP")("Picked %o", picked);
return picked;
}
/**
* Split filters by author
*/
export function splitFlatByWriteRelays(cache: RelayCache, input: Array<FlatReqFilter>): Array<RelayTaggedFlatFilters> {
const authors = input.filter(a => a.authors).map(a => unwrap(a.authors));
if (authors.length === 0) {
return [
{
relay: "",
filters: input,
},
];
}
const topRelays = pickTopRelays(cache, authors, PickNRelays);
const pickedRelays = dedupe(topRelays.flatMap(a => a.relays));
const picked = pickedRelays.map(a => {
const keysOnPickedRelay = new Set(userPickedRelays.filter(b => b.relaysForKey.includes(a)).map(b => b.k));
return {
relay: a,
filter: {
...filter,
authors: [...keysOnPickedRelay],
},
} as RelayTaggedFilter;
});
if (missing.length > 0) {
picked.push({
relay: "",
filter: {
...filter,
authors: missing.map(a => a.key),
},
});
}
debug("GOSSIP")("Picked %o", picked);
return picked;
}
/**
* Pick most popular relays for each authors
*/
function pickTopRelays(cache: RelayCache, authors: Array<string>, n: number) {
// map of pubkey -> [write relays]
const allRelays = authors.map(a => {
return {
key: a,
relays: cache
.getFromCache(a)
?.relays?.filter(a => a.settings.write)
.sort(() => (Math.random() < 0.5 ? 1 : -1)),
};
});
const missing = allRelays.filter(a => a.relays === undefined || a.relays.length === 0);
const hasRelays = allRelays.filter(a => a.relays !== undefined && a.relays.length > 0);
// map of relay -> [pubkeys]
const relayUserMap = hasRelays.reduce((acc, v) => {
for (const r of unwrap(v.relays)) {
if (!acc.has(r.url)) {
acc.set(r.url, new Set([v.key]));
} else {
unwrap(acc.get(r.url)).add(v.key);
}
}
return acc;
}, new Map<string, Set<string>>());
// selection algo will just pick relays with the most users
const topRelays = [...relayUserMap.entries()].sort(([, v], [, v1]) => v1.size - v.size);
// <relay, key[]> - count keys per relay
// <key, relay[]> - pick n top relays
// <relay, key[]> - map keys per relay (for subscription filter)
return hasRelays
.map(k => {
// pick top N relays for this key
const relaysForKey = topRelays
.filter(([, v]) => v.has(k.key))
.slice(0, n)
.map(([k]) => k);
return { key: k.key, relays: relaysForKey };
})
.concat(
missing.map(a => {
return {
key: a.key,
relays: [],
};
})
);
}

View File

@ -4,49 +4,49 @@ import { base64 } from "@scure/base";
import { secp256k1 } from "@noble/curves/secp256k1";
export class Nip4WebCryptoEncryptor implements MessageEncryptor {
getSharedSecret(privateKey: string, publicKey: string) {
const sharedPoint = secp256k1.getSharedSecret(privateKey, "02" + publicKey);
const sharedX = sharedPoint.slice(1, 33);
return sharedX;
}
getSharedSecret(privateKey: string, publicKey: string) {
const sharedPoint = secp256k1.getSharedSecret(privateKey, "02" + publicKey);
const sharedX = sharedPoint.slice(1, 33);
return sharedX;
}
async encryptData(content: string, sharedSecet: Uint8Array) {
const key = await this.#importKey(sharedSecet);
const iv = window.crypto.getRandomValues(new Uint8Array(16));
const data = new TextEncoder().encode(content);
const result = await window.crypto.subtle.encrypt(
{
name: "AES-CBC",
iv: iv,
},
key,
data
);
const uData = new Uint8Array(result);
return `${base64.encode(uData)}?iv=${base64.encode(iv)}`;
}
async encryptData(content: string, sharedSecet: Uint8Array) {
const key = await this.#importKey(sharedSecet);
const iv = window.crypto.getRandomValues(new Uint8Array(16));
const data = new TextEncoder().encode(content);
const result = await window.crypto.subtle.encrypt(
{
name: "AES-CBC",
iv: iv,
},
key,
data
);
const uData = new Uint8Array(result);
return `${base64.encode(uData)}?iv=${base64.encode(iv)}`;
}
/**
* Decrypt the content of the message
*/
async decryptData(cyphertext: string, sharedSecet: Uint8Array) {
const key = await this.#importKey(sharedSecet);
const cSplit = cyphertext.split("?iv=");
const data = base64.decode(cSplit[0]);
const iv = base64.decode(cSplit[1]);
/**
* Decrypt the content of the message
*/
async decryptData(cyphertext: string, sharedSecet: Uint8Array) {
const key = await this.#importKey(sharedSecet);
const cSplit = cyphertext.split("?iv=");
const data = base64.decode(cSplit[0]);
const iv = base64.decode(cSplit[1]);
const result = await window.crypto.subtle.decrypt(
{
name: "AES-CBC",
iv: iv,
},
key,
data
);
return new TextDecoder().decode(result);
}
const result = await window.crypto.subtle.decrypt(
{
name: "AES-CBC",
iv: iv,
},
key,
data
);
return new TextDecoder().decode(result);
}
async #importKey(sharedSecet: Uint8Array) {
return await window.crypto.subtle.importKey("raw", sharedSecet, { name: "AES-CBC" }, false, ["encrypt", "decrypt"]);
}
}
async #importKey(sharedSecet: Uint8Array) {
return await window.crypto.subtle.importKey("raw", sharedSecet, { name: "AES-CBC" }, false, ["encrypt", "decrypt"]);
}
}

View File

@ -1,40 +1,39 @@
import { MessageEncryptor } from "index";
import { base64 } from "@scure/base";
import { randomBytes } from '@noble/hashes/utils'
import { streamXOR as xchacha20 } from '@stablelib/xchacha20'
import { randomBytes } from "@noble/hashes/utils";
import { streamXOR as xchacha20 } from "@stablelib/xchacha20";
import { secp256k1 } from "@noble/curves/secp256k1";
import { sha256 } from '@noble/hashes/sha256'
import { sha256 } from "@noble/hashes/sha256";
export enum Nip44Version {
Reserved = 0x00,
XChaCha20 = 0x01
Reserved = 0x00,
XChaCha20 = 0x01,
}
export class Nip44Encryptor implements MessageEncryptor {
getSharedSecret(privateKey: string, publicKey: string) {
const key = secp256k1.getSharedSecret(privateKey, '02' + publicKey)
return sha256(key.slice(1, 33));
}
getSharedSecret(privateKey: string, publicKey: string) {
const key = secp256k1.getSharedSecret(privateKey, "02" + publicKey);
return sha256(key.slice(1, 33));
}
encryptData(content: string, sharedSecret: Uint8Array) {
const nonce = randomBytes(24)
const plaintext = new TextEncoder().encode(content)
const ciphertext = xchacha20(sharedSecret, nonce, plaintext, plaintext);
const ctb64 = base64.encode(Uint8Array.from(ciphertext))
const nonceb64 = base64.encode(nonce)
return JSON.stringify({ ciphertext: ctb64, nonce: nonceb64, v: Nip44Version.XChaCha20 })
}
encryptData(content: string, sharedSecret: Uint8Array) {
const nonce = randomBytes(24);
const plaintext = new TextEncoder().encode(content);
const ciphertext = xchacha20(sharedSecret, nonce, plaintext, plaintext);
const ctb64 = base64.encode(Uint8Array.from(ciphertext));
const nonceb64 = base64.encode(nonce);
return JSON.stringify({ ciphertext: ctb64, nonce: nonceb64, v: Nip44Version.XChaCha20 });
}
decryptData(cyphertext: string, sharedSecret: Uint8Array) {
const dt = JSON.parse(cyphertext)
if (dt.v !== 1) throw new Error('NIP44: unknown encryption version')
decryptData(cyphertext: string, sharedSecret: Uint8Array) {
const dt = JSON.parse(cyphertext);
if (dt.v !== 1) throw new Error("NIP44: unknown encryption version");
const ciphertext = base64.decode(dt.ciphertext)
const nonce = base64.decode(dt.nonce)
const plaintext = xchacha20(sharedSecret, nonce, ciphertext, ciphertext)
const text = new TextDecoder().decode(plaintext)
return text;
}
}
const ciphertext = base64.decode(dt.ciphertext);
const nonce = base64.decode(dt.nonce);
const plaintext = xchacha20(sharedSecret, nonce, ciphertext, ciphertext);
const text = new TextDecoder().decode(plaintext);
return text;
}
}

View File

@ -12,204 +12,213 @@ import EventKind from "../event-kind";
const NIP46_KIND = 24_133;
interface Nip46Metadata {
name: string
url?: string
description?: string
icons?: Array<string>
name: string;
url?: string;
description?: string;
icons?: Array<string>;
}
interface Nip46Request {
id: string
method: string
params: Array<any>
id: string;
method: string;
params: Array<any>;
}
interface Nip46Response {
id: string
result: any
error: string
id: string;
result: any;
error: string;
}
interface QueueObj {
resolve: (o: any) => void;
reject: (e: Error) => void;
resolve: (o: any) => void;
reject: (e: Error) => void;
}
export class Nip46Signer implements EventSigner {
#conn?: Connection;
#relay: string;
#localPubkey: string;
#remotePubkey?: string;
#token?: string;
#insideSigner: EventSigner;
#commandQueue: Map<string, QueueObj> = new Map();
#log = debug("NIP-46");
#proto: string;
#didInit: boolean = false;
#conn?: Connection;
#relay: string;
#localPubkey: string;
#remotePubkey?: string;
#token?: string;
#insideSigner: EventSigner;
#commandQueue: Map<string, QueueObj> = new Map();
#log = debug("NIP-46");
#proto: string;
#didInit: boolean = false;
constructor(config: string, insideSigner?: EventSigner) {
const u = new URL(config);
this.#proto = u.protocol;
this.#localPubkey = u.pathname.substring(2);
constructor(config: string, insideSigner?: EventSigner) {
const u = new URL(config);
this.#proto = u.protocol;
this.#localPubkey = u.pathname.substring(2);
if (u.hash.length > 1) {
this.#token = u.hash.substring(1);
}
if (this.#localPubkey.startsWith("npub")) {
this.#localPubkey = bech32ToHex(this.#localPubkey);
}
this.#relay = unwrap(u.searchParams.get("relay"));
this.#insideSigner = insideSigner ?? new PrivateKeySigner(secp256k1.utils.randomPrivateKey())
if (u.hash.length > 1) {
this.#token = u.hash.substring(1);
}
if (this.#localPubkey.startsWith("npub")) {
this.#localPubkey = bech32ToHex(this.#localPubkey);
}
get relays() {
return [this.#relay];
}
this.#relay = unwrap(u.searchParams.get("relay"));
this.#insideSigner = insideSigner ?? new PrivateKeySigner(secp256k1.utils.randomPrivateKey());
}
get privateKey() {
if(this.#insideSigner instanceof PrivateKeySigner) {
return this.#insideSigner.privateKey;
}
}
get relays() {
return [this.#relay];
}
get privateKey() {
if (this.#insideSigner instanceof PrivateKeySigner) {
return this.#insideSigner.privateKey;
}
}
async init() {
const isBunker = this.#proto === "bunker:";
if (isBunker) {
this.#remotePubkey = this.#localPubkey;
this.#localPubkey = await this.#insideSigner.getPubKey();
}
return await new Promise<void>((resolve, reject) => {
this.#conn = new Connection(this.#relay, { read: true, write: true });
this.#conn.OnEvent = async (sub, e) => {
await this.#onReply(e);
};
this.#conn.OnConnected = async () => {
this.#conn!.QueueReq(
[
"REQ",
"reply",
{
kinds: [NIP46_KIND],
"#p": [this.#localPubkey],
},
],
() => {}
);
async init() {
const isBunker = this.#proto === "bunker:";
if (isBunker) {
this.#remotePubkey = this.#localPubkey;
this.#localPubkey = await this.#insideSigner.getPubKey();
await this.#connect(unwrap(this.#remotePubkey));
resolve();
} else {
this.#commandQueue.set("connect", {
reject,
resolve,
});
}
return await new Promise<void>((resolve, reject) => {
this.#conn = new Connection(this.#relay, { read: true, write: true });
this.#conn.OnEvent = async (sub, e) => {
await this.#onReply(e);
}
this.#conn.OnConnected = async () => {
this.#conn!.QueueReq(["REQ", "reply", {
kinds: [NIP46_KIND],
"#p": [this.#localPubkey]
}], () => { });
};
this.#conn.Connect();
this.#didInit = true;
});
}
if (isBunker) {
await this.#connect(unwrap(this.#remotePubkey));
resolve();
} else {
this.#commandQueue.set("connect", {
reject,
resolve
})
}
}
this.#conn.Connect();
this.#didInit = true;
})
async close() {
if (this.#conn) {
await this.#disconnect();
this.#conn.CloseReq("reply");
this.#conn.Close();
this.#conn = undefined;
this.#didInit = false;
}
}
async describe() {
return await this.#rpc<Array<string>>("describe", []);
}
async getPubKey() {
return await this.#rpc<string>("get_public_key", []);
}
async nip4Encrypt(content: string, otherKey: string) {
return await this.#rpc<string>("nip04_encrypt", [otherKey, content]);
}
async nip4Decrypt(content: string, otherKey: string) {
return await this.#rpc<string>("nip04_decrypt", [otherKey, content]);
}
async sign(ev: NostrEvent) {
const evStr = await this.#rpc<string>("sign_event", [JSON.stringify(ev)]);
return JSON.parse(evStr);
}
async #disconnect() {
return await this.#rpc("disconnect", []);
}
async #connect(pk: string) {
const connectParams = [pk];
if (this.#token) {
connectParams.push(this.#token);
}
return await this.#rpc<string>("connect", connectParams);
}
async #onReply(e: NostrEvent) {
if (e.kind !== NIP46_KIND) {
throw new Error("Unknown event kind");
}
async close() {
if (this.#conn) {
await this.#disconnect();
this.#conn.CloseReq("reply");
this.#conn.Close();
this.#conn = undefined;
this.#didInit = false;
}
const decryptedContent = await this.#insideSigner.nip4Decrypt(e.content, e.pubkey);
const reply = JSON.parse(decryptedContent) as Nip46Request | Nip46Response;
let id = reply.id;
this.#log("Recv: %O", reply);
if ("method" in reply && reply.method === "connect") {
this.#remotePubkey = reply.params[0];
await this.#sendCommand(
{
id: reply.id,
result: "ack",
error: "",
},
unwrap(this.#remotePubkey)
);
id = "connect";
}
const pending = this.#commandQueue.get(id);
if (!pending) {
throw new Error("No pending command found");
}
async describe() {
return await this.#rpc<Array<string>>("describe", []);
pending.resolve(reply);
this.#commandQueue.delete(reply.id);
}
async #rpc<T>(method: string, params: Array<any>) {
if (!this.#didInit) {
await this.init();
}
if (!this.#conn) throw new Error("Connection error");
async getPubKey() {
return await this.#rpc<string>("get_public_key", []);
}
const payload = {
id: uuid(),
method,
params,
} as Nip46Request;
async nip4Encrypt(content: string, otherKey: string) {
return await this.#rpc<string>("nip04_encrypt", [otherKey, content]);
}
this.#sendCommand(payload, unwrap(this.#remotePubkey));
return await new Promise<T>((resolve, reject) => {
this.#commandQueue.set(payload.id, {
resolve: async (o: Nip46Response) => {
resolve(o.result as T);
},
reject,
});
});
}
async nip4Decrypt(content: string, otherKey: string) {
return await this.#rpc<string>("nip04_decrypt", [otherKey, content]);
}
async #sendCommand(payload: Nip46Request | Nip46Response, target: string) {
if (!this.#conn) return;
async sign(ev: NostrEvent) {
const evStr = await this.#rpc<string>("sign_event", [JSON.stringify(ev)]);
return JSON.parse(evStr);
}
const eb = new EventBuilder();
eb.kind(NIP46_KIND as EventKind)
.content(await this.#insideSigner.nip4Encrypt(JSON.stringify(payload), target))
.tag(["p", target]);
async #disconnect() {
return await this.#rpc("disconnect", []);
}
async #connect(pk: string) {
const connectParams = [pk];
if (this.#token) {
connectParams.push(this.#token);
}
return await this.#rpc<string>("connect", connectParams);
}
async #onReply(e: NostrEvent) {
if (e.kind !== NIP46_KIND) {
throw new Error("Unknown event kind");
}
const decryptedContent = await this.#insideSigner.nip4Decrypt(e.content, e.pubkey);
const reply = JSON.parse(decryptedContent) as Nip46Request | Nip46Response;
let id = reply.id;
this.#log("Recv: %O", reply);
if ("method" in reply && reply.method === "connect") {
this.#remotePubkey = reply.params[0];
await this.#sendCommand({
id: reply.id,
result: "ack",
error: ""
}, unwrap(this.#remotePubkey));
id = "connect";
}
const pending = this.#commandQueue.get(id);
if (!pending) {
throw new Error("No pending command found");
}
pending.resolve(reply);
this.#commandQueue.delete(reply.id);
}
async #rpc<T>(method: string, params: Array<any>) {
if (!this.#didInit) {
await this.init();
}
if (!this.#conn) throw new Error("Connection error");
const payload = {
id: uuid(),
method,
params,
} as Nip46Request;
this.#sendCommand(payload, unwrap(this.#remotePubkey));
return await new Promise<T>((resolve, reject) => {
this.#commandQueue.set(payload.id, {
resolve: async (o: Nip46Response) => {
resolve(o.result as T);
},
reject,
});
});
}
async #sendCommand(payload: Nip46Request | Nip46Response, target: string) {
if (!this.#conn) return;
const eb = new EventBuilder();
eb.kind(NIP46_KIND as EventKind)
.content(await this.#insideSigner.nip4Encrypt(JSON.stringify(payload), target))
.tag(["p", target]);
this.#log("Send: %O", payload);
const evCommand = await eb.buildAndSign(this.#insideSigner);
await this.#conn.SendAsync(evCommand);
}
this.#log("Send: %O", payload);
const evCommand = await eb.buildAndSign(this.#insideSigner);
await this.#conn.SendAsync(evCommand);
}
}

View File

@ -6,56 +6,55 @@ const Nip7Queue: Array<WorkQueueItem> = [];
processWorkQueue(Nip7Queue);
declare global {
interface Window {
nostr?: {
getPublicKey: () => Promise<HexKey>;
signEvent: <T extends NostrEvent>(event: T) => Promise<T>;
interface Window {
nostr?: {
getPublicKey: () => Promise<HexKey>;
signEvent: <T extends NostrEvent>(event: T) => Promise<T>;
getRelays?: () => Promise<Record<string, { read: boolean; write: boolean }>>;
getRelays?: () => Promise<Record<string, { read: boolean; write: boolean }>>;
nip04?: {
encrypt?: (pubkey: HexKey, plaintext: string) => Promise<string>;
decrypt?: (pubkey: HexKey, ciphertext: string) => Promise<string>;
};
};
}
nip04?: {
encrypt?: (pubkey: HexKey, plaintext: string) => Promise<string>;
decrypt?: (pubkey: HexKey, ciphertext: string) => Promise<string>;
};
};
}
}
export class Nip7Signer implements EventSigner {
init(): Promise<void> {
return Promise.resolve();
}
init(): Promise<void> {
return Promise.resolve();
}
async getPubKey(): Promise<string> {
if (!window.nostr) {
throw new Error("Cannot use NIP-07 signer, not found!");
}
return await barrierQueue(Nip7Queue, () => unwrap(window.nostr).getPublicKey());
async getPubKey(): Promise<string> {
if (!window.nostr) {
throw new Error("Cannot use NIP-07 signer, not found!");
}
return await barrierQueue(Nip7Queue, () => unwrap(window.nostr).getPublicKey());
}
async nip4Encrypt(content: string, key: string): Promise<string> {
if (!window.nostr) {
throw new Error("Cannot use NIP-07 signer, not found!");
}
return await barrierQueue(Nip7Queue, () =>
unwrap(window.nostr?.nip04?.encrypt).call(window.nostr?.nip04, key, content)
);
async nip4Encrypt(content: string, key: string): Promise<string> {
if (!window.nostr) {
throw new Error("Cannot use NIP-07 signer, not found!");
}
return await barrierQueue(Nip7Queue, () =>
unwrap(window.nostr?.nip04?.encrypt).call(window.nostr?.nip04, key, content)
);
}
async nip4Decrypt(content: string, otherKey: string): Promise<string> {
if (!window.nostr) {
throw new Error("Cannot use NIP-07 signer, not found!");
}
return await barrierQueue(Nip7Queue, () =>
unwrap(window.nostr?.nip04?.decrypt).call(window.nostr?.nip04, otherKey, content)
);
async nip4Decrypt(content: string, otherKey: string): Promise<string> {
if (!window.nostr) {
throw new Error("Cannot use NIP-07 signer, not found!");
}
return await barrierQueue(Nip7Queue, () =>
unwrap(window.nostr?.nip04?.decrypt).call(window.nostr?.nip04, otherKey, content)
);
}
async sign(ev: NostrEvent): Promise<NostrEvent> {
if (!window.nostr) {
throw new Error("Cannot use NIP-07 signer, not found!");
}
return await barrierQueue(Nip7Queue, () => unwrap(window.nostr).signEvent(ev));
async sign(ev: NostrEvent): Promise<NostrEvent> {
if (!window.nostr) {
throw new Error("Cannot use NIP-07 signer, not found!");
}
}
return await barrierQueue(Nip7Queue, () => unwrap(window.nostr).signEvent(ev));
}
}

View File

@ -37,7 +37,7 @@ export interface SystemInterface {
HandleAuth?: AuthHandler;
get Sockets(): Array<ConnectionStateSnapshot>;
GetQuery(id: string): Query | undefined;
Query<T extends NoteStore>(type: { new(): T }, req: RequestBuilder | null): Query;
Query<T extends NoteStore>(type: { new (): T }, req: RequestBuilder | null): Query;
ConnectToRelay(address: string, options: RelaySettings): Promise<void>;
DisconnectRelay(address: string): void;
BroadcastEvent(ev: NostrEvent): void;
@ -53,7 +53,7 @@ export interface SystemSnapshot {
}
export interface MessageEncryptor {
getSharedSecret(privateKey: string, publicKey: string): Promise<Uint8Array> | Uint8Array
encryptData(plaintext: string, sharedSecet: Uint8Array): Promise<string> | string
decryptData(cyphertext: string, sharedSecet: Uint8Array): Promise<string> | string
}
getSharedSecret(privateKey: string, publicKey: string): Promise<Uint8Array> | Uint8Array;
encryptData(plaintext: string, sharedSecet: Uint8Array): Promise<string> | string;
decryptData(cyphertext: string, sharedSecet: Uint8Array): Promise<string> | string;
}

View File

@ -2,109 +2,108 @@ import { bech32ToHex, hexToBech32 } from "@snort/shared";
import { NostrPrefix, decodeTLV, TLVEntryType } from ".";
export interface NostrLink {
type: NostrPrefix;
id: string;
kind?: number;
author?: string;
relays?: Array<string>;
encode(): string;
}
export function validateNostrLink(link: string): boolean {
try {
const parsedLink = parseNostrLink(link);
if (!parsedLink) {
return false;
}
if (parsedLink.type === NostrPrefix.PublicKey || parsedLink.type === NostrPrefix.Note) {
return parsedLink.id.length === 64;
}
return true;
} catch {
type: NostrPrefix;
id: string;
kind?: number;
author?: string;
relays?: Array<string>;
encode(): string;
}
export function validateNostrLink(link: string): boolean {
try {
const parsedLink = parseNostrLink(link);
if (!parsedLink) {
return false;
}
}
export function tryParseNostrLink(link: string, prefixHint?: NostrPrefix): NostrLink | undefined {
try {
return parseNostrLink(link, prefixHint);
} catch {
return undefined;
if (parsedLink.type === NostrPrefix.PublicKey || parsedLink.type === NostrPrefix.Note) {
return parsedLink.id.length === 64;
}
return true;
} catch {
return false;
}
export function parseNostrLink(link: string, prefixHint?: NostrPrefix): NostrLink {
const entity = link.startsWith("web+nostr:") || link.startsWith("nostr:") ? link.split(":")[1] : link;
const isPrefix = (prefix: NostrPrefix) => {
return entity.startsWith(prefix);
}
export function tryParseNostrLink(link: string, prefixHint?: NostrPrefix): NostrLink | undefined {
try {
return parseNostrLink(link, prefixHint);
} catch {
return undefined;
}
}
export function parseNostrLink(link: string, prefixHint?: NostrPrefix): NostrLink {
const entity = link.startsWith("web+nostr:") || link.startsWith("nostr:") ? link.split(":")[1] : link;
const isPrefix = (prefix: NostrPrefix) => {
return entity.startsWith(prefix);
};
if (isPrefix(NostrPrefix.PublicKey)) {
const id = bech32ToHex(entity);
if (id.length !== 64) throw new Error("Invalid nostr link, must contain 32 byte id");
return {
type: NostrPrefix.PublicKey,
id: id,
encode: () => hexToBech32(NostrPrefix.PublicKey, id),
};
if (isPrefix(NostrPrefix.PublicKey)) {
const id = bech32ToHex(entity);
} else if (isPrefix(NostrPrefix.Note)) {
const id = bech32ToHex(entity);
if (id.length !== 64) throw new Error("Invalid nostr link, must contain 32 byte id");
return {
type: NostrPrefix.Note,
id: id,
encode: () => hexToBech32(NostrPrefix.Note, id),
};
} else if (isPrefix(NostrPrefix.Profile) || isPrefix(NostrPrefix.Event) || isPrefix(NostrPrefix.Address)) {
const decoded = decodeTLV(entity);
const id = decoded.find(a => a.type === TLVEntryType.Special)?.value as string;
const relays = decoded.filter(a => a.type === TLVEntryType.Relay).map(a => a.value as string);
const author = decoded.find(a => a.type === TLVEntryType.Author)?.value as string;
const kind = decoded.find(a => a.type === TLVEntryType.Kind)?.value as number;
const encode = () => {
return entity; // return original
};
if (isPrefix(NostrPrefix.Profile)) {
if (id.length !== 64) throw new Error("Invalid nostr link, must contain 32 byte id");
return {
type: NostrPrefix.PublicKey,
id: id,
encode: () => hexToBech32(NostrPrefix.PublicKey, id),
type: NostrPrefix.Profile,
id,
relays,
kind,
author,
encode,
};
} else if (isPrefix(NostrPrefix.Note)) {
const id = bech32ToHex(entity);
} else if (isPrefix(NostrPrefix.Event)) {
if (id.length !== 64) throw new Error("Invalid nostr link, must contain 32 byte id");
return {
type: NostrPrefix.Note,
id: id,
encode: () => hexToBech32(NostrPrefix.Note, id),
type: NostrPrefix.Event,
id,
relays,
kind,
author,
encode,
};
} else if (isPrefix(NostrPrefix.Profile) || isPrefix(NostrPrefix.Event) || isPrefix(NostrPrefix.Address)) {
const decoded = decodeTLV(entity);
const id = decoded.find(a => a.type === TLVEntryType.Special)?.value as string;
const relays = decoded.filter(a => a.type === TLVEntryType.Relay).map(a => a.value as string);
const author = decoded.find(a => a.type === TLVEntryType.Author)?.value as string;
const kind = decoded.find(a => a.type === TLVEntryType.Kind)?.value as number;
const encode = () => {
return entity; // return original
};
if (isPrefix(NostrPrefix.Profile)) {
if (id.length !== 64) throw new Error("Invalid nostr link, must contain 32 byte id");
return {
type: NostrPrefix.Profile,
id,
relays,
kind,
author,
encode,
};
} else if (isPrefix(NostrPrefix.Event)) {
if (id.length !== 64) throw new Error("Invalid nostr link, must contain 32 byte id");
return {
type: NostrPrefix.Event,
id,
relays,
kind,
author,
encode,
};
} else if (isPrefix(NostrPrefix.Address)) {
return {
type: NostrPrefix.Address,
id,
relays,
kind,
author,
encode,
};
}
} else if (prefixHint) {
} else if (isPrefix(NostrPrefix.Address)) {
return {
type: prefixHint,
id: link,
encode: () => hexToBech32(prefixHint, link),
type: NostrPrefix.Address,
id,
relays,
kind,
author,
encode,
};
}
throw new Error("Invalid nostr link");
} else if (prefixHint) {
return {
type: prefixHint,
id: link,
encode: () => hexToBech32(prefixHint, link),
};
}
throw new Error("Invalid nostr link");
}

View File

@ -17,7 +17,7 @@ import {
UserRelaysCache,
RelayMetricCache,
db,
UsersRelays
UsersRelays,
} from ".";
/**
@ -67,10 +67,10 @@ export class NostrSystem extends ExternalStore<SystemSnapshot> implements System
#relayMetrics: RelayMetricHandler;
constructor(props: {
authHandler?: AuthHandler,
relayCache?: FeedCache<UsersRelays>,
profileCache?: FeedCache<MetadataCache>
relayMetrics?: FeedCache<RelayMetrics>
authHandler?: AuthHandler;
relayCache?: FeedCache<UsersRelays>;
profileCache?: FeedCache<MetadataCache>;
relayMetrics?: FeedCache<RelayMetrics>;
}) {
super();
this.#handleAuth = props.authHandler;
@ -99,11 +99,7 @@ export class NostrSystem extends ExternalStore<SystemSnapshot> implements System
*/
async Init() {
db.ready = await db.isAvailable();
const t = [
this.#relayCache.preload(),
this.#profileCache.preload(),
this.#relayMetricsCache.preload()
];
const t = [this.#relayCache.preload(), this.#profileCache.preload(), this.#relayMetricsCache.preload()];
await Promise.all(t);
}
@ -118,8 +114,8 @@ export class NostrSystem extends ExternalStore<SystemSnapshot> implements System
this.#sockets.set(addr, c);
c.OnEvent = (s, e) => this.OnEvent(s, e);
c.OnEose = s => this.OnEndOfStoredEvents(c, s);
c.OnDisconnect = (code) => this.OnRelayDisconnect(c, code);
c.OnConnected = (r) => this.OnRelayConnected(c, r);
c.OnDisconnect = code => this.OnRelayDisconnect(c, code);
c.OnConnected = r => this.OnRelayConnected(c, r);
await c.Connect();
} else {
// update settings if already connected
@ -170,7 +166,7 @@ export class NostrSystem extends ExternalStore<SystemSnapshot> implements System
c.OnEvent = (s, e) => this.OnEvent(s, e);
c.OnEose = s => this.OnEndOfStoredEvents(c, s);
c.OnDisconnect = code => this.OnRelayDisconnect(c, code);
c.OnConnected = (r) => this.OnRelayConnected(c, r);
c.OnConnected = r => this.OnRelayConnected(c, r);
await c.Connect();
return c;
}
@ -194,7 +190,7 @@ export class NostrSystem extends ExternalStore<SystemSnapshot> implements System
return this.Queries.get(id);
}
Query<T extends NoteStore>(type: { new(): T }, req: RequestBuilder): Query {
Query<T extends NoteStore>(type: { new (): T }, req: RequestBuilder): Query {
const existing = this.Queries.get(req.id);
if (existing) {
// if same instance, just return query

View File

@ -38,21 +38,21 @@ export type ReqCommand = [cmd: "REQ", id: string, ...filters: Array<ReqFilter>];
* Raw REQ filter object
*/
export interface ReqFilter {
ids?: u256[]
authors?: u256[]
kinds?: number[]
"#e"?: u256[]
"#p"?: u256[]
"#t"?: string[]
"#d"?: string[]
"#r"?: string[]
"#a"?: string[]
"#g"?: string[]
search?: string
since?: number
until?: number
limit?: number
[key: string]: Array<string> | Array<number> | string | number | undefined
ids?: u256[];
authors?: u256[];
kinds?: number[];
"#e"?: u256[];
"#p"?: u256[];
"#t"?: string[];
"#d"?: string[];
"#r"?: string[];
"#a"?: string[];
"#g"?: string[];
search?: string;
since?: number;
until?: number;
limit?: number;
[key: string]: Array<string> | Array<number> | string | number | undefined;
}
/**

View File

@ -249,8 +249,8 @@ export class ReplaceableNoteStore extends HookedNoteStore<Readonly<TaggedRawEven
*/
export class NoteCollection extends KeyedReplaceableNoteStore {
constructor() {
super((e) => {
const legacyReplaceable = [0, 3, 41]
super(e => {
const legacyReplaceable = [0, 3, 41];
if (e.kind >= 30_000 && e.kind < 40_000) {
return `${e.kind}:${e.pubkey}:${findTag(e, "d")}`; // Parameterized replaceable
} else if (e.kind >= 10_000 && e.kind < 20_000) {
@ -263,6 +263,6 @@ export class NoteCollection extends KeyedReplaceableNoteStore {
// unknown kind
return e.id;
}
})
});
}
}
}

View File

@ -1,13 +1,10 @@
import debug from "debug";
import { unixNowMs, FeedCache } from "@snort/shared";
import { EventKind, HexKey, SystemInterface, TaggedRawEvent, NoteCollection, RequestBuilder } from ".";
import { ProfileCacheExpire } from "./const";
import { mapEventToProfile, MetadataCache } from "./cache";
const MetadataRelays = [
"wss://purplepag.es"
]
const MetadataRelays = ["wss://purplepag.es"];
export class ProfileLoaderService {
#system: SystemInterface;
@ -88,7 +85,8 @@ export class ProfileLoaderService {
.authors([...missing]);
if (this.#missingLastRun.size > 0) {
const fMissing = sub.withFilter()
const fMissing = sub
.withFilter()
.kinds([EventKind.SetMetadata])
.authors([...this.#missingLastRun]);
MetadataRelays.forEach(r => fMissing.relay(r));

View File

@ -3,11 +3,10 @@ import debug from "debug";
import { unixNowMs, unwrap } from "@snort/shared";
import { Connection, ReqFilter, Nips, TaggedRawEvent } from ".";
import { reqFilterEq } from "./utils";
import { NoteStore } from "./note-collection";
import { flatMerge } from "./request-merger";
import { BuiltRawReqFilter } from "./request-builder";
import { expandFilter } from "./request-expander";
import { FlatReqFilter, expandFilter } from "./request-expander";
/**
* Tracing for relay query status
@ -19,6 +18,7 @@ class QueryTrace {
eose?: number;
close?: number;
#wasForceClosed = false;
readonly flatFilters: Array<FlatReqFilter>;
readonly #fnClose: (id: string) => void;
readonly #fnProgress: () => void;
@ -33,6 +33,7 @@ class QueryTrace {
this.start = unixNowMs();
this.#fnClose = fnClose;
this.#fnProgress = fnProgress;
this.flatFilters = filters.flatMap(expandFilter);
}
sentToRelay() {
@ -168,13 +169,7 @@ export class Query implements QueryBase {
}
get flatFilters() {
const f: Array<ReqFilter> = [];
for (const x of this.#tracing.flatMap(a => a.filters)) {
if (!f.some(a => reqFilterEq(a, x))) {
f.push(x);
}
}
return f.flatMap(expandFilter);
return this.#tracing.flatMap(a => a.flatFilters);
}
get feed() {

View File

@ -3,13 +3,11 @@ import { Connection } from "connection";
import { RelayMetrics } from "cache";
export class RelayMetricHandler {
readonly #cache: FeedCache<RelayMetrics>;
readonly #cache: FeedCache<RelayMetrics>;
constructor(cache: FeedCache<RelayMetrics>) {
this.#cache = cache;
}
constructor(cache: FeedCache<RelayMetrics>) {
this.#cache = cache;
}
onDisconnect(c: Connection, code: number) {
}
}
onDisconnect(c: Connection, code: number) {}
}

View File

@ -108,10 +108,9 @@ export class RequestBuilder {
const next = this.#builders.flatMap(f => expandFilter(f.filter));
const diff = diffFilters(prev, next);
const ts = (unixNowMs() - start);
const ts = unixNowMs() - start;
this.#log("buildDiff %s %d ms", this.id, ts);
if (diff.changed) {
this.#log(diff);
return splitAllByWriteRelays(relays, diff.added).map(a => {
return {
strategy: RequestStrategy.AuthorsRelays,

View File

@ -1,19 +1,19 @@
import { ReqFilter } from "./nostr";
export interface FlatReqFilter {
keys: number
ids?: string
authors?: string
kinds?: number
"#e"?: string
"#p"?: string
"#t"?: string
"#d"?: string
"#r"?: string
search?: string
since?: number
until?: number
limit?: number
keys: number;
ids?: string;
authors?: string;
kinds?: number;
"#e"?: string;
"#p"?: string;
"#t"?: string;
"#d"?: string;
"#r"?: string;
search?: string;
since?: number;
until?: number;
limit?: number;
}
/**

View File

@ -114,7 +114,7 @@ export function flatMerge(all: Array<FlatReqFilter>): Array<ReqFilter> {
acc[k].push(v);
}
}
})
});
return acc;
}, {} as any) as ReqFilter;
}

View File

@ -28,8 +28,8 @@ export function diffFilters(prev: Array<FlatReqFilter>, next: Array<FlatReqFilte
}
const changed = added.length > 0 || removed.length > 0;
return {
added: changed ? flatMerge(added) : [],
removed: changed ? flatMerge(removed) : [],
added: changed ? added : [],
removed: changed ? removed : [],
changed,
};
}

View File

@ -1,42 +1,45 @@
import { equalProp } from "@snort/shared";
import { FlatReqFilter } from "./request-expander";
import { NostrEvent, ReqFilter } from "./nostr";
export function findTag(e: NostrEvent, tag: string) {
const maybeTag = e.tags.find(evTag => {
return evTag[0] === tag;
});
return maybeTag && maybeTag[1];
const maybeTag = e.tags.find(evTag => {
return evTag[0] === tag;
});
return maybeTag && maybeTag[1];
}
export function reqFilterEq(a: FlatReqFilter | ReqFilter, b: FlatReqFilter | ReqFilter): boolean {
return equalProp(a.ids, b.ids)
&& equalProp(a.kinds, b.kinds)
&& equalProp(a.authors, b.authors)
&& equalProp(a.limit, b.limit)
&& equalProp(a.since, b.since)
&& equalProp(a.until, b.until)
&& equalProp(a.search, b.search)
&& equalProp(a["#e"], b["#e"])
&& equalProp(a["#p"], b["#p"])
&& equalProp(a["#t"], b["#t"])
&& equalProp(a["#d"], b["#d"])
&& equalProp(a["#r"], b["#r"]);
return (
equalProp(a.ids, b.ids) &&
equalProp(a.kinds, b.kinds) &&
equalProp(a.authors, b.authors) &&
equalProp(a.limit, b.limit) &&
equalProp(a.since, b.since) &&
equalProp(a.until, b.until) &&
equalProp(a.search, b.search) &&
equalProp(a["#e"], b["#e"]) &&
equalProp(a["#p"], b["#p"]) &&
equalProp(a["#t"], b["#t"]) &&
equalProp(a["#d"], b["#d"]) &&
equalProp(a["#r"], b["#r"])
);
}
export function flatFilterEq(a: FlatReqFilter, b: FlatReqFilter): boolean {
return a.keys === b.keys
&& a.since === b.since
&& a.until === b.until
&& a.limit === b.limit
&& a.search === b.search
&& a.ids === b.ids
&& a.kinds === b.kinds
&& a.authors === b.authors
&& a["#e"] === b["#e"]
&& a["#p"] === b["#p"]
&& a["#t"] === b["#t"]
&& a["#d"] === b["#d"]
&& a["#r"] === b["#r"];
}
return (
a.keys === b.keys &&
a.since === b.since &&
a.until === b.until &&
a.limit === b.limit &&
a.search === b.search &&
a.ids === b.ids &&
a.kinds === b.kinds &&
a.authors === b.authors &&
a["#e"] === b["#e"] &&
a["#p"] === b["#p"] &&
a["#t"] === b["#t"] &&
a["#d"] === b["#d"] &&
a["#r"] === b["#r"]
);
}

View File

@ -5,88 +5,88 @@ import { findTag } from "./utils";
import { MetadataCache } from "./cache";
function getInvoice(zap: NostrEvent): InvoiceDetails | undefined {
const bolt11 = findTag(zap, "bolt11");
if (!bolt11) {
throw new Error("Invalid zap, missing bolt11 tag");
}
return decodeInvoice(bolt11);
const bolt11 = findTag(zap, "bolt11");
if (!bolt11) {
throw new Error("Invalid zap, missing bolt11 tag");
}
return decodeInvoice(bolt11);
}
export function parseZap(zapReceipt: NostrEvent, userCache: FeedCache<MetadataCache>, refNote?: NostrEvent): ParsedZap {
let innerZapJson = findTag(zapReceipt, "description");
if (innerZapJson) {
try {
const invoice = getInvoice(zapReceipt);
if (innerZapJson.startsWith("%")) {
innerZapJson = decodeURIComponent(innerZapJson);
}
const zapRequest: NostrEvent = JSON.parse(innerZapJson);
if (Array.isArray(zapRequest)) {
// old format, ignored
throw new Error("deprecated zap format");
}
const isForwardedZap = refNote?.tags.some(a => a[0] === "zap") ?? false;
const anonZap = zapRequest.tags.find(a => a[0] === "anon");
const metaHash = sha256(innerZapJson);
const pollOpt = zapRequest.tags.find(a => a[0] === "poll_option")?.[1];
const ret: ParsedZap = {
id: zapReceipt.id,
zapService: zapReceipt.pubkey,
amount: (invoice?.amount ?? 0) / 1000,
event: findTag(zapRequest, "e"),
sender: zapRequest.pubkey,
receiver: findTag(zapRequest, "p"),
valid: true,
anonZap: anonZap !== undefined,
content: zapRequest.content,
errors: [],
pollOption: pollOpt ? Number(pollOpt) : undefined,
};
if (invoice?.descriptionHash !== metaHash) {
ret.valid = false;
ret.errors.push("description_hash does not match zap request");
}
if (findTag(zapRequest, "p") !== findTag(zapReceipt, "p")) {
ret.valid = false;
ret.errors.push("p tags dont match");
}
if (ret.event && ret.event !== findTag(zapReceipt, "e")) {
ret.valid = false;
ret.errors.push("e tags dont match");
}
if (findTag(zapRequest, "amount") === invoice?.amount) {
ret.valid = false;
ret.errors.push("amount tag does not match invoice amount");
}
if (userCache.getFromCache(ret.receiver)?.zapService !== ret.zapService && !isForwardedZap) {
ret.valid = false;
ret.errors.push("zap service pubkey doesn't match");
}
return ret;
} catch (e) {
// ignored: console.debug("Invalid zap", zapReceipt, e);
}
}
return {
let innerZapJson = findTag(zapReceipt, "description");
if (innerZapJson) {
try {
const invoice = getInvoice(zapReceipt);
if (innerZapJson.startsWith("%")) {
innerZapJson = decodeURIComponent(innerZapJson);
}
const zapRequest: NostrEvent = JSON.parse(innerZapJson);
if (Array.isArray(zapRequest)) {
// old format, ignored
throw new Error("deprecated zap format");
}
const isForwardedZap = refNote?.tags.some(a => a[0] === "zap") ?? false;
const anonZap = zapRequest.tags.find(a => a[0] === "anon");
const metaHash = sha256(innerZapJson);
const pollOpt = zapRequest.tags.find(a => a[0] === "poll_option")?.[1];
const ret: ParsedZap = {
id: zapReceipt.id,
zapService: zapReceipt.pubkey,
amount: 0,
valid: false,
anonZap: false,
errors: ["invalid zap, parsing failed"],
};
amount: (invoice?.amount ?? 0) / 1000,
event: findTag(zapRequest, "e"),
sender: zapRequest.pubkey,
receiver: findTag(zapRequest, "p"),
valid: true,
anonZap: anonZap !== undefined,
content: zapRequest.content,
errors: [],
pollOption: pollOpt ? Number(pollOpt) : undefined,
};
if (invoice?.descriptionHash !== metaHash) {
ret.valid = false;
ret.errors.push("description_hash does not match zap request");
}
if (findTag(zapRequest, "p") !== findTag(zapReceipt, "p")) {
ret.valid = false;
ret.errors.push("p tags dont match");
}
if (ret.event && ret.event !== findTag(zapReceipt, "e")) {
ret.valid = false;
ret.errors.push("e tags dont match");
}
if (findTag(zapRequest, "amount") === invoice?.amount) {
ret.valid = false;
ret.errors.push("amount tag does not match invoice amount");
}
if (userCache.getFromCache(ret.receiver)?.zapService !== ret.zapService && !isForwardedZap) {
ret.valid = false;
ret.errors.push("zap service pubkey doesn't match");
}
return ret;
} catch (e) {
// ignored: console.debug("Invalid zap", zapReceipt, e);
}
}
return {
id: zapReceipt.id,
zapService: zapReceipt.pubkey,
amount: 0,
valid: false,
anonZap: false,
errors: ["invalid zap, parsing failed"],
};
}
export interface ParsedZap {
id: HexKey;
event?: HexKey;
receiver?: HexKey;
amount: number;
content?: string;
sender?: HexKey;
valid: boolean;
zapService: HexKey;
anonZap: boolean;
errors: Array<string>;
pollOption?: number;
id: HexKey;
event?: HexKey;
receiver?: HexKey;
amount: number;
content?: string;
sender?: HexKey;
valid: boolean;
zapService: HexKey;
anonZap: boolean;
errors: Array<string>;
pollOption?: number;
}

View File

@ -1,62 +1,62 @@
import { EventExt } from "../src/EventExt";
describe("NIP-10", () => {
it("should extract thread", () => {
const a = {
content: "This is the problem with Lightning....",
id: "868187063f...",
kind: 1,
created_at: 1,
pubkey: "test",
sig: "test",
"tags": [
["e", "cbf2375078..."],
["e", "977ac5d3b6..."],
["e", "8f99ca1363..."],
]
}
it("should extract thread", () => {
const a = {
content: "This is the problem with Lightning....",
id: "868187063f...",
kind: 1,
created_at: 1,
pubkey: "test",
sig: "test",
tags: [
["e", "cbf2375078..."],
["e", "977ac5d3b6..."],
["e", "8f99ca1363..."],
],
};
const b = {
"content": "This is a good point, but your ...",
"id": "434ad4a646...",
kind: 1,
created_at: 1,
pubkey: "test",
sig: "test",
"tags": [
["e", "cbf2375078..."],
["e", "868187063f..."],
["e", "6834ffc491..."],
]
}
const b = {
content: "This is a good point, but your ...",
id: "434ad4a646...",
kind: 1,
created_at: 1,
pubkey: "test",
sig: "test",
tags: [
["e", "cbf2375078..."],
["e", "868187063f..."],
["e", "6834ffc491..."],
],
};
const c = {
"content": "There is some middle ground ...",
"id": "6834ffc491...",
kind: 1,
created_at: 1,
pubkey: "test",
sig: "test",
"tags": [
["e", "cbf2375078...", "", "root"],
["e", "868187063f...", "", "reply"],
]
}
const c = {
content: "There is some middle ground ...",
id: "6834ffc491...",
kind: 1,
created_at: 1,
pubkey: "test",
sig: "test",
tags: [
["e", "cbf2375078...", "", "root"],
["e", "868187063f...", "", "reply"],
],
};
expect(EventExt.extractThread(a)).toMatchObject({
root: { key: "e", value: "cbf2375078...", marker: "root" },
replyTo: { key: "e", value: "8f99ca1363...", marker: "reply" },
mentions: [{ key: "e", value: "977ac5d3b6...", marker: "mention" }]
})
expect(EventExt.extractThread(b)).toMatchObject({
root: { key: "e", value: "cbf2375078...", marker: "root" },
replyTo: { key: "e", value: "6834ffc491...", marker: "reply" },
mentions: [{ key: "e", value: "868187063f...", marker: "mention" }]
})
expect(EventExt.extractThread(c)).toMatchObject({
root: { key: "e", value: "cbf2375078...", relay: "", marker: "root" },
replyTo: { key: "e", value: "868187063f...", relay: "", marker: "reply" },
mentions: []
})
})
})
expect(EventExt.extractThread(a)).toMatchObject({
root: { key: "e", value: "cbf2375078...", marker: "root" },
replyTo: { key: "e", value: "8f99ca1363...", marker: "reply" },
mentions: [{ key: "e", value: "977ac5d3b6...", marker: "mention" }],
});
expect(EventExt.extractThread(b)).toMatchObject({
root: { key: "e", value: "cbf2375078...", marker: "root" },
replyTo: { key: "e", value: "6834ffc491...", marker: "reply" },
mentions: [{ key: "e", value: "868187063f...", marker: "mention" }],
});
expect(EventExt.extractThread(c)).toMatchObject({
root: { key: "e", value: "cbf2375078...", relay: "", marker: "root" },
replyTo: { key: "e", value: "868187063f...", relay: "", marker: "reply" },
mentions: [],
});
});
});

View File

@ -1,37 +1,33 @@
import { splitAllByWriteRelays } from "../src/GossipModel"
import { splitAllByWriteRelays } from "../src/GossipModel";
describe("GossipModel", () => {
it("should not output empty", () => {
const Relays = {
getFromCache: (pk?: string) => {
if (pk) {
return {
pubkey: pk,
created_at: 0,
relays: []
};
}
}
it("should not output empty", () => {
const Relays = {
getFromCache: (pk?: string) => {
if (pk) {
return {
pubkey: pk,
created_at: 0,
relays: [],
};
}
const a = [{
"until": 1686651693,
"limit": 200,
"kinds": [
1,
6,
6969
],
"authors": [
"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"
]
}];
},
};
const a = [
{
until: 1686651693,
limit: 200,
kinds: [1, 6, 6969],
authors: ["3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"],
},
];
const output = splitAllByWriteRelays(Relays, a);
expect(output).toEqual([
{
relay: "",
filters: a
}
])
})
})
const output = splitAllByWriteRelays(Relays, a);
expect(output).toEqual([
{
relay: "",
filters: a,
},
]);
});
});

View File

@ -1,4 +1,3 @@
import { schnorr, secp256k1 } from "@noble/curves/secp256k1";
import { Nip4WebCryptoEncryptor } from "../src/impl/nip4";
import { Nip44Encryptor } from "../src/impl/nip44";
@ -10,36 +9,36 @@ const bKey = secp256k1.utils.randomPrivateKey();
const bPubKey = schnorr.getPublicKey(bKey);
describe("NIP-04", () => {
it("should encrypt/decrypt", async () => {
const msg = "test hello, 123";
const enc = new Nip4WebCryptoEncryptor();
const sec = enc.getSharedSecret(bytesToHex(aKey), bytesToHex(bPubKey));
it("should encrypt/decrypt", async () => {
const msg = "test hello, 123";
const enc = new Nip4WebCryptoEncryptor();
const sec = enc.getSharedSecret(bytesToHex(aKey), bytesToHex(bPubKey));
const ciphertext = await enc.encryptData(msg, sec);
expect(ciphertext).toMatch(/^.*\?iv=.*$/i);
const ciphertext = await enc.encryptData(msg, sec);
expect(ciphertext).toMatch(/^.*\?iv=.*$/i);
const dec = new Nip4WebCryptoEncryptor();
const sec2 = enc.getSharedSecret(bytesToHex(bKey), bytesToHex(aPubKey));
const plaintext = await dec.decryptData(ciphertext, sec2);
expect(plaintext).toEqual(msg);
})
})
const dec = new Nip4WebCryptoEncryptor();
const sec2 = enc.getSharedSecret(bytesToHex(bKey), bytesToHex(aPubKey));
const plaintext = await dec.decryptData(ciphertext, sec2);
expect(plaintext).toEqual(msg);
});
});
describe("NIP-44", () => {
it("should encrypt/decrypt", () => {
const msg = "test hello, 123";
const enc = new Nip44Encryptor();
const sec = enc.getSharedSecret(bytesToHex(aKey), bytesToHex(bPubKey));
it("should encrypt/decrypt", () => {
const msg = "test hello, 123";
const enc = new Nip44Encryptor();
const sec = enc.getSharedSecret(bytesToHex(aKey), bytesToHex(bPubKey));
const ciphertext = enc.encryptData(msg, sec);
const jObj = JSON.parse(ciphertext);
expect(jObj).toHaveProperty("ciphertext")
expect(jObj).toHaveProperty("nonce")
expect(jObj.v).toBe(1);
const ciphertext = enc.encryptData(msg, sec);
const jObj = JSON.parse(ciphertext);
expect(jObj).toHaveProperty("ciphertext");
expect(jObj).toHaveProperty("nonce");
expect(jObj.v).toBe(1);
const dec = new Nip44Encryptor();
const sec2 = enc.getSharedSecret(bytesToHex(bKey), bytesToHex(aPubKey));
const plaintext = dec.decryptData(ciphertext, sec2);
expect(plaintext).toEqual(msg);
})
})
const dec = new Nip44Encryptor();
const sec2 = enc.getSharedSecret(bytesToHex(bKey), bytesToHex(aPubKey));
const plaintext = dec.decryptData(ciphertext, sec2);
expect(plaintext).toEqual(msg);
});
});

View File

@ -20,7 +20,7 @@ const DummyCache = {
write: true,
},
},
]
],
};
},
} as RelayCache;
@ -181,23 +181,24 @@ describe("build diff, large follow list", () => {
const start = unixNowMs();
const a = rb.build(DummyCache);
expect(a).toEqual(f.map(a => {
return {
strategy: RequestStrategy.AuthorsRelays,
relay: `wss://${a}.com/`,
filters: [
{
kinds: [1, 6, 10002, 3, 6969],
authors: [a],
}
],
}
}));
expect(a).toEqual(
f.map(a => {
return {
strategy: RequestStrategy.AuthorsRelays,
relay: `wss://${a}.com/`,
filters: [
{
kinds: [1, 6, 10002, 3, 6969],
authors: [a],
},
],
};
})
);
expect(unixNowMs() - start).toBeLessThan(500);
const start2 = unixNowMs();
const b = rb.buildDiff(DummyCache, rb.buildRaw().flatMap(expandFilter));
expect(b).toEqual([]);
expect(unixNowMs() - start2).toBeLessThan(100);
})
});

View File

@ -108,50 +108,50 @@ describe("flatMerge", () => {
});
});
describe('canMerge', () => {
describe("canMerge", () => {
it("should have 0 distance", () => {
const a = {
ids: "a",
keys: 1
keys: 1,
};
const b = {
ids: "a",
keys: 1
keys: 1,
};
expect(canMergeFilters(a, b)).toEqual(true);
});
it("should have 1 distance", () => {
const a = {
ids: "a",
keys: 1
keys: 1,
};
const b = {
ids: "b",
keys: 1
keys: 1,
};
expect(canMergeFilters(a, b)).toEqual(true);
});
it("should have 10 distance", () => {
const a = {
ids: "a",
keys: 1
keys: 1,
};
const b = {
ids: "a",
kinds: 1,
keys: 2
keys: 2,
};
expect(canMergeFilters(a, b)).toEqual(false);
});
it("should have 11 distance", () => {
const a = {
ids: "a",
keys: 1
keys: 1,
};
const b = {
ids: "b",
kinds: 1,
keys: 2
keys: 2,
};
expect(canMergeFilters(a, b)).toEqual(false);
});
@ -160,13 +160,13 @@ describe('canMerge', () => {
since: 1,
until: 100,
kinds: [1],
authors: ["kieran", "snort", "c", "d", "e"]
authors: ["kieran", "snort", "c", "d", "e"],
};
const b = {
since: 1,
until: 100,
kinds: [6969],
authors: ["kieran", "snort", "c", "d", "e"]
authors: ["kieran", "snort", "c", "d", "e"],
};
expect(canMergeFilters(a, b)).toEqual(true);
});
@ -175,14 +175,14 @@ describe('canMerge', () => {
since: 1,
until: 100,
kinds: [1],
authors: ["f", "kieran", "snort", "c", "d"]
authors: ["f", "kieran", "snort", "c", "d"],
};
const b = {
since: 1,
until: 100,
kinds: [1],
authors: ["kieran", "snort", "c", "d", "e"]
authors: ["kieran", "snort", "c", "d", "e"],
};
expect(canMergeFilters(a, b)).toEqual(true);
});
})
});

View File

@ -2,4 +2,4 @@ import { TextEncoder, TextDecoder } from "util";
import { Crypto } from "@peculiar/webcrypto";
Object.assign(global, { TextDecoder, TextEncoder });
Object.assign(globalThis.window.crypto, new Crypto());
Object.assign(globalThis.window.crypto, new Crypto());

View File

@ -22,10 +22,7 @@
"depends": []
},
"externalBin": [],
"icon": [
"icons/128x128.png",
"icons/128x128@2x.png"
],
"icon": ["icons/128x128.png", "icons/128x128@2x.png"],
"identifier": "social.snort.app",
"longDescription": "",
"macOS": {