2024-09-17 01:01:42 +00:00
|
|
|
package khatru
|
|
|
|
|
|
|
|
import (
|
|
|
|
"context"
|
|
|
|
"crypto/rand"
|
|
|
|
"encoding/hex"
|
|
|
|
"errors"
|
|
|
|
"net/http"
|
|
|
|
"strings"
|
|
|
|
"sync"
|
|
|
|
"time"
|
|
|
|
|
|
|
|
"github.com/fasthttp/websocket"
|
|
|
|
"github.com/nbd-wtf/go-nostr"
|
|
|
|
"github.com/nbd-wtf/go-nostr/nip42"
|
|
|
|
"github.com/rs/cors"
|
|
|
|
)
|
|
|
|
|
|
|
|
// ServeHTTP implements http.Handler interface.
|
|
|
|
func (rl *Relay) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
|
|
if rl.ServiceURL == "" {
|
|
|
|
rl.ServiceURL = getServiceBaseURL(r)
|
|
|
|
}
|
|
|
|
|
|
|
|
if r.Header.Get("Upgrade") == "websocket" {
|
|
|
|
rl.HandleWebsocket(w, r)
|
|
|
|
} else if r.Header.Get("Accept") == "application/nostr+json" {
|
|
|
|
cors.AllowAll().Handler(http.HandlerFunc(rl.HandleNIP11)).ServeHTTP(w, r)
|
|
|
|
} else if r.Header.Get("Content-Type") == "application/nostr+json+rpc" {
|
|
|
|
cors.AllowAll().Handler(http.HandlerFunc(rl.HandleNIP86)).ServeHTTP(w, r)
|
|
|
|
} else {
|
|
|
|
rl.serveMux.ServeHTTP(w, r)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func (rl *Relay) HandleWebsocket(w http.ResponseWriter, r *http.Request) {
|
|
|
|
for _, reject := range rl.RejectConnection {
|
|
|
|
if reject(r) {
|
|
|
|
w.WriteHeader(429) // Too many requests
|
|
|
|
return
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
conn, err := rl.upgrader.Upgrade(w, r, nil)
|
|
|
|
if err != nil {
|
|
|
|
rl.Log.Printf("failed to upgrade websocket: %v\n", err)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
ticker := time.NewTicker(rl.PingPeriod)
|
|
|
|
|
|
|
|
// NIP-42 challenge
|
|
|
|
challenge := make([]byte, 8)
|
|
|
|
rand.Read(challenge)
|
|
|
|
|
|
|
|
ws := &WebSocket{
|
|
|
|
conn: conn,
|
|
|
|
Request: r,
|
|
|
|
Challenge: hex.EncodeToString(challenge),
|
|
|
|
}
|
|
|
|
|
|
|
|
rl.clientsMutex.Lock()
|
|
|
|
rl.clients[ws] = make([]listenerSpec, 0, 2)
|
|
|
|
rl.clientsMutex.Unlock()
|
|
|
|
|
|
|
|
ctx, cancel := context.WithCancel(
|
|
|
|
context.WithValue(
|
|
|
|
context.Background(),
|
|
|
|
wsKey, ws,
|
|
|
|
),
|
|
|
|
)
|
|
|
|
|
|
|
|
kill := func() {
|
|
|
|
for _, ondisconnect := range rl.OnDisconnect {
|
|
|
|
ondisconnect(ctx)
|
|
|
|
}
|
|
|
|
|
|
|
|
ticker.Stop()
|
|
|
|
cancel()
|
|
|
|
conn.Close()
|
|
|
|
|
|
|
|
rl.removeClientAndListeners(ws)
|
|
|
|
}
|
|
|
|
|
|
|
|
go func() {
|
|
|
|
defer kill()
|
|
|
|
|
|
|
|
conn.SetReadLimit(rl.MaxMessageSize)
|
|
|
|
conn.SetReadDeadline(time.Now().Add(rl.PongWait))
|
|
|
|
conn.SetPongHandler(func(string) error {
|
|
|
|
conn.SetReadDeadline(time.Now().Add(rl.PongWait))
|
|
|
|
return nil
|
|
|
|
})
|
|
|
|
|
|
|
|
for _, onconnect := range rl.OnConnect {
|
|
|
|
onconnect(ctx)
|
|
|
|
}
|
|
|
|
|
|
|
|
for {
|
|
|
|
typ, message, err := conn.ReadMessage()
|
|
|
|
if err != nil {
|
|
|
|
if websocket.IsUnexpectedCloseError(
|
|
|
|
err,
|
|
|
|
websocket.CloseNormalClosure, // 1000
|
|
|
|
websocket.CloseGoingAway, // 1001
|
|
|
|
websocket.CloseNoStatusReceived, // 1005
|
|
|
|
websocket.CloseAbnormalClosure, // 1006
|
|
|
|
4537, // some client seems to send many of these
|
|
|
|
) {
|
|
|
|
rl.Log.Printf("unexpected close error from %s: %v\n", r.Header.Get("X-Forwarded-For"), err)
|
|
|
|
}
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
if typ == websocket.PingMessage {
|
|
|
|
ws.WriteMessage(websocket.PongMessage, nil)
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
|
|
|
go func(message []byte) {
|
|
|
|
envelope := nostr.ParseMessage(message)
|
|
|
|
if envelope == nil {
|
|
|
|
// stop silently
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
switch env := envelope.(type) {
|
|
|
|
case *nostr.EventEnvelope:
|
|
|
|
// check id
|
2024-09-25 02:29:00 +00:00
|
|
|
if env.Event.CheckID() {
|
2024-09-17 01:01:42 +00:00
|
|
|
ws.WriteJSON(nostr.OKEnvelope{EventID: env.Event.ID, OK: false, Reason: "invalid: id is computed incorrectly"})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
// check signature
|
|
|
|
if ok, err := env.Event.CheckSignature(); err != nil {
|
|
|
|
ws.WriteJSON(nostr.OKEnvelope{EventID: env.Event.ID, OK: false, Reason: "error: failed to verify signature"})
|
|
|
|
return
|
|
|
|
} else if !ok {
|
|
|
|
ws.WriteJSON(nostr.OKEnvelope{EventID: env.Event.ID, OK: false, Reason: "invalid: signature is invalid"})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
// check NIP-70 protected
|
|
|
|
for _, v := range env.Event.Tags {
|
|
|
|
if len(v) == 1 && v[0] == "-" {
|
|
|
|
msg := "must be published by event author"
|
|
|
|
authed := GetAuthed(ctx)
|
|
|
|
if authed == "" {
|
|
|
|
RequestAuth(ctx)
|
|
|
|
ws.WriteJSON(nostr.OKEnvelope{
|
|
|
|
EventID: env.Event.ID,
|
|
|
|
OK: false,
|
|
|
|
Reason: "auth-required: " + msg,
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
if authed != env.Event.PubKey {
|
|
|
|
ws.WriteJSON(nostr.OKEnvelope{
|
|
|
|
EventID: env.Event.ID,
|
|
|
|
OK: false,
|
|
|
|
Reason: "blocked: " + msg,
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
srl := rl
|
|
|
|
if rl.getSubRelayFromEvent != nil {
|
|
|
|
srl = rl.getSubRelayFromEvent(&env.Event)
|
|
|
|
}
|
|
|
|
|
|
|
|
var ok bool
|
|
|
|
var writeErr error
|
|
|
|
var skipBroadcast bool
|
|
|
|
|
|
|
|
if env.Event.Kind == 5 {
|
|
|
|
// this always returns "blocked: " whenever it returns an error
|
|
|
|
writeErr = srl.handleDeleteRequest(ctx, &env.Event)
|
|
|
|
} else {
|
|
|
|
// this will also always return a prefixed reason
|
|
|
|
skipBroadcast, writeErr = srl.AddEvent(ctx, &env.Event)
|
|
|
|
}
|
|
|
|
|
|
|
|
var reason string
|
|
|
|
if writeErr == nil {
|
|
|
|
ok = true
|
|
|
|
for _, ovw := range srl.OverwriteResponseEvent {
|
|
|
|
ovw(ctx, &env.Event)
|
|
|
|
}
|
|
|
|
if !skipBroadcast {
|
|
|
|
srl.notifyListeners(&env.Event)
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
reason = writeErr.Error()
|
|
|
|
if strings.HasPrefix(reason, "auth-required:") {
|
|
|
|
RequestAuth(ctx)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
ws.WriteJSON(nostr.OKEnvelope{EventID: env.Event.ID, OK: ok, Reason: reason})
|
|
|
|
case *nostr.CountEnvelope:
|
|
|
|
if rl.CountEvents == nil {
|
|
|
|
ws.WriteJSON(nostr.ClosedEnvelope{SubscriptionID: env.SubscriptionID, Reason: "unsupported: this relay does not support NIP-45"})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
var total int64
|
|
|
|
for _, filter := range env.Filters {
|
|
|
|
srl := rl
|
|
|
|
if rl.getSubRelayFromFilter != nil {
|
|
|
|
srl = rl.getSubRelayFromFilter(filter)
|
|
|
|
}
|
|
|
|
total += srl.handleCountRequest(ctx, ws, filter)
|
|
|
|
}
|
|
|
|
ws.WriteJSON(nostr.CountEnvelope{SubscriptionID: env.SubscriptionID, Count: &total})
|
|
|
|
case *nostr.ReqEnvelope:
|
|
|
|
eose := sync.WaitGroup{}
|
|
|
|
eose.Add(len(env.Filters))
|
|
|
|
|
|
|
|
// a context just for the "stored events" request handler
|
|
|
|
reqCtx, cancelReqCtx := context.WithCancelCause(ctx)
|
|
|
|
|
|
|
|
// expose subscription id in the context
|
|
|
|
reqCtx = context.WithValue(reqCtx, subscriptionIdKey, env.SubscriptionID)
|
|
|
|
|
|
|
|
// handle each filter separately -- dispatching events as they're loaded from databases
|
|
|
|
for _, filter := range env.Filters {
|
|
|
|
srl := rl
|
|
|
|
if rl.getSubRelayFromFilter != nil {
|
|
|
|
srl = rl.getSubRelayFromFilter(filter)
|
|
|
|
}
|
|
|
|
err := srl.handleRequest(reqCtx, env.SubscriptionID, &eose, ws, filter)
|
|
|
|
if err != nil {
|
|
|
|
// fail everything if any filter is rejected
|
|
|
|
reason := err.Error()
|
|
|
|
if strings.HasPrefix(reason, "auth-required:") {
|
|
|
|
RequestAuth(ctx)
|
|
|
|
}
|
|
|
|
ws.WriteJSON(nostr.ClosedEnvelope{SubscriptionID: env.SubscriptionID, Reason: reason})
|
|
|
|
cancelReqCtx(errors.New("filter rejected"))
|
|
|
|
return
|
|
|
|
} else {
|
|
|
|
rl.addListener(ws, env.SubscriptionID, srl, filter, cancelReqCtx)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
go func() {
|
|
|
|
// when all events have been loaded from databases and dispatched
|
|
|
|
// we can cancel the context and fire the EOSE message
|
|
|
|
eose.Wait()
|
|
|
|
cancelReqCtx(nil)
|
|
|
|
ws.WriteJSON(nostr.EOSEEnvelope(env.SubscriptionID))
|
|
|
|
}()
|
|
|
|
case *nostr.CloseEnvelope:
|
|
|
|
id := string(*env)
|
|
|
|
rl.removeListenerId(ws, id)
|
|
|
|
case *nostr.AuthEnvelope:
|
|
|
|
wsBaseUrl := strings.Replace(rl.ServiceURL, "http", "ws", 1)
|
|
|
|
if pubkey, ok := nip42.ValidateAuthEvent(&env.Event, ws.Challenge, wsBaseUrl); ok {
|
|
|
|
ws.AuthedPublicKey = pubkey
|
|
|
|
ws.authLock.Lock()
|
|
|
|
if ws.Authed != nil {
|
|
|
|
close(ws.Authed)
|
|
|
|
ws.Authed = nil
|
|
|
|
}
|
|
|
|
ws.authLock.Unlock()
|
|
|
|
ws.WriteJSON(nostr.OKEnvelope{EventID: env.Event.ID, OK: true})
|
|
|
|
} else {
|
|
|
|
ws.WriteJSON(nostr.OKEnvelope{EventID: env.Event.ID, OK: false, Reason: "error: failed to authenticate"})
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}(message)
|
|
|
|
}
|
|
|
|
}()
|
|
|
|
|
|
|
|
go func() {
|
|
|
|
defer kill()
|
|
|
|
|
|
|
|
for {
|
|
|
|
select {
|
|
|
|
case <-ctx.Done():
|
|
|
|
return
|
|
|
|
case <-ticker.C:
|
|
|
|
err := ws.WriteMessage(websocket.PingMessage, nil)
|
|
|
|
if err != nil {
|
|
|
|
if !strings.HasSuffix(err.Error(), "use of closed network connection") {
|
|
|
|
rl.Log.Printf("error writing ping: %v; closing websocket\n", err)
|
|
|
|
}
|
|
|
|
return
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}()
|
|
|
|
}
|