/private_html/operanse-data on Cloudways, or /var/www/operanse-data next to /var/www/html. It takes effect on the next page load."; public function __construct( private Plugin $plugin ) {} public function register(): void { add_action( 'admin_notices', array( $this, 'render' ) ); add_action( 'admin_post_' . self::ACTION_DISMISS, array( $this, 'handle_dismiss' ) ); } /** * Which notices apply, in the order they are shown. Pure: everything it reads is a parameter. * * @param string $tier The spool tier in use this request. * @param string|null $reason Why (ADR-035 §2), or null for a decision that predates the key. * @param string|null $configured_dir `OPERANSE_DATA_DIR` as defined, or null. * @param bool $wp_cron_disabled Whether `DISABLE_WP_CRON` is set. * @param bool $paired Whether the site is paired (the cron verdict is meaningless before). * @param bool $recording Whether events are being recorded, which is whether the site is paired. * Before pairing nothing is spooled (ADR-072 §8 Q1), so the database tier * costs a visitor request nothing and is not worth a notice. * @param array{state:string,message:string} $cron CronProbe::assess() for this site. * @param Settings $settings Holds the dismissals. * @param bool $on_own_page On Settings → Operanse every applicable notice is shown, dismissed or not. * @return list */ public static function decide( string $tier, ?string $reason, ?string $configured_dir, bool $wp_cron_disabled, bool $paired, bool $recording, array $cron, Settings $settings, bool $on_own_page ): array { $out = array(); if ( $tier === 'database' && $recording ) { $why = self::spool_reason_sentence( $reason, $configured_dir ); $out[] = array( 'key' => self::KEY_SPOOL_DATABASE, 'level' => 'error', 'title' => 'Operanse is buffering events in your database, which costs a query on the requests that produce them.', 'body' => $why . ' While this is so, every order, payment, webhook and form event is written to a bounded table in your database during the request that produced it, and request sampling is switched off. ' . self::DATA_DIR_INSTRUCTION, 'condition' => self::KEY_SPOOL_DATABASE . '|' . ( $reason ?? '' ) . '|' . ( $configured_dir ?? '' ), ); } if ( $paired && $cron['state'] !== 'ok' && $cron['state'] !== 'never' ) { $out[] = array( 'key' => self::KEY_CRON_FAILING, 'level' => 'error', 'title' => 'WP-Cron is not running reliably on this site, so Operanse is not collecting or uploading.', 'body' => $cron['message'] . ' ' . CronProbe::SYSTEM_CRON_INSTRUCTIONS, 'condition' => self::KEY_CRON_FAILING . '|' . $cron['state'], ); } elseif ( ! $wp_cron_disabled ) { $out[] = array( 'key' => self::KEY_CRON_BUILTIN, 'level' => 'warning', 'title' => 'Operanse runs on WordPress\'s built-in cron trigger. A system cron is more reliable and cheaper for your visitors.', 'body' => 'The built-in trigger runs scheduled work inside a visitor\'s request and depends on a loopback request that fails on many hosts. ' . CronProbe::SYSTEM_CRON_INSTRUCTIONS, 'condition' => self::KEY_CRON_BUILTIN, ); } if ( $on_own_page ) { return $out; } return array_values( array_filter( $out, static fn( array $n ): bool => $settings->get( 'notice_dismissed_' . $n['key'] ) !== self::condition_id( $n['condition'] ) ) ); } /** Events are recorded when the site is paired (the flag `Recorder` reads). */ public static function recording( Plugin $plugin ): bool { return $plugin->credentials()->is_paired() && $plugin->settings()->events_enabled() !== false; } /** Whether notices may appear on this request at all: the plugin's own page, the Dashboard or the Plugins screen. */ public static function shows_on( ?string $screen_id, bool $on_own_page ): bool { return $on_own_page || ( $screen_id !== null && in_array( $screen_id, self::SCREENS, true ) ); } /** What a dismissal stores and the dismiss link carries: short, and free of the paths a condition can contain. */ public static function condition_id( string $condition ): string { return substr( hash( 'sha256', $condition ), 0, 16 ); } /** The first sentence of the spool notice: what went wrong, in the customer's terms. */ private static function spool_reason_sentence( ?string $reason, ?string $configured_dir ): string { if ( $configured_dir !== null && $configured_dir !== '' ) { return 'OPERANSE_DATA_DIR is set to "' . $configured_dir . '" but Operanse cannot use it: the path is inside your web root, or the web server user cannot create and write to it.'; } return match ( $reason ) { 'uploads_privacy_unproven' => 'Operanse could not prove that a directory under wp-content/uploads is unreachable over HTTP (a blocked loopback request, a CDN or firewall challenge, or a genuinely public uploads folder all look the same from inside the site), and it will not store telemetry anywhere it cannot prove is private.', 'uploads_not_writable' => 'The directory Operanse created under wp-content/uploads is no longer writable.', 'no_uploads_dir' => 'WordPress reported no usable uploads directory.', 'probe_not_allowed_here' => 'Operanse has not yet been able to check for a private directory; this usually resolves itself within minutes, and if it does not, the line below settles it.', default => 'Operanse could not find a private directory outside your web root.', }; } public function render(): void { try { if ( ! current_user_can( 'manage_options' ) ) { return; } $own_page = isset( $_GET['page'] ) && sanitize_key( wp_unslash( (string) $_GET['page'] ) ) === SettingsPage::SLUG; // phpcs:ignore WordPress.Security.NonceVerification -- read-only: which admin screen this is $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null; if ( ! self::shows_on( $screen !== null ? (string) $screen->id : null, $own_page ) ) { return; } $settings = $this->plugin->settings(); $spool = $this->plugin->spool(); $locator = new StorageLocator( $settings ); $notices = self::decide( $spool->tier(), $locator->reason(), $locator->configured_dir(), CronProbe::wp_cron_disabled(), $this->plugin->credentials()->is_paired(), self::recording( $this->plugin ), CronProbe::assess( $settings, time(), $this->plugin->credentials()->paired_at() ), $settings, $own_page ); foreach ( $notices as $n ) { echo '

Operanse: ' . esc_html( $n['title'] ) . '

' . esc_html( $n['body'] ) . '

'; if ( ! $own_page ) { echo '

Operanse settings'; $id = self::condition_id( $n['condition'] ); $url = wp_nonce_url( admin_url( 'admin-post.php?action=' . self::ACTION_DISMISS . '¬ice=' . $n['key'] . '&condition=' . $id ), self::ACTION_DISMISS . '_' . $n['key'] . '_' . $id ); echo ' · Dismiss

'; } echo '
'; } } catch ( \Throwable $e ) { // An admin notice must never take wp-admin down with it. $this->plugin->settings()->record_internal_error( 'admin_notices: ' . $e->getMessage() ); } } /** * Stores the dismissal for the condition the notice was shown for, so the same notice for the same reason stays * hidden and a new reason is shown. A plain link rather than WordPress's `is-dismissible` cross, which only hides * the notice until the next page load unless it is wired to an AJAX call; this works without JavaScript. */ public function handle_dismiss(): void { if ( ! current_user_can( 'manage_options' ) ) { wp_die( 'Forbidden', '', array( 'response' => 403 ) ); } $key = isset( $_GET['notice'] ) ? sanitize_key( wp_unslash( (string) $_GET['notice'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification if ( ! in_array( $key, self::KEYS, true ) ) { wp_die( 'Unknown notice', '', array( 'response' => 400 ) ); } $id = isset( $_GET['condition'] ) ? sanitize_key( wp_unslash( (string) $_GET['condition'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification if ( preg_match( '/^[0-9a-f]{16}$/', $id ) !== 1 ) { wp_die( 'Unknown condition', '', array( 'response' => 400 ) ); } check_admin_referer( self::ACTION_DISMISS . '_' . $key . '_' . $id ); $this->plugin->settings()->set( 'notice_dismissed_' . $key, $id ); $back = wp_get_referer(); wp_safe_redirect( is_string( $back ) && $back !== '' ? $back : admin_url() ); exit; } }