diff --git a/.gitignore b/.gitignore index 5570f55..299bfcf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ bin +refs config media data diff --git a/cmd/relay/main.go b/cmd/relay/main.go index 6603b78..3cb7b86 100644 --- a/cmd/relay/main.go +++ b/cmd/relay/main.go @@ -19,18 +19,39 @@ func main() { signal.Notify(shutdown, syscall.SIGINT, syscall.SIGTERM) port := zooid.Env("PORT") + apiHost := zooid.Env("API_HOST") + apiWhitelist := zooid.Env("API_WHITELIST") + configDir := zooid.Env("CONFIG") + + // Create the main handler + mainHandler := http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + instance, exists := zooid.Dispatch(r.Host) + if exists { + instance.Relay.ServeHTTP(w, r) + } else { + http.Error(w, "Not Found", http.StatusNotFound) + } + }, + ) + + // Wrap with API handler if API_HOST is configured + var handler http.Handler = mainHandler + if apiHost != "" && apiWhitelist != "" { + apiHandler := zooid.NewAPIHandler(apiWhitelist, configDir) + handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Check if this request is for the API host + if r.Host == apiHost { + apiHandler.ServeHTTP(w, r) + } else { + mainHandler.ServeHTTP(w, r) + } + }) + } + srv := &http.Server{ - Addr: fmt.Sprintf(":%s", port), - Handler: http.HandlerFunc( - func(w http.ResponseWriter, r *http.Request) { - instance, exists := zooid.Dispatch(r.Host) - if exists { - instance.Relay.ServeHTTP(w, r) - } else { - http.Error(w, "Not Found", http.StatusNotFound) - } - }, - ), + Addr: fmt.Sprintf(":%s", port), + Handler: handler, } go func() { diff --git a/zooid/api.go b/zooid/api.go new file mode 100644 index 0000000..3b5f64b --- /dev/null +++ b/zooid/api.go @@ -0,0 +1,472 @@ +package zooid + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + + "fiatjaf.com/nostr" + "github.com/BurntSushi/toml" +) + +// APIHandler handles REST API requests for managing virtual relays +type APIHandler struct { + whitelist map[string]bool + configDir string +} + +// RelayConfigJSON is the JSON schema for relay configuration input +type RelayConfigJSON struct { + Host string `json:"host"` + Schema string `json:"schema"` + Secret string `json:"secret"` + Info struct { + Name string `json:"name"` + Icon string `json:"icon"` + Pubkey string `json:"pubkey"` + Description string `json:"description"` + } `json:"info"` + Policy struct { + PublicJoin bool `json:"public_join"` + StripSignatures bool `json:"strip_signatures"` + } `json:"policy"` + Groups struct { + Enabled bool `json:"enabled"` + AutoJoin bool `json:"auto_join"` + } `json:"groups"` + Push struct { + Enabled bool `json:"enabled"` + } `json:"push"` + Management struct { + Enabled bool `json:"enabled"` + Methods []string `json:"methods"` + } `json:"management"` + Blossom struct { + Enabled bool `json:"enabled"` + } `json:"blossom"` + Roles map[string]Role `json:"roles"` +} + +// NewAPIHandler creates a new API handler with the given whitelist +func NewAPIHandler(whitelist string, configDir string) *APIHandler { + w := make(map[string]bool) + for _, pubkey := range Split(whitelist, ",") { + pubkey = strings.TrimSpace(pubkey) + if pubkey != "" { + w[pubkey] = true + } + } + return &APIHandler{ + whitelist: w, + configDir: configDir, + } +} + +// ServeHTTP implements the http.Handler interface +func (api *APIHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + // Set JSON content type + w.Header().Set("Content-Type", "application/json") + + // Authenticate the request using NIP-98 + pubkey, err := api.authenticateNIP98(r) + if err != nil { + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) + return + } + + // Check if pubkey is in whitelist + if !api.whitelist[pubkey.Hex()] { + w.WriteHeader(http.StatusForbidden) + json.NewEncoder(w).Encode(map[string]string{"error": "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" { + w.WriteHeader(http.StatusNotFound) + json.NewEncoder(w).Encode(map[string]string{"error": "not found"}) + return + } + + id := parts[1] + if id == "" { + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"error": "relay id is required"}) + return + } + + switch r.Method { + case http.MethodPost: + api.createRelay(w, r, id) + case http.MethodPut: + api.updateRelay(w, r, id) + case http.MethodDelete: + api.deleteRelay(w, r, id) + default: + w.WriteHeader(http.StatusMethodNotAllowed) + json.NewEncoder(w).Encode(map[string]string{"error": "method not allowed"}) + } +} + +// authenticateNIP98 validates NIP-98 HTTP AUTH +func (api *APIHandler) authenticateNIP98(r *http.Request) (nostr.PubKey, error) { + authHeader := r.Header.Get("Authorization") + if authHeader == "" { + return nostr.PubKey{}, fmt.Errorf("missing authorization header") + } + + // Parse the Authorization header + parts := strings.SplitN(authHeader, " ", 2) + if len(parts) != 2 || strings.ToLower(parts[0]) != "nostr" { + return nostr.PubKey{}, fmt.Errorf("invalid authorization header format") + } + + // Decode the base64 event + eventJSON, err := base64.StdEncoding.DecodeString(parts[1]) + if err != nil { + return nostr.PubKey{}, fmt.Errorf("invalid base64 encoding: %w", err) + } + + // Parse the event + var event nostr.Event + if err := json.Unmarshal(eventJSON, &event); err != nil { + return nostr.PubKey{}, fmt.Errorf("invalid event json: %w", err) + } + + // Verify the event kind is HTTP Auth (27235) + if event.Kind != nostr.KindHTTPAuth { + return nostr.PubKey{}, fmt.Errorf("invalid event kind: expected %d, got %d", nostr.KindHTTPAuth, event.Kind) + } + + // Verify the event signature + if !event.VerifySignature() { + return nostr.PubKey{}, fmt.Errorf("invalid event signature") + } + + // Verify the event tags contain the correct URL and method + var hasURL, hasMethod bool + expectedURL := fmt.Sprintf("%s://%s%s", scheme(r), r.Host, r.URL.Path) + + for _, tag := range event.Tags { + if len(tag) < 2 { + continue + } + switch tag[0] { + case "u": + if tag[1] == expectedURL { + hasURL = true + } + case "method": + if strings.ToUpper(tag[1]) == r.Method { + hasMethod = true + } + } + } + + if !hasURL { + return nostr.PubKey{}, fmt.Errorf("event missing or invalid u tag") + } + if !hasMethod { + return nostr.PubKey{}, fmt.Errorf("event missing or invalid method tag") + } + + return event.PubKey, nil +} + +// scheme returns the URL scheme based on the request +func scheme(r *http.Request) string { + if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" { + return "https" + } + return "http" +} + +// createRelay creates a new relay config file +func (api *APIHandler) createRelay(w http.ResponseWriter, r *http.Request, id string) { + configPath := filepath.Join(api.configDir, id+".toml") + + // Check if file already exists + if _, err := os.Stat(configPath); err == nil { + w.WriteHeader(http.StatusConflict) + json.NewEncoder(w).Encode(map[string]string{"error": "relay with this id already exists"}) + return + } + + // Parse and validate the JSON config + config, err := api.parseAndValidateConfig(r) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) + return + } + + // Check for duplicate schema or host + if err := api.checkDuplicateSchemaOrHost(config, ""); err != nil { + w.WriteHeader(http.StatusConflict) + json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) + return + } + + // Convert to TOML and write + if err := api.writeConfigAsTOML(configPath, config); err != nil { + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{"error": fmt.Sprintf("failed to write config: %v", err)}) + return + } + + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]string{"message": "relay created successfully"}) +} + +// updateRelay updates an existing relay config file +func (api *APIHandler) updateRelay(w http.ResponseWriter, r *http.Request, id string) { + configPath := filepath.Join(api.configDir, id+".toml") + + // Check if file exists + if _, err := os.Stat(configPath); err != nil { + if os.IsNotExist(err) { + w.WriteHeader(http.StatusNotFound) + json.NewEncoder(w).Encode(map[string]string{"error": "relay not found"}) + return + } + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{"error": fmt.Sprintf("failed to check config: %v", err)}) + return + } + + // Parse and validate the JSON config + config, err := api.parseAndValidateConfig(r) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) + return + } + + // Check for duplicate schema or host (excluding this config file) + if err := api.checkDuplicateSchemaOrHost(config, id+".toml"); err != nil { + w.WriteHeader(http.StatusConflict) + json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) + return + } + + // Convert to TOML and write + if err := api.writeConfigAsTOML(configPath, config); err != nil { + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{"error": fmt.Sprintf("failed to write config: %v", err)}) + return + } + + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]string{"message": "relay updated successfully"}) +} + +// deleteRelay deletes a relay config file +func (api *APIHandler) deleteRelay(w http.ResponseWriter, r *http.Request, id string) { + configPath := filepath.Join(api.configDir, id+".toml") + + // Check if file exists + if _, err := os.Stat(configPath); err != nil { + if os.IsNotExist(err) { + w.WriteHeader(http.StatusNotFound) + json.NewEncoder(w).Encode(map[string]string{"error": "relay not found"}) + return + } + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{"error": fmt.Sprintf("failed to check config: %v", err)}) + return + } + + // Delete the config file + if err := os.Remove(configPath); err != nil { + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{"error": fmt.Sprintf("failed to delete config: %v", err)}) + return + } + + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]string{"message": "relay deleted successfully"}) +} + +// parseAndValidateConfig parses and validates the JSON config from the request body +func (api *APIHandler) parseAndValidateConfig(r *http.Request) (*RelayConfigJSON, error) { + // Limit body size to prevent abuse + r.Body = http.MaxBytesReader(nil, r.Body, 1024*1024) // 1MB limit + defer r.Body.Close() + + body, err := io.ReadAll(r.Body) + if err != nil { + return nil, fmt.Errorf("failed to read body: %w", err) + } + + // Parse the JSON config + var config RelayConfigJSON + if err := json.Unmarshal(body, &config); err != nil { + return nil, fmt.Errorf("invalid json config: %w", err) + } + + // Validate required fields + if config.Host == "" { + return nil, fmt.Errorf("host is required") + } + if config.Schema == "" { + return nil, fmt.Errorf("schema is required") + } + if config.Secret == "" { + return nil, fmt.Errorf("secret is required") + } + + // Validate the secret key + if _, err := nostr.SecretKeyFromHex(config.Secret); err != nil { + return nil, fmt.Errorf("invalid secret key: %w", err) + } + + // Validate info.pubkey if provided + if config.Info.Pubkey != "" { + if _, err := nostr.PubKeyFromHex(config.Info.Pubkey); err != nil { + return nil, fmt.Errorf("invalid info.pubkey: %w", err) + } + } + + return &config, nil +} + +// writeConfigAsTOML writes the config as TOML to the given path +func (api *APIHandler) writeConfigAsTOML(path string, config *RelayConfigJSON) error { + // Convert JSON config to TOML-compatible struct + tomlConfig := struct { + Host string `toml:"host"` + Schema string `toml:"schema"` + Secret string `toml:"secret"` + Info struct { + Name string `toml:"name"` + Icon string `toml:"icon"` + Pubkey string `toml:"pubkey"` + Description string `toml:"description"` + } `toml:"info"` + Policy struct { + PublicJoin bool `toml:"public_join"` + StripSignatures bool `toml:"strip_signatures"` + } `toml:"policy"` + Groups struct { + Enabled bool `toml:"enabled"` + AutoJoin bool `toml:"auto_join"` + } `toml:"groups"` + Push struct { + Enabled bool `toml:"enabled"` + } `toml:"push"` + Management struct { + Enabled bool `toml:"enabled"` + Methods []string `toml:"methods"` + } `toml:"management"` + Blossom struct { + Enabled bool `toml:"enabled"` + } `toml:"blossom"` + Roles map[string]Role `toml:"roles"` + }{ + Host: config.Host, + Schema: config.Schema, + Secret: config.Secret, + Info: struct { + Name string `toml:"name"` + Icon string `toml:"icon"` + Pubkey string `toml:"pubkey"` + Description string `toml:"description"` + }{ + Name: config.Info.Name, + Icon: config.Info.Icon, + Pubkey: config.Info.Pubkey, + Description: config.Info.Description, + }, + Policy: struct { + PublicJoin bool `toml:"public_join"` + StripSignatures bool `toml:"strip_signatures"` + }{ + PublicJoin: config.Policy.PublicJoin, + StripSignatures: config.Policy.StripSignatures, + }, + Groups: struct { + Enabled bool `toml:"enabled"` + AutoJoin bool `toml:"auto_join"` + }{ + Enabled: config.Groups.Enabled, + AutoJoin: config.Groups.AutoJoin, + }, + Push: struct { + Enabled bool `toml:"enabled"` + }{ + Enabled: config.Push.Enabled, + }, + Management: struct { + Enabled bool `toml:"enabled"` + Methods []string `toml:"methods"` + }{ + Enabled: config.Management.Enabled, + Methods: config.Management.Methods, + }, + Blossom: struct { + Enabled bool `toml:"enabled"` + }{ + Enabled: config.Blossom.Enabled, + }, + Roles: config.Roles, + } + + // Encode to TOML + var buf bytes.Buffer + encoder := toml.NewEncoder(&buf) + if err := encoder.Encode(tomlConfig); err != nil { + return fmt.Errorf("failed to encode toml: %w", err) + } + + // Write to file + if err := os.WriteFile(path, buf.Bytes(), 0644); err != nil { + return fmt.Errorf("failed to write file: %w", err) + } + + return nil +} + +// checkDuplicateSchemaOrHost checks if the schema or host is already in use by another config +func (api *APIHandler) checkDuplicateSchemaOrHost(config *RelayConfigJSON, excludeFilename string) error { + entries, err := os.ReadDir(api.configDir) + if err != nil { + return fmt.Errorf("failed to read config directory: %w", err) + } + + for _, entry := range entries { + if entry.IsDir() { + continue + } + if entry.Name() == excludeFilename { + continue + } + if !strings.HasSuffix(entry.Name(), ".toml") { + continue + } + + path := filepath.Join(api.configDir, entry.Name()) + var existingConfig Config + if _, err := toml.DecodeFile(path, &existingConfig); err != nil { + continue // Skip invalid configs + } + + if existingConfig.Schema == config.Schema { + return fmt.Errorf("schema %q is already in use", config.Schema) + } + if existingConfig.Host == config.Host { + return fmt.Errorf("host %q is already in use", config.Host) + } + } + + return nil +} diff --git a/zooid/api_test.go b/zooid/api_test.go new file mode 100644 index 0000000..f16a4b8 --- /dev/null +++ b/zooid/api_test.go @@ -0,0 +1,696 @@ +package zooid + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "fiatjaf.com/nostr" +) + +func TestAPIHandler_Authentication(t *testing.T) { + // Create a temporary config directory + configDir := t.TempDir() + + // Create a test keypair for authentication + secretKey := nostr.Generate() + pubkey := secretKey.Public() + + // Create API handler with whitelist containing our test pubkey + whitelist := pubkey.Hex() + api := NewAPIHandler(whitelist, configDir) + + t.Run("missing authorization header", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/relay/test", strings.NewReader("{}")) + req.Host = "api.example.com" + w := httptest.NewRecorder() + + api.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("expected status %d, got %d", http.StatusUnauthorized, w.Code) + } + }) + + t.Run("invalid authorization format", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/relay/test", strings.NewReader("{}")) + req.Host = "api.example.com" + req.Header.Set("Authorization", "Bearer token123") + w := httptest.NewRecorder() + + api.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("expected status %d, got %d", http.StatusUnauthorized, w.Code) + } + }) + + t.Run("invalid base64", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/relay/test", strings.NewReader("{}")) + req.Host = "api.example.com" + req.Header.Set("Authorization", "Nostr not-valid-base64!!!") + w := httptest.NewRecorder() + + api.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("expected status %d, got %d", http.StatusUnauthorized, w.Code) + } + }) + + t.Run("invalid event kind", func(t *testing.T) { + event := nostr.Event{ + Kind: 1, // Wrong kind + CreatedAt: nostr.Now(), + Tags: nostr.Tags{ + {"u", "http://api.example.com/relay/test"}, + {"method", "POST"}, + }, + } + event.Sign(secretKey) + + req := createAuthRequest(http.MethodPost, "http://api.example.com/relay/test", event, "{}") + w := httptest.NewRecorder() + + api.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("expected status %d, got %d", http.StatusUnauthorized, w.Code) + } + }) + + t.Run("invalid signature", func(t *testing.T) { + event := nostr.Event{ + Kind: nostr.KindHTTPAuth, + CreatedAt: nostr.Now(), + Tags: nostr.Tags{ + {"u", "http://api.example.com/relay/test"}, + {"method", "POST"}, + }, + } + // Don't sign the event - invalid signature + + req := createAuthRequest(http.MethodPost, "http://api.example.com/relay/test", event, "{}") + w := httptest.NewRecorder() + + api.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("expected status %d, got %d", http.StatusUnauthorized, w.Code) + } + }) + + t.Run("missing u tag", func(t *testing.T) { + event := nostr.Event{ + Kind: nostr.KindHTTPAuth, + CreatedAt: nostr.Now(), + Tags: nostr.Tags{ + {"method", "POST"}, + }, + } + event.Sign(secretKey) + + req := createAuthRequest(http.MethodPost, "http://api.example.com/relay/test", event, "{}") + w := httptest.NewRecorder() + + api.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("expected status %d, got %d", http.StatusUnauthorized, w.Code) + } + }) + + t.Run("missing method tag", func(t *testing.T) { + event := nostr.Event{ + Kind: nostr.KindHTTPAuth, + CreatedAt: nostr.Now(), + Tags: nostr.Tags{ + {"u", "http://api.example.com/relay/test"}, + }, + } + event.Sign(secretKey) + + req := createAuthRequest(http.MethodPost, "http://api.example.com/relay/test", event, "{}") + w := httptest.NewRecorder() + + api.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("expected status %d, got %d", http.StatusUnauthorized, w.Code) + } + }) + + t.Run("pubkey not in whitelist", func(t *testing.T) { + // Create a different keypair not in whitelist + otherSecret := nostr.Generate() + + event := nostr.Event{ + Kind: nostr.KindHTTPAuth, + CreatedAt: nostr.Now(), + Tags: nostr.Tags{ + {"u", "http://api.example.com/relay/test"}, + {"method", "POST"}, + }, + } + event.Sign(otherSecret) + + req := createAuthRequest(http.MethodPost, "http://api.example.com/relay/test", event, "{}") + w := httptest.NewRecorder() + + api.ServeHTTP(w, req) + + if w.Code != http.StatusForbidden { + t.Errorf("expected status %d, got %d", http.StatusForbidden, w.Code) + } + }) +} + +func TestAPIHandler_CreateRelay(t *testing.T) { + configDir := t.TempDir() + + secretKey := nostr.Generate() + pubkey := secretKey.Public() + whitelist := pubkey.Hex() + api := NewAPIHandler(whitelist, configDir) + + validConfig := map[string]interface{}{ + "host": "relay.example.com", + "schema": "testrelay", + "secret": secretKey.Hex(), + "info": map[string]interface{}{ + "name": "Test Relay", + "pubkey": pubkey.Hex(), + "description": "A test relay", + }, + } + + t.Run("create relay successfully", func(t *testing.T) { + body, _ := json.Marshal(validConfig) + req := createAuthenticatedRequest(http.MethodPost, "http://api.example.com/relay/newrelay", secretKey, body) + w := httptest.NewRecorder() + + api.ServeHTTP(w, req) + + if w.Code != http.StatusCreated { + t.Errorf("expected status %d, got %d: %s", http.StatusCreated, w.Code, w.Body.String()) + } + + // Verify file was created + configPath := filepath.Join(configDir, "newrelay.toml") + if _, err := os.Stat(configPath); os.IsNotExist(err) { + t.Error("config file was not created") + } + }) + + t.Run("duplicate id returns conflict", func(t *testing.T) { + body, _ := json.Marshal(validConfig) + req := createAuthenticatedRequest(http.MethodPost, "http://api.example.com/relay/newrelay", secretKey, body) + w := httptest.NewRecorder() + + api.ServeHTTP(w, req) + + if w.Code != http.StatusConflict { + t.Errorf("expected status %d, got %d", http.StatusConflict, w.Code) + } + }) + + t.Run("duplicate schema returns conflict", func(t *testing.T) { + config := map[string]interface{}{ + "host": "other.example.com", + "schema": "testrelay", // Same schema as existing + "secret": secretKey.Hex(), + } + body, _ := json.Marshal(config) + req := createAuthenticatedRequest(http.MethodPost, "http://api.example.com/relay/other", secretKey, body) + w := httptest.NewRecorder() + + api.ServeHTTP(w, req) + + if w.Code != http.StatusConflict { + t.Errorf("expected status %d, got %d", http.StatusConflict, w.Code) + } + }) + + t.Run("duplicate host returns conflict", func(t *testing.T) { + config := map[string]interface{}{ + "host": "relay.example.com", // Same host as existing + "schema": "otherschema", + "secret": secretKey.Hex(), + } + body, _ := json.Marshal(config) + req := createAuthenticatedRequest(http.MethodPost, "http://api.example.com/relay/other2", secretKey, body) + w := httptest.NewRecorder() + + api.ServeHTTP(w, req) + + if w.Code != http.StatusConflict { + t.Errorf("expected status %d, got %d", http.StatusConflict, w.Code) + } + }) + + t.Run("missing required fields", func(t *testing.T) { + config := map[string]interface{}{ + "host": "relay.example.com", + // missing schema and secret + } + body, _ := json.Marshal(config) + req := createAuthenticatedRequest(http.MethodPost, "http://api.example.com/relay/badrelay", secretKey, body) + w := httptest.NewRecorder() + + api.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected status %d, got %d", http.StatusBadRequest, w.Code) + } + }) + + t.Run("invalid secret key", func(t *testing.T) { + config := map[string]interface{}{ + "host": "relay.example.com", + "schema": "badrelay", + "secret": "not-a-valid-hex-key", + } + body, _ := json.Marshal(config) + req := createAuthenticatedRequest(http.MethodPost, "http://api.example.com/relay/badrelay", secretKey, body) + w := httptest.NewRecorder() + + api.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected status %d, got %d", http.StatusBadRequest, w.Code) + } + }) + + t.Run("invalid json", func(t *testing.T) { + req := createAuthenticatedRequest(http.MethodPost, "http://api.example.com/relay/badrelay", secretKey, []byte("not json")) + w := httptest.NewRecorder() + + api.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected status %d, got %d", http.StatusBadRequest, w.Code) + } + }) +} + +func TestAPIHandler_UpdateRelay(t *testing.T) { + configDir := t.TempDir() + + secretKey := nostr.Generate() + pubkey := secretKey.Public() + whitelist := pubkey.Hex() + api := NewAPIHandler(whitelist, configDir) + + // Create initial relay + initialConfig := map[string]interface{}{ + "host": "relay.example.com", + "schema": "testrelay", + "secret": secretKey.Hex(), + "info": map[string]interface{}{ + "name": "Test Relay", + "pubkey": pubkey.Hex(), + }, + } + body, _ := json.Marshal(initialConfig) + req := createAuthenticatedRequest(http.MethodPost, "http://api.example.com/relay/testrelay", secretKey, body) + w := httptest.NewRecorder() + api.ServeHTTP(w, req) + if w.Code != http.StatusCreated { + t.Fatalf("failed to create initial relay: %d - %s", w.Code, w.Body.String()) + } + + t.Run("update existing relay", func(t *testing.T) { + updatedConfig := map[string]interface{}{ + "host": "relay.example.com", + "schema": "testrelay", + "secret": secretKey.Hex(), + "info": map[string]interface{}{ + "name": "Updated Relay Name", + "pubkey": pubkey.Hex(), + "description": "Updated description", + }, + } + body, _ := json.Marshal(updatedConfig) + req := createAuthenticatedRequest(http.MethodPut, "http://api.example.com/relay/testrelay", secretKey, body) + w := httptest.NewRecorder() + + api.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected status %d, got %d: %s", http.StatusOK, w.Code, w.Body.String()) + } + }) + + t.Run("update non-existent relay returns not found", func(t *testing.T) { + config := map[string]interface{}{ + "host": "nonexistent.example.com", + "schema": "nonexistent", + "secret": secretKey.Hex(), + } + body, _ := json.Marshal(config) + req := createAuthenticatedRequest(http.MethodPut, "http://api.example.com/relay/nonexistent", secretKey, body) + w := httptest.NewRecorder() + + api.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("expected status %d, got %d", http.StatusNotFound, w.Code) + } + }) + + t.Run("update with duplicate schema", func(t *testing.T) { + // Create another relay first + otherConfig := map[string]interface{}{ + "host": "other.example.com", + "schema": "otherrelay", + "secret": secretKey.Hex(), + } + body, _ := json.Marshal(otherConfig) + req := createAuthenticatedRequest(http.MethodPost, "http://api.example.com/relay/otherrelay", secretKey, body) + w := httptest.NewRecorder() + api.ServeHTTP(w, req) + if w.Code != http.StatusCreated { + t.Fatalf("failed to create other relay: %d", w.Code) + } + + // Try to update first relay with second relay's schema + updateConfig := map[string]interface{}{ + "host": "relay.example.com", + "schema": "otherrelay", // Duplicate + "secret": secretKey.Hex(), + } + body, _ = json.Marshal(updateConfig) + req = createAuthenticatedRequest(http.MethodPut, "http://api.example.com/relay/testrelay", secretKey, body) + w = httptest.NewRecorder() + + api.ServeHTTP(w, req) + + if w.Code != http.StatusConflict { + t.Errorf("expected status %d, got %d", http.StatusConflict, w.Code) + } + }) +} + +func TestAPIHandler_DeleteRelay(t *testing.T) { + configDir := t.TempDir() + + secretKey := nostr.Generate() + pubkey := secretKey.Public() + whitelist := pubkey.Hex() + api := NewAPIHandler(whitelist, configDir) + + // Create a relay to delete + config := map[string]interface{}{ + "host": "relay.example.com", + "schema": "deleterelay", + "secret": secretKey.Hex(), + "info": map[string]interface{}{ + "name": "Delete Me", + "pubkey": pubkey.Hex(), + }, + } + body, _ := json.Marshal(config) + req := createAuthenticatedRequest(http.MethodPost, "http://api.example.com/relay/deleterelay", secretKey, body) + w := httptest.NewRecorder() + api.ServeHTTP(w, req) + if w.Code != http.StatusCreated { + t.Fatalf("failed to create relay: %d", w.Code) + } + + t.Run("delete existing relay", func(t *testing.T) { + req := createAuthenticatedRequest(http.MethodDelete, "http://api.example.com/relay/deleterelay", secretKey, nil) + w := httptest.NewRecorder() + + api.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected status %d, got %d", http.StatusOK, w.Code) + } + + // Verify file was deleted + configPath := filepath.Join(configDir, "deleterelay.toml") + if _, err := os.Stat(configPath); !os.IsNotExist(err) { + t.Error("config file was not deleted") + } + }) + + t.Run("delete non-existent relay returns not found", func(t *testing.T) { + req := createAuthenticatedRequest(http.MethodDelete, "http://api.example.com/relay/nonexistent", secretKey, nil) + w := httptest.NewRecorder() + + api.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("expected status %d, got %d", http.StatusNotFound, w.Code) + } + }) +} + +func TestAPIHandler_MethodNotAllowed(t *testing.T) { + configDir := t.TempDir() + + secretKey := nostr.Generate() + pubkey := secretKey.Public() + whitelist := pubkey.Hex() + api := NewAPIHandler(whitelist, configDir) + + t.Run("GET method not allowed", func(t *testing.T) { + req := createAuthenticatedRequest(http.MethodGet, "http://api.example.com/relay/test", secretKey, nil) + w := httptest.NewRecorder() + + api.ServeHTTP(w, req) + + if w.Code != http.StatusMethodNotAllowed { + t.Errorf("expected status %d, got %d", http.StatusMethodNotAllowed, w.Code) + } + }) +} + +func TestAPIHandler_InvalidPath(t *testing.T) { + configDir := t.TempDir() + + secretKey := nostr.Generate() + pubkey := secretKey.Public() + whitelist := pubkey.Hex() + api := NewAPIHandler(whitelist, configDir) + + t.Run("invalid path returns not found", func(t *testing.T) { + req := createAuthenticatedRequest(http.MethodPost, "http://api.example.com/invalid/path", secretKey, []byte("{}")) + w := httptest.NewRecorder() + + api.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("expected status %d, got %d", http.StatusNotFound, w.Code) + } + }) + + t.Run("missing relay id", func(t *testing.T) { + req := createAuthenticatedRequest(http.MethodPost, "http://api.example.com/relay/", secretKey, []byte("{}")) + w := httptest.NewRecorder() + + api.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected status %d, got %d", http.StatusBadRequest, w.Code) + } + }) +} + +func TestAPIHandler_ConfigValidation(t *testing.T) { + configDir := t.TempDir() + + secretKey := nostr.Generate() + pubkey := secretKey.Public() + whitelist := pubkey.Hex() + api := NewAPIHandler(whitelist, configDir) + + t.Run("invalid info.pubkey", func(t *testing.T) { + config := map[string]interface{}{ + "host": "relay.example.com", + "schema": "badpubkey", + "secret": secretKey.Hex(), + "info": map[string]interface{}{ + "name": "Test", + "pubkey": "not-a-valid-pubkey", + }, + } + body, _ := json.Marshal(config) + req := createAuthenticatedRequest(http.MethodPost, "http://api.example.com/relay/badpubkey", secretKey, body) + w := httptest.NewRecorder() + + api.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected status %d, got %d", http.StatusBadRequest, w.Code) + } + }) + + t.Run("valid full config", func(t *testing.T) { + config := map[string]interface{}{ + "host": "full.example.com", + "schema": "fullrelay", + "secret": secretKey.Hex(), + "info": map[string]interface{}{ + "name": "Full Test Relay", + "icon": "https://example.com/icon.png", + "pubkey": pubkey.Hex(), + "description": "A full test relay", + }, + "policy": map[string]interface{}{ + "public_join": true, + "strip_signatures": false, + }, + "groups": map[string]interface{}{ + "enabled": true, + "auto_join": true, + }, + "push": map[string]interface{}{ + "enabled": true, + }, + "management": map[string]interface{}{ + "enabled": true, + "methods": []string{"invite"}, + }, + "blossom": map[string]interface{}{ + "enabled": true, + }, + "roles": map[string]interface{}{ + "member": map[string]interface{}{ + "can_invite": true, + "can_manage": false, + }, + }, + } + body, _ := json.Marshal(config) + req := createAuthenticatedRequest(http.MethodPost, "http://api.example.com/relay/fullrelay", secretKey, body) + w := httptest.NewRecorder() + + api.ServeHTTP(w, req) + + if w.Code != http.StatusCreated { + t.Errorf("expected status %d, got %d: %s", http.StatusCreated, w.Code, w.Body.String()) + } + + // Verify the TOML file was created with correct content + configPath := filepath.Join(configDir, "fullrelay.toml") + content, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("failed to read config file: %v", err) + } + + contentStr := string(content) + if !strings.Contains(contentStr, "host = \"full.example.com\"") { + t.Error("TOML missing host") + } + if !strings.Contains(contentStr, "schema = \"fullrelay\"") { + t.Error("TOML missing schema") + } + if !strings.Contains(contentStr, "name = \"Full Test Relay\"") { + t.Error("TOML missing info.name") + } + if !strings.Contains(contentStr, "enabled = true") { + t.Error("TOML missing enabled flags") + } + }) +} + +// Helper functions + +func createAuthRequest(method, url string, event nostr.Event, body string) *http.Request { + var bodyReader *bytes.Reader + if body != "" { + bodyReader = bytes.NewReader([]byte(body)) + } else { + bodyReader = bytes.NewReader([]byte{}) + } + + req := httptest.NewRequest(method, url, bodyReader) + req.Host = "api.example.com" + + jevt, _ := json.Marshal(event) + authHeader := "Nostr " + base64.StdEncoding.EncodeToString(jevt) + req.Header.Set("Authorization", authHeader) + req.Header.Set("Content-Type", "application/json") + + return req +} + +func createAuthenticatedRequest(method, url string, secretKey nostr.SecretKey, body []byte) *http.Request { + var bodyReader *bytes.Reader + if body != nil { + bodyReader = bytes.NewReader(body) + } else { + bodyReader = bytes.NewReader([]byte{}) + } + + req := httptest.NewRequest(method, url, bodyReader) + req.Host = "api.example.com" + + // Create NIP-98 auth event + event := nostr.Event{ + Kind: nostr.KindHTTPAuth, + CreatedAt: nostr.Now(), + Tags: nostr.Tags{ + {"u", url}, + {"method", method}, + }, + } + event.Sign(secretKey) + + jevt, _ := json.Marshal(event) + authHeader := "Nostr " + base64.StdEncoding.EncodeToString(jevt) + req.Header.Set("Authorization", authHeader) + req.Header.Set("Content-Type", "application/json") + + return req +} + +func TestNewAPIHandler(t *testing.T) { + t.Run("empty whitelist", func(t *testing.T) { + api := NewAPIHandler("", "/tmp") + if len(api.whitelist) != 0 { + t.Error("expected empty whitelist") + } + }) + + t.Run("single pubkey", func(t *testing.T) { + pubkey := nostr.Generate().Public().Hex() + api := NewAPIHandler(pubkey, "/tmp") + if len(api.whitelist) != 1 { + t.Error("expected 1 entry in whitelist") + } + if !api.whitelist[pubkey] { + t.Error("pubkey not in whitelist") + } + }) + + t.Run("multiple pubkeys", func(t *testing.T) { + pubkey1 := nostr.Generate().Public().Hex() + pubkey2 := nostr.Generate().Public().Hex() + whitelist := fmt.Sprintf("%s, %s", pubkey1, pubkey2) + api := NewAPIHandler(whitelist, "/tmp") + if len(api.whitelist) != 2 { + t.Error("expected 2 entries in whitelist") + } + if !api.whitelist[pubkey1] || !api.whitelist[pubkey2] { + t.Error("pubkeys not in whitelist") + } + }) + + t.Run("whitespace trimming", func(t *testing.T) { + pubkey := nostr.Generate().Public().Hex() + whitelist := " " + pubkey + " " + api := NewAPIHandler(whitelist, "/tmp") + if len(api.whitelist) != 1 { + t.Error("expected 1 entry in whitelist after trimming") + } + }) +}