Skip to content

Commit

Permalink
add docs.
Browse files Browse the repository at this point in the history
  • Loading branch information
fiatjaf committed Jul 15, 2024
1 parent 5c7121a commit bb8b36d
Show file tree
Hide file tree
Showing 25 changed files with 894 additions and 0 deletions.
1 change: 1 addition & 0 deletions docs/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
node_modules
9 changes: 9 additions & 0 deletions docs/.prettierrc.yaml
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
2 changes: 2 additions & 0 deletions docs/.vitepress/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
cache
dist
20 changes: 20 additions & 0 deletions docs/.vitepress/config.js
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
}
11 changes: 11 additions & 0 deletions docs/.vitepress/theme/Layout.vue
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>
24 changes: 24 additions & 0 deletions docs/.vitepress/theme/custom.css
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;
}
8 changes: 8 additions & 0 deletions docs/.vitepress/theme/index.mjs
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
}
1 change: 1 addition & 0 deletions docs/config.js
62 changes: 62 additions & 0 deletions docs/cookbook/auth.md
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"
}
})
```
64 changes: 64 additions & 0 deletions docs/cookbook/custom-live-events.md
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
}
```
88 changes: 88 additions & 0 deletions docs/cookbook/custom-stores.md
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.
58 changes: 58 additions & 0 deletions docs/cookbook/dynamic.md
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.
Loading

0 comments on commit bb8b36d

Please sign in to comment.