diff --git a/README.md b/README.md index 94ac34f..3218186 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,7 @@ The API accepts JSON config objects and stores them as TOML files in the `CONFIG Endpoints: - `POST /relay/{id}` - Creates a new virtual relay config. Returns 201 on success, 409 if the id/schema/host already exists, 400 for invalid config. - `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. ## Scripts diff --git a/relay b/relay new file mode 100755 index 0000000..fe6ec1c Binary files /dev/null and b/relay differ diff --git a/zooid/api.go b/zooid/api.go index 3b5f64b..bca6609 100644 --- a/zooid/api.go +++ b/zooid/api.go @@ -110,6 +110,8 @@ func (api *APIHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 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: @@ -270,6 +272,146 @@ func (api *APIHandler) updateRelay(w http.ResponseWriter, r *http.Request, id st json.NewEncoder(w).Encode(map[string]string{"message": "relay updated successfully"}) } +// patchRelay partially updates an existing relay config by recursively merging changes +func (api *APIHandler) patchRelay(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 + } + + // Read existing config + var existingConfig RelayConfigJSON + if _, err := toml.DecodeFile(configPath, &existingConfig); err != nil { + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{"error": fmt.Sprintf("failed to read existing config: %v", err)}) + return + } + + // Read and parse the patch + r.Body = http.MaxBytesReader(nil, r.Body, 1024*1024) // 1MB limit + defer r.Body.Close() + + body, err := io.ReadAll(r.Body) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"error": fmt.Sprintf("failed to read body: %v", err)}) + return + } + + var patch map[string]interface{} + if err := json.Unmarshal(body, &patch); err != nil { + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"error": fmt.Sprintf("invalid json: %v", err)}) + return + } + + // Convert existing config to map for merging + existingJSON, _ := json.Marshal(existingConfig) + var existingMap map[string]interface{} + json.Unmarshal(existingJSON, &existingMap) + + // Recursively merge patch into existing + mergedMap := deepMerge(existingMap, patch) + + // Convert back to RelayConfigJSON + mergedJSON, _ := json.Marshal(mergedMap) + var mergedConfig RelayConfigJSON + if err := json.Unmarshal(mergedJSON, &mergedConfig); err != nil { + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{"error": fmt.Sprintf("failed to process merged config: %v", err)}) + return + } + + // Validate required fields are still present + if mergedConfig.Host == "" { + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"error": "host is required"}) + return + } + if mergedConfig.Schema == "" { + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"error": "schema is required"}) + return + } + if mergedConfig.Secret == "" { + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"error": "secret is required"}) + return + } + + // Validate the secret key + if _, err := nostr.SecretKeyFromHex(mergedConfig.Secret); err != nil { + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"error": fmt.Sprintf("invalid secret key: %v", err)}) + return + } + + // Validate info.pubkey if provided + if mergedConfig.Info.Pubkey != "" { + if _, err := nostr.PubKeyFromHex(mergedConfig.Info.Pubkey); err != nil { + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"error": fmt.Sprintf("invalid info.pubkey: %v", err)}) + return + } + } + + // Check for duplicate schema or host (excluding this config file) + if err := api.checkDuplicateSchemaOrHost(&mergedConfig, 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, &mergedConfig); 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 patched successfully"}) +} + +// deepMerge recursively merges patch into base +func deepMerge(base, patch map[string]interface{}) map[string]interface{} { + result := make(map[string]interface{}) + + // Copy all values from base + for k, v := range base { + result[k] = v + } + + // Apply patches + for k, v := range patch { + if v == nil { + // Remove key if explicitly set to null + delete(result, k) + } else if patchMap, ok := v.(map[string]interface{}); ok { + // Recursively merge nested maps + if baseMap, ok := base[k].(map[string]interface{}); ok { + result[k] = deepMerge(baseMap, patchMap) + } else { + result[k] = v + } + } else { + // Replace value + result[k] = v + } + } + + return result +} + // 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") diff --git a/zooid/api_test.go b/zooid/api_test.go index f16a4b8..9cf0d98 100644 --- a/zooid/api_test.go +++ b/zooid/api_test.go @@ -398,6 +398,158 @@ func TestAPIHandler_UpdateRelay(t *testing.T) { }) } +func TestAPIHandler_PatchRelay(t *testing.T) { + configDir := t.TempDir() + + secretKey := nostr.Generate() + pubkey := secretKey.Public() + whitelist := pubkey.Hex() + api := NewAPIHandler(whitelist, configDir) + + // Create initial relay with full config + initialConfig := map[string]interface{}{ + "host": "relay.example.com", + "schema": "testrelay", + "secret": secretKey.Hex(), + "info": map[string]interface{}{ + "name": "Original Name", + "icon": "https://example.com/original.png", + "pubkey": pubkey.Hex(), + "description": "Original description", + }, + "policy": map[string]interface{}{ + "public_join": false, + "strip_signatures": false, + }, + "groups": map[string]interface{}{ + "enabled": true, + "auto_join": false, + }, + } + 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("patch existing relay - update single field", func(t *testing.T) { + patch := map[string]interface{}{ + "info": map[string]interface{}{ + "name": "Patched Name", + }, + } + body, _ := json.Marshal(patch) + req := createAuthenticatedRequest(http.MethodPatch, "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("patch existing relay - update nested fields", func(t *testing.T) { + patch := map[string]interface{}{ + "info": map[string]interface{}{ + "description": "Updated description", + "icon": "https://example.com/new-icon.png", + }, + "policy": map[string]interface{}{ + "public_join": true, + }, + } + body, _ := json.Marshal(patch) + req := createAuthenticatedRequest(http.MethodPatch, "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("patch non-existent relay returns not found", func(t *testing.T) { + patch := map[string]interface{}{ + "info": map[string]interface{}{ + "name": "New Name", + }, + } + body, _ := json.Marshal(patch) + req := createAuthenticatedRequest(http.MethodPatch, "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("patch with duplicate schema returns conflict", func(t *testing.T) { + // Create another relay first + otherConfig := map[string]interface{}{ + "host": "other.example.com", + "schema": "anotherrelay", + "secret": secretKey.Hex(), + } + body, _ := json.Marshal(otherConfig) + req := createAuthenticatedRequest(http.MethodPost, "http://api.example.com/relay/anotherrelay", 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 patch first relay with second relay's schema + patch := map[string]interface{}{ + "schema": "anotherrelay", // Duplicate + } + body, _ = json.Marshal(patch) + req = createAuthenticatedRequest(http.MethodPatch, "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) + } + }) + + t.Run("patch with invalid secret key", func(t *testing.T) { + patch := map[string]interface{}{ + "secret": "not-a-valid-hex-key", + } + body, _ := json.Marshal(patch) + req := createAuthenticatedRequest(http.MethodPatch, "http://api.example.com/relay/testrelay", 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("patch removing required field fails", func(t *testing.T) { + patch := map[string]interface{}{ + "host": nil, + } + body, _ := json.Marshal(patch) + req := createAuthenticatedRequest(http.MethodPatch, "http://api.example.com/relay/testrelay", 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) + } + }) +} + func TestAPIHandler_DeleteRelay(t *testing.T) { configDir := t.TempDir()