diff --git a/docs/.gitignore b/docs/.gitignore new file mode 100644 index 00000000..3c3629e6 --- /dev/null +++ b/docs/.gitignore @@ -0,0 +1 @@ +node_modules diff --git a/docs/.prettierrc.yaml b/docs/.prettierrc.yaml new file mode 100644 index 00000000..a881699f --- /dev/null +++ b/docs/.prettierrc.yaml @@ -0,0 +1,9 @@ +semi: false +arrowParens: avoid +insertPragma: false +printWidth: 80 +proseWrap: preserve +singleQuote: true +trailingComma: none +useTabs: false +bracketSpacing: false diff --git a/docs/.vitepress/.gitignore b/docs/.vitepress/.gitignore new file mode 100644 index 00000000..a8d3ed2c --- /dev/null +++ b/docs/.vitepress/.gitignore @@ -0,0 +1,2 @@ +cache +dist diff --git a/docs/.vitepress/config.js b/docs/.vitepress/config.js new file mode 100644 index 00000000..3ba772d7 --- /dev/null +++ b/docs/.vitepress/config.js @@ -0,0 +1,20 @@ +export default { + lang: 'en-US', + title: 'khatru', + description: 'a framework for making Nostr relays', + themeConfig: { + logo: '/logo.png', + nav: [ + {text: 'Home', link: '/'}, + {text: 'Why', link: '/why'}, + {text: 'Use Cases', link: '/use-cases'}, + {text: 'Get Started', link: '/getting-started'}, + {text: 'Cookbook', link: '/cookbook'} + ], + editLink: { + pattern: 'https://github.com/fiatjaf/khatru/edit/master/docs/:path' + } + }, + head: [['link', {rel: 'icon', href: '/logo.png'}]], + cleanUrls: true +} diff --git a/docs/.vitepress/theme/Layout.vue b/docs/.vitepress/theme/Layout.vue new file mode 100644 index 00000000..4315dd88 --- /dev/null +++ b/docs/.vitepress/theme/Layout.vue @@ -0,0 +1,11 @@ + + diff --git a/docs/.vitepress/theme/custom.css b/docs/.vitepress/theme/custom.css new file mode 100644 index 00000000..b4606abe --- /dev/null +++ b/docs/.vitepress/theme/custom.css @@ -0,0 +1,24 @@ +:root { + --vp-c-brand-1: #2eafab; + --vp-c-brand-2: #30373b; + --vp-c-brand-3: #3b6a3e; + --vp-button-brand-bg: #2eafab; + --vp-button-brand-hover-bg: #3b6a3e; + --vp-button-brand-active-bg: #30373b; + + --vp-c-bg: #f2e6e2; + --vp-c-bg-soft: #f3f2f0; +} + +.dark { + --vp-c-bg: #0a0a08; + --vp-c-bg-soft: #161a0e; +} + +.khatru-layout-bottom { + margin: 2rem auto; + width: 200px; + text-align: center; + font-family: monospace; + font-size: 2rem; +} diff --git a/docs/.vitepress/theme/index.mjs b/docs/.vitepress/theme/index.mjs new file mode 100644 index 00000000..933b3b15 --- /dev/null +++ b/docs/.vitepress/theme/index.mjs @@ -0,0 +1,8 @@ +import DefaultTheme from 'vitepress/theme' +import NostrifyLayout from './Layout.vue' +import './custom.css' + +export default { + extends: DefaultTheme, + Layout: NostrifyLayout +} diff --git a/docs/config.js b/docs/config.js new file mode 120000 index 00000000..5f77cdd8 --- /dev/null +++ b/docs/config.js @@ -0,0 +1 @@ +.vitepress/config.js \ No newline at end of file diff --git a/docs/cookbook/auth.md b/docs/cookbook/auth.md new file mode 100644 index 00000000..b7cfceb3 --- /dev/null +++ b/docs/cookbook/auth.md @@ -0,0 +1,62 @@ +--- +outline: deep +--- + +# NIP-42 `AUTH` + +`khatru` supports [NIP-42](https://nips.nostr.com/42) out of the box. The functionality is exposed in the following ways. + +## Sending arbitrary `AUTH` challenges + +At any time you can send an `AUTH` message to a client that is making a request. + +It makes sense to give the user the option to authenticate right after they establish a connection, for example, when you have a relay that works differently depending on whether the user is authenticated or not. + +```go +relay := khatru.NewRelay() + +relay.OnConnect = append(relay.OnConnect, func(ctx context.Context) { + khatru.RequestAuth(ctx) +}) +``` + +This will send a NIP-42 `AUTH` challenge message to the client so it will have the option to authenticate itself whenever it wants to. + +## Signaling to the client that a specific query requires an authenticated user + +If on `RejectFilter` or `RejectEvent` you prefix the message with `auth-required: `, that will automatically send an `AUTH` message before a `CLOSED` or `OK` with that prefix, such that the client will immediately be able to know it must authenticate to proceed and will already have the challenge required for that, so they can immediately replay the request. + +```go +relay.RejectFilter = append(relay.RejectFilter, func(ctx context.Context, filter nostr.Filter) (bool, string) { + return true, "auth-required: this query requires you to be authenticated" +}) +relay.RejectEvent = append(relay.RejectFilter, func(ctx context.Context, event *nostr.Event) (bool, string) { + return true, "auth-required: publishing this event requires authentication" +}) +``` + +## Reading the auth status of a client + +After a client is authenticated and opens a new subscription with `REQ` or sends a new event with `EVENT`, you'll be able to read the public key they're authenticated with. + +```go +relay.RejectFilter = append(relay.RejectFilter, func(ctx context.Context, filter nostr.Filter) (bool, string) { + authenticatedUser := khatru.GetAuthed(ctx) +}) +``` + +## Telling an authenticated user they're still not allowed to do something + +If the user is authenticated but still not allowed (because some specific filters or events are only accessible to some specific users) you can reply on `RejectFilter` or `RejectEvent` with a message prefixed with `"restricted: "` to make that clear to clients. + +```go +relay.RejectFilter = append(relay.RejectFilter, func(ctx context.Context, filter nostr.Filter) (bool, string) { + authenticatedUser := khatru.GetAuthed(ctx) + + if slices.Contain(authorizedUsers, authenticatedUser) { + return false + } else { + return true, "restricted: you're not a member of the privileged group that can read that stuff" + } +}) +``` diff --git a/docs/cookbook/custom-live-events.md b/docs/cookbook/custom-live-events.md new file mode 100644 index 00000000..adea1e08 --- /dev/null +++ b/docs/cookbook/custom-live-events.md @@ -0,0 +1,64 @@ +--- +outline: deep +--- + +# Generating custom live events + +Suppose you want to generate a new event every time a goal is scored on some soccer game and send that to all clients subscribed to a given game according to a tag `t`. + +We'll assume you'll be polling some HTTP API that gives you the game's current score, and that in your `main` function you'll start the function that does the polling: + +```go +func main () { + // other stuff here + relay := khatru.NewRelay() + + go startPollingGame(relay) + // other stuff here +} + +type GameStatus struct { + TeamA int `json:"team_a"` + TeamB int `json:"team_b"` +} + +func startPollingGame(relay *khatru.Relay) { + current := GameStatus{0, 0} + + for { + newStatus, err := fetchGameStatus() + if err != nil { + continue + } + + if newStatus.TeamA > current.TeamA { + // team A has scored a goal, here we generate an event + evt := nostr.Event{ + CreatedAt: nostr.Now(), + Kind: 1, + Content: "team A has scored!", + Tags: nostr.Tags{{"t", "this-game"}} + } + evt.Sign(global.RelayPrivateKey) + // calling BroadcastEvent will send the event to everybody who has been listening for tag "t=[this-game]" + // there is no need to do any code to keep track of these clients or who is listening to what, khatru + // does that already in the background automatically + relay.BroadcastEvent(evt) + + // just calling BroadcastEvent won't cause this event to be be stored, + // if for any reason you want to store these events you must call the store functions manually + for _, store := range relay.StoreEvent { + store(context.TODO(), evt) + } + } + if newStatus.TeamB > current.TeamB { + // same here, if team B has scored a goal + // ... + } + } +} + +func fetchGameStatus() (GameStatus, error) { + // implementation of calling some external API goes here +} +``` diff --git a/docs/cookbook/custom-stores.md b/docs/cookbook/custom-stores.md new file mode 100644 index 00000000..efce73d9 --- /dev/null +++ b/docs/cookbook/custom-stores.md @@ -0,0 +1,88 @@ +--- +outline: deep +--- + +# Generating events on the fly from a non-Nostr data-source + +Suppose you want to serve events with the weather data for periods in the past. All you have is a big CSV file with the data. + +Then you get a query like `{"#g": ["d6nvp"], "since": 1664074800, "until": 1666666800, "kind": 10774}`, imagine for a while that kind `10774` means weather data. + +First you do some geohashing calculation to discover that `d6nvp` corresponds to Willemstad, Curaçao, then you query your XML file for the Curaçao weather data for the given period -- from `2022-09-25` to `2022-10-25`, then you return the events corresponding to such query, signed on the fly: + +```go +func main () { + // other stuff here + relay := khatru.NewRelay() + + relay.QueryEvents = append(relay.QueryEvents, + handleWeatherQuery, + ) + // other stuff here +} + +func handleWeatherQuery(ctx context.Context, filter nostr.Filter) (ch chan *nostr.Event, err error) { + if filter.Kind != 10774 { + // this function only handles kind 10774, if the query is for something else we return + // a nil channel, which corresponds to no results + return nil, nil + } + + file, err := os.Open("weatherdata.xml") + if err != nil { + return nil, fmt.Errorf("we have lost our file: %w", err) + } + + // QueryEvents functions are expected to return a channel + ch := make(chan *nostr.Event) + + // and they can do their query asynchronously, emitting events to the channel as they come + go func () { + defer file.Close() + + // we're going to do this for each tag in the filter + gTags, _ := filter.Tags["g"] + for _, gTag := range gTags { + // translate geohash into city name + citName, err := geohashToCityName(gTag) + if err != nil { + continue + } + + reader := csv.NewReader(file) + for { + record, err := reader.Read() + if err != nil { + return + } + + // ensure we're only getting records for Willemstad + if cityName != record[0] { + continue + } + + date, _ := time.Parse("2006-01-02", record[1]) + ts := nostr.Timestamp(date.Unix()) + if ts > filter.Since && ts < filter.Until { + // we found a record that matches the filter, so we make + // an event on the fly and return it + evt := nostr.Event{ + CreatedAt: ts, + Kind: 10774, + Tags: nostr.Tags{ + {"temperature", record[2]}, + {"condition", record[3]}, + } + } + evt.Sign(global.RelayPrivateKey) + ch <- evt + } + } + } + }() + + return ch, nil +} +``` + +Beware, the code above is inefficient and the entire approach is not very smart, it's meant just as an example. diff --git a/docs/cookbook/dynamic.md b/docs/cookbook/dynamic.md new file mode 100644 index 00000000..d66d3cc6 --- /dev/null +++ b/docs/cookbook/dynamic.md @@ -0,0 +1,58 @@ +--- +outline: deep +--- + +# Generating `khatru` relays dynamically and serving them from the same path + +Suppose you want to expose a different relay interface depending on the subdomain that is accessed. I don't know, maybe you want to serve just events with pictures on `pictures.example.com` and just events with audio files on `audios.example.com`; maybe you want just events in English on `en.example.com` and just examples in Portuguese on `pt.example.com`, there are many possibilities. + +You could achieve that with a scheme like the following + +```go +var topLevelHost = "example.com" +var mainRelay = khatru.NewRelay() // we're omitting all the configuration steps for brevity +var subRelays = xsync.NewMapOf[string, *khatru.Relay]() + +func main () { + handler := http.HandlerFunc(dynamicRelayHandler) + + log.Printf("listening at http://0.0.0.0:8080") + http.ListenAndServe("0.0.0.0:8080", handler) +} + +func dynamicRelayHandler(w http.ResponseWriter, r *http.Request) { + var relay *khatru.Relay + subdomain := r.Host[0 : len(topLevelHost)-len(topLevelHost)] + if subdomain == "" { + // no subdomain, use the main top-level relay + relay = mainRelay + } else { + // call on subdomain, so get a dynamic relay + subdomain = subdomain[0 : len(subdomain)-1] // remove dangling "." + // get a dynamic relay + relay, _ = subRelays.LoadOrCompute(subdomain, func () *khatru.Relay { + return makeNewRelay(subdomain) + }) + } + + relay.ServeHTTP(w, r) +} + +func makeNewRelay (subdomain string) *khatru.Relay { + // somehow use the subdomain to generate a relay with specific configurations + relay := khatru.NewRelay() + switch subdomain { + case "pictures": + // relay configuration shenanigans go here + case "audios": + // relay configuration shenanigans go here + case "en": + // relay configuration shenanigans go here + case "pt": + // relay configuration shenanigans go here + } + return relay +} +``` + +In practice you could come up with a way that allows all these dynamic relays to share a common underlying datastore, but this is out of the scope of this example. diff --git a/docs/cookbook/embed.md b/docs/cookbook/embed.md new file mode 100644 index 00000000..ec1f92aa --- /dev/null +++ b/docs/cookbook/embed.md @@ -0,0 +1,72 @@ +--- +outline: deep +--- + +# Mixing a `khatru` relay with other HTTP handlers + +If you already have a web server with all its HTML handlers or a JSON HTTP API or anything like that, something like: + +```go +func main() { + mux := http.NewServeMux() + + mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("./static")))) + mux.HandleFunc("/.well-known/nostr.json", handleNIP05) + mux.HandleFunc("/page/{page}", handlePage) + mux.HandleFunc("/", handleHomePage) + + log.Printf("listening at http://0.0.0.0:8080") + http.ListenAndServe("0.0.0.0:8080", mux) +} +``` + +Then you can easily inject a relay or two there in alternative paths if you want: + +```diff + mux := http.NewServeMux() + ++ relay1 := khatru.NewRelay() ++ relay2 := khatru.NewRelay() ++ // and so on + + mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("./static")))) + mux.HandleFunc("/.well-known/nostr.json", handleNIP05) + mux.HandleFunc("/page/{page}", handlePage) + mux.HandleFunc("/", handleHomePage) ++ mux.Handle("/relay1", relay1) ++ mux.Handle("/relay2", relay2) ++ // and so forth + + log.Printf("listening at http://0.0.0.0:8080") +``` + +Imagine each of these relay handlers is different, each can be using a different eventstore and have different policies for writing and reading. + +## Exposing a relay interface at the root + +If you want to expose your relay at the root path `/` that is also possible. You can just use it as the `mux` directly: + +```go +func main() { + relay := khatru.NewRelay() + // ... -- relay configuration steps (omitted for brevity) + + mux := relay.Router() // the relay comes with its own http.ServeMux inside + + mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("./static")))) + mux.HandleFunc("/.well-known/nostr.json", handleNIP05) + mux.HandleFunc("/page/{page}", handlePage) + mux.HandleFunc("/", handleHomePage) + + log.Printf("listening at http://0.0.0.0:8080") + http.ListenAndServe("0.0.0.0:8080", mux) +} +``` + +Every [`khatru.Relay`](https://pkg.go.dev/github.com/fiatjaf/khatru#Relay) instance comes with its own ['http.ServeMux`](https://pkg.go.dev/net/http#ServeMux) inside. It ensures all requests are handled normally, but intercepts the requests that are pertinent to the relay operation, specifically the WebSocket requests, and the [NIP-11](https://nips.nostr.com/11) and the [NIP-86](https://nips.nostr.com/86) HTTP requests. + +## Exposing multiple relays at the same path or at the root + +That's also possible, as long as you have a way of differentiating each HTTP request that comes at the middleware level and associating it with a `khatru.Relay` instance in the background. + +See [dynamic](dynamic) for an example that does that using the subdomain. [`countries`](https://git.fiatjaf.com/countries) does it using the requester country implied from its IP address. diff --git a/docs/cookbook/eventstore.md b/docs/cookbook/eventstore.md new file mode 100644 index 00000000..249f3651 --- /dev/null +++ b/docs/cookbook/eventstore.md @@ -0,0 +1,101 @@ +--- +outline: deep +--- + +# Using the `eventstore` library + +The [`eventstore`](https://github.com/fiatjaf/eventstore) library has adapters that you can easily plug into `khatru`'s: + +* `StoreEvent` +* `DeleteEvent` +* `QueryEvents` +* `CountEvents` + +For all of them you start by instantiating a struct containing some basic options and a pointer (a file path for local databases, a connection string for remote databases) to the data. Then you call `.Init()` and if all is well you're ready to start storing, querying and deleting events, so you can pass the respective functions to their `khatru` counterparts. These eventstores also expose a `.Close()` function that must be called if you're going to stop using that store and keep your application open. + +Here's an example with the [Badger](https://pkg.go.dev/github.com/fiatjaf/eventstore/badger) adapter, made for the [Badger](https://github.com/dgraph-io/badger) embedded key-value database: + +```go +package main + +import ( + "fmt" + "net/http" + + "github.com/fiatjaf/eventstore/badger" + "github.com/fiatjaf/khatru" +) + +func main() { + relay := khatru.NewRelay() + + db := badger.BadgerBackend{Path: "/tmp/khatru-badger-tmp"} + if err := db.Init(); err != nil { + panic(err) + } + + relay.StoreEvent = append(relay.StoreEvent, db.SaveEvent) + relay.QueryEvents = append(relay.QueryEvents, db.QueryEvents) + relay.CountEvents = append(relay.CountEvents, db.CountEvents) + relay.DeleteEvent = append(relay.DeleteEvent, db.DeleteEvent) + + fmt.Println("running on :3334") + http.ListenAndServe(":3334", relay) +} +``` + +Other local key-value embedded databases that work the same way are [LMDB](https://pkg.go.dev/github.com/fiatjaf/eventstore/lmdb) and [BoltDB](https://pkg.go.dev/github.com/fiatjaf/eventstore/bolt). + +[SQLite](https://pkg.go.dev/github.com/fiatjaf/eventstore/sqlite3) also stores things locally so it only needs a `Path`. + +[PostgreSQL](https://pkg.go.dev/github.com/fiatjaf/eventstore/postgresql) and [MySQL](https://pkg.go.dev/github.com/fiatjaf/eventstore/mysql) use remote connections to database servers, so they take a `DatabaseURL` parameter, but after that it's the same. + +## Using two at a time + +If you want to use two different adapters at the same time that's easy. Just add both to the corresponding slices: + +```go + relay.StoreEvent = append(relay.StoreEvent, db1.SaveEvent, db2.SaveEvent) + relay.QueryEvents = append(relay.QueryEvents, db1.QueryEvents, db2.SaveEvent) +``` + +But that will duplicate events on both and then return duplicated events on each query. + +## Sharding + +You can do a kind of sharding, for example, by storing some events in one store and others in another: + +For example, maybe you want kind 1 events in `db1` and kind 30023 events in `db30023`: + +```go + relay.StoreEvent = append(relay.StoreEvent, func (ctx context.Context, evt *nostr.Event) error { + switch evt.Kind { + case 1: + return db1.StoreEvent(ctx, evt) + case 30023: + return db30023.StoreEvent(ctx, evt) + default: + return nil + } + }) + relay.QueryEvents = append(relay.QueryEvents, func (ctx context.Context, filter nostr.Filter) (chan *nostr.Event, error) { + for _, kind := range filter.Kinds { + switch kind { + case 1: + filter1 := filter + filter1.Kinds = []int{1} + return db1.QueryEvents(ctx, filter1) + case 30023: + filter30023 := filter + filter30023.Kinds = []int{30023} + return db30023.QueryEvents(ctx, filter30023) + default: + return nil, nil + } + } + }) +``` + +## Search + +See [search](search). diff --git a/docs/cookbook/google-drive.md b/docs/cookbook/google-drive.md new file mode 100644 index 00000000..e931907b --- /dev/null +++ b/docs/cookbook/google-drive.md @@ -0,0 +1,67 @@ +--- +outline: deep +--- + +## Querying events from Google Drive + +Suppose you have a bunch of events stored in text files on Google Drive and you want to serve them as a relay. You could just store each event as a separate file and use the native Google Drive search to match the queries when serving requests. It would probably not be as fast as using local database, but it would work. + +```go +func main () { + // other stuff here + relay := khatru.NewRelay() + + relay.StoreEvent = append(relay.StoreEvent, handleEvent) + relay.QueryEvents = append(relay.QueryEvents, handleQuery) + // other stuff here +} + +func handleEvent(ctx context.Context, event *nostr.Event) error { + // store each event as a file on google drive + _, err := gdriveService.Files.Create(googledrive.CreateOptions{ + Name: event.ID, // with the name set to their id + Body: event.String(), // the body as the full event JSON + }) + return err +} + +func handleQuery(ctx context.Context, filter nostr.Filter) (ch chan *nostr.Event, err error) { + // QueryEvents functions are expected to return a channel + ch := make(chan *nostr.Event) + + // and they can do their query asynchronously, emitting events to the channel as they come + go func () { + if len(filter.IDs) > 0 { + // if the query is for ids we can do a simpler name match + for _, id := range filter.IDS { + results, _ := gdriveService.Files.List(googledrive.ListOptions{ + Q: fmt.Sprintf("name = '%s'", id) + }) + if len(results) > 0 { + var evt nostr.Event + json.Unmarshal(results[0].Body, &evt) + ch <- evt + } + } + } else { + // otherwise we use the google-provided search and hope it will catch tags that are in the event body + for tagName, tagValues := range filter.Tags { + results, _ := gdriveService.Files.List(googledrive.ListOptions{ + Q: fmt.Sprintf("fullText contains '%s'", tagValues) + }) + for _, result := range results { + var evt nostr.Event + json.Unmarshal(results[0].Body, &evt) + if filter.Match(evt) { + ch <- evt + } + } + } + } + }() + + return ch, nil +} +``` + +(Disclaimer: since I have no idea of how to properly use the Google Drive API this interface is entirely made up.) diff --git a/docs/cookbook/index.md b/docs/cookbook/index.md new file mode 100644 index 00000000..20301d05 --- /dev/null +++ b/docs/cookbook/index.md @@ -0,0 +1,10 @@ +# Cookbook + +- [Dealing with `AUTH` messages and authenticated users](auth) +- [Configuring the Relay Management API](management) +- [Using the eventstore library](eventstore) +- [Custom store: creating Nostr events on the fly from a non-Nostr source](custom-stores) +- [Custom store: reading from Google Drive](google-drive) +- [Live event generation](custom-live-events) +- [Embedding `khatru` inside other Go HTTP servers](embed) +- [Generating relays dynamically and serving them from the same path](dynamic) diff --git a/docs/cookbook/management.md b/docs/cookbook/management.md new file mode 100644 index 00000000..7365208a --- /dev/null +++ b/docs/cookbook/management.md @@ -0,0 +1,85 @@ +--- +outline: deep +--- + +# Setting up the Relay Management API + +[NIP-86](https://nips.nostr.com/86) specifies a set of RPC methods for managing the boring aspects of relays, such as whitelisting or banning users, banning individual events, banning IPs and so on. + +All [`khatru.Relay`](https://pkg.go.dev/github.com/fiatjaf/khatru#Relay) instances expose a field `ManagementAPI` with a [`RelayManagementAPI`](https://pkg.go.dev/github.com/fiatjaf/khatru#RelayManagementAPI) instance inside, which can be used for creating handlers for each of the RPC methods. + +There is also a generic `RejectAPICall` which is a slice of functions that will be called before any RPC method, if they exist and, if any of them returns true, the request will be rejected. + +The most basic implementation of a `RejectAPICall` handler would be one that checks the public key of the caller with a hardcoded public key of the relay owner: + +```go +var owner = "" +var allowedPubkeys = make([]string, 0, 10) + +func main () { + relay := khatru.NewRelay() + + relay.ManagementAPI.RejectAPICall = append(relay.ManagementAPI.RejectAPICall, + func(ctx context.Context, mp nip86.MethodParams) (reject bool, msg string) { + user := khatru.GetAuthed(ctx) + if user != owner { + return true, "go away, intruder" + } + return false, "" + } + ) + + relay.ManagementAPI.AllowPubKey = func(ctx context.Context, pubkey string, reason string) error { + allowedPubkeys = append(allowedPubkeys, pubkey) + return nil + } + relay.ManagementAPI.BanPubKey = func(ctx context.Context, pubkey string, reason string) error { + idx := slices.Index(allowedPubkeys, pubkey) + if idx == -1 { + return fmt.Errorf("pubkey already not allowed") + } + allowedPubkeys = slices.Delete(allowedPubkeys, idx, idx+1) + } +} +``` + +You can also not provide any `RejectAPICall` handler and do the approval specifically on each RPC handler. + +In the following example any current member can include any other pubkey, and anyone who was added before is able to remove any pubkey that was added afterwards (not a very good idea, but serves as an example). + +```go +var allowedPubkeys = []string{""} + +func main () { + relay := khatru.NewRelay() + + relay.ManagementAPI.AllowPubKey = func(ctx context.Context, pubkey string, reason string) error { + caller := khatru.GetAuthed(ctx) + + if slices.Contains(allowedPubkeys, caller) { + allowedPubkeys = append(allowedPubkeys, pubkey) + return nil + } + + return fmt.Errorf("you're not authorized") + } + relay.ManagementAPI.BanPubKey = func(ctx context.Context, pubkey string, reason string) error { + caller := khatru.GetAuthed(ctx) + + callerIdx := slices.Index(allowedPubkeys, caller) + if callerIdx == -1 { + return fmt.Errorf("you're not even allowed here") + } + + targetIdx := slices.Index(allowedPubkeys, pubkey) + if targetIdx < callerIdx { + // target is a bigger OG than the caller, so it has bigger influence and can't be removed + return fmt.Errorf("you're less powerful than the pubkey you're trying to remove") + } + + // allow deletion since the target came after the caller + allowedPubkeys = slices.Delete(allowedPubkeys, targetIdx, targetIdx+1) + return nil + } +} +``` diff --git a/docs/cookbook/search.md b/docs/cookbook/search.md new file mode 100644 index 00000000..8867d96d --- /dev/null +++ b/docs/cookbook/search.md @@ -0,0 +1,51 @@ +--- +outline: deep +--- + +# Implementing NIP-50 `search` support + +The [`nostr.Filter` type](https://pkg.go.dev/github.com/nbd-wtf/go-nostr#Filter) has a `Search` field, so you basically just has to handle that if it's present. + +It can be tricky to implement fulltext search properly though, so some [eventstores](eventstore) implement it natively, such as [Bluge](https://pkg.go.dev/github.com/fiatjaf/eventstore/bluge), [OpenSearch](https://pkg.go.dev/github.com/fiatjaf/eventstore/opensearch) and [ElasticSearch](https://pkg.go.dev/github.com/fiatjaf/eventstore/elasticsearch) (although for the last two you'll need an instance of these database servers running, while with Bluge it's embedded). + +If you have any of these you can just use them just like any other eventstore: + +```go +func main () { + // other stuff here + + normal := &lmdb.LMDBBackend{Path: "data"} + os.MkdirAll(normal.Path, 0755) + if err := normal.Init(); err != nil { + panic(err) + } + + search := bluge.BlugeBackend{Path: "search", RawEventStore: normal} + if err := search.Init(); err != nil { + panic(err) + } + + relay.StoreEvent = append(relay.StoreEvent, normal.SaveEvent, search.SaveEvent) + relay.QueryEvents = append(relay.QueryEvents, normal.QueryEvents, search.QueryEvents) + relay.DeleteEvent = append(relay.DeleteEvent, normal.DeleteEvent, search.DeleteEvent) + + // other stuff here +} +``` + +Note that in this case we're using the [LMDB](https://pkg.go.dev/github.com/fiatjaf/eventstore/lmdb) adapter for normal queries and it explicitly rejects any filter that contains a `Search` field, while [Bluge](https://pkg.go.dev/github.com/fiatjaf/eventstore/bluge) rejects any filter _without_ a `Search` value, which make them pair well together. + +Other adapters, like [SQLite](https://pkg.go.dev/github.com/fiatjaf/eventstore/sqlite3), implement search functionality on their own, so if you don't want to use that you would have to have a middleware between, like: + +```go + relay.StoreEvent = append(relay.StoreEvent, db.SaveEvent, search.SaveEvent) + relay.QueryEvents = append(relay.QueryEvents, func (ctx context.Context, filter nostr.Filter) (chan *nostr.Event, error) { + if len(filter.Search) > 0 { + return search.QueryEvents(ctx, filter) + } else { + filterNoSearch := filter + filterNoSearch.Search = "" + return normal.QueryEvents(ctx, filterNoSearch) + } + }) +``` diff --git a/docs/getting-started/index.md b/docs/getting-started/index.md new file mode 100644 index 00000000..69ee15cf --- /dev/null +++ b/docs/getting-started/index.md @@ -0,0 +1,72 @@ +--- +outline: deep +--- + +# Getting Started + +Include the library: + +```go +import "github.com/fiatjaf/khatru" +``` + +Then in your `main()` function, instantiate a new `Relay`: + +```go +relay := khatru.NewRelay() +``` + +Optionally, set up basic info about the relay that will be returned according to [NIP-11](https://nips.nostr.com/11): + +```go +relay.Info.Name = "my relay" +relay.Info.PubKey = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" +relay.Info.Description = "this is my custom relay" +relay.Info.Icon = "https://external-content.duckduckgo.com/iu/?u=https%3A%2F%2Fliquipedia.net%2Fcommons%2Fimages%2F3%2F35%2FSCProbe.jpg&f=1&nofb=1&ipt=0cbbfef25bce41da63d910e86c3c343e6c3b9d63194ca9755351bb7c2efa3359&ipo=images" +``` + +Now we must set up the basic functions for accepting events and answering queries. We could make our own querying engine from scratch, but we can also use [eventstore](https://github.com/fiatjaf/eventstore). In this example we'll use the SQLite adapter: + +```go +db := sqlite3.SQLite3Backend{DatabaseURL: "/tmp/khatru-sqlite-tmp"} +if err := db.Init(); err != nil { + panic(err) +} + +relay.StoreEvent = append(relay.StoreEvent, db.SaveEvent) +relay.QueryEvents = append(relay.QueryEvents, db.QueryEvents) +relay.DeleteEvent = append(relay.DeleteEvent, db.DeleteEvent) +``` + +These are lists of functions that will be called in order every time an `EVENT` is received, or a `REQ` query is received. You can add more than one handler there, you can have a function that reads from some other server, but just in some cases, you can do anything. + +The next step is adding some protection, because maybe we don't want to allow _anyone_ to write to our relay. Maybe we want to only allow people that have a pubkey starting with `"a"`, `"b"` or `"c"`: + +```go +relay.RejectEvent = append(relay.RejectEvent, func (ctx context.Context, event *nostr.Event) (reject bool, msg string) { + firstHexChar := event.PubKey[0:1] + if firstHexChar == "a" || firstHexChar == "b" || firstHexChar == "c" { + return false, "" // allow + } + return true, "you're not allowed in this shard" +}) +``` + +We can also make use of some default policies that come bundled with Khatru: + +```go +import "github.com/fiatjaf/khatru" // implied + +relay.RejectEvent = append(relay.RejectEvent, policies.PreventLargeTags, policies.PreventTimestampsInThePast(time.Hour * 2), policies.PreventTimestampsInTheFuture(time.Minute * 30)) +``` + +There are many other ways to customize the relay behavior. Take a look at the [`Relay` struct docs](https://pkg.go.dev/github.com/fiatjaf/khatru#Relay) for more, or see the [cookbook](/cookbook/). + +The last step is actually running the server. Our relay is actually an `http.Handler`, so it can just be ran directly with `http.ListenAndServe()` from the standard library: + +```go +fmt.Println("running on :3334") +http.ListenAndServe(":3334", relay) +``` + +And that's it. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 00000000..9047b619 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,40 @@ +--- +layout: home + +hero: + name: khatru + text: a framework for making Nostr relays + tagline: write your custom relay with code over configuration + actions: + - theme: brand + text: Get Started + link: /getting-started + - theme: alt + text: Cookbook + link: /cookbook + +features: + - title: It's a library + icon: 🐢 + link: /getting-started + details: This is not an executable that you have to tweak with config files, it's a library that you import and use, so you just write code and it does exactly what you want. + - title: It's very very customizable + icon: 🎶 + link: /cookbook/embed + details: Run arbitrary functions to reject events, reject filters, overwrite results of queries, perform actual queries, mix the relay stuff with other HTTP handlers or even run it inside an existing website. + - title: It's plugs into event stores easily + icon: 📦 + link: /cookbook/eventstore + details: khatru's companion, the `eventstore` library, provides all methods for storing and querying events efficiently from SQLite, LMDB, Postgres, Badger and others. + - title: It supports NIP-42 AUTH + icon: 🪪 + link: /cookbook/auth + details: You can check if a client is authenticated or request AUTH anytime, or reject an event or a filter with an "auth-required:" and it will be handled automatically. + - title: It supports NIP-86 Management API + icon: 🛠️ + link: /cookbook/management + details: You just define your custom handlers for each RPC call and they will be exposed appropriately to management clients. + - title: It's written in Go + icon: 🛵 + details: That means it is fast and lightweight, you can learn the language in 5 minutes and it builds your relay into a single binary that's easy to ship and deploy. +--- diff --git a/docs/justfile b/docs/justfile new file mode 100644 index 00000000..f4d2a7cf --- /dev/null +++ b/docs/justfile @@ -0,0 +1,7 @@ +export PATH := "./node_modules/.bin:" + env_var('PATH') + +dev: + vitepress dev + +build: + vitepress build diff --git a/docs/logo.png b/docs/logo.png new file mode 100644 index 00000000..614d34ca Binary files /dev/null and b/docs/logo.png differ diff --git a/docs/package.json b/docs/package.json new file mode 100644 index 00000000..325686dd --- /dev/null +++ b/docs/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "vitepress": "^1.3.0" + } +} diff --git a/docs/use-cases.md b/docs/use-cases.md new file mode 100644 index 00000000..50246a63 --- /dev/null +++ b/docs/use-cases.md @@ -0,0 +1,26 @@ +# Use cases + +`khatru` is being used today in the real world by + +* [pyramid](https://github.com/github-tijlxyz/khatru-pyramid), a relay with a invite-based whitelisting system similar to [lobste.rs](https://lobste.rs) +* [triflector](https://github.com/coracle-social/triflector), a relay which enforces authentication based on custom policy +* [countries](https://git.fiatjaf.com/countries), a relay that stores and serves content differently according to the country of the reader or writer +* [jingle](https://github.com/fiatjaf/jingle), a simple relay that exposes part of `khatru`'s configuration options to JavaScript code supplied by the user that is interpreted at runtime +* [njump](https://git.njump.me/njump), a Nostr gateway to the web that also serves its cached content in a relay interface +* [song](https://git.fiatjaf.com/song), a personal git server that comes with an embedded relay dedicated to dealing with [NIP-34](https://nips.nostr.com/34) git-related Nostr events +* [relay29](https://github.com/fiatjaf/relay29), a relay that powers most of the [NIP-29](https://nips.nostr.com/29) Nostr groups ecosystem +* [fiatjaf.com](https://fiatjaf.com), a personal website that serves the same content as HTML but also as Nostr events. + +## Other possible use cases + +Other possible use cases, still not developed, include: + +* Bridges: `khatru` was initially developed to serve as an RSS-to-Nostr bridge server that would fetch RSS feeds on demand in order to serve them to Nostr clients. Other similar use cases could fit. +* Paid relays: Nostr has multiple relays that charge for write-access currently, but there are many other unexplored ways to make this scheme work: charge per each note, charge per month, charge per month per note, have different payment methods, and so on. +* Other whitelisting schemes: _pyramid_ implements a cool inviting scheme for granting access to the relay, same for _triflector_, but there are infinite other possibilities of other ways to grant access to people to an exclusive or community relay. +* Just-in-time content generation: instead of storing a bunch of signed JSON and serving that to clients, there could be relays that store data in a more compact format and turn it into Nostr events at the time they receive a request from a Nostr client -- or relays that do some kind of live data generation based on who is connected, not storing anything. +* Community relays: some internet communities may want relays that restrict writing or browsing of content only to its members, essentially making it a closed group -- or it could be closed for outsiders to write, but public for them to read and vice-versa. +* Automated moderation schemes: relays that are owned by a group (either a static or a dynamic group) can rely on signals from their members, like mutes or reports, to decide what content to allow in its domains and what to disallow, making crowdfunded moderation easy. +* Curation: in the same way as community relays can deal with unwanted content, they can also perform curation based on signals from their members (for example, if a member of the relay likes some note from someone that is outside the relay that note can be fetched and stored), creating a dynamic relay that can be browsed by anyone that share the same interests as that community. +* Local relays: a relay that can be only browsed by people using the WiFi connection of some event or some building, serving as a way to share temporary or restricted content that only interests people sharing that circumstance. +* Cool experiments: relays that only allow one note per user per day, relays that require proof-of-work on event ids], relays that require engagement otherwise you get kicked, relays that return events in different ordering, relays that impose arbitrary funny rules on notes in order for them to be accepted (i.e. they must contain the word "poo"), I don't know! diff --git a/docs/why.md b/docs/why.md new file mode 100644 index 00000000..72efc50e --- /dev/null +++ b/docs/why.md @@ -0,0 +1,10 @@ +# Why `khatru`? + +If you want to craft a relay that isn't completely dumb, but it's supposed to + +* have custom own policies for accepting events; +* handle requests for stored events using data from multiple sources; +* require users to authenticate for some operations and not for others; +* and other stuff. + +`khatru` provides a simple framework for creating your custom relay without having to reimplement it all from scratch or hack into other relay codebases.