→ https://, plus * i=1 → http:// (local development only). * * @return string */ function relaybell_sw_query() { $ver = 'ver=' . rawurlencode(RELAYBELL_VERSION); $base = relaybell_base(); $u = wp_parse_url($base); $u = is_array($u) ? $u : array(); $scheme = isset($u['scheme']) ? strtolower($u['scheme']) : ''; $host = isset($u['host']) ? strtolower($u['host']) : ''; $plain = !isset($u['path']) || trim($u['path'], '/') === ''; $plain = $plain && !isset($u['query']) && !isset($u['user']) && !isset($u['pass']) && !isset($u['fragment']); $hp = $host . (isset($u['port']) ? ':' . (int) $u['port'] : ''); if ($plain && $scheme === 'https' && $hp === (string) wp_parse_url(RELAYBELL_DEFAULT_BASE, PHP_URL_HOST)) { return $ver; } if ($plain && ($scheme === 'https' || $scheme === 'http') && preg_match('/^[A-Za-z0-9.-]+(:\d{1,5})?$/', $hp)) { return 'h=' . $hp . '&' . $ver . ($scheme === 'http' ? '&i=1' : ''); } // A host that can't be written as host[:port] (a sub-path, an IPv6 address): // keep passing the full address, as 1.2.1 and earlier always did. return 'api=' . rawurlencode($base) . '&' . $ver; } /** * Where the service worker lives. It is a static file in the plugin folder, * registered with that folder as its scope — so it works with any permalink * setting or web server and never competes with other plugins for the site-root * worker. If plugin assets are served from another origin (e.g. a CDN), a service * worker can't live there, so we fall back to WordPress serving it from the site. * * @return array{path:string,scope:?string} */ function relaybell_sw_config() { $home = wp_parse_url(home_url('/')); $dir = wp_parse_url(plugins_url('assets/', __FILE__)); $home = is_array($home) ? $home : array(); $dir = is_array($dir) ? $dir : array(); $path = isset($dir['path']) ? (string) $dir['path'] : ''; // The worker is registered by path, so it resolves against the page's own origin: // the same host and port is enough, whatever the scheme (e.g. https visitors // while WordPress behind a proxy builds http:// plugin URLs). Plugin URLs with no // host at all (a relative WP_CONTENT_URL) are on the site's own origin too. $port = function ($u) { return isset($u['port']) ? (int) $u['port'] : 0; }; $same = $path !== '' && $path[0] === '/' && ( !isset($dir['host']) || (isset($home['host']) && strtolower($home['host']) === strtolower($dir['host']) && $port($home) === $port($dir)) ); // Sites connected before 1.2.2 (no relaybell_connected_at yet) where 1.2.1 and // earlier put the worker at the site root — plugin URLs with another scheme, or // without a host — keep it there: a worker in the plugin folder would be a second // registration with a second subscription, so browsers that subscribed before the // update would show every push twice. $legacy_same = isset($home['host'], $dir['host'], $dir['path']) && strtolower($home['host']) === strtolower($dir['host']) && (isset($home['scheme']) ? $home['scheme'] : '') === (isset($dir['scheme']) ? $dir['scheme'] : '') && $port($home) === $port($dir); if ($same && !$legacy_same && get_option('relaybell_connected_at') === false && relaybell_is_connected()) { $same = false; } if ($same) { return array( 'path' => $path . 'relaybell-sw.js?' . relaybell_sw_query(), 'scope' => $path, ); } // Plugin files on another host (a CDN, a mapped domain), or kept at the root as // above: WordPress serves the worker itself, through index.php — that works with // plain permalinks and on servers (nginx) that answer *.js requests without asking // WordPress. The scope stays the site root, as for the /relaybell-sw.js address // used before, so browsers that registered that one update in place (same // subscription). $root = isset($home['path']) ? (string) $home['path'] : '/'; $root = '/' . ltrim(trailingslashit($root), '/'); return array('path' => $root . 'index.php?relaybell_sw=1&ver=' . rawurlencode(RELAYBELL_VERSION), 'scope' => null); } /* ------------------------------------------------------------ Page caches */ /** * Clear the site's page caches. The relaybell set-up (project id, relaybell host, * service worker address) is printed into every page, so after Connect, Disconnect, * a manual change or a plugin update, cached pages would keep giving visitors the * old one — for up to a week with some caches. Calls each known cache's purge API * only when that cache is installed; anything else can hook relaybell_purge_page_caches. * Runs at most once per request. * * @return void */ function relaybell_purge_page_caches() { static $done = false; if ($done) { return; } $done = true; delete_transient('relaybell_cache_probe'); $purges = array( // WP Rocket. function () { if (function_exists('rocket_clean_domain')) { rocket_clean_domain(); } }, // LiteSpeed Cache. function () { do_action('litespeed_purge_all'); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- another plugin's own hook. }, // W3 Total Cache. function () { if (function_exists('w3tc_flush_all')) { w3tc_flush_all(); } }, // WP Super Cache. function () { if (function_exists('wp_cache_clear_cache')) { wp_cache_clear_cache(is_multisite() ? get_current_blog_id() : 0); } }, // SiteGround Speed Optimizer. function () { if (function_exists('sg_cachepress_purge_everything')) { sg_cachepress_purge_everything(); } elseif (function_exists('sg_cachepress_purge_cache')) { sg_cachepress_purge_cache(); } }, // Breeze (Cloudways), Nginx Helper, WP Fastest Cache, Hummingbird. function () { do_action('breeze_clear_all_cache'); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- another plugin's own hook. do_action('rt_nginx_helper_purge_all'); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- another plugin's own hook. do_action('wpfc_clear_all_cache'); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- another plugin's own hook. do_action('wphb_clear_page_cache'); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- another plugin's own hook. }, // Cache Enabler (1.6+, or the older hook). function () { if (has_action('cache_enabler_clear_complete_cache')) { do_action('cache_enabler_clear_complete_cache'); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- another plugin's own hook. } else { do_action('ce_clear_cache'); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- another plugin's own hook. } }, // WP-Optimize. function () { if (function_exists('wpo_cache_flush')) { wpo_cache_flush(); } }, // WP Engine. function () { if (class_exists('WpeCommon')) { if (method_exists('WpeCommon', 'purge_memcached')) { WpeCommon::purge_memcached(); } if (method_exists('WpeCommon', 'purge_varnish_cache')) { WpeCommon::purge_varnish_cache(); } } }, // Kinsta. function () { global $kinsta_cache; if (isset($kinsta_cache) && is_object($kinsta_cache) && !empty($kinsta_cache->kinsta_cache_purge) && method_exists($kinsta_cache->kinsta_cache_purge, 'purge_complete_caches')) { $kinsta_cache->kinsta_cache_purge->purge_complete_caches(); } }, // Anything else (a host cache, a CDN): hook in here. function () { /** * Fires when relaybell asks for the site's page caches to be cleared * (after Connect, Disconnect, a manual connection change or a plugin update). */ do_action('relaybell_purge_page_caches'); }, ); foreach ($purges as $purge) { // A cache plugin's error must never break Connect or the settings page. try { $purge(); } catch (\Throwable $e) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch -- Best effort. } } } /** * What a visitor's copy of one page says about relaybell, fetched like a visitor * does (no cookies, so a page cache or CDN answers if there is one). * * @param string $url Page to fetch. * @return string 'ok' (current set-up) | 'old' (another project, an older service * worker address or plugin version) | 'missing' (no relaybell) | * '' (couldn't tell: the request failed or the answer isn't a page). */ function relaybell_probe_page($url) { $res = wp_remote_get($url, array( 'timeout' => 5, 'redirection' => 3, 'cookies' => array(), 'headers' => array('Accept' => 'text/html'), )); if (is_wp_error($res) || (int) wp_remote_retrieve_response_code($res) !== 200) { return ''; } $type = (string) wp_remote_retrieve_header($res, 'content-type'); $html = (string) wp_remote_retrieve_body($res); if ($type !== '' ? stripos($type, 'html') === false : stripos($html, ' 30 * MINUTE_IN_SECONDS, 'stale' => 2 * MINUTE_IN_SECONDS, 'unknown' => 6 * HOUR_IN_SECONDS, ); set_transient('relaybell_cache_probe', array('sig' => $sig, 'state' => $state), $ttl[$state]); return $state; } /** * A manual change of the project id, API key or relaybell host (Advanced form, or * any update_option): forget what was checked for the old values and clear page * caches so visitors get the new set-up. * * @return void */ function relaybell_on_connection_change() { delete_option('relaybell_key_checked'); delete_option('relaybell_account_state'); delete_transient('relaybell_sub_count'); // The identity secret belongs to the old project: fetched again for the new one. relaybell_identity_forget(); // So does the catalog: the new project gets all of it (in the background). if (function_exists('relaybell_catalog_on_connection_change')) { relaybell_catalog_on_connection_change(); } relaybell_purge_page_caches(); } foreach (array('relaybell_project_id', 'relaybell_api_key', 'relaybell_api_base') as $relaybell_conn_option) { add_action('add_option_' . $relaybell_conn_option, 'relaybell_on_connection_change', 10, 0); add_action('update_option_' . $relaybell_conn_option, 'relaybell_on_connection_change', 10, 0); } unset($relaybell_conn_option); // Saving the Advanced form always empties the site name: let the next status check // ask relaybell again (GET /api/v1/me), which fills it back in. add_action('update_option_relaybell_project_name', function ($old, $new) { if (trim((string) $new) === '') { delete_option('relaybell_key_checked'); } }, 10, 2); /** * When this site last started using a (different) relaybell project — the status * panel doesn't call "relaybell hasn't run on your pages" a problem minutes later. */ function relaybell_mark_connected_now() { update_option('relaybell_connected_at', time(), false); } add_action('add_option_relaybell_project_id', 'relaybell_mark_connected_now', 10, 0); add_action('update_option_relaybell_project_id', 'relaybell_mark_connected_now', 10, 0); /** * Once per plugin version (the first admin request after an update): clear page * caches, so cached pages stop pointing visitors at an older service worker * address. Many hosts' caches aren't cleared by a plugin update. A connected site * also sends its catalog again (in the background), so relaybell has it as this * version builds it. */ add_action('admin_init', function () { if (get_option('relaybell_version') === RELAYBELL_VERSION) { return; } if (update_option('relaybell_version', RELAYBELL_VERSION) && trim((string) get_option('relaybell_project_id')) !== '') { relaybell_purge_page_caches(); if (function_exists('relaybell_catalog_request_full')) { relaybell_catalog_request_full('update'); } } }); /* ----------------------------------------------------- Event toggles */ /** * Whether a given behavioral event is enabled in settings. * * Defaults to enabled for every known event so a fresh install behaves like * previous versions. Once the settings form is saved, an unchecked box stores * '0' (via the hidden companion input) and the emitter is gated off. * * @param string $event Canonical event name (e.g. 'purchase', 'price_drop'). * @return bool */ function relaybell_event_enabled($event) { $map = array( 'purchase' => 'relaybell_evt_purchase', 'cart.updated' => 'relaybell_evt_cart_updated', 'back_in_stock' => 'relaybell_evt_back_in_stock', 'price_drop' => 'relaybell_evt_price_drop', 'signup' => 'relaybell_evt_signup', 'order.refunded' => 'relaybell_evt_order_refunded', 'checkout.started' => 'relaybell_evt_checkout_started', ); if (!isset($map[$event])) { return true; } // Default '1' when the option was never saved (preserves prior behavior). return get_option($map[$event], '1') === '1'; } /* ---------------------------------------------------- Sanitizers */ function relaybell_sanitize_text($v) { return sanitize_text_field((string) $v); } function relaybell_sanitize_url($v) { $v = trim((string) $v); return $v === '' ? '' : esc_url_raw($v); } /** * The relaybell host (Advanced): HTTPS only, since the API key and the front-end script * come from it. Plain http:// is accepted only for local development hosts. */ function relaybell_sanitize_api_base($v) { $v = relaybell_sanitize_url($v); if ($v === '') { return ''; } $scheme = wp_parse_url($v, PHP_URL_SCHEME); $host = strtolower((string) wp_parse_url($v, PHP_URL_HOST)); $local = in_array($host, array('localhost', '127.0.0.1', 'host.docker.internal'), true) || substr($host, -10) === '.localhost'; if ($scheme === 'https' || ($scheme === 'http' && $local)) { return $v; } add_settings_error('relaybell_api_base', 'relaybell_api_base_https', __('The relaybell host must start with https://.', 'relaybell')); return (string) get_option('relaybell_api_base'); } /** * Drop spaces and invisible characters (zero-width spaces, word joiners, byte-order * marks, non-breaking spaces) that come along when an id or key is copied. * * @param mixed $v Raw value. * @return string */ function relaybell_strip_invisible($v) { $v = (string) $v; $clean = preg_replace('/[\s\x{00A0}\x{200B}-\x{200D}\x{2060}\x{FEFF}]+/u', '', $v); // Not valid UTF-8: strip plain whitespace only. return $clean !== null ? $clean : preg_replace('/\s+/', '', $v); } /** * A relaybell Project ID in its canonical form, or '' when the value isn't one. * * @param mixed $v Raw value. * @return string */ function relaybell_clean_uuid($v) { $v = strtolower(relaybell_strip_invisible($v)); return preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/', $v) ? $v : ''; } /** * Project ID (Advanced form, or saved by Connect). Anything that isn't a Project ID * is refused with a message, and the previous one is kept. * * @param mixed $v Submitted value. * @return string */ function relaybell_sanitize_project_id($v) { if (trim(relaybell_strip_invisible($v)) === '') { return ''; } $id = relaybell_clean_uuid($v); if ($id !== '') { return $id; } static $warned = false; if (!$warned && function_exists('add_settings_error')) { $warned = true; add_settings_error( 'relaybell_project_id', 'relaybell_bad_project_id', __('That Project ID doesn’t look right — it should look like 00000000-0000-0000-0000-000000000000. Copy it again from your relaybell dashboard (Setup). The previous Project ID was kept.', 'relaybell') ); } $old = (string) get_option('relaybell_project_id'); $fix = relaybell_clean_uuid($old); return $fix !== '' ? $fix : $old; } /** * API key: as typed, without spaces or invisible characters picked up by copying. * * @param mixed $v Submitted value. * @return string */ function relaybell_sanitize_api_key($v) { return sanitize_text_field(relaybell_strip_invisible($v)); } function relaybell_sanitize_bool($v) { return ($v === '1' || $v === 1 || $v === true) ? '1' : '0'; } function relaybell_sanitize_post_types($val) { if (!is_array($val)) { return array(); } $valid = get_post_types(array('public' => true), 'names'); $out = array(); foreach ($val as $v) { $v = sanitize_key($v); if (isset($valid[$v])) { $out[] = $v; } } return array_values(array_unique($out)); } /* ---------------------------------------------------------------- Settings */ add_action('admin_menu', function () { $hook = add_options_page( __('relaybell', 'relaybell'), __('relaybell', 'relaybell'), 'manage_options', 'relaybell', 'relaybell_render_settings' ); if ($hook) { add_action('load-' . $hook, 'relaybell_settings_page_load'); } }); add_action('admin_init', function () { // Connection (set by one-click Connect, or manually under "Advanced"). // A separate group, so saving the behavior form never touches the keys. register_setting('relaybell_conn', 'relaybell_project_id', array('type' => 'string', 'sanitize_callback' => 'relaybell_sanitize_project_id', 'default' => '')); register_setting('relaybell_conn', 'relaybell_api_key', array('type' => 'string', 'sanitize_callback' => 'relaybell_sanitize_api_key', 'default' => '')); register_setting('relaybell_conn', 'relaybell_api_base', array('type' => 'string', 'sanitize_callback' => 'relaybell_sanitize_api_base', 'default' => '')); register_setting('relaybell_conn', 'relaybell_project_name', array('type' => 'string', 'sanitize_callback' => 'relaybell_sanitize_text', 'default' => '')); // Behavior. register_setting('relaybell_group', 'relaybell_identify', array('type' => 'string', 'sanitize_callback' => 'relaybell_sanitize_bool', 'default' => '1')); register_setting('relaybell_group', 'relaybell_send_on_publish', array('type' => 'string', 'sanitize_callback' => 'relaybell_sanitize_bool', 'default' => '0')); register_setting('relaybell_group', 'relaybell_push_post_types', array('type' => 'array', 'sanitize_callback' => 'relaybell_sanitize_post_types', 'default' => array('post'))); register_setting('relaybell_group', 'relaybell_map_category_topic', array('type' => 'string', 'sanitize_callback' => 'relaybell_sanitize_bool', 'default' => '0')); // WooCommerce per-event toggles. register_setting('relaybell_group', 'relaybell_evt_purchase', array('type' => 'string', 'sanitize_callback' => 'relaybell_sanitize_bool', 'default' => '1')); register_setting('relaybell_group', 'relaybell_evt_cart_updated', array('type' => 'string', 'sanitize_callback' => 'relaybell_sanitize_bool', 'default' => '1')); register_setting('relaybell_group', 'relaybell_evt_back_in_stock', array('type' => 'string', 'sanitize_callback' => 'relaybell_sanitize_bool', 'default' => '1')); register_setting('relaybell_group', 'relaybell_evt_price_drop', array('type' => 'string', 'sanitize_callback' => 'relaybell_sanitize_bool', 'default' => '1')); register_setting('relaybell_group', 'relaybell_evt_signup', array('type' => 'string', 'sanitize_callback' => 'relaybell_sanitize_bool', 'default' => '1')); register_setting('relaybell_group', 'relaybell_evt_order_refunded', array('type' => 'string', 'sanitize_callback' => 'relaybell_sanitize_bool', 'default' => '1')); register_setting('relaybell_group', 'relaybell_evt_checkout_started', array('type' => 'string', 'sanitize_callback' => 'relaybell_sanitize_bool', 'default' => '1')); register_setting('relaybell_group', 'relaybell_notify_button', array('type' => 'string', 'sanitize_callback' => 'relaybell_sanitize_bool', 'default' => '1')); // Personalization: what is sent to relaybell's catalog (catalog.php). register_setting('relaybell_group', 'relaybell_catalog_products', array('type' => 'string', 'sanitize_callback' => 'relaybell_sanitize_bool', 'default' => '1')); register_setting('relaybell_group', 'relaybell_catalog_posts', array('type' => 'string', 'sanitize_callback' => 'relaybell_sanitize_bool', 'default' => '1')); }); /** * Settings → relaybell shortcut on the Plugins list row. */ add_filter('plugin_action_links_' . plugin_basename(__FILE__), function ($links) { $url = admin_url('options-general.php?page=relaybell'); $label = relaybell_is_connected() ? __('Settings', 'relaybell') : __('Connect', 'relaybell'); array_unshift($links, '' . esc_html($label) . ''); return $links; }); /** * The relaybell notices outside our own settings page appear only on the Plugins * screen, only for administrators, and each can be dismissed for good (per user, * stored in the _relaybell_dismissed_notices user meta, which uninstall.php removes). */ function relaybell_notice_dismissed($key) { $done = get_user_meta(get_current_user_id(), '_relaybell_dismissed_notices', true); return is_array($done) && !empty($done[$key]); } function relaybell_show_notice_here($key) { if (!current_user_can('manage_options') || relaybell_notice_dismissed($key)) { return false; } $screen = function_exists('get_current_screen') ? get_current_screen() : null; return $screen && 'plugins' === $screen->id; } /** Loads the dismiss (and, when asked, the email re-check) script for a notice. */ function relaybell_notice_script($recheck) { wp_enqueue_script('relaybell-notice', plugins_url('assets/relaybell-notice.js', __FILE__), array(), RELAYBELL_VERSION, true); wp_add_inline_script( 'relaybell-notice', 'window.relaybellNotice = ' . wp_json_encode(array('nonce' => wp_create_nonce('relaybell_ajax'), 'recheck' => (bool) $recheck)) . ';', 'before' ); } add_action('wp_ajax_relaybell_dismiss_notice', function () { check_ajax_referer('relaybell_ajax'); $key = isset($_POST['notice']) ? sanitize_key(wp_unslash($_POST['notice'])) : ''; if (!current_user_can('manage_options') || !in_array($key, array('connect', 'verify'), true)) { wp_send_json_error(); } $done = get_user_meta(get_current_user_id(), '_relaybell_dismissed_notices', true); $done = is_array($done) ? $done : array(); $done[$key] = time(); update_user_meta(get_current_user_id(), '_relaybell_dismissed_notices', $done); wp_send_json_success(); }); /** * Point the admin to the Connect button, until the site is connected. */ add_action('admin_notices', function () { if (relaybell_is_connected() || !relaybell_show_notice_here('connect')) { return; } relaybell_notice_script(false); printf( '

%1$s %2$s

%4$s

', esc_html__('relaybell is installed.', 'relaybell'), esc_html__('Connect it to start collecting web push subscribers — it takes about a minute.', 'relaybell'), esc_url(admin_url('options-general.php?page=relaybell')), esc_html__('Connect relaybell', 'relaybell') ); }); /** * relaybell blocks sends to visitors until the account email is verified, while * test pushes keep working — easy to miss. Say so on the Plugins screen (from the * state the last status check stored; a stale one is re-checked in the background, * and the notice goes away once the email is verified). Settings → relaybell shows * the same in its status panel. */ add_action('admin_notices', function () { if (!relaybell_is_connected() || !relaybell_show_notice_here('verify')) { return; } $st = get_option('relaybell_account_state'); if (!is_array($st) || empty($st['blocked'])) { return; } printf( '

%1$s %2$s

%4$s %6$s

', esc_html__('relaybell isn’t sending to your visitors yet.', 'relaybell'), esc_html__('Your relaybell account email isn’t verified, so new posts and other pushes to your subscribers are blocked (test pushes still work). Click the link in the email relaybell sent you — check your spam folder — or send it again from your relaybell dashboard.', 'relaybell'), esc_url(relaybell_dashboard_url()), esc_html__('Open relaybell dashboard', 'relaybell'), esc_url(admin_url('options-general.php?page=relaybell')), esc_html__('relaybell settings', 'relaybell') ); // A stale state is re-checked in the background (assets/relaybell-notice.js removes // the notice once the email is verified). relaybell_notice_script(empty($st['at']) || time() - (int) $st['at'] > HOUR_IN_SECONDS); }); /* -------------------------------------------------- One-click connect */ /** * Step 1: send the admin to relaybell to sign in and approve this site. A * single-use `state` value protects the return trip against CSRF. */ add_action('admin_post_relaybell_connect', function () { if (!current_user_can('manage_options')) { wp_die(esc_html__('Sorry, you are not allowed to do that.', 'relaybell')); } check_admin_referer('relaybell_connect'); $state = wp_generate_password(32, false); set_transient('relaybell_connect_state_' . get_current_user_id(), $state, 20 * MINUTE_IN_SECONDS); $url = relaybell_base() . '/connect/wordpress?' . http_build_query( array( 'site' => relaybell_connect_site_url(), 'name' => get_bloginfo('name'), 'return' => admin_url('options-general.php?page=relaybell'), 'state' => $state, ), '', '&', PHP_QUERY_RFC3986 ); $host = (string) wp_parse_url(relaybell_base(), PHP_URL_HOST); add_filter('allowed_redirect_hosts', function ($hosts) use ($host) { $hosts[] = $host; return $hosts; }); wp_safe_redirect($url); exit; }); /** * Step 2: relaybell sends the admin back with a one-time code. Verify `state`, * then trade the code (server-to-server) for the project id and an API key. */ add_action('admin_init', function () { // phpcs:disable WordPress.Security.NonceVerification.Recommended -- OAuth-style return trip: CSRF protection is the single-use `state` value issued in step 1 and verified below. if (!isset($_GET['page'], $_GET['state']) || 'relaybell' !== sanitize_key(wp_unslash($_GET['page']))) { return; } if (!isset($_GET['rb_code']) && !isset($_GET['rb_error'])) { return; } if (!current_user_can('manage_options')) { return; } $state = sanitize_text_field(wp_unslash($_GET['state'])); $code = isset($_GET['rb_code']) ? sanitize_text_field(wp_unslash($_GET['rb_code'])) : ''; $denied = isset($_GET['rb_error']); // phpcs:enable WordPress.Security.NonceVerification.Recommended $uid = get_current_user_id(); $expected = get_transient('relaybell_connect_state_' . $uid); delete_transient('relaybell_connect_state_' . $uid); $back = admin_url('options-general.php?page=relaybell'); if (!is_string($expected) || $expected === '' || !hash_equals($expected, $state)) { wp_safe_redirect(add_query_arg('rb_msg', 'state', $back)); exit; } if ($denied) { wp_safe_redirect(add_query_arg('rb_msg', 'denied', $back)); exit; } $res = wp_remote_post(relaybell_base() . '/api/v1/connect/exchange', array( 'headers' => array('Content-Type' => 'application/json'), 'body' => wp_json_encode(array('code' => $code, 'siteUrl' => relaybell_connect_site_url())), 'timeout' => 20, )); if (is_wp_error($res)) { /* translators: %s: network error message */ $err = sprintf(__('This server could not reach relaybell: %s', 'relaybell'), $res->get_error_message()); } else { $http = (int) wp_remote_retrieve_response_code($res); $body = json_decode(wp_remote_retrieve_body($res), true); if ($http === 200 && is_array($body) && !empty($body['projectId']) && !empty($body['apiKey'])) { update_option('relaybell_project_id', sanitize_text_field($body['projectId'])); update_option('relaybell_api_key', sanitize_text_field($body['apiKey'])); update_option('relaybell_project_name', sanitize_text_field(isset($body['projectName']) ? $body['projectName'] : '')); delete_transient('relaybell_sub_count'); delete_option('relaybell_account_state'); // relaybell issued this key for this project: nothing to cross-check. update_option('relaybell_key_checked', relaybell_key_signature(), false); // Remember it was issued for this site, so Disconnect may cancel it — never a key // pasted in by hand, which might also be used elsewhere (Zapier, another site). update_option('relaybell_connect_key', md5((string) $body['apiKey']), false); relaybell_mark_connected_now(); // Fetch the identity secret now (and turn on verified visitor ids), so pages // sign logged-in visitors' ids from the start. A failure is retried later. relaybell_identity_sync(true); relaybell_purge_page_caches(); wp_safe_redirect(add_query_arg('rb_msg', 'connected', $back)); exit; } $e = (is_array($body) && isset($body['error'])) ? (string) $body['error'] : ''; if ($e === 'invalid_or_expired_code') { $err = __('The connection request expired. Please click Connect again.', 'relaybell'); } elseif ($e === 'site_mismatch') { $err = __('The site address did not match the one you approved. Check Settings → General (WordPress Address and Site Address), then try again.', 'relaybell'); } else { /* translators: %d: HTTP status code */ $err = sprintf(__('relaybell returned HTTP %d.', 'relaybell'), $http); } } set_transient('relaybell_connect_error_' . $uid, $err, 5 * MINUTE_IN_SECONDS); wp_safe_redirect(add_query_arg('rb_msg', 'failed', $back)); exit; }); /** * Disconnect: forget the project id and key on this site. */ add_action('admin_post_relaybell_disconnect', function () { if (!current_user_can('manage_options')) { wp_die(esc_html__('Sorry, you are not allowed to do that.', 'relaybell')); } check_admin_referer('relaybell_disconnect'); // Cancel this site's key in relaybell too, so a copy left in a backup or an old // database can't be used. Best effort: a failure never blocks disconnecting. // Only a key one-click Connect issued for this site; a hand-entered one is left alone. $key = (string) get_option('relaybell_api_key', ''); $own = $key !== '' && md5($key) === (string) get_option('relaybell_connect_key', ''); $msg = ($key === '' || $own) ? 'disconnected' : 'disconnected_manual'; if ($own) { $res = wp_remote_request(relaybell_base() . '/api/v1/keys/current', array( 'method' => 'DELETE', 'headers' => array('Authorization' => 'Bearer ' . $key), 'timeout' => 5, )); $code = is_wp_error($res) ? 0 : (int) wp_remote_retrieve_response_code($res); // 401 = the key was already gone; anything else unexpected → ask the owner to revoke it. if ($code !== 200 && $code !== 401 && $code !== 404) { $msg = 'disconnected_key'; } } delete_option('relaybell_project_id'); delete_option('relaybell_api_key'); delete_option('relaybell_project_name'); delete_option('relaybell_key_checked'); delete_option('relaybell_account_state'); delete_option('relaybell_connected_at'); delete_option('relaybell_connect_key'); delete_transient('relaybell_sub_count'); delete_transient('relaybell_pid_fixed'); relaybell_identity_forget(); wp_clear_scheduled_hook('relaybell_identity_daily'); // Nothing more is sent: no catalog sync, no import. relaybell_catalog_forget(); // Cached pages would keep loading relaybell for visitors. relaybell_purge_page_caches(); wp_safe_redirect(add_query_arg('rb_msg', $msg, admin_url('options-general.php?page=relaybell'))); exit; }); /* ------------------------------------------------------- Status checks */ /** * Identifies the saved relaybell host + API key + project id, to remember that this * combination was already cross-checked. * * @return string */ function relaybell_key_signature() { return md5(relaybell_base() . '|' . trim((string) get_option('relaybell_api_key')) . '|' . strtolower(trim((string) get_option('relaybell_project_id')))); } /** * Is the Project ID saved here the one the API key belongs to? Entered by hand, * they can come from two different relaybell sites: visitors would then subscribe * to one while posts and test pushes go to the other. The key decides (it's what * sends), so a different Project ID is replaced by the key's. Asks relaybell once * per key + project id; a failed request is retried on the next check. * * @return void */ function relaybell_verify_key_project() { $key = trim((string) get_option('relaybell_api_key')); if ($key === '' || get_option('relaybell_key_checked') === relaybell_key_signature()) { return; } $res = wp_remote_get(relaybell_base() . '/api/v1/me', array( 'headers' => array('Authorization' => 'Bearer ' . $key), 'timeout' => 10, )); $code = is_wp_error($res) ? 0 : (int) wp_remote_retrieve_response_code($res); if ($code < 200 || $code >= 300) { return; } $b = json_decode(wp_remote_retrieve_body($res), true); $mine = is_array($b) && isset($b['projectId']) && is_string($b['projectId']) ? relaybell_clean_uuid($b['projectId']) : ''; $old = trim((string) get_option('relaybell_project_id')); $name = (is_array($b) && isset($b['name']) && is_string($b['name'])) ? sanitize_text_field($b['name']) : ''; if ($mine !== '' && $mine !== strtolower($old)) { // Purges page caches and records the change (update_option hooks above). update_option('relaybell_project_id', $mine); update_option('relaybell_project_name', $name); set_transient('relaybell_pid_fixed', $name !== '' ? $name : $mine, HOUR_IN_SECONDS); } elseif ($mine !== '' && $name !== '' && trim((string) get_option('relaybell_project_name')) === '') { // Entered by hand (the Advanced form leaves the name empty): show the site's name. update_option('relaybell_project_name', $name); } update_option('relaybell_key_checked', relaybell_key_signature(), false); } /** * Remember whether relaybell is blocking sends to visitors because the account * email isn't verified (shown as an admin notice on the Plugins and Dashboard * screens, which make no request to relaybell themselves). * * @param bool $blocked Sends are blocked. * @return void */ function relaybell_store_account_state($blocked) { if (!$blocked) { delete_option('relaybell_account_state'); return; } $st = get_option('relaybell_account_state'); if (!is_array($st) || empty($st['blocked']) || empty($st['at']) || time() - (int) $st['at'] > 30 * MINUTE_IN_SECONDS) { update_option('relaybell_account_state', array('blocked' => true, 'at' => time()), false); } } /** * Server-side check: can this site reach relaybell with its key? Also cross-checks * the Project ID against the key (once), and reads whether relaybell is blocking * sends to visitors until the account email is verified. * * @return array{ok:bool,text?:string,subs?:int,blocked?:bool,verified?:?bool} */ function relaybell_check_connection() { $key = trim((string) get_option('relaybell_api_key')); $res = wp_remote_get(relaybell_base() . '/api/v1/stats', array( 'headers' => array('Authorization' => 'Bearer ' . $key), 'timeout' => 10, )); if (is_wp_error($res)) { /* translators: %s: network error message */ return array('ok' => false, 'text' => sprintf(__('This server can’t reach relaybell: %s', 'relaybell'), $res->get_error_message())); } $code = (int) wp_remote_retrieve_response_code($res); if ($code === 401 || $code === 403) { return array('ok' => false, 'text' => __('relaybell rejected the API key. Disconnect, then connect again.', 'relaybell')); } if ($code < 200 || $code >= 300) { /* translators: %d: HTTP status code */ return array('ok' => false, 'text' => sprintf(__('relaybell returned HTTP %d. Try again in a minute.', 'relaybell'), $code)); } relaybell_verify_key_project(); $b = json_decode(wp_remote_retrieve_body($res), true); $subs = (is_array($b) && isset($b['subscribers'])) ? (int) $b['subscribers'] : 0; set_transient('relaybell_sub_count', (string) $subs, 60); // Older relaybell servers don't send these: not blocked, unknown. $blocked = is_array($b) && !empty($b['sendingBlocked']); $verified = (is_array($b) && isset($b['emailVerified']) && is_bool($b['emailVerified'])) ? $b['emailVerified'] : null; relaybell_store_account_state($blocked); return array('ok' => true, 'subs' => $subs, 'blocked' => $blocked, 'verified' => $verified); } /** * The settings page's connection check, made once per request. * * @return array See relaybell_check_connection(). */ function relaybell_page_connection() { static $conn = null; if ($conn === null) { $conn = relaybell_check_connection(); } return $conn; } /** * Runs before the settings page starts printing (load-): the connection * check can replace the saved Project ID and then clear page caches, which some * caches (LiteSpeed, Varnish) only accept before any output. Also queues the page's * stylesheet and script (relaybell_admin_assets()). * * @return void */ function relaybell_settings_page_load() { if (current_user_can('manage_options') && relaybell_is_connected()) { relaybell_page_connection(); } add_action('admin_enqueue_scripts', 'relaybell_admin_assets'); } /* ----------------------------------------------- Identity verification */ // Pages link a logged-in visitor's subscription to their account by passing an id // (externalId: the account email, or the relaybell_external_id filter's value). Anyone // can send relaybell an id from their own browser, so pages also pass userHash: an // HMAC-SHA256 of the id made here with the project's identity secret, which only this // site and relaybell know. Once the project requires it, relaybell ignores an id without // a valid hash, so nobody can pose as one of your customers to get their notifications. // The secret comes from relaybell with this site's API key (GET /api/v1/identity): on // Connect, in the background when the settings page opens, and once a day (cron). /** * Forget the identity secret and what was asked about it (disconnect, or a different * project, key or relaybell host). * * @return void */ function relaybell_identity_forget() { delete_option('relaybell_identity_secret'); delete_option('relaybell_identity_on'); delete_transient('relaybell_identity_checked'); } /** * Fetch the project's identity secret from relaybell and store it. The first time for a * connection (project + key + host), also turn on "only accept verified visitor ids" * for the project, so sites are protected without any setup. Done once: if the owner * turns it off in the dashboard later, it stays off. * * @param bool $force Ask relaybell even if it was asked in the last hours. * @return array{ok:bool,required?:bool,enable_failed?:bool,text?:string} */ function relaybell_identity_sync($force = false) { if (!relaybell_is_connected()) { return array('ok' => false, 'text' => ''); } if (!$force) { $last = get_transient('relaybell_identity_checked'); if (is_array($last) && isset($last['ok'])) { return $last; } } $key = trim((string) get_option('relaybell_api_key')); $res = wp_remote_get(relaybell_base() . '/api/v1/identity', array( 'headers' => array('Authorization' => 'Bearer ' . $key), 'timeout' => 8, )); $code = is_wp_error($res) ? 0 : (int) wp_remote_retrieve_response_code($res); $b = ($code >= 200 && $code < 300) ? json_decode(wp_remote_retrieve_body($res), true) : null; $secret = (is_array($b) && isset($b['secret']) && is_string($b['secret'])) ? strtolower($b['secret']) : ''; if (!preg_match('/^[0-9a-f]{64}$/', $secret)) { if (is_wp_error($res)) { /* translators: %s: network error message */ $text = sprintf(__('This server couldn’t reach relaybell: %s', 'relaybell'), $res->get_error_message()); } elseif ($code === 401 || $code === 403) { $text = __('relaybell rejected the API key. Disconnect, then connect again.', 'relaybell'); } else { /* translators: %d: HTTP status code */ $text = sprintf(__('relaybell returned HTTP %d.', 'relaybell'), $code); } $out = array('ok' => false, 'text' => $text); set_transient('relaybell_identity_checked', $out, HOUR_IN_SECONDS); return $out; } // Always the latest: the owner may have made a new one (Rotate) in the dashboard. update_option('relaybell_identity_secret', $secret, false); $required = is_array($b) && !empty($b['required']); $sig = relaybell_key_signature(); $failed = false; if (get_option('relaybell_identity_on') !== $sig) { if (!$required) { $put = wp_remote_request(relaybell_base() . '/api/v1/identity', array( 'method' => 'PUT', 'headers' => array('Authorization' => 'Bearer ' . $key, 'Content-Type' => 'application/json'), 'body' => wp_json_encode(array('required' => true)), 'timeout' => 8, )); $pc = is_wp_error($put) ? 0 : (int) wp_remote_retrieve_response_code($put); $pb = ($pc >= 200 && $pc < 300) ? json_decode(wp_remote_retrieve_body($put), true) : null; $required = is_array($pb) && !empty($pb['required']); $failed = !$required; } if ($required) { update_option('relaybell_identity_on', $sig, false); } } $out = array('ok' => true, 'required' => $required); if ($failed) { // Not turned on (relaybell unreachable or it said no): not remembered, so the next // check asks again; kept for an hour only, like a failed fetch. $out['enable_failed'] = true; set_transient('relaybell_identity_checked', $out, HOUR_IN_SECONDS); return $out; } set_transient('relaybell_identity_checked', $out, 12 * HOUR_IN_SECONDS); return $out; } /** * The userHash that proves to relaybell this site vouches for an externalId (identity * verification). Use it when your own code passes an id to relaybell, e.g. in the * relaybell_sdk_init filter or with RelayBell.identify(id, hash). * * @param string|int $external_id The id, exactly as the page passes it. * @return string 64 hex characters, or '' while the secret isn't known yet. */ function relaybell_user_hash($external_id) { if (!is_string($external_id) && !is_int($external_id)) { return ''; } $id = (string) $external_id; $secret = (string) get_option('relaybell_identity_secret', ''); if ($id === '' || !preg_match('/^[0-9a-f]{64}$/', $secret)) { return ''; } return hash_hmac('sha256', $id, $secret); } // Once a day: pick up a new secret (Rotate in the dashboard) and retry a failed fetch. add_action('relaybell_identity_daily', function () { relaybell_identity_sync(false); }); add_action('init', function () { if (relaybell_is_connected() && !wp_next_scheduled('relaybell_identity_daily')) { wp_schedule_event(time(), 'daily', 'relaybell_identity_daily'); } }); /** * Render a hidden "0" companion + a checkbox so unchecked boxes persist as '0'. * * @param string $name Option name. * @param string $label Field label. */ function relaybell_checkbox($name, $label) { printf( '', esc_attr($name), checked(get_option($name), '1', false), esc_html($label) ); } /** * One status row. * * @param string $id Element id (browser checks update it). * @param string|null $state 'ok' | 'bad' | 'warn' | 'info' | null (pending). * @param string $html Row text (already escaped/kses'd by the caller). * @param bool $hidden Start hidden (shown by a browser check when needed). */ function relaybell_status_row($id, $state, $html, $hidden = false) { $icons = array('ok' => 'yes-alt', 'bad' => 'dismiss', 'warn' => 'warning', 'info' => 'info-outline'); $icon = isset($icons[$state]) ? $icons[$state] : 'update'; printf( '
  • %4$s
  • ', esc_attr($id), esc_attr($state ? $state : 'pending'), esc_attr($icon), wp_kses($html, array('strong' => array(), 'a' => array('href' => array(), 'target' => array(), 'rel' => array()), 'code' => array())), $hidden ? ' hidden' : '' ); } /* ---------------------------------------------------------- Test push */ /** * Title of the test push. The settings page compares it with what the service * worker reports having shown, to tell whether the test reached this browser. * * @return string */ function relaybell_test_push_title() { return __('Test push from relaybell', 'relaybell'); } /** * What the admin sees for each test push outcome (returned by the AJAX handler). * * @return array */ function relaybell_test_messages() { return array( 'sent' => __('Sent to your browser — watching for it…', 'relaybell'), 'key_mismatch' => __('This browser’s subscription was made with an earlier connection and was rejected. Click “Subscribe this browser” again (it will refresh automatically), then send the test again.', 'relaybell'), 'expired' => __('This browser’s subscription had expired. Click “Subscribe this browser” again.', 'relaybell'), 'failed' => __('Your browser didn’t accept the test — click “Subscribe this browser” again, then retry.', 'relaybell'), 'server_auth' => __('The browser’s push service didn’t accept relaybell’s sign-in, so the test couldn’t be delivered. This is on relaybell’s side, not your site or browser — subscribing again won’t help. Please contact relaybell support.', 'relaybell'), 'timeout' => __('The browser’s push service didn’t answer in time. Wait a minute, then send the test again.', 'relaybell'), 'no_devices' => __('Click “Subscribe this browser” first (and allow notifications) — test pushes go only to your own browser.', 'relaybell'), ); } /** * Shown next to test results while relaybell blocks sends to visitors because the * account email isn't verified (test pushes are never blocked). * * @return string */ function relaybell_unverified_test_warning() { return __('Note: your relaybell account email isn’t verified yet, so pushes to your visitors are blocked — only test pushes go out. Click the link in the email relaybell sent you (check your spam folder).', 'relaybell'); } /** * Per-device results from POST /api/v1/test-devices/send, cleaned up. Older * relaybell servers don't send them (empty array). * * @param mixed $b Decoded response body. * @return array */ function relaybell_test_results($b) { $out = array(); if (!is_array($b) || !isset($b['results']) || !is_array($b['results'])) { return $out; } foreach (array_slice($b['results'], 0, 50) as $r) { if (!is_array($r)) { continue; } $status = (isset($r['status']) && is_string($r['status'])) ? $r['status'] : ''; if (!in_array($status, array('sent', 'expired', 'key_mismatch', 'failed'), true)) { $status = 'failed'; } // Why a 'failed' device failed, when relaybell knows: its own sign-in with the // push service was refused ('server_auth'), or the push service didn't answer // ('timeout'). Neither is fixed by subscribing again. $reason = (isset($r['reason']) && is_string($r['reason'])) ? $r['reason'] : ''; $out[] = array( 'device' => (isset($r['device']) && is_scalar($r['device'])) ? sanitize_text_field((string) $r['device']) : '', 'status' => $status, 'code' => (isset($r['code']) && is_numeric($r['code'])) ? (int) $r['code'] : null, 'reason' => ($status === 'failed' && in_array($reason, array('server_auth', 'timeout'), true)) ? $reason : '', ); } return $out; } /** * One outcome for the whole test: 'sent' if any device took it, otherwise the * most actionable failure. * * @param mixed $b Decoded response body. * @param array $results From relaybell_test_results(). * @return string 'sent' | 'key_mismatch' | 'expired' | 'failed' | 'server_auth' | 'timeout' */ function relaybell_test_outcome($b, $results) { if (empty($results)) { // Older relaybell servers: counts only. return (is_array($b) && isset($b['sent']) && (int) $b['sent'] === 0) ? 'failed' : 'sent'; } $statuses = wp_list_pluck($results, 'status'); foreach (array('sent', 'key_mismatch', 'expired') as $s) { if (in_array($s, $statuses, true)) { return $s; } } // Every device failed. Only when relaybell knows why for all of them is it not // worth subscribing again. $reasons = wp_list_pluck($results, 'reason'); if (in_array('', $reasons, true)) { return 'failed'; } return in_array('server_auth', $reasons, true) ? 'server_auth' : 'timeout'; } /* ------------------------------------------------------ Settings page */ function relaybell_render_settings() { if (!current_user_can('manage_options')) { return; } $connected = relaybell_is_connected(); $secure = relaybell_site_is_secure(); $sel_types = get_option('relaybell_push_post_types', array('post')); if (!is_array($sel_types)) { $sel_types = array('post'); } // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Display-only status flag set by our own redirects. $msg = isset($_GET['rb_msg']) ? sanitize_key(wp_unslash($_GET['rb_msg'])) : ''; ?>

    ' . esc_html__('Connected! Your site is now set up to collect push subscribers.', 'relaybell') . ' ' . esc_html__('relaybell cleared the page caches of the caching plugins it knows. If your host or a CDN (e.g. Cloudflare) also caches your pages, clear that cache too, so visitors get the updated pages.', 'relaybell') . '

    '; } elseif ($msg === 'disconnected' || $msg === 'disconnected_key' || $msg === 'disconnected_manual') { if ($msg === 'disconnected') { $key_note = __('This site’s API key was also cancelled in relaybell.', 'relaybell'); } elseif ($msg === 'disconnected_key') { $key_note = __('relaybell couldn’t be reached to cancel this site’s API key — revoke it in your relaybell dashboard under Settings → API keys.', 'relaybell'); } else { $key_note = __('The API key you entered still works — if nothing else uses it, revoke it in your relaybell dashboard under Settings → API keys.', 'relaybell'); } echo '

    ' . esc_html__('Disconnected. New page views no longer load relaybell. If your site uses a page cache or a CDN, clear it, so visitors stop getting older copies of your pages that still load it.', 'relaybell') . ' ' . esc_html($key_note) . '

    '; } elseif ($msg === 'denied') { echo '

    ' . esc_html__('Connection cancelled.', 'relaybell') . '

    '; } elseif ($msg === 'state') { echo '

    ' . esc_html__('That connection attempt could not be verified — it may have expired or been started in another tab. Please click Connect again.', 'relaybell') . '

    '; } elseif ($msg === 'failed') { $err = get_transient('relaybell_connect_error_' . get_current_user_id()); delete_transient('relaybell_connect_error_' . get_current_user_id()); echo '

    ' . esc_html__('Could not finish connecting.', 'relaybell') . ' ' . esc_html(is_string($err) ? $err : '') . '

    '; } ?>

    ' . esc_html(sprintf( /* translators: %s: relaybell site name (or its Project ID) */ __('The Project ID saved here belonged to a different relaybell site than your API key, so visitors were subscribing to one site while your posts and test pushes went to another. relaybell now uses the site that matches your API key: %s. If your host or a CDN caches your pages, clear that cache so visitors get the change.', 'relaybell'), (string) $fixed )) . '

    '; } // The Site Address as configured: on an https request WordPress turns // home_url() into https://, which hides an http:// Site Address. $home_raw = wp_parse_url((string) get_option('home')); $home_host = (is_array($home_raw) && isset($home_raw['host'])) ? strtolower($home_raw['host']) : ''; $home_http = is_array($home_raw) && isset($home_raw['scheme']) && strtolower($home_raw['scheme']) === 'http' && !in_array($home_host, array('localhost', '127.0.0.1', '[::1]'), true) && substr($home_host, -10) !== '.localhost'; ?>

      ' . esc_html($name !== '' ? $name : __('relaybell', 'relaybell')) . '', '' . esc_html(number_format_i18n($conn['subs'])) . '' )); } else { relaybell_status_row('rb-conn', 'bad', esc_html($conn['text'])); } if ($conn['ok'] && !empty($conn['blocked'])) { relaybell_status_row('rb-account', 'bad', sprintf( /* translators: %s: link to the relaybell dashboard */ esc_html__('Your relaybell account email isn’t verified, so pushes to your visitors (new posts, campaigns and automations) are blocked. Click the link in the email relaybell sent you — check your spam folder — or send it again from your %s. Test pushes still work.', 'relaybell'), '' . esc_html__('relaybell dashboard', 'relaybell') . '' )); } if ($secure) { relaybell_status_row('rb-https', 'ok', esc_html__('Site uses HTTPS', 'relaybell')); if ($home_http) { relaybell_status_row('rb-home', 'warn', esc_html__('Your Site Address in Settings → General starts with http://. Visitors who reach your site on http:// can’t subscribe — change it to https://.', 'relaybell')); } } else { relaybell_status_row('rb-https', 'bad', esc_html__('This site isn’t on HTTPS — browsers only allow push on HTTPS sites (or localhost). Enable HTTPS so visitors can subscribe.', 'relaybell')); } relaybell_status_row('rb-seen', null, esc_html__('Checking whether relaybell runs on your public pages…', 'relaybell')); relaybell_status_row('rb-cache', 'warn', '', true); relaybell_status_row('rb-sdk', null, esc_html__('Checking that the relaybell script loads in your browser…', 'relaybell')); relaybell_status_row('rb-sw', null, esc_html__('Checking that the service worker registers…', 'relaybell')); relaybell_status_row('rb-browser', null, esc_html__('Checking notifications in this browser…', 'relaybell')); relaybell_status_row('rb-identity', null, esc_html__('Checking that visitor sign-in IDs are verified…', 'relaybell')); ?>

    wp_create_nonce('relaybell_ajax'), 'api' => relaybell_base(), 'pid' => (string) get_option('relaybell_project_id'), 'sw' => relaybell_sw_config(), 't' => array( 'disconnectConfirm' => __('Disconnect this site from relaybell?', 'relaybell'), 'sdkOk' => __('The relaybell script loads in your browser', 'relaybell'), 'sdkBad' => __('Your browser couldn’t load the relaybell script. An ad blocker, firewall or security plugin may be blocking it:', 'relaybell'), 'swOk' => __('Service worker registers', 'relaybell'), 'swBad' => __('The service worker failed to register:', 'relaybell'), 'swSkip' => __('Service worker check skipped — push needs HTTPS (or localhost).', 'relaybell'), 'brOk' => __('This browser is subscribed and set as your test device — “Send test push” will show you a notification', 'relaybell'), 'brNotYet' => __('This browser isn’t subscribed yet. Click “Subscribe this browser” to try it yourself.', 'relaybell'), 'brBlocked' => __('Notifications are blocked for this site in this browser. Click the icon left of the address bar → Site settings → Notifications → Allow, then reload this page.', 'relaybell'), 'brNoAsk' => __('The browser didn’t allow notifications. If no pop-up appeared, look for a bell icon in the address bar and choose Allow, then try again.', 'relaybell'), 'brSkip' => __('Browser check skipped — push isn’t available here (needs HTTPS or localhost).', 'relaybell'), 'brWorking' => __('Subscribing this browser…', 'relaybell'), 'brFail' => __('Couldn’t subscribe this browser:', 'relaybell'), 'brNoConfig' => __('relaybell doesn’t recognise this site’s Project ID. Click Disconnect, then connect again.', 'relaybell'), 'brNoSw' => __('Browser check skipped — this browser can’t subscribe until the service worker problem above is fixed.', 'relaybell'), 'brStale' => __('This browser was subscribed with an earlier relaybell connection, so pushes can’t reach it. Click “Subscribe this browser” to refresh it.', 'relaybell'), 'brRefreshed' => __('This browser was subscribed with an old connection — refreshed it. It’s set as your test device now.', 'relaybell'), 'brMarkFail' => __('This browser is subscribed, but it couldn’t be set as your test device, so test pushes won’t come here yet:', 'relaybell'), /* translators: 1: HTTP status code */ 'markRefused' => __('your site refused the request (HTTP %1$s). Reload this page and try again — if it keeps happening, your hosting firewall may be blocking it.', 'relaybell'), /* translators: 1: HTTP status code, 2: path of the plugin's service worker file */ 'swFirewall' => __('Your hosting firewall blocked relaybell’s file (HTTP %1$s). Ask your host to allow %2$s, or turn off the rule blocking it.', 'relaybell'), /* translators: 1: HTTP status code, 2: path of the plugin's service worker file */ 'swServer' => __('Your server or its firewall refused relaybell’s file (HTTP %1$s). Ask your host to allow %2$s, or turn off the rule blocking it.', 'relaybell'), /* translators: 1: HTTP status code */ 'swAuth' => __('Your site asks for a password before it serves relaybell’s file (HTTP %1$s), so the browser can’t load it. Turn off the password protection (common on staging sites) to use push.', 'relaybell'), 'swMissing' => __('relaybell’s file is missing — reinstall the plugin.', 'relaybell'), /* translators: 1: address of relaybell's service worker on this site */ 'swRoute' => __('Your server answered “not found” (HTTP 404) for relaybell’s service worker address, %1$s, so it never reached WordPress. Ask your host to pass this address to WordPress (on nginx, a rule that answers .js or index.php requests itself is the usual cause), then check that Settings → Permalinks has been saved once.', 'relaybell'), /* translators: 1: path of the plugin's service worker file */ 'swRedirect' => __('Your site redirects relaybell’s file (%1$s) to another address, and browsers refuse that. Exclude it from the redirect rules of your security, caching or redirect plugin, or ask your host.', 'relaybell'), /* translators: 1: content type the server answered with, 2: path of the plugin's service worker file */ 'swType' => __('Your site sent relaybell’s file as “%1$s” instead of JavaScript — a security or caching plugin, or your host’s firewall, may be replacing it. Ask your host to allow %2$s.', 'relaybell'), 'working' => __('Working…', 'relaybell'), 'failed' => __('Request failed', 'relaybell'), 'sentNoWatch' => __('Test push sent to your browser — it should appear within a few seconds.', 'relaybell'), 'sentNoSw' => __('Sent — but this browser can’t receive pushes until the service worker problem above is fixed.', 'relaybell'), 'reached' => __('✓ It reached this browser.', 'relaybell'), 'dontSee' => __('Don’t see it?', 'relaybell'), /* translators: 1: browser name, e.g. Google Chrome */ 'helpWin' => __('Windows may be hiding it. Open Settings → System → Notifications: make sure notifications are on, %1$s is allowed to show them, and Do not disturb is off.', 'relaybell'), /* translators: 1: browser name, e.g. Google Chrome */ 'helpMac' => __('macOS may be hiding it. Open System Settings → Notifications → %1$s: turn on Allow notifications, and make sure Focus is off.', 'relaybell'), /* translators: 1: browser name, e.g. Google Chrome */ 'helpOther' => __('Your device may be hiding it. Check that its notification settings allow %1$s to show notifications, and that Do not disturb is off.', 'relaybell'), 'helpSite' => __('Also check that this site may show notifications: click the icon left of the address bar → Site settings → Notifications → Allow.', 'relaybell'), 'yourBrowser' => __('your browser', 'relaybell'), 'notReceived' => __('The push service accepted it, but this browser didn’t receive it.', 'relaybell'), 'notTestDevice' => __('The test went to your other test devices — this browser isn’t one of them yet. Click “Subscribe this browser” to add it, then send the test again.', 'relaybell'), 'adviceBrave' => __('Brave blocks web push unless you turn on “Use Google services for push messaging” at brave://settings/privacy.', 'relaybell'), 'adviceGeneric' => __('Check that this site isn’t blocked from showing notifications (click the icon left of the address bar → Site settings → Notifications), then send the test again. If it still doesn’t arrive, try Google Chrome.', 'relaybell'), ), // Refresh the catalog / import status while they run in the background. 'syncRunning' => relaybell_sync_running(), ); wp_add_inline_script('relaybell-admin', 'window.relaybellAdmin = ' . wp_json_encode($data) . ';', 'before'); } /* ----------------------------------------------------- Admin AJAX */ add_action('wp_ajax_relaybell_mark_test', function () { check_ajax_referer('relaybell_ajax'); if (!current_user_can('manage_options')) { wp_send_json_error(array('message' => __('Not allowed.', 'relaybell'))); } $key = trim((string) get_option('relaybell_api_key')); // The settings page sends the push address without its "https://" (as `ep`): many // hosting firewalls block a request whose fields contain a web address. Push // addresses are always https. `endpoint` (a full address) is still accepted. // phpcs:disable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitized by esc_url_raw() (https only); sanitize_text_field() would strip the %XX parts some push addresses contain. if (isset($_POST['ep']) && is_string($_POST['ep']) && $_POST['ep'] !== '') { $ep = trim(wp_unslash($_POST['ep'])); $endpoint = esc_url_raw(strpos($ep, '://') === false ? 'https://' . ltrim($ep, '/') : $ep, array('https')); } else { $endpoint = isset($_POST['endpoint']) && is_string($_POST['endpoint']) ? esc_url_raw(wp_unslash($_POST['endpoint'])) : ''; } // phpcs:enable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized if ($key === '') { wp_send_json_error(array('message' => __('Connect relaybell first.', 'relaybell'))); } if ($endpoint === '') { wp_send_json_error(array('message' => __('This browser’s subscription address was missing. Click “Subscribe this browser” again.', 'relaybell'))); } $res = wp_remote_post(relaybell_base() . '/api/v1/test-devices', array( 'headers' => array('Authorization' => 'Bearer ' . $key, 'Content-Type' => 'application/json'), 'body' => wp_json_encode(array('endpoint' => $endpoint)), 'timeout' => 15, )); if (is_wp_error($res)) { /* translators: %s: network error message */ wp_send_json_error(array('message' => sprintf(__('This server couldn’t reach relaybell: %s', 'relaybell'), $res->get_error_message()))); } $code = (int) wp_remote_retrieve_response_code($res); if ($code >= 200 && $code < 300) { wp_send_json_success(); } $b = json_decode(wp_remote_retrieve_body($res), true); $e = (is_array($b) && isset($b['error']) && is_string($b['error'])) ? $b['error'] : ''; if ($e === 'too_many_test_devices') { $msg = __('You already have 20 test devices. In your relaybell dashboard, open the test devices list next to “Send test to me”, remove one, then click “Subscribe this browser” again.', 'relaybell'); } elseif ($e === 'not_subscribed') { $msg = __('relaybell hasn’t saved this browser’s subscription yet. Click “Subscribe this browser” again.', 'relaybell'); } elseif ($code === 401 || $code === 403) { $msg = __('relaybell rejected the API key. Disconnect, then connect again.', 'relaybell'); } else { /* translators: %d: HTTP status code */ $msg = sprintf(__('relaybell returned HTTP %d. Try again in a minute.', 'relaybell'), $code); } wp_send_json_error(array('message' => $msg)); }); add_action('wp_ajax_relaybell_test_push', function () { check_ajax_referer('relaybell_ajax'); if (!current_user_can('manage_options')) { wp_send_json_error(array('message' => __('Not allowed.', 'relaybell'))); } $key = trim((string) get_option('relaybell_api_key')); if ($key === '') { wp_send_json_error(array('message' => __('Connect relaybell first.', 'relaybell'))); } $conn = relaybell_check_connection(); if (!$conn['ok']) { wp_send_json_error(array('message' => $conn['text'])); } if (get_transient('relaybell_pid_fixed') !== false) { // The Project ID was just replaced by the API key's: this page still uses the old one. wp_send_json_error(array('message' => __('relaybell switched this site to the Project ID that matches your API key. Reload this page, click “Subscribe this browser”, then send the test again.', 'relaybell'))); } $title = relaybell_test_push_title(); $body = wp_json_encode(array( 'title' => $title, 'body' => __('If you can see this, your relaybell integration works. 🔔', 'relaybell'), 'url' => home_url('/'), )); // Tests go only to the admin's own test devices — never to real subscribers. $res = wp_remote_post(relaybell_base() . '/api/v1/test-devices/send', array( 'headers' => array('Authorization' => 'Bearer ' . $key, 'Content-Type' => 'application/json'), 'body' => $body, 'timeout' => 20, )); if (is_wp_error($res)) { wp_send_json_error(array('message' => $res->get_error_message())); } $code = (int) wp_remote_retrieve_response_code($res); $b = json_decode(wp_remote_retrieve_body($res), true); $msgs = relaybell_test_messages(); if ($code === 409) { wp_send_json_error(array('message' => $msgs['no_devices'], 'outcome' => 'no_devices')); } if ($code >= 200 && $code < 300) { $results = relaybell_test_results($b); $outcome = relaybell_test_outcome($b, $results); $message = $msgs[$outcome]; if ($outcome === 'failed') { foreach ($results as $r) { if ($r['code']) { /* translators: %d: HTTP status code returned by the browser's push service */ $message .= ' ' . sprintf(__('(The push service answered with error %d.)', 'relaybell'), $r['code']); break; } } } // The page watches for $title being shown by this browser's service worker. $data = array( 'outcome' => $outcome, 'message' => $message, 'title' => $title, 'results' => $results, ); if ((is_array($b) && isset($b['warning']) && $b['warning'] === 'email_not_verified') || !empty($conn['blocked'])) { $data['warning'] = relaybell_unverified_test_warning(); } if ($outcome === 'sent') { wp_send_json_success($data); } wp_send_json_error($data); } wp_send_json_error(array('message' => sprintf( /* translators: %d: HTTP status code */ __('Send failed — relaybell returned HTTP %d.', 'relaybell'), $code ))); }); /** * When relaybell last ran on the site's public pages (GET /api/v1/setup-status). * The wp-admin status panel's own requests don't count (?from=wp-admin), so this * shows whether visitors' pages really load relaybell. * * @return array{state:string,message:string}|null Null when relaybell couldn't be asked. */ function relaybell_seen_status() { $key = trim((string) get_option('relaybell_api_key')); if ($key === '') { return null; } $res = wp_remote_get(relaybell_base() . '/api/v1/setup-status', array( 'headers' => array('Authorization' => 'Bearer ' . $key), 'timeout' => 8, )); $code = is_wp_error($res) ? 0 : (int) wp_remote_retrieve_response_code($res); $b = $code >= 200 && $code < 300 ? json_decode(wp_remote_retrieve_body($res), true) : null; if (!is_array($b) || !array_key_exists('sdkLastSeenAt', $b)) { return null; } $seen = (is_string($b['sdkLastSeenAt']) && $b['sdkLastSeenAt'] !== '') ? strtotime($b['sdkLastSeenAt']) : false; if ($seen) { return array( 'state' => 'ok', /* translators: %s: time span, e.g. "5 mins" */ 'message' => sprintf(__('relaybell last ran on your public pages %s ago', 'relaybell'), human_time_diff($seen, time())), ); } $since = (int) get_option('relaybell_connected_at'); if ($since && time() - $since < 30 * MINUTE_IN_SECONDS) { return array( 'state' => 'info', 'message' => __('relaybell hasn’t run on your public pages yet — normal right after connecting, before anyone visits. To check now, open your site in a private window, wait a few seconds, then click Re-check.', 'relaybell'), ); } return array( 'state' => 'warn', 'message' => __('relaybell hasn’t run on your public pages yet, so visitors can’t subscribe. If your site has had visitors since you connected, your pages aren’t loading relaybell — usually because of a page cache (clear it), a cookie-consent plugin holding scripts back, a security policy, or a theme that leaves out the page footer. Open your site in a private window to check.', 'relaybell'), ); } /** * Status checks that would slow the settings page down if they ran while it is * built: the page asks for them once it has loaded. */ add_action('wp_ajax_relaybell_health', function () { check_ajax_referer('relaybell_ajax'); if (!current_user_can('manage_options')) { wp_send_json_error(array('message' => __('Not allowed.', 'relaybell'))); } if (!relaybell_is_connected()) { wp_send_json_success(array('state' => '')); } // phpcs:disable WordPress.Security.NonceVerification.Missing -- Verified by check_ajax_referer() above. $check = isset($_POST['check']) ? sanitize_key(wp_unslash($_POST['check'])) : ''; $fresh = !empty($_POST['fresh']); // phpcs:enable WordPress.Security.NonceVerification.Missing if ($check === 'seen') { $seen = relaybell_seen_status(); wp_send_json_success($seen ? $seen : array('state' => '')); } if ($check === 'cache') { if (relaybell_cache_state($fresh) === 'stale') { wp_send_json_success(array( 'state' => 'warn', 'message' => __('Visitors are still getting an older copy of your pages — clear your page/CDN cache. relaybell cleared the caching plugins it knows; clear the one in your hosting panel or CDN (e.g. Cloudflare) too.', 'relaybell'), )); } wp_send_json_success(array('state' => '')); } if ($check === 'identity') { // Fetches the secret again on every visit to the settings page (e.g. after Rotate). $id = relaybell_identity_sync(true); if (!$id['ok']) { wp_send_json_success(array( 'state' => 'warn', /* translators: %s: why it failed, e.g. "relaybell returned HTTP 500." */ 'message' => sprintf(__('Visitor sign-in IDs can’t be verified yet: this site couldn’t get its verification key from relaybell. %s Click Re-check to try again.', 'relaybell'), $id['text']), )); } if (!empty($id['enable_failed'])) { wp_send_json_success(array( 'state' => 'warn', 'message' => __('Visitor sign-in IDs aren’t checked yet: relaybell didn’t turn on User ID verification for your project. Click Re-check to try again, or turn it on in your relaybell dashboard (Settings → Developer).', 'relaybell'), )); } if (empty($id['required'])) { wp_send_json_success(array( 'state' => 'info', 'message' => __('Visitor sign-in IDs aren’t checked, because User ID verification is turned off in your relaybell dashboard (Settings → Developer). Turn it on so nobody can pretend to be one of your customers to get their notifications.', 'relaybell'), )); } wp_send_json_success(array('state' => 'ok', 'message' => __('Visitor sign-in IDs are verified', 'relaybell'))); } wp_send_json_error(array('message' => __('Unknown check.', 'relaybell'))); }); /** * The Plugins and Dashboard screens show a stored "account email not verified" * notice; they ask relaybell again (in the background) whether it still applies. */ add_action('wp_ajax_relaybell_account_check', function () { check_ajax_referer('relaybell_ajax'); if (!current_user_can('manage_options') || !relaybell_is_connected()) { wp_send_json_error(); } $conn = relaybell_check_connection(); wp_send_json_success(array('blocked' => $conn['ok'] ? !empty($conn['blocked']) : null)); }); /* ------------------------------------------------ Front end: inject the SDK */ add_action('wp_enqueue_scripts', function () { $pid = trim((string) get_option('relaybell_project_id')); if ($pid === '') { return; } $base = relaybell_base(); $sw = relaybell_sw_config(); $init = array( 'projectId' => $pid, 'apiBase' => $base, 'swPath' => $sw['path'], ); if (!empty($sw['scope'])) { $init['swScope'] = $sw['scope']; } // Identify logged-in shoppers so behavioral targeting works cross-device. Not // site administrators: if a misconfigured page or CDN cache ever stored a page // an admin viewed, every visitor would get the admin's email in the page source // and be filed under the admin's subscriber — the one test pushes go to. $identified = false; if (get_option('relaybell_identify', '1') === '1' && is_user_logged_in() && !current_user_can('manage_options')) { $user = wp_get_current_user(); $email = ($user && !empty($user->user_email)) ? (string) $user->user_email : ''; /** * Filter the externalId passed to the SDK. Return a hashed id to avoid * exposing raw emails in the page source. The WooCommerce integration * applies the same filter to the customer email it sends with order, * checkout, cart, sign-up and refund events — so $user is null there for * a guest order. * * @param string $email The account (or order billing) email. * @param WP_User|null $user The current user / the order's customer; null for a guest order. */ $email = apply_filters('relaybell_external_id', $email, $user); if (is_string($email) && $email !== '') { $init['externalId'] = $email; $identified = true; } if ($user && !empty($user->roles)) { $init['tags'] = array('role' => (string) reset($user->roles)); } } /** * Filter the full SDK init options before they are printed. * * For a logged-in visitor this plugin identified, userHash (identity verification) * is added afterwards for the externalId this filter returns. An id you add for * anyone else isn't signed: set userHash yourself with relaybell_user_hash(). * * @param array $init Init options for RelayBell.init(). */ $init = apply_filters('relaybell_sdk_init', $init); // Sign the id, so relaybell knows this site vouches for it — only an id this plugin // set for a logged-in visitor (never an administrator: see above), as finally printed. if ($identified && is_array($init) && isset($init['externalId']) && !isset($init['userHash'])) { $hash = relaybell_user_hash($init['externalId']); if ($hash !== '') { $init['userHash'] = $hash; } } wp_enqueue_script( 'relaybell-sdk', $base . '/relaybell-sdk.js', array(), RELAYBELL_VERSION, true ); // Start on window load — or right away when the page has already loaded: speed // plugins that delay scripts until the visitor interacts (e.g. LiteSpeed Cache's // "Load JS Delayed") run this after the load event, which would never fire again. // The options are also left in window.relaybellInit: when a speed or consent plugin // runs the relaybell script AFTER this one (out of order, or only after consent), // the script starts itself from them. Either side starts it only if it hasn't been // started yet (RelayBell._opts.projectId is set synchronously by init()). wp_add_inline_script( 'relaybell-sdk', '(function () {' . ' var c = ' . wp_json_encode($init) . ';' . ' window.relaybellInit = c;' . ' var go = function () { var rb = window.RelayBell; if (rb && !(rb._opts && rb._opts.projectId)) { var p = rb.init(c); if (p && p.catch) { p.catch(function (e) { if (window.console) { console.warn(\'relaybell: set-up failed\', e); } }); } } };' . ' if (document.readyState === "complete") { go(); } else { window.addEventListener("load", go); }' . ' })();' ); }); /* ------------------------------ Service worker served by WordPress itself */ // Normally the worker is the static file in assets/. WordPress serves it itself // (with the relaybell host filled in) for: // - index.php?relaybell_sw=1 — when plugin files live on another host (see // relaybell_sw_config()); works with any permalink setting; // - /relaybell-sw.js — where versions ≤ 1.1.x registered it, so those workers stay // alive and keep updating (needs pretty permalinks, as it always did). function relaybell_add_rewrite() { add_rewrite_rule('^relaybell-sw\.js$', 'index.php?relaybell_sw=1', 'top'); } add_action('init', 'relaybell_add_rewrite'); add_filter('query_vars', function ($vars) { $vars[] = 'relaybell_sw'; return $vars; }); /** * Keep the service worker on its exact URL (no trailing-slash redirect — a * service worker served via a redirect is refused by the browser). */ add_filter('redirect_canonical', function ($redirect_url) { return get_query_var('relaybell_sw') ? false : $redirect_url; }); /** * Answer as soon as the request is parsed — before the main query, and before * anything on template_redirect (redirect_canonical would send index.php?… to /?…; * a browser refuses a worker that comes through a redirect). * * @param WP $wp Current WordPress environment. */ add_action('parse_request', function ($wp) { if (empty($wp->query_vars['relaybell_sw'])) { return; } // Never stored by a page cache: the relaybell host inside may change. if (!defined('DONOTCACHEPAGE')) { define('DONOTCACHEPAGE', true); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedConstantFound -- standard constant read by page-cache plugins. } nocache_headers(); status_header(200); header('Content-Type: application/javascript; charset=utf-8'); header('X-Content-Type-Options: nosniff'); header('Service-Worker-Allowed: /'); $js = 'self.__RB_API = ' . wp_json_encode(relaybell_base()) . ";\n" . file_get_contents(__DIR__ . '/assets/relaybell-sw.js'); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Response body is JavaScript, not HTML: a JSON-encoded host plus the plugin's own static file. echo $js; exit; }); /* ---------------------------------------------- Send a push when publishing */ // A publish is noted in transition_post_status, but its push is built and sent only // once the save has finished: the block editor (REST API) fires that hook before it // stores the featured image and categories, so a push built there went out without // its image. A noted publish is sent: // - block editor / REST API saves: after the REST API has saved everything // (rest_after_insert_{post_type}); // - other saves (classic editor, Quick Edit, scheduled posts, WP-CLI…): at the end // of the save (wp_after_insert_post); // - anything still waiting when the request ends (a plugin's own REST route, a // save that skips both hooks, WordPress before 5.6): on shutdown. // Each publish is sent at most once. add_action('transition_post_status', function ($new_status, $old_status, $post) { if ($new_status !== 'publish' || $old_status === 'publish') { return; } if (!relaybell_publish_wanted($post)) { return; } relaybell_publish_queue('add', (int) $post->ID); if (defined('REST_REQUEST') && REST_REQUEST) { add_action('rest_after_insert_' . $post->post_type, 'relaybell_publish_after_rest', 10, 1); } }, 10, 3); /** * Non-REST saves: send once the save has finished. REST saves wait for * relaybell_publish_after_rest() instead — inside a REST save this hook can also run * for a nested save, before the featured image is stored. * * @param int $post_id Post ID. */ add_action('wp_after_insert_post', function ($post_id) { if (defined('REST_REQUEST') && REST_REQUEST) { return; } if (relaybell_publish_queue('take', (int) $post_id)) { relaybell_publish_send((int) $post_id); } }, 10, 1); /** * Block editor / REST API: the post, its featured image and its categories are saved. * * @param WP_Post $post The saved post. * @return void */ function relaybell_publish_after_rest($post) { if ($post instanceof WP_Post && relaybell_publish_queue('take', (int) $post->ID)) { relaybell_publish_send((int) $post->ID); } } /** * Shutdown: send any publish still waiting, on the site it was published on. * * @return void */ function relaybell_publish_send_waiting() { foreach (relaybell_publish_queue('take_all') as $item) { $switched = is_multisite() && (int) $item['blog'] !== get_current_blog_id(); if ($switched) { switch_to_blog((int) $item['blog']); } relaybell_publish_send((int) $item['post']); if ($switched) { restore_current_blog(); } } } /** * This request's publishes waiting for their push. * * @param string $op 'add' notes a publish; 'take' removes one and says whether it * was waiting; 'take_all' removes and returns them all. * @param int $post_id Post ID, for 'add' and 'take' (on the current site). * @return bool|array For 'take_all': list of array('blog' => int, 'post' => int). */ function relaybell_publish_queue($op, $post_id = 0) { static $waiting = array(); static $hooked = false; if ($op === 'take_all') { $all = array_values($waiting); $waiting = array(); return $all; } $blog = get_current_blog_id(); $k = $blog . ':' . (int) $post_id; if ($op === 'take') { if (!isset($waiting[$k])) { return false; } unset($waiting[$k]); return true; } $waiting[$k] = array('blog' => $blog, 'post' => (int) $post_id); if (!$hooked) { $hooked = true; add_action('shutdown', 'relaybell_publish_send_waiting'); } return true; } /** * Should this post get a publish push (checked when it is published, and again just * before sending)? * * @param WP_Post $post The post. * @return bool */ function relaybell_publish_wanted($post) { if (!($post instanceof WP_Post) || $post->post_status !== 'publish') { return false; } // A password-protected post is only for people who have the password: its text // must not go out in a push (or show in relaybell's public notification inbox). if ((string) $post->post_password !== '') { return false; } if (get_option('relaybell_send_on_publish') !== '1') { return false; } $types = get_option('relaybell_push_post_types', array('post')); if (!is_array($types) || empty($types)) { $types = array('post'); } if (!in_array($post->post_type, $types, true)) { return false; } return trim((string) get_option('relaybell_api_key')) !== ''; } /** * Build and send the publish push for a post that is still published and wanted. * * @param int $post_id Post ID. * @return void */ function relaybell_publish_send($post_id) { $post = get_post($post_id); if (!relaybell_publish_wanted($post)) { return; } $key = trim((string) get_option('relaybell_api_key')); $payload = array( 'title' => html_entity_decode(get_the_title($post), ENT_QUOTES), 'body' => wp_trim_words(wp_strip_all_tags($post->post_content), 20, '…'), 'url' => get_permalink($post), ); // Richer push: attach the featured image when present. $image = get_the_post_thumbnail_url($post, 'full'); if ($image) { $payload['image'] = $image; } // Optionally route to a topic matching the primary category slug. relaybell looks // the topic up by its slug (or id); a post whose topic doesn't exist isn't pushed. if (get_option('relaybell_map_category_topic') === '1') { $cats = get_the_terms($post, 'category'); if ($cats && !is_wp_error($cats)) { $primary = reset($cats); /** * Filter the relaybell topic a post is sent to, derived from its primary * category: a topic slug (as shown in relaybell) or a topic id. * * @param string $slug Category slug, used as the topic slug. * @param WP_Term $primary The primary category term. * @param WP_Post $post The post being published. */ $topic = apply_filters('relaybell_category_topic', $primary->slug, $primary, $post); if ($topic) { $payload['topicId'] = $topic; } } } /** * Filter the publish push payload before it is sent. * * @param array $payload Notification body for POST /api/v1/notifications. * @param WP_Post $post The post being published. */ $payload = apply_filters('relaybell_publish_payload', $payload, $post); wp_remote_post(relaybell_base() . '/api/v1/notifications', array( 'headers' => array('Authorization' => 'Bearer ' . $key, 'Content-Type' => 'application/json'), 'body' => wp_json_encode($payload), 'timeout' => 15, 'blocking' => false, )); } /* ------------------------------------------- Flush rewrite rules on activate */ // A connected site's pages change when the plugin is switched on or off: clear // page caches so visitors don't keep getting the previous copy. register_activation_hook(__FILE__, function () { relaybell_add_rewrite(); flush_rewrite_rules(); if (trim((string) get_option('relaybell_project_id')) !== '') { relaybell_purge_page_caches(); } // Switched off and on again: carry on with catalog work that was waiting. if (relaybell_is_connected()) { relaybell_sync_soon(10); } }); register_deactivation_hook(__FILE__, function () { flush_rewrite_rules(); wp_clear_scheduled_hook('relaybell_identity_daily'); wp_clear_scheduled_hook('relaybell_sync_run'); if (trim((string) get_option('relaybell_project_id')) !== '') { relaybell_purge_page_caches(); } }); /* ----------------------------------------- Declare HPOS (custom order tables) */ add_action('before_woocommerce_init', function () { if (class_exists('\Automattic\WooCommerce\Utilities\FeaturesUtil')) { \Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility('custom_order_tables', __FILE__, true); } }); /* ------------------------------------------------- Privacy policy suggestion */ /** * Suggested privacy policy text (Settings → Privacy → Policy guide), so the site can * tell visitors what relaybell receives about them. */ add_action('admin_init', function () { if (!function_exists('wp_add_privacy_policy_content')) { return; } $suggested = array( __('We use relaybell (https://relaybell.com, operated by Nexistech) to send our push notifications. When you subscribe, your browser’s push subscription (an address at your browser’s push service and its encryption keys), the page you subscribed on, your language and your time zone are stored by relaybell.', 'relaybell'), __('While you are subscribed, the posts and products you open on this site are recorded, so our notifications can suggest things you are likely to want. If you are logged in, your account email links your subscription to your account, your orders and what you add to your cart.', 'relaybell'), __('When you place an order, its products, amounts and date are sent to relaybell with your email address, which is used only to find your subscription. relaybell gets no names, postal addresses or phone numbers.', 'relaybell'), __('You can unsubscribe at any time in your browser’s site settings. relaybell’s privacy policy: https://relaybell.com/privacy', 'relaybell'), ); $html = '

    ' . esc_html__('relaybell sends this site’s push notifications. Edit the suggested text to match your settings (for example, without WooCommerce there are no orders).', 'relaybell') . '

    '; foreach ($suggested as $text) { $html .= '

    ' . esc_html__('Suggested text:', 'relaybell') . ' ' . esc_html($text) . '

    '; } wp_add_privacy_policy_content('relaybell', wp_kses_post($html)); }); /* ------------------------------------------- Catalog sync, import, content views */ // Sends products and posts to relaybell's catalog in the background, imports past // orders on request, and records subscribed visitors' post views (catalog.php). require_once __DIR__ . '/catalog.php'; /* ------------------------------------------------------ WooCommerce add-on */ // Loads the WooCommerce event integration (purchase / cart.updated / // back_in_stock / price_drop / signup / refund / checkout) when WooCommerce is // active. No-op on non-Woo sites. // Loaded on plugins_loaded, NOT at include time: WordPress loads plugins in // alphabetical order, so "relaybell" runs before "woocommerce" and the WooCommerce // class doesn't exist yet — woocommerce.php would bail out and none of its hooks // would ever be registered. add_action('plugins_loaded', function () { require_once __DIR__ . '/woocommerce.php'; }, 20);