Plugin Directory

Changeset 3706276


Ignore:
Timestamp:
09/21/2026 08:24:35 PM (4 weeks ago)
Author:
n4nion
Message:

Release 5.1.32: security hardening (key scoping, markdown XSS, SSRF), MySQL schema fix, webhook admin UX, optimized listing

Location:
directrelay-automation-bridge-for-n8n
Files:
14 added
6 deleted
72 edited
1 copied

Legend:

Unmodified
Added
Removed
  • directrelay-automation-bridge-for-n8n/tags/5.1.32/assets/js/admin.js

    r3699228 r3706276  
    148148           
    149149            var payload = {
    150                 action: 'directrelay_create_key', 
     150                action: 'directrelay_create_key',
    151151                nonce: directrelayAdmin.nonce,
    152                 label: $('[name=label]').val(), 
     152                label: $('[name=label]').val(),
    153153                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,
    155159                expires_at: $('[name=expires_at]').val(),
    156160                ip_whitelist: $('[name=ip_whitelist]').val(),
    … …  
    348352
    349353        /* —— 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); });
    354363        });
    355364
    356365        /* —— Delete webhook ———————————————————————————————————————————— */
    357         $(document).on('click', '.directrelay-del-wh', function (e) {
     366        $(document).on('click', '.directrelay-delete-wh', function (e) {
    358367            e.preventDefault();
    359368            var id = $(this).data('id');
    … …  
    382391
    383392        /* —— 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(); });
    387396
    388397        /* —— Run System Diagnostic ————————————————————————————————————— */
    … …  
    711720        $(document).on('click', '.directrelay-view-log', function() {
    712721            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();
    713725            $('#directrelay-delivery-log').html('<p class="directrelay-empty">Loading...</p>');
    714726            $.ajax({
  • directrelay-automation-bridge-for-n8n/tags/5.1.32/directrelay.php

    r3700957 r3706276  
    11<?php
    22/**
    3  * Plugin Name: DirectRelay Automation Bridge for n8n
     3 * Plugin Name: DirectRelay – n8n Workflow Automation Plugin (REST API & Webhooks)
    44 * Plugin URI: https://directrelay.wikiofautomation.com
    55 * 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.31
     6 * Version: 5.1.32
    77 * Author: n4nion
    88 * Author URI: https://profiles.wordpress.org/n4nion
    … …  
    4141                        if ( ! empty( $up['basedir'] ) ) {
    4242                                $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                                }
    4353                                $candidates[] = $dir . '/trace.log';
    4454                                $candidates[] = $dir . '/error.log';
    … …  
    7282                        $paths = directrelay_log_paths();
    7383                        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                                }
    7590                        }
    7691                }
    … …  
    104119                        $paths = directrelay_log_paths();
    105120                        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                                }
    107127                        }
    108128                }
    … …  
    169189}
    170190
     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 */
     201if ( 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
    171249if ( defined( 'DIRECTRELAY_LOADED' ) || class_exists( '\DirectRelay\Plugin', false ) ) {
    172250        directrelay_trace( 'already_loaded: bail' );
    … …  
    176254directrelay_trace( 'past_load_guard: defining constants' );
    177255
    178 define( 'DIRECTRELAY_VERSION', '5.1.31' );
     256define( 'DIRECTRELAY_VERSION', '5.1.32' );
    179257define( 'DIRECTRELAY_FILE', __FILE__ );
    180258define( 'DIRECTRELAY_DIR', plugin_dir_path( __FILE__ ) );
    181259define( 'DIRECTRELAY_URL', plugin_dir_url( __FILE__ ) );
    182260define( 'DIRECTRELAY_NS', 'directrelay/v1' );
    183 define( 'DIRECTRELAY_DB_VERSION', 11 );
     261define( 'DIRECTRELAY_DB_VERSION', 12 ); // 12: TEXT columns drop DEFAULT literals (illegal on stock MySQL); key_hash narrowed to 191.
    184262define( 'DIRECTRELAY_BUILD', 'all-in-one' );
    185263
    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' );
    193266
    194267/**
    … …  
    213286                'includes/Compatibility.php',
    214287                'includes/Cron_Health.php',
    215                 'includes/Telemetry_Collector.php',
    216288                'includes/Feature_Gate.php',
    217289                /* REST layer (free surface) */
    … …  
    223295                'includes/REST/Media_Controller.php',
    224296                'includes/REST/Taxonomy_Controller.php',
    225                 'includes/REST/Fleet_Controller.php',
    226297                'includes/REST/Webhook_Controller.php',
    227298                'includes/REST/Keys_Controller.php',
    … …  
    230301                'includes/REST/Openapi_Controller.php',
    231302                /*
    232                  * portal, full debugger, Chart.js analytics) live in the
    233                  * directrelay-pro plugin distributed from
    234                  * https://directrelay.wikiofautomation.com. They re-attach to the
    235                  * 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.
    236307                 */
    237308                /* Admin layer */
    … …  
    301372                        wp_clear_scheduled_hook( 'directrelay_prune_rate_limits' );
    302373                        wp_clear_scheduled_hook( 'directrelay_fire_webhook' );
    303                         wp_clear_scheduled_hook( 'directrelay_retry_webhook' );
     374                        wp_clear_scheduled_hook( 'directrelay_deliver_webhook' );
    304375                        wp_clear_scheduled_hook( 'directrelay_cron_health_ping' );
    305376                        wp_clear_scheduled_hook( 'directrelay_prune_brute_force' );
    306                         wp_clear_scheduled_hook( 'directrelay_telemetry_heartbeat' );
    307377                        flush_rewrite_rules();
    308378                } catch ( \Throwable $e ) {
  • directrelay-automation-bridge-for-n8n/tags/5.1.32/includes/Activity_Logger.php

    r3699228 r3706276  
    115115    public static function hourly_stats( int $hours = 24, ?int $key_id = null ): array {
    116116        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 );
    117120        if ( $key_id ) {
    118121            $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,
    121124                $key_id
    122125            );
    123126        } else {
    124127            $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                 $hours
     128                "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
    127130            );
    128131        }
    … …  
    133136    public static function top_endpoints( int $days = 7 ): array {
    134137        global $wpdb;
     138        /* PHP-side date math for SQLite portability. */
     139        $cutoff = gmdate( 'Y-m-d H:00:00', time() - $days * DAY_IN_SECONDS );
    135140        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- custom table.
    136141        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             $days
     142            "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
    139144        ), ARRAY_A ) ?: array();
    140145    }
    … …  
    143148        global $wpdb;
    144149        $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 );
    145152
    146153        /* Prune standard logs */
    147154        // 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 ) );
    149156
    150157        /* Prune analytics (uses bucket_hour instead of created_at) */
    151158        // 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        }
    153169    }
    154170
  • directrelay-automation-bridge-for-n8n/tags/5.1.32/includes/Agent_Profiles.php

    r3699228 r3706276  
    9797        $role = $key['agent_role'] ?? self::ROLE_FULL_ACCESS;
    9898
    99         // Agent personas (non-FULL_ACCESS) are a add-on feature(s). The free
     99        // Agent personas (non-FULL_ACCESS) are an add-on feature. The free
    100100        // plugin has only the FULL_ACCESS role, so any non-default role
    101101        // means the add-on plugin is extending the agent profile. The free
    … …  
    125125        // 3. Post Ownership Isolation check
    126126        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 );
    135128        }
    136129
     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        }
    137156        return true;
    138157    }
  • directrelay-automation-bridge-for-n8n/tags/5.1.32/includes/Api_Auth.php

    r3699228 r3706276  
    2121        'approval:read', 'approval:write',
    2222        'debugger:read', 'debugger:write',
    23         'fleet:read', 'fleet:write',
    2423        'universal:write',
    2524    ];
    … …  
    5958        // because the WP.org Plugin Directory Guidelines forbid usage
    6059        // 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`
    6261        // filter.
    6362        $raw    = self::generate_raw();
    … …  
    194193        $prefix       = substr( $raw, 0, 12 );
    195194        $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 );
    196198        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom table auth lookup.
    197199        $rows = $wpdb->get_results(
    … …  
    200202                 WHERE (key_prefix = %s OR key_prefix = %s)
    201203                 AND revoked = 0
    202                  AND (expires_at IS NULL OR expires_at > UTC_TIMESTAMP())",
     204                 AND (expires_at IS NULL OR expires_at > %s)",
    203205                $prefix,
    204                 $prefix_short
     206                $prefix_short,
     207                $now_utc
    205208            ),
    206209            ARRAY_A
    … …  
    279282        $row['last_used_ip']   = $ip;
    280283        $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 ) ?: [];
    282285        $row['is_full']        = empty( $row['scopes_array'] );
    283286        return $row;
    … …  
    550553
    551554    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 ) );
    557560        if ( empty( $host ) ) {
    558561            return false;
    559562        }
    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 ) ) {
    562593            // Couldn't resolve — block it.
    563594            return false;
    564595        }
    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            }
    581610        }
    582611
  • directrelay-automation-bridge-for-n8n/tags/5.1.32/includes/Brute_Force.php

    r3699228 r3706276  
    2424    public static function check_ip( string $ip ): bool|\WP_Error {
    2525        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. */
    2730        $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.InterpolatedNotPrepared
     31            $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
    2932            ARRAY_A
    3033        );
    31         if ( ! $row ) {
     34        if ( ! $row || empty( $row['blocked_until'] ) ) {
    3235            return true;
    3336        }
    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();
    3641            return new \WP_Error(
    3742                'amp_ip_blocked',
    38                 sprintf( 
     43                sprintf(
    3944                    /* 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
    4247                ),
    4348                [ 'status' => 429 ]
    … …  
    194199     * @param string $ip Plain IPv4 or IPv6 address (CIDR is rejected to
    195200     *                   keep the table key exact-match; range blocking
    196      *                   is a add-on feature(s)).
     201     *                   is an add-on feature).
    197202     * @return true|\WP_Error True on success, WP_Error with reason on failure.
    198203     */
  • directrelay-automation-bridge-for-n8n/tags/5.1.32/includes/Build.php

    r3699228 r3706276  
    66 *
    77 * 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 separate
     8 * does not carry an add-on variant, a third-party distribution channel,
     9 * or an activation-state flag. Those concerns live in the separate
    1010 * `directrelay-pro` add-on distributed from
    1111 * https://directrelay.wikiofautomation.com/directrelay-pro.
  • directrelay-automation-bridge-for-n8n/tags/5.1.32/includes/Compatibility.php

    r3699228 r3706276  
    1919        }
    2020
    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         */
    6429    }
    6530
    … …  
    187152            $wpdb->prefix . 'directrelay_log',
    188153            $wpdb->prefix . 'directrelay_webhooks',
    189             $wpdb->prefix . 'directrelay_rate_limits',
     154            $wpdb->prefix . 'directrelay_rate',
    190155            $wpdb->prefix . 'directrelay_brute_force',
    191156        ];
  • directrelay-automation-bridge-for-n8n/tags/5.1.32/includes/Content_Processor.php

    r3699228 r3706276  
    8181        }
    8282
    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 );
    8585    }
    8686
    … …  
    101101
    102102        // 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 );
    104104
    105105        // 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 );
    113112
    114113        // 5. Tables: | Col 1 | Col 2 |
    … …  
    143142
    144143        // 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 );
    151169
    152170        // 7. Unordered Lists (* or -) & Ordered Lists (1.)
    153171        $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] );
    155173            return "<ul>\n" . $items . "</ul>\n";
    156174        }, $text );
    157175
    158176        $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] );
    160178            return "<ol>\n" . $items . "</ol>\n";
    161179        }, $text );
  • directrelay-automation-bridge-for-n8n/tags/5.1.32/includes/Cron_Health.php

    r3699228 r3706276  
    152152                                'ondemand' => true,
    153153                        ),
    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' ),
    156156                                'ondemand' => true,
    157157                        ),
  • directrelay-automation-bridge-for-n8n/tags/5.1.32/includes/Feature_Gate.php

    r3699228 r3706276  
    1818 *   - can_create_post() always returns true.
    1919 *
    20  * NOTE: do NOT reintroduce the legacy limit(s) or licence-state
     20 * NOTE: do NOT reintroduce the legacy limit or activation-state
    2121 * methods here. The WordPress.org Plugin Directory Guidelines forbid
    22  * usage limits, licence-keyed unlock, and add-on build flags in any
     22 * usage limits, keyed unlock, and add-on build flags in any
    2323 * plugin hosted on WP.org. The free plugin uses direct free options
    2424 * (`directrelay_settings`) for any tunable webhook configuration.
    … …  
    2727
    2828        /**
    29          * Whether the current site has add-on feature(s).
     29         * Whether the current site has add-on features.
    3030         *
    3131         * Returns true so legacy "if ( ! is_pro() )" checks degrade to
    3232         * a no-op pass-through. The free plugin does not gate features
    33          * by licence state.
     33         * by activation state.
    3434         */
    3535        public static function is_pro(): bool {
    … …  
    7676         * Whether a new post or page can be created.
    7777         *
    78          * The free plugin has no per-key monthly post limit(s). Post
     78         * The free plugin has no per-key monthly post limit. Post
    7979         * creation is always allowed.
    8080         */
  • directrelay-automation-bridge-for-n8n/tags/5.1.32/includes/IndexNow_Pinger.php

    r3699228 r3706276  
    77 * DirectRelay  IndexNow Pinger
    88 *
    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.
    1113 *
    1214 * @since 4.3.0
    … …  
    1618    private const ENDPOINTS = [
    1719        'https://api.indexnow.org/indexnow',
    18         'https://www.bing.com/indexnow',
    1920    ];
    2021
    … …  
    4546
    4647    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;
    5150        }
     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;
    5260    }
    5361
    … …  
    9098                'headers'   => [ 'Content-Type' => 'application/json' ],
    9199                'body'      => $json_body,
    92                 'sslverify' => false,
     100                'sslverify' => true,
    93101            ] );
    94102
    95103            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                ] );
    97116            }
    98117        }
  • directrelay-automation-bridge-for-n8n/tags/5.1.32/includes/Installer.php

    r3699228 r3706276  
    77
    88    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. */
    1317        }
    14     }
    15 
    16     public static function activate_blog(): void {
    1718        self::create_tables();
    1819        update_option( 'directrelay_db_version', DIRECTRELAY_DB_VERSION );
    … …  
    3536
    3637    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();
    4239    }
    4340
    … …  
    4845            'directrelay_prune_rate_limits',
    4946            'directrelay_fire_webhook',
    50             'directrelay_retry_webhook',
     47            'directrelay_deliver_webhook',
    5148            'directrelay_cron_health_ping',
    5249            'directrelay_prune_brute_force',
    … …  
    5754    }
    5855
    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     */
    10770    public static function create_tables(): void {
    10871        global $wpdb;
    … …  
    11477            id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    11578            user_id BIGINT UNSIGNED NOT NULL,
    116             key_hash VARCHAR(255) NOT NULL,
     79            key_hash VARCHAR(191) NOT NULL,
    11780            key_prefix CHAR(12) NOT NULL DEFAULT '',
    11881            label VARCHAR(191) NOT NULL DEFAULT '',
    … …  
    12588            restrict_own_posts TINYINT(1) NOT NULL DEFAULT 0,
    12689            require_approval TINYINT(1) NOT NULL DEFAULT 0,
    127             scopes TEXT NOT NULL DEFAULT '[]',
     90            scopes TEXT NOT NULL,
    12891            ip_whitelist TEXT DEFAULT NULL,
    12992            last_used_at DATETIME DEFAULT NULL,
    … …  
    204167            target_url TEXT NOT NULL,
    205168            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,
    208171            active TINYINT(1) NOT NULL DEFAULT 1,
    209172            last_fired_at DATETIME DEFAULT NULL,
    … …  
    262225    }
    263226
    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 query
    267         $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     }
    274227}
  • directrelay-automation-bridge-for-n8n/tags/5.1.32/includes/Plugin.php

    r3699228 r3706276  
    6161        }
    6262
    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         }
    7563    }
    7664
    … …  
    9179            'REST\Media_Controller',
    9280            'REST\Taxonomy_Controller',
    93             'REST\Fleet_Controller',
    9481            'REST\Webhook_Controller',
    9582            'REST\Openapi_Controller',
  • directrelay-automation-bridge-for-n8n/tags/5.1.32/includes/Post_Manager.php

    r3699228 r3706276  
    116116                return;
    117117            }
    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 );
    123120            if ( ! is_wp_error( $att_id ) && (int) $att_id > 0 ) {
    124121                set_post_thumbnail( $id, (int) $att_id );
    … …  
    144141    }
    145142
     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
    146306    private static function apply_taxonomy( int $id, array $data ): void {
    147307        if ( ! empty( $data['category_names'] ) ) {
    … …  
    162322
    163323    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' ];
    165325        $has_acf = function_exists( 'update_field' );
    166326        foreach ( $meta as $k => $v ) {
    167327            $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
    170339            if ( $has_acf && str_starts_with( $key, 'field_' ) ) {
    171340                update_field( $key, $v, $id );
  • directrelay-automation-bridge-for-n8n/tags/5.1.32/includes/REST/Base_Controller.php

    r3699228 r3706276  
    104104    }
    105105
    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. */
    107107    protected static function require_pro( string $feature ): bool|\WP_Error {
    108108        return true;
  • directrelay-automation-bridge-for-n8n/tags/5.1.32/includes/REST/Media_Controller.php

    r3699228 r3706276  
    8080        }
    8181
    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 
    9582        $settings = get_option( 'directrelay_settings', [] );
    9683        $max_mb   = (int) ( $settings['max_sideload_size'] ?? 20 );
    9784
    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' );
    11291        }
    11392
    11493        /* 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' );
    12497    }
    12598
  • directrelay-automation-bridge-for-n8n/tags/5.1.32/includes/REST/Meta_Controller.php

    r3699228 r3706276  
    8080            return self::fail( $req, new \WP_Error( 'amp_not_found', 'Content not found.', [ 'status' => 404 ] ), 'update_meta' );
    8181        }
     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        }
    8286        $updated = 0; $skipped = 0; $errors = [];
    8387        foreach ( $body as $key => $value ) {
    … …  
    113117            return self::fail( $req, new \WP_Error( 'amp_forbidden', __( 'Cannot delete protected meta key.', 'directrelay-automation-bridge-for-n8n'), [ 'status' => 403 ] ), 'delete_meta' );
    114118        }
     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' );
    115121        $r = delete_post_meta( $id, $key );
    116122        return self::ok( $req, [ 'deleted' => $r, 'key' => $key, 'post_id' => $id ], 200, 'delete_meta', $id, $expected );
    … …  
    125131                return true; /* Use /seo endpoint for these */
    126132            }
     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;
    127137        }
    128138        return false;
  • directrelay-automation-bridge-for-n8n/tags/5.1.32/includes/REST/Openapi_Controller.php

    r3699228 r3706276  
    5252                                        'pages'     => rest_url( self::NS ) . '/pages',
    5353                                        'media'     => rest_url( self::NS ) . '/media',
    54                                         'fleet'     => rest_url( self::NS ) . '/fleet/health',
    5554                                        'webhooks'  => rest_url( self::NS ) . '/webhooks',
    5655                                        'seo'       => rest_url( self::NS ) . '/seo',
    … …  
    345344                                'get' => $priv( 'GET /webhooks/{id}/deliveries - Recent delivery log' ),
    346345                        ),
    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                         ),
    353346                        '/discovery/fields' => array(
    354347                                'get' => $priv( 'GET /discovery/fields - Custom-field discovery' ),
  • directrelay-automation-bridge-for-n8n/tags/5.1.32/includes/REST/Pages_Controller.php

    r3699228 r3706276  
    3737        $body = $req->get_json_params() ?: [];
    3838        $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.org
     39        // The free plugin has no monthly dispatch / request limit.
     40        // The previous add-on-limit guard was removed because the WP.org
    4141        // Plugin Directory Guidelines forbid usage limits in the free
    4242        // 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        }
    4451        $id   = Post_Manager::create( $body, 'page' );
    4552        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 );
    4869        }
    49         $page = Post_Manager::format( get_post( $id ) );
    50         $page['seo'] = Rankmath_Handler::read( $id );
    5170        return self::ok( $req, [ 'page' => $page ], 201, 'create_page', $id, 'page' );
    5271    }
    … …  
    6685        $post = get_post( $id );
    6786        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' );
    6897        $r = Post_Manager::update( $id, $body );
    6998        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 );
    73114        }
    74         $updated = Post_Manager::format( get_post( $id ) );
    75         $updated['seo'] = Rankmath_Handler::read( $id );
    76115        return self::ok( $req, [ 'page' => $updated ], 200, 'update_page', $id, 'page' );
    77116    }
    … …  
    83122            return self::fail( $req, new \WP_Error( 'amp_not_found', 'Page not found.', [ 'status' => 404 ] ), 'delete_page' );
    84123        }
     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' );
    85126        $r = wp_delete_post( $id, (bool) $req->get_param( 'force' ) );
    86127        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  
    4545        $body = $req->get_json_params() ?: [];
    4646        $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.org
     47        // The free plugin has no monthly dispatch / request limit.
     48        // The previous add-on-limit guard was removed because the WP.org
    4949        // Plugin Directory Guidelines forbid usage limits in the free
    5050        // 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        }
    5259        $id   = Post_Manager::create( $body, 'post' );
    5360        if ( is_wp_error( $id ) ) {
    5461            return self::fail( $req, $id, 'create_post' );
    5562        }
    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 );
    5880        }
    59         $post = Post_Manager::format( get_post( $id ) );
    60         $post['seo'] = Rankmath_Handler::read( $id );
    6181        return self::ok( $req, [ 'post' => $post ], 201, 'create_post', $id, 'post' );
    6282    }
    … …  
    7494        $protected = [ '_edit_lock', '_edit_last', '_wp_old_slug', '_wp_attachment_metadata', '_wp_attached_file', '_thumbnail_id' ];
    7595        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' ) ) {
    7797                continue;
    7898            }
    … …  
    100120            return self::fail( $req, new \WP_Error( 'amp_not_found', __( 'Post not found.', 'directrelay-automation-bridge-for-n8n' ), [ 'status' => 404 ] ), 'update_post' );
    101121        }
     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        }
    102134        $r = Post_Manager::update( $id, $body );
    103135        if ( is_wp_error( $r ) ) {
    104136            return self::fail( $req, $r, 'update_post' );
    105137        }
    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 );
    109153        }
    110         $updated = Post_Manager::format( get_post( $id ) );
    111         $updated['seo'] = Rankmath_Handler::read( $id );
    112154        return self::ok( $req, [ 'post' => $updated ], 200, 'update_post', $id, 'post' );
    113155    }
    … …  
    117159        if ( ! get_post( $id ) ) {
    118160            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' );
    119165        }
    120166        $r = wp_delete_post( $id, (bool) $req->get_param( 'force' ) );
    … …  
    128174            return self::fail( $req, new \WP_Error( 'amp_not_found', __( 'Post not found.', 'directrelay-automation-bridge-for-n8n' ), [ 'status' => 404 ] ), 'publish_post' );
    129175        }
     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        }
    130180        $r  = wp_update_post( [ 'ID' => $id, 'post_status' => 'publish' ], true );
    131181        if ( is_wp_error( $r ) ) {
  • directrelay-automation-bridge-for-n8n/tags/5.1.32/includes/REST/Webhook_Controller.php

    r3699228 r3706276  
    4646            'settings' => [
    4747                '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 ),
    4949                'log_retention_days'  => (int) ( get_option( 'directrelay_settings', [] )['log_retention_days'] ?? 90 ),
    5050            ],
  • directrelay-automation-bridge-for-n8n/tags/5.1.32/includes/Rankmath_Handler.php

    r3699228 r3706276  
    9393        return $data;
    9494    }
     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    }
    95139}
  • directrelay-automation-bridge-for-n8n/tags/5.1.32/includes/Rate_Limiter.php

    r3699228 r3706276  
    88    /**
    99     * 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.
    1111     */
    1212    private const SITE_BUCKET = 'site';
    … …  
    2222    private static function tier_limits(): array {
    2323        $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 ) );
    2526    }
    2627
    … …  
    3031
    3132        [ $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. */
    3334        $bucket     = self::SITE_BUCKET;
    3435        $table      = "{$wpdb->prefix}directrelay_rate";
    … …  
    103104    /**
    104105     * 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.
    107108     */
    108109    public static function get_remaining( int $key_id ): int {
    … …  
    121122        }
    122123
    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 ) {
    125126            return $limit;
    126127        }
  • directrelay-automation-bridge-for-n8n/tags/5.1.32/includes/Transactional_Publisher.php

    r3699228 r3706276  
    121121            }
    122122
    123             // Stage 4: SEO Metadata Application
     123            // Stage 4: SEO Metadata (read-only in the free build)
    124124            $execution_steps['seo'] = 'running';
    125125            $seo_result = null;
    126126            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)
    136141            $approval_data = null;
    137142            if ( $require_approval && class_exists( '\DirectRelay\Approval_Gateway' ) ) {
    … …  
    149154                        \DirectRelay\Guardrails_Manager::increment_daily_usage( (int) ( $key['id'] ?? 0 ) );
    150155                    }
    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                    }
    152164                }
    153165            }
    … …  
    183195                    wp_delete_post( $created_post_id, true );
    184196                    $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                    }
    185223                }
    186224            }
    … …  
    202240
    203241    /**
    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.
    205246     */
    206247    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 );
    217249        if ( is_wp_error( $att_id ) ) {
    218250            return $att_id;
    … …  
    221253        if ( ! empty( $title ) ) {
    222254            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             ] );
    227255        }
    228256
  • directrelay-automation-bridge-for-n8n/tags/5.1.32/includes/Webhook_Manager.php

    r3699228 r3706276  
    3030            'post_types'  => [ 'post', 'page' ],
    3131            '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'       => 'PRO PRESET',
    33             'is_pro'      => true,
     32            'badge'       => 'PRESET',
     33            'is_pro'      => false,
    3434            '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}]]}}}',
    3535        ],
    … …  
    4141            'post_types'  => [ 'post' ],
    4242            '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'       => 'PRO PRESET',
    44             'is_pro'      => true,
     43            'badge'       => 'PRESET',
     44            'is_pro'      => false,
    4545            '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}]]}}}',
    4646        ],
    … …  
    5252            'post_types'  => [ 'post', 'page' ],
    5353            'description' => 'n8n instantly submits the updated URL and Rank Math canonical link to Google Indexing API and IndexNow (Bing / Yandex).',
    54             'badge'       => 'PRO PRESET',
    55             'is_pro'      => true,
     54            'badge'       => 'PRESET',
     55            'is_pro'      => false,
    5656            '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}]]}}}',
    5757        ],
    … …  
    6363            'post_types'  => [ 'post', 'page' ],
    6464            'description' => 'Keeps external search indexes like Algolia, Meilisearch, or Typesense synchronized without running heavy server-side sync cron jobs.',
    65             'badge'       => 'PRO PRESET',
    66             'is_pro'      => true,
     65            'badge'       => 'PRESET',
     66            'is_pro'      => false,
    6767            '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}]]}}}',
    6868        ],
    … …  
    7474            'post_types'  => [ 'post', 'page' ],
    7575            'description' => 'Sends a message to a team Slack channel or Discord server with post details, author, and quick review links.',
    76             'badge'       => 'PRO PRESET',
    77             'is_pro'      => true,
     76            'badge'       => 'PRESET',
     77            'is_pro'      => false,
    7878            '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}]]}}}',
    7979        ],
    … …  
    8585            'post_types'  => [ 'post', 'page' ],
    8686            'description' => 'Calls Cloudflare / BunnyCDN API to purge cache for that specific URL immediately.',
    87             'badge'       => 'PRO PRESET',
    88             'is_pro'      => true,
     87            'badge'       => 'PRESET',
     88            'is_pro'      => false,
    8989            '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}]]}}}',
    9090        ],
    … …  
    9696            'post_types'  => [ 'post', 'attachment' ],
    9797            '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'       => 'PRO PRESET',
    99             'is_pro'      => true,
     98            'badge'       => 'PRESET',
     99            'is_pro'      => false,
    100100            '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":{}}',
    101101        ],
    … …  
    108108
    109109    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
    110114        $s = get_option( 'directrelay_settings', [] );
    111115        if ( empty( $s['enable_webhooks'] ) ) {
    … …  
    121125        /* Individual delivery & retry worker */
    122126        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' ] );
    126127    }
    127128
    … …  
    137138        global $wpdb;
    138139        $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.
    140143        $row = $wpdb->get_row(
    141144            $wpdb->prepare(
     145                // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $table is prefix + static name, not user input.
    142146                "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
    145150            ),
    146151            ARRAY_A
    147152        );
    148         // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
    149153        $total = $row ? (int) $row['hits'] : 0;
    150154        $limit = PHP_INT_MAX;
    … …  
    153157            'limit'     => $limit,
    154158            'remaining' => max( 0, $limit - $total ),
    155             'is_pro'    => true,
     159            'is_pro'    => false,
    156160        ];
    157161    }
    … …  
    160164     * Check and record a webhook dispatch.
    161165     *
    162      * The free plugin does not enforce a monthly dispatch limit(s) —
     166     * The free plugin does not enforce a monthly dispatch limit —
    163167     * the WP.org Plugin Directory Guidelines forbid usage limits in
    164168     * the free build. The directrelay-pro add-on may hook into the
    165169     * `directrelay_ext_guardrails_check` filter to re-introduce a
    166      * limit(s). This method is kept as a stable entry point so any
     170     * limit. This method is kept as a stable entry point so any
    167171     * custom code that already calls it does not break.
    168172     *
    … …  
    178182    public static function prune_logs(): void {
    179183        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.
    183189        $wpdb->query(
    184190            $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
    187194            )
    188195        );
    189         // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
    190196    }
    191197
    … …  
    199205            return;
    200206        }
     207        if ( in_array( $post->post_status, [ 'auto-draft', 'new' ], true ) ) {
     208            return; /* Gutenberg's empty editor shell is not a content event. */
     209        }
    201210        $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        }
    203223        if ( $post->post_status === 'publish' ) {
    204224            self::schedule_fire( "{$type}.published", $id, $type );
    … …  
    234254
    235255    public static function fire( string $event, int $object_id, string $object_type ): void {
    236         // The free plugin has no monthly dispatch limit(s). The previous
     256        // The free plugin has no monthly dispatch limit. The previous
    237257        // add-on monthly cap was removed because the WP.org Plugin
    238258        // Directory Guidelines forbid usage limits in the free build.
    239         // The directrelay-pro add-on may re-introduce a limit(s) via the
     259        // The directrelay-pro add-on may re-introduce a limit via the
    240260        // `directrelay_ext_guardrails_check` filter.
    241261        global $wpdb;
    … …  
    250270
    251271        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 ) ?: [];
    254274            if ( ! in_array( $event, $events, true ) ) {
    255275                continue;
    256276            }
    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 ) ) {
    258280                continue;
    259281            }
    … …  
    343365        if ( ! empty( $plain_secret ) ) {
    344366            $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 );
    345378        }
    346379
    … …  
    451484    }
    452485
     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
    453505    public static function encrypt_secret( string $secret ): string {
    454506        if ( empty( $secret ) ) {
    455507            return '';
    456508        }
    457         $key = defined( 'SECURE_AUTH_KEY' ) ? SECURE_AUTH_KEY : 'directrelay_fallback_key';
     509        $key = self::secret_key();
    458510        $iv  = openssl_random_pseudo_bytes( 16 );
    459511        $enc = openssl_encrypt( $secret, 'aes-256-cbc', $key, 0, $iv );
    … …  
    465517            return '';
    466518        }
    467         $raw = base64_decode( $enc_secret );
     519        $raw = base64_decode( $enc_secret ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- storage encoding, not obfuscation.
    468520        if ( ! $raw || strlen( $raw ) <= 16 ) {
    469521            return '';
    470522        }
    471         $key = defined( 'SECURE_AUTH_KEY' ) ? SECURE_AUTH_KEY : 'directrelay_fallback_key';
     523        $key = self::secret_key();
    472524        $iv  = substr( $raw, 0, 16 );
    473525        $enc = substr( $raw, 16 );
  • directrelay-automation-bridge-for-n8n/tags/5.1.32/includes/admin/Ajax_Handler.php

    r3699228 r3706276  
    141141        // per-site cap was removed because the WP.org Plugin Directory
    142142        // Guidelines forbid usage limits in the free build. The
    143         // directrelay-pro add-on may re-introduce a limit(s) via the
     143        // directrelay-pro add-on may re-introduce a limit via the
    144144        // `directrelay_ext_guardrails_check` filter.
    145145
    … …  
    231231        // per-site cap was removed because the WP.org Plugin Directory
    232232        // Guidelines forbid usage limits in the free build. The
    233         // directrelay-pro add-on may re-introduce a limit(s) via the
     233        // directrelay-pro add-on may re-introduce a limit via the
    234234        // `directrelay_ext_guardrails_check` filter.
    235235
    … …  
    451451
    452452        $free_usage = null;
    453         // The free plugin has no monthly request limit(s), so the
     453        // The free plugin has no monthly request limit, so the
    454454        // "free usage" indicator is always null. Kept as a stable
    455455        // 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  
    8484            'l10n'           => [
    8585                // License strings removed in 5.1.0; the directrelay-pro
    86                 // add-on plugin provides its own licence UI.
     86                // add-on plugin provides its own activation UI.
    8787            ],
    8888        ];
  • directrelay-automation-bridge-for-n8n/tags/5.1.32/includes/admin/views/docs.php

    r3699228 r3706276  
    580580const received  = $input.first().headers['x-directrelay-signature'];
    581581if (expected !== received) throw new Error('Signature mismatch');
     582
     583// Replay protection (recommended): reject deliveries older than 5 minutes.
     584const ts      = Number($input.first().headers['x-directrelay-timestamp']);
     585const tsSig   = $input.first().headers['x-directrelay-signature-timestamp'];
     586const tsExpected = 'sha256=' + crypto.createHmac('sha256', secret).update(ts + '.' + body).digest('hex');
     587if (Math.abs(Date.now() / 1000 - ts) > 300 || tsExpected !== tsSig) throw new Error('Stale or invalid timestamp');
    582588return $input.all();</pre>
    583589        </div>
  • directrelay-automation-bridge-for-n8n/tags/5.1.32/includes/admin/views/keys.php

    r3699228 r3706276  
    188188                                                <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>
    189189                                                <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>
    191191                                        </div>
    192192
    … …  
    438438                        <tbody>
    439439                        <?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();
    441441                                $directrelay_is_expired = $directrelay_k['expires_at'] && strtotime( $directrelay_k['expires_at'] ) <= time();
    442442                                $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  
    2828 *  - preview   array   List of node labels rendered as preview chips.
    2929 *  - 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.
    3131 *
    3232 * @since 5.1.0
  • directrelay-automation-bridge-for-n8n/tags/5.1.32/includes/admin/views/settings.php

    r3699228 r3706276  
    186186                    <div class="directrelay-set-label">
    187187                        <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>
    189189                    </div>
    190190                    <div class="directrelay-set-control">
  • directrelay-automation-bridge-for-n8n/tags/5.1.32/includes/admin/views/webhooks.php

    r3699228 r3706276  
    33 * DirectRelay Webhooks admin view (Free).
    44 *
    5  * The free plugin has no monthly dispatch limit(s), no per-site webhook
    6  * cap, and no licence-state gate. The add-on plugin plugin provides the
     5 * 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
    77 * preset library, event-pipeline cards, and add-on filters via the
    88 * `directrelay_ext_templates` filter and the
    … …  
    9090                    data-label="<?php echo esc_attr( $directrelay_p['label'] ); ?>"
    9191                    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 ); ?>">
    9394                    <?php esc_html_e( 'Load Preset', 'directrelay-automation-bridge-for-n8n'); ?>
    9495                </button>
    … …  
    294295            <tbody>
    295296            <?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();
    297298            ?>
    298299            <tr>
    … …  
    317318                        <?php echo $directrelay_wh['active'] ? esc_html__( 'Pause', 'directrelay-automation-bridge-for-n8n' ) : esc_html__( 'Activate', 'directrelay-automation-bridge-for-n8n' ); ?>
    318319                    </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>
    319326                    <button class="directrelay-btn directrelay-delete-wh" data-id="<?php echo (int) $directrelay_wh['id']; ?>" style="color:var(--directrelay-red)">
    320327                        <?php esc_html_e( 'Delete', 'directrelay-automation-bridge-for-n8n'); ?>
    … …  
    329336</div>
    330337
    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;">&times;</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;">&times;</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  
    1111- Response time in milliseconds
    1212- Client IP address
    13 - User agent string
    1413
    15142. API Key Metadata
    … …  
    4847Analytics aggregates are retained for 30 days.
    4948Webhook delivery logs are retained for 30 days.
    50 Expired or revoked API key records are retained for 90 days after revocation for audit purposes.
     49Revoked 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.
    5150
    5251=== Data Sharing & External Connections ===
    … …  
    6665
    6766All 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.
     67Webhook secrets are stored using AES-256 encryption at rest, keyed with your site's WordPress secret keys, and decrypted only at HMAC signing time.
    6968IP addresses are stored for brute-force protection and are purgeable.
    7069Connections 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) ===
    22Contributors: n4nion
    3 Tags: api, rest, n8n, automation, seo
     3Tags: n8n, automation, rest api, webhooks, seo
    44Requires at least: 6.3
    55Tested up to: 7.1
    66Requires PHP: 8.0
    7 Stable tag: 5.1.31
     7Stable tag: 5.1.32
    88License: GPL-2.0+
    99License URI: http://www.gnu.org/licenses/gpl-2.0.txt
    1010
    11 Free REST API + signed webhook bridge for WordPress and n8n. Rank Math + Yoast reads, IndexNow pings. 100% GPL.
     11Connect WordPress to n8n. The free n8n workflow automation plugin: REST API, signed webhooks, AI agents, SEO reads, IndexNow. 100% GPL.
    1212
    1313== Description ==
    1414
    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.
     15Project home page: https://directrelay.wikiofautomation.com
     16
     17= The Automation Bridge Between WordPress and n8n =
     18
     19You built your content workflows in n8n. Now you need WordPress to listen.
     20
     21DirectRelay 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
     23Whether 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
     36Every 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
     48The 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
     58Handing 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
     67The 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
     74Opt-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
     871. Install DirectRelay and open the DirectRelay dashboard.
     882. Generate an API key — pick a scope preset or build your own.
     893. 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
     98DirectRelay is free software licensed under the GPL. Use it, study it, modify it, redistribute it.
     99
     100== DirectRelay Pro (separate add-on plugin) ==
     101
     102SEO 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.
    150103
    151104== External services ==
    … …  
    157110= n8n Webhooks (outgoing) =
    158111* **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.
    160113* **Service Terms & Privacy:**
    161114  * The webhook receiver is the user's choice; DirectRelay itself does not transmit data to any third party by default.
    … …  
    164117
    165118= 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.
    168121* **Service Terms & Privacy:**
    169122  * IndexNow Terms: https://www.indexnow.org/
    … …  
    189142== Installation ==
    190143
    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. Configure 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.
     1441. Upload the plugin files to `/wp-content/plugins/`, or install through the WordPress Plugins screen.
     1452. Activate the plugin.
     1463. Open **DirectRelay** from the admin menu and run the built-in compatibility check.
     1474. Create your first API key (**DirectRelay → API Keys**) and connect it from n8n using either the `X-API-Key` header or a `Bearer` token.
     1485. Optional: register an outgoing webhook under **DirectRelay → Webhooks** to trigger n8n workflows from WordPress events.
    196149
    197150== Frequently Asked Questions ==
    … …  
    199152= Does this require n8n? =
    200153
    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.
     154No. 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.
    202155
    203156= Is this safe to run on a production site? =
    204157
    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.
     158Yes. 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.
    206159
    207160= Does it work with Rank Math AND Yoast SEO? =
    208161
    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.
     162Yes — 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.
    210163
    211164= Does it work with WooCommerce and Custom Post Types? =
    212165
    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.
     166Yes. 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.
    214167
    215168= Can I use this on a multisite network? =
    216169
    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.
     170The 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.
    218171
    219172= What happens to my keys if I uninstall the plugin? =
    220173
    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.
     174Uninstalling 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.
    222175
    223176= How do I rotate an API key without downtime? =
    224177
    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.
     178Open 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.
    226179
    227180= Does it support the Block Editor (Gutenberg)? =
    228181
    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.
     182Yes. 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.
    230183
    231184= Is there a limit on the number of API keys or webhooks? =
    232185
    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).
     186No. The free plugin has no artificial usage limits — no key caps, no webhook caps, no monthly request quotas.
    234187
    235188= Where can I get support? =
    236189
    237 Email help@directrelay.wikiofautomation.com. Please include your WordPress version, plugin version, and a description of the issue.
     190Email help@directrelay.wikiofautomation.com with your WordPress version, plugin version, and a description of the issue.
    238191
    239192== Screenshots ==
    240193
    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.
     1941. Dashboard with at-a-glance request stats and the compatibility scorecard.
     1952. API Keys screen — scoped keys with scope chips, agent personas, expiry, and IP allowlists.
     1963. Webhooks screen — events, targets, HMAC secrets, test-fire, and per-attempt delivery log.
     1974. Activity Log with HTTP-status filters, request durations, and key attribution.
     1985. Compatibility Checker scoring permalinks, REST API, SSL, and SEO plugin detection.
     1996. n8n Templates — copy-paste workflow JSON for your first automation.
    247200
    248201== Changelog ==
    249202
     203All 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 =
     206Release 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
    250225= 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.
    256227
    257228= 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.
    261230
    262231= 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 code changes.
     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.
    264233
    265234= 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.
    268236
    269237= 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.
    274239
    275240= 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.
    278242
    279243= 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.
    283245
    284246= 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.
    289248
    290249= 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.
    294251
    295252= 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.
    298254
    299255= 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.
    302257
    303258= 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.
    308260
    309261= 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.
    312263
    313264= 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  
    66 * It performs a clean uninstall of all plugin data including:
    77 *   - All custom database tables
    8  *   - All plugin options
     8 *   - All plugin options (including the generated IndexNow key)
    99 *   - Scheduled cron events
     10 *   - Diagnostic trace/error log files under the uploads directory
    1011 *
    1112 * @since 4.2.0
    … …  
    6566        'directrelay_prune_rate_limits',
    6667        'directrelay_fire_webhook',
    67         'directrelay_retry_webhook',
     68        'directrelay_deliver_webhook',
    6869        'directrelay_cron_health_ping',
    6970        'directrelay_prune_brute_force',
    … …  
    8384    delete_option( 'directrelay_apppass_dismissed' );
    8485    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    }
    85102}
  • directrelay-automation-bridge-for-n8n/trunk/assets/js/admin.js

    r3699228 r3706276  
    148148           
    149149            var payload = {
    150                 action: 'directrelay_create_key', 
     150                action: 'directrelay_create_key',
    151151                nonce: directrelayAdmin.nonce,
    152                 label: $('[name=label]').val(), 
     152                label: $('[name=label]').val(),
    153153                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,
    155159                expires_at: $('[name=expires_at]').val(),
    156160                ip_whitelist: $('[name=ip_whitelist]').val(),
    … …  
    348352
    349353        /* —— 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); });
    354363        });
    355364
    356365        /* —— Delete webhook ———————————————————————————————————————————— */
    357         $(document).on('click', '.directrelay-del-wh', function (e) {
     366        $(document).on('click', '.directrelay-delete-wh', function (e) {
    358367            e.preventDefault();
    359368            var id = $(this).data('id');
    … …  
    382391
    383392        /* —— 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(); });
    387396
    388397        /* —— Run System Diagnostic ————————————————————————————————————— */
    … …  
    711720        $(document).on('click', '.directrelay-view-log', function() {
    712721            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();
    713725            $('#directrelay-delivery-log').html('<p class="directrelay-empty">Loading...</p>');
    714726            $.ajax({
  • directrelay-automation-bridge-for-n8n/trunk/directrelay.php

    r3700957 r3706276  
    11<?php
    22/**
    3  * Plugin Name: DirectRelay Automation Bridge for n8n
     3 * Plugin Name: DirectRelay – n8n Workflow Automation Plugin (REST API & Webhooks)
    44 * Plugin URI: https://directrelay.wikiofautomation.com
    55 * 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.31
     6 * Version: 5.1.32
    77 * Author: n4nion
    88 * Author URI: https://profiles.wordpress.org/n4nion
    … …  
    4141                        if ( ! empty( $up['basedir'] ) ) {
    4242                                $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                                }
    4353                                $candidates[] = $dir . '/trace.log';
    4454                                $candidates[] = $dir . '/error.log';
    … …  
    7282                        $paths = directrelay_log_paths();
    7383                        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                                }
    7590                        }
    7691                }
    … …  
    104119                        $paths = directrelay_log_paths();
    105120                        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                                }
    107127                        }
    108128                }
    … …  
    169189}
    170190
     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 */
     201if ( 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
    171249if ( defined( 'DIRECTRELAY_LOADED' ) || class_exists( '\DirectRelay\Plugin', false ) ) {
    172250        directrelay_trace( 'already_loaded: bail' );
    … …  
    176254directrelay_trace( 'past_load_guard: defining constants' );
    177255
    178 define( 'DIRECTRELAY_VERSION', '5.1.31' );
     256define( 'DIRECTRELAY_VERSION', '5.1.32' );
    179257define( 'DIRECTRELAY_FILE', __FILE__ );
    180258define( 'DIRECTRELAY_DIR', plugin_dir_path( __FILE__ ) );
    181259define( 'DIRECTRELAY_URL', plugin_dir_url( __FILE__ ) );
    182260define( 'DIRECTRELAY_NS', 'directrelay/v1' );
    183 define( 'DIRECTRELAY_DB_VERSION', 11 );
     261define( 'DIRECTRELAY_DB_VERSION', 12 ); // 12: TEXT columns drop DEFAULT literals (illegal on stock MySQL); key_hash narrowed to 191.
    184262define( 'DIRECTRELAY_BUILD', 'all-in-one' );
    185263
    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' );
    193266
    194267/**
    … …  
    213286                'includes/Compatibility.php',
    214287                'includes/Cron_Health.php',
    215                 'includes/Telemetry_Collector.php',
    216288                'includes/Feature_Gate.php',
    217289                /* REST layer (free surface) */
    … …  
    223295                'includes/REST/Media_Controller.php',
    224296                'includes/REST/Taxonomy_Controller.php',
    225                 'includes/REST/Fleet_Controller.php',
    226297                'includes/REST/Webhook_Controller.php',
    227298                'includes/REST/Keys_Controller.php',
    … …  
    230301                'includes/REST/Openapi_Controller.php',
    231302                /*
    232                  * portal, full debugger, Chart.js analytics) live in the
    233                  * directrelay-pro plugin distributed from
    234                  * https://directrelay.wikiofautomation.com. They re-attach to the
    235                  * 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.
    236307                 */
    237308                /* Admin layer */
    … …  
    301372                        wp_clear_scheduled_hook( 'directrelay_prune_rate_limits' );
    302373                        wp_clear_scheduled_hook( 'directrelay_fire_webhook' );
    303                         wp_clear_scheduled_hook( 'directrelay_retry_webhook' );
     374                        wp_clear_scheduled_hook( 'directrelay_deliver_webhook' );
    304375                        wp_clear_scheduled_hook( 'directrelay_cron_health_ping' );
    305376                        wp_clear_scheduled_hook( 'directrelay_prune_brute_force' );
    306                         wp_clear_scheduled_hook( 'directrelay_telemetry_heartbeat' );
    307377                        flush_rewrite_rules();
    308378                } catch ( \Throwable $e ) {
  • directrelay-automation-bridge-for-n8n/trunk/includes/Activity_Logger.php

    r3699228 r3706276  
    115115    public static function hourly_stats( int $hours = 24, ?int $key_id = null ): array {
    116116        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 );
    117120        if ( $key_id ) {
    118121            $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,
    121124                $key_id
    122125            );
    123126        } else {
    124127            $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                 $hours
     128                "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
    127130            );
    128131        }
    … …  
    133136    public static function top_endpoints( int $days = 7 ): array {
    134137        global $wpdb;
     138        /* PHP-side date math for SQLite portability. */
     139        $cutoff = gmdate( 'Y-m-d H:00:00', time() - $days * DAY_IN_SECONDS );
    135140        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- custom table.
    136141        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             $days
     142            "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
    139144        ), ARRAY_A ) ?: array();
    140145    }
    … …  
    143148        global $wpdb;
    144149        $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 );
    145152
    146153        /* Prune standard logs */
    147154        // 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 ) );
    149156
    150157        /* Prune analytics (uses bucket_hour instead of created_at) */
    151158        // 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        }
    153169    }
    154170
  • directrelay-automation-bridge-for-n8n/trunk/includes/Agent_Profiles.php

    r3699228 r3706276  
    9797        $role = $key['agent_role'] ?? self::ROLE_FULL_ACCESS;
    9898
    99         // Agent personas (non-FULL_ACCESS) are a add-on feature(s). The free
     99        // Agent personas (non-FULL_ACCESS) are an add-on feature. The free
    100100        // plugin has only the FULL_ACCESS role, so any non-default role
    101101        // means the add-on plugin is extending the agent profile. The free
    … …  
    125125        // 3. Post Ownership Isolation check
    126126        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 );
    135128        }
    136129
     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        }
    137156        return true;
    138157    }
  • directrelay-automation-bridge-for-n8n/trunk/includes/Api_Auth.php

    r3699228 r3706276  
    2121        'approval:read', 'approval:write',
    2222        'debugger:read', 'debugger:write',
    23         'fleet:read', 'fleet:write',
    2423        'universal:write',
    2524    ];
    … …  
    5958        // because the WP.org Plugin Directory Guidelines forbid usage
    6059        // 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`
    6261        // filter.
    6362        $raw    = self::generate_raw();
    … …  
    194193        $prefix       = substr( $raw, 0, 12 );
    195194        $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 );
    196198        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom table auth lookup.
    197199        $rows = $wpdb->get_results(
    … …  
    200202                 WHERE (key_prefix = %s OR key_prefix = %s)
    201203                 AND revoked = 0
    202                  AND (expires_at IS NULL OR expires_at > UTC_TIMESTAMP())",
     204                 AND (expires_at IS NULL OR expires_at > %s)",
    203205                $prefix,
    204                 $prefix_short
     206                $prefix_short,
     207                $now_utc
    205208            ),
    206209            ARRAY_A
    … …  
    279282        $row['last_used_ip']   = $ip;
    280283        $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 ) ?: [];
    282285        $row['is_full']        = empty( $row['scopes_array'] );
    283286        return $row;
    … …  
    550553
    551554    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 ) );
    557560        if ( empty( $host ) ) {
    558561            return false;
    559562        }
    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 ) ) {
    562593            // Couldn't resolve — block it.
    563594            return false;
    564595        }
    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            }
    581610        }
    582611
  • directrelay-automation-bridge-for-n8n/trunk/includes/Brute_Force.php

    r3699228 r3706276  
    2424    public static function check_ip( string $ip ): bool|\WP_Error {
    2525        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. */
    2730        $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.InterpolatedNotPrepared
     31            $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
    2932            ARRAY_A
    3033        );
    31         if ( ! $row ) {
     34        if ( ! $row || empty( $row['blocked_until'] ) ) {
    3235            return true;
    3336        }
    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();
    3641            return new \WP_Error(
    3742                'amp_ip_blocked',
    38                 sprintf( 
     43                sprintf(
    3944                    /* 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
    4247                ),
    4348                [ 'status' => 429 ]
    … …  
    194199     * @param string $ip Plain IPv4 or IPv6 address (CIDR is rejected to
    195200     *                   keep the table key exact-match; range blocking
    196      *                   is a add-on feature(s)).
     201     *                   is an add-on feature).
    197202     * @return true|\WP_Error True on success, WP_Error with reason on failure.
    198203     */
  • directrelay-automation-bridge-for-n8n/trunk/includes/Build.php

    r3699228 r3706276  
    66 *
    77 * 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 separate
     8 * does not carry an add-on variant, a third-party distribution channel,
     9 * or an activation-state flag. Those concerns live in the separate
    1010 * `directrelay-pro` add-on distributed from
    1111 * https://directrelay.wikiofautomation.com/directrelay-pro.
  • directrelay-automation-bridge-for-n8n/trunk/includes/Compatibility.php

    r3699228 r3706276  
    1919        }
    2020
    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         */
    6429    }
    6530
    … …  
    187152            $wpdb->prefix . 'directrelay_log',
    188153            $wpdb->prefix . 'directrelay_webhooks',
    189             $wpdb->prefix . 'directrelay_rate_limits',
     154            $wpdb->prefix . 'directrelay_rate',
    190155            $wpdb->prefix . 'directrelay_brute_force',
    191156        ];
  • directrelay-automation-bridge-for-n8n/trunk/includes/Content_Processor.php

    r3699228 r3706276  
    8181        }
    8282
    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 );
    8585    }
    8686
    … …  
    101101
    102102        // 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 );
    104104
    105105        // 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 );
    113112
    114113        // 5. Tables: | Col 1 | Col 2 |
    … …  
    143142
    144143        // 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 );
    151169
    152170        // 7. Unordered Lists (* or -) & Ordered Lists (1.)
    153171        $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] );
    155173            return "<ul>\n" . $items . "</ul>\n";
    156174        }, $text );
    157175
    158176        $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] );
    160178            return "<ol>\n" . $items . "</ol>\n";
    161179        }, $text );
  • directrelay-automation-bridge-for-n8n/trunk/includes/Cron_Health.php

    r3699228 r3706276  
    152152                                'ondemand' => true,
    153153                        ),
    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' ),
    156156                                'ondemand' => true,
    157157                        ),
  • directrelay-automation-bridge-for-n8n/trunk/includes/Feature_Gate.php

    r3699228 r3706276  
    1818 *   - can_create_post() always returns true.
    1919 *
    20  * NOTE: do NOT reintroduce the legacy limit(s) or licence-state
     20 * NOTE: do NOT reintroduce the legacy limit or activation-state
    2121 * methods here. The WordPress.org Plugin Directory Guidelines forbid
    22  * usage limits, licence-keyed unlock, and add-on build flags in any
     22 * usage limits, keyed unlock, and add-on build flags in any
    2323 * plugin hosted on WP.org. The free plugin uses direct free options
    2424 * (`directrelay_settings`) for any tunable webhook configuration.
    … …  
    2727
    2828        /**
    29          * Whether the current site has add-on feature(s).
     29         * Whether the current site has add-on features.
    3030         *
    3131         * Returns true so legacy "if ( ! is_pro() )" checks degrade to
    3232         * a no-op pass-through. The free plugin does not gate features
    33          * by licence state.
     33         * by activation state.
    3434         */
    3535        public static function is_pro(): bool {
    … …  
    7676         * Whether a new post or page can be created.
    7777         *
    78          * The free plugin has no per-key monthly post limit(s). Post
     78         * The free plugin has no per-key monthly post limit. Post
    7979         * creation is always allowed.
    8080         */
  • directrelay-automation-bridge-for-n8n/trunk/includes/IndexNow_Pinger.php

    r3699228 r3706276  
    77 * DirectRelay  IndexNow Pinger
    88 *
    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.
    1113 *
    1214 * @since 4.3.0
    … …  
    1618    private const ENDPOINTS = [
    1719        'https://api.indexnow.org/indexnow',
    18         'https://www.bing.com/indexnow',
    1920    ];
    2021
    … …  
    4546
    4647    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;
    5150        }
     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;
    5260    }
    5361
    … …  
    9098                'headers'   => [ 'Content-Type' => 'application/json' ],
    9199                'body'      => $json_body,
    92                 'sslverify' => false,
     100                'sslverify' => true,
    93101            ] );
    94102
    95103            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                ] );
    97116            }
    98117        }
  • directrelay-automation-bridge-for-n8n/trunk/includes/Installer.php

    r3699228 r3706276  
    77
    88    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. */
    1317        }
    14     }
    15 
    16     public static function activate_blog(): void {
    1718        self::create_tables();
    1819        update_option( 'directrelay_db_version', DIRECTRELAY_DB_VERSION );
    … …  
    3536
    3637    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();
    4239    }
    4340
    … …  
    4845            'directrelay_prune_rate_limits',
    4946            'directrelay_fire_webhook',
    50             'directrelay_retry_webhook',
     47            'directrelay_deliver_webhook',
    5148            'directrelay_cron_health_ping',
    5249            'directrelay_prune_brute_force',
    … …  
    5754    }
    5855
    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     */
    10770    public static function create_tables(): void {
    10871        global $wpdb;
    … …  
    11477            id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    11578            user_id BIGINT UNSIGNED NOT NULL,
    116             key_hash VARCHAR(255) NOT NULL,
     79            key_hash VARCHAR(191) NOT NULL,
    11780            key_prefix CHAR(12) NOT NULL DEFAULT '',
    11881            label VARCHAR(191) NOT NULL DEFAULT '',
    … …  
    12588            restrict_own_posts TINYINT(1) NOT NULL DEFAULT 0,
    12689            require_approval TINYINT(1) NOT NULL DEFAULT 0,
    127             scopes TEXT NOT NULL DEFAULT '[]',
     90            scopes TEXT NOT NULL,
    12891            ip_whitelist TEXT DEFAULT NULL,
    12992            last_used_at DATETIME DEFAULT NULL,
    … …  
    204167            target_url TEXT NOT NULL,
    205168            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,
    208171            active TINYINT(1) NOT NULL DEFAULT 1,
    209172            last_fired_at DATETIME DEFAULT NULL,
    … …  
    262225    }
    263226
    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 query
    267         $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     }
    274227}
  • directrelay-automation-bridge-for-n8n/trunk/includes/Plugin.php

    r3699228 r3706276  
    6161        }
    6262
    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         }
    7563    }
    7664
    … …  
    9179            'REST\Media_Controller',
    9280            'REST\Taxonomy_Controller',
    93             'REST\Fleet_Controller',
    9481            'REST\Webhook_Controller',
    9582            'REST\Openapi_Controller',
  • directrelay-automation-bridge-for-n8n/trunk/includes/Post_Manager.php

    r3699228 r3706276  
    116116                return;
    117117            }
    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 );
    123120            if ( ! is_wp_error( $att_id ) && (int) $att_id > 0 ) {
    124121                set_post_thumbnail( $id, (int) $att_id );
    … …  
    144141    }
    145142
     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
    146306    private static function apply_taxonomy( int $id, array $data ): void {
    147307        if ( ! empty( $data['category_names'] ) ) {
    … …  
    162322
    163323    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' ];
    165325        $has_acf = function_exists( 'update_field' );
    166326        foreach ( $meta as $k => $v ) {
    167327            $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
    170339            if ( $has_acf && str_starts_with( $key, 'field_' ) ) {
    171340                update_field( $key, $v, $id );
  • directrelay-automation-bridge-for-n8n/trunk/includes/REST/Base_Controller.php

    r3699228 r3706276  
    104104    }
    105105
    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. */
    107107    protected static function require_pro( string $feature ): bool|\WP_Error {
    108108        return true;
  • directrelay-automation-bridge-for-n8n/trunk/includes/REST/Media_Controller.php

    r3699228 r3706276  
    8080        }
    8181
    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 
    9582        $settings = get_option( 'directrelay_settings', [] );
    9683        $max_mb   = (int) ( $settings['max_sideload_size'] ?? 20 );
    9784
    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' );
    11291        }
    11392
    11493        /* 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' );
    12497    }
    12598
  • directrelay-automation-bridge-for-n8n/trunk/includes/REST/Meta_Controller.php

    r3699228 r3706276  
    8080            return self::fail( $req, new \WP_Error( 'amp_not_found', 'Content not found.', [ 'status' => 404 ] ), 'update_meta' );
    8181        }
     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        }
    8286        $updated = 0; $skipped = 0; $errors = [];
    8387        foreach ( $body as $key => $value ) {
    … …  
    113117            return self::fail( $req, new \WP_Error( 'amp_forbidden', __( 'Cannot delete protected meta key.', 'directrelay-automation-bridge-for-n8n'), [ 'status' => 403 ] ), 'delete_meta' );
    114118        }
     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' );
    115121        $r = delete_post_meta( $id, $key );
    116122        return self::ok( $req, [ 'deleted' => $r, 'key' => $key, 'post_id' => $id ], 200, 'delete_meta', $id, $expected );
    … …  
    125131                return true; /* Use /seo endpoint for these */
    126132            }
     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;
    127137        }
    128138        return false;
  • directrelay-automation-bridge-for-n8n/trunk/includes/REST/Openapi_Controller.php

    r3699228 r3706276  
    5252                                        'pages'     => rest_url( self::NS ) . '/pages',
    5353                                        'media'     => rest_url( self::NS ) . '/media',
    54                                         'fleet'     => rest_url( self::NS ) . '/fleet/health',
    5554                                        'webhooks'  => rest_url( self::NS ) . '/webhooks',
    5655                                        'seo'       => rest_url( self::NS ) . '/seo',
    … …  
    345344                                'get' => $priv( 'GET /webhooks/{id}/deliveries - Recent delivery log' ),
    346345                        ),
    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                         ),
    353346                        '/discovery/fields' => array(
    354347                                'get' => $priv( 'GET /discovery/fields - Custom-field discovery' ),
  • directrelay-automation-bridge-for-n8n/trunk/includes/REST/Pages_Controller.php

    r3699228 r3706276  
    3737        $body = $req->get_json_params() ?: [];
    3838        $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.org
     39        // The free plugin has no monthly dispatch / request limit.
     40        // The previous add-on-limit guard was removed because the WP.org
    4141        // Plugin Directory Guidelines forbid usage limits in the free
    4242        // 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        }
    4451        $id   = Post_Manager::create( $body, 'page' );
    4552        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 );
    4869        }
    49         $page = Post_Manager::format( get_post( $id ) );
    50         $page['seo'] = Rankmath_Handler::read( $id );
    5170        return self::ok( $req, [ 'page' => $page ], 201, 'create_page', $id, 'page' );
    5271    }
    … …  
    6685        $post = get_post( $id );
    6786        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' );
    6897        $r = Post_Manager::update( $id, $body );
    6998        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 );
    73114        }
    74         $updated = Post_Manager::format( get_post( $id ) );
    75         $updated['seo'] = Rankmath_Handler::read( $id );
    76115        return self::ok( $req, [ 'page' => $updated ], 200, 'update_page', $id, 'page' );
    77116    }
    … …  
    83122            return self::fail( $req, new \WP_Error( 'amp_not_found', 'Page not found.', [ 'status' => 404 ] ), 'delete_page' );
    84123        }
     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' );
    85126        $r = wp_delete_post( $id, (bool) $req->get_param( 'force' ) );
    86127        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  
    4545        $body = $req->get_json_params() ?: [];
    4646        $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.org
     47        // The free plugin has no monthly dispatch / request limit.
     48        // The previous add-on-limit guard was removed because the WP.org
    4949        // Plugin Directory Guidelines forbid usage limits in the free
    5050        // 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        }
    5259        $id   = Post_Manager::create( $body, 'post' );
    5360        if ( is_wp_error( $id ) ) {
    5461            return self::fail( $req, $id, 'create_post' );
    5562        }
    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 );
    5880        }
    59         $post = Post_Manager::format( get_post( $id ) );
    60         $post['seo'] = Rankmath_Handler::read( $id );
    6181        return self::ok( $req, [ 'post' => $post ], 201, 'create_post', $id, 'post' );
    6282    }
    … …  
    7494        $protected = [ '_edit_lock', '_edit_last', '_wp_old_slug', '_wp_attachment_metadata', '_wp_attached_file', '_thumbnail_id' ];
    7595        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' ) ) {
    7797                continue;
    7898            }
    … …  
    100120            return self::fail( $req, new \WP_Error( 'amp_not_found', __( 'Post not found.', 'directrelay-automation-bridge-for-n8n' ), [ 'status' => 404 ] ), 'update_post' );
    101121        }
     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        }
    102134        $r = Post_Manager::update( $id, $body );
    103135        if ( is_wp_error( $r ) ) {
    104136            return self::fail( $req, $r, 'update_post' );
    105137        }
    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 );
    109153        }
    110         $updated = Post_Manager::format( get_post( $id ) );
    111         $updated['seo'] = Rankmath_Handler::read( $id );
    112154        return self::ok( $req, [ 'post' => $updated ], 200, 'update_post', $id, 'post' );
    113155    }
    … …  
    117159        if ( ! get_post( $id ) ) {
    118160            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' );
    119165        }
    120166        $r = wp_delete_post( $id, (bool) $req->get_param( 'force' ) );
    … …  
    128174            return self::fail( $req, new \WP_Error( 'amp_not_found', __( 'Post not found.', 'directrelay-automation-bridge-for-n8n' ), [ 'status' => 404 ] ), 'publish_post' );
    129175        }
     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        }
    130180        $r  = wp_update_post( [ 'ID' => $id, 'post_status' => 'publish' ], true );
    131181        if ( is_wp_error( $r ) ) {
  • directrelay-automation-bridge-for-n8n/trunk/includes/REST/Webhook_Controller.php

    r3699228 r3706276  
    4646            'settings' => [
    4747                '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 ),
    4949                'log_retention_days'  => (int) ( get_option( 'directrelay_settings', [] )['log_retention_days'] ?? 90 ),
    5050            ],
  • directrelay-automation-bridge-for-n8n/trunk/includes/Rankmath_Handler.php

    r3699228 r3706276  
    9393        return $data;
    9494    }
     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    }
    95139}
  • directrelay-automation-bridge-for-n8n/trunk/includes/Rate_Limiter.php

    r3699228 r3706276  
    88    /**
    99     * 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.
    1111     */
    1212    private const SITE_BUCKET = 'site';
    … …  
    2222    private static function tier_limits(): array {
    2323        $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 ) );
    2526    }
    2627
    … …  
    3031
    3132        [ $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. */
    3334        $bucket     = self::SITE_BUCKET;
    3435        $table      = "{$wpdb->prefix}directrelay_rate";
    … …  
    103104    /**
    104105     * 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.
    107108     */
    108109    public static function get_remaining( int $key_id ): int {
    … …  
    121122        }
    122123
    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 ) {
    125126            return $limit;
    126127        }
  • directrelay-automation-bridge-for-n8n/trunk/includes/Transactional_Publisher.php

    r3699228 r3706276  
    121121            }
    122122
    123             // Stage 4: SEO Metadata Application
     123            // Stage 4: SEO Metadata (read-only in the free build)
    124124            $execution_steps['seo'] = 'running';
    125125            $seo_result = null;
    126126            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)
    136141            $approval_data = null;
    137142            if ( $require_approval && class_exists( '\DirectRelay\Approval_Gateway' ) ) {
    … …  
    149154                        \DirectRelay\Guardrails_Manager::increment_daily_usage( (int) ( $key['id'] ?? 0 ) );
    150155                    }
    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                    }
    152164                }
    153165            }
    … …  
    183195                    wp_delete_post( $created_post_id, true );
    184196                    $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                    }
    185223                }
    186224            }
    … …  
    202240
    203241    /**
    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.
    205246     */
    206247    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 );
    217249        if ( is_wp_error( $att_id ) ) {
    218250            return $att_id;
    … …  
    221253        if ( ! empty( $title ) ) {
    222254            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             ] );
    227255        }
    228256
  • directrelay-automation-bridge-for-n8n/trunk/includes/Webhook_Manager.php

    r3699228 r3706276  
    3030            'post_types'  => [ 'post', 'page' ],
    3131            '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'       => 'PRO PRESET',
    33             'is_pro'      => true,
     32            'badge'       => 'PRESET',
     33            'is_pro'      => false,
    3434            '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}]]}}}',
    3535        ],
    … …  
    4141            'post_types'  => [ 'post' ],
    4242            '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'       => 'PRO PRESET',
    44             'is_pro'      => true,
     43            'badge'       => 'PRESET',
     44            'is_pro'      => false,
    4545            '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}]]}}}',
    4646        ],
    … …  
    5252            'post_types'  => [ 'post', 'page' ],
    5353            'description' => 'n8n instantly submits the updated URL and Rank Math canonical link to Google Indexing API and IndexNow (Bing / Yandex).',
    54             'badge'       => 'PRO PRESET',
    55             'is_pro'      => true,
     54            'badge'       => 'PRESET',
     55            'is_pro'      => false,
    5656            '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}]]}}}',
    5757        ],
    … …  
    6363            'post_types'  => [ 'post', 'page' ],
    6464            'description' => 'Keeps external search indexes like Algolia, Meilisearch, or Typesense synchronized without running heavy server-side sync cron jobs.',
    65             'badge'       => 'PRO PRESET',
    66             'is_pro'      => true,
     65            'badge'       => 'PRESET',
     66            'is_pro'      => false,
    6767            '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}]]}}}',
    6868        ],
    … …  
    7474            'post_types'  => [ 'post', 'page' ],
    7575            'description' => 'Sends a message to a team Slack channel or Discord server with post details, author, and quick review links.',
    76             'badge'       => 'PRO PRESET',
    77             'is_pro'      => true,
     76            'badge'       => 'PRESET',
     77            'is_pro'      => false,
    7878            '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}]]}}}',
    7979        ],
    … …  
    8585            'post_types'  => [ 'post', 'page' ],
    8686            'description' => 'Calls Cloudflare / BunnyCDN API to purge cache for that specific URL immediately.',
    87             'badge'       => 'PRO PRESET',
    88             'is_pro'      => true,
     87            'badge'       => 'PRESET',
     88            'is_pro'      => false,
    8989            '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}]]}}}',
    9090        ],
    … …  
    9696            'post_types'  => [ 'post', 'attachment' ],
    9797            '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'       => 'PRO PRESET',
    99             'is_pro'      => true,
     98            'badge'       => 'PRESET',
     99            'is_pro'      => false,
    100100            '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":{}}',
    101101        ],
    … …  
    108108
    109109    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
    110114        $s = get_option( 'directrelay_settings', [] );
    111115        if ( empty( $s['enable_webhooks'] ) ) {
    … …  
    121125        /* Individual delivery & retry worker */
    122126        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' ] );
    126127    }
    127128
    … …  
    137138        global $wpdb;
    138139        $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.
    140143        $row = $wpdb->get_row(
    141144            $wpdb->prepare(
     145                // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $table is prefix + static name, not user input.
    142146                "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
    145150            ),
    146151            ARRAY_A
    147152        );
    148         // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
    149153        $total = $row ? (int) $row['hits'] : 0;
    150154        $limit = PHP_INT_MAX;
    … …  
    153157            'limit'     => $limit,
    154158            'remaining' => max( 0, $limit - $total ),
    155             'is_pro'    => true,
     159            'is_pro'    => false,
    156160        ];
    157161    }
    … …  
    160164     * Check and record a webhook dispatch.
    161165     *
    162      * The free plugin does not enforce a monthly dispatch limit(s) —
     166     * The free plugin does not enforce a monthly dispatch limit —
    163167     * the WP.org Plugin Directory Guidelines forbid usage limits in
    164168     * the free build. The directrelay-pro add-on may hook into the
    165169     * `directrelay_ext_guardrails_check` filter to re-introduce a
    166      * limit(s). This method is kept as a stable entry point so any
     170     * limit. This method is kept as a stable entry point so any
    167171     * custom code that already calls it does not break.
    168172     *
    … …  
    178182    public static function prune_logs(): void {
    179183        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.
    183189        $wpdb->query(
    184190            $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
    187194            )
    188195        );
    189         // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
    190196    }
    191197
    … …  
    199205            return;
    200206        }
     207        if ( in_array( $post->post_status, [ 'auto-draft', 'new' ], true ) ) {
     208            return; /* Gutenberg's empty editor shell is not a content event. */
     209        }
    201210        $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        }
    203223        if ( $post->post_status === 'publish' ) {
    204224            self::schedule_fire( "{$type}.published", $id, $type );
    … …  
    234254
    235255    public static function fire( string $event, int $object_id, string $object_type ): void {
    236         // The free plugin has no monthly dispatch limit(s). The previous
     256        // The free plugin has no monthly dispatch limit. The previous
    237257        // add-on monthly cap was removed because the WP.org Plugin
    238258        // Directory Guidelines forbid usage limits in the free build.
    239         // The directrelay-pro add-on may re-introduce a limit(s) via the
     259        // The directrelay-pro add-on may re-introduce a limit via the
    240260        // `directrelay_ext_guardrails_check` filter.
    241261        global $wpdb;
    … …  
    250270
    251271        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 ) ?: [];
    254274            if ( ! in_array( $event, $events, true ) ) {
    255275                continue;
    256276            }
    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 ) ) {
    258280                continue;
    259281            }
    … …  
    343365        if ( ! empty( $plain_secret ) ) {
    344366            $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 );
    345378        }
    346379
    … …  
    451484    }
    452485
     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
    453505    public static function encrypt_secret( string $secret ): string {
    454506        if ( empty( $secret ) ) {
    455507            return '';
    456508        }
    457         $key = defined( 'SECURE_AUTH_KEY' ) ? SECURE_AUTH_KEY : 'directrelay_fallback_key';
     509        $key = self::secret_key();
    458510        $iv  = openssl_random_pseudo_bytes( 16 );
    459511        $enc = openssl_encrypt( $secret, 'aes-256-cbc', $key, 0, $iv );
    … …  
    465517            return '';
    466518        }
    467         $raw = base64_decode( $enc_secret );
     519        $raw = base64_decode( $enc_secret ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- storage encoding, not obfuscation.
    468520        if ( ! $raw || strlen( $raw ) <= 16 ) {
    469521            return '';
    470522        }
    471         $key = defined( 'SECURE_AUTH_KEY' ) ? SECURE_AUTH_KEY : 'directrelay_fallback_key';
     523        $key = self::secret_key();
    472524        $iv  = substr( $raw, 0, 16 );
    473525        $enc = substr( $raw, 16 );
  • directrelay-automation-bridge-for-n8n/trunk/includes/admin/Ajax_Handler.php

    r3699228 r3706276  
    141141        // per-site cap was removed because the WP.org Plugin Directory
    142142        // Guidelines forbid usage limits in the free build. The
    143         // directrelay-pro add-on may re-introduce a limit(s) via the
     143        // directrelay-pro add-on may re-introduce a limit via the
    144144        // `directrelay_ext_guardrails_check` filter.
    145145
    … …  
    231231        // per-site cap was removed because the WP.org Plugin Directory
    232232        // Guidelines forbid usage limits in the free build. The
    233         // directrelay-pro add-on may re-introduce a limit(s) via the
     233        // directrelay-pro add-on may re-introduce a limit via the
    234234        // `directrelay_ext_guardrails_check` filter.
    235235
    … …  
    451451
    452452        $free_usage = null;
    453         // The free plugin has no monthly request limit(s), so the
     453        // The free plugin has no monthly request limit, so the
    454454        // "free usage" indicator is always null. Kept as a stable
    455455        // key in the AJAX response for any client code that reads it.
  • directrelay-automation-bridge-for-n8n/trunk/includes/admin/Menu.php

    r3699228 r3706276  
    8484            'l10n'           => [
    8585                // License strings removed in 5.1.0; the directrelay-pro
    86                 // add-on plugin provides its own licence UI.
     86                // add-on plugin provides its own activation UI.
    8787            ],
    8888        ];
  • directrelay-automation-bridge-for-n8n/trunk/includes/admin/views/docs.php

    r3699228 r3706276  
    580580const received  = $input.first().headers['x-directrelay-signature'];
    581581if (expected !== received) throw new Error('Signature mismatch');
     582
     583// Replay protection (recommended): reject deliveries older than 5 minutes.
     584const ts      = Number($input.first().headers['x-directrelay-timestamp']);
     585const tsSig   = $input.first().headers['x-directrelay-signature-timestamp'];
     586const tsExpected = 'sha256=' + crypto.createHmac('sha256', secret).update(ts + '.' + body).digest('hex');
     587if (Math.abs(Date.now() / 1000 - ts) > 300 || tsExpected !== tsSig) throw new Error('Stale or invalid timestamp');
    582588return $input.all();</pre>
    583589        </div>
  • directrelay-automation-bridge-for-n8n/trunk/includes/admin/views/keys.php

    r3699228 r3706276  
    188188                                                <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>
    189189                                                <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>
    191191                                        </div>
    192192
    … …  
    438438                        <tbody>
    439439                        <?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();
    441441                                $directrelay_is_expired = $directrelay_k['expires_at'] && strtotime( $directrelay_k['expires_at'] ) <= time();
    442442                                $directrelay_is_active  = ! $directrelay_k['revoked'] && ! $directrelay_is_expired;
  • directrelay-automation-bridge-for-n8n/trunk/includes/admin/views/n8n-templates.php

    r3699228 r3706276  
    2828 *  - preview   array   List of node labels rendered as preview chips.
    2929 *  - 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.
    3131 *
    3232 * @since 5.1.0
  • directrelay-automation-bridge-for-n8n/trunk/includes/admin/views/settings.php

    r3699228 r3706276  
    186186                    <div class="directrelay-set-label">
    187187                        <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>
    189189                    </div>
    190190                    <div class="directrelay-set-control">
  • directrelay-automation-bridge-for-n8n/trunk/includes/admin/views/webhooks.php

    r3699228 r3706276  
    33 * DirectRelay Webhooks admin view (Free).
    44 *
    5  * The free plugin has no monthly dispatch limit(s), no per-site webhook
    6  * cap, and no licence-state gate. The add-on plugin plugin provides the
     5 * 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
    77 * preset library, event-pipeline cards, and add-on filters via the
    88 * `directrelay_ext_templates` filter and the
    … …  
    9090                    data-label="<?php echo esc_attr( $directrelay_p['label'] ); ?>"
    9191                    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 ); ?>">
    9394                    <?php esc_html_e( 'Load Preset', 'directrelay-automation-bridge-for-n8n'); ?>
    9495                </button>
    … …  
    294295            <tbody>
    295296            <?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();
    297298            ?>
    298299            <tr>
    … …  
    317318                        <?php echo $directrelay_wh['active'] ? esc_html__( 'Pause', 'directrelay-automation-bridge-for-n8n' ) : esc_html__( 'Activate', 'directrelay-automation-bridge-for-n8n' ); ?>
    318319                    </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>
    319326                    <button class="directrelay-btn directrelay-delete-wh" data-id="<?php echo (int) $directrelay_wh['id']; ?>" style="color:var(--directrelay-red)">
    320327                        <?php esc_html_e( 'Delete', 'directrelay-automation-bridge-for-n8n'); ?>
    … …  
    329336</div>
    330337
    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;">&times;</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;">&times;</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  
    1111- Response time in milliseconds
    1212- Client IP address
    13 - User agent string
    1413
    15142. API Key Metadata
    … …  
    4847Analytics aggregates are retained for 30 days.
    4948Webhook delivery logs are retained for 30 days.
    50 Expired or revoked API key records are retained for 90 days after revocation for audit purposes.
     49Revoked 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.
    5150
    5251=== Data Sharing & External Connections ===
    … …  
    6665
    6766All 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.
     67Webhook secrets are stored using AES-256 encryption at rest, keyed with your site's WordPress secret keys, and decrypted only at HMAC signing time.
    6968IP addresses are stored for brute-force protection and are purgeable.
    7069Connections 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) ===
    22Contributors: n4nion
    3 Tags: api, rest, n8n, automation, seo
     3Tags: n8n, automation, rest api, webhooks, seo
    44Requires at least: 6.3
    55Tested up to: 7.1
    66Requires PHP: 8.0
    7 Stable tag: 5.1.31
     7Stable tag: 5.1.32
    88License: GPL-2.0+
    99License URI: http://www.gnu.org/licenses/gpl-2.0.txt
    1010
    11 Free REST API + signed webhook bridge for WordPress and n8n. Rank Math + Yoast reads, IndexNow pings. 100% GPL.
     11Connect WordPress to n8n. The free n8n workflow automation plugin: REST API, signed webhooks, AI agents, SEO reads, IndexNow. 100% GPL.
    1212
    1313== Description ==
    1414
    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.
     15Project home page: https://directrelay.wikiofautomation.com
     16
     17= The Automation Bridge Between WordPress and n8n =
     18
     19You built your content workflows in n8n. Now you need WordPress to listen.
     20
     21DirectRelay 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
     23Whether 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
     36Every 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
     48The 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
     58Handing 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
     67The 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
     74Opt-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
     871. Install DirectRelay and open the DirectRelay dashboard.
     882. Generate an API key — pick a scope preset or build your own.
     893. 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
     98DirectRelay is free software licensed under the GPL. Use it, study it, modify it, redistribute it.
     99
     100== DirectRelay Pro (separate add-on plugin) ==
     101
     102SEO 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.
    150103
    151104== External services ==
    … …  
    157110= n8n Webhooks (outgoing) =
    158111* **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.
    160113* **Service Terms & Privacy:**
    161114  * The webhook receiver is the user's choice; DirectRelay itself does not transmit data to any third party by default.
    … …  
    164117
    165118= 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.
    168121* **Service Terms & Privacy:**
    169122  * IndexNow Terms: https://www.indexnow.org/
    … …  
    189142== Installation ==
    190143
    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. Configure 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.
     1441. Upload the plugin files to `/wp-content/plugins/`, or install through the WordPress Plugins screen.
     1452. Activate the plugin.
     1463. Open **DirectRelay** from the admin menu and run the built-in compatibility check.
     1474. Create your first API key (**DirectRelay → API Keys**) and connect it from n8n using either the `X-API-Key` header or a `Bearer` token.
     1485. Optional: register an outgoing webhook under **DirectRelay → Webhooks** to trigger n8n workflows from WordPress events.
    196149
    197150== Frequently Asked Questions ==
    … …  
    199152= Does this require n8n? =
    200153
    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.
     154No. 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.
    202155
    203156= Is this safe to run on a production site? =
    204157
    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.
     158Yes. 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.
    206159
    207160= Does it work with Rank Math AND Yoast SEO? =
    208161
    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.
     162Yes — 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.
    210163
    211164= Does it work with WooCommerce and Custom Post Types? =
    212165
    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.
     166Yes. 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.
    214167
    215168= Can I use this on a multisite network? =
    216169
    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.
     170The 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.
    218171
    219172= What happens to my keys if I uninstall the plugin? =
    220173
    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.
     174Uninstalling 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.
    222175
    223176= How do I rotate an API key without downtime? =
    224177
    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.
     178Open 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.
    226179
    227180= Does it support the Block Editor (Gutenberg)? =
    228181
    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.
     182Yes. 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.
    230183
    231184= Is there a limit on the number of API keys or webhooks? =
    232185
    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).
     186No. The free plugin has no artificial usage limits — no key caps, no webhook caps, no monthly request quotas.
    234187
    235188= Where can I get support? =
    236189
    237 Email help@directrelay.wikiofautomation.com. Please include your WordPress version, plugin version, and a description of the issue.
     190Email help@directrelay.wikiofautomation.com with your WordPress version, plugin version, and a description of the issue.
    238191
    239192== Screenshots ==
    240193
    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.
     1941. Dashboard with at-a-glance request stats and the compatibility scorecard.
     1952. API Keys screen — scoped keys with scope chips, agent personas, expiry, and IP allowlists.
     1963. Webhooks screen — events, targets, HMAC secrets, test-fire, and per-attempt delivery log.
     1974. Activity Log with HTTP-status filters, request durations, and key attribution.
     1985. Compatibility Checker scoring permalinks, REST API, SSL, and SEO plugin detection.
     1996. n8n Templates — copy-paste workflow JSON for your first automation.
    247200
    248201== Changelog ==
    249202
     203All 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 =
     206Release 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
    250225= 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.
    256227
    257228= 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.
    261230
    262231= 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 code changes.
     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.
    264233
    265234= 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.
    268236
    269237= 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.
    274239
    275240= 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.
    278242
    279243= 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.
    283245
    284246= 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.
    289248
    290249= 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.
    294251
    295252= 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.
    298254
    299255= 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.
    302257
    303258= 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.
    308260
    309261= 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.
    312263
    313264= 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  
    66 * It performs a clean uninstall of all plugin data including:
    77 *   - All custom database tables
    8  *   - All plugin options
     8 *   - All plugin options (including the generated IndexNow key)
    99 *   - Scheduled cron events
     10 *   - Diagnostic trace/error log files under the uploads directory
    1011 *
    1112 * @since 4.2.0
    … …  
    6566        'directrelay_prune_rate_limits',
    6667        'directrelay_fire_webhook',
    67         'directrelay_retry_webhook',
     68        'directrelay_deliver_webhook',
    6869        'directrelay_cron_health_ping',
    6970        'directrelay_prune_brute_force',
    … …  
    8384    delete_option( 'directrelay_apppass_dismissed' );
    8485    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    }
    85102}
Note: See TracChangeset for help on using the changeset viewer.