diff --git a/README.md b/README.md index 5a77c01..d95cd00 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,7 @@ Endpoints: - `PUT /relay/{id}` - Updates an existing virtual relay config. Returns 200 on success, 404 if the id doesn't exist, 409 if the new schema/host conflicts with another relay. - `PATCH /relay/{id}` - Partially updates an existing virtual relay config by recursively merging the provided JSON. Returns 200 on success, 404 if the id doesn't exist, 409 if the new schema/host conflicts, 400 for invalid config. Use `null` values to remove fields. - `DELETE /relay/{id}` - Deletes a virtual relay config. Returns 200 on success, 404 if the id doesn't exist. +- `GET /relay/{id}/members` - Returns relay members as JSON (`{"members": ["", ...]}`). Returns 200 on success, 404 if the relay id does not exist. ## Scripts diff --git a/zooid/api.go b/zooid/api.go index c36adac..2df0e91 100644 --- a/zooid/api.go +++ b/zooid/api.go @@ -8,6 +8,7 @@ import ( "os" "path/filepath" "regexp" + "sort" "strings" "fiatjaf.com/nostr" @@ -67,6 +68,24 @@ func (api *APIHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } + if len(parts) > 2 { + if len(parts) == 3 && parts[2] == "members" { + if r.Method != http.MethodGet { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + + api.listRelayMembers(w, id) + return + } + + // Keep trailing-slash compatibility for existing /relay/{id}/ calls. + if len(parts) != 3 || parts[2] != "" { + writeError(w, http.StatusNotFound, "not found") + return + } + } + switch r.Method { case http.MethodPost: api.createRelay(w, r, id) @@ -81,6 +100,69 @@ func (api *APIHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } } +// listRelayMembers returns members for a relay as an array of pubkeys. +func (api *APIHandler) listRelayMembers(w http.ResponseWriter, id string) { + members, err := api.resolveRelayMembers(id) + if err != nil { + if os.IsNotExist(err) { + writeError(w, http.StatusNotFound, "relay not found") + } else { + writeError(w, http.StatusInternalServerError, fmt.Sprintf("failed to load relay members: %v", err)) + } + return + } + + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string][]string{"members": members}) +} + +func (api *APIHandler) resolveRelayMembers(id string) ([]string, error) { + if members, ok := api.getMembersFromLoadedInstance(id); ok { + return members, nil + } + + configPath := api.configPath(id) + if err := api.checkConfigExists(configPath); err != nil { + return nil, err + } + + instance, err := MakeInstanceFromPath(configPath) + if err != nil { + return nil, err + } + defer instance.Cleanup() + + memberSet := make(map[string]struct{}) + for _, pubkey := range instance.Management.GetMembers() { + memberSet[pubkey.Hex()] = struct{}{} + } + + return sortedMembers(memberSet), nil +} + +func (api *APIHandler) getMembersFromLoadedInstance(id string) ([]string, bool) { + instancesMux.RLock() + instance, exists := instancesByName[id+".toml"] + instancesMux.RUnlock() + + if !exists || instance == nil || instance.Config == nil || instance.Management == nil { + return nil, false + } + + memberSet := make(map[string]struct{}) + for _, pubkey := range instance.Management.GetMembers() { + memberSet[pubkey.Hex()] = struct{}{} + } + + return sortedMembers(memberSet), true +} + +func sortedMembers(memberSet map[string]struct{}) []string { + members := Keys(memberSet) + sort.Strings(members) + return members +} + // writeError writes a JSON error response func writeError(w http.ResponseWriter, status int, message string) { w.WriteHeader(status) diff --git a/zooid/api_test.go b/zooid/api_test.go index 07ab751..1ab6b4c 100644 --- a/zooid/api_test.go +++ b/zooid/api_test.go @@ -604,6 +604,166 @@ func TestAPIHandler_DeleteRelay(t *testing.T) { }) } +func TestAPIHandler_ListRelayMembers(t *testing.T) { + configDir := t.TempDir() + + secretKey := nostr.Generate() + pubkey := secretKey.Public() + whitelist := pubkey.Hex() + api := NewAPIHandler(whitelist, configDir) + + t.Run("list members from loaded relay instance", func(t *testing.T) { + member1 := nostr.Generate().Public() + member2 := nostr.Generate().Public() + + management := createTestManagementStore() + if err := management.AllowPubkey(member1); err != nil { + t.Fatalf("failed to add first member: %v", err) + } + if err := management.AllowPubkey(member2); err != nil { + t.Fatalf("failed to add second member: %v", err) + } + + instancesMux.Lock() + oldByName := instancesByName + oldByHost := instancesByHost + instancesByName = map[string]*Instance{ + "loaded.toml": { + Config: &Config{Inactive: false}, + Management: management, + }, + } + instancesByHost = map[string]*Instance{} + instancesMux.Unlock() + defer func() { + instancesMux.Lock() + instancesByName = oldByName + instancesByHost = oldByHost + instancesMux.Unlock() + }() + + req := createAuthenticatedRequest(http.MethodGet, "http://api.example.com/relay/loaded/members", secretKey, nil) + w := httptest.NewRecorder() + + api.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d: %s", http.StatusOK, w.Code, w.Body.String()) + } + + var payload struct { + Members []string `json:"members"` + } + if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + + expected := map[string]struct{}{ + member1.Hex(): {}, + member2.Hex(): {}, + } + + if len(payload.Members) != len(expected) { + t.Fatalf("expected %d members, got %d (%v)", len(expected), len(payload.Members), payload.Members) + } + + for _, actual := range payload.Members { + if _, ok := expected[actual]; !ok { + t.Fatalf("unexpected member in response: %s", actual) + } + } + }) + + t.Run("list members from config fallback", func(t *testing.T) { + relaySecret := nostr.Generate() + owner := nostr.Generate().Public() + rolePubkey := nostr.Generate().Public() + + config := &Config{ + Host: "members.example.com", + Schema: "members_" + RandomString(8), + Secret: relaySecret.Hex(), + Roles: map[string]Role{ + "admin": { + Pubkeys: []string{rolePubkey.Hex()}, + }, + }, + } + config.Info.Pubkey = owner.Hex() + + if err := api.saveConfig(api.configPath("fallback"), config); err != nil { + t.Fatalf("failed to save config: %v", err) + } + + instancesMux.Lock() + oldByName := instancesByName + oldByHost := instancesByHost + instancesByName = map[string]*Instance{} + instancesByHost = map[string]*Instance{} + instancesMux.Unlock() + defer func() { + instancesMux.Lock() + instancesByName = oldByName + instancesByHost = oldByHost + instancesMux.Unlock() + }() + + req := createAuthenticatedRequest(http.MethodGet, "http://api.example.com/relay/fallback/members", secretKey, nil) + w := httptest.NewRecorder() + + api.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d: %s", http.StatusOK, w.Code, w.Body.String()) + } + + var payload struct { + Members []string `json:"members"` + } + if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + + expected := map[string]struct{}{ + owner.Hex(): {}, + relaySecret.Public().Hex(): {}, + rolePubkey.Hex(): {}, + } + + if len(payload.Members) != len(expected) { + t.Fatalf("expected %d members, got %d (%v)", len(expected), len(payload.Members), payload.Members) + } + + for _, actual := range payload.Members { + if _, ok := expected[actual]; !ok { + t.Fatalf("unexpected member in response: %s", actual) + } + } + }) + + t.Run("non-existent relay returns not found", func(t *testing.T) { + req := createAuthenticatedRequest(http.MethodGet, "http://api.example.com/relay/missing/members", secretKey, nil) + w := httptest.NewRecorder() + + api.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Fatalf("expected status %d, got %d", http.StatusNotFound, w.Code) + } + }) + + t.Run("members endpoint rejects non-get methods", func(t *testing.T) { + req := createAuthenticatedRequest(http.MethodPost, "http://api.example.com/relay/loaded/members", secretKey, []byte("{}")) + w := httptest.NewRecorder() + + api.ServeHTTP(w, req) + + if w.Code != http.StatusMethodNotAllowed { + t.Fatalf("expected status %d, got %d", http.StatusMethodNotAllowed, w.Code) + } + }) +} + func TestAPIHandler_MethodNotAllowed(t *testing.T) { configDir := t.TempDir() diff --git a/zooid/config.go b/zooid/config.go index b81076e..50b8574 100644 --- a/zooid/config.go +++ b/zooid/config.go @@ -64,6 +64,11 @@ type Config struct { func LoadConfig(filename string) (*Config, error) { path := filepath.Join(Env("CONFIG"), filename) + return LoadConfigFromPath(path) +} + +func LoadConfigFromPath(path string) (*Config, error) { + var config Config if _, err := toml.DecodeFile(path, &config); err != nil { return nil, fmt.Errorf("Failed to parse config file %s: %w", path, err) diff --git a/zooid/instance.go b/zooid/instance.go index 624cd57..b9dbc6f 100644 --- a/zooid/instance.go +++ b/zooid/instance.go @@ -28,6 +28,19 @@ func MakeInstance(filename string) (*Instance, error) { return nil, err } + return makeInstance(config, filename) +} + +func MakeInstanceFromPath(path string) (*Instance, error) { + config, err := LoadConfigFromPath(path) + if err != nil { + return nil, err + } + + return makeInstance(config, path) +} + +func makeInstance(config *Config, source string) (*Instance, error) { relay := khatru.NewRelay() events := &EventStore{ @@ -122,7 +135,7 @@ func MakeInstance(filename string) (*Instance, error) { // Initialize the database if err := instance.Events.Init(); err != nil { - log.Fatal("Failed to initialize event store for ", filename, ": ", err) + log.Fatal("Failed to initialize event store for ", source, ": ", err) } // Enable extra functionality diff --git a/zooid/lib.go b/zooid/lib.go index 8427b0d..c419c85 100644 --- a/zooid/lib.go +++ b/zooid/lib.go @@ -28,6 +28,15 @@ func Dispatch(hostname string) (*Instance, bool) { return instance, exists } +func cleanupIfInactive(instance *Instance) bool { + if instance != nil && instance.Config != nil && instance.Config.Inactive { + instance.Cleanup() + return true + } + + return false +} + func Start() { dataDir := Env("DATA") if err := os.MkdirAll(dataDir, 0755); err != nil { @@ -63,7 +72,7 @@ func Start() { if err != nil { log.Printf("Failed to make instance for %s: %v", entry.Name(), err) - } else if instance.Config.Inactive { + } else if cleanupIfInactive(instance) { log.Printf("Skipped inactive %s", entry.Name()) } else { instancesByHost[instance.Config.Host] = instance @@ -110,7 +119,7 @@ func Start() { instance, err := MakeInstance(filename) if err != nil { log.Printf("Failed to reload %s: %v", filename, err) - } else if instance.Config.Inactive { + } else if cleanupIfInactive(instance) { log.Printf("Skipped inactive %s", filename) } else { instancesByHost[instance.Config.Host] = instance