ITADN

[v3.3.0] Regex replace fails with 404 - empty source name in URL path

#213Openkia13tip 创建于 2026-01-13
K
kia13tipcommented
# [v3.3.0] Regex replace is broken (Replace All does nothing; single-row Replace hits malformed REST URL) + Preset selection doesn’t populate fields + REST route duplicate registrations (patch included) Report date: **2026-01-13** --- ## Environment * **Plugin Version:** 3.3.0 * **WordPress Version:** 6.9 (single site) * **PHP Version:** 8.3.28 * **Memory Limit:** 256M * **Max Execution Time:** 30s * **Permalink Structure:** Custom (`/%category%/%postname%/`) * **Browser:** Chrome 143.0.0.0 (Windows 11) --- ## Debug Information (from plugin error dialog) ```text Plugin: 3.3.0 WordPress: 6.9 (single) PHP: 8.3.28 256M 30s Browser: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36 JavaScript: https://example.com/wp-content/plugins/search-regex/search-regex.js REST API: https://example.com/wp-json/ Query: ?page=search-regex.php Error: URL とリクエストメソッドに一致するルートが見つかりませんでした。 (rest_no_route) Raw: {"code":"rest_no_route","message":"URL \u3068\u30ea\u30af\u30a8\u30b9\u30c8\u30e1\u30bd\u30c3\u30c9\u306b\u4e00\u81f4\u3059\u308b\u30eb\u30fc\u30c8\u304c\u898b\u3064\u304b\u308a\u307e\u305b\u3093\u3067\u3057\u305f\u3002","data":{"status":404}} ``` > **Note:** The REST API itself (`/wp-json/search-regex/v1`) is accessible and returns valid route information. This 404 occurs specifically when performing regex replace operations due to a malformed URL (`/source//row/` with empty source segment). --- ## Troubleshooting already performed * Disabled all other plugins → issue persists * Cleared browser cache → issue persists * Verified REST API is accessible (`/wp-json/search-regex/v1` returns valid JSON) * Tested in Chrome DevTools (Network tab) → confirmed malformed URL is the cause --- ## Bug 1 (CRITICAL): Regex Replace Completely Broken ### Severity **CRITICAL** — Regex replacement is completely broken (both single row and “Replace All”). ### What works vs. what doesn’t * **Normal (non-regex) text replace:** Works * **Regex replace:** Does not work There are **two different failure modes** depending on which action is used. --- ### 1) “Replace All” button (Regex enabled): nothing happens (no request is sent) #### Symptom When I click **“Replace All”** with **Regex** enabled: * Nothing happens in the UI * No network request is sent (DevTools → Network shows nothing) * No error appears in the browser console (DevTools → Console) * The button appears to do nothing #### Reproduction steps (“Replace All”) 1. Install **Search Regex v3.3.0** on **WordPress 6.9** 2. Go to **Tools → Search Regex** 3. Enable the **Regex** flag 4. Perform a regex search (example): ```regex p2='[^']*'\s+r=' ``` 5. Click the **“Replace All”** button 6. **Result:** Nothing happens, and there is **no** request in DevTools --- ### 2) Single-row “Replace” (Regex enabled): malformed URL → 404 `rest_no_route` #### Symptom When I click **“Replace”** on a specific result row with **Regex** enabled: * A network request **is** sent * The request URL is malformed because the `source` segment is empty * WordPress REST API returns **404** with `rest_no_route` #### Expected URL ```http POST /wp-json/search-regex/v1/source/posts/row/1327/ ``` #### Actual URL sent ```http POST /wp-json/search-regex/v1/source//row/1327/ ``` Note the double slash: `source//row` — the `source` path parameter is empty. #### Request details (captured from DevTools) ```text URL: https://example.com/wp-json/search-regex/v1/source//row/1327/ Method: POST Body: { "searchPhrase": "p2='[^']*'\\s+r='", "searchFlags": ["case", "regex"], "source": ["posts"], // <-- Source is correctly set in body, but NOT in URL "replacement": { "column": "post_content", "operation": "replace", "source": "", // <-- Empty here too "replaceValue": "p2='100' r2='", "searchValue": "p2='100' r='", "posId": 27171 }, ... } ``` The `source` field is correctly set in the request body as `["posts"]`, but: 1. The URL path parameter is empty (`/source//row/`) 2. The `replacement.source` field is also empty #### Error response ```json { "code": "rest_no_route", "message": "URL とリクエストメソッドに一致するルートが見つかりませんでした。", "data": {"status": 404} } ``` > ("No route was found matching the URL and request method.") #### Reproduction steps (single-row Replace) 1. Install **Search Regex v3.3.0** on **WordPress 6.9** 2. Go to **Tools → Search Regex** 3. Enable the **Regex** flag 4. Perform a regex search (example): ```regex p2='[^']*'\s+r=' ``` 5. Find a match and click **Replace** on a specific row 6. **Result:** 404 error occurs 7. Check DevTools → Network to confirm the malformed URL: ```text /wp-json/search-regex/v1/source//row/{rowId}/ ``` --- ### Comparison table | Operation | Regex Mode | Normal Mode | | ----------------------------- | -------------------------------- | ----------- | | **Replace All** button | **Nothing happens** (no request) | Works | | **Single row Replace** button | 404 error (malformed URL) | Works | --- ### Root cause (likely) This appears to be a frontend JavaScript bug in the built bundle: * `build/search-regex.js` The repository does not include the original frontend source (only the built bundle), so I can’t point to the exact original source file/line without access to the unbundled source. **Possible causes:** 1. **Replace All:** click handler validation fails silently in regex mode, or the handler is not bound correctly 2. **Single-row Replace:** the `source` parameter is not passed correctly when building the row URL in regex mode --- ### Attempted workaround (backend) and why it does not fix Bug 1 I attempted a backend workaround using a `rest_pre_dispatch` filter to intercept malformed URLs and extract `source` from the request body. However, **this does NOT fix Bug 1** because: * **Replace All:** never sends a request → nothing to intercept * **Single-row Replace:** the workaround should catch the malformed URL, but in practice it only helped in preset-related situations (see Bug 3/4 cleanup area). It does not fix direct regex searches end-to-end because the critical problem is still in the frontend behavior. **Status:** **NOT FIXED** — requires a frontend fix. --- ## Bug 2 (CRITICAL): Preset selection does not populate the input fields (works only after refresh) ### Severity **CRITICAL** — Presets cannot be used properly without refreshing the page. ### Symptom In **Advanced Mode**, selecting a preset from the preset dropdown does not populate the UI fields (search phrase, source, filters, etc.). The form remains empty. However, if I refresh the page (F5) while the preset is present in the URL (for example `?preset=6965b2bc28f5b`), then the preset loads correctly and the fields populate. ### Comparison | Action | Result | | -------------------------------------- | ------------------------------------ | | Select preset from UI dropdown | Input fields remain **EMPTY** | | Refresh (F5) with `?preset=xxx` in URL | Input fields **POPULATED** correctly | ### Reproduction steps 1. Install **Search Regex v3.3.0** 2. Go to **Tools → Search Regex** 3. Switch to **Advanced Mode** 4. Select a preset from the preset dropdown 5. **Result:** Input fields remain empty 6. Copy the URL (it now includes `?preset=xxx`) 7. Press **F5** to refresh 8. **Result:** Input fields populate with preset data ### Backend investigation (preload looks correct) From the backend side, presets are correctly preloaded and passed to JS. Example: ```php // In Admin::get_preload_data() return [ 'sources' => Source\Manager::get_all_grouped(), 'presets' => $presets, // <-- All presets are available 'schema' => $schema, 'labels' => $this->get_preload_labels( $presets ), ]; // Passed to frontend as: // SearchRegexi10n.preload.presets ``` So the preload mechanism works on initial page load (reading the URL parameter), but the dynamic preset selection handler does not update the form. **Status:** **NOT FIXED** — requires frontend JavaScript fix (cannot be fixed cleanly from backend alone). --- ## Bug 3 (LOW): `/preset` REST route is registered twice (GET + POST) ### Severity **LOW** — may cause intermittent issues; also unnecessary/fragile. ### File `includes/api/route/class-preset-route.php` (Lines 70–88) ### Current code (duplicate registrations) ```php public function __construct( $namespace ) { register_rest_route( $namespace, '/preset', [ $this->get_route( \WP_REST_Server::READABLE, 'route_list', [ $this, 'permission_callback' ] ), ] ); register_rest_route( $namespace, '/preset', [ array_merge( [ 'args' => $this->get_preset_params() ], $this->get_route( \WP_REST_Server::EDITABLE, 'route_create', [ $this, 'permission_callback' ] ) ), ] ); // ... } ``` ### Fix (combine into a single `register_rest_route()` call) ```php public function __construct( $namespace ) { register_rest_route( $namespace, '/preset', [ $this->get_route( \WP_REST_Server::READABLE, 'route_list', [ $this, 'permission_callback' ] ), array_merge( [ 'args' => $this->get_preset_params() ], $this->get_route( \WP_REST_Server::EDITABLE, 'route_create', [ $this, 'permission_callback' ] ) ), ] ); // ... } ``` **Status:** **FIXED** (patch provided below) --- ## Bug 4 (LOW): `/source/{source}/row/{rowId}` REST route is registered twice (GET + POST) ### Severity **LOW** — may cause intermittent issues; also unnecessary/fragile. ### File `includes/api/route/class-source-route.php` (Lines 67–98) ### Current code (duplicate registrations) ```php register_rest_route( $route_namespace, '/source/(?P<source>[a-z\-\_]+)/row/(?P<rowId>[\d]+)', array_merge( [ 'args' => $this->get_source_params(), ], $this->get_route( \WP_REST_Server::READABLE, 'loadRow', [ $this, 'permission_callback' ] ), ) ); register_rest_route( $route_namespace, '/source/(?P<source>[a-z\-\_]+)/row/(?P<rowId>[\d]+)', array_merge( [ 'args' => array_merge( [ 'replacement' => [ 'description' => 'Row replacement. A single action.', 'type' => 'object', 'validate_callback' => [ $this, 'validate_replacement' ], 'required' => true, ], ], $this->get_source_params(), $this->get_search_params() ), ], $this->get_route( \WP_REST_Server::EDITABLE, 'saveRow', [ $this, 'permission_callback' ] ), ) ); ``` ### Fix (combine into a single registration) ```php register_rest_route( $route_namespace, '/source/(?P<source>[a-z\-\_]+)/row/(?P<rowId>[\d]+)', [ array_merge( [ 'args' => $this->get_source_params(), ], $this->get_route( \WP_REST_Server::READABLE, 'loadRow', [ $this, 'permission_callback' ] ), ), array_merge( [ 'args' => array_merge( [ 'replacement' => [ 'description' => 'Row replacement. A single action.', 'type' => 'object', 'validate_callback' => [ $this, 'validate_replacement' ], 'required' => true, ], ], $this->get_source_params(), $this->get_search_params() ), ], $this->get_route( \WP_REST_Server::EDITABLE, 'saveRow', [ $this, 'permission_callback' ] ), ), ] ); ``` **Status:** **FIXED** (patch provided below) --- ## Summary | Priority | Bug | Description | Status | | ------------ | ----- | ---------------------------------------------------------------------------------------- | ------------------- | | **CRITICAL** | Bug 1 | Regex replace broken (“Replace All” = no request; single-row = 404 due to malformed URL) | **NOT FIXED** | | **CRITICAL** | Bug 2 | Selecting a preset does not populate input fields until refresh | **NOT FIXED** | | LOW | Bug 3 | Duplicate route registration for `/preset` | FIXED (patch below) | | LOW | Bug 4 | Duplicate route registration for `/source/.../row/...` | FIXED (patch below) | --- ## Additional notes * Normal (non-regex) text replacement works correctly * REST API routes are registered correctly (confirmed by accessing): ```text /wp-json/search-regex/v1 ``` * Bug 1 and Bug 2 are frontend JavaScript issues that require frontend source access to fix properly * Bug 1 affects all regex replace operations (both direct and via preset) * Bug 2 affects preset selection UI behavior * A backend workaround (`rest_pre_dispatch`) was attempted, but does not fix the critical frontend bugs --- ## Complete Patch (full text, no omissions) ### Modified files 1. **`includes/api/class-api.php`** — add request interceptor filter (attempted workaround; does not fix critical bugs) 2. **`includes/api/route/class-source-route.php`** — fix duplicate registration (Bug 4) + add fallback route + add `saveRowFallback()` (workaround attempt) 3. **`includes/api/route/class-preset-route.php`** — fix duplicate registration (Bug 3) **Note:** The patches for Bug 3 and Bug 4 (duplicate route registration) are confirmed working. The `rest_pre_dispatch` workaround was an attempt to mitigate Bug 1, but it does not fix the critical frontend behaviors. --- ### 1) `includes/api/class-api.php` Add a `rest_pre_dispatch` filter that intercepts malformed URLs (with empty source) and handles them correctly by extracting the source from the request body. **Full modified file:** ```php <?php namespace SearchRegex\Api; class Api { const SEARCHREGEX_API_NAMESPACE = 'search-regex/v1'; /** * Instance variable */ private static ?Api $instance = null; /** * Array of endpoint routes * * @var Route[] * @phpstan-ignore property.onlyWritten */ private array $routes = []; /** * Create API * * @return Api */ public static function init() { if ( is_null( self::$instance ) ) { self::$instance = new Api(); } return self::$instance; } public function __construct() { global $wpdb; $wpdb->hide_errors(); // Fix malformed URLs from frontend bug (empty source in URL) add_filter( 'rest_pre_dispatch', [ $this, 'fix_empty_source_in_url' ], 10, 3 ); $this->routes[] = new Route\Search_Route( self::SEARCHREGEX_API_NAMESPACE ); $this->routes[] = new Route\Source_Route( self::SEARCHREGEX_API_NAMESPACE ); $this->routes[] = new Route\Plugin_Route( self::SEARCHREGEX_API_NAMESPACE ); $this->routes[] = new Route\Settings_Route( self::SEARCHREGEX_API_NAMESPACE ); $this->routes[] = new Route\Preset_Route( self::SEARCHREGEX_API_NAMESPACE ); } /** * Fix malformed requests where source is empty in URL but present in body. * This is a workaround for a frontend bug in regex replace. * * Intercepts URLs like /source//row/1327 and handles them by extracting * the source from the request body. * * @param mixed $result Response to replace the requested version with. * @param \WP_REST_Server $server Server instance. * @param \WP_REST_Request<array<string, mixed>> $request Request. * @return mixed */ public function fix_empty_source_in_url( $result, $server, $request ) { // Don't override if already handled if ( $result !== null ) { return $result; } $route = $request->get_route(); // Only process search-regex source routes with empty source // Match pattern like /search-regex/v1/source//row/1327 if ( ! preg_match( '#^/search-regex/v1/source//row/(\d+)/?$#', $route, $matches ) ) { return $result; } // Only handle POST requests (saveRow) if ( $request->get_method() !== 'POST' ) { return $result; } $row_id = $matches[1]; $params = $request->get_params(); // Get source from request body $source = $params['source'] ?? null; if ( empty( $source ) ) { return new \WP_Error( 'rest_invalid_param', 'Source is required in request body', [ 'status' => 400 ] ); } // Ensure source is a string (take first if array) if ( is_array( $source ) ) { $source = $source[0] ?? null; } if ( empty( $source ) || ! preg_match( '/^[a-z\-\_]+$/', $source ) ) { return new \WP_Error( 'rest_invalid_param', 'Invalid source format', [ 'status' => 400 ] ); } // Set the correct parameters $request->set_param( 'source', [ $source ] ); $request->set_param( 'rowId', $row_id ); // Find and call the correct handler (saveRow) foreach ( $this->routes as $r ) { if ( $r instanceof Route\Source_Route ) { return $r->saveRow( $request ); } } return $result; } } ``` --- ### 2) `includes/api/route/class-source-route.php` **Changes:** 1. Combine duplicate route registrations for `/source/{source}/row/{rowId}` into a single call (Bug 4 fix) 2. Add fallback route `/source/row/{rowId}` for when the frontend sends a request with missing source in the URL (workaround attempt) 3. Add `saveRowFallback()` method **In `__construct()`, replace the two separate registrations with:** ```php register_rest_route( $route_namespace, '/source/(?P<source>[a-z\-\_]+)/row/(?P<rowId>[\d]+)', [ array_merge( [ 'args' => $this->get_source_params(), ], $this->get_route( \WP_REST_Server::READABLE, 'loadRow', [ $this, 'permission_callback' ] ), ), array_merge( [ 'args' => array_merge( [ 'replacement' => [ 'description' => 'Row replacement. A single action.', 'type' => 'object', 'validate_callback' => [ $this, 'validate_replacement' ], 'required' => true, ], ], $this->get_source_params(), $this->get_search_params() ), ], $this->get_route( \WP_REST_Server::EDITABLE, 'saveRow', [ $this, 'permission_callback' ] ), ), ] ); // Fallback route: when frontend sends empty source in URL (regex replace bug workaround) // This route handles /source//row/{rowId} by extracting source from request body register_rest_route( $route_namespace, '/source/row/(?P<rowId>[\d]+)', [ array_merge( [ 'args' => array_merge( [ 'replacement' => [ 'description' => 'Row replacement. A single action.', 'type' => 'object', 'validate_callback' => [ $this, 'validate_replacement' ], 'required' => true, ], ], $this->get_source_params(), $this->get_search_params() ), ], $this->get_route( \WP_REST_Server::EDITABLE, 'saveRowFallback', [ $this, 'permission_callback' ] ), ), ] ); ``` **Add this new method to the class:** ```php /** * Fallback: Perform a replacement on a row when source is missing from URL. * Extracts source from request body instead. * * @param WP_REST_Request<array<string, mixed>> $request The request. * @return WP_Error|array<string, mixed> Return an array of results, or a WP_Error */ public function saveRowFallback( WP_REST_Request $request ) { $params = $request->get_params(); // Source is missing from URL, try to get it from request body if ( ! isset( $params['source'] ) || empty( $params['source'] ) ) { return new WP_Error( 'rest_invalid_param', 'Source is required', [ 'status' => 400 ] ); } // Ensure source is an array if ( ! is_array( $params['source'] ) ) { $params['source'] = [ $params['source'] ]; } // Delegate to the main saveRow logic return $this->saveRow( $request ); } ``` --- ### 3) `includes/api/route/class-preset-route.php` **In `__construct()`, replace the two separate registrations for `/preset` with:** ```php register_rest_route( $namespace, '/preset', [ $this->get_route( \WP_REST_Server::READABLE, 'route_list', [ $this, 'permission_callback' ] ), array_merge( [ 'args' => $this->get_preset_params() ], $this->get_route( \WP_REST_Server::EDITABLE, 'route_create', [ $this, 'permission_callback' ] ) ), ] ); ``` ---
1 条评论