-
Notifications
You must be signed in to change notification settings - Fork 27
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
25 changed files
with
894 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
node_modules |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,9 @@ | ||
semi: false | ||
arrowParens: avoid | ||
insertPragma: false | ||
printWidth: 80 | ||
proseWrap: preserve | ||
singleQuote: true | ||
trailingComma: none | ||
useTabs: false | ||
bracketSpacing: false |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
cache | ||
dist |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,11 @@ | ||
<script setup> | ||
import DefaultTheme from 'vitepress/theme' | ||
const {Layout} = DefaultTheme | ||
</script> | ||
<template> | ||
<Layout> | ||
<template #layout-bottom> | ||
<div class="khatru-layout-bottom">~</div> | ||
</template> | ||
</Layout> | ||
</template> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,8 @@ | ||
import DefaultTheme from 'vitepress/theme' | ||
import NostrifyLayout from './Layout.vue' | ||
import './custom.css' | ||
|
||
export default { | ||
extends: DefaultTheme, | ||
Layout: NostrifyLayout | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
.vitepress/config.js |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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" | ||
} | ||
}) | ||
``` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 | ||
} | ||
``` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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. |
Oops, something went wrong.