بعض الدروس المستفادة من wp-discourse و cloudflare

بعض الدروس الخاصة بالإعداد الشخصي التي تعلمتها خلال الأشهر القليلة الماضية مع Discourse و wp-discourse و Cloudflare. أشاركها في حال كانت مفيدة لأحد.

البيئة:

  • Discourse و WordPress مستضافتان على مثيلتين منفصلتين من AWS EC2 ضمن نفس VPC
  • مكدس WordPress يعتمد nginx مع التخزين المؤقت عبر fastCGI + php8.3-fpm + redis + mariadb
  • استخدام إضافة wp-discourse لربط WP و Discourse معًا وتضمين تعليقات Discourse تحت منشورات المدونة
  • Cloudflare يعمل كوسيط لكل من Discourse و WP
  • يستخدم WP إضافة APO

1) التلاعب قليلاً بـ VPC

عند النشر الأولي، واجهت بسرعة مشاكل مع Cloudflare الذي حاول الحد من معدل الطلبات أو حظر حركة مرور API بين خادم الويب وصندوق Discourse. فكّرت في محاولة إيجاد قواعد استثناء، ولكن بما أن الخادمين موجودان في نفس VPC من AWS، كان من الأسهل بكثير ببساطة إنشاء مدخلات في ملف host على كل منهما لتوجيه اسم النطاق الخاص بكل خادم إلى عنوان VPC الخاص به بدلاً من عنوان DNS العام. لم يعد هناك حاجة للقلق من تدخل Cloudflare بين WP و Discourse. تغيير بسيط، يقلل من الكثير من الصداع.

2) التعامل مع سباق بين wp-discourse و Apple News

كنت أعاني من مشكلة متقطعة غريبة منذ فترة طويلة بعد إعداد Discourse مع WordPress، حيث كان يظهر أحيانًا — ليس كثيرًا، لكن أحيانًا — أن منشور WordPress الجديد يظهر للزوار مع كتلة التعليقات الأصلية الخاصة بـ WordPress في الأسفل بدلاً من تعليقات Discourse. في الواقع، كان المنشور نفسه سليمًا مع تعليقات Discourse الصحيحة، لكن ذاكرة التخزين المؤقت على حافة Cloudflare كانت تلتقط المنشور في اللحظة أو الثوانيين قبل وصول تعليقات Discourse وتحتفظ بالصفحة القديمة.

لم أستطع، مهما حاولت، تحديد كيف أو لماذا وراء هذا السلوك. بدا عشوائيًا دون أي محفز واضح. تفحصت السجلات لكن لم أجد أي شيء واضح.

لذلك، نسبت الأمر إلى التفاعل بين جميع طبقات التخزين المؤقت المختلفة، واستخدمت ChatGPT لكتابة إضافة mu-plugin صغيرة لتجاوز المشكلة عن طريق إجبار WP على الصراخ “لا تخزن هذا” في المنشورات الجديدة قبل أن يتم ربط Discourse، ثم إجبار عملية إضافية واحدة لمسح ذاكرة التخزين المؤقت على جانب CF بعد ارتباط سلسلة التعليقات، للتأكد تمامًا من عدم بقاء أي شيء قديم في ذاكرة التخزين المؤقت لـ CF.

discourse-cloudflare-purge.php
<?php
/**
 * Plugin Name: Discourse → Cloudflare Safe Cache for Comments
 * Description: Prevents caching of single-post HTML until Discourse linkage exists; also purges Cloudflare when linkage lands.
 * Author: Lee Hutchinson + ChatGPT
 * Version: 1.6.1
 *
 * Changelog:
 * 1.6.1 - Added timeout failsafe so that we're not emitting no-cache headers forever
 * 1.6.0 — ADD pre-linkage no-cache gate (template_redirect) so edge/origin never cache the WP-native-comments HTML.
 * 1.4.0 — Purge on added/updated postmeta + status transition; purge slash/no-slash variants.
 * 1.3.0 — Credentials via wp-config.php constants only.
 */

if (!defined('ABSPATH')) exit;

/** Resolve Cloudflare credentials from wp-config.php */
function scw_dcfp_get_creds(): array {
    $zone  = defined('SCW_CF_ZONE_ID')   ? trim((string) SCW_CF_ZONE_ID)   : '';
    $token = defined('SCW_CF_API_TOKEN') ? trim((string) SCW_CF_API_TOKEN) : '';
    return ['zone_id' => $zone, 'api_token' => $token];
}

/** Discourse linkage meta keys (filterable) */
function scw_dcfp_meta_keys(): array {
    $keys = ['discourse_topic_id', 'discourse_post_id', 'discourse_permalink'];
    return apply_filters('scw_dcfp_meta_keys', $keys);
}

/** Build URLs to purge for a post (slash + no-slash, plus home + RSS) */
function scw_dcfp_build_urls(int $post_id): array {
    $urls = [];
    $permalink = get_permalink($post_id);
    if ($permalink) {
        $urls[] = $permalink;
        $urls[] = (substr($permalink, -1) === '/') ? rtrim($permalink, '/') : trailingslashit($permalink);
    }
    $urls[] = home_url('/');
    $urls[] = get_bloginfo('rss2_url');
    $urls = array_unique(array_filter($urls));
    return apply_filters('scw_dcfp_urls', $urls, $post_id);
}

/** Minutes since publish (UTC) or null if unknown */
function scw_dcfp_minutes_since_publish(int $post_id): ?int {
    $post_time = get_post_time('U', true, $post_id);
    if (!$post_time) return null;
    return (int) floor((time() - (int)$post_time) / 60);
}

/** Low-level Cloudflare purge-by-URL */
function scw_dcfp_purge_urls(array $urls): void {
    $creds = scw_dcfp_get_creds();
    if ($creds['zone_id'] === '' || $creds['api_token'] === '' || empty($urls)) return;

    $endpoint = sprintf('https://api.cloudflare.com/client/v4/zones/%s/purge_cache', $creds['zone_id']);
    $response = wp_remote_post($endpoint, [
        'headers' => [
            'Authorization' => 'Bearer ' . $creds['api_token'],
            'Content-Type'  => 'application/json',
        ],
        'body'    => wp_json_encode(['files' => array_values($urls)]),
        'timeout' => 10,
    ]);

    if (is_wp_error($response)) {
        error_log('[SCW CF Purge] WP_Error: ' . $response->get_error_message());
        return;
    }
    $code = (int) wp_remote_retrieve_response_code($response);
    if ($code < 200 || $code >= 300) {
        $body = wp_remote_retrieve_body($response);
        error_log(sprintf('[SCW CF Purge] HTTP %d. Response: %s', $code, $body));
    }
}

/** Common gate: only act when linkage meta key written & post is public */
function scw_dcfp_should_trigger($post_id, $meta_key, $meta_value): bool {
    $creds = scw_dcfp_get_creds();
    if ($creds['zone_id'] === '' || $creds['api_token'] === '') return false;
    if (empty($meta_value)) return false;
    if (!in_array($meta_key, scw_dcfp_meta_keys(), true)) return false;
    return (get_post_status($post_id) === 'publish');
}

/** Purge on added/updated Discourse linkage meta */
function scw_dcfp_on_post_meta_change($meta_id, $post_id, $meta_key, $meta_value): void {
    if (!scw_dcfp_should_trigger($post_id, $meta_key, $meta_value)) return;
    $urls = scw_dcfp_build_urls((int) $post_id);
    scw_dcfp_purge_urls($urls);
}
add_action('added_post_meta',   'scw_dcfp_on_post_meta_change', 10, 4);
add_action('updated_post_meta', 'scw_dcfp_on_post_meta_change', 10, 4);

/** Purge on publish transition, if linkage meta already present */
function scw_dcfp_on_transition_post_status($new_status, $old_status, $post): void {
    if ($new_status !== 'publish' || !($post instanceof WP_Post)) return;
    foreach (scw_dcfp_meta_keys() as $k) {
        if (get_post_meta($post->ID, $k, true)) {
            $urls = scw_dcfp_build_urls((int) $post->ID);
            scw_dcfp_purge_urls($urls);
            break;
        }
    }
}
add_action('transition_post_status', 'scw_dcfp_on_transition_post_status', 10, 3);

/**
 * NEW: Pre-linkage no-cache gate.
 * Until any Discourse linkage meta exists on a published single post,
 * emit strong no-cache headers so neither origin (FastCGI) nor Cloudflare
 * can store the pre-linkage HTML that shows WP's native comment form.
 */
function scw_dcfp_prelinkage_nocache(): void {
    if (!is_singular('post')) return;

    $post = get_queried_object();
    if (!($post instanceof WP_Post) || get_post_status($post) !== 'publish') return;

    foreach (scw_dcfp_meta_keys() as $k) {
        if (get_post_meta($post->ID, $k, true)) return; // linkage exists; cache normally
    }

    // In wp-config.php you can set: define('SCW_CF_LINKAGE_GRACE_MIN', 10);
    $grace = defined('SCW_CF_LINKAGE_GRACE_MIN') ? (int) SCW_CF_LINKAGE_GRACE_MIN : 0;
    if ($grace > 0) {
        $mins = scw_dcfp_minutes_since_publish($post->ID);
        if ($mins !== null && $mins >= $grace) {
            // We’ve waited long enough; stop gating and let caching resume.
            // (Optionally, trigger one purge to refresh edge with whatever is current.)
            // scw_dcfp_purge_urls(scw_dcfp_build_urls((int)$post->ID));
            return;
        }
    }

    // Block caching for this response (origin + edge + browsers)
    if (!headers_sent()) {
        nocache_headers();                    // Cache-Control: no-store, no-cache, must-revalidate, etc.
        header('cf-edge-cache: no-cache');    // Extra hint for Cloudflare/APO
    }
    if (!defined('DONOTCACHEPAGE')) define('DONOTCACHEPAGE', true);

    // Optional debug: uncomment for a few publishes
    // error_log('[SCW CF Gate] Pre-linkage no-cache for post ' . $post->ID);
}
add_action('template_redirect', 'scw_dcfp_prelinkage_nocache', 0);

/** Optional: admin notice if not configured */
add_action('admin_notices', function () {
    if (!current_user_can('manage_options')) return;
    $creds = scw_dcfp_get_creds();
    if ($creds['zone_id'] === '' || $creds['api_token'] === '') {
        echo '<div class="notice notice-error"><p><strong>SCW Discourse → Cloudflare:</strong> Missing credentials. Define <code>SCW_CF_ZONE_ID</code> and <code>SCW_CF_API_TOKEN</code> in <code>wp-config.php</code>.</p></div>';
    }
});

لاحظ أن إضافة mu-plugin هذه تقوم بعمليات API مع Cloudflare، وبالتالي تتطلب تعريف بعض الثوابت في wp-config مع منطقة CF (SCW_CF_ZONE_ID) ومفتاح API (SCW_CF_API_TOKEN). قد ترغب أيضًا في إعادة تسمية هذه الثوابت.

هذا الإجراء قضى تمامًا على سلوك المشكلة — توقف فور نشر discourse-cloudflare-purge.php. لكنه لا يزال يزعجني بشدة. لماذا كان يحدث هذا؟ كان بإمكاني إعادة سلوك المشكلة خلال بضعة أيام إذا قمت بتعطيل إضافة mu-plugin، لذا كان السبب الجذري لا يزال موجودًا.

في النهاية، بعد فحص السجلات بعناية، وجدت المشكلة:

الموقع الذي أديره في WP ينشر محتواه عبر Apple News باستخدام هذه الإضافة، التي يتم تحديثها بانتظام وعملت بشكل ممتاز لسنوات. ومع ذلك، فإن عملية النشر عبر Apple News تُفعّل فور نشر المنشور، وأحيانًا يحدث السلوك الموضح في مقتطف سجل nginx أدناه. يبدأ هذا المقتطف بحدث “POST” الذي يُفعّل عند ضغط المؤلف على زر “نشر” في WordPress:

مقتطف فوضوي من سجل وصول nginx
[22/Dec/2025:06:15:40 -0600] spacecityweather.com [-] [Poster IP] | POST /wp-admin/admin-ajax.php HTTP/2.0 200 Ref: "https://spacecityweather.com/wp-admin/post.php?post=53600&action=edit" UA: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36"

[22/Dec/2025:06:15:40 -0600] spacecityweather.com [BYPASS] [Poster IP] | GET / HTTP/2.0 200 Ref: "-" UA: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36"

[22/Dec/2025:06:15:41 -0600] spacecityweather.com [BYPASS] [Poster IP] | GET /wp-admin/admin.php?page=stats&noheader&proxy&chart=admin-bar-hours-scale HTTP/2.0 200 Ref: "https://spacecityweather.com/" UA: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36"

[22/Dec/2025:06:15:41 -0600] spacecityweather.com [BYPASS] [Poster IP] | GET /wp-json/jetpack/v4/scan HTTP/2.0 200 Ref: "https://spacecityweather.com/" UA: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36"

[22/Dec/2025:06:15:41 -0600] spacecityweather.com [MISS] 57.103.65.197 | GET /its-beginning-to-feel-not-like-christmas-everywhere-you-go-2/ HTTP/2.0 200 Ref: "-" UA: "AppleNewsBot"

[22/Dec/2025:06:15:41 -0600] spacecityweather.com [MISS] 57.103.65.183 | GET /its-beginning-to-feel-not-like-christmas-everywhere-you-go-2/ HTTP/2.0 200 Ref: "-" UA: "AppleNewsBot"

[22/Dec/2025:06:15:41 -0600] spacecityweather.com [-] [Discourse server IP] | POST /wp-json/wp-discourse/v1/update-topic-content HTTP/1.1 200 Ref: "-" UA: "Discourse/2025.12.0-latest-585840225f344268cf72cb570ec0f069b83088a8; +https://www.discourse.org/"

[22/Dec/2025:06:15:41 -0600] spacecityweather.com [MISS] 57.103.65.175 | GET /its-beginning-to-feel-not-like-christmas-everywhere-you-go-2 HTTP/2.0 301 Ref: "-" UA: "AppleNewsBot"

[22/Dec/2025:06:15:42 -0600] spacecityweather.com [MISS] [Discourse server IP] | GET /its-beginning-to-feel-not-like-christmas-everywhere-you-go-2/ HTTP/1.1 200 Ref: "-" UA: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36"

[22/Dec/2025:06:15:42 -0600] spacecityweather.com [-] [Discourse server IP] | GET /wp-content/uploads/2025/12/image-29.png HTTP/1.1 200 Ref: "-" UA: "Discourse/2025.12.0-latest-585840225f344268cf72cb570ec0f069b83088a8; +https://www.discourse.org/"

[22/Dec/2025:06:15:42 -0600] spacecityweather.com [-] [Discourse server IP] | GET /wp-content/uploads/2025/12/change.jpg HTTP/1.1 200 Ref: "-" UA: "Discourse/2025.12.0-latest-585840225f344268cf72cb570ec0f069b83088a8; +https://www.discourse.org/"

[22/Dec/2025:06:15:42 -0600] spacecityweather.com [HIT] [Discourse server IP] | GET /its-beginning-to-feel-not-like-christmas-everywhere-you-go-2/ HTTP/1.1 200 Ref: "-" UA: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36"

[22/Dec/2025:06:15:42 -0600] spacecityweather.com [MISS] 2a01:b747:3003:206::32 | GET /its-beginning-to-feel-not-like-christmas-everywhere-you-go-2/ HTTP/2.0 200 Ref: "-" UA: "AppleNewsBot"

[22/Dec/2025:06:15:42 -0600] spacecityweather.com [HIT] 57.103.65.182 | GET /its-beginning-to-feel-not-like-christmas-everywhere-you-go-2 HTTP/2.0 301 Ref: "-" UA: "AppleNewsBot"

[22/Dec/2025:06:15:42 -0600] spacecityweather.com [BYPASS] [Poster IP] | GET /its-beginning-to-feel-not-like-christmas-everywhere-you-go-2/ HTTP/2.0 200 Ref: "https://spacecityweather.com/" UA: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36"

[22/Dec/2025:06:15:42 -0600] spacecityweather.com [HIT] 57.103.65.181 | GET /its-beginning-to-feel-not-like-christmas-everywhere-you-go-2/ HTTP/2.0 200 Ref: "-" UA: "AppleNewsBot"

[Poster IP] هو عنوان IP المحذوف لمؤلف منشور WP، و [Discourse server IP] هو عنوان VPC لخادم Discourse.

[HIT] أو [MISS] أو [BYPASS] هو حالة الضربة في ذاكرة التخزين المؤقت المحلية لـ nginx fastcgi لذلك الطلب وهو مؤشر مساعد جيد.

رابط المنشور الجديد هو /its-beginning-to-feel-not-like-christmas-everywhere-you-go-2.

باختصار، ما يظهره السجل هو إنشاء المنشور، ثم، قبل أن يتمكن إضافة Discourse من فعل ما عليه، يتدخل AppleNewsBot (مُفعّل بنشر المنشور) ويصل إلى الرابط الجديد المنشور. (يمكنك رؤية Discourse يبني المنشور ويحصل على الصور مباشرةً أدناه، بينما تقوم خوادم Apple News الأخرى أيضًا بنسخها.)

كان هذا السباق هو على الأرجح مصدر ظهور الصفحات القديمة في ذاكرة التخزين المؤقت على حافة CF، وعدم الاتساق ناتج عن أن Discourse يفوز أحيانًا بالسباق ويكون أول مرة يظهر فيها المنشور في الذاكرة المؤقتة مع تعليقات صحيحة، وأحيانًا يفوز Apple News بالسباق ولا تكون هناك تعليقات. تحتوي إضافة CF الخاصة بـ WP على منطقها الخاص لمسح الذاكرة المؤقتة، لكن يبدو أنها تُفعّل قبل Apple News.

على أي حال، أشعر براحة كبيرة جدًا الآن knowing لماذا حدثت المشكلة. أفكر في تقديم طلب مشكلة لمطوري إضافة Apple News، لكنني لست متأكدًا مما إذا كانت هذه المشكلة منتشرة بما يكفي لتستحق انتباه المطورين. بالإضافة إلى ذلك، فإن إضافة mu-plugin (مع التأكد من أن nginx ذكي بشأن ذاكرته المؤقتة الخاصة) تلغي المشكلة تمامًا بالنسبة لي على أي حال.

3) محاولة الحصول على الكعكة وتناولها في نفس الوقت من خلال تخزين استجابات ajax لمدة 60 ثانية

تتمتع إضافة wp-discourse بعدة طرق للتعامل مع تخزين التعليقات في منشورات/صفحات WP، لكن لتتناسب مع متطلبات مالك الموقع، اخترت طريقة ajax لعرض التعليقات. إنها بالضبط ما أريده تقريبًا وتعمل بشكل رائع، حيث تعرض تعليقات جديدة في منشورات WP تقريبًا فورًا بعد نشرها على جانب Discourse.

لكنني دائمًا ما أواجه خطر حدث عالي الحركة — هذا موقع للتنبؤات الجوية، وأحيانًا تنفجر التنبؤات. ماذا لو تم قصفي فجأة بمستخدمين يشاهدون تعليقات مدمجة وكل طلبات ajax التي تولدها؟

اتضح أنه يمكننا إساءة استخدام Cloudflare قليلاً وإجراء تخزين قصير الأمد لاستجابات ajax على حافة Cloudflare، للمساعدة في تخفيف بعض هذا الحمل المحتمل. يتطلب هذا إضافة mu-plugin أخرى تخبر WP بما يجب فعله، وقاعدة تخزين في Cloudflare تخبر CF بما يجب فعله.

discourse-rest-edge-cache.php
<?php
/**
 * Plugin Name: Discourse REST Edge Cache
 * Description: Sets cache headers on WP-Discourse REST responses so Cloudflare can cache them (~60s at edge) while browsers revalidate.
 * Version: 1.0.3
 * Author: Lee Hutchinson + ChatGPT
 */

if (!defined('ABSPATH')) exit;

add_filter('rest_post_dispatch', function($result, $server, $request){
    if (!($request instanceof WP_REST_Request)) return $result;

    // Match the WP-Discourse namespace at the start of the route
    $route = $request->get_route(); // e.g., /wp-discourse/v1/...
    $attrs = method_exists($request, 'get_attributes') ? (array) $request->get_attributes() : [];
    $ns    = $attrs['namespace'] ?? '';

    $is_discourse = ($ns === 'wp-discourse') || (is_string($route) && preg_match('#^/wp-discourse(?:/|$)#', $route));
    if (!$is_discourse) return $result;

    // Safe methods only
    $method = strtoupper($request->get_method() ?: 'GET');
    if ($method !== 'GET' && $method !== 'HEAD') return $result;

    // Don’t cache personalized/authenticated requests
    if (is_user_logged_in()
        || $request->get_header('authorization')
        || $request->get_header('x-wp-nonce')
        || $request->get_header('cookie')) {
        return $result;
    }

    // Cache only successful responses
    if (is_wp_error($result)) return $result;
    $status = ($result instanceof WP_HTTP_Response) ? (int) $result->get_status() : 200;
    if ($status < 200 || $status >= 400) return $result;

    // Edge TTL ~60s, browsers revalidate; SWR smooths refresh
    $server->send_header('Cache-Control', 'public, s-maxage=60, max-age=0, stale-while-revalidate=30');
    $server->send_header('Vary', 'Accept-Encoding');

    return $result;
}, 10, 3);

هذا يجعل WP تضيف رأس Cache-Control إلى استدعاءات /wp-json/wp-discourse/v1/discourse-comments?post_id= التي تقوم بها. s-maxage=60 هو المكان الذي أضع فيه المدة التي أريد أن يبقى فيها الرد في الذاكرة المؤقتة.

عادةً لن تقوم Cloudflare بتخزين هذا النوع من الطلبات، لذا نحتاج إلى تشجيعها بلطف على القيام بذلك بقاعدة تخزين:

لقطات شاشة لبعض قواعد التخزين المؤقت في Cloudflare

تحدد هذه القاعدة المسارات التي تحتوي على /wp-json/wp-discourse/ كمؤهلة للتخزين المؤقت، ثم تخبر ذاكرة التخزين المؤقت على حافة CF بالاحتفاظ بها طالما أن المصدر يطلب. في حالتنا، نطلب 60 ثانية. (يجب أن نفعل ذلك باستخدام الإضافة بدلاً من استخدام مربع “إدخال وقت الصلاحية” الجميل في لقطة الشاشة الثالثة لأن الحد الأدنى لوقت الصلاحية الذي تسمح به Cloudflare للحسابات المجانية هو ساعتين ولحسابات Pro هو ساعة واحدة، وأنا أريد أقل بكثير من ذلك.)

أعتقد أن هذا التخزين المؤقت قصير المدة سيساعدني قليلاً إذا انفجر منشور WP وبدأ 100,000 شخص في النظر إليه في نفس الوقت — ستتحمل CF جزءًا من الحمل، مع السماح لي بالحفاظ على مظهر التحديث شبه الفوري للتعليقات.

باستثناء هذه العناصر — التي لا تعد من أخطاء wp-discourse! — كان دمج CF و wp-discourse سهلًا جدًا. أنا عميل سعيد. (وإذا كنت صادقًا، فقد استمتعت نوعًا ما بهذا العذر للعبث!)

إعجابَين (2)