tweaks

Συνδέστε το Discourse με το WordPress (με ασφάλεια)

24 λεπτά ανάγνωσης

Κάθε νέο άρθρο γίνεται αυτόματα συζήτηση στο forum, και οι απαντήσεις των μελών εμφανίζονται κάτω από το άρθρο, μαζί με τα σχόλια των επισκεπτών. Χωρίς να “δέσουμε” τα δύο συστήματα μεταξύ τους.

Στο προηγούμενο άρθρο εγκαταστήσαμε το Discourse σε server με Plesk, σε ξεχωριστό container. Τώρα το συνδέουμε με το WordPress. Πριν από τα βήματα, αξίζει να ξεκαθαρίσουμε έναν κανόνα, γιατί από αυτόν εξαρτάται η ασφάλεια όλου του συστήματος.discourse wordpress

Δες περισσότερα άρθρα του iGuRu.gr όταν αναζητάς ειδήσεις στην Google.

Ο κανόνας: σύνδεση ναι, κοινά «κλειδιά» όχι

Όσο λιγότερα δικαιώματα έχει το ένα σύστημα πάνω στο άλλο, τόσο μικρότερη η ζημιά αν παραβιαστεί κάποιο από τα δύο. Γι’ αυτό:

  • Χωρίς κοινούς λογαριασμούς (SSO): το DiscourseConnect θα έκανε τους χρήστες του WordPress λογαριασμούς του forum, δένοντας τις ταυτότητες. Τα μέλη του forum μένουν μόνο στο Discourse.
  • Το WordPress δεν κρατά κλειδί διαχειριστή: εκεί που θα χρειαστεί API key για τη δημοσίευση, θα χρησιμοποιήσουμε ένα κλειδί ενός χρήστη, με το ελάχιστο δικαίωμα που χρειάζεται.
  • Το forum δεν κρατά κανένα κλειδί του WordPress: θα διαβάζει μόνο τα δημόσια άρθρα σας.

Οι επιλογές σύνδεσης

Τρόπος Τι κάνει Κλειδιά
Embedding του Discourse Το Discourse δημιουργεί μόνο του ένα θέμα για κάθε άρθρο, την πρώτη φορά που του ζητηθεί η συζήτησή του Κανένα
Δημοσίευση μέσω API Το WordPress δημιουργεί το θέμα τη στιγμή της δημοσίευσης του άρθρου Ένα API key ενός χρήστη (single user), μόνο για θέματα
Επίσημο plugin WP Discourse Δημοσίευση, σχόλια και SSO σε ένα πακέτο API key στο WordPress

 

Το επίσημο plugin είναι η πιο γρήγορη λύση, με δύο παρατηρήσεις.

Πρώτον, στέλνει στο forum ολόκληρο το επεξεργασμένο HTML του άρθρου, οπότε στο θέμα μπορεί να καταλήξουν κουμπιά κοινοποίησης, διαφημίσεις και άλλα στοιχεία του θέματος.

Δεύτερον, μην ενεργοποιήσετε το DiscourseConnect αν θέλετε τα δύο συστήματα να μένουν ανεξάρτητα. Παρακάτω περιγράφουμε τη σύνδεση βήμα βήμα, όπως την εφαρμόσαμε εμείς.

Βήμα 1: Χρήστης για τα θέματα των άρθρων

Τα θέματα των άρθρων χρειάζονται έναν «συντάκτη» στο forum. Δημιουργήστε έναν απλό χρήστη (όχι διαχειριστή), για παράδειγμα iguru. Αν το email δεν λειτουργεί ακόμα, δημιουργήστε τον απευθείας από τον server:

Bash
cd /var/discourse && ./launcher enter app
Code
rails runner 'u = User.new(username: "iguru", name: "iGuRu.gr", email: "forum-bot@example.gr", password: SecureRandom.hex(24), active: true, approved: true); u.save!; u.activate; puts u.username'
Code
exit

Βήμα 2: Embedding στο Discourse

Στο Admin → Customize → Embedding:

  1. Add host: το domain του site σας (π.χ. example.gr) και η κατηγορία όπου θα μπαίνουν τα θέματα των άρθρων.
  2. Username for topic creation: ο χρήστης του βήματος 1.
  3. Ενεργοποιήστε το Truncate the embedded posts: το θέμα δείχνει μόνο μια σύντομη περίληψη με σύνδεσμο προς το άρθρο, αποφεύγοντας το διπλό περιεχόμενο για το Google.
  4. Πείτε στο Discourse ποιο μέρος της σελίδας είναι το άρθρο, ώστε να αγνοεί κουμπιά, διαφημίσεις και πλαίσια. Στο CSS selector for elements that are allowed βάλτε την κλάση του κειμένου του άρθρου στο θέμα σας (π.χ. .entry-content), και στα removed τις κλάσεις των στοιχείων που δεν θέλετε.

Βήμα 3: API key με το ελάχιστο δικαίωμα

Αν θέλετε το WordPress να δημοσιεύει το θέμα αμέσως στο forum με τη δημοσίευση του άρθρου, δημιουργήστε κλειδί στο Admin → API → New API Key με τις εξής ρυθμίσεις:

  • User Level: Single User, ο χρήστης του βήματος 1. Ποτέ “All Users” και ποτέ διαχειριστής.
  • Scope: Granular, μόνο topics → write και topics → read.

Έτσι, ακόμα κι αν το WordPress παραβιαστεί, το κλειδί δεν επιτρέπει τίποτα περισσότερο από τη δημιουργία θεμάτων σαν αυτός ο χρήστης.

Το θέμα δημιουργείται με ένα απλό αίτημα στο /posts.json.

Δύο λεπτομέρειες κάνουν τη διαφορά:

η παράμετρος embed_url συνδέει το θέμα με το άρθρο, και το external_id (π.χ. wp-12345) εγγυάται ότι μια επανάληψη δεν θα φτιάξει δεύτερο θέμα για το ίδιο άρθρο. Αν το forum απαντήσει “Category can’t be blank”, θα πρέπει να ορίσετε κατηγορία, γιατί το Discourse από προεπιλογή δεν δέχεται θέματα χωρίς κατηγορία. Μια καλή πρακτική είναι να αντιστοιχίζονται αυτόματα οι κατηγορίες του WordPress με τις ομώνυμες του forum, που διαβάζονται δημόσια από το /categories.json.

Βήμα 4: Οι απαντήσεις κάτω από το άρθρο

Οι απαντήσεις ενός θέματος είναι δημόσιες και διαβάζονται χωρίς κλειδί από το /t/ID.json. Το WordPress μπορεί να τις διαβάζει στον server, να τις αποθηκεύει προσωρινά και να τις εμφανίζει σαν κανονικό HTML κάτω από το άρθρο, μαζί με τα σχόλια των επισκεπτών.

Τα οφέλη:

  • Χωρίς JavaScript και χωρίς iframe: οι απαντήσεις είναι μέρος της σελίδας και το Google τις βλέπει μαζί με το άρθρο.
  • Οι επισκέπτες χωρίς λογαριασμό συνεχίζουν να σχολιάζουν από τη φόρμα του WordPress, ενώ τα μέλη απαντούν στο forum.
  • Ασφάλεια: το HTML του forum καθαρίζεται ξανά με το φίλτρο wp_kses_post του WordPress πριν εμφανιστεί, οπότε ακόμα και ένα παραβιασμένο forum δεν μπορεί να «περάσει» script στο site.

Βήμα 5: Webhook για άμεση ενημέρωση

Οι σελίδες των άρθρων είναι συνήθως cached (Wp-rocket, FlyingPress, Cloudflare), οπότε μια νέα απάντηση στο forum δεν θα φαινόταν για ώρες. Η λύση είναι ένα webhook: σε κάθε νέα απάντηση, το Discourse θα ειδοποιεί το WordPress, που ανανεώνει τις απαντήσεις και καθαρίζει την cache μόνο εκείνου του άρθρου.

Στο Admin → API → Webhooks → New Webhook ορίστε τη διεύθυνση του WordPress (διαβάστε το τέλος της δημοσίευσης), content type application/json, ένα μυστικό (secret), και ως συμβάντα το Post Event.

Το Discourse υπογράφει κάθε ειδοποίηση με HMAC-SHA256 στην κεφαλίδα X-Discourse-Event-Signature, και το WordPress πρέπει να την ελέγχει. Σε PHP, ο έλεγχος είναι μία γραμμή:

Code
hash_equals( 'sha256=' . hash_hmac( 'sha256', $body, $secret ), $signature )

Μια ειδοποίηση χωρίς σωστή υπογραφή απορρίπτεται με 401. Και μια σωστή επιτρέπει μόνο το “ανανέωσε τις απαντήσεις αυτού του άρθρου”, τίποτα άλλο.

Αν το Cloudflare έχει ενεργό το Bot Fight Mode, μπορεί να μπλοκάρει την ειδοποίηση από τον server σας. Προσθέστε έναν κανόνα στο WAF που παρακάμπτει τους ελέγχους για τη διαδρομή του webhook ή για την IP του server σας.

Bonus: Τελευταία θέματα του forum στο sidebar

Το /latest.json του forum είναι δημόσιο. Ένα μικρό widget στο WordPress μπορεί να το διαβάζει στον server κάθε λίγα λεπτά και να δείχνει τα νεότερα θέματα, χωρίς JavaScript και χωρίς κλειδιά.

Αντιμετώπιση προβλημάτων

Σύμπτωμα Αιτία και λύση
Το webhook επιστρέφει 401 Λάθος secret, ή κάποιο plugin (π.χ. Disable REST API) μπλοκάρει το REST API. Δείτε την απάντηση της παράδοσης στο Discourse για να ξεχωρίσετε τις δύο περιπτώσεις.
Category can’t be blank Δεν ορίστηκε κατηγορία forum για το άρθρο.
Στο θέμα εμφανίζονται κουμπιά κοινοποίησης Δημοσιεύτηκε όλο το HTML της σελίδας. Δημοσιεύστε περίληψη με σύνδεσμο, ή ορίστε τους CSS selectors του Embedding.
Δεν δημιουργείται θέμα Ελέγξτε τα «Allowed hosts» του Embedding και ότι το Cloudflare δεν μπλοκάρει το Discourse όταν διαβάζει το άρθρο σας.
Οι απαντήσεις δεν εμφανίζονται Η σελίδα είναι ακόμα στην cache, ή το forum απαιτεί σύνδεση (login required) και τα θέματα δεν είναι δημόσια.

 

Το αποτέλεσμα: το site και το forum λειτουργούν σαν ένα, με τις συζητήσεις κάτω από κάθε άρθρο, αλλά το καθένα μένει στον δικό του, απομονωμένο χώρο. Αν το ένα πάθει κάτι, το άλλο δεν κινδυνεύει.

Για να έχετε το URL για το Admin → API → Webhooks → New Webhook ορίστε:
• Payload URL: https://iguru.gr/wp-json/xxxx/v1/webhook
• Content type: application/json
• Secret: Ένα δικό σας ισχυρό μυστικό κλειδί (π.χ. μια τυχαία συμβολοσειρά)
• Event Actions: Επιλέξτε μόνο το Post Event (ώστε να ενεργοποιείται μόνο όταν γράφεται νέα απάντηση).

Η Έτοιμη Λύση: Το iGuRu Discourse Bridge Plugin

Για να μην μπλέκετε με την προσθήκη κώδικα στα αρχεία του θέματός σας (functions.php), δημιουργήσαμε ένα αυτόνομο, ελαφρύ και απόλυτα ασφαλές custom plugin. Το πρόσθετο αυτό αναλαμβάνει να ανοίξει την ασφαλή “πόρτα” επικοινωνίας στο WordPress, να ελέγχει τις ψηφιακές υπογραφές των webhooks και να καθαρίζει αυτόματα την cache των WP-Rocket, FlyingPress και LiteSpeed Cache μόλις εμφανίζεται νέα απάντηση στο forum.

Οδηγίες Εγκατάστασης & Ρύθμισης

1. Δημιουργία του αρχείου: Αντιγράψτε τον κώδικα του plugin που σας δίνεται παρακάτω και αποθηκεύστε τον σε ένα αρχείο με όνομα `iguru-discourse-bridge.php στον υπολογιστή σας.
2. Κάντε το ZIP: Συμπιέστε το αρχείο σε μορφή .zip (π.χ. iguru-discourse-bridge.zip).
3. Εγκατάσταση στο WordPress: Στο WordPress Admin μενού Πρόσθετα (Plugins) → Προσθήκη νέου πρόσθετου, ανεβάστε το αρχείο .zip (upload) και πατήστε Ενεργοποίηση.
4. Εισαγωγή Κλειδιών: Μέσα στο μενού Ρυθμίσεις (Settings) → Discourse Bridge, θα βρείτε τη σελίδα ρυθμίσεων του plugin:
• Στο πεδίο Discourse URL, συμπληρώστε τη διεύθυνση του forum σας (π.χ. https://example.gr).
• Στο πεδίο Webhook Secret, επικολλήστε το κρυφό κλειδί (Secret) που ορίσατε κατά τη δημιουργία του Webhook στο Discourse.

5. Αντιγραφή του Payload URL: Η σελίδα ρυθμίσεων θα σας εμφανίσει αυτόματα το ακριβές URL (π.χ. https://site.gr/wp-json/iguru/v1/discourse-webhook). Αντιγράψτε το και επικολλήστε το στο πεδίο Payload URL στις ρυθμίσεις Webhook του Discourse (Admin → API → Webhooks).

Αυτό ήταν! Πλέον το WordPress και το Discourse επικοινωνούν με απόλυτη ασφάλεια, χωρίς κοινούς λογαριασμούς, χωρίς iframe, και με ακαριαία ανανέωση των σχολίων κάτω από κάθε σας άρθρο.

Ο κώδικας για το plugin

PHP
<?php
/**
 * Plugin Name: iGuRu Discourse Bridge
 * Description: Displays Discourse topic replies in WordPress and purges relevant caches via signed Discourse webhooks.
 * Version: 1.1.0
 * Author: iGuRu.gr
 * License: GPL-2.0-or-later
 * Requires at least: 6.0
 * Requires PHP: 7.4
 */

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

final class IGURU_Discourse_Bridge {
    const VERSION       = '1.1.0';
    const OPTION_URL    = 'iguru_discourse_forum_url';
    const OPTION_SECRET = 'iguru_discourse_webhook_secret';
    const META_TOPIC    = '_discourse_topic_id';
    const CACHE_PREFIX  = 'discourse_comments_';
    const CACHE_TTL     = 15 * MINUTE_IN_SECONDS;
    const PAGE_SIZE     = 20;
    const MAX_POSTS     = 200;

    public static function init() {
        add_action( 'rest_api_init', array( __CLASS__, 'register_webhook_route' ) );
        add_action( 'admin_menu', array( __CLASS__, 'admin_menu' ) );
        add_shortcode( 'iguru_discourse_comments', array( __CLASS__, 'shortcode' ) );
        add_filter( 'the_content', array( __CLASS__, 'append_comments' ), 30 );
    }

    public static function register_webhook_route() {
        register_rest_route(
            'iguru/v1',
            '/discourse-webhook',
            array(
                'methods'             => 'POST',
                'callback'            => array( __CLASS__, 'handle_webhook' ),
                'permission_callback' => '__return_true',
            )
        );
    }

    private static function secret() {
        if ( defined( 'IGURU_DISCOURSE_SECRET' ) && is_string( IGURU_DISCOURSE_SECRET ) && '' !== IGURU_DISCOURSE_SECRET ) {
            return IGURU_DISCOURSE_SECRET;
        }
        return (string) get_option( self::OPTION_SECRET, '' );
    }

    private static function forum_url() {
        $url = defined( 'IGURU_DISCOURSE_URL' ) ? IGURU_DISCOURSE_URL : get_option( self::OPTION_URL, '' );
        $url = trim( (string) $url );
        if ( '' === $url ) {
            return '';
        }

        $url = esc_url_raw( $url, array( 'https' ) );
        $parts = wp_parse_url( $url );
        if (
            ! is_array( $parts ) ||
            empty( $parts['scheme'] ) ||
            'https' !== strtolower( $parts['scheme'] ) ||
            empty( $parts['host'] ) ||
            isset( $parts['user'] ) ||
            isset( $parts['pass'] ) ||
            isset( $parts['query'] ) ||
            isset( $parts['fragment'] )
        ) {
            return '';
        }

        return untrailingslashit( $url );
    }

    public static function handle_webhook( WP_REST_Request $request ) {
        $secret    = self::secret();
        $signature = trim( (string) $request->get_header( 'X-Discourse-Event-Signature' ) );
        $body      = $request->get_body();

        if ( '' === $secret ) {
            return new WP_REST_Response( array( 'error' => 'Webhook secret is not configured.' ), 500 );
        }

        $expected = 'sha256=' . hash_hmac( 'sha256', $body, $secret );
        if ( ! preg_match( '/^sha256=[a-f0-9]{64}$/i', $signature ) || ! hash_equals( $expected, strtolower( $signature ) ) ) {
            return new WP_REST_Response( array( 'error' => 'Invalid webhook signature.' ), 401 );
        }

        $data = json_decode( $body, true );
        if ( JSON_ERROR_NONE !== json_last_error() || ! is_array( $data ) ) {
            return new WP_REST_Response( array( 'error' => 'Invalid JSON payload.' ), 400 );
        }

        // Discourse post-event payloads normally include post.topic_id and post.id.
        $topic_id = isset( $data['post']['topic_id'] ) ? absint( $data['post']['topic_id'] ) : 0;
        if ( ! $topic_id ) {
            // Validly signed events unrelated to a topic post need no cache purge.
            return new WP_REST_Response( array( 'status' => 'ignored' ), 200 );
        }

        $ids = get_posts(
            array(
                'post_type'              => 'any',
                'post_status'            => 'publish',
                'posts_per_page'         => 20,
                'fields'                 => 'ids',
                'no_found_rows'          => true,
                'meta_key'               => self::META_TOPIC,
                'meta_value'             => (string) $topic_id,
                'update_post_meta_cache' => false,
                'update_post_term_cache' => false,
            )
        );

        foreach ( $ids as $post_id ) {
            self::purge_post_cache( (int) $post_id );
        }

        return new WP_REST_Response(
            array(
                'status'            => 'success',
                'matching_posts'    => count( $ids ),
            ),
            200
        );
    }

    private static function purge_post_cache( $post_id ) {
        delete_transient( self::CACHE_PREFIX . absint( $post_id ) );

        if ( function_exists( 'rocket_clean_post' ) ) {
            rocket_clean_post( $post_id );
        }

        // Call the FlyingPress method only when the installed version exposes it.
        if ( class_exists( '\FlyingPress\Purge' ) && is_callable( array( '\FlyingPress\Purge', 'post' ) ) ) {
            \FlyingPress\Purge::post( $post_id );
        }

        // LiteSpeed Cache listens for this post-purge action in supported versions.
        if ( defined( 'LSCWP_V' ) ) {
            do_action( 'litespeed_purge_post', $post_id );
        }

        /**
         * Allows a site-specific CDN/proxy integration to purge this post URL.
         *
         * @param int $post_id WordPress post ID.
         */
        do_action( 'iguru_discourse_bridge_purge_post', $post_id );
    }

    private static function get_comments( $post_id ) {
        $topic_id = absint( get_post_meta( $post_id, self::META_TOPIC, true ) );
        if ( ! $topic_id ) {
            return new WP_Error( 'iguru_no_topic', 'No Discourse topic ID is linked to this post.' );
        }

        $cached = get_transient( self::CACHE_PREFIX . $post_id );
        if ( false !== $cached && is_array( $cached ) ) {
            return $cached;
        }

        $base = self::forum_url();
        if ( '' === $base ) {
            return new WP_Error( 'iguru_bad_forum_url', 'The Discourse forum URL is missing or invalid.' );
        }

        $topic_url = $base . '/t/' . $topic_id . '.json';
        $response  = wp_safe_remote_get(
            $topic_url,
            array(
                'timeout'             => 8,
                'redirection'         => 0,
                'limit_response_size' => 2 * MB_IN_BYTES,
                'headers'             => array( 'Accept' => 'application/json' ),
                'user-agent'          => 'iGuRu-Discourse-Bridge/' . self::VERSION . '; ' . home_url( '/' ),
            )
        );

        if ( is_wp_error( $response ) ) {
            return new WP_Error( 'iguru_http_error', 'Could not contact the Discourse forum.' );
        }
        if ( 200 !== (int) wp_remote_retrieve_response_code( $response ) ) {
            return new WP_Error( 'iguru_http_status', 'The Discourse forum returned a non-200 response.' );
        }

        $data = json_decode( wp_remote_retrieve_body( $response ), true );
        if ( JSON_ERROR_NONE !== json_last_error() || ! is_array( $data ) || empty( $data['post_stream'] ) || ! is_array( $data['post_stream'] ) ) {
            return new WP_Error( 'iguru_bad_json', 'The Discourse response was not valid topic JSON.' );
        }

        $stream  = $data['post_stream'];
        $initial = isset( $stream['posts'] ) && is_array( $stream['posts'] ) ? $stream['posts'] : array();
        $all     = array();
        $seen    = array();

        // Keep only posts that belong to this topic; de-duplicate by post ID.
        foreach ( $initial as $post ) {
            if ( is_array( $post ) && ! empty( $post['id'] ) ) {
                $id = absint( $post['id'] );
                if ( $id && ! isset( $seen[ $id ] ) ) {
                    $seen[ $id ] = true;
                    $all[]       = $post;
                }
            }
        }

        // The stream contains IDs for the topic. Fetch missing posts in batches
        // to avoid silently showing only the first page of a long discussion.
        $post_ids = isset( $stream['stream'] ) && is_array( $stream['stream'] ) ? array_values( array_filter( array_map( 'absint', $stream['stream'] ) ) ) : array();
        if ( $post_ids ) {
            $post_ids = array_slice( array_values( array_unique( $post_ids ) ), 0, self::MAX_POSTS );
            $missing  = array_values( array_filter( $post_ids, function ( $id ) use ( $seen ) {
                return ! isset( $seen[ $id ] );
            } ) );

            foreach ( array_chunk( $missing, 20 ) as $chunk ) {
                $query = array();
                foreach ( $chunk as $id ) {
                    $query[] = 'post_ids[]=' . rawurlencode( (string) $id );
                }
                $posts_url = $base . '/t/' . $topic_id . '/posts.json?' . implode( '&', $query );
                $batch_response = wp_safe_remote_get(
                    $posts_url,
                    array(
                        'timeout'             => 8,
                        'redirection'         => 0,
                        'limit_response_size' => 2 * MB_IN_BYTES,
                        'headers'             => array( 'Accept' => 'application/json' ),
                        'user-agent'          => 'iGuRu-Discourse-Bridge/' . self::VERSION . '; ' . home_url( '/' ),
                    )
                );

                if ( is_wp_error( $batch_response ) || 200 !== (int) wp_remote_retrieve_response_code( $batch_response ) ) {
                    continue; // Keep already fetched posts; a later request can retry.
                }

                $batch_data = json_decode( wp_remote_retrieve_body( $batch_response ), true );
                $batch_posts = isset( $batch_data['post_stream']['posts'] ) && is_array( $batch_data['post_stream']['posts'] )
                    ? $batch_data['post_stream']['posts']
                    : ( isset( $batch_data['posts'] ) && is_array( $batch_data['posts'] ) ? $batch_data['posts'] : array() );

                foreach ( $batch_posts as $post ) {
                    if ( is_array( $post ) && ! empty( $post['id'] ) ) {
                        $id = absint( $post['id'] );
                        if ( $id && ! isset( $seen[ $id ] ) ) {
                            $seen[ $id ] = true;
                            $all[]       = $post;
                        }
                    }
                }
            }
        }

        // Sort by post number and exclude the topic's original post explicitly.
        usort( $all, function ( $a, $b ) {
            $na = isset( $a['post_number'] ) ? absint( $a['post_number'] ) : absint( $a['id'] );
            $nb = isset( $b['post_number'] ) ? absint( $b['post_number'] ) : absint( $b['id'] );
            return $na <=> $nb;
        } );
        $all = array_values( array_filter( $all, function ( $post ) {
            // post_number=1 is the topic itself. If the field is absent, retain
            // the post rather than accidentally deleting a real reply.
            return ! isset( $post['post_number'] ) || 1 !== absint( $post['post_number'] );
        } ) );

        set_transient( self::CACHE_PREFIX . $post_id, $all, self::CACHE_TTL );
        return $all;
    }

    public static function shortcode( $atts = array() ) {
        if ( ! is_singular() ) {
            return '';
        }
        $post_id = get_the_ID();
        if ( ! $post_id ) {
            return '';
        }
        return self::render_comments( $post_id );
    }

    public static function append_comments( $content ) {
        if ( is_admin() || ! is_singular( 'post' ) || ! in_the_loop() || ! is_main_query() ) {
            return $content;
        }

        $post_id = get_the_ID();
        if ( ! $post_id || ! get_post_meta( $post_id, self::META_TOPIC, true ) ) {
            return $content;
        }

        // Avoid duplicate output if the editor already placed the shortcode.
        if ( has_shortcode( $content, 'iguru_discourse_comments' ) ) {
            return $content;
        }

        return $content . self::render_comments( $post_id );
    }

    private static function render_comments( $post_id ) {
        $comments = self::get_comments( $post_id );
        if ( is_wp_error( $comments ) ) {
            // Avoid exposing remote error details to public visitors.
            if ( current_user_can( 'manage_options' ) ) {
                return '<section class="iguru-discourse-comments"><p>' . esc_html( $comments->get_error_message() ) . '</p></section>';
            }
            return '';
        }

        $total = count( $comments );
        $page  = isset( $_GET['iguru_discourse_page'] ) ? absint( wp_unslash( $_GET['iguru_discourse_page'] ) ) : 1;
        $page  = max( 1, $page );
        $pages = max( 1, (int) ceil( $total / self::PAGE_SIZE ) );
        $page  = min( $page, $pages );
        $items = array_slice( $comments, ( $page - 1 ) * self::PAGE_SIZE, self::PAGE_SIZE );

        ob_start();
        ?>
        <section class="iguru-discourse-comments" aria-label="<?php echo esc_attr__( 'Discourse comments', 'iguru-discourse-bridge' ); ?>">
            <h2><?php echo esc_html__( 'Σχόλια από το forum', 'iguru-discourse-bridge' ); ?> (<?php echo esc_html( (string) $total ); ?>)</h2>
            <?php if ( empty( $items ) ) : ?>
                <p><?php echo esc_html__( 'Δεν υπάρχουν ακόμη απαντήσεις.', 'iguru-discourse-bridge' ); ?></p>
            <?php else : ?>
                <ol class="iguru-discourse-list">
                    <?php foreach ( $items as $comment ) :
                        $username = isset( $comment['username'] ) ? sanitize_text_field( $comment['username'] ) : 'Discourse user';
                        $created  = isset( $comment['created_at'] ) ? sanitize_text_field( $comment['created_at'] ) : '';
                        $raw      = isset( $comment['cooked'] ) ? $comment['cooked'] : '';
                        $allowed  = wp_kses_allowed_html( 'post' );
                        // Do not allow arbitrary iframes from user-generated forum content.
                    $body     = is_string( $raw ) ? wp_kses( $raw, $allowed, array( 'https' ) ) : '';
                        ?>
                        <li class="iguru-discourse-comment">
                            <div class="iguru-discourse-comment-meta">
                                <strong><?php echo esc_html( $username ); ?></strong>
                                <?php if ( $created ) : ?>
                                    <time datetime="<?php echo esc_attr( $created ); ?>"><?php echo esc_html( $created ); ?></time>
                                <?php endif; ?>
                            </div>
                            <div class="iguru-discourse-comment-body"><?php echo $body; // Sanitized with wp_kses above. ?></div>
                        </li>
                    <?php endforeach; ?>
                </ol>
                <?php if ( $pages > 1 ) : ?>
                    <nav class="iguru-discourse-pagination" aria-label="<?php echo esc_attr__( 'Comment pages', 'iguru-discourse-bridge' ); ?>">
                        <?php for ( $i = 1; $i <= $pages; $i++ ) :
                            $url = add_query_arg( 'iguru_discourse_page', $i, get_permalink( $post_id ) );
                            ?>
                            <a href="<?php echo esc_url( $url ); ?>" <?php echo $i === $page ? 'aria-current="page"' : ''; ?>><?php echo esc_html( (string) $i ); ?></a>
                        <?php endfor; ?>
                    </nav>
                <?php endif; ?>
            <?php endif; ?>
        </section>
        <style>
            .iguru-discourse-comments{margin:2em 0;padding:1.25em;border:1px solid #ddd;border-radius:8px}
            .iguru-discourse-list{list-style:none;margin:0;padding:0}
            .iguru-discourse-comment{padding:1em 0;border-bottom:1px solid #e5e5e5}
            .iguru-discourse-comment:last-child{border-bottom:0}
            .iguru-discourse-comment-meta{display:flex;gap:.75em;flex-wrap:wrap;align-items:baseline;margin-bottom:.5em}
            .iguru-discourse-comment-meta time{font-size:.85em;opacity:.75}
            .iguru-discourse-comment-body{overflow-wrap:anywhere}
            .iguru-discourse-pagination{display:flex;gap:.5em;flex-wrap:wrap;margin-top:1em}
            .iguru-discourse-pagination a{padding:.3em .65em;border:1px solid #ccc;border-radius:4px;text-decoration:none}
            .iguru-discourse-pagination [aria-current="page"]{font-weight:bold;border-color:currentColor}
        </style>
        <?php
        return ob_get_clean();
    }

    public static function admin_menu() {
        add_options_page(
            'iGuRu Discourse Settings',
            'iGuRu Discourse',
            'manage_options',
            'iguru-discourse',
            array( __CLASS__, 'settings_page' )
        );
    }

    public static function settings_page() {
        if ( ! current_user_can( 'manage_options' ) ) {
            return;
        }

        $message = '';
        if ( 'POST' === $_SERVER['REQUEST_METHOD'] && isset( $_POST['iguru_discourse_save'] ) ) {
            check_admin_referer( 'iguru_discourse_settings_action', 'iguru_discourse_nonce' );

            $submitted_url = isset( $_POST['forum_url'] ) ? esc_url_raw( trim( wp_unslash( $_POST['forum_url'] ) ), array( 'https' ) ) : '';
            $submitted_secret = isset( $_POST['webhook_secret'] ) ? trim( (string) wp_unslash( $_POST['webhook_secret'] ) ) : '';

            if ( '' !== $submitted_url ) {
                $parts = wp_parse_url( $submitted_url );
                $valid_url = is_array( $parts ) && isset( $parts['scheme'], $parts['host'] )
                    && 'https' === strtolower( $parts['scheme'] )
                    && ! isset( $parts['user'] )
                    && ! isset( $parts['pass'] )
                    && ! isset( $parts['query'] )
                    && ! isset( $parts['fragment'] );

                if ( ! $valid_url ) {
                    $message = '<div class="notice notice-error"><p>Εισήγαγε έγκυρο HTTPS URL του Discourse χωρίς username, password, query ή fragment.</p></div>';
                } else {
                    update_option( self::OPTION_URL, untrailingslashit( $submitted_url ), false );
                }
            }

            if ( '' !== $message ) {
                // Do not save invalid URL or secret if URL validation failed.
            } else {
                if ( '' !== $submitted_secret ) {
                    if ( strlen( $submitted_secret ) > 512 ) {
                        $message = '<div class="notice notice-error"><p>Το webhook secret είναι υπερβολικά μεγάλο.</p></div>';
                    } else {
                        update_option( self::OPTION_SECRET, sanitize_text_field( $submitted_secret ), false );
                    }
                }
                if ( '' === $message ) {
                    $message = '<div class="notice notice-success"><p>Οι ρυθμίσεις αποθηκεύτηκαν.</p></div>';
                }
            }
        }

        $forum_url = get_option( self::OPTION_URL, '' );
        $webhook_url = rest_url( 'iguru/v1/discourse-webhook' );
        ?>
        <div class="wrap">
            <h1>iGuRu Discourse Bridge</h1>
            <?php echo wp_kses_post( $message ); ?>
            <p>Το secret δεν εμφανίζεται ξανά μετά την αποθήκευση. Άφησε το πεδίο κενό για να διατηρηθεί το υπάρχον secret.</p>
            <form method="post">
                <?php wp_nonce_field( 'iguru_discourse_settings_action', 'iguru_discourse_nonce' ); ?>
                <table class="form-table" role="presentation">
                    <tr>
                        <th scope="row"><label for="forum_url">Discourse Forum URL</label></th>
                        <td>
                            <input name="forum_url" type="url" id="forum_url" value="<?php echo esc_attr( $forum_url ); ?>" class="regular-text" placeholder="https://forum.example.com" required>
                            <p class="description">Χρησιμοποίησε HTTPS και το βασικό URL του forum, χωρίς τελικό κάθετο.</p>
                        </td>
                    </tr>
                    <tr>
                        <th scope="row"><label for="webhook_secret">Webhook Secret</label></th>
                        <td>
                            <input name="webhook_secret" type="password" id="webhook_secret" value="" class="regular-text" autocomplete="new-password">
                            <p class="description">Το ίδιο secret που έχεις ορίσει στο Discourse Admin → API → Webhooks. Άφησέ το κενό για να μη μεταβληθεί.</p>
                        </td>
                    </tr>
                    <tr>
                        <th scope="row">Webhook Payload URL</th>
                        <td>
                            <code><?php echo esc_html( $webhook_url ); ?></code>
                            <p class="description">Βάλε αυτή τη διεύθυνση στο Payload URL του Discourse και ενεργοποίησε τα σχετικά Post events.</p>
                        </td>
                    </tr>
                    <tr>
                        <th scope="row">Σύνδεση άρθρου με topic</th>
                        <td>
                            <p>Για κάθε άρθρο, πρόσθεσε custom field με όνομα <code><?php echo esc_html( self::META_TOPIC ); ?></code> και τιμή το αριθμητικό Topic ID του Discourse.</p>
                        </td>
                    </tr>
                </table>
                <p><button type="submit" name="iguru_discourse_save" value="1" class="button button-primary">Αποθήκευση ρυθμίσεων</button></p>
            </form>
        </div>
        <?php
    }
}

IGURU_Discourse_Bridge::init();

Bonus: Τελευταία θέματα του forum στο sidebar

Για να το υλοποιήσετε, προσθέστε τον παρακάτω κώδικα στο αρχείο functions.php του θέματός σας (ή απευθείας μέσα στο custom plugin που δημιουργήσαμε παραπάνω). Ο κώδικας αποθηκεύει τα θέματα στην cache (Transient) για 10 λεπτά, ώστε το site σας να παραμένει ταχύτατο χωρίς να κάνει συνεχόμενες κλήσεις στο forum:

Bash
/**
 * iGuRu - Latest Discourse Topics
 * Shortcode: [discourse_latest_topics limit="5"]
 */

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

/**
 * Ανάκτηση τελευταίων θεμάτων από το Discourse.
 */
function iguru_get_latest_discourse_topics() {
    $discourse_url = 'https://yourdomain.com'; // Βάλε το URL σου εδώ.
    $discourse_url = untrailingslashit( $discourse_url );

    // Επιτρέπουμε μόνο HTTPS URL με έγκυρο host.
    $parts = wp_parse_url( $discourse_url );

    if (
        ! is_array( $parts ) ||
        empty( $parts['scheme'] ) ||
        'https' !== strtolower( $parts['scheme'] ) ||
        empty( $parts['host'] ) ||
        isset( $parts['user'] ) ||
        isset( $parts['pass'] ) ||
        isset( $parts['query'] ) ||
        isset( $parts['fragment'] )
    ) {
        return [];
    }

    $cache_key = 'iguru_discourse_latest_' . md5( $discourse_url );
    $topics    = get_transient( $cache_key );

    if ( false !== $topics && is_array( $topics ) ) {
        return $topics;
    }

    $response = wp_safe_remote_get(
        $discourse_url . '/latest.json',
        [
            'timeout'             => 8,
            'redirection'         => 0,
            'limit_response_size' => 1024 * 1024,
            'headers'             => [
                'Accept' => 'application/json',
            ],
        ]
    );

    if ( is_wp_error( $response ) ) {
        return [];
    }

    if ( 200 !== (int) wp_remote_retrieve_response_code( $response ) ) {
        return [];
    }

    $data = json_decode(
        wp_remote_retrieve_body( $response ),
        true
    );

    if (
        JSON_ERROR_NONE !== json_last_error() ||
        ! is_array( $data ) ||
        ! isset( $data['topic_list']['topics'] ) ||
        ! is_array( $data['topic_list']['topics'] )
    ) {
        return [];
    }

    $topics = [];

    foreach ( $data['topic_list']['topics'] as $topic ) {
        if (
            ! is_array( $topic ) ||
            empty( $topic['id'] ) ||
            ! isset( $topic['title'] ) ||
            ! is_scalar( $topic['title'] )
        ) {
            continue;
        }

        $id = absint( $topic['id'] );

        if ( ! $id ) {
            continue;
        }

        $topics[] = [
            'id'     => $id,
            'title'  => sanitize_text_field( $topic['title'] ),
            'slug'   => isset( $topic['slug'] ) && is_scalar( $topic['slug'] )
                ? sanitize_title( $topic['slug'] )
                : '',
            'pinned' => ! empty( $topic['pinned'] ),
        ];
    }

    // Cache για 10 λεπτά, ακόμη και αν δεν υπάρχουν θέματα.
    set_transient(
        $cache_key,
        $topics,
        10 * MINUTE_IN_SECONDS
    );

    return $topics;
}

/**
 * Shortcode: [discourse_latest_topics limit="5"]
 */
function iguru_discourse_latest_topics_shortcode( $atts ) {
    $atts = shortcode_atts(
        [
            'limit' => 5,
        ],
        $atts,
        'discourse_latest_topics'
    );

    $limit = absint( $atts['limit'] );
    $limit = max( 1, min( 20, $limit ) );

    $topics = iguru_get_latest_discourse_topics();

    if ( empty( $topics ) ) {
        return '<p>Δεν βρέθηκαν πρόσφατες συζητήσεις.</p>';
    }

    $discourse_url = untrailingslashit(
        'https://yourdomain.com' // Ίδιο URL με τη συνάρτηση παραπάνω.
    );

    $output = '<ul class="discourse-latest-topics">';
    $count  = 0;

    foreach ( $topics as $topic ) {
        if ( ! empty( $topic['pinned'] ) ) {
            continue;
        }

        $topic_url = $discourse_url . '/t/'
            . rawurlencode( $topic['slug'] ) . '/'
            . absint( $topic['id'] );

        $output .= sprintf(
            '<li><a href="%1$s" target="_blank" rel="noopener">%2$s</a></li>',
            esc_url( $topic_url ),
            esc_html( $topic['title'] )
        );

        $count++;

        if ( $count >= $limit ) {
            break;
        }
    }

    $output .= '</ul>';

    if ( 0 === $count ) {
        return '<p>Δεν βρέθηκαν πρόσφατες συζητήσεις.</p>';
    }

    return $output;
}

add_shortcode(
    'discourse_latest_topics',
    'iguru_discourse_latest_topics_shortcode'
);

Πώς θα χρησιμοποιήσεις τον κώδικα

1. Αντικατάστησε και στις δύο θέσεις το https://yourdomain.com με το πραγματικό URL του forum σου.
2. Πρόσθεσε τον κώδικα σε ένα μικρό custom plugin ή στο functions.php του child theme σου.
3. Βάλε το shortcode σε σελίδα, άρθρο ή συμβατό widget:

Πώς να το εμφανίσετε στο WordPress:

• Με Shortcode: Προσθέστε το block Shortcode στο sidebar ή στο widget area σας και γράψτε [discourse_latest_topics limit=”5″].
• Μέσω κώδικα (PHP): Αν επεξεργάζεστε απευθείας τα αρχεία του θέματός σας (π.χ. sidebar.php), μπορείτε να το καλέσετε με:

PHP
<?php echo do_shortcode('[discourse_latest_topics limit="5"]'); ?>.

Έτσι, οι αναγνώστες σας βλέπουν live την κινητικότητα του forum χωρίς να φορτώνεται κανένα εξωτερικό script στον browser τους, διατηρώντας το 100/100 στο Google PageSpeed Insights!

24 λεπτά ανάγνωσης

0 σχόλια

iGuRu.gr
Μέλος του forum; Συμμετέχετε στη συζήτηση στο forum. Συζήτηση στο forum →

Συμμετέχετε στη συζήτηση

Η ηλ. διεύθυνση σας δεν δημοσιεύεται. Τα υποχρεωτικά πεδία σημειώνονται με *

Το μήνυμα σας δεν θα δημοσιευτεί εάν:

  1. Περιέχει υβριστικά, συκοφαντικά, ρατσιστικά, προσβλητικά ή ανάρμοστα σχόλια.
  2. Προκαλεί βλάβη σε ανηλίκους.
  3. Παρενοχλεί την ιδιωτική ζωή και τα ατομικά και κοινωνικά δικαιώματα άλλων χρηστών.
  4. Διαφημίζει προϊόντα ή υπηρεσίες ή διαδικτυακούς τόπους.
  5. Περιέχει προσωπικές πληροφορίες (διεύθυνση, τηλέφωνο κλπ).