feat: add GET /relay/{id}/members endpoint
#6
@@ -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": ["<pubkey>", ...]}`). Returns 200 on success, 404 if the relay id does not exist.
|
||||
|
||||
## Scripts
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
+14
-1
@@ -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
|
||||
|
||||
+11
-2
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user