From 01c9473f3545914ba57e264d6d712f006aa205d0 Mon Sep 17 00:00:00 2001 From: fiatjaf Date: Wed, 6 Dec 2023 15:18:16 -0300 Subject: [PATCH] NIP-42 AUTH and internal queries support. --- README.md | 24 +++++++++-- go.mod | 2 +- go.sum | 4 +- main.go | 10 +++-- quickjs.go | 115 +++++++++++++++++++++++++++++++++++++++++++++++++++++ reject.go | 115 ++++++++++++++++++++++++++--------------------------- 6 files changed, 201 insertions(+), 69 deletions(-) create mode 100644 quickjs.go diff --git a/README.md b/README.md index 7be2f76..a2cbf99 100644 --- a/README.md +++ b/README.md @@ -26,12 +26,30 @@ INF running on http://0.0.0.0:5577 This will create a `./data` and a `./stuff` directories under the current directory. - `./data` is where your database will be placed, with all the Nostr events and indexes. By default it will be an SQLite database under `./data/sqlite`, but you can also specify `--db lmdb` or `--db badger` to use different storage mechanisms. -- `./stuff` is where you should define your custom rules for rejecting events or queries and subscriptions. 2 JavaScript files will be created with example code in them, they are intended to be modified without having to restart the server. Other files also be put in this directory. See the reference: - - `reject-event.js`: this should `export default` a function that takes every incoming `event` as a parameter and returns a string with an error message when that event should be rejected and returns `null` or `undefined` when the event should be accepted. It can also return a `Promise` that resolves to one of these things. - - `reject-filter.js`: this is the same, but takes a `filter` as a parameter and should return an error string if that filter should be rejected. +- `./stuff` is where you should define your custom rules for rejecting events or queries and subscriptions. 2 JavaScript files will be created with example code in them, they are intended to be modified without having to restart the server. Other files can also be put in this directory. These are the possibilities: + - `reject-event.js`: this file should `export default` a function that is called on every `EVENT` message received should return a string with an error message when that event should be rejected and `null` or `undefined` when the event should be accepted. + - `reject-filter.js`: same as above, but refers to `REQ` messages instead. - `index.html` and other `.html` files: these will be served under the root of your relay HTTP server, if present, but they are not required. - `icon.png`, `icon.jpg` or `icon.gif`, if present, will be used as the relay NIP-11 icon. +### More about `reject-event.js` and `reject-filter.js` + +**Parameters** + +The functions exported from `reject-event.js` and `reject-filter.js` can also return `Promise(string)` or `Promise(null)` in order to reject or accept the requests, respectively. + +**Function parameters** + +They both take 3 parameters, in the following order: + - `event`: the event being written, for `reject-event.js`; or `filter`: the subscription filter, for `reject-filter.js`. + - `relay`: an object with some fields: + - `query()`, a function that can be called with any Nostr filter and will return an array of results with events (read from the local database) + - `authedUser`: either a string or `null`, if it's a string it will be the pubkey of a user who has performed `AUTH` with the relay + +**Authentication requests** + +The functions can prompt a client to authenticate using the NIP-42 flow anytime by return a string that starts with `"auth-required: "` (and then some human-readable message afterwards). If the client performs an authentication and make a new request, the next time the same request comes the third parameter, `authedUser`, will be set. + ### Other options Call `jingle --help` to see other possible options. All of these can also be set using environment variables. The most common ones will probably be `--name`, `--pubkey` and `--description`, used to set basic NIP-11 metadata for the relay. diff --git a/go.mod b/go.mod index a2bb32b..7293b22 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ toolchain go1.21.4 require ( github.com/fiatjaf/eventstore v0.2.11 - github.com/fiatjaf/khatru v0.0.15 + github.com/fiatjaf/khatru v0.1.0 github.com/fiatjaf/quickjs-go v0.3.1 github.com/hoisie/mustache v0.0.0-20160804235033-6375acf62c69 github.com/kelseyhightower/envconfig v1.4.0 diff --git a/go.sum b/go.sum index 8f7b086..7665211 100644 --- a/go.sum +++ b/go.sum @@ -41,8 +41,8 @@ github.com/fasthttp/websocket v1.5.3 h1:TPpQuLwJYfd4LJPXvHDYPMFWbLjsT91n3GpWtCQt github.com/fasthttp/websocket v1.5.3/go.mod h1:46gg/UBmTU1kUaTcwQXpUxtRwG2PvIZYeA8oL6vF3Fs= github.com/fiatjaf/eventstore v0.2.11 h1:hb3ImjJAX+aNvMd19sttbZD2PzgohRuZtOgVQLsBXZs= github.com/fiatjaf/eventstore v0.2.11/go.mod h1:Zx1XqwICh7RxxKLkgc0aXlVo298ABs4W5awP/1/bYYs= -github.com/fiatjaf/khatru v0.0.15 h1:BSbK85z9deMy6LpVsSasn7oCj13Bv5ZTtE3BoyezH68= -github.com/fiatjaf/khatru v0.0.15/go.mod h1:reXIM06zBXmFWwM1qp9mW6jCWjxTkEbtObVEPm0jOXE= +github.com/fiatjaf/khatru v0.1.0 h1:+TjGEIUO5ynFwDjLpaVWjtdQlPOp1swS3LkzciEC34E= +github.com/fiatjaf/khatru v0.1.0/go.mod h1:reXIM06zBXmFWwM1qp9mW6jCWjxTkEbtObVEPm0jOXE= github.com/fiatjaf/quickjs-go v0.3.1 h1:NZu3o/P3fGpwr1zfkwadjKm3EsWXEuSHJ4TV0FJ8Zas= github.com/fiatjaf/quickjs-go v0.3.1/go.mod h1:lYXCC+EmJ6YxXs128amkCmMXnimlO4dFCqY6fjwGC0M= github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= diff --git a/main.go b/main.go index 1aa15fd..c875743 100644 --- a/main.go +++ b/main.go @@ -34,9 +34,11 @@ type Settings struct { } var ( - s Settings - log = zerolog.New(os.Stderr).Output(zerolog.ConsoleWriter{Out: os.Stdout}).With().Timestamp().Logger() - relay = khatru.NewRelay() + s Settings + db eventstore.Store + log = zerolog.New(os.Stderr).Output(zerolog.ConsoleWriter{Out: os.Stdout}).With().Timestamp().Logger() + relay = khatru.NewRelay() + wrapper eventstore.RelayInterface ) const ( @@ -167,7 +169,6 @@ func main() { if err := os.MkdirAll(s.DataDirectory, 0700); err != nil { return fmt.Errorf("failed to create datadir '%s': %w", s.DataDirectory, err) } - var db eventstore.Store var dbpath string switch s.DatabaseBackend { case "sqlite", "sqlite3": @@ -197,6 +198,7 @@ func main() { if err := db.Init(); err != nil { return fmt.Errorf("failed to initialize database: %w", err) } + wrapper = eventstore.RelayWrapper{Store: db} log.Info().Msgf("storing data with %s under ./%s", s.DatabaseBackend, dbpath) relay.StoreEvent = append(relay.StoreEvent, db.SaveEvent) diff --git a/quickjs.go b/quickjs.go new file mode 100644 index 0000000..8030ed6 --- /dev/null +++ b/quickjs.go @@ -0,0 +1,115 @@ +package main + +import ( + "github.com/fiatjaf/quickjs-go" + "github.com/nbd-wtf/go-nostr" +) + +func eventToJs(qjs *quickjs.Context, event *nostr.Event) quickjs.Value { + jsEvent := qjs.Object() + jsEvent.Set("id", qjs.String(event.ID)) + jsEvent.Set("pubkey", qjs.String(event.PubKey)) + jsEvent.Set("sig", qjs.String(event.Sig)) + jsEvent.Set("content", qjs.String(event.Content)) + jsEvent.Set("kind", qjs.Int32(int32(event.Kind))) + jsEvent.Set("created_at", qjs.Int64(int64(event.CreatedAt))) + jsTags := qjs.Array() + for _, tag := range event.Tags { + jsTags.Push(qjsStringArray(qjs, tag)) + } + jsEvent.Set("tags", jsTags.ToValue()) + return jsEvent +} + +func filterToJs(qjs *quickjs.Context, filter nostr.Filter) quickjs.Value { + jsFilter := qjs.Object() + + if len(filter.IDs) > 0 { + jsFilter.Set("ids", qjsStringArray(qjs, filter.IDs)) + } + if len(filter.Authors) > 0 { + jsFilter.Set("authors", qjsStringArray(qjs, filter.Authors)) + } + if len(filter.Kinds) > 0 { + jsFilter.Set("kinds", qjsIntArray(qjs, filter.Kinds)) + } + for tag, values := range filter.Tags { + jsFilter.Set("#"+tag, qjsStringArray(qjs, values)) + } + if filter.Limit > 0 { + jsFilter.Set("limit", qjs.Int32(int32(filter.Limit))) + } + if filter.Since != nil { + jsFilter.Set("since", qjs.Int64(int64(*filter.Since))) + } + if filter.Until != nil { + jsFilter.Set("until", qjs.Int64(int64(*filter.Until))) + } + if filter.Search != "" { + jsFilter.Set("search", qjs.String(filter.Search)) + } + + return jsFilter +} + +func filterFromJs(qjs *quickjs.Context, jsFilter quickjs.Value) nostr.Filter { + filter := nostr.Filter{} + filter.Tags = make(nostr.TagMap) + + keys, _ := jsFilter.PropertyNames() + for _, key := range keys { + switch key { + case "ids": + filter.IDs = qjsReadStringArray(jsFilter.Get("ids")) + case "authors": + filter.Authors = qjsReadStringArray(jsFilter.Get("authors")) + case "kinds": + filter.Kinds = qjsReadIntArray(jsFilter.Get("kinds")) + case "limit": + filter.Limit = int(jsFilter.Get(key).Int64()) + case "since": + v := nostr.Timestamp(jsFilter.Get(key).Int64()) + filter.Until = &v + case "until": + v := nostr.Timestamp(jsFilter.Get(key).Int64()) + filter.Until = &v + default: + if key[0] == '#' { + filter.Tags[key[1:]] = qjsReadStringArray(jsFilter.Get(key)) + } + } + } + return filter +} + +func qjsStringArray(qjs *quickjs.Context, src []string) quickjs.Value { + arr := qjs.Array() + for _, item := range src { + arr.Push(qjs.String(item)) + } + return arr.ToValue() +} + +func qjsReadStringArray(arr quickjs.Value) []string { + strs := make([]string, arr.Len()) + for i := 0; i < len(strs); i++ { + strs[i] = arr.GetIdx(int64(i)).String() + } + return strs +} + +func qjsReadIntArray(arr quickjs.Value) []int { + ints := make([]int, arr.Len()) + for i := 0; i < len(ints); i++ { + ints[i] = int(arr.GetIdx(int64(i)).Int32()) + } + return ints +} + +func qjsIntArray(qjs *quickjs.Context, src []int) quickjs.Value { + arr := qjs.Array() + for _, item := range src { + arr.Push(qjs.Int32(int32(item))) + } + return arr.ToValue() +} diff --git a/reject.go b/reject.go index 58ebc63..af22c79 100644 --- a/reject.go +++ b/reject.go @@ -6,6 +6,7 @@ import ( "path/filepath" "runtime" + "github.com/fiatjaf/khatru" "github.com/fiatjaf/quickjs-go" "github.com/fiatjaf/quickjs-go/polyfill/pkg/console" "github.com/fiatjaf/quickjs-go/polyfill/pkg/fetch" @@ -21,13 +22,29 @@ const ( ) var defaultScripts = map[scriptPath]string{ - REJECT_EVENT: `export default function (event) { + REJECT_EVENT: `export default function (event, relay, authedUser) { + if (event.kind === 0) { + if (authedUser) { + return null + } else { + return 'auth-required: please auth before publishing metadata' + } + } + if (event.kind !== 1) return 'we only accept kind:1 notes' if (event.content.length > 140) return 'notes must have up to 140 characters only' if (event.tags.length > 0) return 'notes cannot have tags' + + let metadata = relay.query({ + kinds: [0], + authors: [event.pubkey] + }) + if (metadata.length === 0) return 'publish your metadata here first' }`, - REJECT_FILTER: `export default function (filter) { + REJECT_FILTER: `export default function (filter, relay, authedUser) { + if (!authedUser) return "auth-required: take a selfie and send it to the CIA" + return fetch( 'https://www.random.org/integers/?num=1&min=1&max=9&col=1&base=10&format=plain&rnd=new' ) @@ -41,56 +58,52 @@ var defaultScripts = map[scriptPath]string{ } func rejectEvent(ctx context.Context, event *nostr.Event) (reject bool, msg string) { - return runAndGetResult(REJECT_EVENT, func(qjs *quickjs.Context) quickjs.Value { + return runAndGetResult(REJECT_EVENT, // first argument: the nostr event object we'll pass to the script - jsEvent := qjs.Object() - jsEvent.Set("id", qjs.String(event.ID)) - jsEvent.Set("pubkey", qjs.String(event.PubKey)) - jsEvent.Set("sig", qjs.String(event.Sig)) - jsEvent.Set("content", qjs.String(event.Content)) - jsEvent.Set("kind", qjs.Int32(int32(event.Kind))) - jsEvent.Set("created_at", qjs.Int64(int64(event.CreatedAt))) - jsTags := qjs.Array() - for _, tag := range event.Tags { - jsTags.Push(qjsStringArray(qjs, tag)) - } - jsEvent.Set("tags", jsTags.ToValue()) - return jsEvent - }) + func(qjs *quickjs.Context) quickjs.Value { return eventToJs(qjs, event) }, + // second argument: the relay object with goodies + func(qjs *quickjs.Context) quickjs.Value { return makeRelayObject(ctx, qjs) }, + // third argument: the currently authenticated user + func(qjs *quickjs.Context) quickjs.Value { return makeAuthedUserString(ctx, qjs) }, + ) } func rejectFilter(ctx context.Context, filter nostr.Filter) (reject bool, msg string) { - return runAndGetResult(REJECT_FILTER, func(qjs *quickjs.Context) quickjs.Value { + return runAndGetResult(REJECT_FILTER, // first argument: the nostr filter object we'll pass to the script - jsFilter := qjs.Object() + func(qjs *quickjs.Context) quickjs.Value { return filterToJs(qjs, filter) }, + // second argument: the relay object with goodies + func(qjs *quickjs.Context) quickjs.Value { return makeRelayObject(ctx, qjs) }, + // third argument: the currently authenticated user + func(qjs *quickjs.Context) quickjs.Value { return makeAuthedUserString(ctx, qjs) }, + ) +} - if len(filter.IDs) > 0 { - jsFilter.Set("ids", qjsStringArray(qjs, filter.IDs)) - } - if len(filter.Authors) > 0 { - jsFilter.Set("authors", qjsStringArray(qjs, filter.Authors)) - } - if len(filter.Kinds) > 0 { - jsFilter.Set("kinds", qjsIntArray(qjs, filter.Kinds)) - } - for tag, values := range filter.Tags { - jsFilter.Set("#"+tag, qjsStringArray(qjs, values)) - } - if filter.Limit > 0 { - jsFilter.Set("limit", qjs.Int32(int32(filter.Limit))) - } - if filter.Since != nil { - jsFilter.Set("since", qjs.Int64(int64(*filter.Since))) +func makeRelayObject(ctx context.Context, qjs *quickjs.Context) quickjs.Value { + relayObject := qjs.Object() + queryFunc := qjs.Function(func(qjs *quickjs.Context, this quickjs.Value, args []quickjs.Value) quickjs.Value { + filterjs := args[0] // this is expected to be a nostr filter object + filter := filterFromJs(qjs, filterjs) + events, err := wrapper.QuerySync(ctx, filter) + if err != nil { + qjs.ThrowError(err) } - if filter.Until != nil { - jsFilter.Set("until", qjs.Int64(int64(*filter.Until))) + results := qjs.Array() + for _, event := range events { + results.Push(eventToJs(qjs, event)) } - if filter.Search != "" { - jsFilter.Set("search", qjs.String(filter.Search)) - } - - return jsFilter + return results.ToValue() }) + relayObject.Set("query", queryFunc) + return relayObject +} + +func makeAuthedUserString(ctx context.Context, qjs *quickjs.Context) quickjs.Value { + if pubkey := khatru.GetAuthed(ctx); pubkey != "" { + return qjs.String(pubkey) + } else { + return qjs.Null() + } } func runAndGetResult(scriptPath scriptPath, makeArgs ...func(qjs *quickjs.Context) quickjs.Value) (reject bool, msg string) { @@ -163,19 +176,3 @@ ____grab(msg) // this will also handle the case in which 'msg' is a promise return reject, msg } - -func qjsStringArray(qjs *quickjs.Context, src []string) quickjs.Value { - arr := qjs.Array() - for _, item := range src { - arr.Push(qjs.String(item)) - } - return arr.ToValue() -} - -func qjsIntArray(qjs *quickjs.Context, src []int) quickjs.Value { - arr := qjs.Array() - for _, item := range src { - arr.Push(qjs.Int32(int32(item))) - } - return arr.ToValue() -}