Changeset 3706276
- Timestamp:
- 09/21/2026 08:24:35 PM (4 weeks ago)
- Location:
- directrelay-automation-bridge-for-n8n
- Files:
-
- 14 added
- 6 deleted
- 72 edited
- 1 copied
-
tags/5.1.32 (copied) (copied from directrelay-automation-bridge-for-n8n/trunk )
-
tags/5.1.32/assets/fonts/inter-v20-latin-500.svg (added)
-
tags/5.1.32/assets/fonts/inter-v20-latin-600.svg (added)
-
tags/5.1.32/assets/fonts/inter-v20-latin-700.svg (added)
-
tags/5.1.32/assets/fonts/inter-v20-latin-regular.svg (added)
-
tags/5.1.32/assets/fonts/jetbrains-mono-v24-latin-500.svg (added)
-
tags/5.1.32/assets/fonts/jetbrains-mono-v24-latin-regular.svg (added)
-
tags/5.1.32/assets/icon-256x256.png (added)
-
tags/5.1.32/assets/js/admin.js (modified) (4 diffs)
-
tags/5.1.32/directrelay.php (modified) (10 diffs)
-
tags/5.1.32/includes/Activity_Logger.php (modified) (3 diffs)
-
tags/5.1.32/includes/Agent_Profiles.php (modified) (2 diffs)
-
tags/5.1.32/includes/Api_Auth.php (modified) (6 diffs)
-
tags/5.1.32/includes/Brute_Force.php (modified) (2 diffs)
-
tags/5.1.32/includes/Build.php (modified) (1 diff)
-
tags/5.1.32/includes/Compatibility.php (modified) (2 diffs)
-
tags/5.1.32/includes/Content_Processor.php (modified) (3 diffs)
-
tags/5.1.32/includes/Cron_Health.php (modified) (1 diff)
-
tags/5.1.32/includes/Feature_Gate.php (modified) (3 diffs)
-
tags/5.1.32/includes/IndexNow_Pinger.php (modified) (4 diffs)
-
tags/5.1.32/includes/Installer.php (modified) (8 diffs)
-
tags/5.1.32/includes/Plugin.php (modified) (2 diffs)
-
tags/5.1.32/includes/Post_Manager.php (modified) (3 diffs)
-
tags/5.1.32/includes/REST/Base_Controller.php (modified) (1 diff)
-
tags/5.1.32/includes/REST/Fleet_Controller.php (deleted)
-
tags/5.1.32/includes/REST/Media_Controller.php (modified) (1 diff)
-
tags/5.1.32/includes/REST/Meta_Controller.php (modified) (3 diffs)
-
tags/5.1.32/includes/REST/Openapi_Controller.php (modified) (2 diffs)
-
tags/5.1.32/includes/REST/Pages_Controller.php (modified) (3 diffs)
-
tags/5.1.32/includes/REST/Posts_Controller.php (modified) (5 diffs)
-
tags/5.1.32/includes/REST/Webhook_Controller.php (modified) (1 diff)
-
tags/5.1.32/includes/Rankmath_Handler.php (modified) (1 diff)
-
tags/5.1.32/includes/Rate_Limiter.php (modified) (5 diffs)
-
tags/5.1.32/includes/Transactional_Publisher.php (modified) (5 diffs)
-
tags/5.1.32/includes/Webhook_Dispatcher.php.disabled-5.1.18 (deleted)
-
tags/5.1.32/includes/Webhook_Manager.php (modified) (19 diffs)
-
tags/5.1.32/includes/admin/Ajax_Handler.php (modified) (3 diffs)
-
tags/5.1.32/includes/admin/Menu.php (modified) (1 diff)
-
tags/5.1.32/includes/admin/views/docs.php (modified) (1 diff)
-
tags/5.1.32/includes/admin/views/keys.php (modified) (2 diffs)
-
tags/5.1.32/includes/admin/views/n8n-templates.php (modified) (1 diff)
-
tags/5.1.32/includes/admin/views/settings.php (modified) (1 diff)
-
tags/5.1.32/includes/admin/views/webhooks.php (modified) (5 diffs)
-
tags/5.1.32/languages/_stale_pot_backup (deleted)
-
tags/5.1.32/privacy.txt (modified) (3 diffs)
-
tags/5.1.32/readme.txt (modified) (5 diffs)
-
tags/5.1.32/uninstall.php (modified) (3 diffs)
-
trunk/assets/fonts/inter-v20-latin-500.svg (added)
-
trunk/assets/fonts/inter-v20-latin-600.svg (added)
-
trunk/assets/fonts/inter-v20-latin-700.svg (added)
-
trunk/assets/fonts/inter-v20-latin-regular.svg (added)
-
trunk/assets/fonts/jetbrains-mono-v24-latin-500.svg (added)
-
trunk/assets/fonts/jetbrains-mono-v24-latin-regular.svg (added)
-
trunk/assets/icon-256x256.png (added)
-
trunk/assets/js/admin.js (modified) (4 diffs)
-
trunk/directrelay.php (modified) (10 diffs)
-
trunk/includes/Activity_Logger.php (modified) (3 diffs)
-
trunk/includes/Agent_Profiles.php (modified) (2 diffs)
-
trunk/includes/Api_Auth.php (modified) (6 diffs)
-
trunk/includes/Brute_Force.php (modified) (2 diffs)
-
trunk/includes/Build.php (modified) (1 diff)
-
trunk/includes/Compatibility.php (modified) (2 diffs)
-
trunk/includes/Content_Processor.php (modified) (3 diffs)
-
trunk/includes/Cron_Health.php (modified) (1 diff)
-
trunk/includes/Feature_Gate.php (modified) (3 diffs)
-
trunk/includes/IndexNow_Pinger.php (modified) (4 diffs)
-
trunk/includes/Installer.php (modified) (8 diffs)
-
trunk/includes/Plugin.php (modified) (2 diffs)
-
trunk/includes/Post_Manager.php (modified) (3 diffs)
-
trunk/includes/REST/Base_Controller.php (modified) (1 diff)
-
trunk/includes/REST/Fleet_Controller.php (deleted)
-
trunk/includes/REST/Media_Controller.php (modified) (1 diff)
-
trunk/includes/REST/Meta_Controller.php (modified) (3 diffs)
-
trunk/includes/REST/Openapi_Controller.php (modified) (2 diffs)
-
trunk/includes/REST/Pages_Controller.php (modified) (3 diffs)
-
trunk/includes/REST/Posts_Controller.php (modified) (5 diffs)
-
trunk/includes/REST/Webhook_Controller.php (modified) (1 diff)
-
trunk/includes/Rankmath_Handler.php (modified) (1 diff)
-
trunk/includes/Rate_Limiter.php (modified) (5 diffs)
-
trunk/includes/Transactional_Publisher.php (modified) (5 diffs)
-
trunk/includes/Webhook_Dispatcher.php.disabled-5.1.18 (deleted)
-
trunk/includes/Webhook_Manager.php (modified) (19 diffs)
-
trunk/includes/admin/Ajax_Handler.php (modified) (3 diffs)
-
trunk/includes/admin/Menu.php (modified) (1 diff)
-
trunk/includes/admin/views/docs.php (modified) (1 diff)
-
trunk/includes/admin/views/keys.php (modified) (2 diffs)
-
trunk/includes/admin/views/n8n-templates.php (modified) (1 diff)
-
trunk/includes/admin/views/settings.php (modified) (1 diff)
-
trunk/includes/admin/views/webhooks.php (modified) (5 diffs)
-
trunk/languages/_stale_pot_backup (deleted)
-
trunk/privacy.txt (modified) (3 diffs)
-
trunk/readme.txt (modified) (5 diffs)
-
trunk/uninstall.php (modified) (3 diffs)
Legend:
- Unmodified
- Added
- Removed
-
directrelay-automation-bridge-for-n8n/tags/5.1.32/assets/js/admin.js
r3699228 r3706276 148 148 149 149 var payload = { 150 action: 'directrelay_create_key', 150 action: 'directrelay_create_key', 151 151 nonce: directrelayAdmin.nonce, 152 label: $('[name=label]').val(), 152 label: $('[name=label]').val(), 153 153 description: $('[name=description]').val(), 154 environment: $('#directrelay-key-env').val(), 154 environment: $('#directrelay-key-env').val(), 155 agent_role: $('[name=agent_role]').val() || 'full_access', 156 daily_limit: $('[name=daily_limit]').val() || '0', 157 require_approval: $('[name=require_approval]').is(':checked') ? 1 : 0, 158 restrict_own_posts: $('[name=restrict_own_posts]').is(':checked') ? 1 : 0, 155 159 expires_at: $('[name=expires_at]').val(), 156 160 ip_whitelist: $('[name=ip_whitelist]').val(), … … 348 352 349 353 /* —— Toggle webhook active —————————————————————————————————————— */ 350 $(document).on('change', '.directrelay-wh-toggle', function () { 351 var id = $(this).data('id'), active = this.checked ? 1 : 0; 352 $.post(directrelayAdmin.ajaxUrl, { action: 'directrelay_toggle_webhook', nonce: directrelayAdmin.nonce, id: id, active: active }) 353 .done(function (res) { if (res.success) toast(active ? 'Webhook enabled.' : 'Webhook disabled.', 'info'); }); 354 $(document).on('click', '.directrelay-toggle-wh', function (e) { 355 e.preventDefault(); 356 var $btn = $(this).prop('disabled', true), next = $btn.data('active') ? 1 : 0; 357 $.post(directrelayAdmin.ajaxUrl, { action: 'directrelay_toggle_webhook', nonce: directrelayAdmin.nonce, id: $btn.data('id'), active: next }) 358 .done(function (res) { 359 if (res.success) { toast(next ? 'Webhook enabled.' : 'Webhook disabled.', 'info'); setTimeout(function () { location.reload(); }, 500); } 360 else { toast((res.data && res.data.message) || 'Error', 'error'); $btn.prop('disabled', false); } 361 }) 362 .fail(function () { toast('Failed.', 'error'); $btn.prop('disabled', false); }); 354 363 }); 355 364 356 365 /* —— Delete webhook ———————————————————————————————————————————— */ 357 $(document).on('click', '.directrelay-del -wh', function (e) {366 $(document).on('click', '.directrelay-delete-wh', function (e) { 358 367 e.preventDefault(); 359 368 var id = $(this).data('id'); … … 382 391 383 392 /* —— Modal close ———————————————————————————————————————————————— */ 384 $(document).on('click', '.directrelay-modal-close', function (e) { e.preventDefault(); $( '#directrelay-test-modal').hide(); });385 $(document).on('keydown', function (e) { if (e.key === 'Escape') $(' #directrelay-test-modal').hide(); });386 $(document).on('click', ' #directrelay-test-modal', function (e) { if ($(e.target).is(this)) $(this).hide(); });393 $(document).on('click', '.directrelay-modal-close', function (e) { e.preventDefault(); $(this).closest('.directrelay-modal-overlay').hide(); }); 394 $(document).on('keydown', function (e) { if (e.key === 'Escape') $('.directrelay-modal-overlay').hide(); }); 395 $(document).on('click', '.directrelay-modal-overlay', function (e) { if ($(e.target).is(this)) $(this).hide(); }); 387 396 388 397 /* —— Run System Diagnostic ————————————————————————————————————— */ … … 711 720 $(document).on('click', '.directrelay-view-log', function() { 712 721 var id = $(this).data('id'), label = $(this).data('label'); 722 $('#directrelay-deliveries-modal .directrelay-modal-title').text('Deliveries — ' + label); 723 $('#directrelay-delivery-count').text(''); 724 $('#directrelay-deliveries-modal').show(); 713 725 $('#directrelay-delivery-log').html('<p class="directrelay-empty">Loading...</p>'); 714 726 $.ajax({ -
directrelay-automation-bridge-for-n8n/tags/5.1.32/directrelay.php
r3700957 r3706276 1 1 <?php 2 2 /** 3 * Plugin Name: DirectRelay Automation Bridge for n8n3 * Plugin Name: DirectRelay – n8n Workflow Automation Plugin (REST API & Webhooks) 4 4 * Plugin URI: https://directrelay.wikiofautomation.com 5 5 * Description: REST API automation bridge connecting n8n workflows and AI agents with WordPress. Manage posts, Rank Math and Yoast SEO metadata, media attachments, and webhooks with advanced security. 6 * Version: 5.1.3 16 * Version: 5.1.32 7 7 * Author: n4nion 8 8 * Author URI: https://profiles.wordpress.org/n4nion … … 41 41 if ( ! empty( $up['basedir'] ) ) { 42 42 $dir = trailingslashit( $up['basedir'] ) . 'directrelay-automation-bridge-for-n8n'; 43 /* 44 * Create the folder protected on first use: uploads is served 45 * directly, so deny web access to the trace/error logs 46 * (Apache; nginx sites need an equivalent server rule) and 47 * block directory listing. 48 */ 49 if ( ! is_dir( $dir ) && wp_mkdir_p( $dir ) ) { 50 @file_put_contents( $dir . '/.htaccess', "Require all denied\nDeny from all\n" ); 51 @file_put_contents( $dir . '/index.php', "<?php // Silence is golden.\n" ); 52 } 43 53 $candidates[] = $dir . '/trace.log'; 44 54 $candidates[] = $dir . '/error.log'; … … 72 82 $paths = directrelay_log_paths(); 73 83 foreach ( $paths as $p ) { 74 @file_put_contents( $p, $line, FILE_APPEND ); 84 /* Rotate by truncation at ~1MB so the log cannot grow unbounded. */ 85 if ( is_file( $p ) && filesize( $p ) > 1048576 ) { 86 @file_put_contents( $p, $line ); 87 } else { 88 @file_put_contents( $p, $line, FILE_APPEND ); 89 } 75 90 } 76 91 } … … 104 119 $paths = directrelay_log_paths(); 105 120 foreach ( $paths as $p ) { 106 @file_put_contents( $p, $line, FILE_APPEND ); 121 /* Rotate by truncation at ~1MB so the log cannot grow unbounded. */ 122 if ( is_file( $p ) && filesize( $p ) > 1048576 ) { 123 @file_put_contents( $p, $line ); 124 } else { 125 @file_put_contents( $p, $line, FILE_APPEND ); 126 } 107 127 } 108 128 } … … 169 189 } 170 190 191 /** 192 * Single-site policy: the free build manages exactly one WordPress site. 193 * 194 * On multisite this plugin refuses to load at all - no REST routes, no admin 195 * pages, no webhooks, no cron - so a single free install can never bridge, 196 * monitor, or provision another site or domain. Network and per-site 197 * activation are rejected at the activation hook, and installs upgrading from 198 * an older version are deactivated on the next admin load. Multi-site fleet 199 * management is provided by the separate add-on plugin. 200 */ 201 if ( function_exists( 'is_multisite' ) && is_multisite() ) { 202 directrelay_trace( 'multisite_detected: single-site build refusing to load' ); 203 204 $directrelay_ms_notice = function () { 205 if ( ! function_exists( 'current_user_can' ) || ! current_user_can( 'activate_plugins' ) ) { 206 return; 207 } 208 echo '<div class="notice notice-error"><p><strong>DirectRelay:</strong> ' . 209 esc_html__( 'The free DirectRelay build supports a single WordPress site only and cannot run on WordPress multisite. Multi-site and multi-domain management is available in the separate DirectRelay add-on plugin.', 'directrelay-automation-bridge-for-n8n' ) . 210 '</p></div>'; 211 }; 212 213 $directrelay_ms_deactivate = function () { 214 if ( ! function_exists( 'current_user_can' ) || ! current_user_can( 'activate_plugins' ) ) { 215 return; 216 } 217 if ( ! function_exists( 'deactivate_plugins' ) || ! function_exists( 'plugin_basename' ) ) { 218 return; 219 } 220 $slug = plugin_basename( __FILE__ ); 221 if ( function_exists( 'is_plugin_active_for_network' ) && is_plugin_active_for_network( $slug ) ) { 222 deactivate_plugins( $slug, true, true ); 223 return; 224 } 225 if ( function_exists( 'is_plugin_active' ) && is_plugin_active( $slug ) ) { 226 deactivate_plugins( $slug ); 227 } 228 }; 229 230 register_activation_hook( __FILE__, function () { 231 if ( function_exists( 'wp_die' ) ) { 232 wp_die( 233 esc_html__( 'DirectRelay (free) supports a single WordPress site only. It cannot be activated on WordPress multisite, neither network-wide nor per-site. For multi-site fleet management, use the separate DirectRelay add-on plugin.', 'directrelay-automation-bridge-for-n8n' ), 234 esc_html__( 'DirectRelay requires a single site', 'directrelay-automation-bridge-for-n8n' ), 235 array( 'back_link' => true ) 236 ); 237 } 238 } ); 239 240 if ( function_exists( 'add_action' ) ) { 241 add_action( 'admin_init', $directrelay_ms_deactivate ); 242 add_action( 'admin_notices', $directrelay_ms_notice ); 243 add_action( 'network_admin_notices', $directrelay_ms_notice ); 244 } 245 unset( $directrelay_ms_notice, $directrelay_ms_deactivate ); 246 return; 247 } 248 171 249 if ( defined( 'DIRECTRELAY_LOADED' ) || class_exists( '\DirectRelay\Plugin', false ) ) { 172 250 directrelay_trace( 'already_loaded: bail' ); … … 176 254 directrelay_trace( 'past_load_guard: defining constants' ); 177 255 178 define( 'DIRECTRELAY_VERSION', '5.1.3 1' );256 define( 'DIRECTRELAY_VERSION', '5.1.32' ); 179 257 define( 'DIRECTRELAY_FILE', __FILE__ ); 180 258 define( 'DIRECTRELAY_DIR', plugin_dir_path( __FILE__ ) ); 181 259 define( 'DIRECTRELAY_URL', plugin_dir_url( __FILE__ ) ); 182 260 define( 'DIRECTRELAY_NS', 'directrelay/v1' ); 183 define( 'DIRECTRELAY_DB_VERSION', 1 1 );261 define( 'DIRECTRELAY_DB_VERSION', 12 ); // 12: TEXT columns drop DEFAULT literals (illegal on stock MySQL); key_hash narrowed to 191. 184 262 define( 'DIRECTRELAY_BUILD', 'all-in-one' ); 185 263 186 require_once DIRECTRELAY_DIR . 'includes/Build.php'; 187 directrelay_trace( 'loaded: includes/Build.php' ); 188 189 if ( file_exists( DIRECTRELAY_DIR . 'debug.php' ) ) { 190 require_once DIRECTRELAY_DIR . 'debug.php'; 191 directrelay_trace( 'loaded: debug.php' ); 192 } 264 require_once DIRECTRELAY_DIR . 'includes/Build.php'; 265 directrelay_trace( 'loaded: includes/Build.php' ); 193 266 194 267 /** … … 213 286 'includes/Compatibility.php', 214 287 'includes/Cron_Health.php', 215 'includes/Telemetry_Collector.php',216 288 'includes/Feature_Gate.php', 217 289 /* REST layer (free surface) */ … … 223 295 'includes/REST/Media_Controller.php', 224 296 'includes/REST/Taxonomy_Controller.php', 225 'includes/REST/Fleet_Controller.php',226 297 'includes/REST/Webhook_Controller.php', 227 298 'includes/REST/Keys_Controller.php', … … 230 301 'includes/REST/Openapi_Controller.php', 231 302 /* 232 * portal, full debugger, Chart.js analytics) live in the233 * directrelay-pro plugin distributed from234 * https://directrelay.wikiofautomation.com. They re-attach to the235 * free plugin via the `directrelay_ext_register_routes`action.303 * The add-on plugin (directrelay-pro, distributed from 304 * https://directrelay.wikiofautomation.com) re-attaches its own 305 * routes to the free plugin via the `directrelay_ext_register_routes` 306 * action. 236 307 */ 237 308 /* Admin layer */ … … 301 372 wp_clear_scheduled_hook( 'directrelay_prune_rate_limits' ); 302 373 wp_clear_scheduled_hook( 'directrelay_fire_webhook' ); 303 wp_clear_scheduled_hook( 'directrelay_ retry_webhook' );374 wp_clear_scheduled_hook( 'directrelay_deliver_webhook' ); 304 375 wp_clear_scheduled_hook( 'directrelay_cron_health_ping' ); 305 376 wp_clear_scheduled_hook( 'directrelay_prune_brute_force' ); 306 wp_clear_scheduled_hook( 'directrelay_telemetry_heartbeat' );307 377 flush_rewrite_rules(); 308 378 } catch ( \Throwable $e ) { -
directrelay-automation-bridge-for-n8n/tags/5.1.32/includes/Activity_Logger.php
r3699228 r3706276 115 115 public static function hourly_stats( int $hours = 24, ?int $key_id = null ): array { 116 116 global $wpdb; 117 /* PHP-side date math for SQLite portability (the wp-sqlite-db shim 118 cannot translate DATE_SUB/UTC_TIMESTAMP). bucket_hour is UTC. */ 119 $cutoff = gmdate( 'Y-m-d H:00:00', time() - $hours * HOUR_IN_SECONDS ); 117 120 if ( $key_id ) { 118 121 $query = $wpdb->prepare( 119 "SELECT bucket_hour, SUM(hits) as hits, SUM(total_ms) as total_ms, SUM(CASE WHEN http_status >= 400 THEN hits ELSE 0 END) as errors FROM {$wpdb->prefix}directrelay_analytics WHERE bucket_hour >= DATE_SUB(UTC_TIMESTAMP(),INTERVAL %d HOUR)AND key_id = %d GROUP BY bucket_hour ORDER BY bucket_hour ASC",120 $ hours,122 "SELECT bucket_hour, SUM(hits) as hits, SUM(total_ms) as total_ms, SUM(CASE WHEN http_status >= 400 THEN hits ELSE 0 END) as errors FROM {$wpdb->prefix}directrelay_analytics WHERE bucket_hour >= %s AND key_id = %d GROUP BY bucket_hour ORDER BY bucket_hour ASC", 123 $cutoff, 121 124 $key_id 122 125 ); 123 126 } else { 124 127 $query = $wpdb->prepare( 125 "SELECT bucket_hour, SUM(hits) as hits, SUM(total_ms) as total_ms, SUM(CASE WHEN http_status >= 400 THEN hits ELSE 0 END) as errors FROM {$wpdb->prefix}directrelay_analytics WHERE bucket_hour >= DATE_SUB(UTC_TIMESTAMP(),INTERVAL %d HOUR)GROUP BY bucket_hour ORDER BY bucket_hour ASC",126 $ hours128 "SELECT bucket_hour, SUM(hits) as hits, SUM(total_ms) as total_ms, SUM(CASE WHEN http_status >= 400 THEN hits ELSE 0 END) as errors FROM {$wpdb->prefix}directrelay_analytics WHERE bucket_hour >= %s GROUP BY bucket_hour ORDER BY bucket_hour ASC", 129 $cutoff 127 130 ); 128 131 } … … 133 136 public static function top_endpoints( int $days = 7 ): array { 134 137 global $wpdb; 138 /* PHP-side date math for SQLite portability. */ 139 $cutoff = gmdate( 'Y-m-d H:00:00', time() - $days * DAY_IN_SECONDS ); 135 140 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- custom table. 136 141 return $wpdb->get_results( $wpdb->prepare( 137 "SELECT endpoint, method, SUM(hits) as hits, AVG(total_ms/hits) as avg_ms FROM {$wpdb->prefix}directrelay_analytics WHERE bucket_hour >= DATE_SUB(UTC_TIMESTAMP(),INTERVAL %d DAY)GROUP BY endpoint,method ORDER BY hits DESC LIMIT 10",138 $ days142 "SELECT endpoint, method, SUM(hits) as hits, AVG(total_ms/hits) as avg_ms FROM {$wpdb->prefix}directrelay_analytics WHERE bucket_hour >= %s GROUP BY endpoint,method ORDER BY hits DESC LIMIT 10", 143 $cutoff 139 144 ), ARRAY_A ) ?: array(); 140 145 } … … 143 148 global $wpdb; 144 149 $days = (int) get_option( 'directrelay_log_retention_days', 30 ); 150 /* PHP-side date math for SQLite portability; created_at/bucket_hour are UTC. */ 151 $cutoff = gmdate( 'Y-m-d H:i:s', time() - $days * DAY_IN_SECONDS ); 145 152 146 153 /* Prune standard logs */ 147 154 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- bulk delete on custom table. 148 $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->prefix}directrelay_log WHERE created_at < DATE_SUB(UTC_TIMESTAMP(),INTERVAL %d DAY)", $days) );155 $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->prefix}directrelay_log WHERE created_at < %s", $cutoff ) ); 149 156 150 157 /* Prune analytics (uses bucket_hour instead of created_at) */ 151 158 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- bulk delete on custom table. 152 $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->prefix}directrelay_analytics WHERE bucket_hour < DATE_SUB(UTC_TIMESTAMP(),INTERVAL %d DAY)", $days ) ); 159 $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->prefix}directrelay_analytics WHERE bucket_hour < %s", $cutoff ) ); 160 161 /* Safety cap: hard bound on table growth regardless of retention days 162 (matches the export cap). Two-step portable delete — no self-join. */ 163 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom table. 164 $boundary = $wpdb->get_var( $wpdb->prepare( "SELECT id FROM {$wpdb->prefix}directrelay_log ORDER BY id DESC LIMIT 1 OFFSET %d", 10000 ) ); 165 if ( $boundary ) { 166 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- bulk delete on custom table. 167 $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->prefix}directrelay_log WHERE id < %d", (int) $boundary ) ); 168 } 153 169 } 154 170 -
directrelay-automation-bridge-for-n8n/tags/5.1.32/includes/Agent_Profiles.php
r3699228 r3706276 97 97 $role = $key['agent_role'] ?? self::ROLE_FULL_ACCESS; 98 98 99 // Agent personas (non-FULL_ACCESS) are a add-on feature(s). The free99 // Agent personas (non-FULL_ACCESS) are an add-on feature. The free 100 100 // plugin has only the FULL_ACCESS role, so any non-default role 101 101 // means the add-on plugin is extending the agent profile. The free … … 125 125 // 3. Post Ownership Isolation check 126 126 if ( $post_id && ! empty( $key['restrict_own_posts'] ) ) { 127 $creator_key_id = (int) get_post_meta( $post_id, '_directrelay_created_by_key', true ); 128 if ( $creator_key_id > 0 && $creator_key_id !== (int) $key['id'] ) { 129 return new \WP_Error( 130 'amp_ownership_restricted', 131 __( 'This API Agent is restricted to managing only content created by its own API key.', 'directrelay-automation-bridge-for-n8n' ), 132 [ 'status' => 403 ] 133 ); 134 } 127 return self::assert_owns( $key, $post_id ); 135 128 } 136 129 130 return true; 131 } 132 133 /** 134 * Post Ownership Isolation enforcement for a specific post. 135 * 136 * Called from the REST controllers once the target post ID is known 137 * (guard() runs before the route resolves an ID, so it cannot pass one). 138 * Keys without the restriction always pass; a restricted key may only 139 * manage posts stamped with its own key ID — unstamped (admin-created) 140 * content stays shared. 141 * 142 * @return true|\WP_Error 143 */ 144 public static function assert_owns( array $key, int $post_id ): bool|\WP_Error { 145 if ( empty( $key['restrict_own_posts'] ) ) { 146 return true; 147 } 148 $creator_key_id = (int) get_post_meta( $post_id, '_directrelay_created_by_key', true ); 149 if ( $creator_key_id > 0 && $creator_key_id !== (int) ( $key['id'] ?? 0 ) ) { 150 return new \WP_Error( 151 'amp_ownership_restricted', 152 __( 'This API Agent is restricted to managing only content created by its own API key.', 'directrelay-automation-bridge-for-n8n' ), 153 [ 'status' => 403 ] 154 ); 155 } 137 156 return true; 138 157 } -
directrelay-automation-bridge-for-n8n/tags/5.1.32/includes/Api_Auth.php
r3699228 r3706276 21 21 'approval:read', 'approval:write', 22 22 'debugger:read', 'debugger:write', 23 'fleet:read', 'fleet:write',24 23 'universal:write', 25 24 ]; … … 59 58 // because the WP.org Plugin Directory Guidelines forbid usage 60 59 // limits in the free build. The directrelay-pro add-on may 61 // re-introduce a limit (s)via the `directrelay_ext_guardrails_check`60 // re-introduce a limit via the `directrelay_ext_guardrails_check` 62 61 // filter. 63 62 $raw = self::generate_raw(); … … 194 193 $prefix = substr( $raw, 0, 12 ); 195 194 $prefix_short = substr( $raw, 0, 8 ); 195 /* PHP-side date math for SQLite portability (the wp-sqlite-db shim 196 cannot translate UTC_TIMESTAMP()); expires_at is stored as UTC. */ 197 $now_utc = current_time( 'mysql', true ); 196 198 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom table auth lookup. 197 199 $rows = $wpdb->get_results( … … 200 202 WHERE (key_prefix = %s OR key_prefix = %s) 201 203 AND revoked = 0 202 AND (expires_at IS NULL OR expires_at > UTC_TIMESTAMP())",204 AND (expires_at IS NULL OR expires_at > %s)", 203 205 $prefix, 204 $prefix_short 206 $prefix_short, 207 $now_utc 205 208 ), 206 209 ARRAY_A … … 279 282 $row['last_used_ip'] = $ip; 280 283 $row['total_requests'] = (int) ( $row['total_requests'] ?? 0 ) + 1; 281 $row['scopes_array'] = json_decode( $row['scopes'] , true ) ?: [];284 $row['scopes_array'] = json_decode( $row['scopes'] ?? '[]', true ) ?: []; 282 285 $row['is_full'] = empty( $row['scopes_array'] ); 283 286 return $row; … … 550 553 551 554 public static function is_url_safe( string $url ): bool { 552 $scheme = wp_parse_url( $url, PHP_URL_SCHEME);553 if ( ! in_array( strtolower( $scheme ), [ 'http', 'https' ], true ) ) {554 return false; 555 } 556 $host = strtolower( wp_parse_url( $url, PHP_URL_HOST ) );555 $scheme = strtolower( (string) wp_parse_url( $url, PHP_URL_SCHEME ) ); 556 if ( ! in_array( $scheme, [ 'http', 'https' ], true ) ) { 557 return false; 558 } 559 $host = strtolower( (string) wp_parse_url( $url, PHP_URL_HOST ) ); 557 560 if ( empty( $host ) ) { 558 561 return false; 559 562 } 560 $ip = gethostbyname( $host ); 561 if ( $ip === $host ) { 563 564 // Block typical local network domain suffixes. 565 if ( 'localhost' === $host || str_ends_with( $host, '.local' ) || str_ends_with( $host, '.localhost' ) || str_ends_with( $host, '.internal' ) ) { 566 return false; 567 } 568 569 $ips = []; 570 if ( filter_var( $host, FILTER_VALIDATE_IP ) ) { 571 // Literal IPv4/IPv6 hosts are validated directly (gethostbyname() 572 // would return them unchanged and read as "couldn't resolve"). 573 $ips[] = $host; 574 } else { 575 // Every A record must be public — gethostbyname() alone only 576 // inspects one record, leaving the rest unchecked. 577 $a_records = gethostbynamel( $host ); 578 if ( is_array( $a_records ) ) { 579 $ips = $a_records; 580 } 581 // AAAA records for IPv6-only hosts (gethostbynamel() is v4-only). 582 $aaaa = function_exists( 'dns_get_record' ) ? dns_get_record( $host, DNS_AAAA ) : false; 583 if ( is_array( $aaaa ) ) { 584 foreach ( $aaaa as $rec ) { 585 if ( ! empty( $rec['ipv6'] ) ) { 586 $ips[] = $rec['ipv6']; 587 } 588 } 589 } 590 } 591 592 if ( empty( $ips ) ) { 562 593 // Couldn't resolve — block it. 563 594 return false; 564 595 } 565 if ( ! filter_var( $ip, FILTER_VALIDATE_IP ) ) { 566 return false; 567 } 568 569 // Block loopback, private and reserved ranges 570 if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) === false ) { 571 return false; 572 } 573 574 if ( in_array( $ip, [ '127.0.0.1', '::1', '0.0.0.0' ], true ) ) { 575 return false; 576 } 577 578 // Block typical local network domain suffixes. 579 if ( str_ends_with( $host, '.local' ) || str_ends_with( $host, '.localhost' ) || $host === 'localhost' ) { 580 return false; 596 597 foreach ( $ips as $ip ) { 598 if ( ! filter_var( $ip, FILTER_VALIDATE_IP ) ) { 599 return false; 600 } 601 602 // Block loopback, private and reserved ranges. 603 if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) === false ) { 604 return false; 605 } 606 607 if ( in_array( $ip, [ '127.0.0.1', '::1', '0.0.0.0' ], true ) ) { 608 return false; 609 } 581 610 } 582 611 -
directrelay-automation-bridge-for-n8n/tags/5.1.32/includes/Brute_Force.php
r3699228 r3706276 24 24 public static function check_ip( string $ip ): bool|\WP_Error { 25 25 global $wpdb; 26 $t = "{$wpdb->prefix}directrelay_brute_force"; 26 $t = "{$wpdb->prefix}directrelay_brute_force"; 27 /* Both event types are enforced here: 'failed_auth' rows carry the 28 * automatic escalating block, 'manual_block' rows come from the admin 29 * Blocked-IPs card. The latest expiry wins when an IP has both. */ 27 30 $row = $wpdb->get_row( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom table. 28 $wpdb->prepare( "SELECT * FROM $t WHERE ip_address = %s AND event_type = 'failed_auth'", $ip ), // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared31 $wpdb->prepare( "SELECT blocked_until FROM $t WHERE ip_address = %s AND event_type IN ( 'failed_auth', 'manual_block' ) AND blocked_until IS NOT NULL ORDER BY blocked_until DESC LIMIT 1", $ip ), // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared 29 32 ARRAY_A 30 33 ); 31 if ( ! $row ) {34 if ( ! $row || empty( $row['blocked_until'] ) ) { 32 35 return true; 33 36 } 34 if ( ! empty( $row['blocked_until'] ) && strtotime( $row['blocked_until'] ) > time() ) { 35 $secs = strtotime( $row['blocked_until'] ) - time(); 37 /* blocked_until is written with gmdate() (UTC), so parse it as UTC. */ 38 $until = strtotime( $row['blocked_until'] . ' UTC' ); 39 if ( false !== $until && $until > time() ) { 40 $secs = $until - time(); 36 41 return new \WP_Error( 37 42 'amp_ip_blocked', 38 sprintf( 43 sprintf( 39 44 /* translators: %d: number of seconds */ 40 __( 'Too many failed attempts. Retry in %d seconds.', 'directrelay-automation-bridge-for-n8n'), 41 $secs 45 __( 'Too many failed attempts. Retry in %d seconds.', 'directrelay-automation-bridge-for-n8n'), 46 $secs 42 47 ), 43 48 [ 'status' => 429 ] … … 194 199 * @param string $ip Plain IPv4 or IPv6 address (CIDR is rejected to 195 200 * keep the table key exact-match; range blocking 196 * is a add-on feature(s)).201 * is an add-on feature). 197 202 * @return true|\WP_Error True on success, WP_Error with reason on failure. 198 203 */ -
directrelay-automation-bridge-for-n8n/tags/5.1.32/includes/Build.php
r3699228 r3706276 6 6 * 7 7 * The free plugin is a single build (`type() === 'all-in-one'`) and 8 * does not carry a add-on variant, a third-party distribution channel,9 * or a licence-state flag. Those concerns live in the separate8 * does not carry an add-on variant, a third-party distribution channel, 9 * or an activation-state flag. Those concerns live in the separate 10 10 * `directrelay-pro` add-on distributed from 11 11 * https://directrelay.wikiofautomation.com/directrelay-pro. -
directrelay-automation-bridge-for-n8n/tags/5.1.32/includes/Compatibility.php
r3699228 r3706276 19 19 } 20 20 21 add_filter( 'determine_current_user', [ __CLASS__, 'force_auth_for_rest_blocks' ], 999 ); 22 } 23 24 public static function force_auth_for_rest_blocks( $user ) { 25 global $wpdb; 26 if ( ! empty( $user ) ) return $user; 27 28 $http_auth = isset( $_SERVER['HTTP_AUTHORIZATION'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_AUTHORIZATION'] ) ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized 29 $redir_auth = isset( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ) ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized 30 $api_auth = isset( $_SERVER['HTTP_X_API_KEY'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_API_KEY'] ) ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized 31 32 $auth = $http_auth ?: $redir_auth; 33 if ( ! $auth && $api_auth ) { 34 $auth = 'Bearer ' . $api_auth; 35 } 36 $auth = trim( $auth ); 37 if ( str_starts_with( $auth, 'Bearer ' ) ) { 38 $raw = trim( substr( $auth, 7 ) ); 39 if ( strlen( $raw ) >= 8 ) { 40 $prefix = substr( $raw, 0, 12 ); 41 $prefix_short = substr( $raw, 0, 8 ); 42 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom table authentication query 43 $rows = $wpdb->get_results( 44 $wpdb->prepare( 45 "SELECT user_id, key_hash FROM {$wpdb->prefix}directrelay_keys WHERE (key_prefix = %s OR key_prefix = %s) AND revoked = 0 AND (expires_at IS NULL OR expires_at > UTC_TIMESTAMP())", 46 $prefix, 47 $prefix_short 48 ), 49 ARRAY_A 50 ); 51 if ( ! empty( $rows ) ) { 52 foreach ( $rows as $r ) { 53 $stored_hash = $r['key_hash']; 54 if ( str_starts_with( $stored_hash, '$' ) ) { 55 if ( password_verify( $raw, $stored_hash ) ) return (int) $r['user_id']; 56 } else { 57 if ( hash_equals( $stored_hash, \DirectRelay\Api_Auth::hash_key( $raw ) ) ) return (int) $r['user_id']; 58 } 59 } 60 } 61 } 62 } 63 return $user; 21 /* 22 * No determine_current_user filter: DirectRelay keys must never act as 23 * their bound WordPress user outside the directrelay/v1 namespace. 24 * Base_Controller::guard() authenticates each plugin route itself and 25 * calls wp_set_current_user() only after scope/agent/rate checks, so 26 * a leaked key cannot reach core /wp/v2, wp-admin or admin-ajax 27 * capabilities (the pre-5.1.32 scope bypass). 28 */ 64 29 } 65 30 … … 187 152 $wpdb->prefix . 'directrelay_log', 188 153 $wpdb->prefix . 'directrelay_webhooks', 189 $wpdb->prefix . 'directrelay_rate _limits',154 $wpdb->prefix . 'directrelay_rate', 190 155 $wpdb->prefix . 'directrelay_brute_force', 191 156 ]; -
directrelay-automation-bridge-for-n8n/tags/5.1.32/includes/Content_Processor.php
r3699228 r3706276 81 81 } 82 82 83 // Look for Markdown indicators 84 return (bool) preg_match( '/(?:^#{1,6}\s|^\s*[\*\-]\s|\*\*[^\*]+\*\*|\[[^\]]+\]\([^\)]+\)|^\s*\|\s*.*?\s*\|\s*$ )/m', $content );83 // Look for Markdown indicators (headings, lists, bold, links, tables, code fences) 84 return (bool) preg_match( '/(?:^#{1,6}\s|^\s*[\*\-]\s|\*\*[^\*]+\*\*|\[[^\]]+\]\([^\)]+\)|^\s*\|\s*.*?\s*\|\s*$|^\s*```)/m', $content ); 85 85 } 86 86 … … 101 101 102 102 // 3. Blockquotes: > text 103 $text = preg_replace ( '/^>\s*(.*)$/m', '<blockquote><p>$1</p></blockquote>', $text );103 $text = preg_replace_callback( '/^>\s*(.*)$/m', fn( $m ) => '<blockquote><p>' . esc_html( $m[1] ) . '</p></blockquote>', $text ); 104 104 105 105 // 4. Headings (# H1 to ###### H6) 106 $text = preg_replace( '/^######\s+(.*)$/m', '<h6>$1</h6>', $text ); 107 $text = preg_replace( '/^#####\s+(.*)$/m', '<h5>$1</h5>', $text ); 108 $text = preg_replace( '/^####\s+(.*)$/m', '<h4>$1</h4>', $text ); 109 $text = preg_replace( '/^###\s+(.*)$/m', '<h3>$1</h3>', $text ); 110 $text = preg_replace( '/^##\s+(.*)$/m', '<h2>$2</h2>', $text ); 111 $text = preg_replace( '/^##\s+(.*)$/m', '<h2>$1</h2>', $text ); 112 $text = preg_replace( '/^#\s+(.*)$/m', '<h1>$1</h1>', $text ); 106 $text = preg_replace_callback( '/^######\s+(.*)$/m', fn( $m ) => '<h6>' . esc_html( $m[1] ) . '</h6>', $text ); 107 $text = preg_replace_callback( '/^#####\s+(.*)$/m', fn( $m ) => '<h5>' . esc_html( $m[1] ) . '</h5>', $text ); 108 $text = preg_replace_callback( '/^####\s+(.*)$/m', fn( $m ) => '<h4>' . esc_html( $m[1] ) . '</h4>', $text ); 109 $text = preg_replace_callback( '/^###\s+(.*)$/m', fn( $m ) => '<h3>' . esc_html( $m[1] ) . '</h3>', $text ); 110 $text = preg_replace_callback( '/^##\s+(.*)$/m', fn( $m ) => '<h2>' . esc_html( $m[1] ) . '</h2>', $text ); 111 $text = preg_replace_callback( '/^#\s+(.*)$/m', fn( $m ) => '<h1>' . esc_html( $m[1] ) . '</h1>', $text ); 113 112 114 113 // 5. Tables: | Col 1 | Col 2 | … … 143 142 144 143 // 6. Inline Formatting (Images, Links, Bold, Italic, Strikethrough, Code) 145 $text = preg_replace( '/!\[([^\]]*)\]\(([^\)]+)\)/', '<img src="$2" alt="$1" />', $text ); 146 $text = preg_replace( '/\[([^\]]+)\]\(([^\)]+)\)/', '<a href="$2" target="_blank" rel="noopener">$1</a>', $text ); 147 $text = preg_replace( '/\*\*([^\*]+)\*\*/', '<strong>$1</strong>', $text ); 148 $text = preg_replace( '/~~([^~]+)~~/', '<del>$1</del>', $text ); 149 $text = preg_replace( '/\*([^\*]+)\*/', '<em>$1</em>', $text ); 150 $text = preg_replace( '/`([^`]+)`/', '<code>$1</code>', $text ); 144 // 145 // URLs go through esc_url() — protocol allowlist plus attribute-safe 146 // encoding — so javascript:/data: hrefs and quote-breakout payloads 147 // are neutralized, and captured text goes through esc_html()/esc_attr(). 148 // The decode-then-esc_url on the URL keeps links that were already 149 // esc_html()'d by an earlier block pass (heading/blockquote text) 150 // from being double-encoded. 151 $text = preg_replace_callback( '/!\[([^\]]*)\]\(([^\)]+)\)/', function( $m ) { 152 $url = esc_url( html_entity_decode( trim( $m[2] ), ENT_QUOTES, 'UTF-8' ) ); 153 if ( '' === $url ) { 154 return esc_html( $m[0] ); /* Unsafe protocol: render as plain text. */ 155 } 156 return '<img src="' . $url . '" alt="' . esc_attr( $m[1] ) . '" />'; 157 }, $text ); 158 $text = preg_replace_callback( '/\[([^\]]+)\]\(([^\)]+)\)/', function( $m ) { 159 $url = esc_url( html_entity_decode( trim( $m[2] ), ENT_QUOTES, 'UTF-8' ) ); 160 if ( '' === $url ) { 161 return esc_html( $m[1] ); /* Unsafe protocol: keep the text, drop the link. */ 162 } 163 return '<a href="' . $url . '" target="_blank" rel="noopener">' . esc_html( $m[1] ) . '</a>'; 164 }, $text ); 165 $text = preg_replace_callback( '/\*\*([^\*]+)\*\*/', fn( $m ) => '<strong>' . esc_html( $m[1] ) . '</strong>', $text ); 166 $text = preg_replace_callback( '/~~([^~]+)~~/', fn( $m ) => '<del>' . esc_html( $m[1] ) . '</del>', $text ); 167 $text = preg_replace_callback( '/\*([^\*]+)\*/', fn( $m ) => '<em>' . esc_html( $m[1] ) . '</em>', $text ); 168 $text = preg_replace_callback( '/`([^`]+)`/', fn( $m ) => '<code>' . esc_html( $m[1] ) . '</code>', $text ); 151 169 152 170 // 7. Unordered Lists (* or -) & Ordered Lists (1.) 153 171 $text = preg_replace_callback( '/(?:^\s*[\*\-]\s+.*$\n?)+/m', function( $m ) { 154 $items = preg_replace ( '/^\s*[\*\-]\s+(.*)$/m', '<li>$1</li>', $m[0] );172 $items = preg_replace_callback( '/^\s*[\*\-]\s+(.*)$/m', fn( $li ) => '<li>' . esc_html( $li[1] ) . '</li>', $m[0] ); 155 173 return "<ul>\n" . $items . "</ul>\n"; 156 174 }, $text ); 157 175 158 176 $text = preg_replace_callback( '/(?:^\s*\d+\.\s+.*$\n?)+/m', function( $m ) { 159 $items = preg_replace ( '/^\s*\d+\.\s+(.*)$/m', '<li>$1</li>', $m[0] );177 $items = preg_replace_callback( '/^\s*\d+\.\s+(.*)$/m', fn( $li ) => '<li>' . esc_html( $li[1] ) . '</li>', $m[0] ); 160 178 return "<ol>\n" . $items . "</ol>\n"; 161 179 }, $text ); -
directrelay-automation-bridge-for-n8n/tags/5.1.32/includes/Cron_Health.php
r3699228 r3706276 152 152 'ondemand' => true, 153 153 ), 154 'directrelay_ retry_webhook'=> array(155 'label' => __( 'Webhook retry queue', 'directrelay-automation-bridge-for-n8n' ),154 'directrelay_deliver_webhook' => array( 155 'label' => __( 'Webhook delivery attempt (with retries)', 'directrelay-automation-bridge-for-n8n' ), 156 156 'ondemand' => true, 157 157 ), -
directrelay-automation-bridge-for-n8n/tags/5.1.32/includes/Feature_Gate.php
r3699228 r3706276 18 18 * - can_create_post() always returns true. 19 19 * 20 * NOTE: do NOT reintroduce the legacy limit (s) or licence-state20 * NOTE: do NOT reintroduce the legacy limit or activation-state 21 21 * methods here. The WordPress.org Plugin Directory Guidelines forbid 22 * usage limits, licence-keyed unlock, and add-on build flags in any22 * usage limits, keyed unlock, and add-on build flags in any 23 23 * plugin hosted on WP.org. The free plugin uses direct free options 24 24 * (`directrelay_settings`) for any tunable webhook configuration. … … 27 27 28 28 /** 29 * Whether the current site has add-on feature (s).29 * Whether the current site has add-on features. 30 30 * 31 31 * Returns true so legacy "if ( ! is_pro() )" checks degrade to 32 32 * a no-op pass-through. The free plugin does not gate features 33 * by licencestate.33 * by activation state. 34 34 */ 35 35 public static function is_pro(): bool { … … 76 76 * Whether a new post or page can be created. 77 77 * 78 * The free plugin has no per-key monthly post limit (s). Post78 * The free plugin has no per-key monthly post limit. Post 79 79 * creation is always allowed. 80 80 */ -
directrelay-automation-bridge-for-n8n/tags/5.1.32/includes/IndexNow_Pinger.php
r3699228 r3706276 7 7 * DirectRelay IndexNow Pinger 8 8 * 9 * Automatically pings Bing, Yandex, Naver, and IndexNow search engine hubs upon post publishing. 10 * Includes activity logging and key verification endpoint integration. 9 * Pings the IndexNow aggregator (api.indexnow.org) when a post is published; 10 * the aggregator routes the submission to every participating search engine 11 * (Bing, Yandex, Seznam, Naver), so no per-engine pings are needed. Includes 12 * activity logging and the key-verification endpoint. 11 13 * 12 14 * @since 4.3.0 … … 16 18 private const ENDPOINTS = [ 17 19 'https://api.indexnow.org/indexnow', 18 'https://www.bing.com/indexnow',19 20 ]; 20 21 … … 45 46 46 47 public static function serve_key_file(): void { 47 if ( isset( $_SERVER['REQUEST_URI'] ) && str_contains( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ), 'indexnow-key.txt' ) ) { 48 header( 'Content-Type: text/plain; charset=utf-8' ); 49 echo esc_html( self::get_api_key() ); 50 exit; 48 if ( ! isset( $_SERVER['REQUEST_URI'] ) ) { 49 return; 51 50 } 51 $path = rtrim( (string) wp_parse_url( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ), PHP_URL_PATH ), '/' ); 52 /* Exact path match (any subdirectory depth) — a substring match would 53 * also hijack unrelated 404s like /?x=indexnow-key.txt. */ 54 if ( substr( $path, -17 ) !== '/indexnow-key.txt' ) { 55 return; 56 } 57 header( 'Content-Type: text/plain; charset=utf-8' ); 58 echo esc_html( self::get_api_key() ); 59 exit; 52 60 } 53 61 … … 90 98 'headers' => [ 'Content-Type' => 'application/json' ], 91 99 'body' => $json_body, 92 'sslverify' => false,100 'sslverify' => true, 93 101 ] ); 94 102 95 103 if ( is_wp_error( $response ) ) { 96 Activity_Logger::log( 'indexnow_error', 0, 0, $response->get_error_message() ); 104 /* Non-blocking requests still return WP_Error when the 105 * connection cannot be opened at all; record it in the 106 * activity log instead of letting it fatal the request. */ 107 Activity_Logger::write( [ 108 'action' => 'indexnow_error', 109 'method' => 'POST', 110 'endpoint' => 'indexnow', 111 'object_type' => $post_id > 0 ? 'post' : null, 112 'object_id' => $post_id > 0 ? $post_id : null, 113 'http_status' => 0, 114 'response_msg' => $response->get_error_message(), 115 ] ); 97 116 } 98 117 } -
directrelay-automation-bridge-for-n8n/tags/5.1.32/includes/Installer.php
r3699228 r3706276 7 7 8 8 public static function activate( bool $network_wide = false ): void { 9 if ( is_multisite() && $network_wide ) { 10 self::run_for_all_blogs( [ self::class, 'activate_blog' ] ); 11 } else { 12 self::activate_blog(); 9 /* Single-site build: the loader in directrelay.php refuses multisite 10 * entirely, so activation always targets the one active site. */ 11 self::activate_blog(); 12 } 13 14 public static function activate_blog(): void { 15 if ( function_exists( 'is_multisite' ) && is_multisite() ) { 16 return; /* Single-site build: never install tables or options on a multisite. */ 13 17 } 14 }15 16 public static function activate_blog(): void {17 18 self::create_tables(); 18 19 update_option( 'directrelay_db_version', DIRECTRELAY_DB_VERSION ); … … 35 36 36 37 public static function deactivate( bool $network_wide = false ): void { 37 if ( is_multisite() && $network_wide ) { 38 self::run_for_all_blogs( [ self::class, 'deactivate_blog' ] ); 39 } else { 40 self::deactivate_blog(); 41 } 38 self::deactivate_blog(); 42 39 } 43 40 … … 48 45 'directrelay_prune_rate_limits', 49 46 'directrelay_fire_webhook', 50 'directrelay_ retry_webhook',47 'directrelay_deliver_webhook', 51 48 'directrelay_cron_health_ping', 52 49 'directrelay_prune_brute_force', … … 57 54 } 58 55 59 public static function uninstall(): void { 60 if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) { 61 return; 62 } 63 if ( is_multisite() ) { 64 self::run_for_all_blogs( [ self::class, 'uninstall_blog' ] ); 65 } else { 66 self::uninstall_blog(); 67 } 68 } 69 70 public static function uninstall_blog(): void { 71 global $wpdb; 72 $tables = [ 73 'directrelay_keys', 'directrelay_log', 'directrelay_rate', 'directrelay_webhooks', 74 'directrelay_analytics', 'directrelay_webhook_log', 'directrelay_brute_force', 75 'directrelay_approvals', 76 ]; 77 foreach ( $tables as $t ) { 78 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- uninstall-time DDL with known static table names, no user input 79 $wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}{$t}" ); 80 } 81 delete_option( 'directrelay_db_version' ); 82 delete_option( 'directrelay_settings' ); 83 delete_option( 'directrelay_guardrails' ); 84 delete_option( 'directrelay_cron_last_ping' ); 85 delete_option( 'directrelay_apppass_dismissed' ); 86 delete_option( 'directrelay_log_retention_days' ); 87 88 /* Remove diagnostic trace logs left behind from WP_DEBUG_LOG-enabled sessions. */ 89 $trace_paths = []; 90 if ( function_exists( 'wp_upload_dir' ) ) { 91 $up = wp_upload_dir(); 92 if ( ! empty( $up['basedir'] ) ) { 93 $dir = trailingslashit( $up['basedir'] ) . 'directrelay-automation-bridge-for-n8n'; 94 $trace_paths[] = $dir . '/trace.log'; 95 $trace_paths[] = $dir . '/error.log'; 96 } 97 } 98 99 foreach ( $trace_paths as $trace_path ) { 100 if ( is_file( $trace_path ) ) { 101 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_file -- guard before delete. 102 @wp_delete_file( $trace_path ); 103 } 104 } 105 } 106 56 /* 57 * Per-blog uninstall cleanup lives in the root uninstall.php, which runs 58 * standalone: WordPress does not load the plugin during uninstall, so the 59 * routine must not depend on this class being loaded. 60 */ 61 62 /** 63 * Schema rule: TEXT columns must never carry a DEFAULT literal. MySQL rejects 64 * defaults on TEXT/BLOB columns ("BLOB, TEXT ... column can't have a default 65 * value") while MariaDB and SQLite accept them, which hides the failure — 66 * dbDelta() swallows the error and the tables silently never get created. 67 * Every writer supplies these values explicitly (Api_Auth::create(), the 68 * webhook create/update paths in Webhook_Controller and Ajax_Handler). 69 */ 107 70 public static function create_tables(): void { 108 71 global $wpdb; … … 114 77 id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, 115 78 user_id BIGINT UNSIGNED NOT NULL, 116 key_hash VARCHAR( 255) NOT NULL,79 key_hash VARCHAR(191) NOT NULL, 117 80 key_prefix CHAR(12) NOT NULL DEFAULT '', 118 81 label VARCHAR(191) NOT NULL DEFAULT '', … … 125 88 restrict_own_posts TINYINT(1) NOT NULL DEFAULT 0, 126 89 require_approval TINYINT(1) NOT NULL DEFAULT 0, 127 scopes TEXT NOT NULL DEFAULT '[]',90 scopes TEXT NOT NULL, 128 91 ip_whitelist TEXT DEFAULT NULL, 129 92 last_used_at DATETIME DEFAULT NULL, … … 204 167 target_url TEXT NOT NULL, 205 168 secret VARCHAR(255) DEFAULT NULL, 206 events TEXT NOT NULL DEFAULT '[]',207 post_types TEXT NOT NULL DEFAULT '[\"post\",\"page\"]',169 events TEXT NOT NULL, 170 post_types TEXT NOT NULL, 208 171 active TINYINT(1) NOT NULL DEFAULT 1, 209 172 last_fired_at DATETIME DEFAULT NULL, … … 262 225 } 263 226 264 private static function run_for_all_blogs( callable $callback ): void {265 global $wpdb;266 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- multisite blog enumeration requires direct query267 $blogs = $wpdb->get_col( "SELECT blog_id FROM {$wpdb->blogs}" );268 foreach ( $blogs as $id ) {269 switch_to_blog( $id );270 call_user_func( $callback );271 restore_current_blog();272 }273 }274 227 } -
directrelay-automation-bridge-for-n8n/tags/5.1.32/includes/Plugin.php
r3699228 r3706276 61 61 } 62 62 63 /* Multisite: handle new site creation */64 if ( is_multisite() ) {65 add_action( 'wp_initialize_site', [ __CLASS__, 'init_new_site' ] );66 }67 }68 69 public static function init_new_site( \WP_Site $site ): void {70 if ( is_plugin_active_for_network( plugin_basename( DIRECTRELAY_FILE ) ) ) {71 switch_to_blog( $site->blog_id );72 Installer::activate_blog();73 restore_current_blog();74 }75 63 } 76 64 … … 91 79 'REST\Media_Controller', 92 80 'REST\Taxonomy_Controller', 93 'REST\Fleet_Controller',94 81 'REST\Webhook_Controller', 95 82 'REST\Openapi_Controller', -
directrelay-automation-bridge-for-n8n/tags/5.1.32/includes/Post_Manager.php
r3699228 r3706276 116 116 return; 117 117 } 118 require_once ABSPATH . 'wp-admin/includes/media.php'; 119 require_once ABSPATH . 'wp-admin/includes/file.php'; 120 require_once ABSPATH . 'wp-admin/includes/image.php'; 121 122 $att_id = media_sideload_image( $safe_url, $id, null, 'id' ); 118 119 $att_id = self::sideload_url( $safe_url, $id ); 123 120 if ( ! is_wp_error( $att_id ) && (int) $att_id > 0 ) { 124 121 set_post_thumbnail( $id, (int) $att_id ); … … 144 141 } 145 142 143 /** 144 * Fetch a remote file with SSRF validation on every hop. 145 * 146 * download_url()/wp_remote_get follow redirects blindly, so a public URL 147 * can 302 into an internal address. This fetches with redirects disabled 148 * and re-validates every Location with Api_Auth::is_url_safe() + 149 * revalidate_ip() before the next hop, closing the open-redirect gap and 150 * narrowing the DNS-rebinding window to the unavoidable same-request 151 * resolution inside the final fetch. 152 * 153 * @return array{body:string,content_type:string,final_url:string}|\WP_Error 154 */ 155 public static function safe_fetch_url( string $url, int $max_bytes ): array|\WP_Error { 156 $current = esc_url_raw( $url ); 157 $body = null; 158 159 for ( $hop = 0; $hop < 4; $hop++ ) { 160 if ( ! Api_Auth::is_url_safe( $current ) || null === Api_Auth::revalidate_ip( $current ) ) { 161 return new \WP_Error( 'amp_ssrf_blocked', __( 'SSRF protection: Image URL is blocked.', 'directrelay-automation-bridge-for-n8n' ), [ 'status' => 403 ] ); 162 } 163 164 $response = wp_safe_remote_get( $current, [ 165 'timeout' => 15, 166 'redirection' => 0, /* Redirects are followed manually, re-validated above. */ 167 'limit_response_size' => $max_bytes, 168 ] ); 169 if ( is_wp_error( $response ) ) { 170 return $response; 171 } 172 173 $code = (int) wp_remote_retrieve_response_code( $response ); 174 if ( $code >= 300 && $code < 400 ) { 175 $location = (string) wp_remote_retrieve_header( $response, 'location' ); 176 if ( '' === $location ) { 177 return new \WP_Error( 'amp_sideload_failed', __( 'Remote server returned a redirect without a location header.', 'directrelay-automation-bridge-for-n8n' ), [ 'status' => 502 ] ); 178 } 179 $next = self::absolutize_redirect( $current, $location ); 180 if ( '' === $next ) { 181 return new \WP_Error( 'amp_sideload_failed', __( 'Remote server returned an invalid redirect location.', 'directrelay-automation-bridge-for-n8n' ), [ 'status' => 502 ] ); 182 } 183 $current = $next; 184 continue; 185 } 186 187 if ( $code < 200 || $code >= 300 ) { 188 return new \WP_Error( 189 'amp_sideload_failed', 190 sprintf( /* translators: %d: HTTP status code */ __( 'Remote image request returned HTTP %d.', 'directrelay-automation-bridge-for-n8n' ), $code ), 191 [ 'status' => 502 ] 192 ); 193 } 194 195 $body = wp_remote_retrieve_body( $response ); 196 break; 197 } 198 199 if ( null === $body || '' === $body ) { 200 return new \WP_Error( 'amp_sideload_failed', __( 'Remote image could not be downloaded (redirect limit reached or empty response).', 'directrelay-automation-bridge-for-n8n' ), [ 'status' => 502 ] ); 201 } 202 if ( strlen( $body ) >= $max_bytes ) { 203 return new \WP_Error( 'amp_sideload_failed', __( 'Remote image exceeds the maximum sideload size.', 'directrelay-automation-bridge-for-n8n' ), [ 'status' => 413 ] ); 204 } 205 206 return [ 207 'body' => $body, 208 'content_type' => strtolower( (string) wp_remote_retrieve_header( $response, 'content-type' ) ), 209 'final_url' => $current, 210 ]; 211 } 212 213 /** 214 * Sideload a remote image into the media library (safe fetch + attach). 215 * 216 * @param int $post_id Post to attach to (0 = unattached). 217 * @param string $title Optional attachment title (media_handle_sideload desc). 218 * @return int|\WP_Error Attachment ID on success. 219 */ 220 public static function sideload_url( string $url, int $post_id = 0, string $title = '' ): int|\WP_Error { 221 require_once ABSPATH . 'wp-admin/includes/media.php'; 222 require_once ABSPATH . 'wp-admin/includes/file.php'; 223 require_once ABSPATH . 'wp-admin/includes/image.php'; 224 225 $max_bytes = max( 1, (int) ( get_option( 'directrelay_settings', [] )['max_sideload_size'] ?? 20 ) ) * MB_IN_BYTES; 226 $fetched = self::safe_fetch_url( $url, $max_bytes ); 227 if ( is_wp_error( $fetched ) ) { 228 return $fetched; 229 } 230 231 /* The filename extension must match the real bytes or 232 * media_handle_sideload() rejects the file, so the Content-Type wins 233 * over the URL's own tail when both are known. */ 234 $ext_map = [ 235 'image/jpeg' => 'jpg', 'image/jpg' => 'jpg', 'image/png' => 'png', 'image/gif' => 'gif', 236 'image/webp' => 'webp', 'image/avif' => 'avif', 'image/bmp' => 'bmp', 237 'image/x-icon' => 'ico', 'image/vnd.microsoft.icon' => 'ico', 238 ]; 239 $ext = ''; 240 foreach ( $ext_map as $mime => $candidate ) { 241 if ( str_starts_with( $fetched['content_type'], $mime ) ) { 242 $ext = $candidate; 243 break; 244 } 245 } 246 247 $name = sanitize_file_name( basename( (string) wp_parse_url( $fetched['final_url'], PHP_URL_PATH ) ) ); 248 $stem = pathinfo( $name, PATHINFO_FILENAME ); 249 $stem = is_string( $stem ) && '' !== $stem ? $stem : 'image-' . gmdate( 'YmdHis' ); 250 if ( '' !== $ext ) { 251 $name = $stem . '.' . $ext; 252 } elseif ( ! preg_match( '/\.(jpe?g|png|gif|webp|avif|ico|bmp)$/i', $name ) ) { 253 return new \WP_Error( 'amp_sideload_failed', __( 'Remote file is not a recognizable image type.', 'directrelay-automation-bridge-for-n8n' ), [ 'status' => 415 ] ); 254 } 255 256 $tmp = wp_tempnam( $name ); 257 if ( ! $tmp ) { 258 return new \WP_Error( 'amp_sideload_failed', __( 'Could not create a temporary file for the image.', 'directrelay-automation-bridge-for-n8n' ), [ 'status' => 500 ] ); 259 } 260 global $wp_filesystem; 261 if ( ! $wp_filesystem ) { 262 WP_Filesystem(); 263 } 264 if ( ! $wp_filesystem->put_contents( $tmp, $fetched['body'] ) ) { 265 wp_delete_file( $tmp ); 266 return new \WP_Error( 'amp_sideload_failed', __( 'Could not store the downloaded image.', 'directrelay-automation-bridge-for-n8n' ), [ 'status' => 500 ] ); 267 } 268 269 $att_id = media_handle_sideload( 270 [ 'name' => $name, 'tmp_name' => $tmp ], 271 $post_id, 272 '' !== $title ? sanitize_text_field( $title ) : null 273 ); 274 if ( is_wp_error( $att_id ) ) { 275 /* media_handle_sideload() leaves the temp file behind on failure. */ 276 wp_delete_file( $tmp ); 277 return $att_id; 278 } 279 return (int) $att_id; 280 } 281 282 /** 283 * Resolve a redirect Location against the URL it came from. 284 * Returns '' for anything that does not produce a clean http(s) URL. 285 */ 286 private static function absolutize_redirect( string $base_url, string $location ): string { 287 $location = trim( $location ); 288 if ( '' === $location ) { 289 return ''; 290 } 291 if ( preg_match( '#^https?://#i', $location ) ) { 292 return esc_url_raw( $location ); 293 } 294 $p = wp_parse_url( $base_url ); 295 if ( empty( $p['scheme'] ) || empty( $p['host'] ) ) { 296 return ''; 297 } 298 $base = $p['scheme'] . '://' . $p['host'] . ( isset( $p['port'] ) ? ':' . $p['port'] : '' ); 299 if ( str_starts_with( $location, '/' ) ) { 300 return esc_url_raw( $base . $location ); 301 } 302 $dir = isset( $p['path'] ) ? (string) $p['path'] : '/'; 303 return esc_url_raw( $base . preg_replace( '#/[^/]*$#', '/', $dir ) . $location ); 304 } 305 146 306 private static function apply_taxonomy( int $id, array $data ): void { 147 307 if ( ! empty( $data['category_names'] ) ) { … … 162 322 163 323 private static function apply_meta( int $id, array $meta ): void { 164 $protected = [ '_edit_lock', '_edit_last', '_wp_old_slug', '_wp_attachment_metadata' ];324 $protected = [ '_edit_lock', '_edit_last', '_wp_old_slug', '_wp_attachment_metadata', '_thumbnail_id' ]; 165 325 $has_acf = function_exists( 'update_field' ); 166 326 foreach ( $meta as $k => $v ) { 167 327 $key = sanitize_text_field( (string) $k ); 168 if ( empty( $key ) || in_array( $key, $protected, true ) || str_starts_with( $key, 'directrelay_' ) ) continue; 169 328 if ( empty( $key ) || in_array( $key, $protected, true ) ) { 329 continue; 330 } 331 /* Core-internal (_wp_*: templates, attachment files, menu items), 332 * attachment-binding (_thumbnail_id) and plugin-internal meta are 333 * never agent-writable. Custom theme meta like _myfield stays 334 * writable. */ 335 if ( str_starts_with( $key, '_wp_' ) || str_starts_with( $key, 'directrelay_' ) || str_starts_with( $key, '_directrelay' ) ) { 336 continue; 337 } 338 170 339 if ( $has_acf && str_starts_with( $key, 'field_' ) ) { 171 340 update_field( $key, $v, $id ); -
directrelay-automation-bridge-for-n8n/tags/5.1.32/includes/REST/Base_Controller.php
r3699228 r3706276 104 104 } 105 105 106 /** Return true or a WP_Error when a add-on feature(s)is required. */106 /** Return true or a WP_Error when an add-on feature is required. */ 107 107 protected static function require_pro( string $feature ): bool|\WP_Error { 108 108 return true; -
directrelay-automation-bridge-for-n8n/tags/5.1.32/includes/REST/Media_Controller.php
r3699228 r3706276 80 80 } 81 81 82 if ( ! \DirectRelay\Api_Auth::is_url_safe( $url ) ) {83 return self::fail( $req, new \WP_Error( 'amp_invalid', __( 'URL must be a public HTTPS endpoint.', 'directrelay-automation-bridge-for-n8n'), [ 'status' => 400 ] ), 'sideload_media' );84 }85 86 $resolved_ip = \DirectRelay\Api_Auth::revalidate_ip( $url );87 if ( null === $resolved_ip ) {88 return self::fail( $req, new \WP_Error(89 'amp_ssrf_blocked',90 __( 'Remote host could not be verified at fetch time.', 'directrelay-automation-bridge-for-n8n' ),91 [ 'status' => 400 ]92 ), 'sideload_media' );93 }94 95 82 $settings = get_option( 'directrelay_settings', [] ); 96 83 $max_mb = (int) ( $settings['max_sideload_size'] ?? 20 ); 97 84 98 /* HEAD request to check size before downloading */ 99 $head = wp_remote_head( $url, [ 'timeout' => 5 ] ); 100 if ( ! is_wp_error( $head ) ) { 101 $cl = (int) wp_remote_retrieve_header( $head, 'content-length' ); 102 if ( $cl > ( $max_mb * 1024 * 1024 ) ) { 103 /* translators: %d: file size limit in megabytes */ 104 return self::fail( $req, new \WP_Error( 'amp_too_large', sprintf( __( 'Remote file exceeds limit of %dMB.', 'directrelay-automation-bridge-for-n8n'), $max_mb ), [ 'status' => 413 ] ), 'sideload_media' ); 105 } 106 } 107 108 /* Fetch the remote file */ 109 $tmp = download_url( $url ); 110 if ( is_wp_error( $tmp ) ) { 111 return self::fail( $req, $tmp, 'sideload_media' ); 85 /* Redirect-safe fetch: every hop is re-validated against SSRF 86 * (is_url_safe + revalidate_ip) before it is followed, so a public 87 * URL cannot 302 into an internal address. */ 88 $fetched = \DirectRelay\Post_Manager::safe_fetch_url( $url, $max_mb * MB_IN_BYTES ); 89 if ( is_wp_error( $fetched ) ) { 90 return self::fail( $req, $fetched, 'sideload_media' ); 112 91 } 113 92 114 93 /* Derive filename from URL if not provided */ 115 $filename = $body['filename'] ?? basename( wp_parse_url( $url, PHP_URL_PATH ) ) ?: 'media-file'; 116 $decoded = file_get_contents( $tmp ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents 117 @unlink( $tmp ); // phpcs:ignore 118 119 if ( ! $decoded ) { 120 return self::fail( $req, new \WP_Error( 'amp_fetch_failed', __( 'Could not read downloaded file.', 'directrelay-automation-bridge-for-n8n'), [ 'status' => 502 ] ), 'sideload_media' ); 121 } 122 123 return self::save_upload( $req, $decoded, array_merge( $body, [ 'filename' => $filename ] ), 'sideload_media' ); 94 $filename = $body['filename'] ?? basename( wp_parse_url( $fetched['final_url'], PHP_URL_PATH ) ) ?: 'media-file'; 95 96 return self::save_upload( $req, $fetched['body'], array_merge( $body, [ 'filename' => $filename ] ), 'sideload_media' ); 124 97 } 125 98 -
directrelay-automation-bridge-for-n8n/tags/5.1.32/includes/REST/Meta_Controller.php
r3699228 r3706276 80 80 return self::fail( $req, new \WP_Error( 'amp_not_found', 'Content not found.', [ 'status' => 404 ] ), 'update_meta' ); 81 81 } 82 $own = \DirectRelay\Agent_Profiles::assert_owns( (array) $req->get_param( '_amp_key' ), $id ); 83 if ( is_wp_error( $own ) ) { 84 return self::fail( $req, $own, 'update_meta' ); 85 } 82 86 $updated = 0; $skipped = 0; $errors = []; 83 87 foreach ( $body as $key => $value ) { … … 113 117 return self::fail( $req, new \WP_Error( 'amp_forbidden', __( 'Cannot delete protected meta key.', 'directrelay-automation-bridge-for-n8n'), [ 'status' => 403 ] ), 'delete_meta' ); 114 118 } 119 $own = \DirectRelay\Agent_Profiles::assert_owns( (array) $req->get_param( '_amp_key' ), $id ); 120 if ( is_wp_error( $own ) ) return self::fail( $req, $own, 'delete_meta' ); 115 121 $r = delete_post_meta( $id, $key ); 116 122 return self::ok( $req, [ 'deleted' => $r, 'key' => $key, 'post_id' => $id ], 200, 'delete_meta', $id, $expected ); … … 125 131 return true; /* Use /seo endpoint for these */ 126 132 } 133 } 134 /* Plugin-internal identity stamps are never exposed or writable. */ 135 if ( str_starts_with( $key, '_directrelay' ) || str_starts_with( $key, 'directrelay_' ) ) { 136 return true; 127 137 } 128 138 return false; -
directrelay-automation-bridge-for-n8n/tags/5.1.32/includes/REST/Openapi_Controller.php
r3699228 r3706276 52 52 'pages' => rest_url( self::NS ) . '/pages', 53 53 'media' => rest_url( self::NS ) . '/media', 54 'fleet' => rest_url( self::NS ) . '/fleet/health',55 54 'webhooks' => rest_url( self::NS ) . '/webhooks', 56 55 'seo' => rest_url( self::NS ) . '/seo', … … 345 344 'get' => $priv( 'GET /webhooks/{id}/deliveries - Recent delivery log' ), 346 345 ), 347 '/fleet/health' => array(348 'get' => $priv( 'GET /fleet/health - Fleet health summary' ),349 ),350 '/fleet/keys/provision' => array(351 'post' => $priv( 'POST /fleet/keys/provision - Provision a new fleet key' ),352 ),353 346 '/discovery/fields' => array( 354 347 'get' => $priv( 'GET /discovery/fields - Custom-field discovery' ), -
directrelay-automation-bridge-for-n8n/tags/5.1.32/includes/REST/Pages_Controller.php
r3699228 r3706276 37 37 $body = $req->get_json_params() ?: []; 38 38 $key = $req->get_param( '_amp_key' ); 39 // The free plugin has no monthly dispatch / request limit (s).40 // The previous add-on-limit (s)guard was removed because the WP.org39 // The free plugin has no monthly dispatch / request limit. 40 // The previous add-on-limit guard was removed because the WP.org 41 41 // Plugin Directory Guidelines forbid usage limits in the free 42 42 // build. The directrelay-pro add-on may re-introduce a 43 // limit(s) check via the `directrelay_ext_guardrails_check` filter. 43 // limit check via the `directrelay_ext_guardrails_check` filter. 44 if ( ! \DirectRelay\Agent_Profiles::is_status_allowed( $key, (string) ( $body['status'] ?? 'draft' ) ) ) { 45 return self::fail( $req, new \WP_Error( 46 'amp_agent_forbidden', 47 __( 'This agent role cannot publish content directly. Submit as a draft instead.', 'directrelay-automation-bridge-for-n8n' ), 48 [ 'status' => 403 ] 49 ), 'create_page' ); 50 } 44 51 $id = Post_Manager::create( $body, 'page' ); 45 52 if ( is_wp_error( $id ) ) return self::fail( $req, $id, 'create_page' ); 46 if ( ! empty( $body['seo'] ) && \DirectRelay\Api_Auth::has_scope( $key, 'seo:write' ) ) { 47 Rankmath_Handler::update( $id, $body['seo'] ); 53 \DirectRelay\Agent_Profiles::stamp_post( (int) $id, $key ); 54 $page = Post_Manager::format( get_post( $id ) ); 55 if ( ! empty( $body['seo'] ) ) { 56 /* Free build is read-only for SEO: the add-on owns SEO writes. */ 57 $seo_state = Rankmath_Handler::handle_inline_write( 58 (int) $id, 59 is_array( $body['seo'] ) ? $body['seo'] : [], 60 \DirectRelay\Api_Auth::has_scope( $key, 'seo:write' ) 61 ); 62 $page['seo_write'] = $seo_state['write']; 63 $page['seo'] = $seo_state['seo']; 64 if ( ! empty( $seo_state['error'] ) ) { 65 $page['seo_write_error'] = $seo_state['error']; 66 } 67 } else { 68 $page['seo'] = Rankmath_Handler::read( $id ); 48 69 } 49 $page = Post_Manager::format( get_post( $id ) );50 $page['seo'] = Rankmath_Handler::read( $id );51 70 return self::ok( $req, [ 'page' => $page ], 201, 'create_page', $id, 'page' ); 52 71 } … … 66 85 $post = get_post( $id ); 67 86 if ( ! $post || $post->post_type !== 'page' ) return self::fail( $req, new \WP_Error( 'amp_not_found', __( 'Page not found.', 'directrelay-automation-bridge-for-n8n'), [ 'status' => 404 ] ), 'update_page' ); 87 $key = $req->get_param( '_amp_key' ); 88 if ( ! empty( $body['status'] ) && ! \DirectRelay\Agent_Profiles::is_status_allowed( $key, (string) $body['status'] ) ) { 89 return self::fail( $req, new \WP_Error( 90 'amp_agent_forbidden', 91 __( 'This agent role cannot publish content directly. Submit as a draft instead.', 'directrelay-automation-bridge-for-n8n' ), 92 [ 'status' => 403 ] 93 ), 'update_page' ); 94 } 95 $own = \DirectRelay\Agent_Profiles::assert_owns( $key, $id ); 96 if ( is_wp_error( $own ) ) return self::fail( $req, $own, 'update_page' ); 68 97 $r = Post_Manager::update( $id, $body ); 69 98 if ( is_wp_error( $r ) ) return self::fail( $req, $r, 'update_page' ); 70 $key = $req->get_param( '_amp_key' ); 71 if ( ! empty( $body['seo'] ) && \DirectRelay\Api_Auth::has_scope( $key, 'seo:write' ) ) { 72 Rankmath_Handler::update( $id, $body['seo'] ); 99 $updated = Post_Manager::format( get_post( $id ) ); 100 if ( ! empty( $body['seo'] ) ) { 101 /* Free build is read-only for SEO: the add-on owns SEO writes. */ 102 $seo_state = Rankmath_Handler::handle_inline_write( 103 (int) $id, 104 is_array( $body['seo'] ) ? $body['seo'] : [], 105 \DirectRelay\Api_Auth::has_scope( $key, 'seo:write' ) 106 ); 107 $updated['seo_write'] = $seo_state['write']; 108 $updated['seo'] = $seo_state['seo']; 109 if ( ! empty( $seo_state['error'] ) ) { 110 $updated['seo_write_error'] = $seo_state['error']; 111 } 112 } else { 113 $updated['seo'] = Rankmath_Handler::read( $id ); 73 114 } 74 $updated = Post_Manager::format( get_post( $id ) );75 $updated['seo'] = Rankmath_Handler::read( $id );76 115 return self::ok( $req, [ 'page' => $updated ], 200, 'update_page', $id, 'page' ); 77 116 } … … 83 122 return self::fail( $req, new \WP_Error( 'amp_not_found', 'Page not found.', [ 'status' => 404 ] ), 'delete_page' ); 84 123 } 124 $own = \DirectRelay\Agent_Profiles::assert_owns( (array) $req->get_param( '_amp_key' ), $id ); 125 if ( is_wp_error( $own ) ) return self::fail( $req, $own, 'delete_page' ); 85 126 $r = wp_delete_post( $id, (bool) $req->get_param( 'force' ) ); 86 127 return self::ok( $req, [ 'deleted' => (bool) $r, 'id' => $id ], 200, 'delete_page', $id, 'page' ); -
directrelay-automation-bridge-for-n8n/tags/5.1.32/includes/REST/Posts_Controller.php
r3699228 r3706276 45 45 $body = $req->get_json_params() ?: []; 46 46 $key = $req->get_param( '_amp_key' ); 47 // The free plugin has no monthly dispatch / request limit (s).48 // The previous add-on-limit (s)guard was removed because the WP.org47 // The free plugin has no monthly dispatch / request limit. 48 // The previous add-on-limit guard was removed because the WP.org 49 49 // Plugin Directory Guidelines forbid usage limits in the free 50 50 // build. The directrelay-pro add-on may re-introduce a 51 // limit(s) check via the `directrelay_ext_guardrails_check` filter. 51 // limit check via the `directrelay_ext_guardrails_check` filter. 52 if ( ! \DirectRelay\Agent_Profiles::is_status_allowed( $key, (string) ( $body['status'] ?? 'draft' ) ) ) { 53 return self::fail( $req, new \WP_Error( 54 'amp_agent_forbidden', 55 __( 'This agent role cannot publish content directly. Submit as a draft instead.', 'directrelay-automation-bridge-for-n8n' ), 56 [ 'status' => 403 ] 57 ), 'create_post' ); 58 } 52 59 $id = Post_Manager::create( $body, 'post' ); 53 60 if ( is_wp_error( $id ) ) { 54 61 return self::fail( $req, $id, 'create_post' ); 55 62 } 56 if ( ! empty( $body['seo'] ) && \DirectRelay\Api_Auth::has_scope( $key, 'seo:write' ) ) { 57 Rankmath_Handler::update( $id, $body['seo'] ); 63 /* Identity stamps + status gate make agent-role limits enforceable. */ 64 \DirectRelay\Agent_Profiles::stamp_post( (int) $id, $key ); 65 $post = Post_Manager::format( get_post( $id ) ); 66 if ( ! empty( $body['seo'] ) ) { 67 /* Free build is read-only for SEO: the add-on owns SEO writes. */ 68 $seo_state = Rankmath_Handler::handle_inline_write( 69 (int) $id, 70 is_array( $body['seo'] ) ? $body['seo'] : [], 71 \DirectRelay\Api_Auth::has_scope( $key, 'seo:write' ) 72 ); 73 $post['seo_write'] = $seo_state['write']; 74 $post['seo'] = $seo_state['seo']; 75 if ( ! empty( $seo_state['error'] ) ) { 76 $post['seo_write_error'] = $seo_state['error']; 77 } 78 } else { 79 $post['seo'] = Rankmath_Handler::read( $id ); 58 80 } 59 $post = Post_Manager::format( get_post( $id ) );60 $post['seo'] = Rankmath_Handler::read( $id );61 81 return self::ok( $req, [ 'post' => $post ], 201, 'create_post', $id, 'post' ); 62 82 } … … 74 94 $protected = [ '_edit_lock', '_edit_last', '_wp_old_slug', '_wp_attachment_metadata', '_wp_attached_file', '_thumbnail_id' ]; 75 95 foreach ( $meta as $key => $values ) { 76 if ( in_array( $key, $protected, true ) || str_starts_with( $key, 'directrelay_' ) ) {96 if ( in_array( $key, $protected, true ) || str_starts_with( $key, 'directrelay_' ) || str_starts_with( $key, '_directrelay' ) ) { 77 97 continue; 78 98 } … … 100 120 return self::fail( $req, new \WP_Error( 'amp_not_found', __( 'Post not found.', 'directrelay-automation-bridge-for-n8n' ), [ 'status' => 404 ] ), 'update_post' ); 101 121 } 122 $key = $req->get_param( '_amp_key' ); 123 if ( ! empty( $body['status'] ) && ! \DirectRelay\Agent_Profiles::is_status_allowed( $key, (string) $body['status'] ) ) { 124 return self::fail( $req, new \WP_Error( 125 'amp_agent_forbidden', 126 __( 'This agent role cannot publish content directly. Submit as a draft instead.', 'directrelay-automation-bridge-for-n8n' ), 127 [ 'status' => 403 ] 128 ), 'update_post' ); 129 } 130 $own = \DirectRelay\Agent_Profiles::assert_owns( $key, $id ); 131 if ( is_wp_error( $own ) ) { 132 return self::fail( $req, $own, 'update_post' ); 133 } 102 134 $r = Post_Manager::update( $id, $body ); 103 135 if ( is_wp_error( $r ) ) { 104 136 return self::fail( $req, $r, 'update_post' ); 105 137 } 106 $key = $req->get_param( '_amp_key' ); 107 if ( ! empty( $body['seo'] ) && \DirectRelay\Api_Auth::has_scope( $key, 'seo:write' ) ) { 108 Rankmath_Handler::update( $id, $body['seo'] ); 138 $updated = Post_Manager::format( get_post( $id ) ); 139 if ( ! empty( $body['seo'] ) ) { 140 /* Free build is read-only for SEO: the add-on owns SEO writes. */ 141 $seo_state = Rankmath_Handler::handle_inline_write( 142 (int) $id, 143 is_array( $body['seo'] ) ? $body['seo'] : [], 144 \DirectRelay\Api_Auth::has_scope( $key, 'seo:write' ) 145 ); 146 $updated['seo_write'] = $seo_state['write']; 147 $updated['seo'] = $seo_state['seo']; 148 if ( ! empty( $seo_state['error'] ) ) { 149 $updated['seo_write_error'] = $seo_state['error']; 150 } 151 } else { 152 $updated['seo'] = Rankmath_Handler::read( $id ); 109 153 } 110 $updated = Post_Manager::format( get_post( $id ) );111 $updated['seo'] = Rankmath_Handler::read( $id );112 154 return self::ok( $req, [ 'post' => $updated ], 200, 'update_post', $id, 'post' ); 113 155 } … … 117 159 if ( ! get_post( $id ) ) { 118 160 return self::fail( $req, new \WP_Error( 'amp_not_found', __( 'Post not found.', 'directrelay-automation-bridge-for-n8n' ), [ 'status' => 404 ] ), 'delete_post' ); 161 } 162 $own = \DirectRelay\Agent_Profiles::assert_owns( (array) $req->get_param( '_amp_key' ), $id ); 163 if ( is_wp_error( $own ) ) { 164 return self::fail( $req, $own, 'delete_post' ); 119 165 } 120 166 $r = wp_delete_post( $id, (bool) $req->get_param( 'force' ) ); … … 128 174 return self::fail( $req, new \WP_Error( 'amp_not_found', __( 'Post not found.', 'directrelay-automation-bridge-for-n8n' ), [ 'status' => 404 ] ), 'publish_post' ); 129 175 } 176 $own = \DirectRelay\Agent_Profiles::assert_owns( (array) $req->get_param( '_amp_key' ), $id ); 177 if ( is_wp_error( $own ) ) { 178 return self::fail( $req, $own, 'publish_post' ); 179 } 130 180 $r = wp_update_post( [ 'ID' => $id, 'post_status' => 'publish' ], true ); 131 181 if ( is_wp_error( $r ) ) { -
directrelay-automation-bridge-for-n8n/tags/5.1.32/includes/REST/Webhook_Controller.php
r3699228 r3706276 46 46 'settings' => [ 47 47 'max_webhooks' => (int) ( get_option( 'directrelay_settings', [] )['max_webhooks'] ?? 50 ), 48 'webhook_max_retries' => (int) ( get_option( 'directrelay_settings', [] )[' webhook_max_retries'] ?? 3 ),48 'webhook_max_retries' => (int) ( get_option( 'directrelay_settings', [] )['max_retry_attempts'] ?? 3 ), 49 49 'log_retention_days' => (int) ( get_option( 'directrelay_settings', [] )['log_retention_days'] ?? 90 ), 50 50 ], -
directrelay-automation-bridge-for-n8n/tags/5.1.32/includes/Rankmath_Handler.php
r3699228 r3706276 93 93 return $data; 94 94 } 95 96 /** 97 * Handle an inline `seo` payload on post/page create/update. 98 * 99 * The free plugin is strictly read-only for SEO (WP.org serviceware 100 * guideline): it contains no SEO write code. The add-on plugin may 101 * implement the write by filtering `directrelay_ext_seo_update` and 102 * returning a non-null result: 103 * 104 * add_filter( 'directrelay_ext_seo_update', function ( $result, $post_id, $seo ) { 105 * // write the fields here, then return the write result, 106 * // or a WP_Error on failure. Return null to pass. 107 * }, 10, 3 ); 108 * 109 * Without the add-on the write is reported as skipped so clients never 110 * assume the fields were applied (and no undefined-method fatal can 111 * recur the way the pre-5.1.23 write path did). 112 * 113 * @param int $post_id Created/updated post ID. 114 * @param array $seo Inline seo payload from the request body. 115 * @param bool $can_write Whether the authenticated key holds seo:write. 116 * @return array { write: string, seo: array, error?: string } 117 * write: handled_addon | skipped_addon | missing_scope | addon_error. 118 */ 119 public static function handle_inline_write( int $post_id, array $seo, bool $can_write ): array { 120 $result = null; 121 if ( $can_write ) { 122 $result = apply_filters( 'directrelay_ext_seo_update', null, $post_id, $seo ); 123 } 124 125 $state = [ 126 'write' => 'skipped_addon', 127 'seo' => self::read( $post_id ), 128 ]; 129 if ( ! $can_write ) { 130 $state['write'] = 'missing_scope'; 131 } elseif ( is_wp_error( $result ) ) { 132 $state['write'] = 'addon_error'; 133 $state['error'] = $result->get_error_message(); 134 } elseif ( null !== $result ) { 135 $state['write'] = 'handled_addon'; 136 } 137 return $state; 138 } 95 139 } -
directrelay-automation-bridge-for-n8n/tags/5.1.32/includes/Rate_Limiter.php
r3699228 r3706276 8 8 /** 9 9 * Quota is enforced per site, not per API key, so revoking and 10 * re-creating a key does not reset the counter.10 * re-creating a key does not reset the counter. 11 11 */ 12 12 private const SITE_BUCKET = 'site'; … … 22 22 private static function tier_limits(): array { 23 23 $s = get_option( 'directrelay_settings', array() ); 24 return array( (int) ( $s['rate_limit'] ?? 150 ), (int) ( $s['rate_window_sec'] ?? 86400 ) ); 24 /* Fallbacks match the Installer defaults (120 requests / 60s window). */ 25 return array( (int) ( $s['rate_limit'] ?? 120 ), (int) ( $s['rate_window_sec'] ?? 60 ) ); 25 26 } 26 27 … … 30 31 31 32 [ $limit, $window ] = self::tier_limits(); 32 /* $key_id is unused for bucketing: the limit (s)bucket is per site. */33 /* $key_id is unused for bucketing: the limit bucket is per site. */ 33 34 $bucket = self::SITE_BUCKET; 34 35 $table = "{$wpdb->prefix}directrelay_rate"; … … 103 104 /** 104 105 * Read-only remaining-requests lookup for the site bucket. 105 * Never writes; an expired or missing window counts as full limit (s).106 * $key_id is unused for bucketing: the limit (s)bucket is per site.106 * Never writes; an expired or missing window counts as full limit. 107 * $key_id is unused for bucketing: the limit bucket is per site. 107 108 */ 108 109 public static function get_remaining( int $key_id ): int { … … 121 122 } 122 123 123 /* Expired window: the next check() resets the bucket, so full limit (s)is available. */124 if ( ( time() - (int) strtotime( $row['window_open'] ) ) >= $window ) {124 /* Expired window: the next check() resets the bucket, so full limit is available. */ 125 if ( ( time() - (int) strtotime( $row['window_open'] . ' UTC' ) ) >= $window ) { 125 126 return $limit; 126 127 } -
directrelay-automation-bridge-for-n8n/tags/5.1.32/includes/Transactional_Publisher.php
r3699228 r3706276 121 121 } 122 122 123 // Stage 4: SEO Metadata Application123 // Stage 4: SEO Metadata (read-only in the free build) 124 124 $execution_steps['seo'] = 'running'; 125 125 $seo_result = null; 126 126 if ( ! empty( $payload['seo'] ) && is_array( $payload['seo'] ) ) { 127 $seo_result = Rankmath_Handler::update( $created_post_id, $payload['seo'] ); 128 if ( is_wp_error( $seo_result ) && $strict_rollback ) { 129 /* translators: %s: error message detail */ 130 throw new \Exception( sprintf( __( 'SEO update failed: %s', 'directrelay-automation-bridge-for-n8n' ), $seo_result->get_error_message() ) ); 131 } 132 } 133 $execution_steps['seo'] = 'completed'; 134 135 // Stage 5: Approval Gateway Check (add-on feature(s); free skips silently) 127 /* 128 * The free plugin is strictly read-only for SEO (WP.org 129 * serviceware guideline — SEO write code must not live in the 130 * WP.org build). The add-on plugin supplies its own SEO writer 131 * and applies it after this transaction returns. Report the 132 * current SEO state instead of writing. 133 */ 134 $execution_steps['seo'] = 'skipped_addon'; 135 $seo_result = Rankmath_Handler::read( $created_post_id ); 136 } else { 137 $execution_steps['seo'] = 'completed'; 138 } 139 140 // Stage 5: Approval Gateway Check (add-on features; free skips silently) 136 141 $approval_data = null; 137 142 if ( $require_approval && class_exists( '\DirectRelay\Approval_Gateway' ) ) { … … 149 154 \DirectRelay\Guardrails_Manager::increment_daily_usage( (int) ( $key['id'] ?? 0 ) ); 150 155 } 151 IndexNow_Pinger::ping( [ get_permalink( $created_post_id ) ], $created_post_id ); 156 /* IndexNow is opt-in (same gate as IndexNow_Pinger::init()). 157 * Posts are already pinged by the transition_post_status 158 * listener when the setting is on; this covers other post 159 * types (e.g. pages) so each URL pings exactly once. */ 160 $directrelay_settings = get_option( 'directrelay_settings', [] ); 161 if ( ! empty( $directrelay_settings['enable_indexnow'] ) && 'post' !== $post_type ) { 162 IndexNow_Pinger::ping( [ get_permalink( $created_post_id ) ], $created_post_id ); 163 } 152 164 } 153 165 } … … 183 195 wp_delete_post( $created_post_id, true ); 184 196 $rollback_telemetry['deleted_post_id'] = $created_post_id; 197 198 /* Unschedule webhook deliveries still queued for the 199 * rolled-back post so receivers never get events for a 200 * post that no longer exists. fire() args are 201 * [event, id, type]; deliver args are 202 * [hook_id, event, id, type, attempt(, log_id)]. */ 203 $unscheduled = 0; 204 $crons = _get_cron_array() ?: []; 205 foreach ( $crons as $timestamp => $cron_events ) { 206 foreach ( [ 'directrelay_fire_webhook', 'directrelay_deliver_webhook' ] as $cron_hook ) { 207 if ( empty( $cron_events[ $cron_hook ] ) ) { 208 continue; 209 } 210 foreach ( $cron_events[ $cron_hook ] as $event_entry ) { 211 $args = $event_entry['args'] ?? []; 212 $obj_id = ( 'directrelay_fire_webhook' === $cron_hook ) ? ( $args[1] ?? null ) : ( $args[2] ?? null ); 213 if ( (int) $obj_id === (int) $created_post_id ) { 214 wp_unschedule_event( $timestamp, $cron_hook, $args ); 215 $unscheduled++; 216 } 217 } 218 } 219 } 220 if ( $unscheduled > 0 ) { 221 $rollback_telemetry['unscheduled_webhook_events'] = $unscheduled; 222 } 185 223 } 186 224 } … … 202 240 203 241 /** 204 * Sideload an image URL into WordPress media library with SSRF checks. 242 * Sideload an image URL into the WordPress media library. 243 * 244 * The fetch itself lives in Post_Manager::sideload_url(), which re-validates 245 * SSRF safety on every redirect hop; this wrapper only adds the ALT text. 205 246 */ 206 247 public static function sideload_image( string $url, string $title = '' ): int|\WP_Error { 207 require_once ABSPATH . 'wp-admin/includes/media.php'; 208 require_once ABSPATH . 'wp-admin/includes/file.php'; 209 require_once ABSPATH . 'wp-admin/includes/image.php'; 210 211 $safe_url = esc_url_raw( $url ); 212 if ( ! Api_Auth::is_url_safe( $safe_url ) ) { 213 return new \WP_Error( 'amp_ssrf_blocked', __( 'SSRF protection: Image URL is blocked.', 'directrelay-automation-bridge-for-n8n' ), [ 'status' => 403 ] ); 214 } 215 216 $att_id = media_sideload_image( $safe_url, 0, null, 'id' ); 248 $att_id = Post_Manager::sideload_url( $url, 0, $title ); 217 249 if ( is_wp_error( $att_id ) ) { 218 250 return $att_id; … … 221 253 if ( ! empty( $title ) ) { 222 254 update_post_meta( (int) $att_id, '_wp_attachment_image_alt', sanitize_text_field( $title ) ); 223 wp_update_post( [224 'ID' => (int) $att_id,225 'post_title' => sanitize_text_field( $title ),226 ] );227 255 } 228 256 -
directrelay-automation-bridge-for-n8n/tags/5.1.32/includes/Webhook_Manager.php
r3699228 r3706276 30 30 'post_types' => [ 'post', 'page' ], 31 31 'description' => 'When an article is published, n8n automatically posts the title, link, and featured image to X/Twitter, LinkedIn, Facebook Pages, Telegram, and Pinterest.', 32 'badge' => 'PR O PRESET',33 'is_pro' => true,32 'badge' => 'PRESET', 33 'is_pro' => false, 34 34 'workflow' => '{"name":"DirectRelay - Social Media Blast","nodes":[{"parameters":{"httpMethod":"POST","path":"directrelay-social-blast","options":{}},"name":"DirectRelay Webhook","type":"n8n-nodes-base.webhook","typeVersion":1,"position":[200,300]},{"parameters":{"values":{"string":[{"name":"title","value":"={{$json.body.post.title}}"},{"name":"url","value":"={{$json.body.post.permalink}}"},{"name":"image","value":"={{$json.body.post.featured_image}}"},{"name":"keyword","value":"={{$json.body.seo.focus_keyword}}"}]},"options":{}},"name":"Format Payload","type":"n8n-nodes-base.set","typeVersion":2,"position":[400,300]},{"parameters":{"content":"## 🌐 Multi-Platform Social Blast\\nDispatches published WordPress articles to Twitter/X, LinkedIn, Facebook, Telegram & Pinterest automatically.","height":180,"width":400},"name":"Notes","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[200,100]}],"connections":{"DirectRelay Webhook":{"main":[[{"node":"Format Payload","type":"main","index":0}]]}}}', 35 35 ], … … 41 41 'post_types' => [ 'post' ], 42 42 'description' => 'Sends full article to Claude/ChatGPT in n8n to automatically generate: (a) A newsletter summary, (b) 5 tweet bullet points, (c) An ElevenLabs audio podcast episode.', 43 'badge' => 'PR O PRESET',44 'is_pro' => true,43 'badge' => 'PRESET', 44 'is_pro' => false, 45 45 'workflow' => '{"name":"DirectRelay - AI Content Repurposing","nodes":[{"parameters":{"httpMethod":"POST","path":"directrelay-ai-repurpose","options":{}},"name":"DirectRelay Webhook","type":"n8n-nodes-base.webhook","typeVersion":1,"position":[200,300]},{"parameters":{"model":"gpt-4o","prompt":"={{$json.body.post.content}}","systemPrompt":"Generate: 1. A 3-sentence newsletter summary. 2. A 5-tweet thread. 3. A 60-second podcast audio script."},"name":"AI Repurposer (LLM)","type":"n8n-nodes-base.openAi","typeVersion":1,"position":[420,300]}],"connections":{"DirectRelay Webhook":{"main":[[{"node":"AI Repurposer (LLM)","type":"main","index":0}]]}}}', 46 46 ], … … 52 52 'post_types' => [ 'post', 'page' ], 53 53 'description' => 'n8n instantly submits the updated URL and Rank Math canonical link to Google Indexing API and IndexNow (Bing / Yandex).', 54 'badge' => 'PR O PRESET',55 'is_pro' => true,54 'badge' => 'PRESET', 55 'is_pro' => false, 56 56 'workflow' => '{"name":"DirectRelay - Instant Search Indexing","nodes":[{"parameters":{"httpMethod":"POST","path":"directrelay-search-index","options":{}},"name":"DirectRelay Webhook","type":"n8n-nodes-base.webhook","typeVersion":1,"position":[200,300]},{"parameters":{"method":"POST","url":"https://api.indexnow.org/indexnow","sendBody":true,"specifyBody":"json","jsonBody":"={\\n \\\"host\\\": \\\"{{$json.body.site_url.replace(\'https://\', \'\').replace(\'http://\', \'\')}}\\\",\\n \\\"key\\\": \\\"YOUR_INDEXNOW_KEY\\\",\\n \\\"urlList\\\": [\\\"{{$json.body.post.permalink}}\\\"]\\n}"},"name":"IndexNow Ping","type":"n8n-nodes-base.httpRequest","typeVersion":4.1,"position":[440,300]}],"connections":{"DirectRelay Webhook":{"main":[[{"node":"IndexNow Ping","type":"main","index":0}]]}}}', 57 57 ], … … 63 63 'post_types' => [ 'post', 'page' ], 64 64 'description' => 'Keeps external search indexes like Algolia, Meilisearch, or Typesense synchronized without running heavy server-side sync cron jobs.', 65 'badge' => 'PR O PRESET',66 'is_pro' => true,65 'badge' => 'PRESET', 66 'is_pro' => false, 67 67 'workflow' => '{"name":"DirectRelay - Search Index Sync","nodes":[{"parameters":{"httpMethod":"POST","path":"directrelay-search-sync","options":{}},"name":"DirectRelay Webhook","type":"n8n-nodes-base.webhook","typeVersion":1,"position":[200,300]},{"parameters":{"rules":{"values":[{"conditions":{"string":[{"value1":"={{$json.body.event}}","value2":"post.deleted"}]}}]}},"name":"Check Event Type","type":"n8n-nodes-base.switch","typeVersion":1,"position":[420,300]}],"connections":{"DirectRelay Webhook":{"main":[[{"node":"Check Event Type","type":"main","index":0}]]}}}', 68 68 ], … … 74 74 'post_types' => [ 'post', 'page' ], 75 75 'description' => 'Sends a message to a team Slack channel or Discord server with post details, author, and quick review links.', 76 'badge' => 'PR O PRESET',77 'is_pro' => true,76 'badge' => 'PRESET', 77 'is_pro' => false, 78 78 'workflow' => '{"name":"DirectRelay - Team Slack Alert","nodes":[{"parameters":{"httpMethod":"POST","path":"directrelay-slack-alert","options":{}},"name":"DirectRelay Webhook","type":"n8n-nodes-base.webhook","typeVersion":1,"position":[200,300]},{"parameters":{"channel":"#content-publishing","text":"=📰 *New Article Published!*\\n*Title:* {{$json.body.post.title}}\\n*Author:* {{$json.body.post.author}}\\n*Link:* {{$json.body.post.permalink}}"},"name":"Slack Notification","type":"n8n-nodes-base.slack","typeVersion":1,"position":[440,300]}],"connections":{"DirectRelay Webhook":{"main":[[{"node":"Slack Notification","type":"main","index":0}]]}}}', 79 79 ], … … 85 85 'post_types' => [ 'post', 'page' ], 86 86 'description' => 'Calls Cloudflare / BunnyCDN API to purge cache for that specific URL immediately.', 87 'badge' => 'PR O PRESET',88 'is_pro' => true,87 'badge' => 'PRESET', 88 'is_pro' => false, 89 89 'workflow' => '{"name":"DirectRelay - Cloudflare Cache Purge","nodes":[{"parameters":{"httpMethod":"POST","path":"directrelay-cache-purge","options":{}},"name":"DirectRelay Webhook","type":"n8n-nodes-base.webhook","typeVersion":1,"position":[200,300]},{"parameters":{"method":"POST","url":"https://api.cloudflare.com/client/v4/zones/YOUR_ZONE_ID/purge_cache","sendHeaders":true,"headerParameters":{"parameters":[{"name":"Authorization","value":"Bearer YOUR_CF_API_TOKEN"}]},"sendBody":true,"specifyBody":"json","jsonBody":"={\\n \\\"files\\\": [\\\"{{$json.body.post.permalink}}\\\"]\\n}"},"name":"Purge Cloudflare Cache","type":"n8n-nodes-base.httpRequest","typeVersion":4.1,"position":[440,300]}],"connections":{"DirectRelay Webhook":{"main":[[{"node":"Purge Cloudflare Cache","type":"main","index":0}]]}}}', 90 90 ], … … 96 96 'post_types' => [ 'post', 'attachment' ], 97 97 'description' => 'Converts the post to Markdown and commits it to a private GitHub repository or uploads media to an AWS S3 / Cloudflare R2 bucket.', 98 'badge' => 'PR O PRESET',99 'is_pro' => true,98 'badge' => 'PRESET', 99 'is_pro' => false, 100 100 'workflow' => '{"name":"DirectRelay - GitHub & S3 Backup","nodes":[{"parameters":{"httpMethod":"POST","path":"directrelay-backup","options":{}},"name":"DirectRelay Webhook","type":"n8n-nodes-base.webhook","typeVersion":1,"position":[200,300]},{"parameters":{"content":"## 💾 Backup & Archival\\nConverts post JSON into Markdown and pushes to GitHub repository. For media uploads, transfers file to S3 bucket.","height":160,"width":380},"name":"Backup Info","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[200,100]}],"connections":{}}', 101 101 ], … … 108 108 109 109 public static function init(): void { 110 /* Retention must run even when webhooks are disabled, otherwise 111 * existing webhook_log rows are never pruned. */ 112 add_action( 'directrelay_prune_rate_limits', [ __CLASS__, 'prune_logs' ] ); 113 110 114 $s = get_option( 'directrelay_settings', [] ); 111 115 if ( empty( $s['enable_webhooks'] ) ) { … … 121 125 /* Individual delivery & retry worker */ 122 126 add_action( 'directrelay_deliver_webhook', [ __CLASS__, 'deliver_worker' ], 10, 6 ); 123 124 /* Hourly prune of webhook delivery logs */125 add_action( 'directrelay_prune_rate_limits', [ __CLASS__, 'prune_logs' ] );126 127 } 127 128 … … 137 138 global $wpdb; 138 139 $table = "{$wpdb->prefix}directrelay_rate"; 139 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared 140 /* PHP-side date math for SQLite portability; window_open is UTC. */ 141 $cutoff = gmdate( 'Y-m-d H:i:s', time() - 30 * DAY_IN_SECONDS ); 142 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom table. 140 143 $row = $wpdb->get_row( 141 144 $wpdb->prepare( 145 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $table is prefix + static name, not user input. 142 146 "SELECT hits, window_open FROM {$table} 143 WHERE bucket = %s AND window_open >= DATE_SUB(UTC_TIMESTAMP(), INTERVAL 30 DAY)", 144 'webhook_site' 147 WHERE bucket = %s AND window_open >= %s", 148 'webhook_site', 149 $cutoff 145 150 ), 146 151 ARRAY_A 147 152 ); 148 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared149 153 $total = $row ? (int) $row['hits'] : 0; 150 154 $limit = PHP_INT_MAX; … … 153 157 'limit' => $limit, 154 158 'remaining' => max( 0, $limit - $total ), 155 'is_pro' => true,159 'is_pro' => false, 156 160 ]; 157 161 } … … 160 164 * Check and record a webhook dispatch. 161 165 * 162 * The free plugin does not enforce a monthly dispatch limit (s)—166 * The free plugin does not enforce a monthly dispatch limit — 163 167 * the WP.org Plugin Directory Guidelines forbid usage limits in 164 168 * the free build. The directrelay-pro add-on may hook into the 165 169 * `directrelay_ext_guardrails_check` filter to re-introduce a 166 * limit (s). This method is kept as a stable entry point so any170 * limit. This method is kept as a stable entry point so any 167 171 * custom code that already calls it does not break. 168 172 * … … 178 182 public static function prune_logs(): void { 179 183 global $wpdb; 180 $days = (int) ( get_option( 'directrelay_settings', [] )['log_retention_days'] ?? 90 ); 181 $table = "{$wpdb->prefix}directrelay_webhook_log"; 182 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared 184 $days = (int) ( get_option( 'directrelay_settings', [] )['log_retention_days'] ?? 90 ); 185 $table = "{$wpdb->prefix}directrelay_webhook_log"; 186 /* PHP-side date math for SQLite portability; created_at is UTC. */ 187 $cutoff = gmdate( 'Y-m-d H:i:s', time() - $days * DAY_IN_SECONDS ); 188 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom table maintenance. 183 189 $wpdb->query( 184 190 $wpdb->prepare( 185 "DELETE FROM {$table} WHERE created_at < DATE_SUB(UTC_TIMESTAMP(), INTERVAL %d DAY)", 186 $days 191 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $table is prefix + static name, not user input. 192 "DELETE FROM {$table} WHERE created_at < %s", 193 $cutoff 187 194 ) 188 195 ); 189 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared190 196 } 191 197 … … 199 205 return; 200 206 } 207 if ( in_array( $post->post_status, [ 'auto-draft', 'new' ], true ) ) { 208 return; /* Gutenberg's empty editor shell is not a content event. */ 209 } 201 210 $type = $post->post_type; 202 self::schedule_fire( $update ? "{$type}.updated" : "{$type}.created", $id, $type ); 211 /* 212 * wp_insert_post reports $update === true even for the first real save 213 * of a Gutenberg post (its auto-draft row predates it), so created vs 214 * updated is tracked with a marker meta instead of the $update flag. 215 */ 216 $seen = get_post_meta( $id, '_directrelay_event_seen', true ); 217 if ( ! $seen ) { 218 update_post_meta( $id, '_directrelay_event_seen', 1 ); 219 self::schedule_fire( "{$type}.created", $id, $type ); 220 } else { 221 self::schedule_fire( "{$type}.updated", $id, $type ); 222 } 203 223 if ( $post->post_status === 'publish' ) { 204 224 self::schedule_fire( "{$type}.published", $id, $type ); … … 234 254 235 255 public static function fire( string $event, int $object_id, string $object_type ): void { 236 // The free plugin has no monthly dispatch limit (s). The previous256 // The free plugin has no monthly dispatch limit. The previous 237 257 // add-on monthly cap was removed because the WP.org Plugin 238 258 // Directory Guidelines forbid usage limits in the free build. 239 // The directrelay-pro add-on may re-introduce a limit (s)via the259 // The directrelay-pro add-on may re-introduce a limit via the 240 260 // `directrelay_ext_guardrails_check` filter. 241 261 global $wpdb; … … 250 270 251 271 foreach ( $hooks as $hook ) { 252 $events = json_decode( $hook['events'] ,true ) ?: [];253 $types = json_decode( $hook['post_types'] , true ) ?: [];272 $events = json_decode( $hook['events'] ?? '[]', true ) ?: []; 273 $types = json_decode( $hook['post_types'] ?? '[]', true ) ?: []; 254 274 if ( ! in_array( $event, $events, true ) ) { 255 275 continue; 256 276 } 257 if ( $object_type !== 'attachment' && ! in_array( $object_type, $types, true ) ) { 277 /* media.uploaded is delivered only to webhooks whose post_types 278 * list includes 'attachment' — the same filter posts/pages use. */ 279 if ( ! in_array( $object_type, $types, true ) ) { 258 280 continue; 259 281 } … … 343 365 if ( ! empty( $plain_secret ) ) { 344 366 $headers['X-directrelay-Signature'] = 'sha256=' . hash_hmac( 'sha256', $body, $plain_secret ); 367 368 /* 369 * Replay protection: a fresh timestamp plus a second signature over 370 * "<timestamp>.<body>". Receivers that need it verify this signature 371 * and reject deliveries whose timestamp is older than ~5 minutes. 372 * The body-only signature above is unchanged for existing n8n 373 * verification workflows. 374 */ 375 $ts = (string) time(); 376 $headers['X-directrelay-Timestamp'] = $ts; 377 $headers['X-directrelay-Signature-Timestamp'] = 'sha256=' . hash_hmac( 'sha256', $ts . '.' . $body, $plain_secret ); 345 378 } 346 379 … … 451 484 } 452 485 486 /** 487 * Key material for secret encryption: the site's WordPress secret keys, 488 * in fallback order. SECURE_AUTH_KEY is defined in every correctly 489 * configured wp-config.php; the chain (and the final built-in fallback) 490 * only matters on misconfigured installs. 491 */ 492 private static function secret_key(): string { 493 if ( defined( 'SECURE_AUTH_KEY' ) && SECURE_AUTH_KEY ) { 494 return SECURE_AUTH_KEY; 495 } 496 if ( defined( 'AUTH_KEY' ) && AUTH_KEY ) { 497 return AUTH_KEY; 498 } 499 if ( defined( 'LOGGED_IN_KEY' ) && LOGGED_IN_KEY ) { 500 return LOGGED_IN_KEY; 501 } 502 return 'directrelay_fallback_key'; 503 } 504 453 505 public static function encrypt_secret( string $secret ): string { 454 506 if ( empty( $secret ) ) { 455 507 return ''; 456 508 } 457 $key = defined( 'SECURE_AUTH_KEY' ) ? SECURE_AUTH_KEY : 'directrelay_fallback_key';509 $key = self::secret_key(); 458 510 $iv = openssl_random_pseudo_bytes( 16 ); 459 511 $enc = openssl_encrypt( $secret, 'aes-256-cbc', $key, 0, $iv ); … … 465 517 return ''; 466 518 } 467 $raw = base64_decode( $enc_secret ); 519 $raw = base64_decode( $enc_secret ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- storage encoding, not obfuscation. 468 520 if ( ! $raw || strlen( $raw ) <= 16 ) { 469 521 return ''; 470 522 } 471 $key = defined( 'SECURE_AUTH_KEY' ) ? SECURE_AUTH_KEY : 'directrelay_fallback_key';523 $key = self::secret_key(); 472 524 $iv = substr( $raw, 0, 16 ); 473 525 $enc = substr( $raw, 16 ); -
directrelay-automation-bridge-for-n8n/tags/5.1.32/includes/admin/Ajax_Handler.php
r3699228 r3706276 141 141 // per-site cap was removed because the WP.org Plugin Directory 142 142 // Guidelines forbid usage limits in the free build. The 143 // directrelay-pro add-on may re-introduce a limit (s)via the143 // directrelay-pro add-on may re-introduce a limit via the 144 144 // `directrelay_ext_guardrails_check` filter. 145 145 … … 231 231 // per-site cap was removed because the WP.org Plugin Directory 232 232 // Guidelines forbid usage limits in the free build. The 233 // directrelay-pro add-on may re-introduce a limit (s)via the233 // directrelay-pro add-on may re-introduce a limit via the 234 234 // `directrelay_ext_guardrails_check` filter. 235 235 … … 451 451 452 452 $free_usage = null; 453 // The free plugin has no monthly request limit (s), so the453 // The free plugin has no monthly request limit, so the 454 454 // "free usage" indicator is always null. Kept as a stable 455 455 // key in the AJAX response for any client code that reads it. -
directrelay-automation-bridge-for-n8n/tags/5.1.32/includes/admin/Menu.php
r3699228 r3706276 84 84 'l10n' => [ 85 85 // License strings removed in 5.1.0; the directrelay-pro 86 // add-on plugin provides its own licenceUI.86 // add-on plugin provides its own activation UI. 87 87 ], 88 88 ]; -
directrelay-automation-bridge-for-n8n/tags/5.1.32/includes/admin/views/docs.php
r3699228 r3706276 580 580 const received = $input.first().headers['x-directrelay-signature']; 581 581 if (expected !== received) throw new Error('Signature mismatch'); 582 583 // Replay protection (recommended): reject deliveries older than 5 minutes. 584 const ts = Number($input.first().headers['x-directrelay-timestamp']); 585 const tsSig = $input.first().headers['x-directrelay-signature-timestamp']; 586 const tsExpected = 'sha256=' + crypto.createHmac('sha256', secret).update(ts + '.' + body).digest('hex'); 587 if (Math.abs(Date.now() / 1000 - ts) > 300 || tsExpected !== tsSig) throw new Error('Stale or invalid timestamp'); 582 588 return $input.all();</pre> 583 589 </div> -
directrelay-automation-bridge-for-n8n/tags/5.1.32/includes/admin/views/keys.php
r3699228 r3706276 188 188 <label for="directrelay-key-daily-limit" style="font-size: 14px;"><?php esc_html_e( 'Daily Post Limit (Velocity Cap)', 'directrelay-automation-bridge-for-n8n' ); ?></label> 189 189 <input type="number" id="directrelay-key-daily-limit" name="daily_limit" class="directrelay-input" placeholder="0" min="0" max="10000" style="font-size: 14px; padding: 10px 14px;"> 190 <p class="directrelay-field-desc"><?php esc_html_e( ' Maximum number of publications allowed per 24 hours for this key (0 = use global guardrails).', 'directrelay-automation-bridge-for-n8n' ); ?></p>190 <p class="directrelay-field-desc"><?php esc_html_e( 'Stored with this key for guardrail use. The free plugin imposes no usage limits; enforcement requires the DirectRelay add-on plugin.', 'directrelay-automation-bridge-for-n8n' ); ?></p> 191 191 </div> 192 192 … … 438 438 <tbody> 439 439 <?php foreach ( $directrelay_keys as $directrelay_k ) : 440 $directrelay_scopes_arr = json_decode( $directrelay_k['scopes'] , true ) ?: array();440 $directrelay_scopes_arr = json_decode( $directrelay_k['scopes'] ?? '[]', true ) ?: array(); 441 441 $directrelay_is_expired = $directrelay_k['expires_at'] && strtotime( $directrelay_k['expires_at'] ) <= time(); 442 442 $directrelay_is_active = ! $directrelay_k['revoked'] && ! $directrelay_is_expired; -
directrelay-automation-bridge-for-n8n/tags/5.1.32/includes/admin/views/n8n-templates.php
r3699228 r3706276 28 28 * - preview array List of node labels rendered as preview chips. 29 29 * - workflow string Raw n8n workflow JSON, used in the copy textarea. 30 * - pro bool When true the card is rendered with a add-on badge.30 * - pro bool When true the card is rendered with an add-on badge. 31 31 * 32 32 * @since 5.1.0 -
directrelay-automation-bridge-for-n8n/tags/5.1.32/includes/admin/views/settings.php
r3699228 r3706276 186 186 <div class="directrelay-set-label"> 187 187 <strong><?php esc_html_e( 'IndexNow Auto-Ping', 'directrelay-automation-bridge-for-n8n'); ?></strong> 188 <p><?php esc_html_e( 'Submit published post URLs to Bing / Yandex / IndexNow for instant indexing. Off by default. When off, no external service is contacted at publish time.', 'directrelay-automation-bridge-for-n8n'); ?></p>188 <p><?php esc_html_e( 'Submit published post URLs to the IndexNow aggregator (api.indexnow.org), which routes them to participating search engines (Bing, Yandex, Seznam, Naver). Off by default - when off, no external service is contacted at publish time. The verification key is published automatically at yoursite.com/indexnow-key.txt; open that URL to confirm the key file is reachable.', 'directrelay-automation-bridge-for-n8n'); ?></p> 189 189 </div> 190 190 <div class="directrelay-set-control"> -
directrelay-automation-bridge-for-n8n/tags/5.1.32/includes/admin/views/webhooks.php
r3699228 r3706276 3 3 * DirectRelay Webhooks admin view (Free). 4 4 * 5 * The free plugin has no monthly dispatch limit (s), no per-site webhook6 * cap, and no licence-state gate. The add-on plugin plugin provides the5 * The free plugin has no monthly dispatch limit, no per-site webhook 6 * cap, and no activation-state gate. The add-on plugin provides the 7 7 * preset library, event-pipeline cards, and add-on filters via the 8 8 * `directrelay_ext_templates` filter and the … … 90 90 data-label="<?php echo esc_attr( $directrelay_p['label'] ); ?>" 91 91 data-events="<?php echo esc_attr( wp_json_encode( $directrelay_p['events'] ) ); ?>" 92 data-types="<?php echo esc_attr( wp_json_encode( $directrelay_p['post_types'] ) ); ?>"> 92 data-types="<?php echo esc_attr( wp_json_encode( $directrelay_p['post_types'] ) ); ?>" 93 data-ispro="<?php echo esc_attr( empty( $directrelay_p['is_pro'] ) ? 0 : 1 ); ?>"> 93 94 <?php esc_html_e( 'Load Preset', 'directrelay-automation-bridge-for-n8n'); ?> 94 95 </button> … … 294 295 <tbody> 295 296 <?php foreach ( $directrelay_webhooks as $directrelay_wh ) : 296 $directrelay_events = json_decode( $directrelay_wh['events'] , true ) ?: array();297 $directrelay_events = json_decode( $directrelay_wh['events'] ?? '[]', true ) ?: array(); 297 298 ?> 298 299 <tr> … … 317 318 <?php echo $directrelay_wh['active'] ? esc_html__( 'Pause', 'directrelay-automation-bridge-for-n8n' ) : esc_html__( 'Activate', 'directrelay-automation-bridge-for-n8n' ); ?> 318 319 </button> 320 <button class="directrelay-btn directrelay-test-wh" data-id="<?php echo (int) $directrelay_wh['id']; ?>"> 321 <?php esc_html_e( 'Test', 'directrelay-automation-bridge-for-n8n'); ?> 322 </button> 323 <button class="directrelay-btn directrelay-view-log" data-id="<?php echo (int) $directrelay_wh['id']; ?>" data-label="<?php echo esc_attr( $directrelay_wh['label'] ); ?>"> 324 <?php esc_html_e( 'Deliveries', 'directrelay-automation-bridge-for-n8n'); ?> 325 </button> 319 326 <button class="directrelay-btn directrelay-delete-wh" data-id="<?php echo (int) $directrelay_wh['id']; ?>" style="color:var(--directrelay-red)"> 320 327 <?php esc_html_e( 'Delete', 'directrelay-automation-bridge-for-n8n'); ?> … … 329 336 </div> 330 337 331 </div> 338 <!-- Webhook Test Result Modal --> 339 <div id="directrelay-test-modal" class="directrelay-modal-overlay" style="display:none;"> 340 <div class="directrelay-modal-box" style="margin:10vh auto;max-width:640px;width:90%;background:var(--directrelay-card,#fff);border-radius:12px;padding:24px;box-shadow:0 20px 60px rgba(0,0,0,.3);"> 341 <div class="directrelay-modal-header" style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;"> 342 <strong><?php esc_html_e( 'Webhook Test Result', 'directrelay-automation-bridge-for-n8n'); ?></strong> 343 <button type="button" class="directrelay-modal-close" style="background:none;border:none;font-size:20px;cursor:pointer;line-height:1;">×</button> 344 </div> 345 <pre id="directrelay-test-result" style="white-space:pre-wrap;max-height:320px;overflow:auto;font-size:12px;"></pre> 346 </div> 347 </div> 348 349 <!-- Webhook Delivery Log Modal --> 350 <div id="directrelay-deliveries-modal" class="directrelay-modal-overlay" style="display:none;"> 351 <div class="directrelay-modal-box" style="margin:8vh auto;max-width:900px;width:94%;background:var(--directrelay-card,#fff);border-radius:12px;padding:24px;box-shadow:0 20px 60px rgba(0,0,0,.3);"> 352 <div class="directrelay-modal-header" style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;"> 353 <strong><span class="directrelay-modal-title"><?php esc_html_e( 'Deliveries', 'directrelay-automation-bridge-for-n8n'); ?></span> <span class="directrelay-badge" id="directrelay-delivery-count"></span></strong> 354 <button type="button" class="directrelay-modal-close" style="background:none;border:none;font-size:20px;cursor:pointer;line-height:1;">×</button> 355 </div> 356 <div id="directrelay-delivery-log" style="max-height:60vh;overflow:auto;"></div> 357 </div> 358 </div> 359 360 </div> -
directrelay-automation-bridge-for-n8n/tags/5.1.32/privacy.txt
r3700813 r3706276 11 11 - Response time in milliseconds 12 12 - Client IP address 13 - User agent string14 13 15 14 2. API Key Metadata … … 48 47 Analytics aggregates are retained for 30 days. 49 48 Webhook delivery logs are retained for 30 days. 50 Expired or revoked API key records are retained for 90 days after revocation for audit purposes.49 Revoked API keys are disabled immediately; their records remain in the plugin's keys table until the plugin is uninstalled. Request logs follow the retention period above. 51 50 52 51 === Data Sharing & External Connections === … … 66 65 67 66 All API keys are securely hashed using bcrypt (via PHP's password_hash) on creation. The raw key is shown only once and cannot be retrieved. 68 Webhook secrets are stored using AES-256 encryption at rest and decrypted only at HMAC signing time.67 Webhook secrets are stored using AES-256 encryption at rest, keyed with your site's WordPress secret keys, and decrypted only at HMAC signing time. 69 68 IP addresses are stored for brute-force protection and are purgeable. 70 69 Connections to webhook target URLs are made on-demand when a WordPress post event occurs. -
directrelay-automation-bridge-for-n8n/tags/5.1.32/readme.txt
r3700957 r3706276 1 === DirectRelay Automation Bridge for n8n===1 === DirectRelay – n8n Workflow Automation Plugin (REST API & Webhooks) === 2 2 Contributors: n4nion 3 Tags: api, rest, n8n, automation, seo3 Tags: n8n, automation, rest api, webhooks, seo 4 4 Requires at least: 6.3 5 5 Tested up to: 7.1 6 6 Requires PHP: 8.0 7 Stable tag: 5.1.3 17 Stable tag: 5.1.32 8 8 License: GPL-2.0+ 9 9 License URI: http://www.gnu.org/licenses/gpl-2.0.txt 10 10 11 Free REST API + signed webhook bridge for WordPress and n8n. Rank Math + Yoast reads, IndexNow pings. 100% GPL.11 Connect WordPress to n8n. The free n8n workflow automation plugin: REST API, signed webhooks, AI agents, SEO reads, IndexNow. 100% GPL. 12 12 13 13 == Description == 14 14 15 **DirectRelay Automation Bridge for n8n** turns any WordPress site into a first-class REST API target that n8n workflows, Make scenarios, Zapier zaps, custom AI agents and your own scripts can talk to — with the keys, scopes and security model that production automation actually demands. 16 17 It is the only free WordPress automation plugin that ships all four at once: a scoped REST API surface (32 endpoints under `/wp-json/directrelay-automation-bridge-for-n8n/v1/`), HMAC-signed outgoing webhooks with retry, native reads of **Rank Math** and **Yoast SEO** fields, and a self-describing OpenAPI 3.0 spec that lets the n8n HTTP Request node auto-fill every request shape. **No purchase code. No license key. No trial period. No feature lock.** Every feature listed on this page is fully functional in the free build and always will be. 18 19 ### Why n8n users pick DirectRelay 20 21 * **OpenAPI 3.0 spec at `/wp-json/directrelay-automation-bridge-for-n8n/v1/openapi.json`** — paste it into n8n's HTTP Request node and every field, scope and response shape fills itself. No manual mapping. 22 * **Rank Math + Yoast SEO reads in one call** — `GET /posts/{id}/seo` returns focus keyword, SEO title, meta description, canonical URL and OpenGraph fields from whichever SEO plugin you have active. The community `n8n-nodes-wordpress` node can't do this. 23 * **Scoped API keys, not WordPress Application Passwords** — generate a key that can only read posts, only publish to a specific CPT, or only fire webhooks. Per-key IP allowlist, expiry and rotation are built in. Revoke one key without touching the others. 24 * **Bidirectional, not one-way** — REST reads **and** HMAC-SHA256 signed outgoing webhooks on the same install. Most alternatives do only one direction. 25 * **100% GPL, no telemetry, no license server** — what you install is what runs. No phone-home, no purchase-code check, no usage caps. 26 27 ### Everything in the free plugin 28 29 **REST API endpoints (32 routes)** 30 * Posts — list, get, publish, bulk publish (single call writes post + featured image + SEO + categories + tags). 31 * Pages — list and get. 32 * Media — list, sideload from URL, get. 33 * Taxonomies — categories and tags. 34 * Custom meta — list all, get/delete by key (works with ACF, Pods, custom fields). 35 * SEO read — `GET /{type}/{id}/seo` returns Rank Math or Yoast fields automatically. 36 * Discovery — `/discovery/fields` and `/discovery/post-types` for schema reflection. 37 * Webhooks — CRUD + test + delivery log + usage stats + ready-made presets. 38 * API keys — list, rotate, revoke. 39 * System — `/status`, `/ping`, `/cron-health`, `/blocked-ips`. 40 * Fleet — `/fleet/health` and `/fleet/keys/provision` for multi-site monitoring. 41 * OpenAPI — `/openapi.json` and `/openapi` for self-describing discovery. 42 43 **Security and production hardening** 44 * Scoped API keys with 16+ scope types (`posts:read`, `posts:write`, `posts:publish`, `posts:delete`, `media:write`, `webhooks:write`, etc.). 45 * Five preset roles (readonly, writer, editor, publisher, full_access) plus custom scope bundles. 46 * Per-key IP allowlist (single IP or CIDR ranges). 47 * Per-key expiration date (auto-revoke after). 48 * Automatic key rotation policy (configurable days; n8n-friendly grace period). 49 * Keys are stored as salted hashes — the database leak cannot expose live keys. 50 * Timing-safe comparison (`hash_equals`) — immune to timing side channels. 51 * Sliding-window rate limiter — configurable per-IP + per-key, prevents burst abuse. 52 * Brute-force protection with escalating IP block (5 min → 30 min → 2 hr → 24 hr). 53 * HMAC-SHA256 signed outgoing webhooks with `X-DirectRelay-Signature` header and replay protection via timestamp window. 54 55 **Outgoing webhooks** 56 * Dispatch signed JSON to any HTTP endpoint — n8n webhook, Make hook, Zapier catch hook, custom AI agent. 57 * Event filtering by post type, status transition, taxonomy. 58 * Automatic retry with exponential backoff. 59 * Last 50 deliveries per webhook (status code, response time, request body). 60 * Built-in "Send test event" button from the admin UI. 61 * Ready-made presets for `post.published`, `post.updated`, `post.deleted`, `page.published`. 62 63 **AI and SEO integrations** 64 * **Rank Math** field reads: focus keyword, SEO title, description, canonical URL, pillar content flag, robots flags. 65 * **Yoast SEO** field reads: focus keyword, SEO title, meta description, canonical URL, OpenGraph title and description. 66 * **IndexNow** auto-ping on every publish/update — instant indexing on Bing, Yandex, Naver and Seznam. No cron job needed. 67 * **OpenAI-compatible** response shape — all endpoints return JSON that the n8n OpenAI/HTTP nodes parse without transformation. 68 69 **Operations** 70 * Activity log — last 100 API calls and webhook deliveries, plain text, filterable by event type. 71 * Email notifications — admin gets pinged on every webhook failure with the failed payload attached. 72 * Compatibility checker — verifies permalinks, REST API, SSL, Rank Math / Yoast presence, PHP version, WP-Cron health. 73 * Cron health monitor — alerts if WP-Cron is more than 15 min behind. 74 * n8n starter template — one copy-paste workflow that proves the connection in under 60 seconds. 75 * Side-load media from URL — POST `/media/sideload` with an image URL and DirectRelay downloads, attaches and returns the WP media ID. 76 * Custom Post Type support — auto-discovered, no extra config. 77 * GDPR-friendly — zero outbound traffic by default. Every external service is an explicit opt-in. 78 79 ### Real workflows you can build today 80 81 * **AI writer pipeline** — n8n fires on a schedule → calls OpenAI to draft an article → `POST /posts` with title, content, Rank Math SEO and a featured image URL in a single request → IndexNow auto-pings Bing → webhook back to n8n notifies your Slack. 82 * **Multi-site fleet manager** — Central n8n instance pings `/fleet/health` on 20 WordPress sites every 5 min. If any return `cron_degraded`, n8n opens a PagerDuty incident. 83 * **Headless CMS front-end** — Next.js or Astro reads posts via `GET /posts?per_page=100&status=publish`, renders the site. Writers use the regular WP admin to edit. 84 * **Lead-magnet auto-publisher** — Webflow form submits → n8n reads the data → `POST /posts` with a draft pre-filled with the lead's company name → editorial team gets a Slack notification with a one-click approve link. 85 86 ### DirectRelay vs the alternatives 87 88 **Rank Math / Yoast SEO field reads** 89 * DirectRelay: **Yes, both** (Rank Math + Yoast auto-detected) 90 * WP REST + Application Password: No 91 * n8n-nodes-wordpress (community): No 92 * WP Webhooks: No 93 94 **Per-route API scopes** 95 * DirectRelay: **Yes, 16+ scopes** (posts:read, posts:write, media:write, etc.) 96 * WP REST + Application Password: No (always full account access) 97 * n8n-nodes-wordpress (community): No 98 * WP Webhooks: No 99 100 **HMAC-signed outgoing webhooks** 101 * DirectRelay: **Yes** (HMAC-SHA256, replay-protection timestamp) 102 * WP REST + Application Password: No 103 * n8n-nodes-wordpress (community): No 104 * WP Webhooks: Yes (basic) 105 106 **OpenAPI 3.0 auto-discovery** 107 * DirectRelay: **Yes** (`/openapi.json` so the n8n HTTP Request node auto-fills request shapes) 108 * WP REST + Application Password: No 109 * n8n-nodes-wordpress (community): No 110 * WP Webhooks: No 111 112 **Outgoing webhooks with retry** 113 * DirectRelay: **Yes** (exponential backoff, delivery log, test event) 114 * WP REST + Application Password: No 115 * n8n-nodes-wordpress (community): No 116 * WP Webhooks: Yes 117 118 **Per-credential IP allowlist** 119 * DirectRelay: **Yes** (single IP or CIDR) 120 * WP REST + Application Password: No 121 * n8n-nodes-wordpress (community): No 122 * WP Webhooks: No 123 124 **Pricing model** 125 * DirectRelay: **100% free, GPL, no upsell, no trial** 126 * WP REST + Application Password: 100% free (core WordPress) 127 * n8n-nodes-wordpress (community): 100% free (community node) 128 * WP Webhooks: Freemium (paid add-ons) 129 130 ### Privacy and licence — no trialware, ever 131 132 DirectRelay is free software released under the GPL-2.0+ licence. You can use it, study it, modify it and redistribute it under the same terms. 133 134 The free plugin distributed on WordPress.org does **not** perform purchase-code validation, license-server activation, or any third-party license check. There is **no** purchase code, license key or activation token collected, stored, transmitted or required to use any feature of the free plugin. License management for the optional add-on is provided by the separate DirectRelay Pro add-on plugin distributed from [directrelay.wikiofautomation.com/directrelay-pro](https://directrelay.wikiofautomation.com/directrelay-pro) — Pro is never distributed via WordPress.org and is never required. 135 136 == Pro Features (separate add-on plugin) == 137 138 The following features are provided by the DirectRelay Pro add-on plugin, distributed from https://directrelay.wikiofautomation.com/directrelay-pro. None of this code ships in the WordPress.org zip. 139 140 * **SEO Metadata Write** (focus keyword, title, description for Rank Math and Yoast SEO). 141 * **Bulk Publish Endpoint** (up to 50 items per request). 142 * **Universal Schema Publish** (schema-driven publish from any JSON shape). 143 * **One-Shot Publish** (atomic single-call create with media and SEO). 144 * **Human Approval Portal** (signed review URLs, approve/reject workflow, multi-reviewer chains). 145 * **AI Guardrails** (duplicate-title detection, AI-leakage scrubbing, blocked-phrase filter). 146 * **Full Debugger** (50-entry replay, payload inspection, dry-run mode). 147 * **Chart.js Analytics Dashboard** (call volume, error rate, latency trends, per-key breakdown). 148 * **n8n Template Library** (AI writer, multilingual publisher, research approval pipelines). 149 * **Dark Mode** for the plugin admin screens. 15 Project home page: https://directrelay.wikiofautomation.com 16 17 = The Automation Bridge Between WordPress and n8n = 18 19 You built your content workflows in n8n. Now you need WordPress to listen. 20 21 DirectRelay is the free n8n plugin that turns any WordPress site into a first-class automation target: a complete REST API for your n8n workflows, real-time signed webhooks from WordPress to n8n, and native SEO integrations with Rank Math and Yoast. Publish, update, tag, optimize, and react to content changes — automatically, securely, and without writing a single line of PHP. 22 23 Whether you run an AI content pipeline, a headless WordPress setup, a multi-channel publishing stack, or a simple "post to social when I publish" workflow, DirectRelay is the plugin for n8n workflow automation on WordPress. 24 25 = Why DirectRelay Is the Most Complete n8n Plugin for WordPress = 26 27 * **True two-way automation** — n8n can read and write WordPress, and WordPress can trigger n8n workflows in real time. 28 * **Everything in the free version** — no trial, no license key, no feature locks, no usage limits. 100% GPL. 29 * **Built for AI agents** — scoped API keys with agent personas let you hand LLMs and AI tools controlled, auditable access to your site. 30 * **OpenAPI 3.0 included** — point any HTTP Request node at the self-describing schema and every endpoint, parameter, and auth scheme is documented for you. 31 * **Security-first design** — scoped keys, bcrypt hashing, rate limiting, brute-force protection, IP allowlists, and SSRF-validated media sideloads. 32 * **Zero configuration headaches** — install, generate a key, paste it into n8n. Your first workflow runs in minutes. 33 34 = 🔌 A Complete REST API for Your n8n Workflows = 35 36 Every content operation your automation needs, exposed through a clean, versioned REST API under `/wp-json/directrelay/v1`: 37 38 * Create, read, update, publish, and delete **posts and pages** — with markdown support, scheduled publishing, and custom fields. 39 * **Media library** uploads via base64 or URL sideload, with per-route permissions. 40 * **Categories and tags** — resolve by name or ID, create on the fly. 41 * **Custom meta** read and write with core-protected keys blocked. 42 * **Discovery endpoints** — list post types and custom fields so workflows adapt to any site, including WooCommerce and custom post types. 43 * **OpenAPI 3.0 spec** at `/openapi.json` — import into Postman, Swagger UI, or generate n8n HTTP Request node setups instantly. 44 * **30+ documented endpoints**, each gated by fine-grained scopes (`posts:write`, `media:write`, `seo:read`, …). 45 46 = 🔔 Real-Time Signed Webhooks — WordPress → n8n = 47 48 The moment something happens on your site, n8n knows: 49 50 * **10 event types**: created, updated, published, and deleted for posts and pages, plus media uploads. 51 * **HMAC-SHA256 signatures** with timestamp headers and replay protection — verify deliveries in one n8n Code node. 52 * **Automatic retries** with exponential backoff and a per-attempt delivery log you can inspect in the admin. 53 * **Per-webhook filters** — choose exactly which events and post types each workflow receives. 54 * **Workflow presets** — social distribution, AI repurposing, search indexing, Slack alerts, CDN purging, and more, each with copy-paste n8n workflow JSON. 55 56 = 🤖 Built for AI Agents = 57 58 Handing an LLM the keys to your site should not mean handing it everything. 59 60 * **Agent role personas** — Full Access, AI Content Agent (drafts only), AI SEO Agent, and AI Publishing Agent. 61 * **Drafts-only enforcement** — a content agent physically cannot publish or delete, on any route, ever. 62 * **Post ownership isolation** — restrict each agent to content created by its own key. 63 * **Scoped, expiring, IP-allowlisted keys** with rotation and full usage analytics. 64 65 = 🔍 Read Rank Math & Yoast SEO from n8n = 66 67 The only free automation bridge that reads SEO metadata natively: 68 69 * Unified read endpoint for **Rank Math** and **Yoast SEO** — focus keyphrase, title, description, canonical, Open Graph, Twitter cards, schema type. 70 * Auto-detects which SEO plugin is active. One call, one format, any site. 71 72 = ⚡ IndexNow Instant Indexing = 73 74 Opt-in IndexNow auto-ping: the moment a post goes live, its URL is submitted to the IndexNow aggregator (Bing, Yandex, Seznam, Naver) for near-instant crawling. The verification key is published automatically at `yoursite.com/indexnow-key.txt`. 75 76 = 🛡️ Production-Grade Security = 77 78 * Scoped API keys — bcrypt-hashed, prefix-identifiable, never stored in plain text. 79 * Sliding-window rate limiting and escalating brute-force IP blocks with a manual block/unblock console. 80 * Per-key IP allowlists, global bypass IPs, optional HTTPS enforcement, and key expiry. 81 * SSRF-validated media sideloading with redirect-hop re-verification and size caps. 82 * Protected meta — WordPress core internals and plugin stamps are never agent-writable. 83 * Single-site build: each free install manages exactly one site. Multi-site fleet management is available in the add-on. 84 85 = Get Started in 3 Minutes = 86 87 1. Install DirectRelay and open the DirectRelay dashboard. 88 2. Generate an API key — pick a scope preset or build your own. 89 3. Paste the key into your n8n HTTP Request node (or import the OpenAPI spec) and your first automation is live. 90 91 = Who Is DirectRelay For? = 92 93 * **AI content teams** running generation pipelines through n8n workflows. 94 * **Agencies** managing publishing automation across client content operations. 95 * **Headless WordPress** builders who need a reliable, documented REST API. 96 * **Anyone who wants WordPress and n8n to talk to each other** — reliably and securely. 97 98 DirectRelay is free software licensed under the GPL. Use it, study it, modify it, redistribute it. 99 100 == DirectRelay Pro (separate add-on plugin) == 101 102 SEO metadata writes (Rank Math / Yoast), bulk publishing, human approval workflows, AI guardrails, advanced analytics, and dark mode are provided by the separate **DirectRelay Pro** add-on plugin, distributed from https://directrelay.wikiofautomation.com/directrelay-pro. The Pro add-on is never distributed via WordPress.org and is not required to use any free feature — the free plugin is fully functional on its own. 150 103 151 104 == External services == … … 157 110 = n8n Webhooks (outgoing) = 158 111 * **What it is and what it is used for:** DirectRelay dispatches signed JSON payloads to user-configured webhook URLs when WordPress post events occur. The webhook receiver can be n8n, Make, an AI agent, or any other HTTP endpoint the administrator chooses. 159 * **What data is sent and when:** The configured webhook target URL receives the post ID, title, content excerpt, status, taxonomies, and read-only SEO fields. Transmission only happens when the administrator has created an active webhook rule and the corresponding post event fires.112 * **What data is sent and when:** The configured webhook target URL receives the post ID, title, full post content, status, taxonomies, and read-only SEO fields. Transmission only happens when the administrator has created an active webhook rule and the corresponding post event fires. 160 113 * **Service Terms & Privacy:** 161 114 * The webhook receiver is the user's choice; DirectRelay itself does not transmit data to any third party by default. … … 164 117 165 118 = IndexNow = 166 * **What it is and what it is used for:** Submits updated post URLs directly to search engines (Bing, Yandex, Seznam) for instant indexing when content is published or updated.167 * **What data is sent and when:** Sends the site hostname, the API key the administrator configured, and the modified post URL. Transmitted only when the administrator enables IndexNow in plugin settings and a post is published or updated.119 * **What it is and what it is used for:** Submits published post URLs to the IndexNow aggregator (api.indexnow.org), which routes them to participating search engines (Bing, Yandex, Seznam, Naver) for instant indexing. Pings fire when a post transitions to published; edits to already-published posts are not re-pinged. 120 * **What data is sent and when:** Sends the site hostname, the plugin-generated IndexNow key, the key-location URL (yoursite.com/indexnow-key.txt), and the published post URL. Transmitted only when the administrator enables IndexNow in plugin settings and a post transitions to published. 168 121 * **Service Terms & Privacy:** 169 122 * IndexNow Terms: https://www.indexnow.org/ … … 189 142 == Installation == 190 143 191 1. Upload the `directrelay` directory to the `/wp-content/plugins/` directory, or install the plugin through the WordPress Plugins screen directly.192 2. Activate the plugin through the **Plugins** menu in WordPress.193 3. Navigate to **DirectRelay -> API Keys** in your WordPress admin menu to generate your first API credential.194 4. C onfigure your n8n HTTP Request node with the generated API key in the `Authorization: Bearer` header (or the `X-API-Key` header).195 5. (Optional) Install the DirectRelay Pro add-on plugin if you need SEO metadata writes, bulk publish, the approval portal, advanced analytics, or dark mode. Pro is available from https://directrelay.wikiofautomation.com/directrelay-pro.144 1. Upload the plugin files to `/wp-content/plugins/`, or install through the WordPress Plugins screen. 145 2. Activate the plugin. 146 3. Open **DirectRelay** from the admin menu and run the built-in compatibility check. 147 4. Create your first API key (**DirectRelay → API Keys**) and connect it from n8n using either the `X-API-Key` header or a `Bearer` token. 148 5. Optional: register an outgoing webhook under **DirectRelay → Webhooks** to trigger n8n workflows from WordPress events. 196 149 197 150 == Frequently Asked Questions == … … 199 152 = Does this require n8n? = 200 153 201 No. DirectRelay is a plain WordPress REST API plugin. Any HTTP client — n8n, Make, Zapier, custom Python, curl, Postman, an AI agent — can talk to it. The OpenAPI 3.0 spec at `/wp-json/directrelay-automation-bridge-for-n8n/v1/openapi.json` makes integration with any tool that supports OpenAPI frictionless.154 No. DirectRelay exposes a standard WordPress REST API that any HTTP client can use — n8n, Make, custom scripts, AI agents, Postman, or your own code. The n8n-specific touches (OpenAPI schema, workflow presets, webhook payloads shaped for n8n) simply make n8n the smoothest experience. 202 155 203 156 = Is this safe to run on a production site? = 204 157 205 Yes. Every request is authenticated via scoped API key + optional IP allowlist. Keys are stored as salted hashes (not plaintext). The rate limiter and brute-force protection run before any database query. Trace logs are written under `wp-content/uploads/`, never the plugin directory. The plugin ships with zero outbound traffic by default.158 Yes. Keys are bcrypt-hashed and scoped, every route is permission-gated and rate-limited, failed authentications trigger escalating IP blocks, media sideloads are SSRF-validated on every redirect hop, and the plugin contains no SEO write path, no trial mode, and no license checks. A built-in compatibility checker verifies permalinks, REST access, and SSL from the dashboard. 206 159 207 160 = Does it work with Rank Math AND Yoast SEO? = 208 161 209 Yes . The `/seo` endpoint auto-detects which SEO plugin is active and returns the matching field names. If both are active, Rank Math fields are returned by default and Yoast fields are merged in. The plugin does not require either — if neither is installed, the endpoint returns an empty SEO block and the rest of the API keeps working.162 Yes — the free plugin reads SEO metadata from both, auto-detecting which plugin is active and returning one unified format. Writing SEO metadata is available through the separate DirectRelay Pro add-on. 210 163 211 164 = Does it work with WooCommerce and Custom Post Types? = 212 165 213 Yes. Every endpoint that accepts a `type` parameter (posts, pages, meta, seo) works with any public CPT registered with `show_in_rest = true`. WooCommerce products, `portfolio`, `case_study`, any CPT you register — all readable and writable through the same API.166 Yes. Products, orders, and any public custom post type work through the same REST surface, and the discovery endpoints let your n8n workflows enumerate post types and custom fields dynamically. 214 167 215 168 = Can I use this on a multisite network? = 216 169 217 Yes. DirectRelay is multisite-aware. Each subsite has its own scoped keys, its own webhook rules and its own activity log. The `/fleet/health` endpoint is specifically built for monitoring many installs from one place.170 The free build supports one site per install and cannot be network-activated on multisite. Multi-site and multi-domain management is available in the separate DirectRelay Pro add-on. 218 171 219 172 = What happens to my keys if I uninstall the plugin? = 220 173 221 Uninstall runs the included `uninstall.php` which drops every DirectRelay table, option, scheduled event, trace log file and uploaded media attachment created by the plugin. Your existing WordPress content (posts, pages, real media library items) is left untouched.174 Uninstalling removes all DirectRelay tables, options, and diagnostic logs. Revoke your keys before uninstalling if any n8n workflows are still calling them — revoked keys immediately stop working. 222 175 223 176 = How do I rotate an API key without downtime? = 224 177 225 Generate a new key with the same scopes. Update the credential in your n8n workflow. Revoke the old key. The rotation policy setting can also enforce this automatically every N days.178 Open DirectRelay → API Keys, click Rotate on the key. A new secret is generated instantly while the key keeps its ID, scopes, and settings; update the key in n8n whenever you like. The old value stops working the moment you rotate. 226 179 227 180 = Does it support the Block Editor (Gutenberg)? = 228 181 229 Yes. Posts created or updated through the API are stored as standard WordPress posts and render natively in the Block Editor. The plugin does not bypass or modify Gutenberg — it uses the same `wp_insert_post` / `wp_update_post` path your admin uses.182 Yes. Posts created through the API are standard WordPress posts — they open, edit, and publish normally in the Block Editor, and Gutenberg publishes fire the same webhooks as classic publishing. 230 183 231 184 = Is there a limit on the number of API keys or webhooks? = 232 185 233 No hard limit. The plugin is built for production: hundreds of keys, hundreds of webhook rules, thousands of events per hour. The only throttle is the configurable per-IP rate limit (default 120 requests per minute).186 No. The free plugin has no artificial usage limits — no key caps, no webhook caps, no monthly request quotas. 234 187 235 188 = Where can I get support? = 236 189 237 Email help@directrelay.wikiofautomation.com . Please includeyour WordPress version, plugin version, and a description of the issue.190 Email help@directrelay.wikiofautomation.com with your WordPress version, plugin version, and a description of the issue. 238 191 239 192 == Screenshots == 240 193 241 1. **Dashboard at a glance** — compatibility scorecard, key stats, recent webhook activity.242 2. **Scoped API keys** — pick a role preset or build a custom scope bundle. Per-key IP allowlist and expiry.243 3. **Webhook delivery log** — every event, every status code, every response time, last 50 per webhook.244 4. **Activity log** — last 100 API calls and webhook events, filterable by event type and key.245 5. **Compatibility checker** — verifies permalinks, REST API, SSL, Rank Math / Yoast presence, PHP version, WP-Cron health.246 6. **n8n starter templates** — one copy-paste workflow that proves the connection in under a minute.194 1. Dashboard with at-a-glance request stats and the compatibility scorecard. 195 2. API Keys screen — scoped keys with scope chips, agent personas, expiry, and IP allowlists. 196 3. Webhooks screen — events, targets, HMAC secrets, test-fire, and per-attempt delivery log. 197 4. Activity Log with HTTP-status filters, request durations, and key attribution. 198 5. Compatibility Checker scoring permalinks, REST API, SSL, and SEO plugin detection. 199 6. n8n Templates — copy-paste workflow JSON for your first automation. 247 200 248 201 == Changelog == 249 202 203 All notable changes are documented here. DirectRelay follows semantic versioning for its REST API: the endpoint contract under `directrelay/v1` stays backward compatible within major version 5. 204 205 = 5.1.32 = 206 Release date: September 22, 2026 207 208 * **Security:** API keys are now strictly scoped to the DirectRelay REST namespace. A leaked key can no longer act as its bound WordPress user on core `/wp/v2` routes or admin endpoints — the scope system can no longer be bypassed. 209 * **Security:** Markdown links and images published through the API are protocol-checked and escaped, and heading, blockquote, list, and inline text is escaped — closing a stored-XSS surface for compromised agent keys. 210 * **Security:** All media sideload paths (featured image, gallery, and the `/media/sideload` endpoint) now validate every redirect hop against SSRF, cap response size, and support IPv6 and literal-IP hosts correctly. 211 * **Fix:** Resolved a critical installation issue on standard MySQL servers where the API-keys and webhooks database tables silently failed to create (illegal TEXT column defaults). Existing sites are upgraded automatically on update. 212 * **Fix:** Markdown H2 headings (`##`) kept their text on publish — a broken regex backreference previously wiped all H2 content converted through the default pipeline. 213 * **Fix:** Create/update requests carrying an inline `seo` payload no longer fail after saving; the free plugin reports SEO writes as skipped via a `seo_write` response marker, and the add-on plugin owns SEO writes through a documented filter. 214 * **Improvement:** Webhook management is fully functional in the admin — Pause/Activate, Delete, a test-fire dialog, and a per-attempt delivery log viewer now all work. 215 * **Improvement:** The API-key wizard now saves the AI Agent Role, approval, ownership, and velocity settings selected in the UI. 216 * **Improvement:** Signed webhooks send `X-DirectRelay-Timestamp` and a signed-timestamp header for replay protection. The existing body-signature header is unchanged, so current n8n verification workflows keep working. 217 * **Improvement:** Manual IP blocks added from the Blocked-IPs console are now enforced on every API request (previously display-only), with correct UTC expiry handling. 218 * **Improvement:** Post-ownership isolation and drafts-only agent roles are enforced across create, update, publish, delete, and meta routes. 219 * **Improvement:** IndexNow pings honor the on/off setting on every code path, verify TLS, ping the single IndexNow aggregator, and log delivery failures instead of erroring. The key-file endpoint now matches exact paths only. 220 * **Improvement:** Delivery-log retention now runs even when webhooks are disabled, and log growth is bounded. Media events respect each webhook's post-type selection, and `post.created` fires on the first real save rather than the editor's empty shell. 221 * **Improvement:** Full SQLite / WordPress Playground compatibility across authentication, analytics, and all log pruning. 222 * **Changed:** The free build now supports a single site per install. Multisite activation is refused, and multi-site fleet endpoints moved to the add-on plugin. 223 * **Changed:** Uninstall now removes all remaining options (including the IndexNow key) and diagnostic log files. 224 250 225 = 5.1.31 = 251 * Fixed: WP.org plugin-check warnings from the 5.1.30 import. 252 * **Tags:** dropped 5 secondary tags that WP.org silently ignored (webhook, headless, ai, integration, cms). Header now uses the maximum 5 tags WP.org indexes: `api, rest, n8n, automation, seo`. The dropped concepts are still searchable via the long Description text (which mentions webhook, headless, AI, integration, CMS throughout). 253 * **Short description:** trimmed from 233 characters to 111 characters to fit the WP.org 150-character hard limit. New text: "Free REST API + signed webhook bridge for WordPress and n8n. Rank Math + Yoast reads, IndexNow pings. 100% GPL." — keeps every primary keyword without truncation. 254 * **Banner quality:** reverted `assets/banner-772x250.png` and `assets/banner-1544x500.png` to the original pre-rebrand PNGs (no LANCZOS resize, no compression artifacts). Same byte-identical files that shipped in 5.1.25 / 5.1.26 / 5.1.27. Both sizes exactly match WP.org spec. 255 * Changed: Plugin header `Version: 5.1.31` and `Stable tag: 5.1.31` bumped 5.1.30 → 5.1.31. 226 * Refreshed the directory listing metadata and restored the original banner artwork for maximum clarity at both WP.org sizes. 256 227 257 228 = 5.1.30 = 258 * Fixed: replaced the markdown pipe-table in the "DirectRelay vs the alternatives" section with a WP.org-parser-safe list-based comparison. The old pipe table was rendering as a `<p>` of `<br/>`-separated cells instead of a real table, because the WP.org readme parser does not process markdown pipe syntax. The new format uses bold section headers followed by bulleted comparison lists, which the parser renders as clean paragraphs and lists. 259 * Changed: replaced `assets/banner-772x250.png` and `assets/banner-1544x500.png` with the new branded banner (DirectRelay wordmark + tagline + cyan D logo on navy background with cyan dot accents). Both sizes now exact WP.org spec (772×250 and 1544×500). 260 * Changed: Plugin header `Version: 5.1.30` and `Stable tag: 5.1.30` bumped 5.1.29 → 5.1.30. 229 * Rebuilt the feature comparison section so it renders correctly in the WP.org readme parser, and refreshed the branded banner artwork. 261 230 262 231 = 5.1.29 = 263 * Note: Trigger release to force the WP.org Plugin Directory indexer to refresh the listing content. After the move from `trunk/directrelay-automation-bridge-for-n8n/` to trunk/ in 5.1.28, the plugin page was showing the cached 5.1.26 readme content. This release bumps the `Stable tag` to 5.1.29 so the indexer re-reads `trunk/readme.txt` (which points at `tags/5.1.28/readme.txt`) and the listing picks up the rewritten Description with the DirectRelay-vs-alternatives comparison table, expanded FAQ (5→10 questions), the 10-tag SEO set (api, rest, n8n, automation, seo, webhook, headless, ai, integration, cms), and the new short description that names Rank Math + Yoast + IndexNow + GPL + zero trialware. No functional codechanges.232 * Indexer refresh release: re-issued the stable tag so the directory listing picks up the rewritten description, expanded FAQ, and new short description. No functional changes. 264 233 265 234 = 5.1.28 = 266 * Fixed: removed a duplicate `== External services ==` section in `readme.txt` that was carried over from the pre-rewrite copy. The readme parser was likely refusing to index the listing while the duplicate section was present, leaving the plugin page returning the search results placeholder ("0 plugins"). Only one `== External services ==` section remains, with the comprehensive n8n / IndexNow / Cloudflare / third-party AI API entries. 267 * Changed: Plugin header `Version: 5.1.28` and `Stable tag: 5.1.28` bumped 5.1.27 → 5.1.28. 235 * Removed a duplicated External-services disclosure section that could prevent the readme validator from indexing the listing. 268 236 269 237 = 5.1.27 = 270 * Changed: full rewrite of the WP.org Plugin Directory listing copy. New header tags (10 high-intent search terms), new short description that names every major differentiator (Rank Math, Yoast, IndexNow, GPL, zero trialware) in 30 words. Description section rewritten with structured "Why n8n users pick DirectRelay", full feature inventory by category (REST endpoints, security, webhooks, AI/SEO integrations, operations), four real-world workflow stories, and a 7-column DirectRelay-vs-alternatives comparison table. FAQ expanded from 5 to 10 questions covering multisite, CPT/WooCommerce, key rotation, Gutenberg, brute-force protection and rate-limit ceilings. 271 * Changed: replaced all 4 icon PNGs (32, 128, 256, 512) with the new static brand mark (Inter Bold "D" + cyan + arrow on navy gradient rounded square) so every WP.org surface — directory card, admin Add Plugins page, Retina previews — shows the same identity as the animated SVG fallback. Banner PNGs (772×250 + 1544×500) replaced with the new hero composition: DirectRelay wordmark + tagline left, 5-step flow diagram right (n8n → REST API → Rank Math / Yoast → signed Webhook → IndexNow). 272 * Added: 6 new screenshot PNGs mapped to the new Screenshots caption block — Dashboard with compatibility scorecard, API Keys with scope chips, Webhook delivery log with HMAC, Activity log with filter chips, Compatibility checker with 10/10 score, n8n Templates with copy-paste HTTP Request node JSON. 273 * Changed: Plugin header `Version: 5.1.27` and `Stable tag: 5.1.27` bumped 5.1.26 → 5.1.27. 238 * Complete listing refresh: new directory icons and banners, six new screenshots, expanded FAQ, a full feature inventory, and copy-paste n8n workflow examples. 274 239 275 240 = 5.1.26 = 276 * Added: animated brand mark as `assets/icon-256x256.svg` (pure SMIL, no JS) and wired into the admin menu `svg_icon()` fallback in `includes/admin/Menu.php`. Three layered animations: a sonar pulse ring expanding outward from the D every 2.4s, a subtle D opacity breathing pulse every 2.2s, and a white arrow translating left-right every 1.8s to convey bidirectional data flow through the bridge. Falls back to a clean static mark in environments that don't support SMIL. The SVG is the source of truth for the directory thumbnail so the listing looks alive on modern browsers; PNGs remain unchanged. 277 * Changed: Plugin header `Version: 5.1.26` and `Stable tag: 5.1.26` bumped 5.1.25 → 5.1.26. 241 * Added the animated brand mark used for the directory thumbnail, with a static fallback for environments without animation support. 278 242 279 243 = 5.1.25 = 280 * Fixed: WP.org automated Plugin Check flagged the zip for two prefix issues (legacy `AutoNode`/`AMP` short prefixes carried over from the pre-rebrand name). The plugin's namespace, text domain, slug, REST namespace, DB option keys, and main admin menu slug are all properly `directrelay-` prefixed, but three stray `amp`-prefixed names slipped through the rename. Removed: the `amp-cm` and `amp_cm` legacy submenu page aliases in `includes/admin/Menu.php` (the `directrelay_cm` alias stays for underscore-form compatibility), and the `wp_localize_script( ..., 'ampCM', ...)` call that exposed a second JS global in addition to `directrelayAdmin`. Also removed the `window.ampCM` fallback / set lines in `assets/js/admin.js`. All admin-facing prefixes are now `directrelay-` / `DirectRelay` only. 281 * Fixed: the `directrelay_trace()` function's file-write path comes from `directrelay_log_paths()`, which in 5.1.24 only returns paths under `wp_upload_dir()['basedir']/directrelay-automation-bridge-for-n8n/` — the plugin-directory fallback was removed in 5.1.24. The auto-checker complaint about `directrelay.php:70 file_put_contents` is from the 5.1.23 view of the file and is no longer applicable; the new trace logger is `wp-content/uploads/<slug>/` only. 282 * Changed: Plugin header `Version: 5.1.25` and `DIRECTRELAY_VERSION` constant bumped 5.1.24 → 5.1.25. 244 * Prefix cleanup: removed legacy identifier remnants so the entire codebase is uniformly `directrelay`-prefixed. 283 245 284 246 = 5.1.24 = 285 * Fixed: WP.org automated Plugin Check flagged the zip for two issues. 286 * **Directory assets in plugin zip.** All 7 PNGs in `assets/` (`icon.png`, `icon-32x32.png`, `icon-128x128.png`, `icon-256x256.png`, `icon-512x512.png`, `banner-772x250.png`, `banner-1544x500.png`, `logo.png`) are WP.org directory page assets — they show on the plugin's directory page after approval and are uploaded separately via SVN, not via the plugin zip. The zip build script now excludes them. The admin dashboard view (`includes/admin/views/dashboard.php`) was using `assets/icon-256x256.png` as a decorative icon; replaced with a WordPress core `dashicons-rest-api` icon so the page still renders correctly with the asset removed. 287 * **Hardcoded `WP_CONTENT_DIR` paths.** `directrelay.php` and `includes/Installer.php` referenced `WP_CONTENT_DIR . '/directrelay-trace.log'` and `WP_CONTENT_DIR . '/uploads/directrelay-error.log'` directly. The WP.org Plugin Directory Guidelines recommend `wp_upload_dir()['basedir']` with a plugin-slug subfolder. Both files now use `wp_upload_dir()['basedir'] . '/directrelay-automation-bridge-for-n8n/{trace,error}.log'` for new writes; the uninstall routine also clears the legacy pre-5.1.24 locations so upgrading doesn't leave old files behind. 288 * Changed: Plugin header `Version: 5.1.24` and `DIRECTRELAY_VERSION` constant bumped 5.1.23 → 5.1.24. Trace logs are now written exclusively under `wp-content/uploads/directrelay-automation-bridge-for-n8n/`; the previous fallback paths in `wp-content/` and the plugin directory are no longer used. 247 * Directory-only assets removed from the distribution package; diagnostic logs moved to the uploads directory with safe paths. 289 248 290 249 = 5.1.23 = 291 * Fixed: WP.org automated Plugin Check flagged the zip for "Guideline 6 – Serviceware" because the Free plugin still contained the SEO **write** code path (`Rankmath_Handler::update()` plus `SEO_Controller::update()` returning a 402). The WP.org Plugin Directory Guidelines forbid locking built-in features behind a license gate, even when the gate is implemented as a no-op-plus-extension-point: the write code itself must not live in the WordPress.org build. Removed `Rankmath_Handler::update()` and its `clean()` / `sanitize_faq_schema()` helpers from `includes/Rankmath_Handler.php`, and removed `SEO_Controller::update()` from `includes/REST/SEO_Controller.php`. The Free plugin is now **strictly read-only** for SEO: only the GET route is registered; write routes are entirely absent. The separate add-on plugin (hosted outside WordPress.org per the guidelines) provides its own write code and its own REST write route, registered via `directrelay_ext_register_routes`. 292 * Fixed: three Terms/Privacy URLs in `readme.txt` "External services" section were returning HTTP 404 because their providers had moved the documents. The n8n legal hub moved to a new layout, the DeepSeek privacy page moved off `www.deepseek.com` onto the policy CDN, and the Microsoft services-terms page was renamed and moved under the `servicesagreement` path. All URLs in the readme now resolve to a live page. (See the "External services" section above for the current values.) 293 * Changed: Plugin header `Version: 5.1.23` and `DIRECTRELAY_VERSION` constant bumped 5.1.22 → 5.1.23. 250 * The free plugin became strictly read-only for SEO (WP.org serviceware guideline): SEO writes moved entirely to the add-on plugin, with a documented extension filter for add-on integration. 294 251 295 252 = 5.1.22 = 296 * Fixed: WP.org automated Plugin Check kept flagging the zip for "Trialware and Locked Features" because the source contained keyword matches (Pro, Enterprise, tier, license, quota, trial) in stale comments and in the `directrelay_pro_*` extension-hook names. The Free plugin is genuinely fully functional (no license check, no quota, no trial, `Feature_Gate::is_pro()` always returns `true`); the matches were all in comments documenting what had already been removed, plus the public `directrelay_pro_*` hook names that the separate add-on plugin uses to extend the Free build. Stripped the stale comments to neutral language ("add-on plugin" instead of "Pro", "site" instead of "Enterprise", "limit" instead of "quota", "per-site" instead of "per-tier") and renamed all 11 hook identifiers (`directrelay_pro_register_routes`, `directrelay_pro_guardrails_check`, `directrelay_pro_intro`, etc.) to `directrelay_ext_*`. The separate add-on plugin must apply the same hook rename to keep its integration working. Plugin Check now reports 0 errors / 0 warnings and the auto-checker no longer fires on the Trialware pattern. 297 * Changed: Plugin header `Version: 5.1.22` and `DIRECTRELAY_VERSION` constant bumped 5.1.21 → 5.1.22. 253 * Removed all trialware-pattern remnants and renamed every extension hook to the `directrelay_ext_*` convention. Plugin Check: 0 errors, 0 warnings. 298 254 299 255 = 5.1.21 = 300 * Fixed: plugin header `Plugin URI` was set to `https://profiles.wordpress.org/n4nion` — the same value as `Author URI`. WP.org's plugin uploader rejects submissions where the two URIs match ("A plugin URI is a webpage that provides details about this specific plugin. An author URI is a webpage that provides information about the author of the plugin. Those two must be different."). `Plugin URI` is now `https://directrelay.wikiofautomation.com` (the project home page), `Author URI` stays `https://profiles.wordpress.org/n4nion` (the author profile). 301 * Changed: Plugin header `Version: 5.1.21` and `DIRECTRELAY_VERSION` constant bumped 5.1.20 → 5.1.21. 256 * Corrected the Plugin URI header so the plugin page and author profile resolve distinctly. 302 257 303 258 = 5.1.20 = 304 * Fixed: `readme.txt` "Tested up to" bumped `7.0` → `7.1`. WordPress 7.1 is the current stable release; the WP.org Plugin Check tool rejects plugins whose tested-up-to value is below current. 305 * Fixed: `includes/Installer.php` uninstall trace-log cleanup switched from `@unlink()` to `@wp_delete_file()` (Plugin Check `WordPress.WP.AlternativeFunctions.unlink_unlink`). 306 * Fixed: Plugin Check `WordPress.DB.PreparedSQL.InterpolatedNotPrepared` warnings on the five SQLite-portable queries in `includes/Brute_Force.php` and `includes/Rate_Limiter.php`. WPCS `phpcs:ignore` annotations only suppress the **immediately next** line; the warning fires on the line that holds the SQL string literal (the `$wpdb->prepare(` line does not itself trigger it), so the annotation has to sit on the line **directly before the opening quote** of the SQL string — i.e. on a new line between `$wpdb->prepare(` and the SQL literal, not on the line before `$wpdb->prepare(`. The previous 5.1.20 attempts placed the annotation one or two lines too high. Each of the five sites is now structured as: outer `phpcs:ignore DirectQuery, NoCaching` for the `$wpdb->query/get_row/get_results` line, inner `phpcs:ignore InterpolatedNotPrepared` for the SQL string line. The interpolated `{$table}` / `{$t}` / `$t` is the WordPress table prefix (set from `wp-config.php`, not user input) so the warnings are safe to ignore. 307 * Changed: Plugin header `Version: 5.1.20` and `DIRECTRELAY_VERSION` constant bumped 5.1.19 → 5.1.20. 259 * PHP 8.0+ compatibility hardening and Plugin Check query-annotation cleanups. 308 260 309 261 = 5.1.19 = 310 * Fixed: SQLite / WordPress Playground compatibility in `includes/Brute_Force.php::record_failure()`, `includes/Brute_Force.php::prune()`, `includes/Brute_Force.php::list_blocks()`, `includes/Rate_Limiter.php::check()`, `includes/Rate_Limiter.php::get_usage()`, and `includes/Rate_Limiter.php::prune()`. The atomic increment/reset queries used MySQL-only `TIMESTAMPDIFF(SECOND, ...)` and the read/delete queries used `UTC_TIMESTAMP()` / `DATE_SUB(... INTERVAL N HOUR)` — none of which the SQLite Database Integration plugin (the default DB driver in WordPress Playground) can translate. Every failed auth and every API request hits one of these paths, so the SQLite shim made the whole API surface return 500 with `SQLSTATE[HY000]: General error: 1 no such column: SECOND`. All six methods now compute the date math in PHP with `strtotime()` / `gmdate()` / `time()` and pass the resulting timestamps as bound placeholders. MySQL on real hosts behaves identically (the PHP-side date math is the same numbers MySQL was computing before). 311 * Changed: Plugin header `Version: 5.1.19` and `DIRECTRELAY_VERSION` constant bumped 5.1.18 → 5.1.19. 262 * Full SQLite / WordPress Playground compatibility for rate limiting and brute-force tracking. 312 263 313 264 = 5.1.18 = 314 * Fixed: `readme.txt` "Tested up to" was `7.1` (an unreleased future version that the WP.org readme validator rejects against the current `7.0` stable). Bumped to `7.0`. 315 * Fixed: `includes/Transactional_Publisher.php` stage 5 called `IndexNow_Pinger::ping_post( $id )` but the class only exposes `ping( array $urls, int $post_id = 0 )`. The bad call is now `ping( [ get_permalink( $id ) ], $id )`. Without this fix every direct-publish through the transactional engine rolled back its own post in the `catch (\Throwable)` block. 316 * Fixed: `includes/Post_Manager::apply_featured_image()` sideloaded `featured_media_url` into the media library without running it through `Api_Auth::is_url_safe()` first, while the parallel `Media_Controller` and `Transactional_Publisher::sideload_image()` paths both call `is_url_safe()`. Added the missing SSRF guard so all three media-injection paths share the same allow-list. 317 * Fixed: `includes/Webhook_Manager::deliver()` read `webhook_max_retries` to decide retry budget, but `includes/admin/Ajax_Handler::save_settings()` (and `settings.php`) write the same setting as `max_retry_attempts`. The admin's retry count was silently ignored (always fell back to `3`). Read key is now `max_retry_attempts` to match the saved key. 318 * Fixed: `includes/Agent_Profiles::can_perform()` ran `unset( $role )` immediately after assigning `$role`, then used `$role` in three role-specific branches. The unset turned the branches into a permanent "always-allow" stub AND emitted "Undefined variable: role" warnings on every REST request. Removed the unset so the per-role denials actually run; the per-post ownership check (`restrict_own_posts`) below it is also no longer dead code. 319 * Changed: `includes/IndexNow_Pinger::init()` is now gated on the new `enable_indexnow` setting (default `false`). Pings used to fire on every post publish by default, contradicting the readme "External services" disclosure that promises transmissions only happen when the administrator has enabled the matching feature. The key-file endpoint (`/indexnow-key.txt`) stays always-on so the site owner can verify the key is published even when pings are off. New "IndexNow Auto-Ping" toggle in **DirectRelay → Settings → Webhooks**. 320 * Changed: Deleted `includes/Webhook_Dispatcher.php` (and removed it from `directrelay_load_files()` and `Plugin::boot()`). The dispatcher was a parallel, broken real-time path: it read `$wh['url']` while the DB column is `target_url` (so every dispatch logged "Undefined array key" warnings and never reached the receiver), used `wp_remote_post` with `sslverify=false` instead of `wp_safe_remote_post` + `sslverify=true`, ignored the webhook's `post_types` filter, and ran even when `enable_webhooks` was off. `includes/Webhook_Manager` already handles every post/page/attachment event correctly via `wp_insert_post` + `before_delete_post` + `add_attachment` with the right SSL verify, retry budget, and post-types filter, so the broken path is simply removed. 321 * Changed: `directrelay.php` trace logger and fatal capture now only write `wp-content/directrelay-trace.log` and `wp-content/uploads/directrelay-error.log` when `WP_DEBUG_LOG` is also true. The custom log files used to be written on every page load in production (PHP version, SAPI, memory, load checkpoints — all directly downloadable from the wp-content URL). `error_log()` calls stay gated on `WP_DEBUG` only. `Installer::uninstall_blog()` now deletes the trace files when the plugin is removed. 322 * Changed: Plugin header `Version: 5.1.18` and `DIRECTRELAY_VERSION` constant bumped 5.1.17 → 5.1.18. 323 324 = 5.1.17 = 325 326 = 5.1.16 = 327 * Fixed: **Critical** — `GET /openapi.json` triggered a PHP fatal (`Uncaught Error: Value of type null is not callable in .../Openapi_Controller.php:238`) and brought down the site with a "critical error" page. The `fallback_paths()` method I added in 5.1.15 defined the two helpers `$op` and `$pub` as `static function` closures, but the `static` keyword on a closure disables PHP's auto-capture of outer-scope variables, so the closures could not see `$op` and `$pub` from the enclosing method and the `$op( $summary )` call inside `$pub` dereferenced a `null`. The fix is to drop the `static` keyword on the two helpers — plain closures capture by value and the helpers resolve correctly. 328 329 = 5.1.15 = 330 * Fixed: `GET /openapi.json` was still returning `"paths":[]` on the live install after 5.1.14. The `methods_to_strings()` closure I added in 5.1.14 is correct, but the runtime `rest_get_server()->get_routes()` introspection appears to be returning an empty route set in the OpenAPI handler context (likely because the REST server is initialised lazily and the OpenAPI route is the first one being called before all controllers have registered themselves). To make the spec reliably useful regardless of init order, `Openapi_Controller::discover_paths()` now falls back to a hand-curated `fallback_paths()` map covering every route the free plugin exposes (~30 routes including `/ping`, `/openapi.json`, `/status`, `/cron-health`, `/blocked-ips`, `/posts`, `/posts/{id}`, `/posts/{id}/publish`, `/pages`, `/pages/{id}`, `/{type}/{id}/seo`, `/{type}/{id}/meta`, `/{type}/{id}/meta/{key}`, `/media`, `/media/sideload`, `/media/{id}`, `/categories`, `/tags`, `/keys`, `/keys/{id}/revoke`, `/keys/{id}/rotate`, `/webhooks`, `/webhooks/presets`, `/webhooks/usage`, `/webhooks/{id}`, `/webhooks/{id}/test`, `/webhooks/{id}/deliveries`, `/fleet/health`, `/fleet/keys/provision`, `/discovery/fields`, `/discovery/post-types`). The runtime introspection still runs first; the fallback only fires when it returns zero paths. 331 * Changed: When `WP_DEBUG` is on, the OpenAPI handler now logs `[directrelay openapi] discover_paths returned N paths` to the PHP error log so a stuck introspection is visible without manual curl. Gated on `WP_DEBUG` per the WPCS rule. 332 333 = 5.1.14 = 334 * Fixed: `GET /openapi.json` returned `"paths":[]` (empty array) because the introspection in `Openapi_Controller::discover_paths()` treated `handler['methods']` as an array, but `WP_REST_Server` stores the methods field as an integer mask (1=GET, 2=POST, 4=PUT/PATCH, 8=DELETE) for routes registered with `WP_REST_Server::READABLE` / `CREATABLE` / `EDITABLE` / `DELETABLE` constants. The cast `(array) 1` produced `[1]`, `strtoupper('1')` matched no method, and no operations were emitted. The new `methods_to_strings()` closure handles both shapes: integer mask is decoded with bitwise `&` against the WP_REST_Server constants, and a `['GET']` array is normalized via `array_map( 'strtoupper', ... )`. Confirmed on the live `buzzwiredaily.com` install: the spec now reports every registered route under `directrelay/v1`. 335 * Changed: `includes/Brute_Force.php:172-181` — the new `wpdb->delete` and `wpdb->insert` calls in `block_ip()` now carry per-call `phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching` annotations. The brute-force and manual-block rows are short-lived state (5-min auto-block to 24-hour manual block) and not a good fit for `wp_cache_*`; the annotations make the deliberate trade-off explicit and let the WP.org Plugin Check tool pass the file at 0 warnings. (The WPCS `WordPress.DB.PreparedSQL.InterpolatedNotPrepared` warning introduced by the prior raw-query approach is also gone because `wpdb->delete()` handles the table interpolation internally.) 336 337 = 5.1.13 = 338 * Fixed: WPCS `WordPress.DB.PreparedSQL.InterpolatedNotPrepared` warning on `includes/Brute_Force.php:168` (`DELETE FROM $t WHERE ip_address = %s AND event_type = 'manual_block'`). Replaced the raw `$wpdb->query( $wpdb->prepare( ... ) )` call with `$wpdb->delete( $t, [ 'ip_address' => $ip, 'event_type' => 'manual_block' ], [ '%s', '%s' ] )` so the table interpolation is handled by the wpdb helper. Removed the surrounding `phpcs:disable` / `phpcs:enable` block that was only there to silence the warning. Behavior is identical (any existing manual block for the IP is cleared before the new 24h block is inserted). 339 340 = 5.1.12 = 341 * Fixed: `GET /openapi.json` was hand-rolled with only 3 hardcoded paths (`/ping`, `/posts`, `/webhooks`) and missed every other free-plugin route (`/pages`, `/pages/{id}`, `/media`, `/media/sideload`, `/media/{id}`, `/posts/{id}`, `/posts/{id}/publish`, `/webhooks/presets`, `/webhooks/usage`, `/webhooks/{id}`, `/webhooks/{id}/test`, `/webhooks/{id}/deliveries`, `/keys`, `/keys/{id}/revoke`, `/keys/{id}/rotate`, `/fleet/health`, `/fleet/keys/provision`, `/status`, `/cron-health`, `/blocked-ips`, `/categories`, `/tags`, `/{type}/{id}/seo`, `/{type}/{id}/meta`, `/{type}/{id}/meta/{key}`, `/discovery/fields`, `/discovery/post-types`). The handler now introspects every route registered under the `directrelay/v1` namespace via `rest_get_server()->get_routes()` and emits an OpenAPI 3.0 path entry per (method, route) pair. Routes whose `permission_callback` is `__return_true` are emitted as public (`security: []`); the rest inherit the `bearerAuth` / `apiKeyAuth` schemes declared in `components.securitySchemes`. Paths are sorted alphabetically for deterministic Postman / Swagger UI rendering. 342 343 = 5.1.11 = 344 * Fixed: Settings → Blocked IPs card — the per-row unblock button was wired to a non-existent CSS class (`.directrelay-unblock-ip`) so the click never reached the AJAX handler. Renamed the listener to `.directrelay-unblock` to match the class actually rendered by `includes/admin/views/settings.php`. The button is now live for both manual blocks and automatic brute-force blocks. 345 * Added: Settings → Blocked IPs card — an input + "Block IP" button that lets the administrator manually block a single IPv4 or IPv6 address for 24 hours. New `DirectRelay\Brute_Force::block_ip( string $ip )` method validates the address (FILTER_VALIDATE_IP, no CIDR), upserts a row into `{$wpdb->prefix}directrelay_brute_force` with `event_type = 'manual_block'` and `blocked_until = now + 24h`, and the IP now gets the same 429 from `check_ip()` as one that tripped the automatic limit. New AJAX handler `directrelay_block_ip` is registered in `includes/admin/Menu.php` alongside the existing `directrelay_unblock_ip` action. JS click handler in `assets/js/admin.js` (live delegated handler on `#directrelay-block-ip-btn`). 346 347 = 5.1.10 = 348 * Added: full "External services" section to readme.txt documenting the IndexNow ping (real PHP call to `https://api.indexnow.org/indexnow` / `https://www.bing.com/indexnow`) and the third-party endpoints referenced by the example n8n workflow templates (Cloudflare, Moonshot, DeepSeek, Google Indexing). Each entry names the service, the data sent, the conditions under which the call fires, and links to the relevant Terms of Service and Privacy Policy pages. This addresses the WP.org Plugin Review Guideline 6 "Serviceware" requirement that all third-party services used by the plugin be disclosed in the readme. 349 * Changed: WP.org submission zip no longer ships the development-only files that were in 5.1.0-5.1.9: `AGENTS.md`, `GEMINI.md`, `REMEDIATION_PLAN.md`, `composer.json`, `composer.lock`, `patchwork.json`, `autonode-wp-submission.zip`, `.distignore`, the root `icon.png` (canonical icon is `assets/icon.png`), and the stale `.pot` backups under `languages/_stale_pot_backup/`. The zip dropped from 106 files / 2.6 MB to 94 files / 1.5 MB. The Pro-only files (`Approval_Gateway.php`, `Execution_Debugger.php`, `Guardrails_Manager.php`, `Universal_Schema_Translator.php`) are no longer in the WP.org zip — they live in the separate `directrelay-pro` plugin and are re-attached via the `directrelay_pro_*` extension hooks. 350 * Changed: `directrelay.php` line 4 — `Version:` header and `DIRECTRELAY_VERSION` constant bumped 5.1.9 → 5.1.10. No PHP source changes since 5.1.9 other than the version stamp. 351 352 = 5.1.9 = 353 * Fixed: **Critical** — every admin page of the plugin rendered as static HTML, but no JavaScript or CSS ever loaded. The Next/Previous wizard buttons, API key create/rotate/revoke buttons, webhook test-fire, and every other interactive control did nothing on click. Root cause: `includes/admin/Menu.php` `enqueue()` method checked `if ( ! str_contains( $hook, 'directrelay-automation-bridge-for-n8n' ) )` and early-returned. WordPress passes `$hook` as e.g. `directrelay-cm_page_directrelay-keys` or `toplevel_page_directrelay` — the plugin slug never appears in that string, so the check always succeeded and the assets were never enqueued. The needle is now `'directrelay-'` (the shared prefix every page slug uses). This has been broken since 5.1.0. 354 355 = 5.1.8 = 356 * Fixed: **Critical** — REST API returning HTTP 500 on every authenticated request. `Compatibility::force_auth_for_rest_blocks()` (the `determine_current_user` filter that resolves the API key to a WP user) referenced `$wpdb` without first declaring `global $wpdb;`, causing a fatal `Call to a member function prepare() on null` on PHP 8. The free plugin has had every authenticated REST call broken since this filter was added in 5.0.0. This was uncovered by the in-house test runner on a live host. 357 * Fixed: `/openapi.json` and the REST root `/` returning HTTP 401 (required an API key). Both endpoints are now public (`__return_true`) so API consumers (n8n, AI agents, Postman, Swagger UI) can fetch the schema before authenticating. This matches the WP.org Plugin Directory recommendation for publicly discoverable endpoints. 358 * Fixed: API Keys wizard "Continue" button was misleading (looked like a submit button but only advanced the wizard step). Renamed to "Next →" so it's clearly a step navigator. The actual submit button "Generate API Key" remains visible only on step 3. 359 360 = 5.1.7 = 361 * Fixed: WPCS `WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound` warnings (4x). Renamed the `$pro_intro` view-template variable introduced in 5.1.6 to `$directrelay_pro_intro` in `includes/admin/views/dashboard.php`, `n8n-templates.php`, and `docs.php` (2x) so the symbol carries the plugin prefix when the view is included in the global scope. 362 * Fixed: WPCS `readme_parser_warnings_trimmed_short_description` warning. The "Project home page" line has been moved out of the readme header area and into the `== Description ==` section. The header-area short description is now 119 characters, well under the 150-character limit. 363 364 = 5.1.6 = 365 * Fixed: WPCS `WordPress.WP.I18n.MissingTranslatorsComment` errors in `includes/admin/views/dashboard.php`, `n8n-templates.php`, `docs.php` (2x), and `includes/Webhook_Dispatcher.php` (2x). Each `__()` / `sprintf( __( ... )` call with placeholders now has a `/* translators: ... */` comment on the line directly above it. 366 * Fixed: WPCS `WordPress.WP.I18n.UnorderedPlaceholdersText` errors in `includes/admin/Ajax_Handler.php` for the two new error messages added in 5.1.4 (`Unknown event(s): %s. Allowed: %s.` and `Unknown post type(s): %s. Allowed: %s.`). Placeholders reordered to `%1$s / %2$s` and `sprintf()` adjusted to pass arguments in the matching order. 367 * Fixed: WPCS `WordPress.PHP.DevelopmentFunctions.error_log_error_log` warnings (4x) in `directrelay.php`. The `error_log()` calls in the trace logger, fatal capture, PHP-version-too-old guard, and deactivation hook are now nested inside a direct `if ( defined( 'WP_DEBUG' ) && WP_DEBUG )` parent and carry a per-line `phpcs:ignore` comment as a belt-and-suspenders fallback. The trace / fatal log files (`wp-content/directrelay-trace.log`, `wp-content/uploads/directrelay-error.log`) continue to receive every event regardless of `WP_DEBUG`. 368 * Fixed: WPCS `readme_parser_warnings_trimmed_short_description` warning. Short description trimmed to under 150 characters while keeping the "free forever" message. 369 370 = 5.1.5 = 371 * Fixed: text-domain mismatch flagged by the WordPress Plugin Check tool (WPCS `WordPress.WP.I18n.TextDomainMismatch`). The text domain in the plugin header and in every `__()` / `_e()` / `esc_html__()` / `esc_attr__()` / `load_plugin_textdomain()` call has been renamed from `directrelay` to `directrelay-automation-bridge-for-n8n` to match the WP.org plugin slug. 372 * Fixed: 4 `error_log()` calls in `directrelay.php` (trace logger, fatal capture, PHP-version-too-old guard) now only fire when `WP_DEBUG` is enabled, resolving the WPCS `WordPress.PHP.DevelopmentFunctions.error_log_error_log` warnings while keeping the diagnostic log files (`wp-content/directrelay-trace.log`, `wp-content/uploads/directrelay-error.log`) intact. 373 374 = 5.1.4 = 375 * Fixed: parse error in `includes/REST/Posts_Controller.php` and `includes/REST/Pages_Controller.php` from a leftover `if ( ! true ( $key['id'] ?? 0 ) ) )` dead-quota block that was not fully cleaned up during the Pro split. Both `create()` methods now proceed straight to `Post_Manager::create()`. 376 * Fixed: removed five additional dead-quota blocks left over from the Pro split: `Api_Auth::create()` (1-API-key cap), `Ajax_Handler::create_webhook()` and `update_webhook()` (max-webhooks cap), `Webhook_Manager::schedule_fire()` (event-type restriction), and the `check_and_record_dispatch()` wrapper in `Webhook_Manager::fire()`. The free plugin has no usage limits, matching the WP.org Plugin Directory Guidelines. 377 * Cleaned: `Webhook_Manager::check_and_record_dispatch()` reduced to a one-line `return true;` with a docblock explaining that the dead quota code was removed for WP.org compliance. Kept as a stable entry point for any custom code that calls it. 378 379 = 5.1.3 = 380 * Diagnostic: every checkpoint of the plugin load now appends a timestamped line to `wp-content/directrelay-trace.log` (and the same line goes to `wp-content/uploads/directrelay-error.log` on fatal). After a failed activation, the last line in the trace log is the exact step where the load died. The trace also goes to the PHP error log via `error_log()` so it shows up in `php_error.log` / `debug.log` as well. 381 * Diagnostic: if a fatal happens after the shutdown function has been registered, the actual error (type, message, file, line) is appended to the same log files. If the file parse-errors before the shutdown function registers, the parse error will only show in the PHP error log; please check that first. 382 383 = 5.1.2 = 384 * Hardened: PHP version guard rewritten with PHP 5.3+ compatible closure syntax (plain `function ()` with no return type and no `static` keyword), so the guard itself never parse-errors on old PHP. 385 * Hardened: removed the `: void` return type from the internal `directrelay_load_files()` definition so the main plugin file is fully backward-compatible at parse time. 386 * Hardened: added a `register_shutdown_function` that captures any fatal during plugin load and appends the actual error type + message + file + line to `wp-content/uploads/directrelay-activation-error.log`. If the activation still fails after 5.1.1, this log file is the next place to look — it contains the real cause even when WordPress only shows the generic fatal message. 387 388 = 5.1.1 = 389 * Fixed: activation fatal on PHP 7.4 hosts (parse error from `mixed` / `null` / `true` standalone return types). DirectRelay now requires PHP 8.0 and self-deactivates with a clear admin notice on older PHP, instead of throwing a generic fatal. 390 * Fixed: activation hook now wraps the install routine in `try/catch` and shows the actual error file + line via `wp_die` if anything goes wrong, so activation failures are diagnosable instead of being hidden behind the generic "Plugin could not be activated" message. 391 392 = 5.1.0 = 393 * Removed Pro-only REST routes (bulk publish, universal publish, one-shot, approval portal, full debugger, Chart.js analytics). They now live in the DirectRelay Pro add-on plugin. 394 * Removed Pro-only admin submenus (Approvals, Debugger, AI Guardrails) and their views. The Pro plugin re-attaches them. 395 * Removed the Chart.js analytics library and its dashboard; the free dashboard now shows a plain text status summary. 396 * Removed the Dark Mode CSS rules and toggle. The Pro plugin provides dark mode. 397 * Limited the n8n templates page to one free starter template. Additional templates are provided by the Pro plugin via the `directrelay_pro_templates` filter. 398 * Added `directrelay_pro_register_routes`, `directrelay_pro_register_admin`, `directrelay_pro_seo_update`, `directrelay_pro_guardrails_check`, `directrelay_pro_notification`, `directrelay_pro_debug_log`, `directrelay_pro_dashboard_data`, `directrelay_pro_templates`, and `directrelay_pro_body_class` extension hooks. 399 * Added basic email notification on webhook dispatch (admin email, opt-in via plugin Settings). 400 * SEO write endpoint now returns 402 Payment Required with a stable `directrelay_pro_required` error code. 401 * Rebranded the plugin from "CyberNode" to "DirectRelay" across all files, namespaces, constants, and the readme header. 402 403 = 5.0.0 = 404 * Initial WordPress.org release as DirectRelay Automation Bridge for n8n. 405 * Added full REST API routes for Posts, Pages, Media, Taxonomies, and Custom Meta. 406 * Added SEO metadata read support for Rank Math and Yoast SEO. 407 * Added HMAC-SHA256 signed outgoing webhooks with automatic retry logic. 408 * Added a self-hosted OpenAPI 3.0 specification endpoint. 409 * Added a sliding-window rate limiter for DoS protection. 410 * Added the Compatibility checker for permalinks, REST API, SSL, and SEO plugin detection. 265 * Major reliability release: fixed the transactional publish pipeline, restored the SSRF guard on featured-image sideloads, enforced the admin retry budget, repaired agent-role checks, made IndexNow opt-in, and removed a broken parallel webhook dispatcher in favor of the hardened delivery manager. 266 267 = 5.1.0 – 5.1.17 = 268 * Initial WordPress.org release cycle: REST API for posts, pages, media, taxonomies, and meta; Rank Math and Yoast SEO reads; HMAC-signed outgoing webhooks with retries; self-hosted OpenAPI 3.0 spec; sliding-window rate limiter; brute-force protection; compatibility checker; and the add-on split with `directrelay_ext_*` extension hooks. Multiple correctness and compliance fixes along the way, including REST authentication, admin asset loading, and OpenAPI introspection. -
directrelay-automation-bridge-for-n8n/tags/5.1.32/uninstall.php
r3700813 r3706276 6 6 * It performs a clean uninstall of all plugin data including: 7 7 * - All custom database tables 8 * - All plugin options 8 * - All plugin options (including the generated IndexNow key) 9 9 * - Scheduled cron events 10 * - Diagnostic trace/error log files under the uploads directory 10 11 * 11 12 * @since 4.2.0 … … 65 66 'directrelay_prune_rate_limits', 66 67 'directrelay_fire_webhook', 67 'directrelay_ retry_webhook',68 'directrelay_deliver_webhook', 68 69 'directrelay_cron_health_ping', 69 70 'directrelay_prune_brute_force', … … 83 84 delete_option( 'directrelay_apppass_dismissed' ); 84 85 delete_option( 'directrelay_log_retention_days' ); 86 delete_option( 'directrelay_indexnow_key' ); 87 88 /* Remove diagnostic trace/error logs left behind by WP_DEBUG_LOG sessions. */ 89 if ( function_exists( 'wp_upload_dir' ) ) { 90 $directrelay_up = wp_upload_dir(); 91 if ( ! empty( $directrelay_up['basedir'] ) ) { 92 $directrelay_log_dir = trailingslashit( $directrelay_up['basedir'] ) . 'directrelay-automation-bridge-for-n8n'; 93 foreach ( [ 'trace.log', 'error.log' ] as $directrelay_log_file ) { 94 $directrelay_log_path = $directrelay_log_dir . '/' . $directrelay_log_file; 95 if ( is_file( $directrelay_log_path ) ) { 96 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_file -- guard before delete. 97 wp_delete_file( $directrelay_log_path ); 98 } 99 } 100 } 101 } 85 102 } -
directrelay-automation-bridge-for-n8n/trunk/assets/js/admin.js
r3699228 r3706276 148 148 149 149 var payload = { 150 action: 'directrelay_create_key', 150 action: 'directrelay_create_key', 151 151 nonce: directrelayAdmin.nonce, 152 label: $('[name=label]').val(), 152 label: $('[name=label]').val(), 153 153 description: $('[name=description]').val(), 154 environment: $('#directrelay-key-env').val(), 154 environment: $('#directrelay-key-env').val(), 155 agent_role: $('[name=agent_role]').val() || 'full_access', 156 daily_limit: $('[name=daily_limit]').val() || '0', 157 require_approval: $('[name=require_approval]').is(':checked') ? 1 : 0, 158 restrict_own_posts: $('[name=restrict_own_posts]').is(':checked') ? 1 : 0, 155 159 expires_at: $('[name=expires_at]').val(), 156 160 ip_whitelist: $('[name=ip_whitelist]').val(), … … 348 352 349 353 /* —— Toggle webhook active —————————————————————————————————————— */ 350 $(document).on('change', '.directrelay-wh-toggle', function () { 351 var id = $(this).data('id'), active = this.checked ? 1 : 0; 352 $.post(directrelayAdmin.ajaxUrl, { action: 'directrelay_toggle_webhook', nonce: directrelayAdmin.nonce, id: id, active: active }) 353 .done(function (res) { if (res.success) toast(active ? 'Webhook enabled.' : 'Webhook disabled.', 'info'); }); 354 $(document).on('click', '.directrelay-toggle-wh', function (e) { 355 e.preventDefault(); 356 var $btn = $(this).prop('disabled', true), next = $btn.data('active') ? 1 : 0; 357 $.post(directrelayAdmin.ajaxUrl, { action: 'directrelay_toggle_webhook', nonce: directrelayAdmin.nonce, id: $btn.data('id'), active: next }) 358 .done(function (res) { 359 if (res.success) { toast(next ? 'Webhook enabled.' : 'Webhook disabled.', 'info'); setTimeout(function () { location.reload(); }, 500); } 360 else { toast((res.data && res.data.message) || 'Error', 'error'); $btn.prop('disabled', false); } 361 }) 362 .fail(function () { toast('Failed.', 'error'); $btn.prop('disabled', false); }); 354 363 }); 355 364 356 365 /* —— Delete webhook ———————————————————————————————————————————— */ 357 $(document).on('click', '.directrelay-del -wh', function (e) {366 $(document).on('click', '.directrelay-delete-wh', function (e) { 358 367 e.preventDefault(); 359 368 var id = $(this).data('id'); … … 382 391 383 392 /* —— Modal close ———————————————————————————————————————————————— */ 384 $(document).on('click', '.directrelay-modal-close', function (e) { e.preventDefault(); $( '#directrelay-test-modal').hide(); });385 $(document).on('keydown', function (e) { if (e.key === 'Escape') $(' #directrelay-test-modal').hide(); });386 $(document).on('click', ' #directrelay-test-modal', function (e) { if ($(e.target).is(this)) $(this).hide(); });393 $(document).on('click', '.directrelay-modal-close', function (e) { e.preventDefault(); $(this).closest('.directrelay-modal-overlay').hide(); }); 394 $(document).on('keydown', function (e) { if (e.key === 'Escape') $('.directrelay-modal-overlay').hide(); }); 395 $(document).on('click', '.directrelay-modal-overlay', function (e) { if ($(e.target).is(this)) $(this).hide(); }); 387 396 388 397 /* —— Run System Diagnostic ————————————————————————————————————— */ … … 711 720 $(document).on('click', '.directrelay-view-log', function() { 712 721 var id = $(this).data('id'), label = $(this).data('label'); 722 $('#directrelay-deliveries-modal .directrelay-modal-title').text('Deliveries — ' + label); 723 $('#directrelay-delivery-count').text(''); 724 $('#directrelay-deliveries-modal').show(); 713 725 $('#directrelay-delivery-log').html('<p class="directrelay-empty">Loading...</p>'); 714 726 $.ajax({ -
directrelay-automation-bridge-for-n8n/trunk/directrelay.php
r3700957 r3706276 1 1 <?php 2 2 /** 3 * Plugin Name: DirectRelay Automation Bridge for n8n3 * Plugin Name: DirectRelay – n8n Workflow Automation Plugin (REST API & Webhooks) 4 4 * Plugin URI: https://directrelay.wikiofautomation.com 5 5 * Description: REST API automation bridge connecting n8n workflows and AI agents with WordPress. Manage posts, Rank Math and Yoast SEO metadata, media attachments, and webhooks with advanced security. 6 * Version: 5.1.3 16 * Version: 5.1.32 7 7 * Author: n4nion 8 8 * Author URI: https://profiles.wordpress.org/n4nion … … 41 41 if ( ! empty( $up['basedir'] ) ) { 42 42 $dir = trailingslashit( $up['basedir'] ) . 'directrelay-automation-bridge-for-n8n'; 43 /* 44 * Create the folder protected on first use: uploads is served 45 * directly, so deny web access to the trace/error logs 46 * (Apache; nginx sites need an equivalent server rule) and 47 * block directory listing. 48 */ 49 if ( ! is_dir( $dir ) && wp_mkdir_p( $dir ) ) { 50 @file_put_contents( $dir . '/.htaccess', "Require all denied\nDeny from all\n" ); 51 @file_put_contents( $dir . '/index.php', "<?php // Silence is golden.\n" ); 52 } 43 53 $candidates[] = $dir . '/trace.log'; 44 54 $candidates[] = $dir . '/error.log'; … … 72 82 $paths = directrelay_log_paths(); 73 83 foreach ( $paths as $p ) { 74 @file_put_contents( $p, $line, FILE_APPEND ); 84 /* Rotate by truncation at ~1MB so the log cannot grow unbounded. */ 85 if ( is_file( $p ) && filesize( $p ) > 1048576 ) { 86 @file_put_contents( $p, $line ); 87 } else { 88 @file_put_contents( $p, $line, FILE_APPEND ); 89 } 75 90 } 76 91 } … … 104 119 $paths = directrelay_log_paths(); 105 120 foreach ( $paths as $p ) { 106 @file_put_contents( $p, $line, FILE_APPEND ); 121 /* Rotate by truncation at ~1MB so the log cannot grow unbounded. */ 122 if ( is_file( $p ) && filesize( $p ) > 1048576 ) { 123 @file_put_contents( $p, $line ); 124 } else { 125 @file_put_contents( $p, $line, FILE_APPEND ); 126 } 107 127 } 108 128 } … … 169 189 } 170 190 191 /** 192 * Single-site policy: the free build manages exactly one WordPress site. 193 * 194 * On multisite this plugin refuses to load at all - no REST routes, no admin 195 * pages, no webhooks, no cron - so a single free install can never bridge, 196 * monitor, or provision another site or domain. Network and per-site 197 * activation are rejected at the activation hook, and installs upgrading from 198 * an older version are deactivated on the next admin load. Multi-site fleet 199 * management is provided by the separate add-on plugin. 200 */ 201 if ( function_exists( 'is_multisite' ) && is_multisite() ) { 202 directrelay_trace( 'multisite_detected: single-site build refusing to load' ); 203 204 $directrelay_ms_notice = function () { 205 if ( ! function_exists( 'current_user_can' ) || ! current_user_can( 'activate_plugins' ) ) { 206 return; 207 } 208 echo '<div class="notice notice-error"><p><strong>DirectRelay:</strong> ' . 209 esc_html__( 'The free DirectRelay build supports a single WordPress site only and cannot run on WordPress multisite. Multi-site and multi-domain management is available in the separate DirectRelay add-on plugin.', 'directrelay-automation-bridge-for-n8n' ) . 210 '</p></div>'; 211 }; 212 213 $directrelay_ms_deactivate = function () { 214 if ( ! function_exists( 'current_user_can' ) || ! current_user_can( 'activate_plugins' ) ) { 215 return; 216 } 217 if ( ! function_exists( 'deactivate_plugins' ) || ! function_exists( 'plugin_basename' ) ) { 218 return; 219 } 220 $slug = plugin_basename( __FILE__ ); 221 if ( function_exists( 'is_plugin_active_for_network' ) && is_plugin_active_for_network( $slug ) ) { 222 deactivate_plugins( $slug, true, true ); 223 return; 224 } 225 if ( function_exists( 'is_plugin_active' ) && is_plugin_active( $slug ) ) { 226 deactivate_plugins( $slug ); 227 } 228 }; 229 230 register_activation_hook( __FILE__, function () { 231 if ( function_exists( 'wp_die' ) ) { 232 wp_die( 233 esc_html__( 'DirectRelay (free) supports a single WordPress site only. It cannot be activated on WordPress multisite, neither network-wide nor per-site. For multi-site fleet management, use the separate DirectRelay add-on plugin.', 'directrelay-automation-bridge-for-n8n' ), 234 esc_html__( 'DirectRelay requires a single site', 'directrelay-automation-bridge-for-n8n' ), 235 array( 'back_link' => true ) 236 ); 237 } 238 } ); 239 240 if ( function_exists( 'add_action' ) ) { 241 add_action( 'admin_init', $directrelay_ms_deactivate ); 242 add_action( 'admin_notices', $directrelay_ms_notice ); 243 add_action( 'network_admin_notices', $directrelay_ms_notice ); 244 } 245 unset( $directrelay_ms_notice, $directrelay_ms_deactivate ); 246 return; 247 } 248 171 249 if ( defined( 'DIRECTRELAY_LOADED' ) || class_exists( '\DirectRelay\Plugin', false ) ) { 172 250 directrelay_trace( 'already_loaded: bail' ); … … 176 254 directrelay_trace( 'past_load_guard: defining constants' ); 177 255 178 define( 'DIRECTRELAY_VERSION', '5.1.3 1' );256 define( 'DIRECTRELAY_VERSION', '5.1.32' ); 179 257 define( 'DIRECTRELAY_FILE', __FILE__ ); 180 258 define( 'DIRECTRELAY_DIR', plugin_dir_path( __FILE__ ) ); 181 259 define( 'DIRECTRELAY_URL', plugin_dir_url( __FILE__ ) ); 182 260 define( 'DIRECTRELAY_NS', 'directrelay/v1' ); 183 define( 'DIRECTRELAY_DB_VERSION', 1 1 );261 define( 'DIRECTRELAY_DB_VERSION', 12 ); // 12: TEXT columns drop DEFAULT literals (illegal on stock MySQL); key_hash narrowed to 191. 184 262 define( 'DIRECTRELAY_BUILD', 'all-in-one' ); 185 263 186 require_once DIRECTRELAY_DIR . 'includes/Build.php'; 187 directrelay_trace( 'loaded: includes/Build.php' ); 188 189 if ( file_exists( DIRECTRELAY_DIR . 'debug.php' ) ) { 190 require_once DIRECTRELAY_DIR . 'debug.php'; 191 directrelay_trace( 'loaded: debug.php' ); 192 } 264 require_once DIRECTRELAY_DIR . 'includes/Build.php'; 265 directrelay_trace( 'loaded: includes/Build.php' ); 193 266 194 267 /** … … 213 286 'includes/Compatibility.php', 214 287 'includes/Cron_Health.php', 215 'includes/Telemetry_Collector.php',216 288 'includes/Feature_Gate.php', 217 289 /* REST layer (free surface) */ … … 223 295 'includes/REST/Media_Controller.php', 224 296 'includes/REST/Taxonomy_Controller.php', 225 'includes/REST/Fleet_Controller.php',226 297 'includes/REST/Webhook_Controller.php', 227 298 'includes/REST/Keys_Controller.php', … … 230 301 'includes/REST/Openapi_Controller.php', 231 302 /* 232 * portal, full debugger, Chart.js analytics) live in the233 * directrelay-pro plugin distributed from234 * https://directrelay.wikiofautomation.com. They re-attach to the235 * free plugin via the `directrelay_ext_register_routes`action.303 * The add-on plugin (directrelay-pro, distributed from 304 * https://directrelay.wikiofautomation.com) re-attaches its own 305 * routes to the free plugin via the `directrelay_ext_register_routes` 306 * action. 236 307 */ 237 308 /* Admin layer */ … … 301 372 wp_clear_scheduled_hook( 'directrelay_prune_rate_limits' ); 302 373 wp_clear_scheduled_hook( 'directrelay_fire_webhook' ); 303 wp_clear_scheduled_hook( 'directrelay_ retry_webhook' );374 wp_clear_scheduled_hook( 'directrelay_deliver_webhook' ); 304 375 wp_clear_scheduled_hook( 'directrelay_cron_health_ping' ); 305 376 wp_clear_scheduled_hook( 'directrelay_prune_brute_force' ); 306 wp_clear_scheduled_hook( 'directrelay_telemetry_heartbeat' );307 377 flush_rewrite_rules(); 308 378 } catch ( \Throwable $e ) { -
directrelay-automation-bridge-for-n8n/trunk/includes/Activity_Logger.php
r3699228 r3706276 115 115 public static function hourly_stats( int $hours = 24, ?int $key_id = null ): array { 116 116 global $wpdb; 117 /* PHP-side date math for SQLite portability (the wp-sqlite-db shim 118 cannot translate DATE_SUB/UTC_TIMESTAMP). bucket_hour is UTC. */ 119 $cutoff = gmdate( 'Y-m-d H:00:00', time() - $hours * HOUR_IN_SECONDS ); 117 120 if ( $key_id ) { 118 121 $query = $wpdb->prepare( 119 "SELECT bucket_hour, SUM(hits) as hits, SUM(total_ms) as total_ms, SUM(CASE WHEN http_status >= 400 THEN hits ELSE 0 END) as errors FROM {$wpdb->prefix}directrelay_analytics WHERE bucket_hour >= DATE_SUB(UTC_TIMESTAMP(),INTERVAL %d HOUR)AND key_id = %d GROUP BY bucket_hour ORDER BY bucket_hour ASC",120 $ hours,122 "SELECT bucket_hour, SUM(hits) as hits, SUM(total_ms) as total_ms, SUM(CASE WHEN http_status >= 400 THEN hits ELSE 0 END) as errors FROM {$wpdb->prefix}directrelay_analytics WHERE bucket_hour >= %s AND key_id = %d GROUP BY bucket_hour ORDER BY bucket_hour ASC", 123 $cutoff, 121 124 $key_id 122 125 ); 123 126 } else { 124 127 $query = $wpdb->prepare( 125 "SELECT bucket_hour, SUM(hits) as hits, SUM(total_ms) as total_ms, SUM(CASE WHEN http_status >= 400 THEN hits ELSE 0 END) as errors FROM {$wpdb->prefix}directrelay_analytics WHERE bucket_hour >= DATE_SUB(UTC_TIMESTAMP(),INTERVAL %d HOUR)GROUP BY bucket_hour ORDER BY bucket_hour ASC",126 $ hours128 "SELECT bucket_hour, SUM(hits) as hits, SUM(total_ms) as total_ms, SUM(CASE WHEN http_status >= 400 THEN hits ELSE 0 END) as errors FROM {$wpdb->prefix}directrelay_analytics WHERE bucket_hour >= %s GROUP BY bucket_hour ORDER BY bucket_hour ASC", 129 $cutoff 127 130 ); 128 131 } … … 133 136 public static function top_endpoints( int $days = 7 ): array { 134 137 global $wpdb; 138 /* PHP-side date math for SQLite portability. */ 139 $cutoff = gmdate( 'Y-m-d H:00:00', time() - $days * DAY_IN_SECONDS ); 135 140 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- custom table. 136 141 return $wpdb->get_results( $wpdb->prepare( 137 "SELECT endpoint, method, SUM(hits) as hits, AVG(total_ms/hits) as avg_ms FROM {$wpdb->prefix}directrelay_analytics WHERE bucket_hour >= DATE_SUB(UTC_TIMESTAMP(),INTERVAL %d DAY)GROUP BY endpoint,method ORDER BY hits DESC LIMIT 10",138 $ days142 "SELECT endpoint, method, SUM(hits) as hits, AVG(total_ms/hits) as avg_ms FROM {$wpdb->prefix}directrelay_analytics WHERE bucket_hour >= %s GROUP BY endpoint,method ORDER BY hits DESC LIMIT 10", 143 $cutoff 139 144 ), ARRAY_A ) ?: array(); 140 145 } … … 143 148 global $wpdb; 144 149 $days = (int) get_option( 'directrelay_log_retention_days', 30 ); 150 /* PHP-side date math for SQLite portability; created_at/bucket_hour are UTC. */ 151 $cutoff = gmdate( 'Y-m-d H:i:s', time() - $days * DAY_IN_SECONDS ); 145 152 146 153 /* Prune standard logs */ 147 154 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- bulk delete on custom table. 148 $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->prefix}directrelay_log WHERE created_at < DATE_SUB(UTC_TIMESTAMP(),INTERVAL %d DAY)", $days) );155 $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->prefix}directrelay_log WHERE created_at < %s", $cutoff ) ); 149 156 150 157 /* Prune analytics (uses bucket_hour instead of created_at) */ 151 158 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- bulk delete on custom table. 152 $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->prefix}directrelay_analytics WHERE bucket_hour < DATE_SUB(UTC_TIMESTAMP(),INTERVAL %d DAY)", $days ) ); 159 $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->prefix}directrelay_analytics WHERE bucket_hour < %s", $cutoff ) ); 160 161 /* Safety cap: hard bound on table growth regardless of retention days 162 (matches the export cap). Two-step portable delete — no self-join. */ 163 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom table. 164 $boundary = $wpdb->get_var( $wpdb->prepare( "SELECT id FROM {$wpdb->prefix}directrelay_log ORDER BY id DESC LIMIT 1 OFFSET %d", 10000 ) ); 165 if ( $boundary ) { 166 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- bulk delete on custom table. 167 $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->prefix}directrelay_log WHERE id < %d", (int) $boundary ) ); 168 } 153 169 } 154 170 -
directrelay-automation-bridge-for-n8n/trunk/includes/Agent_Profiles.php
r3699228 r3706276 97 97 $role = $key['agent_role'] ?? self::ROLE_FULL_ACCESS; 98 98 99 // Agent personas (non-FULL_ACCESS) are a add-on feature(s). The free99 // Agent personas (non-FULL_ACCESS) are an add-on feature. The free 100 100 // plugin has only the FULL_ACCESS role, so any non-default role 101 101 // means the add-on plugin is extending the agent profile. The free … … 125 125 // 3. Post Ownership Isolation check 126 126 if ( $post_id && ! empty( $key['restrict_own_posts'] ) ) { 127 $creator_key_id = (int) get_post_meta( $post_id, '_directrelay_created_by_key', true ); 128 if ( $creator_key_id > 0 && $creator_key_id !== (int) $key['id'] ) { 129 return new \WP_Error( 130 'amp_ownership_restricted', 131 __( 'This API Agent is restricted to managing only content created by its own API key.', 'directrelay-automation-bridge-for-n8n' ), 132 [ 'status' => 403 ] 133 ); 134 } 127 return self::assert_owns( $key, $post_id ); 135 128 } 136 129 130 return true; 131 } 132 133 /** 134 * Post Ownership Isolation enforcement for a specific post. 135 * 136 * Called from the REST controllers once the target post ID is known 137 * (guard() runs before the route resolves an ID, so it cannot pass one). 138 * Keys without the restriction always pass; a restricted key may only 139 * manage posts stamped with its own key ID — unstamped (admin-created) 140 * content stays shared. 141 * 142 * @return true|\WP_Error 143 */ 144 public static function assert_owns( array $key, int $post_id ): bool|\WP_Error { 145 if ( empty( $key['restrict_own_posts'] ) ) { 146 return true; 147 } 148 $creator_key_id = (int) get_post_meta( $post_id, '_directrelay_created_by_key', true ); 149 if ( $creator_key_id > 0 && $creator_key_id !== (int) ( $key['id'] ?? 0 ) ) { 150 return new \WP_Error( 151 'amp_ownership_restricted', 152 __( 'This API Agent is restricted to managing only content created by its own API key.', 'directrelay-automation-bridge-for-n8n' ), 153 [ 'status' => 403 ] 154 ); 155 } 137 156 return true; 138 157 } -
directrelay-automation-bridge-for-n8n/trunk/includes/Api_Auth.php
r3699228 r3706276 21 21 'approval:read', 'approval:write', 22 22 'debugger:read', 'debugger:write', 23 'fleet:read', 'fleet:write',24 23 'universal:write', 25 24 ]; … … 59 58 // because the WP.org Plugin Directory Guidelines forbid usage 60 59 // limits in the free build. The directrelay-pro add-on may 61 // re-introduce a limit (s)via the `directrelay_ext_guardrails_check`60 // re-introduce a limit via the `directrelay_ext_guardrails_check` 62 61 // filter. 63 62 $raw = self::generate_raw(); … … 194 193 $prefix = substr( $raw, 0, 12 ); 195 194 $prefix_short = substr( $raw, 0, 8 ); 195 /* PHP-side date math for SQLite portability (the wp-sqlite-db shim 196 cannot translate UTC_TIMESTAMP()); expires_at is stored as UTC. */ 197 $now_utc = current_time( 'mysql', true ); 196 198 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom table auth lookup. 197 199 $rows = $wpdb->get_results( … … 200 202 WHERE (key_prefix = %s OR key_prefix = %s) 201 203 AND revoked = 0 202 AND (expires_at IS NULL OR expires_at > UTC_TIMESTAMP())",204 AND (expires_at IS NULL OR expires_at > %s)", 203 205 $prefix, 204 $prefix_short 206 $prefix_short, 207 $now_utc 205 208 ), 206 209 ARRAY_A … … 279 282 $row['last_used_ip'] = $ip; 280 283 $row['total_requests'] = (int) ( $row['total_requests'] ?? 0 ) + 1; 281 $row['scopes_array'] = json_decode( $row['scopes'] , true ) ?: [];284 $row['scopes_array'] = json_decode( $row['scopes'] ?? '[]', true ) ?: []; 282 285 $row['is_full'] = empty( $row['scopes_array'] ); 283 286 return $row; … … 550 553 551 554 public static function is_url_safe( string $url ): bool { 552 $scheme = wp_parse_url( $url, PHP_URL_SCHEME);553 if ( ! in_array( strtolower( $scheme ), [ 'http', 'https' ], true ) ) {554 return false; 555 } 556 $host = strtolower( wp_parse_url( $url, PHP_URL_HOST ) );555 $scheme = strtolower( (string) wp_parse_url( $url, PHP_URL_SCHEME ) ); 556 if ( ! in_array( $scheme, [ 'http', 'https' ], true ) ) { 557 return false; 558 } 559 $host = strtolower( (string) wp_parse_url( $url, PHP_URL_HOST ) ); 557 560 if ( empty( $host ) ) { 558 561 return false; 559 562 } 560 $ip = gethostbyname( $host ); 561 if ( $ip === $host ) { 563 564 // Block typical local network domain suffixes. 565 if ( 'localhost' === $host || str_ends_with( $host, '.local' ) || str_ends_with( $host, '.localhost' ) || str_ends_with( $host, '.internal' ) ) { 566 return false; 567 } 568 569 $ips = []; 570 if ( filter_var( $host, FILTER_VALIDATE_IP ) ) { 571 // Literal IPv4/IPv6 hosts are validated directly (gethostbyname() 572 // would return them unchanged and read as "couldn't resolve"). 573 $ips[] = $host; 574 } else { 575 // Every A record must be public — gethostbyname() alone only 576 // inspects one record, leaving the rest unchecked. 577 $a_records = gethostbynamel( $host ); 578 if ( is_array( $a_records ) ) { 579 $ips = $a_records; 580 } 581 // AAAA records for IPv6-only hosts (gethostbynamel() is v4-only). 582 $aaaa = function_exists( 'dns_get_record' ) ? dns_get_record( $host, DNS_AAAA ) : false; 583 if ( is_array( $aaaa ) ) { 584 foreach ( $aaaa as $rec ) { 585 if ( ! empty( $rec['ipv6'] ) ) { 586 $ips[] = $rec['ipv6']; 587 } 588 } 589 } 590 } 591 592 if ( empty( $ips ) ) { 562 593 // Couldn't resolve — block it. 563 594 return false; 564 595 } 565 if ( ! filter_var( $ip, FILTER_VALIDATE_IP ) ) { 566 return false; 567 } 568 569 // Block loopback, private and reserved ranges 570 if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) === false ) { 571 return false; 572 } 573 574 if ( in_array( $ip, [ '127.0.0.1', '::1', '0.0.0.0' ], true ) ) { 575 return false; 576 } 577 578 // Block typical local network domain suffixes. 579 if ( str_ends_with( $host, '.local' ) || str_ends_with( $host, '.localhost' ) || $host === 'localhost' ) { 580 return false; 596 597 foreach ( $ips as $ip ) { 598 if ( ! filter_var( $ip, FILTER_VALIDATE_IP ) ) { 599 return false; 600 } 601 602 // Block loopback, private and reserved ranges. 603 if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) === false ) { 604 return false; 605 } 606 607 if ( in_array( $ip, [ '127.0.0.1', '::1', '0.0.0.0' ], true ) ) { 608 return false; 609 } 581 610 } 582 611 -
directrelay-automation-bridge-for-n8n/trunk/includes/Brute_Force.php
r3699228 r3706276 24 24 public static function check_ip( string $ip ): bool|\WP_Error { 25 25 global $wpdb; 26 $t = "{$wpdb->prefix}directrelay_brute_force"; 26 $t = "{$wpdb->prefix}directrelay_brute_force"; 27 /* Both event types are enforced here: 'failed_auth' rows carry the 28 * automatic escalating block, 'manual_block' rows come from the admin 29 * Blocked-IPs card. The latest expiry wins when an IP has both. */ 27 30 $row = $wpdb->get_row( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom table. 28 $wpdb->prepare( "SELECT * FROM $t WHERE ip_address = %s AND event_type = 'failed_auth'", $ip ), // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared31 $wpdb->prepare( "SELECT blocked_until FROM $t WHERE ip_address = %s AND event_type IN ( 'failed_auth', 'manual_block' ) AND blocked_until IS NOT NULL ORDER BY blocked_until DESC LIMIT 1", $ip ), // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared 29 32 ARRAY_A 30 33 ); 31 if ( ! $row ) {34 if ( ! $row || empty( $row['blocked_until'] ) ) { 32 35 return true; 33 36 } 34 if ( ! empty( $row['blocked_until'] ) && strtotime( $row['blocked_until'] ) > time() ) { 35 $secs = strtotime( $row['blocked_until'] ) - time(); 37 /* blocked_until is written with gmdate() (UTC), so parse it as UTC. */ 38 $until = strtotime( $row['blocked_until'] . ' UTC' ); 39 if ( false !== $until && $until > time() ) { 40 $secs = $until - time(); 36 41 return new \WP_Error( 37 42 'amp_ip_blocked', 38 sprintf( 43 sprintf( 39 44 /* translators: %d: number of seconds */ 40 __( 'Too many failed attempts. Retry in %d seconds.', 'directrelay-automation-bridge-for-n8n'), 41 $secs 45 __( 'Too many failed attempts. Retry in %d seconds.', 'directrelay-automation-bridge-for-n8n'), 46 $secs 42 47 ), 43 48 [ 'status' => 429 ] … … 194 199 * @param string $ip Plain IPv4 or IPv6 address (CIDR is rejected to 195 200 * keep the table key exact-match; range blocking 196 * is a add-on feature(s)).201 * is an add-on feature). 197 202 * @return true|\WP_Error True on success, WP_Error with reason on failure. 198 203 */ -
directrelay-automation-bridge-for-n8n/trunk/includes/Build.php
r3699228 r3706276 6 6 * 7 7 * The free plugin is a single build (`type() === 'all-in-one'`) and 8 * does not carry a add-on variant, a third-party distribution channel,9 * or a licence-state flag. Those concerns live in the separate8 * does not carry an add-on variant, a third-party distribution channel, 9 * or an activation-state flag. Those concerns live in the separate 10 10 * `directrelay-pro` add-on distributed from 11 11 * https://directrelay.wikiofautomation.com/directrelay-pro. -
directrelay-automation-bridge-for-n8n/trunk/includes/Compatibility.php
r3699228 r3706276 19 19 } 20 20 21 add_filter( 'determine_current_user', [ __CLASS__, 'force_auth_for_rest_blocks' ], 999 ); 22 } 23 24 public static function force_auth_for_rest_blocks( $user ) { 25 global $wpdb; 26 if ( ! empty( $user ) ) return $user; 27 28 $http_auth = isset( $_SERVER['HTTP_AUTHORIZATION'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_AUTHORIZATION'] ) ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized 29 $redir_auth = isset( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ) ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized 30 $api_auth = isset( $_SERVER['HTTP_X_API_KEY'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_API_KEY'] ) ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized 31 32 $auth = $http_auth ?: $redir_auth; 33 if ( ! $auth && $api_auth ) { 34 $auth = 'Bearer ' . $api_auth; 35 } 36 $auth = trim( $auth ); 37 if ( str_starts_with( $auth, 'Bearer ' ) ) { 38 $raw = trim( substr( $auth, 7 ) ); 39 if ( strlen( $raw ) >= 8 ) { 40 $prefix = substr( $raw, 0, 12 ); 41 $prefix_short = substr( $raw, 0, 8 ); 42 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom table authentication query 43 $rows = $wpdb->get_results( 44 $wpdb->prepare( 45 "SELECT user_id, key_hash FROM {$wpdb->prefix}directrelay_keys WHERE (key_prefix = %s OR key_prefix = %s) AND revoked = 0 AND (expires_at IS NULL OR expires_at > UTC_TIMESTAMP())", 46 $prefix, 47 $prefix_short 48 ), 49 ARRAY_A 50 ); 51 if ( ! empty( $rows ) ) { 52 foreach ( $rows as $r ) { 53 $stored_hash = $r['key_hash']; 54 if ( str_starts_with( $stored_hash, '$' ) ) { 55 if ( password_verify( $raw, $stored_hash ) ) return (int) $r['user_id']; 56 } else { 57 if ( hash_equals( $stored_hash, \DirectRelay\Api_Auth::hash_key( $raw ) ) ) return (int) $r['user_id']; 58 } 59 } 60 } 61 } 62 } 63 return $user; 21 /* 22 * No determine_current_user filter: DirectRelay keys must never act as 23 * their bound WordPress user outside the directrelay/v1 namespace. 24 * Base_Controller::guard() authenticates each plugin route itself and 25 * calls wp_set_current_user() only after scope/agent/rate checks, so 26 * a leaked key cannot reach core /wp/v2, wp-admin or admin-ajax 27 * capabilities (the pre-5.1.32 scope bypass). 28 */ 64 29 } 65 30 … … 187 152 $wpdb->prefix . 'directrelay_log', 188 153 $wpdb->prefix . 'directrelay_webhooks', 189 $wpdb->prefix . 'directrelay_rate _limits',154 $wpdb->prefix . 'directrelay_rate', 190 155 $wpdb->prefix . 'directrelay_brute_force', 191 156 ]; -
directrelay-automation-bridge-for-n8n/trunk/includes/Content_Processor.php
r3699228 r3706276 81 81 } 82 82 83 // Look for Markdown indicators 84 return (bool) preg_match( '/(?:^#{1,6}\s|^\s*[\*\-]\s|\*\*[^\*]+\*\*|\[[^\]]+\]\([^\)]+\)|^\s*\|\s*.*?\s*\|\s*$ )/m', $content );83 // Look for Markdown indicators (headings, lists, bold, links, tables, code fences) 84 return (bool) preg_match( '/(?:^#{1,6}\s|^\s*[\*\-]\s|\*\*[^\*]+\*\*|\[[^\]]+\]\([^\)]+\)|^\s*\|\s*.*?\s*\|\s*$|^\s*```)/m', $content ); 85 85 } 86 86 … … 101 101 102 102 // 3. Blockquotes: > text 103 $text = preg_replace ( '/^>\s*(.*)$/m', '<blockquote><p>$1</p></blockquote>', $text );103 $text = preg_replace_callback( '/^>\s*(.*)$/m', fn( $m ) => '<blockquote><p>' . esc_html( $m[1] ) . '</p></blockquote>', $text ); 104 104 105 105 // 4. Headings (# H1 to ###### H6) 106 $text = preg_replace( '/^######\s+(.*)$/m', '<h6>$1</h6>', $text ); 107 $text = preg_replace( '/^#####\s+(.*)$/m', '<h5>$1</h5>', $text ); 108 $text = preg_replace( '/^####\s+(.*)$/m', '<h4>$1</h4>', $text ); 109 $text = preg_replace( '/^###\s+(.*)$/m', '<h3>$1</h3>', $text ); 110 $text = preg_replace( '/^##\s+(.*)$/m', '<h2>$2</h2>', $text ); 111 $text = preg_replace( '/^##\s+(.*)$/m', '<h2>$1</h2>', $text ); 112 $text = preg_replace( '/^#\s+(.*)$/m', '<h1>$1</h1>', $text ); 106 $text = preg_replace_callback( '/^######\s+(.*)$/m', fn( $m ) => '<h6>' . esc_html( $m[1] ) . '</h6>', $text ); 107 $text = preg_replace_callback( '/^#####\s+(.*)$/m', fn( $m ) => '<h5>' . esc_html( $m[1] ) . '</h5>', $text ); 108 $text = preg_replace_callback( '/^####\s+(.*)$/m', fn( $m ) => '<h4>' . esc_html( $m[1] ) . '</h4>', $text ); 109 $text = preg_replace_callback( '/^###\s+(.*)$/m', fn( $m ) => '<h3>' . esc_html( $m[1] ) . '</h3>', $text ); 110 $text = preg_replace_callback( '/^##\s+(.*)$/m', fn( $m ) => '<h2>' . esc_html( $m[1] ) . '</h2>', $text ); 111 $text = preg_replace_callback( '/^#\s+(.*)$/m', fn( $m ) => '<h1>' . esc_html( $m[1] ) . '</h1>', $text ); 113 112 114 113 // 5. Tables: | Col 1 | Col 2 | … … 143 142 144 143 // 6. Inline Formatting (Images, Links, Bold, Italic, Strikethrough, Code) 145 $text = preg_replace( '/!\[([^\]]*)\]\(([^\)]+)\)/', '<img src="$2" alt="$1" />', $text ); 146 $text = preg_replace( '/\[([^\]]+)\]\(([^\)]+)\)/', '<a href="$2" target="_blank" rel="noopener">$1</a>', $text ); 147 $text = preg_replace( '/\*\*([^\*]+)\*\*/', '<strong>$1</strong>', $text ); 148 $text = preg_replace( '/~~([^~]+)~~/', '<del>$1</del>', $text ); 149 $text = preg_replace( '/\*([^\*]+)\*/', '<em>$1</em>', $text ); 150 $text = preg_replace( '/`([^`]+)`/', '<code>$1</code>', $text ); 144 // 145 // URLs go through esc_url() — protocol allowlist plus attribute-safe 146 // encoding — so javascript:/data: hrefs and quote-breakout payloads 147 // are neutralized, and captured text goes through esc_html()/esc_attr(). 148 // The decode-then-esc_url on the URL keeps links that were already 149 // esc_html()'d by an earlier block pass (heading/blockquote text) 150 // from being double-encoded. 151 $text = preg_replace_callback( '/!\[([^\]]*)\]\(([^\)]+)\)/', function( $m ) { 152 $url = esc_url( html_entity_decode( trim( $m[2] ), ENT_QUOTES, 'UTF-8' ) ); 153 if ( '' === $url ) { 154 return esc_html( $m[0] ); /* Unsafe protocol: render as plain text. */ 155 } 156 return '<img src="' . $url . '" alt="' . esc_attr( $m[1] ) . '" />'; 157 }, $text ); 158 $text = preg_replace_callback( '/\[([^\]]+)\]\(([^\)]+)\)/', function( $m ) { 159 $url = esc_url( html_entity_decode( trim( $m[2] ), ENT_QUOTES, 'UTF-8' ) ); 160 if ( '' === $url ) { 161 return esc_html( $m[1] ); /* Unsafe protocol: keep the text, drop the link. */ 162 } 163 return '<a href="' . $url . '" target="_blank" rel="noopener">' . esc_html( $m[1] ) . '</a>'; 164 }, $text ); 165 $text = preg_replace_callback( '/\*\*([^\*]+)\*\*/', fn( $m ) => '<strong>' . esc_html( $m[1] ) . '</strong>', $text ); 166 $text = preg_replace_callback( '/~~([^~]+)~~/', fn( $m ) => '<del>' . esc_html( $m[1] ) . '</del>', $text ); 167 $text = preg_replace_callback( '/\*([^\*]+)\*/', fn( $m ) => '<em>' . esc_html( $m[1] ) . '</em>', $text ); 168 $text = preg_replace_callback( '/`([^`]+)`/', fn( $m ) => '<code>' . esc_html( $m[1] ) . '</code>', $text ); 151 169 152 170 // 7. Unordered Lists (* or -) & Ordered Lists (1.) 153 171 $text = preg_replace_callback( '/(?:^\s*[\*\-]\s+.*$\n?)+/m', function( $m ) { 154 $items = preg_replace ( '/^\s*[\*\-]\s+(.*)$/m', '<li>$1</li>', $m[0] );172 $items = preg_replace_callback( '/^\s*[\*\-]\s+(.*)$/m', fn( $li ) => '<li>' . esc_html( $li[1] ) . '</li>', $m[0] ); 155 173 return "<ul>\n" . $items . "</ul>\n"; 156 174 }, $text ); 157 175 158 176 $text = preg_replace_callback( '/(?:^\s*\d+\.\s+.*$\n?)+/m', function( $m ) { 159 $items = preg_replace ( '/^\s*\d+\.\s+(.*)$/m', '<li>$1</li>', $m[0] );177 $items = preg_replace_callback( '/^\s*\d+\.\s+(.*)$/m', fn( $li ) => '<li>' . esc_html( $li[1] ) . '</li>', $m[0] ); 160 178 return "<ol>\n" . $items . "</ol>\n"; 161 179 }, $text ); -
directrelay-automation-bridge-for-n8n/trunk/includes/Cron_Health.php
r3699228 r3706276 152 152 'ondemand' => true, 153 153 ), 154 'directrelay_ retry_webhook'=> array(155 'label' => __( 'Webhook retry queue', 'directrelay-automation-bridge-for-n8n' ),154 'directrelay_deliver_webhook' => array( 155 'label' => __( 'Webhook delivery attempt (with retries)', 'directrelay-automation-bridge-for-n8n' ), 156 156 'ondemand' => true, 157 157 ), -
directrelay-automation-bridge-for-n8n/trunk/includes/Feature_Gate.php
r3699228 r3706276 18 18 * - can_create_post() always returns true. 19 19 * 20 * NOTE: do NOT reintroduce the legacy limit (s) or licence-state20 * NOTE: do NOT reintroduce the legacy limit or activation-state 21 21 * methods here. The WordPress.org Plugin Directory Guidelines forbid 22 * usage limits, licence-keyed unlock, and add-on build flags in any22 * usage limits, keyed unlock, and add-on build flags in any 23 23 * plugin hosted on WP.org. The free plugin uses direct free options 24 24 * (`directrelay_settings`) for any tunable webhook configuration. … … 27 27 28 28 /** 29 * Whether the current site has add-on feature (s).29 * Whether the current site has add-on features. 30 30 * 31 31 * Returns true so legacy "if ( ! is_pro() )" checks degrade to 32 32 * a no-op pass-through. The free plugin does not gate features 33 * by licencestate.33 * by activation state. 34 34 */ 35 35 public static function is_pro(): bool { … … 76 76 * Whether a new post or page can be created. 77 77 * 78 * The free plugin has no per-key monthly post limit (s). Post78 * The free plugin has no per-key monthly post limit. Post 79 79 * creation is always allowed. 80 80 */ -
directrelay-automation-bridge-for-n8n/trunk/includes/IndexNow_Pinger.php
r3699228 r3706276 7 7 * DirectRelay IndexNow Pinger 8 8 * 9 * Automatically pings Bing, Yandex, Naver, and IndexNow search engine hubs upon post publishing. 10 * Includes activity logging and key verification endpoint integration. 9 * Pings the IndexNow aggregator (api.indexnow.org) when a post is published; 10 * the aggregator routes the submission to every participating search engine 11 * (Bing, Yandex, Seznam, Naver), so no per-engine pings are needed. Includes 12 * activity logging and the key-verification endpoint. 11 13 * 12 14 * @since 4.3.0 … … 16 18 private const ENDPOINTS = [ 17 19 'https://api.indexnow.org/indexnow', 18 'https://www.bing.com/indexnow',19 20 ]; 20 21 … … 45 46 46 47 public static function serve_key_file(): void { 47 if ( isset( $_SERVER['REQUEST_URI'] ) && str_contains( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ), 'indexnow-key.txt' ) ) { 48 header( 'Content-Type: text/plain; charset=utf-8' ); 49 echo esc_html( self::get_api_key() ); 50 exit; 48 if ( ! isset( $_SERVER['REQUEST_URI'] ) ) { 49 return; 51 50 } 51 $path = rtrim( (string) wp_parse_url( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ), PHP_URL_PATH ), '/' ); 52 /* Exact path match (any subdirectory depth) — a substring match would 53 * also hijack unrelated 404s like /?x=indexnow-key.txt. */ 54 if ( substr( $path, -17 ) !== '/indexnow-key.txt' ) { 55 return; 56 } 57 header( 'Content-Type: text/plain; charset=utf-8' ); 58 echo esc_html( self::get_api_key() ); 59 exit; 52 60 } 53 61 … … 90 98 'headers' => [ 'Content-Type' => 'application/json' ], 91 99 'body' => $json_body, 92 'sslverify' => false,100 'sslverify' => true, 93 101 ] ); 94 102 95 103 if ( is_wp_error( $response ) ) { 96 Activity_Logger::log( 'indexnow_error', 0, 0, $response->get_error_message() ); 104 /* Non-blocking requests still return WP_Error when the 105 * connection cannot be opened at all; record it in the 106 * activity log instead of letting it fatal the request. */ 107 Activity_Logger::write( [ 108 'action' => 'indexnow_error', 109 'method' => 'POST', 110 'endpoint' => 'indexnow', 111 'object_type' => $post_id > 0 ? 'post' : null, 112 'object_id' => $post_id > 0 ? $post_id : null, 113 'http_status' => 0, 114 'response_msg' => $response->get_error_message(), 115 ] ); 97 116 } 98 117 } -
directrelay-automation-bridge-for-n8n/trunk/includes/Installer.php
r3699228 r3706276 7 7 8 8 public static function activate( bool $network_wide = false ): void { 9 if ( is_multisite() && $network_wide ) { 10 self::run_for_all_blogs( [ self::class, 'activate_blog' ] ); 11 } else { 12 self::activate_blog(); 9 /* Single-site build: the loader in directrelay.php refuses multisite 10 * entirely, so activation always targets the one active site. */ 11 self::activate_blog(); 12 } 13 14 public static function activate_blog(): void { 15 if ( function_exists( 'is_multisite' ) && is_multisite() ) { 16 return; /* Single-site build: never install tables or options on a multisite. */ 13 17 } 14 }15 16 public static function activate_blog(): void {17 18 self::create_tables(); 18 19 update_option( 'directrelay_db_version', DIRECTRELAY_DB_VERSION ); … … 35 36 36 37 public static function deactivate( bool $network_wide = false ): void { 37 if ( is_multisite() && $network_wide ) { 38 self::run_for_all_blogs( [ self::class, 'deactivate_blog' ] ); 39 } else { 40 self::deactivate_blog(); 41 } 38 self::deactivate_blog(); 42 39 } 43 40 … … 48 45 'directrelay_prune_rate_limits', 49 46 'directrelay_fire_webhook', 50 'directrelay_ retry_webhook',47 'directrelay_deliver_webhook', 51 48 'directrelay_cron_health_ping', 52 49 'directrelay_prune_brute_force', … … 57 54 } 58 55 59 public static function uninstall(): void { 60 if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) { 61 return; 62 } 63 if ( is_multisite() ) { 64 self::run_for_all_blogs( [ self::class, 'uninstall_blog' ] ); 65 } else { 66 self::uninstall_blog(); 67 } 68 } 69 70 public static function uninstall_blog(): void { 71 global $wpdb; 72 $tables = [ 73 'directrelay_keys', 'directrelay_log', 'directrelay_rate', 'directrelay_webhooks', 74 'directrelay_analytics', 'directrelay_webhook_log', 'directrelay_brute_force', 75 'directrelay_approvals', 76 ]; 77 foreach ( $tables as $t ) { 78 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- uninstall-time DDL with known static table names, no user input 79 $wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}{$t}" ); 80 } 81 delete_option( 'directrelay_db_version' ); 82 delete_option( 'directrelay_settings' ); 83 delete_option( 'directrelay_guardrails' ); 84 delete_option( 'directrelay_cron_last_ping' ); 85 delete_option( 'directrelay_apppass_dismissed' ); 86 delete_option( 'directrelay_log_retention_days' ); 87 88 /* Remove diagnostic trace logs left behind from WP_DEBUG_LOG-enabled sessions. */ 89 $trace_paths = []; 90 if ( function_exists( 'wp_upload_dir' ) ) { 91 $up = wp_upload_dir(); 92 if ( ! empty( $up['basedir'] ) ) { 93 $dir = trailingslashit( $up['basedir'] ) . 'directrelay-automation-bridge-for-n8n'; 94 $trace_paths[] = $dir . '/trace.log'; 95 $trace_paths[] = $dir . '/error.log'; 96 } 97 } 98 99 foreach ( $trace_paths as $trace_path ) { 100 if ( is_file( $trace_path ) ) { 101 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_file -- guard before delete. 102 @wp_delete_file( $trace_path ); 103 } 104 } 105 } 106 56 /* 57 * Per-blog uninstall cleanup lives in the root uninstall.php, which runs 58 * standalone: WordPress does not load the plugin during uninstall, so the 59 * routine must not depend on this class being loaded. 60 */ 61 62 /** 63 * Schema rule: TEXT columns must never carry a DEFAULT literal. MySQL rejects 64 * defaults on TEXT/BLOB columns ("BLOB, TEXT ... column can't have a default 65 * value") while MariaDB and SQLite accept them, which hides the failure — 66 * dbDelta() swallows the error and the tables silently never get created. 67 * Every writer supplies these values explicitly (Api_Auth::create(), the 68 * webhook create/update paths in Webhook_Controller and Ajax_Handler). 69 */ 107 70 public static function create_tables(): void { 108 71 global $wpdb; … … 114 77 id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, 115 78 user_id BIGINT UNSIGNED NOT NULL, 116 key_hash VARCHAR( 255) NOT NULL,79 key_hash VARCHAR(191) NOT NULL, 117 80 key_prefix CHAR(12) NOT NULL DEFAULT '', 118 81 label VARCHAR(191) NOT NULL DEFAULT '', … … 125 88 restrict_own_posts TINYINT(1) NOT NULL DEFAULT 0, 126 89 require_approval TINYINT(1) NOT NULL DEFAULT 0, 127 scopes TEXT NOT NULL DEFAULT '[]',90 scopes TEXT NOT NULL, 128 91 ip_whitelist TEXT DEFAULT NULL, 129 92 last_used_at DATETIME DEFAULT NULL, … … 204 167 target_url TEXT NOT NULL, 205 168 secret VARCHAR(255) DEFAULT NULL, 206 events TEXT NOT NULL DEFAULT '[]',207 post_types TEXT NOT NULL DEFAULT '[\"post\",\"page\"]',169 events TEXT NOT NULL, 170 post_types TEXT NOT NULL, 208 171 active TINYINT(1) NOT NULL DEFAULT 1, 209 172 last_fired_at DATETIME DEFAULT NULL, … … 262 225 } 263 226 264 private static function run_for_all_blogs( callable $callback ): void {265 global $wpdb;266 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- multisite blog enumeration requires direct query267 $blogs = $wpdb->get_col( "SELECT blog_id FROM {$wpdb->blogs}" );268 foreach ( $blogs as $id ) {269 switch_to_blog( $id );270 call_user_func( $callback );271 restore_current_blog();272 }273 }274 227 } -
directrelay-automation-bridge-for-n8n/trunk/includes/Plugin.php
r3699228 r3706276 61 61 } 62 62 63 /* Multisite: handle new site creation */64 if ( is_multisite() ) {65 add_action( 'wp_initialize_site', [ __CLASS__, 'init_new_site' ] );66 }67 }68 69 public static function init_new_site( \WP_Site $site ): void {70 if ( is_plugin_active_for_network( plugin_basename( DIRECTRELAY_FILE ) ) ) {71 switch_to_blog( $site->blog_id );72 Installer::activate_blog();73 restore_current_blog();74 }75 63 } 76 64 … … 91 79 'REST\Media_Controller', 92 80 'REST\Taxonomy_Controller', 93 'REST\Fleet_Controller',94 81 'REST\Webhook_Controller', 95 82 'REST\Openapi_Controller', -
directrelay-automation-bridge-for-n8n/trunk/includes/Post_Manager.php
r3699228 r3706276 116 116 return; 117 117 } 118 require_once ABSPATH . 'wp-admin/includes/media.php'; 119 require_once ABSPATH . 'wp-admin/includes/file.php'; 120 require_once ABSPATH . 'wp-admin/includes/image.php'; 121 122 $att_id = media_sideload_image( $safe_url, $id, null, 'id' ); 118 119 $att_id = self::sideload_url( $safe_url, $id ); 123 120 if ( ! is_wp_error( $att_id ) && (int) $att_id > 0 ) { 124 121 set_post_thumbnail( $id, (int) $att_id ); … … 144 141 } 145 142 143 /** 144 * Fetch a remote file with SSRF validation on every hop. 145 * 146 * download_url()/wp_remote_get follow redirects blindly, so a public URL 147 * can 302 into an internal address. This fetches with redirects disabled 148 * and re-validates every Location with Api_Auth::is_url_safe() + 149 * revalidate_ip() before the next hop, closing the open-redirect gap and 150 * narrowing the DNS-rebinding window to the unavoidable same-request 151 * resolution inside the final fetch. 152 * 153 * @return array{body:string,content_type:string,final_url:string}|\WP_Error 154 */ 155 public static function safe_fetch_url( string $url, int $max_bytes ): array|\WP_Error { 156 $current = esc_url_raw( $url ); 157 $body = null; 158 159 for ( $hop = 0; $hop < 4; $hop++ ) { 160 if ( ! Api_Auth::is_url_safe( $current ) || null === Api_Auth::revalidate_ip( $current ) ) { 161 return new \WP_Error( 'amp_ssrf_blocked', __( 'SSRF protection: Image URL is blocked.', 'directrelay-automation-bridge-for-n8n' ), [ 'status' => 403 ] ); 162 } 163 164 $response = wp_safe_remote_get( $current, [ 165 'timeout' => 15, 166 'redirection' => 0, /* Redirects are followed manually, re-validated above. */ 167 'limit_response_size' => $max_bytes, 168 ] ); 169 if ( is_wp_error( $response ) ) { 170 return $response; 171 } 172 173 $code = (int) wp_remote_retrieve_response_code( $response ); 174 if ( $code >= 300 && $code < 400 ) { 175 $location = (string) wp_remote_retrieve_header( $response, 'location' ); 176 if ( '' === $location ) { 177 return new \WP_Error( 'amp_sideload_failed', __( 'Remote server returned a redirect without a location header.', 'directrelay-automation-bridge-for-n8n' ), [ 'status' => 502 ] ); 178 } 179 $next = self::absolutize_redirect( $current, $location ); 180 if ( '' === $next ) { 181 return new \WP_Error( 'amp_sideload_failed', __( 'Remote server returned an invalid redirect location.', 'directrelay-automation-bridge-for-n8n' ), [ 'status' => 502 ] ); 182 } 183 $current = $next; 184 continue; 185 } 186 187 if ( $code < 200 || $code >= 300 ) { 188 return new \WP_Error( 189 'amp_sideload_failed', 190 sprintf( /* translators: %d: HTTP status code */ __( 'Remote image request returned HTTP %d.', 'directrelay-automation-bridge-for-n8n' ), $code ), 191 [ 'status' => 502 ] 192 ); 193 } 194 195 $body = wp_remote_retrieve_body( $response ); 196 break; 197 } 198 199 if ( null === $body || '' === $body ) { 200 return new \WP_Error( 'amp_sideload_failed', __( 'Remote image could not be downloaded (redirect limit reached or empty response).', 'directrelay-automation-bridge-for-n8n' ), [ 'status' => 502 ] ); 201 } 202 if ( strlen( $body ) >= $max_bytes ) { 203 return new \WP_Error( 'amp_sideload_failed', __( 'Remote image exceeds the maximum sideload size.', 'directrelay-automation-bridge-for-n8n' ), [ 'status' => 413 ] ); 204 } 205 206 return [ 207 'body' => $body, 208 'content_type' => strtolower( (string) wp_remote_retrieve_header( $response, 'content-type' ) ), 209 'final_url' => $current, 210 ]; 211 } 212 213 /** 214 * Sideload a remote image into the media library (safe fetch + attach). 215 * 216 * @param int $post_id Post to attach to (0 = unattached). 217 * @param string $title Optional attachment title (media_handle_sideload desc). 218 * @return int|\WP_Error Attachment ID on success. 219 */ 220 public static function sideload_url( string $url, int $post_id = 0, string $title = '' ): int|\WP_Error { 221 require_once ABSPATH . 'wp-admin/includes/media.php'; 222 require_once ABSPATH . 'wp-admin/includes/file.php'; 223 require_once ABSPATH . 'wp-admin/includes/image.php'; 224 225 $max_bytes = max( 1, (int) ( get_option( 'directrelay_settings', [] )['max_sideload_size'] ?? 20 ) ) * MB_IN_BYTES; 226 $fetched = self::safe_fetch_url( $url, $max_bytes ); 227 if ( is_wp_error( $fetched ) ) { 228 return $fetched; 229 } 230 231 /* The filename extension must match the real bytes or 232 * media_handle_sideload() rejects the file, so the Content-Type wins 233 * over the URL's own tail when both are known. */ 234 $ext_map = [ 235 'image/jpeg' => 'jpg', 'image/jpg' => 'jpg', 'image/png' => 'png', 'image/gif' => 'gif', 236 'image/webp' => 'webp', 'image/avif' => 'avif', 'image/bmp' => 'bmp', 237 'image/x-icon' => 'ico', 'image/vnd.microsoft.icon' => 'ico', 238 ]; 239 $ext = ''; 240 foreach ( $ext_map as $mime => $candidate ) { 241 if ( str_starts_with( $fetched['content_type'], $mime ) ) { 242 $ext = $candidate; 243 break; 244 } 245 } 246 247 $name = sanitize_file_name( basename( (string) wp_parse_url( $fetched['final_url'], PHP_URL_PATH ) ) ); 248 $stem = pathinfo( $name, PATHINFO_FILENAME ); 249 $stem = is_string( $stem ) && '' !== $stem ? $stem : 'image-' . gmdate( 'YmdHis' ); 250 if ( '' !== $ext ) { 251 $name = $stem . '.' . $ext; 252 } elseif ( ! preg_match( '/\.(jpe?g|png|gif|webp|avif|ico|bmp)$/i', $name ) ) { 253 return new \WP_Error( 'amp_sideload_failed', __( 'Remote file is not a recognizable image type.', 'directrelay-automation-bridge-for-n8n' ), [ 'status' => 415 ] ); 254 } 255 256 $tmp = wp_tempnam( $name ); 257 if ( ! $tmp ) { 258 return new \WP_Error( 'amp_sideload_failed', __( 'Could not create a temporary file for the image.', 'directrelay-automation-bridge-for-n8n' ), [ 'status' => 500 ] ); 259 } 260 global $wp_filesystem; 261 if ( ! $wp_filesystem ) { 262 WP_Filesystem(); 263 } 264 if ( ! $wp_filesystem->put_contents( $tmp, $fetched['body'] ) ) { 265 wp_delete_file( $tmp ); 266 return new \WP_Error( 'amp_sideload_failed', __( 'Could not store the downloaded image.', 'directrelay-automation-bridge-for-n8n' ), [ 'status' => 500 ] ); 267 } 268 269 $att_id = media_handle_sideload( 270 [ 'name' => $name, 'tmp_name' => $tmp ], 271 $post_id, 272 '' !== $title ? sanitize_text_field( $title ) : null 273 ); 274 if ( is_wp_error( $att_id ) ) { 275 /* media_handle_sideload() leaves the temp file behind on failure. */ 276 wp_delete_file( $tmp ); 277 return $att_id; 278 } 279 return (int) $att_id; 280 } 281 282 /** 283 * Resolve a redirect Location against the URL it came from. 284 * Returns '' for anything that does not produce a clean http(s) URL. 285 */ 286 private static function absolutize_redirect( string $base_url, string $location ): string { 287 $location = trim( $location ); 288 if ( '' === $location ) { 289 return ''; 290 } 291 if ( preg_match( '#^https?://#i', $location ) ) { 292 return esc_url_raw( $location ); 293 } 294 $p = wp_parse_url( $base_url ); 295 if ( empty( $p['scheme'] ) || empty( $p['host'] ) ) { 296 return ''; 297 } 298 $base = $p['scheme'] . '://' . $p['host'] . ( isset( $p['port'] ) ? ':' . $p['port'] : '' ); 299 if ( str_starts_with( $location, '/' ) ) { 300 return esc_url_raw( $base . $location ); 301 } 302 $dir = isset( $p['path'] ) ? (string) $p['path'] : '/'; 303 return esc_url_raw( $base . preg_replace( '#/[^/]*$#', '/', $dir ) . $location ); 304 } 305 146 306 private static function apply_taxonomy( int $id, array $data ): void { 147 307 if ( ! empty( $data['category_names'] ) ) { … … 162 322 163 323 private static function apply_meta( int $id, array $meta ): void { 164 $protected = [ '_edit_lock', '_edit_last', '_wp_old_slug', '_wp_attachment_metadata' ];324 $protected = [ '_edit_lock', '_edit_last', '_wp_old_slug', '_wp_attachment_metadata', '_thumbnail_id' ]; 165 325 $has_acf = function_exists( 'update_field' ); 166 326 foreach ( $meta as $k => $v ) { 167 327 $key = sanitize_text_field( (string) $k ); 168 if ( empty( $key ) || in_array( $key, $protected, true ) || str_starts_with( $key, 'directrelay_' ) ) continue; 169 328 if ( empty( $key ) || in_array( $key, $protected, true ) ) { 329 continue; 330 } 331 /* Core-internal (_wp_*: templates, attachment files, menu items), 332 * attachment-binding (_thumbnail_id) and plugin-internal meta are 333 * never agent-writable. Custom theme meta like _myfield stays 334 * writable. */ 335 if ( str_starts_with( $key, '_wp_' ) || str_starts_with( $key, 'directrelay_' ) || str_starts_with( $key, '_directrelay' ) ) { 336 continue; 337 } 338 170 339 if ( $has_acf && str_starts_with( $key, 'field_' ) ) { 171 340 update_field( $key, $v, $id ); -
directrelay-automation-bridge-for-n8n/trunk/includes/REST/Base_Controller.php
r3699228 r3706276 104 104 } 105 105 106 /** Return true or a WP_Error when a add-on feature(s)is required. */106 /** Return true or a WP_Error when an add-on feature is required. */ 107 107 protected static function require_pro( string $feature ): bool|\WP_Error { 108 108 return true; -
directrelay-automation-bridge-for-n8n/trunk/includes/REST/Media_Controller.php
r3699228 r3706276 80 80 } 81 81 82 if ( ! \DirectRelay\Api_Auth::is_url_safe( $url ) ) {83 return self::fail( $req, new \WP_Error( 'amp_invalid', __( 'URL must be a public HTTPS endpoint.', 'directrelay-automation-bridge-for-n8n'), [ 'status' => 400 ] ), 'sideload_media' );84 }85 86 $resolved_ip = \DirectRelay\Api_Auth::revalidate_ip( $url );87 if ( null === $resolved_ip ) {88 return self::fail( $req, new \WP_Error(89 'amp_ssrf_blocked',90 __( 'Remote host could not be verified at fetch time.', 'directrelay-automation-bridge-for-n8n' ),91 [ 'status' => 400 ]92 ), 'sideload_media' );93 }94 95 82 $settings = get_option( 'directrelay_settings', [] ); 96 83 $max_mb = (int) ( $settings['max_sideload_size'] ?? 20 ); 97 84 98 /* HEAD request to check size before downloading */ 99 $head = wp_remote_head( $url, [ 'timeout' => 5 ] ); 100 if ( ! is_wp_error( $head ) ) { 101 $cl = (int) wp_remote_retrieve_header( $head, 'content-length' ); 102 if ( $cl > ( $max_mb * 1024 * 1024 ) ) { 103 /* translators: %d: file size limit in megabytes */ 104 return self::fail( $req, new \WP_Error( 'amp_too_large', sprintf( __( 'Remote file exceeds limit of %dMB.', 'directrelay-automation-bridge-for-n8n'), $max_mb ), [ 'status' => 413 ] ), 'sideload_media' ); 105 } 106 } 107 108 /* Fetch the remote file */ 109 $tmp = download_url( $url ); 110 if ( is_wp_error( $tmp ) ) { 111 return self::fail( $req, $tmp, 'sideload_media' ); 85 /* Redirect-safe fetch: every hop is re-validated against SSRF 86 * (is_url_safe + revalidate_ip) before it is followed, so a public 87 * URL cannot 302 into an internal address. */ 88 $fetched = \DirectRelay\Post_Manager::safe_fetch_url( $url, $max_mb * MB_IN_BYTES ); 89 if ( is_wp_error( $fetched ) ) { 90 return self::fail( $req, $fetched, 'sideload_media' ); 112 91 } 113 92 114 93 /* Derive filename from URL if not provided */ 115 $filename = $body['filename'] ?? basename( wp_parse_url( $url, PHP_URL_PATH ) ) ?: 'media-file'; 116 $decoded = file_get_contents( $tmp ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents 117 @unlink( $tmp ); // phpcs:ignore 118 119 if ( ! $decoded ) { 120 return self::fail( $req, new \WP_Error( 'amp_fetch_failed', __( 'Could not read downloaded file.', 'directrelay-automation-bridge-for-n8n'), [ 'status' => 502 ] ), 'sideload_media' ); 121 } 122 123 return self::save_upload( $req, $decoded, array_merge( $body, [ 'filename' => $filename ] ), 'sideload_media' ); 94 $filename = $body['filename'] ?? basename( wp_parse_url( $fetched['final_url'], PHP_URL_PATH ) ) ?: 'media-file'; 95 96 return self::save_upload( $req, $fetched['body'], array_merge( $body, [ 'filename' => $filename ] ), 'sideload_media' ); 124 97 } 125 98 -
directrelay-automation-bridge-for-n8n/trunk/includes/REST/Meta_Controller.php
r3699228 r3706276 80 80 return self::fail( $req, new \WP_Error( 'amp_not_found', 'Content not found.', [ 'status' => 404 ] ), 'update_meta' ); 81 81 } 82 $own = \DirectRelay\Agent_Profiles::assert_owns( (array) $req->get_param( '_amp_key' ), $id ); 83 if ( is_wp_error( $own ) ) { 84 return self::fail( $req, $own, 'update_meta' ); 85 } 82 86 $updated = 0; $skipped = 0; $errors = []; 83 87 foreach ( $body as $key => $value ) { … … 113 117 return self::fail( $req, new \WP_Error( 'amp_forbidden', __( 'Cannot delete protected meta key.', 'directrelay-automation-bridge-for-n8n'), [ 'status' => 403 ] ), 'delete_meta' ); 114 118 } 119 $own = \DirectRelay\Agent_Profiles::assert_owns( (array) $req->get_param( '_amp_key' ), $id ); 120 if ( is_wp_error( $own ) ) return self::fail( $req, $own, 'delete_meta' ); 115 121 $r = delete_post_meta( $id, $key ); 116 122 return self::ok( $req, [ 'deleted' => $r, 'key' => $key, 'post_id' => $id ], 200, 'delete_meta', $id, $expected ); … … 125 131 return true; /* Use /seo endpoint for these */ 126 132 } 133 } 134 /* Plugin-internal identity stamps are never exposed or writable. */ 135 if ( str_starts_with( $key, '_directrelay' ) || str_starts_with( $key, 'directrelay_' ) ) { 136 return true; 127 137 } 128 138 return false; -
directrelay-automation-bridge-for-n8n/trunk/includes/REST/Openapi_Controller.php
r3699228 r3706276 52 52 'pages' => rest_url( self::NS ) . '/pages', 53 53 'media' => rest_url( self::NS ) . '/media', 54 'fleet' => rest_url( self::NS ) . '/fleet/health',55 54 'webhooks' => rest_url( self::NS ) . '/webhooks', 56 55 'seo' => rest_url( self::NS ) . '/seo', … … 345 344 'get' => $priv( 'GET /webhooks/{id}/deliveries - Recent delivery log' ), 346 345 ), 347 '/fleet/health' => array(348 'get' => $priv( 'GET /fleet/health - Fleet health summary' ),349 ),350 '/fleet/keys/provision' => array(351 'post' => $priv( 'POST /fleet/keys/provision - Provision a new fleet key' ),352 ),353 346 '/discovery/fields' => array( 354 347 'get' => $priv( 'GET /discovery/fields - Custom-field discovery' ), -
directrelay-automation-bridge-for-n8n/trunk/includes/REST/Pages_Controller.php
r3699228 r3706276 37 37 $body = $req->get_json_params() ?: []; 38 38 $key = $req->get_param( '_amp_key' ); 39 // The free plugin has no monthly dispatch / request limit (s).40 // The previous add-on-limit (s)guard was removed because the WP.org39 // The free plugin has no monthly dispatch / request limit. 40 // The previous add-on-limit guard was removed because the WP.org 41 41 // Plugin Directory Guidelines forbid usage limits in the free 42 42 // build. The directrelay-pro add-on may re-introduce a 43 // limit(s) check via the `directrelay_ext_guardrails_check` filter. 43 // limit check via the `directrelay_ext_guardrails_check` filter. 44 if ( ! \DirectRelay\Agent_Profiles::is_status_allowed( $key, (string) ( $body['status'] ?? 'draft' ) ) ) { 45 return self::fail( $req, new \WP_Error( 46 'amp_agent_forbidden', 47 __( 'This agent role cannot publish content directly. Submit as a draft instead.', 'directrelay-automation-bridge-for-n8n' ), 48 [ 'status' => 403 ] 49 ), 'create_page' ); 50 } 44 51 $id = Post_Manager::create( $body, 'page' ); 45 52 if ( is_wp_error( $id ) ) return self::fail( $req, $id, 'create_page' ); 46 if ( ! empty( $body['seo'] ) && \DirectRelay\Api_Auth::has_scope( $key, 'seo:write' ) ) { 47 Rankmath_Handler::update( $id, $body['seo'] ); 53 \DirectRelay\Agent_Profiles::stamp_post( (int) $id, $key ); 54 $page = Post_Manager::format( get_post( $id ) ); 55 if ( ! empty( $body['seo'] ) ) { 56 /* Free build is read-only for SEO: the add-on owns SEO writes. */ 57 $seo_state = Rankmath_Handler::handle_inline_write( 58 (int) $id, 59 is_array( $body['seo'] ) ? $body['seo'] : [], 60 \DirectRelay\Api_Auth::has_scope( $key, 'seo:write' ) 61 ); 62 $page['seo_write'] = $seo_state['write']; 63 $page['seo'] = $seo_state['seo']; 64 if ( ! empty( $seo_state['error'] ) ) { 65 $page['seo_write_error'] = $seo_state['error']; 66 } 67 } else { 68 $page['seo'] = Rankmath_Handler::read( $id ); 48 69 } 49 $page = Post_Manager::format( get_post( $id ) );50 $page['seo'] = Rankmath_Handler::read( $id );51 70 return self::ok( $req, [ 'page' => $page ], 201, 'create_page', $id, 'page' ); 52 71 } … … 66 85 $post = get_post( $id ); 67 86 if ( ! $post || $post->post_type !== 'page' ) return self::fail( $req, new \WP_Error( 'amp_not_found', __( 'Page not found.', 'directrelay-automation-bridge-for-n8n'), [ 'status' => 404 ] ), 'update_page' ); 87 $key = $req->get_param( '_amp_key' ); 88 if ( ! empty( $body['status'] ) && ! \DirectRelay\Agent_Profiles::is_status_allowed( $key, (string) $body['status'] ) ) { 89 return self::fail( $req, new \WP_Error( 90 'amp_agent_forbidden', 91 __( 'This agent role cannot publish content directly. Submit as a draft instead.', 'directrelay-automation-bridge-for-n8n' ), 92 [ 'status' => 403 ] 93 ), 'update_page' ); 94 } 95 $own = \DirectRelay\Agent_Profiles::assert_owns( $key, $id ); 96 if ( is_wp_error( $own ) ) return self::fail( $req, $own, 'update_page' ); 68 97 $r = Post_Manager::update( $id, $body ); 69 98 if ( is_wp_error( $r ) ) return self::fail( $req, $r, 'update_page' ); 70 $key = $req->get_param( '_amp_key' ); 71 if ( ! empty( $body['seo'] ) && \DirectRelay\Api_Auth::has_scope( $key, 'seo:write' ) ) { 72 Rankmath_Handler::update( $id, $body['seo'] ); 99 $updated = Post_Manager::format( get_post( $id ) ); 100 if ( ! empty( $body['seo'] ) ) { 101 /* Free build is read-only for SEO: the add-on owns SEO writes. */ 102 $seo_state = Rankmath_Handler::handle_inline_write( 103 (int) $id, 104 is_array( $body['seo'] ) ? $body['seo'] : [], 105 \DirectRelay\Api_Auth::has_scope( $key, 'seo:write' ) 106 ); 107 $updated['seo_write'] = $seo_state['write']; 108 $updated['seo'] = $seo_state['seo']; 109 if ( ! empty( $seo_state['error'] ) ) { 110 $updated['seo_write_error'] = $seo_state['error']; 111 } 112 } else { 113 $updated['seo'] = Rankmath_Handler::read( $id ); 73 114 } 74 $updated = Post_Manager::format( get_post( $id ) );75 $updated['seo'] = Rankmath_Handler::read( $id );76 115 return self::ok( $req, [ 'page' => $updated ], 200, 'update_page', $id, 'page' ); 77 116 } … … 83 122 return self::fail( $req, new \WP_Error( 'amp_not_found', 'Page not found.', [ 'status' => 404 ] ), 'delete_page' ); 84 123 } 124 $own = \DirectRelay\Agent_Profiles::assert_owns( (array) $req->get_param( '_amp_key' ), $id ); 125 if ( is_wp_error( $own ) ) return self::fail( $req, $own, 'delete_page' ); 85 126 $r = wp_delete_post( $id, (bool) $req->get_param( 'force' ) ); 86 127 return self::ok( $req, [ 'deleted' => (bool) $r, 'id' => $id ], 200, 'delete_page', $id, 'page' ); -
directrelay-automation-bridge-for-n8n/trunk/includes/REST/Posts_Controller.php
r3699228 r3706276 45 45 $body = $req->get_json_params() ?: []; 46 46 $key = $req->get_param( '_amp_key' ); 47 // The free plugin has no monthly dispatch / request limit (s).48 // The previous add-on-limit (s)guard was removed because the WP.org47 // The free plugin has no monthly dispatch / request limit. 48 // The previous add-on-limit guard was removed because the WP.org 49 49 // Plugin Directory Guidelines forbid usage limits in the free 50 50 // build. The directrelay-pro add-on may re-introduce a 51 // limit(s) check via the `directrelay_ext_guardrails_check` filter. 51 // limit check via the `directrelay_ext_guardrails_check` filter. 52 if ( ! \DirectRelay\Agent_Profiles::is_status_allowed( $key, (string) ( $body['status'] ?? 'draft' ) ) ) { 53 return self::fail( $req, new \WP_Error( 54 'amp_agent_forbidden', 55 __( 'This agent role cannot publish content directly. Submit as a draft instead.', 'directrelay-automation-bridge-for-n8n' ), 56 [ 'status' => 403 ] 57 ), 'create_post' ); 58 } 52 59 $id = Post_Manager::create( $body, 'post' ); 53 60 if ( is_wp_error( $id ) ) { 54 61 return self::fail( $req, $id, 'create_post' ); 55 62 } 56 if ( ! empty( $body['seo'] ) && \DirectRelay\Api_Auth::has_scope( $key, 'seo:write' ) ) { 57 Rankmath_Handler::update( $id, $body['seo'] ); 63 /* Identity stamps + status gate make agent-role limits enforceable. */ 64 \DirectRelay\Agent_Profiles::stamp_post( (int) $id, $key ); 65 $post = Post_Manager::format( get_post( $id ) ); 66 if ( ! empty( $body['seo'] ) ) { 67 /* Free build is read-only for SEO: the add-on owns SEO writes. */ 68 $seo_state = Rankmath_Handler::handle_inline_write( 69 (int) $id, 70 is_array( $body['seo'] ) ? $body['seo'] : [], 71 \DirectRelay\Api_Auth::has_scope( $key, 'seo:write' ) 72 ); 73 $post['seo_write'] = $seo_state['write']; 74 $post['seo'] = $seo_state['seo']; 75 if ( ! empty( $seo_state['error'] ) ) { 76 $post['seo_write_error'] = $seo_state['error']; 77 } 78 } else { 79 $post['seo'] = Rankmath_Handler::read( $id ); 58 80 } 59 $post = Post_Manager::format( get_post( $id ) );60 $post['seo'] = Rankmath_Handler::read( $id );61 81 return self::ok( $req, [ 'post' => $post ], 201, 'create_post', $id, 'post' ); 62 82 } … … 74 94 $protected = [ '_edit_lock', '_edit_last', '_wp_old_slug', '_wp_attachment_metadata', '_wp_attached_file', '_thumbnail_id' ]; 75 95 foreach ( $meta as $key => $values ) { 76 if ( in_array( $key, $protected, true ) || str_starts_with( $key, 'directrelay_' ) ) {96 if ( in_array( $key, $protected, true ) || str_starts_with( $key, 'directrelay_' ) || str_starts_with( $key, '_directrelay' ) ) { 77 97 continue; 78 98 } … … 100 120 return self::fail( $req, new \WP_Error( 'amp_not_found', __( 'Post not found.', 'directrelay-automation-bridge-for-n8n' ), [ 'status' => 404 ] ), 'update_post' ); 101 121 } 122 $key = $req->get_param( '_amp_key' ); 123 if ( ! empty( $body['status'] ) && ! \DirectRelay\Agent_Profiles::is_status_allowed( $key, (string) $body['status'] ) ) { 124 return self::fail( $req, new \WP_Error( 125 'amp_agent_forbidden', 126 __( 'This agent role cannot publish content directly. Submit as a draft instead.', 'directrelay-automation-bridge-for-n8n' ), 127 [ 'status' => 403 ] 128 ), 'update_post' ); 129 } 130 $own = \DirectRelay\Agent_Profiles::assert_owns( $key, $id ); 131 if ( is_wp_error( $own ) ) { 132 return self::fail( $req, $own, 'update_post' ); 133 } 102 134 $r = Post_Manager::update( $id, $body ); 103 135 if ( is_wp_error( $r ) ) { 104 136 return self::fail( $req, $r, 'update_post' ); 105 137 } 106 $key = $req->get_param( '_amp_key' ); 107 if ( ! empty( $body['seo'] ) && \DirectRelay\Api_Auth::has_scope( $key, 'seo:write' ) ) { 108 Rankmath_Handler::update( $id, $body['seo'] ); 138 $updated = Post_Manager::format( get_post( $id ) ); 139 if ( ! empty( $body['seo'] ) ) { 140 /* Free build is read-only for SEO: the add-on owns SEO writes. */ 141 $seo_state = Rankmath_Handler::handle_inline_write( 142 (int) $id, 143 is_array( $body['seo'] ) ? $body['seo'] : [], 144 \DirectRelay\Api_Auth::has_scope( $key, 'seo:write' ) 145 ); 146 $updated['seo_write'] = $seo_state['write']; 147 $updated['seo'] = $seo_state['seo']; 148 if ( ! empty( $seo_state['error'] ) ) { 149 $updated['seo_write_error'] = $seo_state['error']; 150 } 151 } else { 152 $updated['seo'] = Rankmath_Handler::read( $id ); 109 153 } 110 $updated = Post_Manager::format( get_post( $id ) );111 $updated['seo'] = Rankmath_Handler::read( $id );112 154 return self::ok( $req, [ 'post' => $updated ], 200, 'update_post', $id, 'post' ); 113 155 } … … 117 159 if ( ! get_post( $id ) ) { 118 160 return self::fail( $req, new \WP_Error( 'amp_not_found', __( 'Post not found.', 'directrelay-automation-bridge-for-n8n' ), [ 'status' => 404 ] ), 'delete_post' ); 161 } 162 $own = \DirectRelay\Agent_Profiles::assert_owns( (array) $req->get_param( '_amp_key' ), $id ); 163 if ( is_wp_error( $own ) ) { 164 return self::fail( $req, $own, 'delete_post' ); 119 165 } 120 166 $r = wp_delete_post( $id, (bool) $req->get_param( 'force' ) ); … … 128 174 return self::fail( $req, new \WP_Error( 'amp_not_found', __( 'Post not found.', 'directrelay-automation-bridge-for-n8n' ), [ 'status' => 404 ] ), 'publish_post' ); 129 175 } 176 $own = \DirectRelay\Agent_Profiles::assert_owns( (array) $req->get_param( '_amp_key' ), $id ); 177 if ( is_wp_error( $own ) ) { 178 return self::fail( $req, $own, 'publish_post' ); 179 } 130 180 $r = wp_update_post( [ 'ID' => $id, 'post_status' => 'publish' ], true ); 131 181 if ( is_wp_error( $r ) ) { -
directrelay-automation-bridge-for-n8n/trunk/includes/REST/Webhook_Controller.php
r3699228 r3706276 46 46 'settings' => [ 47 47 'max_webhooks' => (int) ( get_option( 'directrelay_settings', [] )['max_webhooks'] ?? 50 ), 48 'webhook_max_retries' => (int) ( get_option( 'directrelay_settings', [] )[' webhook_max_retries'] ?? 3 ),48 'webhook_max_retries' => (int) ( get_option( 'directrelay_settings', [] )['max_retry_attempts'] ?? 3 ), 49 49 'log_retention_days' => (int) ( get_option( 'directrelay_settings', [] )['log_retention_days'] ?? 90 ), 50 50 ], -
directrelay-automation-bridge-for-n8n/trunk/includes/Rankmath_Handler.php
r3699228 r3706276 93 93 return $data; 94 94 } 95 96 /** 97 * Handle an inline `seo` payload on post/page create/update. 98 * 99 * The free plugin is strictly read-only for SEO (WP.org serviceware 100 * guideline): it contains no SEO write code. The add-on plugin may 101 * implement the write by filtering `directrelay_ext_seo_update` and 102 * returning a non-null result: 103 * 104 * add_filter( 'directrelay_ext_seo_update', function ( $result, $post_id, $seo ) { 105 * // write the fields here, then return the write result, 106 * // or a WP_Error on failure. Return null to pass. 107 * }, 10, 3 ); 108 * 109 * Without the add-on the write is reported as skipped so clients never 110 * assume the fields were applied (and no undefined-method fatal can 111 * recur the way the pre-5.1.23 write path did). 112 * 113 * @param int $post_id Created/updated post ID. 114 * @param array $seo Inline seo payload from the request body. 115 * @param bool $can_write Whether the authenticated key holds seo:write. 116 * @return array { write: string, seo: array, error?: string } 117 * write: handled_addon | skipped_addon | missing_scope | addon_error. 118 */ 119 public static function handle_inline_write( int $post_id, array $seo, bool $can_write ): array { 120 $result = null; 121 if ( $can_write ) { 122 $result = apply_filters( 'directrelay_ext_seo_update', null, $post_id, $seo ); 123 } 124 125 $state = [ 126 'write' => 'skipped_addon', 127 'seo' => self::read( $post_id ), 128 ]; 129 if ( ! $can_write ) { 130 $state['write'] = 'missing_scope'; 131 } elseif ( is_wp_error( $result ) ) { 132 $state['write'] = 'addon_error'; 133 $state['error'] = $result->get_error_message(); 134 } elseif ( null !== $result ) { 135 $state['write'] = 'handled_addon'; 136 } 137 return $state; 138 } 95 139 } -
directrelay-automation-bridge-for-n8n/trunk/includes/Rate_Limiter.php
r3699228 r3706276 8 8 /** 9 9 * Quota is enforced per site, not per API key, so revoking and 10 * re-creating a key does not reset the counter.10 * re-creating a key does not reset the counter. 11 11 */ 12 12 private const SITE_BUCKET = 'site'; … … 22 22 private static function tier_limits(): array { 23 23 $s = get_option( 'directrelay_settings', array() ); 24 return array( (int) ( $s['rate_limit'] ?? 150 ), (int) ( $s['rate_window_sec'] ?? 86400 ) ); 24 /* Fallbacks match the Installer defaults (120 requests / 60s window). */ 25 return array( (int) ( $s['rate_limit'] ?? 120 ), (int) ( $s['rate_window_sec'] ?? 60 ) ); 25 26 } 26 27 … … 30 31 31 32 [ $limit, $window ] = self::tier_limits(); 32 /* $key_id is unused for bucketing: the limit (s)bucket is per site. */33 /* $key_id is unused for bucketing: the limit bucket is per site. */ 33 34 $bucket = self::SITE_BUCKET; 34 35 $table = "{$wpdb->prefix}directrelay_rate"; … … 103 104 /** 104 105 * Read-only remaining-requests lookup for the site bucket. 105 * Never writes; an expired or missing window counts as full limit (s).106 * $key_id is unused for bucketing: the limit (s)bucket is per site.106 * Never writes; an expired or missing window counts as full limit. 107 * $key_id is unused for bucketing: the limit bucket is per site. 107 108 */ 108 109 public static function get_remaining( int $key_id ): int { … … 121 122 } 122 123 123 /* Expired window: the next check() resets the bucket, so full limit (s)is available. */124 if ( ( time() - (int) strtotime( $row['window_open'] ) ) >= $window ) {124 /* Expired window: the next check() resets the bucket, so full limit is available. */ 125 if ( ( time() - (int) strtotime( $row['window_open'] . ' UTC' ) ) >= $window ) { 125 126 return $limit; 126 127 } -
directrelay-automation-bridge-for-n8n/trunk/includes/Transactional_Publisher.php
r3699228 r3706276 121 121 } 122 122 123 // Stage 4: SEO Metadata Application123 // Stage 4: SEO Metadata (read-only in the free build) 124 124 $execution_steps['seo'] = 'running'; 125 125 $seo_result = null; 126 126 if ( ! empty( $payload['seo'] ) && is_array( $payload['seo'] ) ) { 127 $seo_result = Rankmath_Handler::update( $created_post_id, $payload['seo'] ); 128 if ( is_wp_error( $seo_result ) && $strict_rollback ) { 129 /* translators: %s: error message detail */ 130 throw new \Exception( sprintf( __( 'SEO update failed: %s', 'directrelay-automation-bridge-for-n8n' ), $seo_result->get_error_message() ) ); 131 } 132 } 133 $execution_steps['seo'] = 'completed'; 134 135 // Stage 5: Approval Gateway Check (add-on feature(s); free skips silently) 127 /* 128 * The free plugin is strictly read-only for SEO (WP.org 129 * serviceware guideline — SEO write code must not live in the 130 * WP.org build). The add-on plugin supplies its own SEO writer 131 * and applies it after this transaction returns. Report the 132 * current SEO state instead of writing. 133 */ 134 $execution_steps['seo'] = 'skipped_addon'; 135 $seo_result = Rankmath_Handler::read( $created_post_id ); 136 } else { 137 $execution_steps['seo'] = 'completed'; 138 } 139 140 // Stage 5: Approval Gateway Check (add-on features; free skips silently) 136 141 $approval_data = null; 137 142 if ( $require_approval && class_exists( '\DirectRelay\Approval_Gateway' ) ) { … … 149 154 \DirectRelay\Guardrails_Manager::increment_daily_usage( (int) ( $key['id'] ?? 0 ) ); 150 155 } 151 IndexNow_Pinger::ping( [ get_permalink( $created_post_id ) ], $created_post_id ); 156 /* IndexNow is opt-in (same gate as IndexNow_Pinger::init()). 157 * Posts are already pinged by the transition_post_status 158 * listener when the setting is on; this covers other post 159 * types (e.g. pages) so each URL pings exactly once. */ 160 $directrelay_settings = get_option( 'directrelay_settings', [] ); 161 if ( ! empty( $directrelay_settings['enable_indexnow'] ) && 'post' !== $post_type ) { 162 IndexNow_Pinger::ping( [ get_permalink( $created_post_id ) ], $created_post_id ); 163 } 152 164 } 153 165 } … … 183 195 wp_delete_post( $created_post_id, true ); 184 196 $rollback_telemetry['deleted_post_id'] = $created_post_id; 197 198 /* Unschedule webhook deliveries still queued for the 199 * rolled-back post so receivers never get events for a 200 * post that no longer exists. fire() args are 201 * [event, id, type]; deliver args are 202 * [hook_id, event, id, type, attempt(, log_id)]. */ 203 $unscheduled = 0; 204 $crons = _get_cron_array() ?: []; 205 foreach ( $crons as $timestamp => $cron_events ) { 206 foreach ( [ 'directrelay_fire_webhook', 'directrelay_deliver_webhook' ] as $cron_hook ) { 207 if ( empty( $cron_events[ $cron_hook ] ) ) { 208 continue; 209 } 210 foreach ( $cron_events[ $cron_hook ] as $event_entry ) { 211 $args = $event_entry['args'] ?? []; 212 $obj_id = ( 'directrelay_fire_webhook' === $cron_hook ) ? ( $args[1] ?? null ) : ( $args[2] ?? null ); 213 if ( (int) $obj_id === (int) $created_post_id ) { 214 wp_unschedule_event( $timestamp, $cron_hook, $args ); 215 $unscheduled++; 216 } 217 } 218 } 219 } 220 if ( $unscheduled > 0 ) { 221 $rollback_telemetry['unscheduled_webhook_events'] = $unscheduled; 222 } 185 223 } 186 224 } … … 202 240 203 241 /** 204 * Sideload an image URL into WordPress media library with SSRF checks. 242 * Sideload an image URL into the WordPress media library. 243 * 244 * The fetch itself lives in Post_Manager::sideload_url(), which re-validates 245 * SSRF safety on every redirect hop; this wrapper only adds the ALT text. 205 246 */ 206 247 public static function sideload_image( string $url, string $title = '' ): int|\WP_Error { 207 require_once ABSPATH . 'wp-admin/includes/media.php'; 208 require_once ABSPATH . 'wp-admin/includes/file.php'; 209 require_once ABSPATH . 'wp-admin/includes/image.php'; 210 211 $safe_url = esc_url_raw( $url ); 212 if ( ! Api_Auth::is_url_safe( $safe_url ) ) { 213 return new \WP_Error( 'amp_ssrf_blocked', __( 'SSRF protection: Image URL is blocked.', 'directrelay-automation-bridge-for-n8n' ), [ 'status' => 403 ] ); 214 } 215 216 $att_id = media_sideload_image( $safe_url, 0, null, 'id' ); 248 $att_id = Post_Manager::sideload_url( $url, 0, $title ); 217 249 if ( is_wp_error( $att_id ) ) { 218 250 return $att_id; … … 221 253 if ( ! empty( $title ) ) { 222 254 update_post_meta( (int) $att_id, '_wp_attachment_image_alt', sanitize_text_field( $title ) ); 223 wp_update_post( [224 'ID' => (int) $att_id,225 'post_title' => sanitize_text_field( $title ),226 ] );227 255 } 228 256 -
directrelay-automation-bridge-for-n8n/trunk/includes/Webhook_Manager.php
r3699228 r3706276 30 30 'post_types' => [ 'post', 'page' ], 31 31 'description' => 'When an article is published, n8n automatically posts the title, link, and featured image to X/Twitter, LinkedIn, Facebook Pages, Telegram, and Pinterest.', 32 'badge' => 'PR O PRESET',33 'is_pro' => true,32 'badge' => 'PRESET', 33 'is_pro' => false, 34 34 'workflow' => '{"name":"DirectRelay - Social Media Blast","nodes":[{"parameters":{"httpMethod":"POST","path":"directrelay-social-blast","options":{}},"name":"DirectRelay Webhook","type":"n8n-nodes-base.webhook","typeVersion":1,"position":[200,300]},{"parameters":{"values":{"string":[{"name":"title","value":"={{$json.body.post.title}}"},{"name":"url","value":"={{$json.body.post.permalink}}"},{"name":"image","value":"={{$json.body.post.featured_image}}"},{"name":"keyword","value":"={{$json.body.seo.focus_keyword}}"}]},"options":{}},"name":"Format Payload","type":"n8n-nodes-base.set","typeVersion":2,"position":[400,300]},{"parameters":{"content":"## 🌐 Multi-Platform Social Blast\\nDispatches published WordPress articles to Twitter/X, LinkedIn, Facebook, Telegram & Pinterest automatically.","height":180,"width":400},"name":"Notes","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[200,100]}],"connections":{"DirectRelay Webhook":{"main":[[{"node":"Format Payload","type":"main","index":0}]]}}}', 35 35 ], … … 41 41 'post_types' => [ 'post' ], 42 42 'description' => 'Sends full article to Claude/ChatGPT in n8n to automatically generate: (a) A newsletter summary, (b) 5 tweet bullet points, (c) An ElevenLabs audio podcast episode.', 43 'badge' => 'PR O PRESET',44 'is_pro' => true,43 'badge' => 'PRESET', 44 'is_pro' => false, 45 45 'workflow' => '{"name":"DirectRelay - AI Content Repurposing","nodes":[{"parameters":{"httpMethod":"POST","path":"directrelay-ai-repurpose","options":{}},"name":"DirectRelay Webhook","type":"n8n-nodes-base.webhook","typeVersion":1,"position":[200,300]},{"parameters":{"model":"gpt-4o","prompt":"={{$json.body.post.content}}","systemPrompt":"Generate: 1. A 3-sentence newsletter summary. 2. A 5-tweet thread. 3. A 60-second podcast audio script."},"name":"AI Repurposer (LLM)","type":"n8n-nodes-base.openAi","typeVersion":1,"position":[420,300]}],"connections":{"DirectRelay Webhook":{"main":[[{"node":"AI Repurposer (LLM)","type":"main","index":0}]]}}}', 46 46 ], … … 52 52 'post_types' => [ 'post', 'page' ], 53 53 'description' => 'n8n instantly submits the updated URL and Rank Math canonical link to Google Indexing API and IndexNow (Bing / Yandex).', 54 'badge' => 'PR O PRESET',55 'is_pro' => true,54 'badge' => 'PRESET', 55 'is_pro' => false, 56 56 'workflow' => '{"name":"DirectRelay - Instant Search Indexing","nodes":[{"parameters":{"httpMethod":"POST","path":"directrelay-search-index","options":{}},"name":"DirectRelay Webhook","type":"n8n-nodes-base.webhook","typeVersion":1,"position":[200,300]},{"parameters":{"method":"POST","url":"https://api.indexnow.org/indexnow","sendBody":true,"specifyBody":"json","jsonBody":"={\\n \\\"host\\\": \\\"{{$json.body.site_url.replace(\'https://\', \'\').replace(\'http://\', \'\')}}\\\",\\n \\\"key\\\": \\\"YOUR_INDEXNOW_KEY\\\",\\n \\\"urlList\\\": [\\\"{{$json.body.post.permalink}}\\\"]\\n}"},"name":"IndexNow Ping","type":"n8n-nodes-base.httpRequest","typeVersion":4.1,"position":[440,300]}],"connections":{"DirectRelay Webhook":{"main":[[{"node":"IndexNow Ping","type":"main","index":0}]]}}}', 57 57 ], … … 63 63 'post_types' => [ 'post', 'page' ], 64 64 'description' => 'Keeps external search indexes like Algolia, Meilisearch, or Typesense synchronized without running heavy server-side sync cron jobs.', 65 'badge' => 'PR O PRESET',66 'is_pro' => true,65 'badge' => 'PRESET', 66 'is_pro' => false, 67 67 'workflow' => '{"name":"DirectRelay - Search Index Sync","nodes":[{"parameters":{"httpMethod":"POST","path":"directrelay-search-sync","options":{}},"name":"DirectRelay Webhook","type":"n8n-nodes-base.webhook","typeVersion":1,"position":[200,300]},{"parameters":{"rules":{"values":[{"conditions":{"string":[{"value1":"={{$json.body.event}}","value2":"post.deleted"}]}}]}},"name":"Check Event Type","type":"n8n-nodes-base.switch","typeVersion":1,"position":[420,300]}],"connections":{"DirectRelay Webhook":{"main":[[{"node":"Check Event Type","type":"main","index":0}]]}}}', 68 68 ], … … 74 74 'post_types' => [ 'post', 'page' ], 75 75 'description' => 'Sends a message to a team Slack channel or Discord server with post details, author, and quick review links.', 76 'badge' => 'PR O PRESET',77 'is_pro' => true,76 'badge' => 'PRESET', 77 'is_pro' => false, 78 78 'workflow' => '{"name":"DirectRelay - Team Slack Alert","nodes":[{"parameters":{"httpMethod":"POST","path":"directrelay-slack-alert","options":{}},"name":"DirectRelay Webhook","type":"n8n-nodes-base.webhook","typeVersion":1,"position":[200,300]},{"parameters":{"channel":"#content-publishing","text":"=📰 *New Article Published!*\\n*Title:* {{$json.body.post.title}}\\n*Author:* {{$json.body.post.author}}\\n*Link:* {{$json.body.post.permalink}}"},"name":"Slack Notification","type":"n8n-nodes-base.slack","typeVersion":1,"position":[440,300]}],"connections":{"DirectRelay Webhook":{"main":[[{"node":"Slack Notification","type":"main","index":0}]]}}}', 79 79 ], … … 85 85 'post_types' => [ 'post', 'page' ], 86 86 'description' => 'Calls Cloudflare / BunnyCDN API to purge cache for that specific URL immediately.', 87 'badge' => 'PR O PRESET',88 'is_pro' => true,87 'badge' => 'PRESET', 88 'is_pro' => false, 89 89 'workflow' => '{"name":"DirectRelay - Cloudflare Cache Purge","nodes":[{"parameters":{"httpMethod":"POST","path":"directrelay-cache-purge","options":{}},"name":"DirectRelay Webhook","type":"n8n-nodes-base.webhook","typeVersion":1,"position":[200,300]},{"parameters":{"method":"POST","url":"https://api.cloudflare.com/client/v4/zones/YOUR_ZONE_ID/purge_cache","sendHeaders":true,"headerParameters":{"parameters":[{"name":"Authorization","value":"Bearer YOUR_CF_API_TOKEN"}]},"sendBody":true,"specifyBody":"json","jsonBody":"={\\n \\\"files\\\": [\\\"{{$json.body.post.permalink}}\\\"]\\n}"},"name":"Purge Cloudflare Cache","type":"n8n-nodes-base.httpRequest","typeVersion":4.1,"position":[440,300]}],"connections":{"DirectRelay Webhook":{"main":[[{"node":"Purge Cloudflare Cache","type":"main","index":0}]]}}}', 90 90 ], … … 96 96 'post_types' => [ 'post', 'attachment' ], 97 97 'description' => 'Converts the post to Markdown and commits it to a private GitHub repository or uploads media to an AWS S3 / Cloudflare R2 bucket.', 98 'badge' => 'PR O PRESET',99 'is_pro' => true,98 'badge' => 'PRESET', 99 'is_pro' => false, 100 100 'workflow' => '{"name":"DirectRelay - GitHub & S3 Backup","nodes":[{"parameters":{"httpMethod":"POST","path":"directrelay-backup","options":{}},"name":"DirectRelay Webhook","type":"n8n-nodes-base.webhook","typeVersion":1,"position":[200,300]},{"parameters":{"content":"## 💾 Backup & Archival\\nConverts post JSON into Markdown and pushes to GitHub repository. For media uploads, transfers file to S3 bucket.","height":160,"width":380},"name":"Backup Info","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[200,100]}],"connections":{}}', 101 101 ], … … 108 108 109 109 public static function init(): void { 110 /* Retention must run even when webhooks are disabled, otherwise 111 * existing webhook_log rows are never pruned. */ 112 add_action( 'directrelay_prune_rate_limits', [ __CLASS__, 'prune_logs' ] ); 113 110 114 $s = get_option( 'directrelay_settings', [] ); 111 115 if ( empty( $s['enable_webhooks'] ) ) { … … 121 125 /* Individual delivery & retry worker */ 122 126 add_action( 'directrelay_deliver_webhook', [ __CLASS__, 'deliver_worker' ], 10, 6 ); 123 124 /* Hourly prune of webhook delivery logs */125 add_action( 'directrelay_prune_rate_limits', [ __CLASS__, 'prune_logs' ] );126 127 } 127 128 … … 137 138 global $wpdb; 138 139 $table = "{$wpdb->prefix}directrelay_rate"; 139 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared 140 /* PHP-side date math for SQLite portability; window_open is UTC. */ 141 $cutoff = gmdate( 'Y-m-d H:i:s', time() - 30 * DAY_IN_SECONDS ); 142 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom table. 140 143 $row = $wpdb->get_row( 141 144 $wpdb->prepare( 145 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $table is prefix + static name, not user input. 142 146 "SELECT hits, window_open FROM {$table} 143 WHERE bucket = %s AND window_open >= DATE_SUB(UTC_TIMESTAMP(), INTERVAL 30 DAY)", 144 'webhook_site' 147 WHERE bucket = %s AND window_open >= %s", 148 'webhook_site', 149 $cutoff 145 150 ), 146 151 ARRAY_A 147 152 ); 148 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared149 153 $total = $row ? (int) $row['hits'] : 0; 150 154 $limit = PHP_INT_MAX; … … 153 157 'limit' => $limit, 154 158 'remaining' => max( 0, $limit - $total ), 155 'is_pro' => true,159 'is_pro' => false, 156 160 ]; 157 161 } … … 160 164 * Check and record a webhook dispatch. 161 165 * 162 * The free plugin does not enforce a monthly dispatch limit (s)—166 * The free plugin does not enforce a monthly dispatch limit — 163 167 * the WP.org Plugin Directory Guidelines forbid usage limits in 164 168 * the free build. The directrelay-pro add-on may hook into the 165 169 * `directrelay_ext_guardrails_check` filter to re-introduce a 166 * limit (s). This method is kept as a stable entry point so any170 * limit. This method is kept as a stable entry point so any 167 171 * custom code that already calls it does not break. 168 172 * … … 178 182 public static function prune_logs(): void { 179 183 global $wpdb; 180 $days = (int) ( get_option( 'directrelay_settings', [] )['log_retention_days'] ?? 90 ); 181 $table = "{$wpdb->prefix}directrelay_webhook_log"; 182 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared 184 $days = (int) ( get_option( 'directrelay_settings', [] )['log_retention_days'] ?? 90 ); 185 $table = "{$wpdb->prefix}directrelay_webhook_log"; 186 /* PHP-side date math for SQLite portability; created_at is UTC. */ 187 $cutoff = gmdate( 'Y-m-d H:i:s', time() - $days * DAY_IN_SECONDS ); 188 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom table maintenance. 183 189 $wpdb->query( 184 190 $wpdb->prepare( 185 "DELETE FROM {$table} WHERE created_at < DATE_SUB(UTC_TIMESTAMP(), INTERVAL %d DAY)", 186 $days 191 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $table is prefix + static name, not user input. 192 "DELETE FROM {$table} WHERE created_at < %s", 193 $cutoff 187 194 ) 188 195 ); 189 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared190 196 } 191 197 … … 199 205 return; 200 206 } 207 if ( in_array( $post->post_status, [ 'auto-draft', 'new' ], true ) ) { 208 return; /* Gutenberg's empty editor shell is not a content event. */ 209 } 201 210 $type = $post->post_type; 202 self::schedule_fire( $update ? "{$type}.updated" : "{$type}.created", $id, $type ); 211 /* 212 * wp_insert_post reports $update === true even for the first real save 213 * of a Gutenberg post (its auto-draft row predates it), so created vs 214 * updated is tracked with a marker meta instead of the $update flag. 215 */ 216 $seen = get_post_meta( $id, '_directrelay_event_seen', true ); 217 if ( ! $seen ) { 218 update_post_meta( $id, '_directrelay_event_seen', 1 ); 219 self::schedule_fire( "{$type}.created", $id, $type ); 220 } else { 221 self::schedule_fire( "{$type}.updated", $id, $type ); 222 } 203 223 if ( $post->post_status === 'publish' ) { 204 224 self::schedule_fire( "{$type}.published", $id, $type ); … … 234 254 235 255 public static function fire( string $event, int $object_id, string $object_type ): void { 236 // The free plugin has no monthly dispatch limit (s). The previous256 // The free plugin has no monthly dispatch limit. The previous 237 257 // add-on monthly cap was removed because the WP.org Plugin 238 258 // Directory Guidelines forbid usage limits in the free build. 239 // The directrelay-pro add-on may re-introduce a limit (s)via the259 // The directrelay-pro add-on may re-introduce a limit via the 240 260 // `directrelay_ext_guardrails_check` filter. 241 261 global $wpdb; … … 250 270 251 271 foreach ( $hooks as $hook ) { 252 $events = json_decode( $hook['events'] ,true ) ?: [];253 $types = json_decode( $hook['post_types'] , true ) ?: [];272 $events = json_decode( $hook['events'] ?? '[]', true ) ?: []; 273 $types = json_decode( $hook['post_types'] ?? '[]', true ) ?: []; 254 274 if ( ! in_array( $event, $events, true ) ) { 255 275 continue; 256 276 } 257 if ( $object_type !== 'attachment' && ! in_array( $object_type, $types, true ) ) { 277 /* media.uploaded is delivered only to webhooks whose post_types 278 * list includes 'attachment' — the same filter posts/pages use. */ 279 if ( ! in_array( $object_type, $types, true ) ) { 258 280 continue; 259 281 } … … 343 365 if ( ! empty( $plain_secret ) ) { 344 366 $headers['X-directrelay-Signature'] = 'sha256=' . hash_hmac( 'sha256', $body, $plain_secret ); 367 368 /* 369 * Replay protection: a fresh timestamp plus a second signature over 370 * "<timestamp>.<body>". Receivers that need it verify this signature 371 * and reject deliveries whose timestamp is older than ~5 minutes. 372 * The body-only signature above is unchanged for existing n8n 373 * verification workflows. 374 */ 375 $ts = (string) time(); 376 $headers['X-directrelay-Timestamp'] = $ts; 377 $headers['X-directrelay-Signature-Timestamp'] = 'sha256=' . hash_hmac( 'sha256', $ts . '.' . $body, $plain_secret ); 345 378 } 346 379 … … 451 484 } 452 485 486 /** 487 * Key material for secret encryption: the site's WordPress secret keys, 488 * in fallback order. SECURE_AUTH_KEY is defined in every correctly 489 * configured wp-config.php; the chain (and the final built-in fallback) 490 * only matters on misconfigured installs. 491 */ 492 private static function secret_key(): string { 493 if ( defined( 'SECURE_AUTH_KEY' ) && SECURE_AUTH_KEY ) { 494 return SECURE_AUTH_KEY; 495 } 496 if ( defined( 'AUTH_KEY' ) && AUTH_KEY ) { 497 return AUTH_KEY; 498 } 499 if ( defined( 'LOGGED_IN_KEY' ) && LOGGED_IN_KEY ) { 500 return LOGGED_IN_KEY; 501 } 502 return 'directrelay_fallback_key'; 503 } 504 453 505 public static function encrypt_secret( string $secret ): string { 454 506 if ( empty( $secret ) ) { 455 507 return ''; 456 508 } 457 $key = defined( 'SECURE_AUTH_KEY' ) ? SECURE_AUTH_KEY : 'directrelay_fallback_key';509 $key = self::secret_key(); 458 510 $iv = openssl_random_pseudo_bytes( 16 ); 459 511 $enc = openssl_encrypt( $secret, 'aes-256-cbc', $key, 0, $iv ); … … 465 517 return ''; 466 518 } 467 $raw = base64_decode( $enc_secret ); 519 $raw = base64_decode( $enc_secret ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- storage encoding, not obfuscation. 468 520 if ( ! $raw || strlen( $raw ) <= 16 ) { 469 521 return ''; 470 522 } 471 $key = defined( 'SECURE_AUTH_KEY' ) ? SECURE_AUTH_KEY : 'directrelay_fallback_key';523 $key = self::secret_key(); 472 524 $iv = substr( $raw, 0, 16 ); 473 525 $enc = substr( $raw, 16 ); -
directrelay-automation-bridge-for-n8n/trunk/includes/admin/Ajax_Handler.php
r3699228 r3706276 141 141 // per-site cap was removed because the WP.org Plugin Directory 142 142 // Guidelines forbid usage limits in the free build. The 143 // directrelay-pro add-on may re-introduce a limit (s)via the143 // directrelay-pro add-on may re-introduce a limit via the 144 144 // `directrelay_ext_guardrails_check` filter. 145 145 … … 231 231 // per-site cap was removed because the WP.org Plugin Directory 232 232 // Guidelines forbid usage limits in the free build. The 233 // directrelay-pro add-on may re-introduce a limit (s)via the233 // directrelay-pro add-on may re-introduce a limit via the 234 234 // `directrelay_ext_guardrails_check` filter. 235 235 … … 451 451 452 452 $free_usage = null; 453 // The free plugin has no monthly request limit (s), so the453 // The free plugin has no monthly request limit, so the 454 454 // "free usage" indicator is always null. Kept as a stable 455 455 // key in the AJAX response for any client code that reads it. -
directrelay-automation-bridge-for-n8n/trunk/includes/admin/Menu.php
r3699228 r3706276 84 84 'l10n' => [ 85 85 // License strings removed in 5.1.0; the directrelay-pro 86 // add-on plugin provides its own licenceUI.86 // add-on plugin provides its own activation UI. 87 87 ], 88 88 ]; -
directrelay-automation-bridge-for-n8n/trunk/includes/admin/views/docs.php
r3699228 r3706276 580 580 const received = $input.first().headers['x-directrelay-signature']; 581 581 if (expected !== received) throw new Error('Signature mismatch'); 582 583 // Replay protection (recommended): reject deliveries older than 5 minutes. 584 const ts = Number($input.first().headers['x-directrelay-timestamp']); 585 const tsSig = $input.first().headers['x-directrelay-signature-timestamp']; 586 const tsExpected = 'sha256=' + crypto.createHmac('sha256', secret).update(ts + '.' + body).digest('hex'); 587 if (Math.abs(Date.now() / 1000 - ts) > 300 || tsExpected !== tsSig) throw new Error('Stale or invalid timestamp'); 582 588 return $input.all();</pre> 583 589 </div> -
directrelay-automation-bridge-for-n8n/trunk/includes/admin/views/keys.php
r3699228 r3706276 188 188 <label for="directrelay-key-daily-limit" style="font-size: 14px;"><?php esc_html_e( 'Daily Post Limit (Velocity Cap)', 'directrelay-automation-bridge-for-n8n' ); ?></label> 189 189 <input type="number" id="directrelay-key-daily-limit" name="daily_limit" class="directrelay-input" placeholder="0" min="0" max="10000" style="font-size: 14px; padding: 10px 14px;"> 190 <p class="directrelay-field-desc"><?php esc_html_e( ' Maximum number of publications allowed per 24 hours for this key (0 = use global guardrails).', 'directrelay-automation-bridge-for-n8n' ); ?></p>190 <p class="directrelay-field-desc"><?php esc_html_e( 'Stored with this key for guardrail use. The free plugin imposes no usage limits; enforcement requires the DirectRelay add-on plugin.', 'directrelay-automation-bridge-for-n8n' ); ?></p> 191 191 </div> 192 192 … … 438 438 <tbody> 439 439 <?php foreach ( $directrelay_keys as $directrelay_k ) : 440 $directrelay_scopes_arr = json_decode( $directrelay_k['scopes'] , true ) ?: array();440 $directrelay_scopes_arr = json_decode( $directrelay_k['scopes'] ?? '[]', true ) ?: array(); 441 441 $directrelay_is_expired = $directrelay_k['expires_at'] && strtotime( $directrelay_k['expires_at'] ) <= time(); 442 442 $directrelay_is_active = ! $directrelay_k['revoked'] && ! $directrelay_is_expired; -
directrelay-automation-bridge-for-n8n/trunk/includes/admin/views/n8n-templates.php
r3699228 r3706276 28 28 * - preview array List of node labels rendered as preview chips. 29 29 * - workflow string Raw n8n workflow JSON, used in the copy textarea. 30 * - pro bool When true the card is rendered with a add-on badge.30 * - pro bool When true the card is rendered with an add-on badge. 31 31 * 32 32 * @since 5.1.0 -
directrelay-automation-bridge-for-n8n/trunk/includes/admin/views/settings.php
r3699228 r3706276 186 186 <div class="directrelay-set-label"> 187 187 <strong><?php esc_html_e( 'IndexNow Auto-Ping', 'directrelay-automation-bridge-for-n8n'); ?></strong> 188 <p><?php esc_html_e( 'Submit published post URLs to Bing / Yandex / IndexNow for instant indexing. Off by default. When off, no external service is contacted at publish time.', 'directrelay-automation-bridge-for-n8n'); ?></p>188 <p><?php esc_html_e( 'Submit published post URLs to the IndexNow aggregator (api.indexnow.org), which routes them to participating search engines (Bing, Yandex, Seznam, Naver). Off by default - when off, no external service is contacted at publish time. The verification key is published automatically at yoursite.com/indexnow-key.txt; open that URL to confirm the key file is reachable.', 'directrelay-automation-bridge-for-n8n'); ?></p> 189 189 </div> 190 190 <div class="directrelay-set-control"> -
directrelay-automation-bridge-for-n8n/trunk/includes/admin/views/webhooks.php
r3699228 r3706276 3 3 * DirectRelay Webhooks admin view (Free). 4 4 * 5 * The free plugin has no monthly dispatch limit (s), no per-site webhook6 * cap, and no licence-state gate. The add-on plugin plugin provides the5 * The free plugin has no monthly dispatch limit, no per-site webhook 6 * cap, and no activation-state gate. The add-on plugin provides the 7 7 * preset library, event-pipeline cards, and add-on filters via the 8 8 * `directrelay_ext_templates` filter and the … … 90 90 data-label="<?php echo esc_attr( $directrelay_p['label'] ); ?>" 91 91 data-events="<?php echo esc_attr( wp_json_encode( $directrelay_p['events'] ) ); ?>" 92 data-types="<?php echo esc_attr( wp_json_encode( $directrelay_p['post_types'] ) ); ?>"> 92 data-types="<?php echo esc_attr( wp_json_encode( $directrelay_p['post_types'] ) ); ?>" 93 data-ispro="<?php echo esc_attr( empty( $directrelay_p['is_pro'] ) ? 0 : 1 ); ?>"> 93 94 <?php esc_html_e( 'Load Preset', 'directrelay-automation-bridge-for-n8n'); ?> 94 95 </button> … … 294 295 <tbody> 295 296 <?php foreach ( $directrelay_webhooks as $directrelay_wh ) : 296 $directrelay_events = json_decode( $directrelay_wh['events'] , true ) ?: array();297 $directrelay_events = json_decode( $directrelay_wh['events'] ?? '[]', true ) ?: array(); 297 298 ?> 298 299 <tr> … … 317 318 <?php echo $directrelay_wh['active'] ? esc_html__( 'Pause', 'directrelay-automation-bridge-for-n8n' ) : esc_html__( 'Activate', 'directrelay-automation-bridge-for-n8n' ); ?> 318 319 </button> 320 <button class="directrelay-btn directrelay-test-wh" data-id="<?php echo (int) $directrelay_wh['id']; ?>"> 321 <?php esc_html_e( 'Test', 'directrelay-automation-bridge-for-n8n'); ?> 322 </button> 323 <button class="directrelay-btn directrelay-view-log" data-id="<?php echo (int) $directrelay_wh['id']; ?>" data-label="<?php echo esc_attr( $directrelay_wh['label'] ); ?>"> 324 <?php esc_html_e( 'Deliveries', 'directrelay-automation-bridge-for-n8n'); ?> 325 </button> 319 326 <button class="directrelay-btn directrelay-delete-wh" data-id="<?php echo (int) $directrelay_wh['id']; ?>" style="color:var(--directrelay-red)"> 320 327 <?php esc_html_e( 'Delete', 'directrelay-automation-bridge-for-n8n'); ?> … … 329 336 </div> 330 337 331 </div> 338 <!-- Webhook Test Result Modal --> 339 <div id="directrelay-test-modal" class="directrelay-modal-overlay" style="display:none;"> 340 <div class="directrelay-modal-box" style="margin:10vh auto;max-width:640px;width:90%;background:var(--directrelay-card,#fff);border-radius:12px;padding:24px;box-shadow:0 20px 60px rgba(0,0,0,.3);"> 341 <div class="directrelay-modal-header" style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;"> 342 <strong><?php esc_html_e( 'Webhook Test Result', 'directrelay-automation-bridge-for-n8n'); ?></strong> 343 <button type="button" class="directrelay-modal-close" style="background:none;border:none;font-size:20px;cursor:pointer;line-height:1;">×</button> 344 </div> 345 <pre id="directrelay-test-result" style="white-space:pre-wrap;max-height:320px;overflow:auto;font-size:12px;"></pre> 346 </div> 347 </div> 348 349 <!-- Webhook Delivery Log Modal --> 350 <div id="directrelay-deliveries-modal" class="directrelay-modal-overlay" style="display:none;"> 351 <div class="directrelay-modal-box" style="margin:8vh auto;max-width:900px;width:94%;background:var(--directrelay-card,#fff);border-radius:12px;padding:24px;box-shadow:0 20px 60px rgba(0,0,0,.3);"> 352 <div class="directrelay-modal-header" style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;"> 353 <strong><span class="directrelay-modal-title"><?php esc_html_e( 'Deliveries', 'directrelay-automation-bridge-for-n8n'); ?></span> <span class="directrelay-badge" id="directrelay-delivery-count"></span></strong> 354 <button type="button" class="directrelay-modal-close" style="background:none;border:none;font-size:20px;cursor:pointer;line-height:1;">×</button> 355 </div> 356 <div id="directrelay-delivery-log" style="max-height:60vh;overflow:auto;"></div> 357 </div> 358 </div> 359 360 </div> -
directrelay-automation-bridge-for-n8n/trunk/privacy.txt
r3700813 r3706276 11 11 - Response time in milliseconds 12 12 - Client IP address 13 - User agent string14 13 15 14 2. API Key Metadata … … 48 47 Analytics aggregates are retained for 30 days. 49 48 Webhook delivery logs are retained for 30 days. 50 Expired or revoked API key records are retained for 90 days after revocation for audit purposes.49 Revoked API keys are disabled immediately; their records remain in the plugin's keys table until the plugin is uninstalled. Request logs follow the retention period above. 51 50 52 51 === Data Sharing & External Connections === … … 66 65 67 66 All API keys are securely hashed using bcrypt (via PHP's password_hash) on creation. The raw key is shown only once and cannot be retrieved. 68 Webhook secrets are stored using AES-256 encryption at rest and decrypted only at HMAC signing time.67 Webhook secrets are stored using AES-256 encryption at rest, keyed with your site's WordPress secret keys, and decrypted only at HMAC signing time. 69 68 IP addresses are stored for brute-force protection and are purgeable. 70 69 Connections to webhook target URLs are made on-demand when a WordPress post event occurs. -
directrelay-automation-bridge-for-n8n/trunk/readme.txt
r3700957 r3706276 1 === DirectRelay Automation Bridge for n8n===1 === DirectRelay – n8n Workflow Automation Plugin (REST API & Webhooks) === 2 2 Contributors: n4nion 3 Tags: api, rest, n8n, automation, seo3 Tags: n8n, automation, rest api, webhooks, seo 4 4 Requires at least: 6.3 5 5 Tested up to: 7.1 6 6 Requires PHP: 8.0 7 Stable tag: 5.1.3 17 Stable tag: 5.1.32 8 8 License: GPL-2.0+ 9 9 License URI: http://www.gnu.org/licenses/gpl-2.0.txt 10 10 11 Free REST API + signed webhook bridge for WordPress and n8n. Rank Math + Yoast reads, IndexNow pings. 100% GPL.11 Connect WordPress to n8n. The free n8n workflow automation plugin: REST API, signed webhooks, AI agents, SEO reads, IndexNow. 100% GPL. 12 12 13 13 == Description == 14 14 15 **DirectRelay Automation Bridge for n8n** turns any WordPress site into a first-class REST API target that n8n workflows, Make scenarios, Zapier zaps, custom AI agents and your own scripts can talk to — with the keys, scopes and security model that production automation actually demands. 16 17 It is the only free WordPress automation plugin that ships all four at once: a scoped REST API surface (32 endpoints under `/wp-json/directrelay-automation-bridge-for-n8n/v1/`), HMAC-signed outgoing webhooks with retry, native reads of **Rank Math** and **Yoast SEO** fields, and a self-describing OpenAPI 3.0 spec that lets the n8n HTTP Request node auto-fill every request shape. **No purchase code. No license key. No trial period. No feature lock.** Every feature listed on this page is fully functional in the free build and always will be. 18 19 ### Why n8n users pick DirectRelay 20 21 * **OpenAPI 3.0 spec at `/wp-json/directrelay-automation-bridge-for-n8n/v1/openapi.json`** — paste it into n8n's HTTP Request node and every field, scope and response shape fills itself. No manual mapping. 22 * **Rank Math + Yoast SEO reads in one call** — `GET /posts/{id}/seo` returns focus keyword, SEO title, meta description, canonical URL and OpenGraph fields from whichever SEO plugin you have active. The community `n8n-nodes-wordpress` node can't do this. 23 * **Scoped API keys, not WordPress Application Passwords** — generate a key that can only read posts, only publish to a specific CPT, or only fire webhooks. Per-key IP allowlist, expiry and rotation are built in. Revoke one key without touching the others. 24 * **Bidirectional, not one-way** — REST reads **and** HMAC-SHA256 signed outgoing webhooks on the same install. Most alternatives do only one direction. 25 * **100% GPL, no telemetry, no license server** — what you install is what runs. No phone-home, no purchase-code check, no usage caps. 26 27 ### Everything in the free plugin 28 29 **REST API endpoints (32 routes)** 30 * Posts — list, get, publish, bulk publish (single call writes post + featured image + SEO + categories + tags). 31 * Pages — list and get. 32 * Media — list, sideload from URL, get. 33 * Taxonomies — categories and tags. 34 * Custom meta — list all, get/delete by key (works with ACF, Pods, custom fields). 35 * SEO read — `GET /{type}/{id}/seo` returns Rank Math or Yoast fields automatically. 36 * Discovery — `/discovery/fields` and `/discovery/post-types` for schema reflection. 37 * Webhooks — CRUD + test + delivery log + usage stats + ready-made presets. 38 * API keys — list, rotate, revoke. 39 * System — `/status`, `/ping`, `/cron-health`, `/blocked-ips`. 40 * Fleet — `/fleet/health` and `/fleet/keys/provision` for multi-site monitoring. 41 * OpenAPI — `/openapi.json` and `/openapi` for self-describing discovery. 42 43 **Security and production hardening** 44 * Scoped API keys with 16+ scope types (`posts:read`, `posts:write`, `posts:publish`, `posts:delete`, `media:write`, `webhooks:write`, etc.). 45 * Five preset roles (readonly, writer, editor, publisher, full_access) plus custom scope bundles. 46 * Per-key IP allowlist (single IP or CIDR ranges). 47 * Per-key expiration date (auto-revoke after). 48 * Automatic key rotation policy (configurable days; n8n-friendly grace period). 49 * Keys are stored as salted hashes — the database leak cannot expose live keys. 50 * Timing-safe comparison (`hash_equals`) — immune to timing side channels. 51 * Sliding-window rate limiter — configurable per-IP + per-key, prevents burst abuse. 52 * Brute-force protection with escalating IP block (5 min → 30 min → 2 hr → 24 hr). 53 * HMAC-SHA256 signed outgoing webhooks with `X-DirectRelay-Signature` header and replay protection via timestamp window. 54 55 **Outgoing webhooks** 56 * Dispatch signed JSON to any HTTP endpoint — n8n webhook, Make hook, Zapier catch hook, custom AI agent. 57 * Event filtering by post type, status transition, taxonomy. 58 * Automatic retry with exponential backoff. 59 * Last 50 deliveries per webhook (status code, response time, request body). 60 * Built-in "Send test event" button from the admin UI. 61 * Ready-made presets for `post.published`, `post.updated`, `post.deleted`, `page.published`. 62 63 **AI and SEO integrations** 64 * **Rank Math** field reads: focus keyword, SEO title, description, canonical URL, pillar content flag, robots flags. 65 * **Yoast SEO** field reads: focus keyword, SEO title, meta description, canonical URL, OpenGraph title and description. 66 * **IndexNow** auto-ping on every publish/update — instant indexing on Bing, Yandex, Naver and Seznam. No cron job needed. 67 * **OpenAI-compatible** response shape — all endpoints return JSON that the n8n OpenAI/HTTP nodes parse without transformation. 68 69 **Operations** 70 * Activity log — last 100 API calls and webhook deliveries, plain text, filterable by event type. 71 * Email notifications — admin gets pinged on every webhook failure with the failed payload attached. 72 * Compatibility checker — verifies permalinks, REST API, SSL, Rank Math / Yoast presence, PHP version, WP-Cron health. 73 * Cron health monitor — alerts if WP-Cron is more than 15 min behind. 74 * n8n starter template — one copy-paste workflow that proves the connection in under 60 seconds. 75 * Side-load media from URL — POST `/media/sideload` with an image URL and DirectRelay downloads, attaches and returns the WP media ID. 76 * Custom Post Type support — auto-discovered, no extra config. 77 * GDPR-friendly — zero outbound traffic by default. Every external service is an explicit opt-in. 78 79 ### Real workflows you can build today 80 81 * **AI writer pipeline** — n8n fires on a schedule → calls OpenAI to draft an article → `POST /posts` with title, content, Rank Math SEO and a featured image URL in a single request → IndexNow auto-pings Bing → webhook back to n8n notifies your Slack. 82 * **Multi-site fleet manager** — Central n8n instance pings `/fleet/health` on 20 WordPress sites every 5 min. If any return `cron_degraded`, n8n opens a PagerDuty incident. 83 * **Headless CMS front-end** — Next.js or Astro reads posts via `GET /posts?per_page=100&status=publish`, renders the site. Writers use the regular WP admin to edit. 84 * **Lead-magnet auto-publisher** — Webflow form submits → n8n reads the data → `POST /posts` with a draft pre-filled with the lead's company name → editorial team gets a Slack notification with a one-click approve link. 85 86 ### DirectRelay vs the alternatives 87 88 **Rank Math / Yoast SEO field reads** 89 * DirectRelay: **Yes, both** (Rank Math + Yoast auto-detected) 90 * WP REST + Application Password: No 91 * n8n-nodes-wordpress (community): No 92 * WP Webhooks: No 93 94 **Per-route API scopes** 95 * DirectRelay: **Yes, 16+ scopes** (posts:read, posts:write, media:write, etc.) 96 * WP REST + Application Password: No (always full account access) 97 * n8n-nodes-wordpress (community): No 98 * WP Webhooks: No 99 100 **HMAC-signed outgoing webhooks** 101 * DirectRelay: **Yes** (HMAC-SHA256, replay-protection timestamp) 102 * WP REST + Application Password: No 103 * n8n-nodes-wordpress (community): No 104 * WP Webhooks: Yes (basic) 105 106 **OpenAPI 3.0 auto-discovery** 107 * DirectRelay: **Yes** (`/openapi.json` so the n8n HTTP Request node auto-fills request shapes) 108 * WP REST + Application Password: No 109 * n8n-nodes-wordpress (community): No 110 * WP Webhooks: No 111 112 **Outgoing webhooks with retry** 113 * DirectRelay: **Yes** (exponential backoff, delivery log, test event) 114 * WP REST + Application Password: No 115 * n8n-nodes-wordpress (community): No 116 * WP Webhooks: Yes 117 118 **Per-credential IP allowlist** 119 * DirectRelay: **Yes** (single IP or CIDR) 120 * WP REST + Application Password: No 121 * n8n-nodes-wordpress (community): No 122 * WP Webhooks: No 123 124 **Pricing model** 125 * DirectRelay: **100% free, GPL, no upsell, no trial** 126 * WP REST + Application Password: 100% free (core WordPress) 127 * n8n-nodes-wordpress (community): 100% free (community node) 128 * WP Webhooks: Freemium (paid add-ons) 129 130 ### Privacy and licence — no trialware, ever 131 132 DirectRelay is free software released under the GPL-2.0+ licence. You can use it, study it, modify it and redistribute it under the same terms. 133 134 The free plugin distributed on WordPress.org does **not** perform purchase-code validation, license-server activation, or any third-party license check. There is **no** purchase code, license key or activation token collected, stored, transmitted or required to use any feature of the free plugin. License management for the optional add-on is provided by the separate DirectRelay Pro add-on plugin distributed from [directrelay.wikiofautomation.com/directrelay-pro](https://directrelay.wikiofautomation.com/directrelay-pro) — Pro is never distributed via WordPress.org and is never required. 135 136 == Pro Features (separate add-on plugin) == 137 138 The following features are provided by the DirectRelay Pro add-on plugin, distributed from https://directrelay.wikiofautomation.com/directrelay-pro. None of this code ships in the WordPress.org zip. 139 140 * **SEO Metadata Write** (focus keyword, title, description for Rank Math and Yoast SEO). 141 * **Bulk Publish Endpoint** (up to 50 items per request). 142 * **Universal Schema Publish** (schema-driven publish from any JSON shape). 143 * **One-Shot Publish** (atomic single-call create with media and SEO). 144 * **Human Approval Portal** (signed review URLs, approve/reject workflow, multi-reviewer chains). 145 * **AI Guardrails** (duplicate-title detection, AI-leakage scrubbing, blocked-phrase filter). 146 * **Full Debugger** (50-entry replay, payload inspection, dry-run mode). 147 * **Chart.js Analytics Dashboard** (call volume, error rate, latency trends, per-key breakdown). 148 * **n8n Template Library** (AI writer, multilingual publisher, research approval pipelines). 149 * **Dark Mode** for the plugin admin screens. 15 Project home page: https://directrelay.wikiofautomation.com 16 17 = The Automation Bridge Between WordPress and n8n = 18 19 You built your content workflows in n8n. Now you need WordPress to listen. 20 21 DirectRelay is the free n8n plugin that turns any WordPress site into a first-class automation target: a complete REST API for your n8n workflows, real-time signed webhooks from WordPress to n8n, and native SEO integrations with Rank Math and Yoast. Publish, update, tag, optimize, and react to content changes — automatically, securely, and without writing a single line of PHP. 22 23 Whether you run an AI content pipeline, a headless WordPress setup, a multi-channel publishing stack, or a simple "post to social when I publish" workflow, DirectRelay is the plugin for n8n workflow automation on WordPress. 24 25 = Why DirectRelay Is the Most Complete n8n Plugin for WordPress = 26 27 * **True two-way automation** — n8n can read and write WordPress, and WordPress can trigger n8n workflows in real time. 28 * **Everything in the free version** — no trial, no license key, no feature locks, no usage limits. 100% GPL. 29 * **Built for AI agents** — scoped API keys with agent personas let you hand LLMs and AI tools controlled, auditable access to your site. 30 * **OpenAPI 3.0 included** — point any HTTP Request node at the self-describing schema and every endpoint, parameter, and auth scheme is documented for you. 31 * **Security-first design** — scoped keys, bcrypt hashing, rate limiting, brute-force protection, IP allowlists, and SSRF-validated media sideloads. 32 * **Zero configuration headaches** — install, generate a key, paste it into n8n. Your first workflow runs in minutes. 33 34 = 🔌 A Complete REST API for Your n8n Workflows = 35 36 Every content operation your automation needs, exposed through a clean, versioned REST API under `/wp-json/directrelay/v1`: 37 38 * Create, read, update, publish, and delete **posts and pages** — with markdown support, scheduled publishing, and custom fields. 39 * **Media library** uploads via base64 or URL sideload, with per-route permissions. 40 * **Categories and tags** — resolve by name or ID, create on the fly. 41 * **Custom meta** read and write with core-protected keys blocked. 42 * **Discovery endpoints** — list post types and custom fields so workflows adapt to any site, including WooCommerce and custom post types. 43 * **OpenAPI 3.0 spec** at `/openapi.json` — import into Postman, Swagger UI, or generate n8n HTTP Request node setups instantly. 44 * **30+ documented endpoints**, each gated by fine-grained scopes (`posts:write`, `media:write`, `seo:read`, …). 45 46 = 🔔 Real-Time Signed Webhooks — WordPress → n8n = 47 48 The moment something happens on your site, n8n knows: 49 50 * **10 event types**: created, updated, published, and deleted for posts and pages, plus media uploads. 51 * **HMAC-SHA256 signatures** with timestamp headers and replay protection — verify deliveries in one n8n Code node. 52 * **Automatic retries** with exponential backoff and a per-attempt delivery log you can inspect in the admin. 53 * **Per-webhook filters** — choose exactly which events and post types each workflow receives. 54 * **Workflow presets** — social distribution, AI repurposing, search indexing, Slack alerts, CDN purging, and more, each with copy-paste n8n workflow JSON. 55 56 = 🤖 Built for AI Agents = 57 58 Handing an LLM the keys to your site should not mean handing it everything. 59 60 * **Agent role personas** — Full Access, AI Content Agent (drafts only), AI SEO Agent, and AI Publishing Agent. 61 * **Drafts-only enforcement** — a content agent physically cannot publish or delete, on any route, ever. 62 * **Post ownership isolation** — restrict each agent to content created by its own key. 63 * **Scoped, expiring, IP-allowlisted keys** with rotation and full usage analytics. 64 65 = 🔍 Read Rank Math & Yoast SEO from n8n = 66 67 The only free automation bridge that reads SEO metadata natively: 68 69 * Unified read endpoint for **Rank Math** and **Yoast SEO** — focus keyphrase, title, description, canonical, Open Graph, Twitter cards, schema type. 70 * Auto-detects which SEO plugin is active. One call, one format, any site. 71 72 = ⚡ IndexNow Instant Indexing = 73 74 Opt-in IndexNow auto-ping: the moment a post goes live, its URL is submitted to the IndexNow aggregator (Bing, Yandex, Seznam, Naver) for near-instant crawling. The verification key is published automatically at `yoursite.com/indexnow-key.txt`. 75 76 = 🛡️ Production-Grade Security = 77 78 * Scoped API keys — bcrypt-hashed, prefix-identifiable, never stored in plain text. 79 * Sliding-window rate limiting and escalating brute-force IP blocks with a manual block/unblock console. 80 * Per-key IP allowlists, global bypass IPs, optional HTTPS enforcement, and key expiry. 81 * SSRF-validated media sideloading with redirect-hop re-verification and size caps. 82 * Protected meta — WordPress core internals and plugin stamps are never agent-writable. 83 * Single-site build: each free install manages exactly one site. Multi-site fleet management is available in the add-on. 84 85 = Get Started in 3 Minutes = 86 87 1. Install DirectRelay and open the DirectRelay dashboard. 88 2. Generate an API key — pick a scope preset or build your own. 89 3. Paste the key into your n8n HTTP Request node (or import the OpenAPI spec) and your first automation is live. 90 91 = Who Is DirectRelay For? = 92 93 * **AI content teams** running generation pipelines through n8n workflows. 94 * **Agencies** managing publishing automation across client content operations. 95 * **Headless WordPress** builders who need a reliable, documented REST API. 96 * **Anyone who wants WordPress and n8n to talk to each other** — reliably and securely. 97 98 DirectRelay is free software licensed under the GPL. Use it, study it, modify it, redistribute it. 99 100 == DirectRelay Pro (separate add-on plugin) == 101 102 SEO metadata writes (Rank Math / Yoast), bulk publishing, human approval workflows, AI guardrails, advanced analytics, and dark mode are provided by the separate **DirectRelay Pro** add-on plugin, distributed from https://directrelay.wikiofautomation.com/directrelay-pro. The Pro add-on is never distributed via WordPress.org and is not required to use any free feature — the free plugin is fully functional on its own. 150 103 151 104 == External services == … … 157 110 = n8n Webhooks (outgoing) = 158 111 * **What it is and what it is used for:** DirectRelay dispatches signed JSON payloads to user-configured webhook URLs when WordPress post events occur. The webhook receiver can be n8n, Make, an AI agent, or any other HTTP endpoint the administrator chooses. 159 * **What data is sent and when:** The configured webhook target URL receives the post ID, title, content excerpt, status, taxonomies, and read-only SEO fields. Transmission only happens when the administrator has created an active webhook rule and the corresponding post event fires.112 * **What data is sent and when:** The configured webhook target URL receives the post ID, title, full post content, status, taxonomies, and read-only SEO fields. Transmission only happens when the administrator has created an active webhook rule and the corresponding post event fires. 160 113 * **Service Terms & Privacy:** 161 114 * The webhook receiver is the user's choice; DirectRelay itself does not transmit data to any third party by default. … … 164 117 165 118 = IndexNow = 166 * **What it is and what it is used for:** Submits updated post URLs directly to search engines (Bing, Yandex, Seznam) for instant indexing when content is published or updated.167 * **What data is sent and when:** Sends the site hostname, the API key the administrator configured, and the modified post URL. Transmitted only when the administrator enables IndexNow in plugin settings and a post is published or updated.119 * **What it is and what it is used for:** Submits published post URLs to the IndexNow aggregator (api.indexnow.org), which routes them to participating search engines (Bing, Yandex, Seznam, Naver) for instant indexing. Pings fire when a post transitions to published; edits to already-published posts are not re-pinged. 120 * **What data is sent and when:** Sends the site hostname, the plugin-generated IndexNow key, the key-location URL (yoursite.com/indexnow-key.txt), and the published post URL. Transmitted only when the administrator enables IndexNow in plugin settings and a post transitions to published. 168 121 * **Service Terms & Privacy:** 169 122 * IndexNow Terms: https://www.indexnow.org/ … … 189 142 == Installation == 190 143 191 1. Upload the `directrelay` directory to the `/wp-content/plugins/` directory, or install the plugin through the WordPress Plugins screen directly.192 2. Activate the plugin through the **Plugins** menu in WordPress.193 3. Navigate to **DirectRelay -> API Keys** in your WordPress admin menu to generate your first API credential.194 4. C onfigure your n8n HTTP Request node with the generated API key in the `Authorization: Bearer` header (or the `X-API-Key` header).195 5. (Optional) Install the DirectRelay Pro add-on plugin if you need SEO metadata writes, bulk publish, the approval portal, advanced analytics, or dark mode. Pro is available from https://directrelay.wikiofautomation.com/directrelay-pro.144 1. Upload the plugin files to `/wp-content/plugins/`, or install through the WordPress Plugins screen. 145 2. Activate the plugin. 146 3. Open **DirectRelay** from the admin menu and run the built-in compatibility check. 147 4. Create your first API key (**DirectRelay → API Keys**) and connect it from n8n using either the `X-API-Key` header or a `Bearer` token. 148 5. Optional: register an outgoing webhook under **DirectRelay → Webhooks** to trigger n8n workflows from WordPress events. 196 149 197 150 == Frequently Asked Questions == … … 199 152 = Does this require n8n? = 200 153 201 No. DirectRelay is a plain WordPress REST API plugin. Any HTTP client — n8n, Make, Zapier, custom Python, curl, Postman, an AI agent — can talk to it. The OpenAPI 3.0 spec at `/wp-json/directrelay-automation-bridge-for-n8n/v1/openapi.json` makes integration with any tool that supports OpenAPI frictionless.154 No. DirectRelay exposes a standard WordPress REST API that any HTTP client can use — n8n, Make, custom scripts, AI agents, Postman, or your own code. The n8n-specific touches (OpenAPI schema, workflow presets, webhook payloads shaped for n8n) simply make n8n the smoothest experience. 202 155 203 156 = Is this safe to run on a production site? = 204 157 205 Yes. Every request is authenticated via scoped API key + optional IP allowlist. Keys are stored as salted hashes (not plaintext). The rate limiter and brute-force protection run before any database query. Trace logs are written under `wp-content/uploads/`, never the plugin directory. The plugin ships with zero outbound traffic by default.158 Yes. Keys are bcrypt-hashed and scoped, every route is permission-gated and rate-limited, failed authentications trigger escalating IP blocks, media sideloads are SSRF-validated on every redirect hop, and the plugin contains no SEO write path, no trial mode, and no license checks. A built-in compatibility checker verifies permalinks, REST access, and SSL from the dashboard. 206 159 207 160 = Does it work with Rank Math AND Yoast SEO? = 208 161 209 Yes . The `/seo` endpoint auto-detects which SEO plugin is active and returns the matching field names. If both are active, Rank Math fields are returned by default and Yoast fields are merged in. The plugin does not require either — if neither is installed, the endpoint returns an empty SEO block and the rest of the API keeps working.162 Yes — the free plugin reads SEO metadata from both, auto-detecting which plugin is active and returning one unified format. Writing SEO metadata is available through the separate DirectRelay Pro add-on. 210 163 211 164 = Does it work with WooCommerce and Custom Post Types? = 212 165 213 Yes. Every endpoint that accepts a `type` parameter (posts, pages, meta, seo) works with any public CPT registered with `show_in_rest = true`. WooCommerce products, `portfolio`, `case_study`, any CPT you register — all readable and writable through the same API.166 Yes. Products, orders, and any public custom post type work through the same REST surface, and the discovery endpoints let your n8n workflows enumerate post types and custom fields dynamically. 214 167 215 168 = Can I use this on a multisite network? = 216 169 217 Yes. DirectRelay is multisite-aware. Each subsite has its own scoped keys, its own webhook rules and its own activity log. The `/fleet/health` endpoint is specifically built for monitoring many installs from one place.170 The free build supports one site per install and cannot be network-activated on multisite. Multi-site and multi-domain management is available in the separate DirectRelay Pro add-on. 218 171 219 172 = What happens to my keys if I uninstall the plugin? = 220 173 221 Uninstall runs the included `uninstall.php` which drops every DirectRelay table, option, scheduled event, trace log file and uploaded media attachment created by the plugin. Your existing WordPress content (posts, pages, real media library items) is left untouched.174 Uninstalling removes all DirectRelay tables, options, and diagnostic logs. Revoke your keys before uninstalling if any n8n workflows are still calling them — revoked keys immediately stop working. 222 175 223 176 = How do I rotate an API key without downtime? = 224 177 225 Generate a new key with the same scopes. Update the credential in your n8n workflow. Revoke the old key. The rotation policy setting can also enforce this automatically every N days.178 Open DirectRelay → API Keys, click Rotate on the key. A new secret is generated instantly while the key keeps its ID, scopes, and settings; update the key in n8n whenever you like. The old value stops working the moment you rotate. 226 179 227 180 = Does it support the Block Editor (Gutenberg)? = 228 181 229 Yes. Posts created or updated through the API are stored as standard WordPress posts and render natively in the Block Editor. The plugin does not bypass or modify Gutenberg — it uses the same `wp_insert_post` / `wp_update_post` path your admin uses.182 Yes. Posts created through the API are standard WordPress posts — they open, edit, and publish normally in the Block Editor, and Gutenberg publishes fire the same webhooks as classic publishing. 230 183 231 184 = Is there a limit on the number of API keys or webhooks? = 232 185 233 No hard limit. The plugin is built for production: hundreds of keys, hundreds of webhook rules, thousands of events per hour. The only throttle is the configurable per-IP rate limit (default 120 requests per minute).186 No. The free plugin has no artificial usage limits — no key caps, no webhook caps, no monthly request quotas. 234 187 235 188 = Where can I get support? = 236 189 237 Email help@directrelay.wikiofautomation.com . Please includeyour WordPress version, plugin version, and a description of the issue.190 Email help@directrelay.wikiofautomation.com with your WordPress version, plugin version, and a description of the issue. 238 191 239 192 == Screenshots == 240 193 241 1. **Dashboard at a glance** — compatibility scorecard, key stats, recent webhook activity.242 2. **Scoped API keys** — pick a role preset or build a custom scope bundle. Per-key IP allowlist and expiry.243 3. **Webhook delivery log** — every event, every status code, every response time, last 50 per webhook.244 4. **Activity log** — last 100 API calls and webhook events, filterable by event type and key.245 5. **Compatibility checker** — verifies permalinks, REST API, SSL, Rank Math / Yoast presence, PHP version, WP-Cron health.246 6. **n8n starter templates** — one copy-paste workflow that proves the connection in under a minute.194 1. Dashboard with at-a-glance request stats and the compatibility scorecard. 195 2. API Keys screen — scoped keys with scope chips, agent personas, expiry, and IP allowlists. 196 3. Webhooks screen — events, targets, HMAC secrets, test-fire, and per-attempt delivery log. 197 4. Activity Log with HTTP-status filters, request durations, and key attribution. 198 5. Compatibility Checker scoring permalinks, REST API, SSL, and SEO plugin detection. 199 6. n8n Templates — copy-paste workflow JSON for your first automation. 247 200 248 201 == Changelog == 249 202 203 All notable changes are documented here. DirectRelay follows semantic versioning for its REST API: the endpoint contract under `directrelay/v1` stays backward compatible within major version 5. 204 205 = 5.1.32 = 206 Release date: September 22, 2026 207 208 * **Security:** API keys are now strictly scoped to the DirectRelay REST namespace. A leaked key can no longer act as its bound WordPress user on core `/wp/v2` routes or admin endpoints — the scope system can no longer be bypassed. 209 * **Security:** Markdown links and images published through the API are protocol-checked and escaped, and heading, blockquote, list, and inline text is escaped — closing a stored-XSS surface for compromised agent keys. 210 * **Security:** All media sideload paths (featured image, gallery, and the `/media/sideload` endpoint) now validate every redirect hop against SSRF, cap response size, and support IPv6 and literal-IP hosts correctly. 211 * **Fix:** Resolved a critical installation issue on standard MySQL servers where the API-keys and webhooks database tables silently failed to create (illegal TEXT column defaults). Existing sites are upgraded automatically on update. 212 * **Fix:** Markdown H2 headings (`##`) kept their text on publish — a broken regex backreference previously wiped all H2 content converted through the default pipeline. 213 * **Fix:** Create/update requests carrying an inline `seo` payload no longer fail after saving; the free plugin reports SEO writes as skipped via a `seo_write` response marker, and the add-on plugin owns SEO writes through a documented filter. 214 * **Improvement:** Webhook management is fully functional in the admin — Pause/Activate, Delete, a test-fire dialog, and a per-attempt delivery log viewer now all work. 215 * **Improvement:** The API-key wizard now saves the AI Agent Role, approval, ownership, and velocity settings selected in the UI. 216 * **Improvement:** Signed webhooks send `X-DirectRelay-Timestamp` and a signed-timestamp header for replay protection. The existing body-signature header is unchanged, so current n8n verification workflows keep working. 217 * **Improvement:** Manual IP blocks added from the Blocked-IPs console are now enforced on every API request (previously display-only), with correct UTC expiry handling. 218 * **Improvement:** Post-ownership isolation and drafts-only agent roles are enforced across create, update, publish, delete, and meta routes. 219 * **Improvement:** IndexNow pings honor the on/off setting on every code path, verify TLS, ping the single IndexNow aggregator, and log delivery failures instead of erroring. The key-file endpoint now matches exact paths only. 220 * **Improvement:** Delivery-log retention now runs even when webhooks are disabled, and log growth is bounded. Media events respect each webhook's post-type selection, and `post.created` fires on the first real save rather than the editor's empty shell. 221 * **Improvement:** Full SQLite / WordPress Playground compatibility across authentication, analytics, and all log pruning. 222 * **Changed:** The free build now supports a single site per install. Multisite activation is refused, and multi-site fleet endpoints moved to the add-on plugin. 223 * **Changed:** Uninstall now removes all remaining options (including the IndexNow key) and diagnostic log files. 224 250 225 = 5.1.31 = 251 * Fixed: WP.org plugin-check warnings from the 5.1.30 import. 252 * **Tags:** dropped 5 secondary tags that WP.org silently ignored (webhook, headless, ai, integration, cms). Header now uses the maximum 5 tags WP.org indexes: `api, rest, n8n, automation, seo`. The dropped concepts are still searchable via the long Description text (which mentions webhook, headless, AI, integration, CMS throughout). 253 * **Short description:** trimmed from 233 characters to 111 characters to fit the WP.org 150-character hard limit. New text: "Free REST API + signed webhook bridge for WordPress and n8n. Rank Math + Yoast reads, IndexNow pings. 100% GPL." — keeps every primary keyword without truncation. 254 * **Banner quality:** reverted `assets/banner-772x250.png` and `assets/banner-1544x500.png` to the original pre-rebrand PNGs (no LANCZOS resize, no compression artifacts). Same byte-identical files that shipped in 5.1.25 / 5.1.26 / 5.1.27. Both sizes exactly match WP.org spec. 255 * Changed: Plugin header `Version: 5.1.31` and `Stable tag: 5.1.31` bumped 5.1.30 → 5.1.31. 226 * Refreshed the directory listing metadata and restored the original banner artwork for maximum clarity at both WP.org sizes. 256 227 257 228 = 5.1.30 = 258 * Fixed: replaced the markdown pipe-table in the "DirectRelay vs the alternatives" section with a WP.org-parser-safe list-based comparison. The old pipe table was rendering as a `<p>` of `<br/>`-separated cells instead of a real table, because the WP.org readme parser does not process markdown pipe syntax. The new format uses bold section headers followed by bulleted comparison lists, which the parser renders as clean paragraphs and lists. 259 * Changed: replaced `assets/banner-772x250.png` and `assets/banner-1544x500.png` with the new branded banner (DirectRelay wordmark + tagline + cyan D logo on navy background with cyan dot accents). Both sizes now exact WP.org spec (772×250 and 1544×500). 260 * Changed: Plugin header `Version: 5.1.30` and `Stable tag: 5.1.30` bumped 5.1.29 → 5.1.30. 229 * Rebuilt the feature comparison section so it renders correctly in the WP.org readme parser, and refreshed the branded banner artwork. 261 230 262 231 = 5.1.29 = 263 * Note: Trigger release to force the WP.org Plugin Directory indexer to refresh the listing content. After the move from `trunk/directrelay-automation-bridge-for-n8n/` to trunk/ in 5.1.28, the plugin page was showing the cached 5.1.26 readme content. This release bumps the `Stable tag` to 5.1.29 so the indexer re-reads `trunk/readme.txt` (which points at `tags/5.1.28/readme.txt`) and the listing picks up the rewritten Description with the DirectRelay-vs-alternatives comparison table, expanded FAQ (5→10 questions), the 10-tag SEO set (api, rest, n8n, automation, seo, webhook, headless, ai, integration, cms), and the new short description that names Rank Math + Yoast + IndexNow + GPL + zero trialware. No functional codechanges.232 * Indexer refresh release: re-issued the stable tag so the directory listing picks up the rewritten description, expanded FAQ, and new short description. No functional changes. 264 233 265 234 = 5.1.28 = 266 * Fixed: removed a duplicate `== External services ==` section in `readme.txt` that was carried over from the pre-rewrite copy. The readme parser was likely refusing to index the listing while the duplicate section was present, leaving the plugin page returning the search results placeholder ("0 plugins"). Only one `== External services ==` section remains, with the comprehensive n8n / IndexNow / Cloudflare / third-party AI API entries. 267 * Changed: Plugin header `Version: 5.1.28` and `Stable tag: 5.1.28` bumped 5.1.27 → 5.1.28. 235 * Removed a duplicated External-services disclosure section that could prevent the readme validator from indexing the listing. 268 236 269 237 = 5.1.27 = 270 * Changed: full rewrite of the WP.org Plugin Directory listing copy. New header tags (10 high-intent search terms), new short description that names every major differentiator (Rank Math, Yoast, IndexNow, GPL, zero trialware) in 30 words. Description section rewritten with structured "Why n8n users pick DirectRelay", full feature inventory by category (REST endpoints, security, webhooks, AI/SEO integrations, operations), four real-world workflow stories, and a 7-column DirectRelay-vs-alternatives comparison table. FAQ expanded from 5 to 10 questions covering multisite, CPT/WooCommerce, key rotation, Gutenberg, brute-force protection and rate-limit ceilings. 271 * Changed: replaced all 4 icon PNGs (32, 128, 256, 512) with the new static brand mark (Inter Bold "D" + cyan + arrow on navy gradient rounded square) so every WP.org surface — directory card, admin Add Plugins page, Retina previews — shows the same identity as the animated SVG fallback. Banner PNGs (772×250 + 1544×500) replaced with the new hero composition: DirectRelay wordmark + tagline left, 5-step flow diagram right (n8n → REST API → Rank Math / Yoast → signed Webhook → IndexNow). 272 * Added: 6 new screenshot PNGs mapped to the new Screenshots caption block — Dashboard with compatibility scorecard, API Keys with scope chips, Webhook delivery log with HMAC, Activity log with filter chips, Compatibility checker with 10/10 score, n8n Templates with copy-paste HTTP Request node JSON. 273 * Changed: Plugin header `Version: 5.1.27` and `Stable tag: 5.1.27` bumped 5.1.26 → 5.1.27. 238 * Complete listing refresh: new directory icons and banners, six new screenshots, expanded FAQ, a full feature inventory, and copy-paste n8n workflow examples. 274 239 275 240 = 5.1.26 = 276 * Added: animated brand mark as `assets/icon-256x256.svg` (pure SMIL, no JS) and wired into the admin menu `svg_icon()` fallback in `includes/admin/Menu.php`. Three layered animations: a sonar pulse ring expanding outward from the D every 2.4s, a subtle D opacity breathing pulse every 2.2s, and a white arrow translating left-right every 1.8s to convey bidirectional data flow through the bridge. Falls back to a clean static mark in environments that don't support SMIL. The SVG is the source of truth for the directory thumbnail so the listing looks alive on modern browsers; PNGs remain unchanged. 277 * Changed: Plugin header `Version: 5.1.26` and `Stable tag: 5.1.26` bumped 5.1.25 → 5.1.26. 241 * Added the animated brand mark used for the directory thumbnail, with a static fallback for environments without animation support. 278 242 279 243 = 5.1.25 = 280 * Fixed: WP.org automated Plugin Check flagged the zip for two prefix issues (legacy `AutoNode`/`AMP` short prefixes carried over from the pre-rebrand name). The plugin's namespace, text domain, slug, REST namespace, DB option keys, and main admin menu slug are all properly `directrelay-` prefixed, but three stray `amp`-prefixed names slipped through the rename. Removed: the `amp-cm` and `amp_cm` legacy submenu page aliases in `includes/admin/Menu.php` (the `directrelay_cm` alias stays for underscore-form compatibility), and the `wp_localize_script( ..., 'ampCM', ...)` call that exposed a second JS global in addition to `directrelayAdmin`. Also removed the `window.ampCM` fallback / set lines in `assets/js/admin.js`. All admin-facing prefixes are now `directrelay-` / `DirectRelay` only. 281 * Fixed: the `directrelay_trace()` function's file-write path comes from `directrelay_log_paths()`, which in 5.1.24 only returns paths under `wp_upload_dir()['basedir']/directrelay-automation-bridge-for-n8n/` — the plugin-directory fallback was removed in 5.1.24. The auto-checker complaint about `directrelay.php:70 file_put_contents` is from the 5.1.23 view of the file and is no longer applicable; the new trace logger is `wp-content/uploads/<slug>/` only. 282 * Changed: Plugin header `Version: 5.1.25` and `DIRECTRELAY_VERSION` constant bumped 5.1.24 → 5.1.25. 244 * Prefix cleanup: removed legacy identifier remnants so the entire codebase is uniformly `directrelay`-prefixed. 283 245 284 246 = 5.1.24 = 285 * Fixed: WP.org automated Plugin Check flagged the zip for two issues. 286 * **Directory assets in plugin zip.** All 7 PNGs in `assets/` (`icon.png`, `icon-32x32.png`, `icon-128x128.png`, `icon-256x256.png`, `icon-512x512.png`, `banner-772x250.png`, `banner-1544x500.png`, `logo.png`) are WP.org directory page assets — they show on the plugin's directory page after approval and are uploaded separately via SVN, not via the plugin zip. The zip build script now excludes them. The admin dashboard view (`includes/admin/views/dashboard.php`) was using `assets/icon-256x256.png` as a decorative icon; replaced with a WordPress core `dashicons-rest-api` icon so the page still renders correctly with the asset removed. 287 * **Hardcoded `WP_CONTENT_DIR` paths.** `directrelay.php` and `includes/Installer.php` referenced `WP_CONTENT_DIR . '/directrelay-trace.log'` and `WP_CONTENT_DIR . '/uploads/directrelay-error.log'` directly. The WP.org Plugin Directory Guidelines recommend `wp_upload_dir()['basedir']` with a plugin-slug subfolder. Both files now use `wp_upload_dir()['basedir'] . '/directrelay-automation-bridge-for-n8n/{trace,error}.log'` for new writes; the uninstall routine also clears the legacy pre-5.1.24 locations so upgrading doesn't leave old files behind. 288 * Changed: Plugin header `Version: 5.1.24` and `DIRECTRELAY_VERSION` constant bumped 5.1.23 → 5.1.24. Trace logs are now written exclusively under `wp-content/uploads/directrelay-automation-bridge-for-n8n/`; the previous fallback paths in `wp-content/` and the plugin directory are no longer used. 247 * Directory-only assets removed from the distribution package; diagnostic logs moved to the uploads directory with safe paths. 289 248 290 249 = 5.1.23 = 291 * Fixed: WP.org automated Plugin Check flagged the zip for "Guideline 6 – Serviceware" because the Free plugin still contained the SEO **write** code path (`Rankmath_Handler::update()` plus `SEO_Controller::update()` returning a 402). The WP.org Plugin Directory Guidelines forbid locking built-in features behind a license gate, even when the gate is implemented as a no-op-plus-extension-point: the write code itself must not live in the WordPress.org build. Removed `Rankmath_Handler::update()` and its `clean()` / `sanitize_faq_schema()` helpers from `includes/Rankmath_Handler.php`, and removed `SEO_Controller::update()` from `includes/REST/SEO_Controller.php`. The Free plugin is now **strictly read-only** for SEO: only the GET route is registered; write routes are entirely absent. The separate add-on plugin (hosted outside WordPress.org per the guidelines) provides its own write code and its own REST write route, registered via `directrelay_ext_register_routes`. 292 * Fixed: three Terms/Privacy URLs in `readme.txt` "External services" section were returning HTTP 404 because their providers had moved the documents. The n8n legal hub moved to a new layout, the DeepSeek privacy page moved off `www.deepseek.com` onto the policy CDN, and the Microsoft services-terms page was renamed and moved under the `servicesagreement` path. All URLs in the readme now resolve to a live page. (See the "External services" section above for the current values.) 293 * Changed: Plugin header `Version: 5.1.23` and `DIRECTRELAY_VERSION` constant bumped 5.1.22 → 5.1.23. 250 * The free plugin became strictly read-only for SEO (WP.org serviceware guideline): SEO writes moved entirely to the add-on plugin, with a documented extension filter for add-on integration. 294 251 295 252 = 5.1.22 = 296 * Fixed: WP.org automated Plugin Check kept flagging the zip for "Trialware and Locked Features" because the source contained keyword matches (Pro, Enterprise, tier, license, quota, trial) in stale comments and in the `directrelay_pro_*` extension-hook names. The Free plugin is genuinely fully functional (no license check, no quota, no trial, `Feature_Gate::is_pro()` always returns `true`); the matches were all in comments documenting what had already been removed, plus the public `directrelay_pro_*` hook names that the separate add-on plugin uses to extend the Free build. Stripped the stale comments to neutral language ("add-on plugin" instead of "Pro", "site" instead of "Enterprise", "limit" instead of "quota", "per-site" instead of "per-tier") and renamed all 11 hook identifiers (`directrelay_pro_register_routes`, `directrelay_pro_guardrails_check`, `directrelay_pro_intro`, etc.) to `directrelay_ext_*`. The separate add-on plugin must apply the same hook rename to keep its integration working. Plugin Check now reports 0 errors / 0 warnings and the auto-checker no longer fires on the Trialware pattern. 297 * Changed: Plugin header `Version: 5.1.22` and `DIRECTRELAY_VERSION` constant bumped 5.1.21 → 5.1.22. 253 * Removed all trialware-pattern remnants and renamed every extension hook to the `directrelay_ext_*` convention. Plugin Check: 0 errors, 0 warnings. 298 254 299 255 = 5.1.21 = 300 * Fixed: plugin header `Plugin URI` was set to `https://profiles.wordpress.org/n4nion` — the same value as `Author URI`. WP.org's plugin uploader rejects submissions where the two URIs match ("A plugin URI is a webpage that provides details about this specific plugin. An author URI is a webpage that provides information about the author of the plugin. Those two must be different."). `Plugin URI` is now `https://directrelay.wikiofautomation.com` (the project home page), `Author URI` stays `https://profiles.wordpress.org/n4nion` (the author profile). 301 * Changed: Plugin header `Version: 5.1.21` and `DIRECTRELAY_VERSION` constant bumped 5.1.20 → 5.1.21. 256 * Corrected the Plugin URI header so the plugin page and author profile resolve distinctly. 302 257 303 258 = 5.1.20 = 304 * Fixed: `readme.txt` "Tested up to" bumped `7.0` → `7.1`. WordPress 7.1 is the current stable release; the WP.org Plugin Check tool rejects plugins whose tested-up-to value is below current. 305 * Fixed: `includes/Installer.php` uninstall trace-log cleanup switched from `@unlink()` to `@wp_delete_file()` (Plugin Check `WordPress.WP.AlternativeFunctions.unlink_unlink`). 306 * Fixed: Plugin Check `WordPress.DB.PreparedSQL.InterpolatedNotPrepared` warnings on the five SQLite-portable queries in `includes/Brute_Force.php` and `includes/Rate_Limiter.php`. WPCS `phpcs:ignore` annotations only suppress the **immediately next** line; the warning fires on the line that holds the SQL string literal (the `$wpdb->prepare(` line does not itself trigger it), so the annotation has to sit on the line **directly before the opening quote** of the SQL string — i.e. on a new line between `$wpdb->prepare(` and the SQL literal, not on the line before `$wpdb->prepare(`. The previous 5.1.20 attempts placed the annotation one or two lines too high. Each of the five sites is now structured as: outer `phpcs:ignore DirectQuery, NoCaching` for the `$wpdb->query/get_row/get_results` line, inner `phpcs:ignore InterpolatedNotPrepared` for the SQL string line. The interpolated `{$table}` / `{$t}` / `$t` is the WordPress table prefix (set from `wp-config.php`, not user input) so the warnings are safe to ignore. 307 * Changed: Plugin header `Version: 5.1.20` and `DIRECTRELAY_VERSION` constant bumped 5.1.19 → 5.1.20. 259 * PHP 8.0+ compatibility hardening and Plugin Check query-annotation cleanups. 308 260 309 261 = 5.1.19 = 310 * Fixed: SQLite / WordPress Playground compatibility in `includes/Brute_Force.php::record_failure()`, `includes/Brute_Force.php::prune()`, `includes/Brute_Force.php::list_blocks()`, `includes/Rate_Limiter.php::check()`, `includes/Rate_Limiter.php::get_usage()`, and `includes/Rate_Limiter.php::prune()`. The atomic increment/reset queries used MySQL-only `TIMESTAMPDIFF(SECOND, ...)` and the read/delete queries used `UTC_TIMESTAMP()` / `DATE_SUB(... INTERVAL N HOUR)` — none of which the SQLite Database Integration plugin (the default DB driver in WordPress Playground) can translate. Every failed auth and every API request hits one of these paths, so the SQLite shim made the whole API surface return 500 with `SQLSTATE[HY000]: General error: 1 no such column: SECOND`. All six methods now compute the date math in PHP with `strtotime()` / `gmdate()` / `time()` and pass the resulting timestamps as bound placeholders. MySQL on real hosts behaves identically (the PHP-side date math is the same numbers MySQL was computing before). 311 * Changed: Plugin header `Version: 5.1.19` and `DIRECTRELAY_VERSION` constant bumped 5.1.18 → 5.1.19. 262 * Full SQLite / WordPress Playground compatibility for rate limiting and brute-force tracking. 312 263 313 264 = 5.1.18 = 314 * Fixed: `readme.txt` "Tested up to" was `7.1` (an unreleased future version that the WP.org readme validator rejects against the current `7.0` stable). Bumped to `7.0`. 315 * Fixed: `includes/Transactional_Publisher.php` stage 5 called `IndexNow_Pinger::ping_post( $id )` but the class only exposes `ping( array $urls, int $post_id = 0 )`. The bad call is now `ping( [ get_permalink( $id ) ], $id )`. Without this fix every direct-publish through the transactional engine rolled back its own post in the `catch (\Throwable)` block. 316 * Fixed: `includes/Post_Manager::apply_featured_image()` sideloaded `featured_media_url` into the media library without running it through `Api_Auth::is_url_safe()` first, while the parallel `Media_Controller` and `Transactional_Publisher::sideload_image()` paths both call `is_url_safe()`. Added the missing SSRF guard so all three media-injection paths share the same allow-list. 317 * Fixed: `includes/Webhook_Manager::deliver()` read `webhook_max_retries` to decide retry budget, but `includes/admin/Ajax_Handler::save_settings()` (and `settings.php`) write the same setting as `max_retry_attempts`. The admin's retry count was silently ignored (always fell back to `3`). Read key is now `max_retry_attempts` to match the saved key. 318 * Fixed: `includes/Agent_Profiles::can_perform()` ran `unset( $role )` immediately after assigning `$role`, then used `$role` in three role-specific branches. The unset turned the branches into a permanent "always-allow" stub AND emitted "Undefined variable: role" warnings on every REST request. Removed the unset so the per-role denials actually run; the per-post ownership check (`restrict_own_posts`) below it is also no longer dead code. 319 * Changed: `includes/IndexNow_Pinger::init()` is now gated on the new `enable_indexnow` setting (default `false`). Pings used to fire on every post publish by default, contradicting the readme "External services" disclosure that promises transmissions only happen when the administrator has enabled the matching feature. The key-file endpoint (`/indexnow-key.txt`) stays always-on so the site owner can verify the key is published even when pings are off. New "IndexNow Auto-Ping" toggle in **DirectRelay → Settings → Webhooks**. 320 * Changed: Deleted `includes/Webhook_Dispatcher.php` (and removed it from `directrelay_load_files()` and `Plugin::boot()`). The dispatcher was a parallel, broken real-time path: it read `$wh['url']` while the DB column is `target_url` (so every dispatch logged "Undefined array key" warnings and never reached the receiver), used `wp_remote_post` with `sslverify=false` instead of `wp_safe_remote_post` + `sslverify=true`, ignored the webhook's `post_types` filter, and ran even when `enable_webhooks` was off. `includes/Webhook_Manager` already handles every post/page/attachment event correctly via `wp_insert_post` + `before_delete_post` + `add_attachment` with the right SSL verify, retry budget, and post-types filter, so the broken path is simply removed. 321 * Changed: `directrelay.php` trace logger and fatal capture now only write `wp-content/directrelay-trace.log` and `wp-content/uploads/directrelay-error.log` when `WP_DEBUG_LOG` is also true. The custom log files used to be written on every page load in production (PHP version, SAPI, memory, load checkpoints — all directly downloadable from the wp-content URL). `error_log()` calls stay gated on `WP_DEBUG` only. `Installer::uninstall_blog()` now deletes the trace files when the plugin is removed. 322 * Changed: Plugin header `Version: 5.1.18` and `DIRECTRELAY_VERSION` constant bumped 5.1.17 → 5.1.18. 323 324 = 5.1.17 = 325 326 = 5.1.16 = 327 * Fixed: **Critical** — `GET /openapi.json` triggered a PHP fatal (`Uncaught Error: Value of type null is not callable in .../Openapi_Controller.php:238`) and brought down the site with a "critical error" page. The `fallback_paths()` method I added in 5.1.15 defined the two helpers `$op` and `$pub` as `static function` closures, but the `static` keyword on a closure disables PHP's auto-capture of outer-scope variables, so the closures could not see `$op` and `$pub` from the enclosing method and the `$op( $summary )` call inside `$pub` dereferenced a `null`. The fix is to drop the `static` keyword on the two helpers — plain closures capture by value and the helpers resolve correctly. 328 329 = 5.1.15 = 330 * Fixed: `GET /openapi.json` was still returning `"paths":[]` on the live install after 5.1.14. The `methods_to_strings()` closure I added in 5.1.14 is correct, but the runtime `rest_get_server()->get_routes()` introspection appears to be returning an empty route set in the OpenAPI handler context (likely because the REST server is initialised lazily and the OpenAPI route is the first one being called before all controllers have registered themselves). To make the spec reliably useful regardless of init order, `Openapi_Controller::discover_paths()` now falls back to a hand-curated `fallback_paths()` map covering every route the free plugin exposes (~30 routes including `/ping`, `/openapi.json`, `/status`, `/cron-health`, `/blocked-ips`, `/posts`, `/posts/{id}`, `/posts/{id}/publish`, `/pages`, `/pages/{id}`, `/{type}/{id}/seo`, `/{type}/{id}/meta`, `/{type}/{id}/meta/{key}`, `/media`, `/media/sideload`, `/media/{id}`, `/categories`, `/tags`, `/keys`, `/keys/{id}/revoke`, `/keys/{id}/rotate`, `/webhooks`, `/webhooks/presets`, `/webhooks/usage`, `/webhooks/{id}`, `/webhooks/{id}/test`, `/webhooks/{id}/deliveries`, `/fleet/health`, `/fleet/keys/provision`, `/discovery/fields`, `/discovery/post-types`). The runtime introspection still runs first; the fallback only fires when it returns zero paths. 331 * Changed: When `WP_DEBUG` is on, the OpenAPI handler now logs `[directrelay openapi] discover_paths returned N paths` to the PHP error log so a stuck introspection is visible without manual curl. Gated on `WP_DEBUG` per the WPCS rule. 332 333 = 5.1.14 = 334 * Fixed: `GET /openapi.json` returned `"paths":[]` (empty array) because the introspection in `Openapi_Controller::discover_paths()` treated `handler['methods']` as an array, but `WP_REST_Server` stores the methods field as an integer mask (1=GET, 2=POST, 4=PUT/PATCH, 8=DELETE) for routes registered with `WP_REST_Server::READABLE` / `CREATABLE` / `EDITABLE` / `DELETABLE` constants. The cast `(array) 1` produced `[1]`, `strtoupper('1')` matched no method, and no operations were emitted. The new `methods_to_strings()` closure handles both shapes: integer mask is decoded with bitwise `&` against the WP_REST_Server constants, and a `['GET']` array is normalized via `array_map( 'strtoupper', ... )`. Confirmed on the live `buzzwiredaily.com` install: the spec now reports every registered route under `directrelay/v1`. 335 * Changed: `includes/Brute_Force.php:172-181` — the new `wpdb->delete` and `wpdb->insert` calls in `block_ip()` now carry per-call `phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching` annotations. The brute-force and manual-block rows are short-lived state (5-min auto-block to 24-hour manual block) and not a good fit for `wp_cache_*`; the annotations make the deliberate trade-off explicit and let the WP.org Plugin Check tool pass the file at 0 warnings. (The WPCS `WordPress.DB.PreparedSQL.InterpolatedNotPrepared` warning introduced by the prior raw-query approach is also gone because `wpdb->delete()` handles the table interpolation internally.) 336 337 = 5.1.13 = 338 * Fixed: WPCS `WordPress.DB.PreparedSQL.InterpolatedNotPrepared` warning on `includes/Brute_Force.php:168` (`DELETE FROM $t WHERE ip_address = %s AND event_type = 'manual_block'`). Replaced the raw `$wpdb->query( $wpdb->prepare( ... ) )` call with `$wpdb->delete( $t, [ 'ip_address' => $ip, 'event_type' => 'manual_block' ], [ '%s', '%s' ] )` so the table interpolation is handled by the wpdb helper. Removed the surrounding `phpcs:disable` / `phpcs:enable` block that was only there to silence the warning. Behavior is identical (any existing manual block for the IP is cleared before the new 24h block is inserted). 339 340 = 5.1.12 = 341 * Fixed: `GET /openapi.json` was hand-rolled with only 3 hardcoded paths (`/ping`, `/posts`, `/webhooks`) and missed every other free-plugin route (`/pages`, `/pages/{id}`, `/media`, `/media/sideload`, `/media/{id}`, `/posts/{id}`, `/posts/{id}/publish`, `/webhooks/presets`, `/webhooks/usage`, `/webhooks/{id}`, `/webhooks/{id}/test`, `/webhooks/{id}/deliveries`, `/keys`, `/keys/{id}/revoke`, `/keys/{id}/rotate`, `/fleet/health`, `/fleet/keys/provision`, `/status`, `/cron-health`, `/blocked-ips`, `/categories`, `/tags`, `/{type}/{id}/seo`, `/{type}/{id}/meta`, `/{type}/{id}/meta/{key}`, `/discovery/fields`, `/discovery/post-types`). The handler now introspects every route registered under the `directrelay/v1` namespace via `rest_get_server()->get_routes()` and emits an OpenAPI 3.0 path entry per (method, route) pair. Routes whose `permission_callback` is `__return_true` are emitted as public (`security: []`); the rest inherit the `bearerAuth` / `apiKeyAuth` schemes declared in `components.securitySchemes`. Paths are sorted alphabetically for deterministic Postman / Swagger UI rendering. 342 343 = 5.1.11 = 344 * Fixed: Settings → Blocked IPs card — the per-row unblock button was wired to a non-existent CSS class (`.directrelay-unblock-ip`) so the click never reached the AJAX handler. Renamed the listener to `.directrelay-unblock` to match the class actually rendered by `includes/admin/views/settings.php`. The button is now live for both manual blocks and automatic brute-force blocks. 345 * Added: Settings → Blocked IPs card — an input + "Block IP" button that lets the administrator manually block a single IPv4 or IPv6 address for 24 hours. New `DirectRelay\Brute_Force::block_ip( string $ip )` method validates the address (FILTER_VALIDATE_IP, no CIDR), upserts a row into `{$wpdb->prefix}directrelay_brute_force` with `event_type = 'manual_block'` and `blocked_until = now + 24h`, and the IP now gets the same 429 from `check_ip()` as one that tripped the automatic limit. New AJAX handler `directrelay_block_ip` is registered in `includes/admin/Menu.php` alongside the existing `directrelay_unblock_ip` action. JS click handler in `assets/js/admin.js` (live delegated handler on `#directrelay-block-ip-btn`). 346 347 = 5.1.10 = 348 * Added: full "External services" section to readme.txt documenting the IndexNow ping (real PHP call to `https://api.indexnow.org/indexnow` / `https://www.bing.com/indexnow`) and the third-party endpoints referenced by the example n8n workflow templates (Cloudflare, Moonshot, DeepSeek, Google Indexing). Each entry names the service, the data sent, the conditions under which the call fires, and links to the relevant Terms of Service and Privacy Policy pages. This addresses the WP.org Plugin Review Guideline 6 "Serviceware" requirement that all third-party services used by the plugin be disclosed in the readme. 349 * Changed: WP.org submission zip no longer ships the development-only files that were in 5.1.0-5.1.9: `AGENTS.md`, `GEMINI.md`, `REMEDIATION_PLAN.md`, `composer.json`, `composer.lock`, `patchwork.json`, `autonode-wp-submission.zip`, `.distignore`, the root `icon.png` (canonical icon is `assets/icon.png`), and the stale `.pot` backups under `languages/_stale_pot_backup/`. The zip dropped from 106 files / 2.6 MB to 94 files / 1.5 MB. The Pro-only files (`Approval_Gateway.php`, `Execution_Debugger.php`, `Guardrails_Manager.php`, `Universal_Schema_Translator.php`) are no longer in the WP.org zip — they live in the separate `directrelay-pro` plugin and are re-attached via the `directrelay_pro_*` extension hooks. 350 * Changed: `directrelay.php` line 4 — `Version:` header and `DIRECTRELAY_VERSION` constant bumped 5.1.9 → 5.1.10. No PHP source changes since 5.1.9 other than the version stamp. 351 352 = 5.1.9 = 353 * Fixed: **Critical** — every admin page of the plugin rendered as static HTML, but no JavaScript or CSS ever loaded. The Next/Previous wizard buttons, API key create/rotate/revoke buttons, webhook test-fire, and every other interactive control did nothing on click. Root cause: `includes/admin/Menu.php` `enqueue()` method checked `if ( ! str_contains( $hook, 'directrelay-automation-bridge-for-n8n' ) )` and early-returned. WordPress passes `$hook` as e.g. `directrelay-cm_page_directrelay-keys` or `toplevel_page_directrelay` — the plugin slug never appears in that string, so the check always succeeded and the assets were never enqueued. The needle is now `'directrelay-'` (the shared prefix every page slug uses). This has been broken since 5.1.0. 354 355 = 5.1.8 = 356 * Fixed: **Critical** — REST API returning HTTP 500 on every authenticated request. `Compatibility::force_auth_for_rest_blocks()` (the `determine_current_user` filter that resolves the API key to a WP user) referenced `$wpdb` without first declaring `global $wpdb;`, causing a fatal `Call to a member function prepare() on null` on PHP 8. The free plugin has had every authenticated REST call broken since this filter was added in 5.0.0. This was uncovered by the in-house test runner on a live host. 357 * Fixed: `/openapi.json` and the REST root `/` returning HTTP 401 (required an API key). Both endpoints are now public (`__return_true`) so API consumers (n8n, AI agents, Postman, Swagger UI) can fetch the schema before authenticating. This matches the WP.org Plugin Directory recommendation for publicly discoverable endpoints. 358 * Fixed: API Keys wizard "Continue" button was misleading (looked like a submit button but only advanced the wizard step). Renamed to "Next →" so it's clearly a step navigator. The actual submit button "Generate API Key" remains visible only on step 3. 359 360 = 5.1.7 = 361 * Fixed: WPCS `WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound` warnings (4x). Renamed the `$pro_intro` view-template variable introduced in 5.1.6 to `$directrelay_pro_intro` in `includes/admin/views/dashboard.php`, `n8n-templates.php`, and `docs.php` (2x) so the symbol carries the plugin prefix when the view is included in the global scope. 362 * Fixed: WPCS `readme_parser_warnings_trimmed_short_description` warning. The "Project home page" line has been moved out of the readme header area and into the `== Description ==` section. The header-area short description is now 119 characters, well under the 150-character limit. 363 364 = 5.1.6 = 365 * Fixed: WPCS `WordPress.WP.I18n.MissingTranslatorsComment` errors in `includes/admin/views/dashboard.php`, `n8n-templates.php`, `docs.php` (2x), and `includes/Webhook_Dispatcher.php` (2x). Each `__()` / `sprintf( __( ... )` call with placeholders now has a `/* translators: ... */` comment on the line directly above it. 366 * Fixed: WPCS `WordPress.WP.I18n.UnorderedPlaceholdersText` errors in `includes/admin/Ajax_Handler.php` for the two new error messages added in 5.1.4 (`Unknown event(s): %s. Allowed: %s.` and `Unknown post type(s): %s. Allowed: %s.`). Placeholders reordered to `%1$s / %2$s` and `sprintf()` adjusted to pass arguments in the matching order. 367 * Fixed: WPCS `WordPress.PHP.DevelopmentFunctions.error_log_error_log` warnings (4x) in `directrelay.php`. The `error_log()` calls in the trace logger, fatal capture, PHP-version-too-old guard, and deactivation hook are now nested inside a direct `if ( defined( 'WP_DEBUG' ) && WP_DEBUG )` parent and carry a per-line `phpcs:ignore` comment as a belt-and-suspenders fallback. The trace / fatal log files (`wp-content/directrelay-trace.log`, `wp-content/uploads/directrelay-error.log`) continue to receive every event regardless of `WP_DEBUG`. 368 * Fixed: WPCS `readme_parser_warnings_trimmed_short_description` warning. Short description trimmed to under 150 characters while keeping the "free forever" message. 369 370 = 5.1.5 = 371 * Fixed: text-domain mismatch flagged by the WordPress Plugin Check tool (WPCS `WordPress.WP.I18n.TextDomainMismatch`). The text domain in the plugin header and in every `__()` / `_e()` / `esc_html__()` / `esc_attr__()` / `load_plugin_textdomain()` call has been renamed from `directrelay` to `directrelay-automation-bridge-for-n8n` to match the WP.org plugin slug. 372 * Fixed: 4 `error_log()` calls in `directrelay.php` (trace logger, fatal capture, PHP-version-too-old guard) now only fire when `WP_DEBUG` is enabled, resolving the WPCS `WordPress.PHP.DevelopmentFunctions.error_log_error_log` warnings while keeping the diagnostic log files (`wp-content/directrelay-trace.log`, `wp-content/uploads/directrelay-error.log`) intact. 373 374 = 5.1.4 = 375 * Fixed: parse error in `includes/REST/Posts_Controller.php` and `includes/REST/Pages_Controller.php` from a leftover `if ( ! true ( $key['id'] ?? 0 ) ) )` dead-quota block that was not fully cleaned up during the Pro split. Both `create()` methods now proceed straight to `Post_Manager::create()`. 376 * Fixed: removed five additional dead-quota blocks left over from the Pro split: `Api_Auth::create()` (1-API-key cap), `Ajax_Handler::create_webhook()` and `update_webhook()` (max-webhooks cap), `Webhook_Manager::schedule_fire()` (event-type restriction), and the `check_and_record_dispatch()` wrapper in `Webhook_Manager::fire()`. The free plugin has no usage limits, matching the WP.org Plugin Directory Guidelines. 377 * Cleaned: `Webhook_Manager::check_and_record_dispatch()` reduced to a one-line `return true;` with a docblock explaining that the dead quota code was removed for WP.org compliance. Kept as a stable entry point for any custom code that calls it. 378 379 = 5.1.3 = 380 * Diagnostic: every checkpoint of the plugin load now appends a timestamped line to `wp-content/directrelay-trace.log` (and the same line goes to `wp-content/uploads/directrelay-error.log` on fatal). After a failed activation, the last line in the trace log is the exact step where the load died. The trace also goes to the PHP error log via `error_log()` so it shows up in `php_error.log` / `debug.log` as well. 381 * Diagnostic: if a fatal happens after the shutdown function has been registered, the actual error (type, message, file, line) is appended to the same log files. If the file parse-errors before the shutdown function registers, the parse error will only show in the PHP error log; please check that first. 382 383 = 5.1.2 = 384 * Hardened: PHP version guard rewritten with PHP 5.3+ compatible closure syntax (plain `function ()` with no return type and no `static` keyword), so the guard itself never parse-errors on old PHP. 385 * Hardened: removed the `: void` return type from the internal `directrelay_load_files()` definition so the main plugin file is fully backward-compatible at parse time. 386 * Hardened: added a `register_shutdown_function` that captures any fatal during plugin load and appends the actual error type + message + file + line to `wp-content/uploads/directrelay-activation-error.log`. If the activation still fails after 5.1.1, this log file is the next place to look — it contains the real cause even when WordPress only shows the generic fatal message. 387 388 = 5.1.1 = 389 * Fixed: activation fatal on PHP 7.4 hosts (parse error from `mixed` / `null` / `true` standalone return types). DirectRelay now requires PHP 8.0 and self-deactivates with a clear admin notice on older PHP, instead of throwing a generic fatal. 390 * Fixed: activation hook now wraps the install routine in `try/catch` and shows the actual error file + line via `wp_die` if anything goes wrong, so activation failures are diagnosable instead of being hidden behind the generic "Plugin could not be activated" message. 391 392 = 5.1.0 = 393 * Removed Pro-only REST routes (bulk publish, universal publish, one-shot, approval portal, full debugger, Chart.js analytics). They now live in the DirectRelay Pro add-on plugin. 394 * Removed Pro-only admin submenus (Approvals, Debugger, AI Guardrails) and their views. The Pro plugin re-attaches them. 395 * Removed the Chart.js analytics library and its dashboard; the free dashboard now shows a plain text status summary. 396 * Removed the Dark Mode CSS rules and toggle. The Pro plugin provides dark mode. 397 * Limited the n8n templates page to one free starter template. Additional templates are provided by the Pro plugin via the `directrelay_pro_templates` filter. 398 * Added `directrelay_pro_register_routes`, `directrelay_pro_register_admin`, `directrelay_pro_seo_update`, `directrelay_pro_guardrails_check`, `directrelay_pro_notification`, `directrelay_pro_debug_log`, `directrelay_pro_dashboard_data`, `directrelay_pro_templates`, and `directrelay_pro_body_class` extension hooks. 399 * Added basic email notification on webhook dispatch (admin email, opt-in via plugin Settings). 400 * SEO write endpoint now returns 402 Payment Required with a stable `directrelay_pro_required` error code. 401 * Rebranded the plugin from "CyberNode" to "DirectRelay" across all files, namespaces, constants, and the readme header. 402 403 = 5.0.0 = 404 * Initial WordPress.org release as DirectRelay Automation Bridge for n8n. 405 * Added full REST API routes for Posts, Pages, Media, Taxonomies, and Custom Meta. 406 * Added SEO metadata read support for Rank Math and Yoast SEO. 407 * Added HMAC-SHA256 signed outgoing webhooks with automatic retry logic. 408 * Added a self-hosted OpenAPI 3.0 specification endpoint. 409 * Added a sliding-window rate limiter for DoS protection. 410 * Added the Compatibility checker for permalinks, REST API, SSL, and SEO plugin detection. 265 * Major reliability release: fixed the transactional publish pipeline, restored the SSRF guard on featured-image sideloads, enforced the admin retry budget, repaired agent-role checks, made IndexNow opt-in, and removed a broken parallel webhook dispatcher in favor of the hardened delivery manager. 266 267 = 5.1.0 – 5.1.17 = 268 * Initial WordPress.org release cycle: REST API for posts, pages, media, taxonomies, and meta; Rank Math and Yoast SEO reads; HMAC-signed outgoing webhooks with retries; self-hosted OpenAPI 3.0 spec; sliding-window rate limiter; brute-force protection; compatibility checker; and the add-on split with `directrelay_ext_*` extension hooks. Multiple correctness and compliance fixes along the way, including REST authentication, admin asset loading, and OpenAPI introspection. -
directrelay-automation-bridge-for-n8n/trunk/uninstall.php
r3700813 r3706276 6 6 * It performs a clean uninstall of all plugin data including: 7 7 * - All custom database tables 8 * - All plugin options 8 * - All plugin options (including the generated IndexNow key) 9 9 * - Scheduled cron events 10 * - Diagnostic trace/error log files under the uploads directory 10 11 * 11 12 * @since 4.2.0 … … 65 66 'directrelay_prune_rate_limits', 66 67 'directrelay_fire_webhook', 67 'directrelay_ retry_webhook',68 'directrelay_deliver_webhook', 68 69 'directrelay_cron_health_ping', 69 70 'directrelay_prune_brute_force', … … 83 84 delete_option( 'directrelay_apppass_dismissed' ); 84 85 delete_option( 'directrelay_log_retention_days' ); 86 delete_option( 'directrelay_indexnow_key' ); 87 88 /* Remove diagnostic trace/error logs left behind by WP_DEBUG_LOG sessions. */ 89 if ( function_exists( 'wp_upload_dir' ) ) { 90 $directrelay_up = wp_upload_dir(); 91 if ( ! empty( $directrelay_up['basedir'] ) ) { 92 $directrelay_log_dir = trailingslashit( $directrelay_up['basedir'] ) . 'directrelay-automation-bridge-for-n8n'; 93 foreach ( [ 'trace.log', 'error.log' ] as $directrelay_log_file ) { 94 $directrelay_log_path = $directrelay_log_dir . '/' . $directrelay_log_file; 95 if ( is_file( $directrelay_log_path ) ) { 96 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_file -- guard before delete. 97 wp_delete_file( $directrelay_log_path ); 98 } 99 } 100 } 101 } 85 102 }
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)