'', 'secret' => '', 'auto' => 1, // auto-placement: publish immediately (on by default) 'category' => 0, 'author' => 0, 'label' => 'Partner content', 'label_ru' => 'Партнёрский материал', 'connected' => 0, ]; } public static function opt(?string $key = null) { $o = wp_parse_args((array) get_option(self::OPT, []), self::defaults()); return $key === null ? $o : ($o[$key] ?? null); } public static function activate(): void { if (!wp_next_scheduled('satelo_heartbeat')) { wp_schedule_event(time() + 300, 'daily', 'satelo_heartbeat'); } // Re-activation of an already connected site: tell Satelo it can receive articles again. if (self::opt('site_id') && self::opt('secret')) { self::call('handshake', self::status_payload()); } } public static function deactivate(): void { wp_clear_scheduled_hook('satelo_heartbeat'); // Tell Satelo the connector is gone: no new articles will be ordered for this site. self::call('revoke', ['reason' => 'plugin_deactivated']); } public static function action_links(array $links): array { array_unshift($links, '' . esc_html__('Settings', 'satelo') . ''); return $links; } public static function menu(): void { add_options_page('Satelo', 'Satelo', 'manage_options', 'satelo', [__CLASS__, 'page']); } public static function register_settings(): void { register_setting('satelo', self::OPT, [ 'type' => 'array', 'sanitize_callback' => [__CLASS__, 'sanitize'], ]); } public static function sanitize($in): array { $o = self::opt(); $in = (array) $in; // Connection key from the Satelo dashboard: "." if (!empty($in['key'])) { $parts = explode('.', trim((string) $in['key']), 2); if (count($parts) === 2 && preg_match('/^[a-z0-9_-]{4,64}$/i', $parts[0]) && strlen($parts[1]) >= 32) { $o['site_id'] = $parts[0]; $o['secret'] = $parts[1]; $o['connected'] = 0; } else { add_settings_error('satelo', 'key', __('The connection key looks wrong. Copy it again from your Satelo dashboard.', 'satelo')); } } $o['auto'] = empty($in['auto']) ? 0 : 1; $o['category'] = absint($in['category'] ?? 0); $o['author'] = absint($in['author'] ?? 0); $o['label'] = sanitize_text_field($in['label'] ?? $o['label']); $o['label_ru'] = sanitize_text_field($in['label_ru'] ?? $o['label_ru']); return $o; } /** One-click join: Satelo issues a key for this site, then the signed handshake proves ownership. */ public static function join(): void { if (!current_user_can('manage_options') || !check_admin_referer('satelo_join')) { wp_die(esc_html__('Not allowed.', 'satelo')); } $email = sanitize_email(wp_unslash($_POST['email'] ?? '')); $back = admin_url('options-general.php?page=satelo'); if (empty($_POST['consent']) || !is_email($email)) { wp_safe_redirect(add_query_arg('satelo', 'consent', $back)); exit; } $res = wp_remote_post(self::API . '/join', [ 'timeout' => 15, 'headers' => ['Content-Type' => 'application/json'], 'body' => wp_json_encode(['site_url' => home_url(), 'email' => $email, 'lang' => strpos(get_locale(), 'ru') === 0 ? 'ru' : 'en', 'consent' => true, 'plugin_version' => self::VERSION]), ]); $code = is_wp_error($res) ? 0 : wp_remote_retrieve_response_code($res); $j = $code ? json_decode(wp_remote_retrieve_body($res), true) : null; if ($code !== 200 || empty($j['key']) || strpos((string) $j['key'], '.') === false) { $err = is_array($j) && !empty($j['error']) ? sanitize_key($j['error']) : 'network'; wp_safe_redirect(add_query_arg('satelo', 'error_' . $err, $back)); exit; } [$sid, $secret] = explode('.', (string) $j['key'], 2); $o = self::opt(); $o['site_id'] = sanitize_key($sid); $o['secret'] = preg_replace('/[^a-f0-9]/i', '', $secret); $o['connected'] = 0; update_option(self::OPT, $o); if (self::call('handshake', self::status_payload()) === true) { $o['connected'] = 1; update_option(self::OPT, $o); self::heartbeat(); wp_safe_redirect(add_query_arg('satelo', 'joined', $back)); } else { wp_safe_redirect(add_query_arg('satelo', 'pending', $back)); } exit; } /** The owner published a Satelo article that arrived as a draft: tell Satelo so the order completes. */ public static function on_publish(string $new, string $old, $post): void { if ($new !== 'publish' || $old === 'publish' || !($post instanceof WP_Post)) { return; } $sid = (string) get_post_meta($post->ID, '_satelo_id', true); if ($sid === '') { return; } self::call('published', ['id' => $sid, 'url' => get_permalink($post)]); } public static function notice(): void { if (!current_user_can('manage_options') || self::opt('site_id') || (isset($_GET['page']) && sanitize_key(wp_unslash($_GET['page'])) === 'satelo')) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only: hides the notice on the settings page. return; } echo '

' . esc_html__('Satelo is installed. Join in one click to get articles about your site on similar sites.', 'satelo') . ' ' . esc_html__('Connect now', 'satelo') . '

'; } public static function page(): void { if (!current_user_can('manage_options')) { return; } $o = self::opt(); if (isset($_GET['settings-updated']) && $o['site_id'] && !$o['connected']) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only flag set by options.php after a nonce-checked save. $res = self::call('handshake', self::status_payload()); if ($res === true) { $o['connected'] = 1; update_option(self::OPT, $o); self::heartbeat(); } } ?>

Satelo

● () ● ● —

['success', __('Connected! Check your inbox for a sign-in link to your Satelo dashboard. The first article (about Satelo) earns you satellites.', 'satelo')], 'pending' => ['warning', __('Key received, but Satelo could not reach this site to confirm it yet. Make sure the REST API (/wp-json/) is publicly reachable, then save settings again.', 'satelo')], 'consent' => ['error', __('Please enter a valid email and accept the terms.', 'satelo')], 'error_taken' => ['error', __('This site is already connected to another Satelo account. Contact info@satelo.io.', 'satelo')], 'error_rate' => ['error', __('Too many attempts. Please try again later.', 'satelo')], 'error_domain' => ['error', __('Only a site on a public domain can connect (localhost and staging copies cannot). Check Settings → General → Site Address.', 'satelo')], ]; if ($flag !== '') { $m = $msgs[$flag] ?? ['error', __('Could not connect to Satelo. Please try again or paste a connection key below.', 'satelo')]; echo '

' . esc_html($m[1]) . '

'; } if (!$o['site_id']) : ?>

' . "\n"; } } /* ---------------- REST API (called by Satelo) ---------------- */ public static function routes(): void { $auth = [__CLASS__, 'authorize']; register_rest_route(self::NS, '/status', ['methods' => 'GET', 'callback' => [__CLASS__, 'rest_status'], 'permission_callback' => $auth]); register_rest_route(self::NS, '/publish', ['methods' => 'POST', 'callback' => [__CLASS__, 'rest_publish'], 'permission_callback' => $auth]); register_rest_route(self::NS, '/unpublish', ['methods' => 'POST', 'callback' => [__CLASS__, 'rest_unpublish'], 'permission_callback' => $auth]); // Public, cookie-less pageview counter (no IP or personal data stored). register_rest_route(self::NS, '/hit', ['methods' => 'POST', 'callback' => [__CLASS__, 'rest_hit'], 'permission_callback' => '__return_true']); } /** Every request is signed: HMAC-SHA256(secret, "...") */ public static function authorize(WP_REST_Request $req) { $secret = (string) self::opt('secret'); $ts = (int) $req->get_header('x_satelo_timestamp'); $sig = (string) $req->get_header('x_satelo_signature'); if ($secret === '' || !$ts || $sig === '') { return new WP_Error('satelo_unauthorized', 'Missing signature', ['status' => 401]); } if (abs(time() - $ts) > self::MAX_SKEW) { return new WP_Error('satelo_stale', 'Stale request', ['status' => 401]); } $body = (string) $req->get_body(); if (strlen($body) > self::MAX_BODY) { return new WP_Error('satelo_too_large', 'Payload too large', ['status' => 413]); } $expected = hash_hmac('sha256', $ts . '.' . $req->get_method() . '.' . $req->get_route() . '.' . $body, $secret); if (!hash_equals($expected, $sig)) { return new WP_Error('satelo_bad_signature', 'Bad signature', ['status' => 401]); } // Replay protection: a signed request is accepted only once within the allowed time window. $seen = 'satelo_sig_' . substr($sig, 0, 40); if (get_transient($seen)) { return new WP_Error('satelo_replay', 'Request already processed', ['status' => 409]); } set_transient($seen, 1, self::MAX_SKEW * 2); return true; } public static function status_payload(): array { $cats = []; foreach (get_categories(['hide_empty' => false]) as $c) { $cats[] = ['id' => (int) $c->term_id, 'name' => $c->name]; } return [ 'site_url' => home_url('/'), 'plugin_version' => self::VERSION, 'wp_version' => get_bloginfo('version'), 'language' => get_bloginfo('language'), 'auto' => (bool) self::opt('auto'), 'categories' => $cats, ]; } public static function rest_status(): WP_REST_Response { $o = self::opt(); if (!$o['connected']) { $o['connected'] = 1; update_option(self::OPT, $o); } return new WP_REST_Response(['ok' => true] + self::status_payload()); } public static function rest_publish(WP_REST_Request $req) { $a = (array) $req->get_json_params(); $sid = sanitize_text_field($a['id'] ?? ''); $title = sanitize_text_field($a['title'] ?? ''); $html = (string) ($a['html'] ?? ''); if ($sid === '' || $title === '' || $html === '') { return new WP_Error('satelo_invalid', 'id, title and html are required', ['status' => 422]); } require_once ABSPATH . 'wp-admin/includes/media.php'; require_once ABSPATH . 'wp-admin/includes/file.php'; require_once ABSPATH . 'wp-admin/includes/image.php'; $existing = self::find_post($sid); $o = self::opt(); $lang = (isset($a['lang']) && $a['lang'] === 'ru') ? 'ru' : 'en'; // Create or update the post first (images need a parent post ID). $postarr = [ 'post_title' => $title, 'post_content' => '', 'post_excerpt' => sanitize_text_field($a['excerpt'] ?? ''), 'post_type' => 'post', 'post_author' => $o['author'] ?: self::first_admin(), 'meta_input' => ['_satelo_id' => $sid], ]; $prev_status = $existing ? get_post_status($existing) : ''; if ($existing) { $postarr['ID'] = $existing; unset($postarr['post_title']); // title is updated together with content below } else { $postarr['post_status'] = 'draft'; } $post_id = wp_insert_post($postarr, true); if (is_wp_error($post_id)) { return $post_id; } // Images: download into the media library, replace remote URLs with local ones. $images = array_slice((array) ($a['images'] ?? []), 0, self::MAX_IMAGES); foreach ($images as $img) { $url = esc_url_raw($img['url'] ?? ''); if (!$url || stripos($url, 'https://') !== 0) { continue; } $att = self::sideload($url, $post_id, sanitize_text_field($img['alt'] ?? ''), sanitize_text_field($img['credit'] ?? '')); if ($att) { $local = wp_get_attachment_url($att); $html = str_replace($url, $local, $html); } } // Cover image -> featured image. if (!empty($a['cover']['url']) && stripos($a['cover']['url'], 'https://') === 0) { $cover = self::sideload(esc_url_raw($a['cover']['url']), $post_id, sanitize_text_field($a['cover']['alt'] ?? $title), sanitize_text_field($a['cover']['credit'] ?? '')); if ($cover) { set_post_thumbnail($post_id, $cover); } } $html = wp_kses_post($html); $html = self::apply_link_rel($html, in_array($a['rel'] ?? '', ['sponsored', 'nofollow', 'dofollow'], true) ? $a['rel'] : 'sponsored'); if (!empty($a['sponsored']) || !isset($a['sponsored'])) { $label = $lang === 'ru' ? $o['label_ru'] : $o['label']; $html = '

' . esc_html($label) . '

' . "\n" . $html; } // Category / tags / SEO description. $cat = $o['category']; if (!empty($a['category_id']) && term_exists((int) $a['category_id'], 'category')) { $cat = (int) $a['category_id']; } $tags = array_map('sanitize_text_field', array_slice((array) ($a['tags'] ?? []), 0, 10)); // Status: auto-placement publishes immediately; manual mode leaves a draft for review. $status = $o['auto'] ? 'publish' : 'draft'; if (in_array($prev_status, ['publish', 'future'], true)) { $status = $prev_status; // updating a live article must never take it offline (12-month guarantee) } $date = null; if ($status === 'publish' && $prev_status !== 'publish' && !empty($a['publish_at'])) { $t = strtotime((string) $a['publish_at']); if ($t && $t > time() + 60) { $status = 'future'; $date = gmdate('Y-m-d H:i:s', $t); } } $upd = [ 'ID' => $post_id, 'post_title' => $title, 'post_content' => $html, 'post_status' => $status, ]; if ($date) { $upd['post_date_gmt'] = $date; $upd['post_date'] = get_date_from_gmt($date); } wp_update_post($upd); if ($cat) { wp_set_post_categories($post_id, [$cat]); } if ($tags) { wp_set_post_tags($post_id, $tags); } if (!empty($a['meta_description'])) { $desc = sanitize_text_field($a['meta_description']); update_post_meta($post_id, '_satelo_meta_description', $desc); update_post_meta($post_id, '_yoast_wpseo_metadesc', $desc); // Yoast SEO update_post_meta($post_id, 'rank_math_description', $desc); // Rank Math } return new WP_REST_Response([ 'ok' => true, 'post_id' => $post_id, 'status' => get_post_status($post_id), 'url' => get_permalink($post_id), ]); } public static function rest_unpublish(WP_REST_Request $req) { $a = (array) $req->get_json_params(); $pid = self::find_post(sanitize_text_field($a['id'] ?? '')); if (!$pid) { return new WP_Error('satelo_not_found', 'Article not found', ['status' => 404]); } wp_trash_post($pid); return new WP_REST_Response(['ok' => true, 'post_id' => $pid]); } /* ---------------- helpers ---------------- */ private static function find_post(string $sid): int { if ($sid === '') { return 0; } $q = get_posts(['post_type' => 'post', 'post_status' => 'any', 'meta_key' => '_satelo_id', 'meta_value' => $sid, 'fields' => 'ids', 'numberposts' => 1]); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- single lookup by our unique article id. return $q ? (int) $q[0] : 0; } private static function first_admin(): int { $u = get_users(['role' => 'administrator', 'number' => 1, 'fields' => 'ID']); return $u ? (int) $u[0] : 1; } private static function sideload(string $url, int $post_id, string $alt, string $credit): int { $tmp = download_url($url, 20); if (is_wp_error($tmp)) { return 0; } $type = wp_check_filetype_and_ext($tmp, basename(wp_parse_url($url, PHP_URL_PATH)) ?: 'image.jpg'); $mime = function_exists('mime_content_type') ? (string) mime_content_type($tmp) : (string) ($type['type'] ?? ''); if (strpos($mime, 'image/') !== 0) { wp_delete_file($tmp); return 0; } $ext = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp', 'image/gif' => 'gif'][$mime] ?? 'jpg'; $file = ['name' => 'satelo-' . substr(md5($url), 0, 10) . '.' . $ext, 'tmp_name' => $tmp]; $att = media_handle_sideload($file, $post_id, $alt ?: null); if (is_wp_error($att)) { wp_delete_file($tmp); return 0; } if ($alt) { update_post_meta($att, '_wp_attachment_image_alt', $alt); } if ($credit) { wp_update_post(['ID' => $att, 'post_excerpt' => $credit]); // caption = photo credit } return (int) $att; } /** External links get the agreed rel; links to this site stay untouched. */ private static function apply_link_rel(string $html, string $rel): string { if (!class_exists('WP_HTML_Tag_Processor')) { return $html; } $host = wp_parse_url(home_url(), PHP_URL_HOST); $p = new WP_HTML_Tag_Processor($html); while ($p->next_tag('a')) { $href = (string) $p->get_attribute('href'); $h = wp_parse_url($href, PHP_URL_HOST); if (!$h || $h === $host) { continue; } $p->set_attribute('rel', $rel === 'dofollow' ? 'noopener' : $rel . ' noopener'); $p->set_attribute('target', '_blank'); } return $p->get_updated_html(); } /** Signed call from this site to Satelo (handshake, heartbeat, revoke). */ private static function call(string $action, array $data) { $o = self::opt(); if (!$o['site_id'] || !$o['secret']) { return false; } $body = wp_json_encode(['site_id' => $o['site_id'], 'action' => $action, 'data' => $data]); $ts = time(); $res = wp_remote_post(self::API . '/' . $action, [ 'timeout' => 10, 'headers' => [ 'Content-Type' => 'application/json', 'X-Satelo-Site' => $o['site_id'], 'X-Satelo-Timestamp' => (string) $ts, 'X-Satelo-Signature' => hash_hmac('sha256', $ts . '.' . $body, $o['secret']), ], 'body' => $body, ]); return !is_wp_error($res) && wp_remote_retrieve_response_code($res) === 200; } public static function heartbeat(): void { self::call('heartbeat', self::status_payload() + ['pageviews' => self::pageviews()]); } /* ---------------- pageview counter ---------------- */ /** Daily token so the counter only accepts hits from pages of this site (works with page caches for ~3 days). */ private static function hit_token(int $daysAgo = 0): string { return substr(hash_hmac('sha256', gmdate('Y-m-d', time() - $daysAgo * 86400), (string) self::opt('secret')), 0, 16); } public static function beacon(): void { if (!self::opt('connected') || is_admin() || is_preview() || is_feed() || current_user_can('edit_posts')) { return; } $url = esc_url_raw(rest_url(self::NS . '/hit')) . (strpos(rest_url(), '?') === false ? '?' : '&') . 't=' . self::hit_token(); wp_enqueue_script('satelo-beacon', plugins_url('assets/beacon.js', __FILE__), [], self::VERSION, ['in_footer' => true, 'strategy' => 'defer']); wp_add_inline_script('satelo-beacon', 'window.sateloBeacon = ' . wp_json_encode(['url' => $url]) . ';', 'before'); } public static function rest_hit(WP_REST_Request $req): WP_REST_Response { $t = (string) $req->get_param('t'); $ok = $t !== '' && (hash_equals(self::hit_token(0), $t) || hash_equals(self::hit_token(1), $t) || hash_equals(self::hit_token(2), $t)); $ua = (string) $req->get_header('user_agent'); if ($ok && $ua !== '' && !preg_match('/bot|crawl|spider|slurp|preview|headless|monitor|curl|wget|python|http-client|lighthouse/i', $ua)) { $pv = (array) get_option('satelo_pv', []); $day = gmdate('Y-m-d'); $pv[$day] = (int) ($pv[$day] ?? 0) + 1; if (count($pv) > 40) { krsort($pv); $pv = array_slice($pv, 0, 35, true); } update_option('satelo_pv', $pv, false); } return new WP_REST_Response(null, 204); } /** Last 35 days of pageviews: ['YYYY-MM-DD' => count]. */ public static function pageviews(): array { $pv = (array) get_option('satelo_pv', []); krsort($pv); return array_slice($pv, 0, 35, true); } } Satelo_Connector::init();