178 lines
6.2 KiB
Go
178 lines
6.2 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
|
|
"fiatjaf.com/nostr"
|
|
"fiatjaf.com/nostr/eventstore"
|
|
"fiatjaf.com/nostr/khatru"
|
|
|
|
"github.com/nosterm/relay/internal/federation"
|
|
"github.com/nosterm/relay/internal/group"
|
|
"github.com/nosterm/relay/internal/relayinfo"
|
|
)
|
|
|
|
// relayConfig holds everything buildRelay needs to assemble a relay. It is the
|
|
// seam between env/flag parsing (main) and the wiring (buildRelay), so tests can
|
|
// construct a relay with an in-memory store and explicit config.
|
|
type relayConfig struct {
|
|
secretKey nostr.SecretKey
|
|
store eventstore.Store
|
|
name string
|
|
description string
|
|
contact string
|
|
motd string
|
|
fedCfg federation.Config
|
|
}
|
|
|
|
// relayApp is a fully-wired relay: the khatru relay, its HTTP handler, the group
|
|
// state authority, and (when configured) the federator. main starts/stops these;
|
|
// tests assert against them.
|
|
type relayApp struct {
|
|
relay *khatru.Relay
|
|
handler http.Handler
|
|
groups *group.State
|
|
federator *federation.Federator
|
|
pubKey nostr.PubKey
|
|
}
|
|
|
|
// buildRelay assembles the relay from its internal packages and wires khatru's
|
|
// hooks: NIP-29 enforcement on ingress, addressable-list (re)publishing on
|
|
// group mutations, federation ingress/egress, and NIP-11 serving with the
|
|
// Nosterm capability handshake. It does NOT rebuild state, start retention,
|
|
// or start federation — the caller owns lifecycle so tests can drive it.
|
|
func buildRelay(cfg relayConfig) *relayApp {
|
|
pk := nostr.GetPublicKey(cfg.secretKey)
|
|
|
|
groups := group.NewState(cfg.secretKey)
|
|
if cfg.fedCfg.Enabled() {
|
|
// Chat on a federated channel is accepted regardless of local membership.
|
|
groups.IsFederated = cfg.fedCfg.FederatesChannel
|
|
}
|
|
|
|
relay := khatru.NewRelay()
|
|
relay.Info.Name = cfg.name
|
|
relay.Info.Description = cfg.description
|
|
relay.Info.Contact = cfg.contact
|
|
relay.Info.PubKey = &pk
|
|
relay.Info.SupportedNIPs = []any{1, 11, 29, 42}
|
|
relay.UseEventstore(cfg.store, 500)
|
|
|
|
// Enforce NIP-29 group rules on every write.
|
|
relay.OnEvent = func(ctx context.Context, evt nostr.Event) (reject bool, msg string) {
|
|
return groups.Evaluate(evt)
|
|
}
|
|
|
|
// Federator: connects to peers, pulls their chat (injecting it through the
|
|
// relay's own add pipeline — whose eventstore dedup breaks forwarding loops),
|
|
// and forwards locally-saved chat to mirror peers via the OnEventSaved hook.
|
|
var fed *federation.Federator
|
|
if cfg.fedCfg.Enabled() {
|
|
fed = federation.NewFederator(cfg.fedCfg, func(ctx context.Context, evt nostr.Event) bool {
|
|
// Ensure the channel exists locally so federated chat has a home, then
|
|
// run it through the normal pipeline (OnEvent → store → OnEventSaved).
|
|
// AddEvent returns skipBroadcast=true on a duplicate: that is our loop
|
|
// guard — a re-seen event never reaches the egress hook again.
|
|
groups.EnsureFederatedGroup(group.GroupIDFromEvent(evt))
|
|
skip, err := relay.AddEvent(ctx, evt)
|
|
if err != nil || skip {
|
|
return false
|
|
}
|
|
// AddEvent stores but does not broadcast; deliver to local subscribers.
|
|
relay.BroadcastEvent(evt)
|
|
return true
|
|
})
|
|
}
|
|
|
|
// After a group-mutating event is stored, (re)publish the affected NIP-29
|
|
// addressable lists (39000 metadata, 39001 admins, 39002 members) so clients
|
|
// can discover the channel and track who may moderate it.
|
|
publish := func(evt nostr.Event, ok bool) {
|
|
if ok {
|
|
relay.BroadcastEvent(evt)
|
|
_ = cfg.store.SaveEvent(evt)
|
|
}
|
|
}
|
|
relay.OnEventSaved = func(ctx context.Context, evt nostr.Event) {
|
|
id := group.GroupIDFromEvent(evt)
|
|
if id == "" {
|
|
return
|
|
}
|
|
switch evt.Kind {
|
|
case nostr.KindSimpleGroupCreateGroup:
|
|
publish(groups.MetadataEvent(ctx, id))
|
|
publish(groups.AdminsEvent(id))
|
|
publish(groups.MembersEvent(id))
|
|
case nostr.KindSimpleGroupEditMetadata:
|
|
publish(groups.MetadataEvent(ctx, id))
|
|
case nostr.KindSimpleGroupJoinRequest,
|
|
nostr.KindSimpleGroupLeaveRequest,
|
|
nostr.KindSimpleGroupPutUser,
|
|
nostr.KindSimpleGroupRemoveUser:
|
|
// Membership (and possibly admin) changed → refresh both lists.
|
|
publish(groups.AdminsEvent(id))
|
|
publish(groups.MembersEvent(id))
|
|
case nostr.KindSimpleGroupChatMessage, nostr.KindSimpleGroupThreadedReply:
|
|
// Egress: forward newly-saved chat to mirror peers. A chat event
|
|
// re-injected from a peer is a store duplicate and never reaches here
|
|
// (AddEvent stops on dedup before OnEventSaved), so this can't loop.
|
|
if fed != nil {
|
|
fed.Forward(evt)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Serve NIP-11 ourselves so we can add the nostermd `features` array
|
|
// (the upstream document type has no field for it), then dispatch the real
|
|
// protocol traffic to khatru's specific handlers. We must NOT call
|
|
// relay.ServeHTTP here: khatru's ServeHTTP dispatches through this same mux,
|
|
// so routing "/" back into it recurses infinitely (stack overflow on the
|
|
// first non-NIP-11 request). Call the concrete handlers directly instead.
|
|
mux := relay.Router()
|
|
federated := cfg.fedCfg.Enabled()
|
|
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
|
switch {
|
|
case r.Header.Get("Accept") == "application/nostr+json":
|
|
serveNIP11(w, cfg.name, cfg.description, cfg.contact, cfg.motd, pk, federated)
|
|
case r.Header.Get("Upgrade") == "websocket":
|
|
relay.HandleWebsocket(w, r)
|
|
case r.Header.Get("Content-Type") == "application/nostr+json+rpc":
|
|
relay.HandleNIP86(w, r)
|
|
default:
|
|
http.Error(w, "expected a nostr relay connection (WebSocket) or NIP-11 request", http.StatusUpgradeRequired)
|
|
}
|
|
})
|
|
|
|
return &relayApp{
|
|
relay: relay,
|
|
handler: mux,
|
|
groups: groups,
|
|
federator: fed,
|
|
pubKey: pk,
|
|
}
|
|
}
|
|
|
|
// serveNIP11 emits a standard NIP-11 document merged with the Nosterm handshake
|
|
// keys (software/version/features), plus an optional `motd` the client shows in
|
|
// the relay's server window.
|
|
func serveNIP11(w http.ResponseWriter, name, description, contact, motd string, pk nostr.PubKey, federated bool) {
|
|
doc := map[string]any{
|
|
"name": name,
|
|
"description": description,
|
|
"contact": contact,
|
|
"pubkey": pk.Hex(),
|
|
"supported_nips": []int{1, 11, 29, 42},
|
|
}
|
|
if motd != "" {
|
|
doc["motd"] = motd
|
|
}
|
|
for k, v := range relayinfo.Extras(federated) {
|
|
doc[k] = v
|
|
}
|
|
w.Header().Set("Content-Type", "application/nostr+json")
|
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
_ = json.NewEncoder(w).Encode(doc)
|
|
}
|