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;
}