options_repository = $options_repository; $this->nonce_service = $nonce_service; $this->capabilities_service = $capabilities_service; $this->logger = $logger; $this->xpath_engine = $xpath_engine; $this->inline_css_generator = $inline_css_generator; } /** * Initialize REST API endpoints. */ public function init(): void { add_action('rest_api_init', [$this, 'register_routes']); } /** * Register REST API routes. */ public function register_routes(): void { register_rest_route( 'adsd/v1', '/rules', [ [ 'methods' => 'GET', 'callback' => [$this, 'handle_list_rules'], 'permission_callback' => [$this, 'check_read_permission'], ], [ 'methods' => 'POST', 'callback' => [$this, 'handle_create_rule'], 'permission_callback' => [$this, 'check_write_permission'], ], ] ); // Reset all rules at once (register before parameterized route to avoid conflicts) register_rest_route( 'adsd/v1', '/rules/reset', [ 'methods' => 'POST', 'callback' => [$this, 'handle_reset_rules'], 'permission_callback' => [$this, 'check_admin_permission'], ] ); // Activate existing rule register_rest_route( 'adsd/v1', '/rules/activate-existing', [ 'methods' => 'POST', 'callback' => [$this, 'handle_activate_existing_rule'], 'permission_callback' => [$this, 'check_write_permission'], ] ); register_rest_route( 'adsd/v1', '/rules/(?P[a-zA-Z0-9_-]+)', [ [ 'methods' => 'GET', 'callback' => [$this, 'handle_get_rule'], 'permission_callback' => [$this, 'check_read_permission'], ], [ 'methods' => 'DELETE', 'callback' => [$this, 'handle_delete_rule'], 'permission_callback' => [$this, 'check_write_permission'], ], [ 'methods' => 'POST', 'callback' => [$this, 'handle_update_rule'], 'permission_callback' => [$this, 'check_write_permission'], ], ] ); register_rest_route( 'adsd/v1', '/test', [ 'methods' => 'POST', 'callback' => [$this, 'handle_test_xpath'], 'permission_callback' => [$this, 'check_read_permission'], ] ); // Log management endpoints. Logs may contain data about other users, // so they are restricted to administrators. register_rest_route( 'adsd/v1', '/logs/latest', [ 'methods' => 'GET', 'callback' => [$this, 'handle_get_latest_log'], 'permission_callback' => [$this, 'check_admin_permission'], ] ); register_rest_route( 'adsd/v1', '/logs/download', [ 'methods' => 'POST', 'callback' => [$this, 'handle_download_latest_log'], 'permission_callback' => [$this, 'check_admin_permission'], ] ); register_rest_route( 'adsd/v1', '/logs/clear', [ 'methods' => 'POST', 'callback' => [$this, 'handle_clear_all_logs'], 'permission_callback' => [$this, 'check_admin_permission'], ] ); register_rest_route( 'adsd/v1', '/logs/js', [ 'methods' => 'POST', 'callback' => [$this, 'handle_log_js_message'], 'permission_callback' => [$this, 'check_read_permission'], ] ); } /** * Check read permission for rule data. */ public function check_read_permission(): bool { return $this->capabilities_service->can_use_selector(); } /** * Check write permission for rule data. */ public function check_write_permission(): bool { return $this->capabilities_service->can_use_selector(); } /** * Check permission for administrative operations (logs, resetting rules). */ public function check_admin_permission(): bool { return $this->capabilities_service->can_manage_settings(); } /** * GET /rules * * @return WP_REST_Response|WP_Error */ public function handle_list_rules(WP_REST_Request $request) { if (!$this->nonce_service->verify_request($request)) { $this->logger->warning('Invalid nonce for list rules request'); return new WP_Error('invalid_nonce', __('Invalid nonce', 'ads-destroyer'), ['status' => 403]); } $rules = $this->options_repository->get_rules(); $this->logger->debug('Rules list requested', ['count' => count($rules)]); return new WP_REST_Response(['rules' => $rules]); } /** * GET /rules/{id} * * @return WP_REST_Response|WP_Error */ public function handle_get_rule(WP_REST_Request $request) { if (!$this->nonce_service->verify_request($request)) { return new WP_Error('invalid_nonce', __('Invalid nonce', 'ads-destroyer'), ['status' => 403]); } $rule_id = $request->get_param('id'); $rules = $this->options_repository->get_rules(); // Use OptionsRepository to get complete rule data $rule = $this->options_repository->get_rule($rule_id); if ($rule) { return new WP_REST_Response($rule); } return new WP_Error('rule_not_found', __('Rule not found', 'ads-destroyer'), ['status' => 404]); } /** * POST /rules * * @return WP_REST_Response|WP_Error */ public function handle_create_rule(WP_REST_Request $request) { if (!$this->nonce_service->verify_request($request)) { $this->logger->warning('Invalid nonce for rule creation request'); return new WP_Error('invalid_nonce', __('Invalid nonce', 'ads-destroyer'), ['status' => 403]); } $params = $request->get_json_params(); $xpath = sanitize_text_field($params['xpath'] ?? ''); $label = sanitize_text_field($params['label'] ?? ''); $active = filter_var($params['active'] ?? true, FILTER_VALIDATE_BOOLEAN); $expires_at = (int) ($params['expires_at'] ?? 0); $page_title = sanitize_text_field($params['page_title'] ?? ''); $page_url = esc_url_raw($params['page_url'] ?? ''); $css_selector = sanitize_text_field($params['css_selector'] ?? ''); $strategy = sanitize_key($params['strategy'] ?? ''); $scope = ($params['scope'] ?? OptionsRepository::SCOPE_ANY) === OptionsRepository::SCOPE_PAGE ? OptionsRepository::SCOPE_PAGE : OptionsRepository::SCOPE_ANY; $this->logger->debug('Rule creation request', [ 'xpath' => $xpath, 'label' => $label, 'active' => $active, 'expires_at' => $expires_at, 'strategy' => $strategy, 'scope' => $scope, 'user' => wp_get_current_user()->user_login ?? 'unknown' ]); if (empty($xpath)) { $this->logger->warning('Empty XPath in rule creation request'); return new WP_Error('missing_xpath', __('XPath is required', 'ads-destroyer'), ['status' => 400]); } // Get existing rules $rules = $this->options_repository->get_rules(); // Check for duplicate XPath foreach ($rules as $existing_rule) { if ($existing_rule['xpath'] === $xpath) { return new WP_Error( 'duplicate_xpath', __('A rule for this element already exists', 'ads-destroyer'), [ 'status' => 409, 'code' => 'duplicate_xpath', 'existing_rule' => $existing_rule, 'message' => __('A rule for hiding this element already exists. Do you want to activate it?', 'ads-destroyer') ] ); } } // Create new rule $new_rule = $this->options_repository->add_rule([ 'xpath' => $xpath, // translators: %s: XPath expression 'label' => $label ?: sprintf(__('Rule for %s', 'ads-destroyer'), $xpath), 'active' => $active, 'expires_at' => $expires_at, 'page_title' => $page_title, 'page_url' => $page_url, 'css_selector' => $css_selector, 'strategy' => $strategy, 'scope' => $scope, ]); $this->logger->info('New rule created by user: ' . wp_get_current_user()->user_login . ' - XPath: ' . $xpath); // Invalidate inline CSS cache $this->inline_css_generator->invalidate_cache(); return new WP_REST_Response([ 'rule' => $new_rule, 'message' => __('Rule created successfully', 'ads-destroyer'), ]); } /** * POST /rules/activate-existing * * @return WP_REST_Response|WP_Error */ public function handle_activate_existing_rule(WP_REST_Request $request) { if (!$this->nonce_service->verify_request($request)) { return new WP_Error('invalid_nonce', __('Invalid nonce', 'ads-destroyer'), ['status' => 403]); } $params = $request->get_json_params(); $rule_id = sanitize_text_field($params['rule_id'] ?? ''); if (empty($rule_id)) { return new WP_Error('missing_rule_id', __('Rule ID is required', 'ads-destroyer'), ['status' => 400]); } // Update rule to active status $updated = $this->options_repository->update_rule($rule_id, ['active' => true]); if (!$updated) { return new WP_Error('rule_not_found', __('Rule not found', 'ads-destroyer'), ['status' => 404]); } $this->logger->info('Rule activated by user: ' . wp_get_current_user()->user_login . ' - Rule ID: ' . $rule_id); return new WP_REST_Response([ 'message' => __('Rule activated successfully', 'ads-destroyer'), ]); } /** * POST /rules/reset * * @return WP_REST_Response|WP_Error */ public function handle_reset_rules(WP_REST_Request $request) { if (!$this->nonce_service->verify_request($request)) { return new WP_Error('invalid_nonce', __('Security check failed', 'ads-destroyer'), ['status' => 403]); } $this->options_repository->set_option('rules', []); // Invalidate inline CSS cache $this->inline_css_generator->invalidate_cache(); $this->logger->info('All rules reset by user: ' . wp_get_current_user()->user_login); return new WP_REST_Response([ 'message' => __('All rules have been reset', 'ads-destroyer'), ]); } /** * DELETE /rules/{id} * * @return WP_REST_Response|WP_Error */ public function handle_delete_rule(WP_REST_Request $request) { if (!$this->nonce_service->verify_request($request)) { $this->logger->warning('Invalid nonce for rule deletion request'); return new WP_Error('invalid_nonce', __('Invalid nonce', 'ads-destroyer'), ['status' => 403]); } $rule_id = $request->get_param('id'); $this->logger->info('Rule deletion requested', ['rule_id' => $rule_id, 'user' => wp_get_current_user()->user_login ?? 'unknown']); // Delete the rule $deleted = $this->options_repository->delete_rule($rule_id); if (!$deleted) { return new WP_Error('rule_not_found', __('Rule not found', 'ads-destroyer'), ['status' => 404]); } $this->logger->info('Rule deleted by user: ' . wp_get_current_user()->user_login . ' - Rule ID: ' . $rule_id); // Invalidate inline CSS cache $this->inline_css_generator->invalidate_cache(); return new WP_REST_Response([ 'message' => __('Rule deleted successfully', 'ads-destroyer'), ]); } /** * POST /rules/{id} * * @return WP_REST_Response|WP_Error */ public function handle_update_rule(WP_REST_Request $request) { if (!$this->nonce_service->verify_request($request)) { return new WP_Error('invalid_nonce', __('Invalid nonce', 'ads-destroyer'), ['status' => 403]); } $rule_id = $request->get_param('id'); $params = $request->get_json_params(); $this->logger->debug('update_rule request', ['rule_id' => $rule_id, 'params' => $params]); // Prepare update data $update_data = []; if (isset($params['active'])) { $update_data['active'] = filter_var($params['active'], FILTER_VALIDATE_BOOLEAN); } if (isset($params['label'])) { $update_data['label'] = sanitize_text_field($params['label']); } if (isset($params['xpath'])) { $update_data['xpath'] = sanitize_textarea_field($params['xpath']); } if (isset($params['expires_at'])) { $update_data['expires_at'] = (int)$params['expires_at']; } if (isset($params['page_url'])) { $update_data['page_url'] = esc_url_raw($params['page_url']); } if (isset($params['page_title'])) { $update_data['page_title'] = sanitize_text_field($params['page_title']); } if (isset($params['css_selector'])) { $update_data['css_selector'] = sanitize_text_field($params['css_selector']); } // Update the rule $updated = $this->options_repository->update_rule($rule_id, $update_data); if (!$updated) { $this->logger->warning('Rule not found for update', ['rule_id' => $rule_id]); return new WP_Error('rule_not_found', __('Rule not found', 'ads-destroyer'), ['status' => 404]); } // Get the updated rule using OptionsRepository $updated_rule = $this->options_repository->get_rule($rule_id); if (!$updated_rule) { return new WP_Error('rule_not_found', __('Updated rule not found', 'ads-destroyer'), ['status' => 404]); } $this->logger->info('Rule updated', ['rule_id' => $rule_id, 'user' => wp_get_current_user()->user_login]); $this->logger->debug('update_rule result', ['updated_rule' => $updated_rule]); // Invalidate inline CSS cache $this->inline_css_generator->invalidate_cache(); return new WP_REST_Response($updated_rule); } /** * POST /test * * @return WP_REST_Response|WP_Error */ public function handle_test_xpath(WP_REST_Request $request) { if (!$this->nonce_service->verify_request($request)) { return new WP_Error('invalid_nonce', __('Invalid nonce', 'ads-destroyer'), ['status' => 403]); } $params = $request->get_json_params(); $xpath = sanitize_text_field($params['xpath'] ?? ''); if (empty($xpath)) { return new WP_Error('missing_xpath', __('XPath is required', 'ads-destroyer'), ['status' => 400]); } // Test XPath using the XPath engine $result = $this->xpath_engine->test_xpath($xpath); return new WP_REST_Response([ 'ok' => $result['valid'], 'count' => $result['matches'], 'message' => $result['message'], ]); } /** * Handle get latest log REST API request. * * @return WP_REST_Response|WP_Error */ public function handle_get_latest_log(WP_REST_Request $request) { if (!$this->nonce_service->verify_request($request)) { return new WP_Error('invalid_nonce', __('Invalid nonce', 'ads-destroyer'), ['status' => 403]); } $latest_file = $this->logger->get_latest_log_file(); if ($latest_file) { $content = $this->logger->read_log_file($latest_file, 1000); return new WP_REST_Response(['content' => $content]); } else { return new WP_REST_Response(['content' => __('No log files found.', 'ads-destroyer')]); } } /** * Handle download latest log REST API request. * * @return WP_REST_Response|WP_Error */ public function handle_download_latest_log(WP_REST_Request $request) { if (!$this->nonce_service->verify_request($request)) { return new WP_Error('invalid_nonce', __('Invalid nonce', 'ads-destroyer'), ['status' => 403]); } $latest_file = $this->logger->get_latest_log_file(); if ($latest_file === null) { return new WP_Error('no_file', __('No log files found.', 'ads-destroyer'), ['status' => 404]); } return new WP_REST_Response([ 'filename' => 'ads-destroyer-latest.log', 'content' => $this->logger->read_log_file($latest_file, 0), 'download' => true, ]); } /** * Handle clear all logs REST API request. * * @return WP_REST_Response|WP_Error */ public function handle_clear_all_logs(WP_REST_Request $request) { if (!$this->nonce_service->verify_request($request)) { return new WP_Error('invalid_nonce', __('Invalid nonce', 'ads-destroyer'), ['status' => 403]); } $deleted_count = $this->logger->clear_all_logs(); return new WP_REST_Response([ 'count' => $deleted_count, // translators: %d: Number of deleted log files 'message' => sprintf(__('Cleared %d log files.', 'ads-destroyer'), $deleted_count) ]); } /** * Handle JavaScript log message REST API request. * * @return WP_REST_Response|WP_Error */ public function handle_log_js_message(WP_REST_Request $request) { if (!$this->nonce_service->verify_request($request)) { return new WP_Error('invalid_nonce', __('Invalid nonce', 'ads-destroyer'), ['status' => 403]); } $log_entry_json = $request->get_param('log_entry'); $log_entry = is_string($log_entry_json) ? json_decode($log_entry_json, true) : null; if (!is_array($log_entry)) { return new WP_Error('invalid_data', __('Invalid log entry data', 'ads-destroyer'), ['status' => 400]); } $level = is_string($log_entry['level'] ?? null) ? strtolower($log_entry['level']) : 'info'; if (!in_array($level, self::ALLOWED_LOG_LEVELS, true)) { $level = 'info'; } $message = $this->sanitize_log_string($log_entry['message'] ?? '', self::MAX_LOG_MESSAGE_LENGTH); if ($message === '') { return new WP_Error('invalid_data', __('Invalid log entry data', 'ads-destroyer'), ['status' => 400]); } $context = $this->sanitize_log_context($log_entry['context'] ?? []); $this->logger->log($level, '[JS] ' . $message, $context); return new WP_REST_Response(['success' => true]); } /** * Sanitize a single value coming from client-side logging. * * Log files are rendered back in the admin UI, so control characters that * could forge a new log line are removed along with any markup. * * @since 1.0.29 * @param mixed $value Raw value. * @param int $max_length Maximum allowed length. * @return string Sanitized value. */ private function sanitize_log_string($value, int $max_length): string { if (is_bool($value)) { return $value ? 'true' : 'false'; } if (!is_scalar($value)) { return ''; } $value = sanitize_text_field(wp_strip_all_tags((string) $value)); $value = preg_replace('/[\x00-\x1F\x7F]+/u', ' ', $value) ?? ''; $value = trim($value); if (mb_strlen($value) > $max_length) { $value = mb_substr($value, 0, $max_length) . '…'; } return $value; } /** * Recursively sanitize the context payload of a client-side log entry. * * @since 1.0.29 * @param mixed $context Raw context data. * @param int $depth Current recursion depth. * @return array Sanitized context. */ private function sanitize_log_context($context, int $depth = 0): array { if (!is_array($context) || $depth >= self::MAX_LOG_CONTEXT_DEPTH) { return []; } $sanitized = []; $size = 0; foreach ($context as $key => $value) { $safe_key = sanitize_key((string) $key); if ($safe_key === '') { continue; } if (is_array($value)) { $sanitized[$safe_key] = $this->sanitize_log_context($value, $depth + 1); } elseif (is_int($value) || is_float($value)) { $sanitized[$safe_key] = $value; } else { $sanitized[$safe_key] = $this->sanitize_log_string($value, self::MAX_LOG_MESSAGE_LENGTH); } $size = strlen((string) wp_json_encode($sanitized)); if ($size > self::MAX_LOG_CONTEXT_BYTES) { unset($sanitized[$safe_key]); break; } } return $sanitized; } }