seed default channel(s) on boot
fresh relay seeds configured open channels (RELAY_DEFAULT_CHANNELS, default general) so a client always has a room. relay-signed 9007 through the normal pipeline; idempotent; never reopens a closed group. also adds mission/roadmap and separation rationale to the readme.
This commit is contained in:
@@ -5,10 +5,27 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"fiatjaf.com/nostr"
|
||||
)
|
||||
|
||||
// parseChannels splits a comma-separated channel list into cleaned group ids: a
|
||||
// leading `#` is tolerated and blanks/dupes are dropped. Order is preserved.
|
||||
func parseChannels(s string) []string {
|
||||
var out []string
|
||||
seen := map[string]bool{}
|
||||
for _, part := range strings.Split(s, ",") {
|
||||
id := strings.TrimPrefix(strings.TrimSpace(part), "#")
|
||||
if id == "" || seen[id] {
|
||||
continue
|
||||
}
|
||||
seen[id] = true
|
||||
out = append(out, id)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func envOr(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -63,8 +64,10 @@ func main() {
|
||||
contact: envOr("RELAY_CONTACT", "relay@nosterm.com"),
|
||||
// Optional message-of-the-day shown in the client's relay server window;
|
||||
// defaults to the nerdworks.io banner when unset.
|
||||
motd: envOr("RELAY_MOTD", defaultMOTD),
|
||||
fedCfg: fedCfg,
|
||||
motd: envOr("RELAY_MOTD", defaultMOTD),
|
||||
// Seed at least one open channel so a fresh relay is never empty.
|
||||
defaultChannels: parseChannels(envOr("RELAY_DEFAULT_CHANNELS", "general")),
|
||||
fedCfg: fedCfg,
|
||||
})
|
||||
|
||||
// Reconstruct membership/metadata from persisted events so groups survive
|
||||
@@ -73,6 +76,10 @@ func main() {
|
||||
log.Printf("replayed %d group management event(s) from storage", replayed)
|
||||
}
|
||||
|
||||
// Seed default channel(s) after replay so a fresh relay always has a room to
|
||||
// join; existing channels are left untouched.
|
||||
app.SeedDefaultChannels(context.Background())
|
||||
|
||||
// Retention: prune old chat messages so the store doesn't grow forever.
|
||||
stopRetention := retention.Start(store, retention.Config{
|
||||
MaxAge: time.Duration(envIntOr("RELAY_RETENTION_DAYS", 0)) * 24 * time.Hour,
|
||||
|
||||
+48
-11
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"fiatjaf.com/nostr"
|
||||
@@ -24,18 +25,23 @@ type relayConfig struct {
|
||||
description string
|
||||
contact string
|
||||
motd string
|
||||
fedCfg federation.Config
|
||||
// defaultChannels are group ids seeded as open channels on boot so a fresh
|
||||
// relay is never empty — a client always has at least one room to join.
|
||||
defaultChannels []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
|
||||
relay *khatru.Relay
|
||||
handler http.Handler
|
||||
groups *group.State
|
||||
federator *federation.Federator
|
||||
pubKey nostr.PubKey
|
||||
secretKey nostr.SecretKey
|
||||
defaultChannels []string
|
||||
}
|
||||
|
||||
// buildRelay assembles the relay from its internal packages and wires khatru's
|
||||
@@ -146,11 +152,42 @@ func buildRelay(cfg relayConfig) *relayApp {
|
||||
})
|
||||
|
||||
return &relayApp{
|
||||
relay: relay,
|
||||
handler: mux,
|
||||
groups: groups,
|
||||
federator: fed,
|
||||
pubKey: pk,
|
||||
relay: relay,
|
||||
handler: mux,
|
||||
groups: groups,
|
||||
federator: fed,
|
||||
pubKey: pk,
|
||||
secretKey: cfg.secretKey,
|
||||
defaultChannels: cfg.defaultChannels,
|
||||
}
|
||||
}
|
||||
|
||||
// SeedDefaultChannels creates any configured default channel that doesn't
|
||||
// already exist, so a fresh relay always has at least one room to join. Each
|
||||
// missing channel is created by the relay identity via a signed kind-9007
|
||||
// create-group event pushed through the normal add pipeline (OnEvent → store →
|
||||
// OnEventSaved), so it persists, replays on restart, and publishes its 39000/
|
||||
// 39001/39002 lists exactly like a client-created group. Existing channels are
|
||||
// left untouched. Call after Rebuild so it only seeds what's genuinely missing.
|
||||
func (app *relayApp) SeedDefaultChannels(ctx context.Context) {
|
||||
for _, id := range app.defaultChannels {
|
||||
if id == "" || app.groups.Has(id) {
|
||||
continue
|
||||
}
|
||||
evt := nostr.Event{
|
||||
Kind: nostr.KindSimpleGroupCreateGroup,
|
||||
CreatedAt: nostr.Now(),
|
||||
Tags: nostr.Tags{{"h", id}},
|
||||
}
|
||||
if err := evt.Sign(app.secretKey); err != nil {
|
||||
log.Printf("could not sign default-channel create for %q: %v", id, err)
|
||||
continue
|
||||
}
|
||||
if _, err := app.relay.AddEvent(ctx, evt); err != nil {
|
||||
log.Printf("could not seed default channel %q: %v", id, err)
|
||||
continue
|
||||
}
|
||||
log.Printf("seeded default channel %q", id)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -192,3 +192,51 @@ func TestMultiUserAdminOverWebsocket(t *testing.T) {
|
||||
t.Fatal("admin did not receive the admitted member's message")
|
||||
}
|
||||
}
|
||||
|
||||
// SeedDefaultChannels creates configured channels that don't exist yet, signed
|
||||
// by the relay, and is idempotent: seeding an already-present channel is a
|
||||
// no-op. A seeded open channel accepts chat without an explicit create.
|
||||
func TestSeedDefaultChannels(t *testing.T) {
|
||||
store := &slicestore.SliceStore{}
|
||||
if err := store.Init(); err != nil {
|
||||
t.Fatalf("store init: %v", err)
|
||||
}
|
||||
app := buildRelay(relayConfig{
|
||||
secretKey: nostr.Generate(),
|
||||
store: store,
|
||||
name: "test-relay",
|
||||
defaultChannels: []string{"general", "general", ""}, // dupes/blanks ignored
|
||||
})
|
||||
|
||||
ctx := context.Background()
|
||||
app.SeedDefaultChannels(ctx)
|
||||
if !app.groups.Has("general") {
|
||||
t.Fatal("expected default channel 'general' to be seeded")
|
||||
}
|
||||
// The seed event is signed by the relay identity.
|
||||
if !app.groups.IsAdmin("general", app.pubKey) {
|
||||
t.Fatal("expected the relay to be admin of the seeded channel")
|
||||
}
|
||||
|
||||
// Idempotent: a second seed doesn't error or duplicate.
|
||||
app.SeedDefaultChannels(ctx)
|
||||
|
||||
// Seeding never clobbers an existing channel: pre-create one closed, then a
|
||||
// seed of the same id must leave it closed.
|
||||
closed := nostr.Event{Kind: nostr.KindSimpleGroupCreateGroup, CreatedAt: nostr.Now(), Tags: nostr.Tags{{"h", "ops"}, {"closed"}}}
|
||||
mustSign(t, app.secretKey, &closed)
|
||||
if _, err := app.relay.AddEvent(ctx, closed); err != nil {
|
||||
t.Fatalf("pre-create closed group: %v", err)
|
||||
}
|
||||
app.defaultChannels = []string{"ops"}
|
||||
app.SeedDefaultChannels(ctx)
|
||||
// A non-member posting to the still-closed 'ops' must be rejected.
|
||||
stranger := nostr.Generate()
|
||||
if reject, _ := app.groups.Evaluate(func() nostr.Event {
|
||||
e := nostr.Event{Kind: nostr.KindSimpleGroupChatMessage, CreatedAt: nostr.Now(), Tags: nostr.Tags{{"h", "ops"}}}
|
||||
mustSign(t, stranger, &e)
|
||||
return e
|
||||
}()); !reject {
|
||||
t.Fatal("seed must not reopen a pre-existing closed channel")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user