This commit is contained in:
2022-12-18 14:51:47 +00:00
parent 968ea78077
commit e6ef1a5bc9
35 changed files with 10237 additions and 0 deletions

96
src/nostr/Connection.js Normal file
View File

@ -0,0 +1,96 @@
import { Subscriptions } from "./Subscriptions";
import Event from "./Event";
export default class Connection {
constructor(addr) {
this.Address = addr;
this.Socket = new WebSocket(addr);
this.Socket.onopen = (e) => this.OnOpen(e);
this.Socket.onmessage = (e) => this.OnMessage(e);
this.Socket.onerror = (e) => this.OnError(e);
this.Pending = [];
this.Subscriptions = {};
}
OnOpen(e) {
console.log(`Opened connection to: ${this.Address}`);
console.log(e);
// send pending
for (let p of this.Pending) {
this._SendJson(p);
}
}
OnMessage(e) {
let msg = JSON.parse(e.data);
let tag = msg[0];
switch (tag) {
case "EVENT": {
this._OnEvent(msg[1], msg[2]);
break;
}
case "EOSE": {
// ignored for now
break;
}
default: {
console.warn(`Unknown tag: ${tag}`);
break;
}
}
}
OnError(e) {
console.log(e);
}
SendEvent(e) {
let req = ["EVENT", e];
this._SendJson(req);
}
/**
* Subscribe to data from this connection
* @param {Subscriptions | Array<Subscriptions>} sub Subscriptions object
*/
AddSubscription(sub) {
let req = ["REQ", sub.Id, sub.ToObject()];
if(sub.OrSubs.length > 0) {
req = [
...req,
...sub.OrSubs.map(o => o.ToObject())
];
}
this._SendJson(req);
this.Subscriptions[sub.Id] = sub;
}
/**
* Remove a subscription
* @param {any} subId Subscription id to remove
*/
RemoveSubscription(subId) {
let req = ["CLOSE", subId];
this._SendJson(req);
delete this.Subscriptions[subId];
}
_SendJson(obj) {
if (this.Socket.readyState !== this.Socket.OPEN) {
this.Pending.push(obj);
return;
}
let json = JSON.stringify(obj);
console.debug(`[${this.Address}] >> ${json}`);
this.Socket.send(json);
}
_OnEvent(subId, ev) {
if (this.Subscriptions[subId]) {
this.Subscriptions[subId].OnEvent(ev);
} else {
console.warn("No subscription for event!");
}
}
}

138
src/nostr/Event.js Normal file
View File

@ -0,0 +1,138 @@
import * as secp from '@noble/secp256k1';
import EventKind from "./EventKind";
import Tag from './Tag';
import Thread from './Thread';
export default class Event {
constructor() {
/**
* Id of the event
* @type {string}
*/
this.Id = null;
/**
* Pub key of the creator
* @type {string}
*/
this.PubKey = null;
/**
* Timestamp when the event was created
* @type {number}
*/
this.CreatedAt = null;
/**
* The type of event
* @type {EventKind}
*/
this.Kind = null;
/**
* A list of metadata tags
* @type {Array<Tag>}
*/
this.Tags = [];
/**
* Content of the event
* @type {string}
*/
this.Content = null;
/**
* Signature of this event from the creator
* @type {string}
*/
this.Signature = null;
}
/**
* Sign this message with a private key
* @param {string} key Key to sign message with
*/
async Sign(key) {
this.Id = await this.CreateId();
let sig = await secp.schnorr.sign(this.Id, key);
this.Signature = secp.utils.bytesToHex(sig);
}
/**
* Check the signature of this message
* @returns True if valid signature
*/
async Verify() {
let id = await this.CreateId();
let result = await secp.schnorr.verify(this.Signature, id, this.PubKey);
return result;
}
async CreateId() {
let payload = [
0,
this.PubKey,
this.CreatedAt,
this.Kind,
this.Tags.map(a => a.ToObject()),
this.Content
];
let payloadData = new TextEncoder().encode(JSON.stringify(payload));
let data = await secp.utils.sha256(payloadData);
let hash = secp.utils.bytesToHex(data);
if(this.Id !== null && hash !== this.Id) {
console.debug(payload);
throw "ID doesnt match!";
}
return hash;
}
/**
* Get thread information
* @returns {Thread}
*/
GetThread() {
return Thread.ExtractThread(this);
}
/**
* Does this event have content
* @returns {boolean}
*/
IsContent() {
const ContentKinds = [
EventKind.TextNote
];
return ContentKinds.includes(this.Kind);
}
static FromObject(obj) {
if(typeof obj !== "object") {
return null;
}
let ret = new Event();
ret.Id = obj.id;
ret.PubKey = obj.pubkey;
ret.CreatedAt = obj.created_at;
ret.Kind = obj.kind;
ret.Tags = obj.tags.map(e => new Tag(e));
ret.Content = obj.content;
ret.Signature = obj.sig;
return ret;
}
ToObject() {
return {
id: this.Id,
pubkey: this.PubKey,
created_at: this.CreatedAt,
kind: this.Kind,
tags: this.Tags.map(a => a.ToObject()),
content: this.Content,
sig: this.Signature
};
}
}

11
src/nostr/EventKind.js Normal file
View File

@ -0,0 +1,11 @@
const EventKind = {
Unknown: -1,
SetMetadata: 0,
TextNote: 1,
RecommendServer: 2,
ContactList: 3, // NIP-02
DirectMessage: 4, // NIP-04
Reaction: 7 // NIP-25
};
export default EventKind;

110
src/nostr/Subscriptions.js Normal file
View File

@ -0,0 +1,110 @@
import { v4 as uuid } from "uuid";
export class Subscriptions {
constructor() {
/**
* A unique id for this subscription filter
*/
this.Id = uuid();
/**
* a list of event ids or prefixes
*/
this.Ids = new Set();
/**
* a list of pubkeys or prefixes, the pubkey of an event must be one of these
*/
this.Authors = new Set();
/**
* a list of a kind numbers
*/
this.Kinds = new Set();
/**
* a list of event ids that are referenced in an "e" tag
*/
this.ETags = new Set();
/**
* a list of pubkeys that are referenced in a "p" tag
*/
this.PTags = new Set();
/**
* a timestamp, events must be newer than this to pass
*/
this.Since = NaN;
/**
* a timestamp, events must be older than this to pass
*/
this.Until = NaN;
/**
* maximum number of events to be returned in the initial query
*/
this.Limit = NaN;
/**
* Handler function for this event
*/
this.OnEvent = (e) => { console.warn(`No event handler was set on subscription: ${this.Id}`) };
/**
* Collection of OR sub scriptions linked to this
*/
this.OrSubs = [];
}
/**
* Adds OR filter subscriptions
* @param {Subscriptions} sub Extra filters
*/
AddSubscription(sub) {
this.OrSubs.push(sub);
}
static FromObject(obj) {
let ret = new Subscriptions();
ret.Ids = new Set(obj.ids);
ret.Authors = new Set(obj.authors);
ret.Kinds = new Set(obj.kinds);
ret.ETags = new Set(obj["#e"]);
ret.PTags = new Set(obj["#p"]);
ret.Since = parseInt(obj.since);
ret.Until = parseInt(obj.until);
ret.Limit = parseInt(obj.limit);
return ret;
}
ToObject() {
let ret = {};
if (this.Ids.size > 0) {
ret.ids = Array.from(this.Ids);
}
if (this.Authors.size > 0) {
ret.authors = Array.from(this.Authors);
}
if (this.Kinds.size > 0) {
ret.kinds = Array.from(this.Kinds);
}
if (this.ETags.size > 0) {
ret["#e"] = Array.from(this.ETags);
}
if (this.PTags.size > 0) {
ret["#p"] = Array.from(this.PTags);
}
if (!isNaN(this.Since)) {
ret.since = this.Since;
}
if (!isNaN(this.Until)) {
ret.until = this.Until;
}
if (!isNaN(this.Limit)) {
ret.limit = this.Limit;
}
return ret;
}
}

46
src/nostr/System.js Normal file
View File

@ -0,0 +1,46 @@
import { useSelector } from "react-redux";
import Connection from "./Connection";
/**
* Manages nostr content retrival system
*/
export class NostrSystem {
constructor() {
this.Sockets = {};
this.Subscriptions = {};
}
/**
* Connect to a NOSTR relay if not already connected
* @param {string} address
*/
ConnectToRelay(address) {
if(typeof this.Sockets[address] === "undefined") {
let c = new Connection(address);
for(let s of Object.values(this.Subscriptions)) {
c.AddSubscription(s);
}
this.Sockets[address] = c;
}
}
AddSubscription(sub) {
for(let s of Object.values(this.Sockets)) {
s.AddSubscription(sub);
}
this.Subscriptions[sub.Id] = sub;
}
RemoveSubscription(subId) {
for(let s of Object.values(this.Sockets)) {
s.RemoveSubscription(subId);
}
delete this.Subscriptions[subId];
}
BroadcastEvent(ev) {
for(let s of Object.values(this.Sockets)) {
s.SendEvent(ev);
}
}
}

36
src/nostr/Tag.js Normal file
View File

@ -0,0 +1,36 @@
export default class Tag {
constructor(tag) {
this.Key = tag[0];
this.Event = null;
this.PubKey = null;
this.Relay = null;
this.Marker = null;
switch (this.Key) {
case "e": {
// ["e", <event-id>, <relay-url>, <marker>]
this.Event = tag[1];
this.Relay = tag.length > 2 ? tag[2] : null;
this.Marker = tag.length > 3 ? tag[3] : null;
break;
}
case "p": {
// ["p", <pubkey>]
this.PubKey = tag[1];
break;
}
}
}
ToObject() {
switch(this.Key) {
case "e": {
return ["e", this.Event, this.Relay, this.Marker].filter(a => a !== null);
}
case "p": {
return ["p", this.PubKey];
}
}
return null;
}
}

40
src/nostr/Thread.js Normal file
View File

@ -0,0 +1,40 @@
import Event from "./Event";
export default class Thread {
constructor() {
this.Root = null;
this.ReplyTo = null;
this.Mentions = [];
this.Reply = null;
}
/**
* Extract thread information from an Event
* @param {Event} ev Event to extract thread from
*/
static ExtractThread(ev) {
let isThread = ev.Tags.some(a => a.Key === "e");
if (!isThread) {
return null;
}
let ret = new Thread();
ret.Reply = ev;
let eTags = ev.Tags.filter(a => a.Key === "e");
let marked = eTags.some(a => a.Marker !== null);
if (!marked) {
ret.Root = eTags[0];
if (eTags.length > 2) {
ret.Mentions = eTags.slice(1, -1);
}
ret.ReplyTo = eTags[eTags.length - 1];
} else {
let root = eTags.find(a => a.Marker === "root");
let reply = eTags.find(a => a.Marker === "reply");
ret.Root = root;
ret.ReplyTo = reply;
}
return ret;
}
}