HEX
Server: Apache/2.4.68 (Debian)
System: Linux as-cs-widget-demo-us-central1 6.1.0-44-cloud-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.1.164-1 (2026-03-09) x86_64
User: root (0)
PHP: 8.2.32
Disabled: NONE
Upload Files
File: /var/www/kevin-demo/wp-content/plugins/allspice/includes/export.php
<?php
// includes/export.php
//
// WordPress Direct: read-only REST routes the Allspice crawler pulls from. Auth reuses
// the domain's webhook token (settings option webhook_token) presented back as a bearer,
// so the existing pairing doubles as access control. No writes, no side effects.
//
//     GET /wp-json/allspice/v1/comments?post_url=...&limit=20
//     GET /wp-json/allspice/v1/posts?after_id=0&per_page=200
//     Authorization: Bearer {webhook_token}
//
// Comment selection happens here rather than crawler-side because WordPress can see
// users, capabilities and comment threading cheaply:
//  - approved comments only, no pingbacks/trackbacks
//  - whole threads: replies stay with their root comment
//  - threads the site itself replied to rank first, then newest
//  - capped at `limit` (default 20, max 1000)
//  - only display name, text and timestamps go out; never emails or IPs

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

const ALLSPICE_EXPORT_COMMENTS_DEFAULT_LIMIT = 20;
/* Raised from 100 for the content-indexer's advanced-processing pulls (2026-08-21; they ask
   for 2000, Brandon capped at 1000 - the response echoes the effective limit so the client
   sees the clamp). The cap only bounds the RESPONSE: selection already loads and ranks every
   approved comment on the post regardless of limit, so a higher cap costs payload bytes,
   not server work. */
const ALLSPICE_EXPORT_COMMENTS_MAX_LIMIT = 1000;
const ALLSPICE_EXPORT_POSTS_DEFAULT_PER_PAGE = 200;
const ALLSPICE_EXPORT_POSTS_MAX_PER_PAGE = 500;

add_action('rest_api_init', 'allspice_export_register_routes');
function allspice_export_register_routes(): void {
    register_rest_route('allspice/v1', '/comments', [
        'methods' => 'GET',
        'callback' => 'allspice_export_comments',
        'permission_callback' => 'allspice_export_permission',
        'args' => [
            'post_url' => ['type' => 'string', 'required' => false],
            'post_id' => ['type' => 'integer', 'required' => false],
            'limit' => ['type' => 'integer', 'required' => false],
        ],
    ]);
    register_rest_route('allspice/v1', '/posts', [
        'methods' => 'GET',
        'callback' => 'allspice_export_posts',
        'permission_callback' => 'allspice_export_permission',
        'args' => [
            'after_id' => ['type' => 'integer', 'required' => false],
            'per_page' => ['type' => 'integer', 'required' => false],
            'modified_after' => ['type' => 'string', 'required' => false],
            'post_url' => ['type' => 'string', 'required' => false],
            'post_id' => ['type' => 'integer', 'required' => false],
        ],
    ]);
}

/*
 * Bearer token must match the paired webhook token, compared constant-time. An unpaired
 * site (empty token) exports nothing - there is no anonymous mode.
 */
function allspice_export_permission($request) {
    $s = allspice_opt_get();
    $expected = trim((string)($s['webhook_token'] ?? ''));
    if ($expected === '') return false;

    $header = trim((string)$request->get_header('authorization'));
    if (stripos($header, 'bearer ') === 0) {
        $presented = trim(substr($header, 7));
    } else {
        $presented = trim((string)$request->get_param('token'));
    }
    if ($presented === '') return false;
    return hash_equals($expected, $presented);
}

function allspice_export_comments($request) {
    $post_id = (int)$request->get_param('post_id');
    if (!$post_id) {
        $post_url = trim((string)$request->get_param('post_url'));
        if ($post_url === '') {
            return new WP_Error('allspice_missing_post', 'Provide post_id or post_url.', ['status' => 400]);
        }
        $post_id = url_to_postid($post_url);
        if (!$post_id) {
            return new WP_Error('allspice_unknown_post', 'No post found for that URL.', ['status' => 404]);
        }
    }
    $post = get_post($post_id);
    if (!$post) {
        return new WP_Error('allspice_unknown_post', 'No post found for that id.', ['status' => 404]);
    }

    $limit = (int)$request->get_param('limit');
    if ($limit < 1) $limit = ALLSPICE_EXPORT_COMMENTS_DEFAULT_LIMIT;
    if ($limit > ALLSPICE_EXPORT_COMMENTS_MAX_LIMIT) $limit = ALLSPICE_EXPORT_COMMENTS_MAX_LIMIT;

    $selected = allspice_export_select_comments($post, $limit);

    return rest_ensure_response([
        'postId' => (int)$post->ID,
        'postUrl' => get_permalink($post),
        'totalApproved' => (int)get_comments_number($post),
        'limit' => $limit,
        'comments' => $selected,
    ]);
}

/*
 * Threads for a post, ranked, flattened, capped. Separated from the route handler so the
 * offline tests can drive it with stubbed comment lists.
 */
function allspice_export_select_comments($post, int $limit): array {
    $raw = get_comments([
        'post_id' => (int)$post->ID,
        'status' => 'approve',
        'type' => 'comment',      /* excludes pingbacks/trackbacks */
        'orderby' => 'comment_date_gmt',
        'order' => 'ASC',
    ]);

    /* Build threads: every comment hangs under its ROOT ancestor, so a reply-to-a-reply
       still travels with the conversation it belongs to. */
    $by_id = [];
    foreach ($raw as $c) {
        $by_id[(int)$c->comment_ID] = $c;
    }
    $root_of = function ($c) use ($by_id) {
        $seen = [];
        while ((int)$c->comment_parent && isset($by_id[(int)$c->comment_parent])) {
            $cid = (int)$c->comment_ID;
            if (isset($seen[$cid])) break; /* defensive: broken parent cycles */
            $seen[$cid] = true;
            $c = $by_id[(int)$c->comment_parent];
        }
        return (int)$c->comment_ID;
    };

    $threads = []; /* root id => ['comments' => [...], 'has_site_reply' => bool, 'latest' => gmt] */
    foreach ($raw as $c) {
        $root = $root_of($c);
        if (!isset($threads[$root])) {
            $threads[$root] = ['comments' => [], 'has_site_reply' => false, 'latest' => ''];
        }
        $is_site = allspice_export_is_site_author($c, $post);
        $threads[$root]['comments'][] = allspice_export_serialize_comment($c, $is_site);
        if ($is_site) $threads[$root]['has_site_reply'] = true;
        if ($c->comment_date_gmt > $threads[$root]['latest']) {
            $threads[$root]['latest'] = $c->comment_date_gmt;
        }
    }

    /* Site-answered threads first; within each group, most recent conversation first. */
    $ordered = array_values($threads);
    usort($ordered, function ($a, $b) {
        if ($a['has_site_reply'] !== $b['has_site_reply']) {
            return $a['has_site_reply'] ? -1 : 1;
        }
        return strcmp($b['latest'], $a['latest']);
    });

    /* Whole threads until the cap; a thread that would burst the cap is trimmed from its
       tail (replies go before the root does - the root holds the question). */
    $out = [];
    foreach ($ordered as $thread) {
        $room = $limit - count($out);
        if ($room <= 0) break;
        $chunk = array_slice($thread['comments'], 0, $room);
        foreach ($chunk as $c) $out[] = $c;
    }
    return $out;
}

/*
 * "The site talking": the comment belongs to the post's author account, or to any
 * logged-in user who can edit posts. Computed here because it needs WP user data an
 * outside crawler can't see.
 */
function allspice_export_is_site_author($comment, $post): bool {
    $user_id = (int)$comment->user_id;
    if (!$user_id) return false;
    if ($user_id === (int)$post->post_author) return true;
    return user_can($user_id, 'edit_posts');
}

/* Display name, text and shape only. Never email, never IP, never user ids. */
function allspice_export_serialize_comment($comment, bool $is_site_author): array {
    return [
        'id' => (int)$comment->comment_ID,
        'parentId' => (int)$comment->comment_parent ?: null,
        'author' => trim((string)$comment->comment_author),
        'isSiteAuthor' => $is_site_author,
        'date' => (string)$comment->comment_date_gmt,
        'text' => trim(wp_strip_all_tags((string)$comment->comment_content)),
    ];
}

/* ---------------------------------------------------------------- posts enumeration
 *
 * Sitemap replacement for the crawler: lists this site's published posts with metadata
 * a sitemap can't carry (noindex, exact modified time, post type). Scraping/parsing on
 * the crawler side is unchanged.
 *
 * Only published, public, password-free content is listed (the crawler fetches the
 * public page, so anything unreachable would just waste scrapes). Pagination is keyset
 * by ID ascending; `nextAfterId` is null on the last page. `noindex` is read from
 * Yoast / Rank Math / SEOPress postmeta plus their type-level defaults; AIOSEO v4 keeps
 * robots flags in its own tables, so those sites report indexable. Good enough: the
 * flag is advisory metadata, not an access gate.
 */

function allspice_export_posts($request) {
    /* Single-post lookup (post_url or post_id): the crawler's webhook single-URL path
       asks for one post's robots state when an update ping arrives, so a noindex flip
       is seen immediately instead of waiting for the next full enumeration. Same
       response shape as a one-entry page. */
    $single_id = (int)$request->get_param('post_id');
    $single_url = trim((string)$request->get_param('post_url'));
    if ($single_id || $single_url !== '') {
        if (!$single_id) {
            $single_id = url_to_postid($single_url);
        }
        $post = $single_id ? get_post($single_id) : null;
        if (!$post || $post->post_status !== 'publish' || (string)$post->post_password !== '') {
            return new WP_Error('allspice_unknown_post', 'No published post found for that lookup.', ['status' => 404]);
        }
        return rest_ensure_response([
            'posts' => [allspice_export_serialize_post($post)],
            'perPage' => 1,
            'nextAfterId' => null,
        ]);
    }

    $after_id = max(0, (int)$request->get_param('after_id'));
    $per_page = (int)$request->get_param('per_page');
    if ($per_page < 1) $per_page = ALLSPICE_EXPORT_POSTS_DEFAULT_PER_PAGE;
    if ($per_page > ALLSPICE_EXPORT_POSTS_MAX_PER_PAGE) $per_page = ALLSPICE_EXPORT_POSTS_MAX_PER_PAGE;
    $modified_after = trim((string)$request->get_param('modified_after'));

    $posts = allspice_export_query_posts($after_id, $per_page, $modified_after);

    $items = [];
    $last_id = 0;
    foreach ($posts as $p) {
        $items[] = allspice_export_serialize_post($p);
        if ((int)$p->ID > $last_id) $last_id = (int)$p->ID;
    }
    return rest_ensure_response([
        'posts' => $items,
        'perPage' => $per_page,
        /* A full page means "maybe more"; the crawler follows until null. A short page
           is authoritative end-of-list. */
        'nextAfterId' => count($posts) === $per_page ? $last_id : null,
    ]);
}

function allspice_export_query_posts(int $after_id, int $per_page, string $modified_after): array {
    $types = array_values(array_diff((array)get_post_types(['public' => true]), ['attachment']));
    $args = [
        'post_type' => $types,
        'post_status' => 'publish',
        'has_password' => false,
        'orderby' => 'ID',
        'order' => 'ASC',
        'numberposts' => $per_page,
        /* get_posts defaults suppress_filters=true, which would skip our posts_where
           cursor below. Must stay false. */
        'suppress_filters' => false,
    ];
    if ($modified_after !== '') {
        $args['date_query'] = [[
            'column' => 'post_modified_gmt',
            'after' => $modified_after,
            'inclusive' => false,
        ]];
    }
    $where = allspice_export_posts_after_id_where($after_id);
    add_filter('posts_where', $where);
    $posts = get_posts($args);
    remove_filter('posts_where', $where);
    return is_array($posts) ? $posts : [];
}

/* Keyset cursor as a posts_where fragment. (int) cast is the sanitizer. */
function allspice_export_posts_after_id_where(int $after_id) {
    return function ($sql) use ($after_id) {
        global $wpdb;
        $table = (is_object($wpdb) && !empty($wpdb->posts)) ? $wpdb->posts : 'wp_posts';
        return $sql . " AND {$table}.ID > " . (int)$after_id;
    };
}

function allspice_export_serialize_post($post): array {
    return [
        'id' => (int)$post->ID,
        'url' => get_permalink($post),
        'type' => (string)$post->post_type,
        'modifiedGmt' => (string)$post->post_modified_gmt,
        'noindex' => allspice_export_post_noindex($post),
    ];
}

/*
 * Noindex resolution, mirroring how the SEO plugins themselves do it: per-post postmeta
 * first, then the plugin's type-level default from wp_options (a type noindexed in
 * Yoast settings leaves no postmeta trace but still drops out of the sitemap, so
 * skipping this layer over-reports indexable posts). Per-post overrides beat the type
 * default: Yoast meta '2' and Rank Math 'index' both mean "index this one anyway".
 */
function allspice_export_post_noindex($post): bool {
    $id = (int)$post->ID;
    $yoast = (string)get_post_meta($id, '_yoast_wpseo_meta-robots-noindex', true);
    if ($yoast === '1') return true;
    $rank_math = get_post_meta($id, 'rank_math_robots', true);
    if (is_array($rank_math)) {
        if (in_array('noindex', $rank_math, true)) return true;
        if (in_array('index', $rank_math, true)) return false; /* explicit per-post index */
    } elseif (is_string($rank_math) && $rank_math !== '' && strpos($rank_math, 'noindex') !== false) {
        return true;
    }
    if ((string)get_post_meta($id, '_seopress_robots_index', true) === 'yes') return true;
    if ($yoast === '2') return false; /* explicit per-post index beats the type default */
    return allspice_export_type_default_noindex((string)$post->post_type);
}

function allspice_export_type_default_noindex(string $type): bool {
    if ($type === '') return false;
    $yoast_titles = get_option('wpseo_titles');
    if (is_array($yoast_titles) && !empty($yoast_titles['noindex-' . $type])) return true;
    $rm = get_option('rank-math-options-titles');
    if (is_array($rm)) {
        $custom = $rm['pt_' . $type . '_custom_robots'] ?? null;
        $robots = $rm['pt_' . $type . '_robots'] ?? null;
        if (($custom === 'on' || $custom === true) && is_array($robots) && in_array('noindex', $robots, true)) {
            return true;
        }
    }
    return false;
}