Κάθε νέο άρθρο γίνεται αυτόματα συζήτηση στο forum, και οι απαντήσεις των μελών εμφανίζονται κάτω από το άρθρο, μαζί με τα σχόλια των επισκεπτών. Χωρίς να “δέσουμε” τα δύο συστήματα μεταξύ τους.
Στο προηγούμενο άρθρο εγκαταστήσαμε το Discourse σε server με Plesk, σε ξεχωριστό container. Τώρα το συνδέουμε με το WordPress. Πριν από τα βήματα, αξίζει να ξεκαθαρίσουμε έναν κανόνα, γιατί από αυτόν εξαρτάται η ασφάλεια όλου του συστήματος.
Ο κανόνας: σύνδεση ναι, κοινά «κλειδιά» όχι
Όσο λιγότερα δικαιώματα έχει το ένα σύστημα πάνω στο άλλο, τόσο μικρότερη η ζημιά αν παραβιαστεί κάποιο από τα δύο. Γι’ αυτό:
- Χωρίς κοινούς λογαριασμούς (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:
cd /var/discourse && ./launcher enter apprails 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'exitΒήμα 2: Embedding στο Discourse
Στο Admin → Customize → Embedding:
- Add host: το domain του site σας (π.χ. example.gr) και η κατηγορία όπου θα μπαίνουν τα θέματα των άρθρων.
- Username for topic creation: ο χρήστης του βήματος 1.
- Ενεργοποιήστε το Truncate the embedded posts: το θέμα δείχνει μόνο μια σύντομη περίληψη με σύνδεσμο προς το άρθρο, αποφεύγοντας το διπλό περιεχόμενο για το Google.
- Πείτε στο 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, ο έλεγχος είναι μία γραμμή:
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
/**
* Plugin Name: iGuRu Discourse Bridge
* Description: Displays Discourse topic replies in WordPress and purges relevant caches via signed Discourse webhooks.
* Version: 1.2.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.2.0';
const OPTION_URL = 'iguru_discourse_forum_url';
const OPTION_SECRET = 'iguru_discourse_webhook_secret';
// Visible custom field (no leading "_"), also used by the official WP Discourse plugin.
const META_TOPIC = 'discourse_topic_id';
const META_LEGACY = '_discourse_topic_id';
const CACHE_PREFIX = 'discourse_comments_';
const CACHE_TTL = 15 * MINUTE_IN_SECONDS;
const FAIL_TTL = 2 * 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', // Authenticated by the HMAC signature.
)
);
}
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 );
}
// The topic ID of an article: the visible custom field, or the older hidden one.
private static function topic_id( $post_id ) {
$id = absint( get_post_meta( $post_id, self::META_TOPIC, true ) );
return $id ? $id : absint( get_post_meta( $post_id, self::META_LEGACY, true ) );
}
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 include post.topic_id.
$topic_id = isset( $data['post']['topic_id'] ) ? absint( $data['post']['topic_id'] ) : 0;
if ( ! $topic_id ) {
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,
'update_post_term_cache' => false,
'meta_query' => array(
'relation' => 'OR',
array( 'key' => self::META_TOPIC, 'value' => (string) $topic_id ),
array( 'key' => self::META_LEGACY, 'value' => (string) $topic_id ),
),
)
);
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 ) );
$url = get_permalink( $post_id );
if ( function_exists( 'rocket_clean_post' ) ) {
rocket_clean_post( $post_id );
}
// FlyingPress: purge the article's address, with whichever method the installed version has.
if ( class_exists( '\FlyingPress\Purge' ) ) {
if ( is_callable( array( '\FlyingPress\Purge', 'purge_urls' ) ) ) {
\FlyingPress\Purge::purge_urls( array( $url ) );
} elseif ( is_callable( array( '\FlyingPress\Purge', 'purge_url' ) ) ) {
\FlyingPress\Purge::purge_url( $url );
}
}
if ( defined( 'LSCWP_V' ) ) {
do_action( 'litespeed_purge_post', $post_id );
}
/**
* For a CDN/proxy cache (e.g. Cloudflare APO), hook here and purge $url,
* otherwise the CDN keeps serving the old page until its cache expires.
*/
do_action( 'iguru_discourse_bridge_purge_post', $post_id, $url );
}
private static function request( $url ) {
return wp_safe_remote_get(
$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( '/' ),
)
);
}
private static function get_comments( $post_id ) {
$topic_id = self::topic_id( $post_id );
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 ( is_array( $cached ) ) {
return $cached;
}
if ( 'failed' === $cached ) {
return new WP_Error( 'iguru_recent_failure', 'The forum could not be read a moment ago; retrying shortly.' );
}
$base = self::forum_url();
if ( '' === $base ) {
return new WP_Error( 'iguru_bad_forum_url', 'The Discourse forum URL is missing or invalid.' );
}
$response = self::request( $base . '/t/' . $topic_id . '.json' );
if ( is_wp_error( $response ) || 200 !== (int) wp_remote_retrieve_response_code( $response ) ) {
// Remember the failure briefly, so visitors don't each wait for a timeout while the forum is down.
set_transient( self::CACHE_PREFIX . $post_id, 'failed', self::FAIL_TTL );
return new WP_Error( 'iguru_http_error', 'Could not read the topic from the Discourse forum.' );
}
$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'] ) ) {
set_transient( self::CACHE_PREFIX . $post_id, 'failed', self::FAIL_TTL );
return new WP_Error( 'iguru_bad_json', 'The Discourse response was not valid topic JSON.' );
}
$stream = $data['post_stream'];
$all = array();
$seen = array();
$add = function ( $posts ) use ( &$all, &$seen ) {
foreach ( (array) $posts as $post ) {
if ( is_array( $post ) && ! empty( $post['id'] ) ) {
$id = absint( $post['id'] );
if ( $id && ! isset( $seen[ $id ] ) ) {
$seen[ $id ] = true;
$all[] = $post;
}
}
}
};
$add( isset( $stream['posts'] ) ? $stream['posts'] : array() );
// Long topics: fetch the remaining posts in batches of 20.
$post_ids = isset( $stream['stream'] ) && is_array( $stream['stream'] ) ? array_values( array_filter( array_map( 'absint', $stream['stream'] ) ) ) : array();
$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 );
}
$batch = self::request( $base . '/t/' . $topic_id . '/posts.json?' . implode( '&', $query ) );
if ( is_wp_error( $batch ) || 200 !== (int) wp_remote_retrieve_response_code( $batch ) ) {
continue; // Keep what we have; the next refresh retries.
}
$batch_data = json_decode( wp_remote_retrieve_body( $batch ), true );
$add( isset( $batch_data['post_stream']['posts'] ) ? $batch_data['post_stream']['posts'] : ( isset( $batch_data['posts'] ) ? $batch_data['posts'] : array() ) );
}
// Only real, visible replies: not the topic itself (post 1), moderator notes, hidden or deleted posts.
$all = array_values( array_filter( $all, function ( $post ) {
return ( ! isset( $post['post_number'] ) || 1 !== absint( $post['post_number'] ) )
&& ( ! isset( $post['post_type'] ) || 1 === absint( $post['post_type'] ) )
&& empty( $post['hidden'] )
&& empty( $post['deleted_at'] );
} ) );
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;
} );
set_transient( self::CACHE_PREFIX . $post_id, $all, self::CACHE_TTL );
return $all;
}
// Forum HTML: relative links/images point to the forum; then WordPress' post filter; links get rel="nofollow ugc".
private static function clean_html( $html ) {
$base = self::forum_url();
$html = preg_replace( '#(src|href)=(["\'])/(?!/)#i', '$1=$2' . $base . '/', (string) $html );
return wp_rel_ugc( wp_kses_post( $html ) );
}
public static function shortcode( $atts = array() ) {
if ( ! is_singular() ) {
return '';
}
$post_id = get_the_ID();
return $post_id ? 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 || ! self::topic_id( $post_id ) || 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 ) ) {
// Show the reason only to administrators.
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;
$pages = max( 1, (int) ceil( $total / self::PAGE_SIZE ) );
$page = min( max( 1, $page ), $pages );
$items = array_slice( $comments, ( $page - 1 ) * self::PAGE_SIZE, self::PAGE_SIZE );
$fmt = get_option( 'date_format' ) . ' ' . get_option( 'time_format' );
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';
$time = isset( $comment['created_at'] ) ? strtotime( (string) $comment['created_at'] ) : false;
$body = isset( $comment['cooked'] ) && is_string( $comment['cooked'] ) ? self::clean_html( $comment['cooked'] ) : '';
?>
<li class="iguru-discourse-comment">
<div class="iguru-discourse-comment-meta">
<strong><?php echo esc_html( $username ); ?></strong>
<?php if ( $time ) : ?>
<time datetime="<?php echo esc_attr( gmdate( 'c', $time ) ); ?>"><?php echo esc_html( wp_date( $fmt, $time ) ); ?></time>
<?php endif; ?>
</div>
<div class="iguru-discourse-comment-body"><?php echo $body; // Sanitized in clean_html(). ?></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++ ) : ?>
<a href="<?php echo esc_url( add_query_arg( 'iguru_discourse_page', $i, get_permalink( $post_id ) ) ); ?>" <?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-comment-body img{max-width:100%;height:auto}
.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' ) ) : '';
// The secret is only used for the HMAC and never printed, so it is stored exactly as typed
// (sanitize_text_field() could change some characters and break every signature).
$submitted_secret = isset( $_POST['webhook_secret'] ) ? trim( (string) wp_unslash( $_POST['webhook_secret'] ) ) : '';
$parts = '' !== $submitted_url ? wp_parse_url( $submitted_url ) : null;
$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 ( '' !== $submitted_url && ! $valid_url ) {
$message = '<div class="notice notice-error"><p>Εισάγετε έγκυρο HTTPS URL του forum, χωρίς username, password, query ή fragment.</p></div>';
} elseif ( '' !== $submitted_secret && ( strlen( $submitted_secret ) < 16 || strlen( $submitted_secret ) > 512 || preg_match( '/[^\x21-\x7E]/', $submitted_secret ) ) ) {
$message = '<div class="notice notice-error"><p>Το secret πρέπει να έχει 16 έως 512 λατινικούς χαρακτήρες, χωρίς κενά.</p></div>';
} else {
if ( '' !== $submitted_url ) {
update_option( self::OPTION_URL, untrailingslashit( $submitted_url ), false );
}
if ( '' !== $submitted_secret ) {
update_option( self::OPTION_SECRET, $submitted_secret, false );
}
$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 δεν εμφανίζεται ξανά μετά την αποθήκευση. Αφήστε το πεδίο κενό για να διατηρηθεί το υπάρχον.</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">Η διεύθυνση του forum, με HTTPS και χωρίς τελική κάθετο.</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 του webhook στο Discourse, με το Post Event.</p>
</td>
</tr>
<tr>
<th scope="row">Σύνδεση άρθρου με topic</th>
<td>
<p>Σε κάθε άρθρο, στο πλαίσιο «Προσαρμοσμένα πεδία», προσθέστε το πεδίο <code><?php echo esc_html( self::META_TOPIC ); ?></code> με τιμή τον αριθμό του θέματος στο Discourse (π.χ. 15 για το /t/thema/15).</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:
<?php
/**
* iGuRu - Latest Discourse Topics
* Shortcode: [discourse_latest_topics limit="5"]
* Add to a small custom plugin or to your child theme's functions.php (without the opening <?php line there).
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
// The forum address, in one place (or define IGURU_DISCOURSE_URL in wp-config.php).
if ( ! defined( 'IGURU_DISCOURSE_URL' ) ) {
define( 'IGURU_DISCOURSE_URL', 'https://forum.example.com' ); // Βάλτε εδώ τη διεύθυνση του forum σας.
}
/**
* The forum's base URL, only if it is a clean HTTPS address.
*/
function iguru_discourse_base_url() {
$url = untrailingslashit( trim( (string) IGURU_DISCOURSE_URL ) );
$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 $url;
}
/**
* The latest topics from the forum, cached for 10 minutes (2 minutes after a failure).
*/
function iguru_get_latest_discourse_topics() {
$base = iguru_discourse_base_url();
if ( '' === $base ) {
return array();
}
$cache_key = 'iguru_discourse_latest_' . md5( $base );
$topics = get_transient( $cache_key );
if ( is_array( $topics ) ) {
return $topics;
}
$response = wp_safe_remote_get(
$base . '/latest.json',
array(
'timeout' => 8,
'redirection' => 0,
'limit_response_size' => 1024 * 1024,
'headers' => array( 'Accept' => 'application/json' ),
)
);
$data = ( ! is_wp_error( $response ) && 200 === (int) wp_remote_retrieve_response_code( $response ) )
? json_decode( wp_remote_retrieve_body( $response ), true )
: null;
if ( ! is_array( $data ) || ! isset( $data['topic_list']['topics'] ) || ! is_array( $data['topic_list']['topics'] ) ) {
// Remember the failure briefly, so pages don't wait for the forum on every visit.
set_transient( $cache_key, array(), 2 * MINUTE_IN_SECONDS );
return array();
}
$topics = array();
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;
}
$slug = isset( $topic['slug'] ) && is_scalar( $topic['slug'] ) ? sanitize_title( $topic['slug'] ) : '';
$topics[] = array(
'id' => $id,
'title' => sanitize_text_field( $topic['title'] ),
'slug' => '' !== $slug ? $slug : 'topic', // Discourse accepts any slug; never an empty one (/t//123).
'pinned' => ! empty( $topic['pinned'] ),
);
}
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( array( 'limit' => 5 ), $atts, 'discourse_latest_topics' );
$limit = max( 1, min( 20, absint( $atts['limit'] ) ) );
$base = iguru_discourse_base_url();
$items = array();
foreach ( iguru_get_latest_discourse_topics() as $topic ) {
if ( ! empty( $topic['pinned'] ) ) {
continue;
}
$items[] = sprintf(
'<li><a href="%1$s" target="_blank" rel="noopener">%2$s</a></li>',
esc_url( $base . '/t/' . rawurlencode( $topic['slug'] ) . '/' . absint( $topic['id'] ) ),
esc_html( $topic['title'] )
);
if ( count( $items ) >= $limit ) {
break;
}
}
if ( ! $items ) {
return '<p class="discourse-latest-empty">Δεν βρέθηκαν πρόσφατες συζητήσεις.</p>';
}
return '<ul class="discourse-latest-topics">' . implode( '', $items ) . '</ul>';
}
add_shortcode( 'discourse_latest_topics', 'iguru_discourse_latest_topics_shortcode' );Πώς θα χρησιμοποιήσεις τον κώδικα
1. Αλλάξτε μόνο τη γραμμή με το https://forum.example.com στη διεύθυνση του 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 echo do_shortcode('[discourse_latest_topics limit="5"]'); ?>.Έτσι, οι αναγνώστες σας βλέπουν live την κινητικότητα του forum χωρίς να φορτώνεται κανένα εξωτερικό script στον browser τους, διατηρώντας το 100/100 στο Google PageSpeed Insights!





0 σχόλια
iGuRu.gr