initial public relay build repo
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user