Compare commits

..

3 Commits

Author SHA1 Message Date
Jon Staab 53bf913fe6 Fix some bugs
Docker / build-and-push-image (push) Has been cancelled
2026-04-22 14:40:37 -07:00
Jon Staab b3c2ee7f87 Use serve mux for api handler 2026-04-22 14:07:54 -07:00
Jon Staab 081c4765ed Do a little cleanup on the api 2026-04-22 13:52:20 -07:00
9 changed files with 123 additions and 116 deletions
+68 -79
View File
@@ -13,12 +13,14 @@ import (
"fiatjaf.com/nostr"
"github.com/BurntSushi/toml"
"github.com/gosimple/slug"
)
// APIHandler handles REST API requests for managing virtual relays
type APIHandler struct {
whitelist map[string]bool
configDir string
mux http.Handler
}
// NewAPIHandler creates a new API handler with the given whitelist
@@ -30,78 +32,48 @@ func NewAPIHandler(whitelist string, configDir string) *APIHandler {
w[pubkey] = true
}
}
return &APIHandler{
api := &APIHandler{
whitelist: w,
configDir: configDir,
}
api.mux = api.buildMux()
return api
}
func (api *APIHandler) buildMux() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("POST /relay/{id}", api.auth(api.createRelay))
mux.HandleFunc("PUT /relay/{id}", api.auth(api.updateRelay))
mux.HandleFunc("PATCH /relay/{id}", api.auth(api.patchRelay))
mux.HandleFunc("DELETE /relay/{id}", api.auth(api.deleteRelay))
mux.HandleFunc("GET /relay/{id}/members", api.auth(api.listRelayMembers))
return mux
}
func (api *APIHandler) auth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
pubkey, err := validateNIP98Auth(r)
if err != nil {
writeError(w, http.StatusUnauthorized, err.Error())
return
}
if !api.whitelist[pubkey.Hex()] {
writeError(w, http.StatusForbidden, "pubkey not in whitelist")
return
}
next(w, r)
}
}
// ServeHTTP implements the http.Handler interface
func (api *APIHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
// Authenticate the request using NIP-98
pubkey, err := validateNIP98Auth(r)
if err != nil {
writeError(w, http.StatusUnauthorized, err.Error())
return
}
// Check if pubkey is in whitelist
if !api.whitelist[pubkey.Hex()] {
writeError(w, http.StatusForbidden, "pubkey not in whitelist")
return
}
// Route the request
path := strings.TrimPrefix(r.URL.Path, "/")
parts := strings.Split(path, "/")
if len(parts) < 2 || parts[0] != "relay" {
writeError(w, http.StatusNotFound, "not found")
return
}
id := parts[1]
if id == "" {
writeError(w, http.StatusBadRequest, "relay id is required")
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)
case http.MethodPut:
api.updateRelay(w, r, id)
case http.MethodPatch:
api.patchRelay(w, r, id)
case http.MethodDelete:
api.deleteRelay(w, r, id)
default:
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
}
api.mux.ServeHTTP(w, r)
}
// listRelayMembers returns members for a relay as an array of pubkeys.
func (api *APIHandler) listRelayMembers(w http.ResponseWriter, id string) {
func (api *APIHandler) listRelayMembers(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
members, err := api.resolveRelayMembers(id)
if err != nil {
if os.IsNotExist(err) {
@@ -121,19 +93,28 @@ func (api *APIHandler) resolveRelayMembers(id string) ([]string, error) {
return members, nil
}
configPath := api.configPath(id)
if err := api.checkConfigExists(configPath); err != nil {
return nil, err
}
instance, err := MakeInstanceFromPath(configPath)
config, err := api.loadConfigFromPath(api.configPath(id))
if err != nil {
return nil, err
}
defer instance.Cleanup()
events := &EventStore{
Config: config,
Schema: &Schema{Name: slug.Make(config.Schema)},
}
if err := events.Init(); err != nil {
return nil, fmt.Errorf("failed to init event store: %w", err)
}
management := &ManagementStore{
Config: config,
Events: events,
}
memberSet := make(map[string]struct{})
for _, pubkey := range instance.Management.GetMembers() {
for _, pubkey := range management.GetMembers() {
memberSet[pubkey.Hex()] = struct{}{}
}
@@ -184,7 +165,8 @@ func scheme(r *http.Request) string {
}
// createRelay creates a new relay config file
func (api *APIHandler) createRelay(w http.ResponseWriter, r *http.Request, id string) {
func (api *APIHandler) createRelay(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
configPath := api.configPath(id)
if _, err := os.Stat(configPath); err == nil {
@@ -212,7 +194,8 @@ func (api *APIHandler) createRelay(w http.ResponseWriter, r *http.Request, id st
}
// updateRelay updates an existing relay config file
func (api *APIHandler) updateRelay(w http.ResponseWriter, r *http.Request, id string) {
func (api *APIHandler) updateRelay(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
configPath := api.configPath(id)
if err := api.checkConfigExists(configPath); err != nil {
@@ -244,7 +227,8 @@ func (api *APIHandler) updateRelay(w http.ResponseWriter, r *http.Request, id st
}
// patchRelay partially updates an existing relay config
func (api *APIHandler) patchRelay(w http.ResponseWriter, r *http.Request, id string) {
func (api *APIHandler) patchRelay(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
configPath := api.configPath(id)
if err := api.checkConfigExists(configPath); err != nil {
@@ -377,17 +361,17 @@ func (api *APIHandler) validatePatchedConfig(config *Config) error {
if _, err := nostr.SecretKeyFromHex(config.Secret); err != nil {
return fmt.Errorf("invalid secret key: %w", err)
}
if config.Info.Pubkey == "" {
return fmt.Errorf("info.pubkey is required")
}
if _, err := nostr.PubKeyFromHex(config.Info.Pubkey); err != nil {
return fmt.Errorf("invalid info.pubkey: %w", err)
if config.Info.Pubkey != "" {
if _, err := nostr.PubKeyFromHex(config.Info.Pubkey); err != nil {
return fmt.Errorf("invalid info.pubkey: %w", err)
}
}
return nil
}
// deleteRelay deletes a relay config file
func (api *APIHandler) deleteRelay(w http.ResponseWriter, r *http.Request, id string) {
func (api *APIHandler) deleteRelay(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
configPath := api.configPath(id)
if err := api.checkConfigExists(configPath); err != nil {
@@ -407,9 +391,14 @@ func (api *APIHandler) deleteRelay(w http.ResponseWriter, r *http.Request, id st
writeJSON(w, http.StatusOK, map[string]string{"message": "relay deleted successfully"})
}
// configName returns the config file name
func (api *APIHandler) configName(id string) string {
return id+".toml"
}
// configPath returns the full path for a config file
func (api *APIHandler) configPath(id string) string {
return filepath.Join(api.configDir, id+".toml")
return filepath.Join(api.configDir, api.configName(id))
}
// checkConfigExists checks if a config file exists
+28 -13
View File
@@ -13,6 +13,7 @@ import (
"testing"
"fiatjaf.com/nostr"
"github.com/gosimple/slug"
)
func TestAPIHandler_Authentication(t *testing.T) {
@@ -676,25 +677,40 @@ func TestAPIHandler_ListRelayMembers(t *testing.T) {
t.Run("list members from config fallback", func(t *testing.T) {
relaySecret := nostr.Generate()
owner := nostr.Generate().Public()
rolePubkey := nostr.Generate().Public()
member1 := nostr.Generate().Public()
member2 := 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)
}
// Seed DB with RELAY_MEMBERS to simulate a prior relay load.
seedEvents := &EventStore{
Config: &Config{secret: relaySecret},
Schema: &Schema{Name: slug.Make(config.Schema)},
}
if err := seedEvents.Init(); err != nil {
t.Fatalf("failed to init seed events: %v", err)
}
membersEvent := nostr.Event{
Kind: RELAY_MEMBERS,
CreatedAt: nostr.Now(),
Tags: nostr.Tags{
{"-"},
{"member", member1.Hex()},
{"member", member2.Hex()},
},
}
if err := seedEvents.SignAndStoreEvent(&membersEvent, false); err != nil {
t.Fatalf("failed to seed members event: %v", err)
}
instancesMux.Lock()
oldByName := instancesByName
oldByHost := instancesByHost
@@ -725,9 +741,8 @@ func TestAPIHandler_ListRelayMembers(t *testing.T) {
}
expected := map[string]struct{}{
owner.Hex(): {},
relaySecret.Public().Hex(): {},
rolePubkey.Hex(): {},
member1.Hex(): {},
member2.Hex(): {},
}
if len(payload.Members) != len(expected) {
@@ -809,8 +824,8 @@ func TestAPIHandler_InvalidPath(t *testing.T) {
api.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status %d, got %d", http.StatusBadRequest, w.Code)
if w.Code != http.StatusNotFound {
t.Errorf("expected status %d, got %d", http.StatusNotFound, w.Code)
}
})
}
-1
View File
@@ -68,7 +68,6 @@ func LoadConfig(filename string) (*Config, error) {
}
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)
+1 -1
View File
@@ -364,7 +364,7 @@ func (events *EventStore) SignAndStoreEvent(event *nostr.Event, broadcast bool)
return err
}
if broadcast {
if broadcast && events.Relay != nil {
events.Relay.BroadcastEvent(*event)
}
-9
View File
@@ -31,15 +31,6 @@ func MakeInstance(filename string) (*Instance, error) {
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()
+1 -1
View File
@@ -27,7 +27,7 @@ func createTestInstance() *Instance {
schema := &Schema{Name: "test_" + RandomString(8)}
relay := &khatru.Relay{}
relay := khatru.NewRelay()
events := &EventStore{
Relay: relay,
+4 -11
View File
@@ -28,15 +28,6 @@ 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 {
@@ -72,7 +63,8 @@ func Start() {
if err != nil {
log.Printf("Failed to make instance for %s: %v", entry.Name(), err)
} else if cleanupIfInactive(instance) {
} else if instance.Config.Inactive {
instance.Cleanup()
log.Printf("Skipped inactive %s", entry.Name())
} else {
instancesByHost[instance.Config.Host] = instance
@@ -119,7 +111,8 @@ func Start() {
instance, err := MakeInstance(filename)
if err != nil {
log.Printf("Failed to reload %s: %v", filename, err)
} else if cleanupIfInactive(instance) {
} else if instance.Config.Inactive {
instance.Cleanup()
log.Printf("Skipped inactive %s", filename)
} else {
instancesByHost[instance.Config.Host] = instance
+20
View File
@@ -0,0 +1,20 @@
package zooid
import (
"os"
"testing"
)
func TestMain(m *testing.M) {
dir, err := os.MkdirTemp("", "zooid-test-*")
if err != nil {
panic(err)
}
os.Setenv("DATA", dir)
code := m.Run()
os.RemoveAll(dir)
os.Exit(code)
}
+1 -1
View File
@@ -13,7 +13,7 @@ func createTestManagementStore() *ManagementStore {
secret: nostr.Generate(),
}
schema := &Schema{Name: "test_" + RandomString(8)}
relay := &khatru.Relay{}
relay := khatru.NewRelay()
events := &EventStore{
Relay: relay,
Config: config,