| 1 |
<?php |
| 2 |
/** |
| 3 |
* Privacy module — the dashboard control for usage-analytics consent. |
| 4 |
* |
| 5 |
* Consent was collectable in exactly one place, the setup wizard, and |
| 6 |
* withdrawable in none: `Onboarding::apply()` was the only writer in Free or |
| 7 |
* Pro, no module schema carried a field for it, and the strings only ever |
| 8 |
* appeared in the wizard bundle. So the wizard's own "Change it anytime from |
| 9 |
* the dashboard" and readme.txt's "disable it later from the xSpeed Cache |
| 10 |
* dashboard" were both untrue — the only way to withdraw consent was to |
| 11 |
* re-run the whole wizard. (#437) |
| 12 |
* |
| 13 |
* This module is that missing control, and being a Module rather than a |
| 14 |
* bespoke panel is what gives it the dashboard, `wp xspeed privacy` and MCP |
| 15 |
* `run_command` in one go (IMPLEMENTATION.md §17). |
| 16 |
* |
| 17 |
* The setting is a VIEW over WP Insights' own `wpins_allow_tracking` row, not |
| 18 |
* a copy of it: |
| 19 |
* - reads come from the tracker via `xspeed_setting_external_source`, so |
| 20 |
* the panel can never disagree with what the tracker actually believes; |
| 21 |
* - writes go through `Usage_Tracker::opt_in()` on `xspeed_settings_saved`, |
| 22 |
* because consent is not just a flag — opting in schedules the cron and |
| 23 |
* registers the install, opting out clears the cron. Writing the option |
| 24 |
* row alone would leave a site "opted out" in the UI with the daily send |
| 25 |
* still scheduled. |
| 26 |
* |
| 27 |
* @package XSpeed |
| 28 |
*/ |
| 29 |
|
| 30 |
declare(strict_types=1); |
| 31 |
|
| 32 |
namespace XSpeed\Modules\Privacy; |
| 33 |
|
| 34 |
defined( 'ABSPATH' ) || exit; |
| 35 |
|
| 36 |
use XSpeed\Module; |
| 37 |
use XSpeed\Plugin; |
| 38 |
|
| 39 |
final class PrivacyModule extends Module { |
| 40 |
|
| 41 |
public const SLUG = 'privacy'; |
| 42 |
public const TIER = self::TIER_FREE; |
| 43 |
public const VERSION = '1.0.0'; |
| 44 |
|
| 45 |
public function ui_metadata(): array { |
| 46 |
return array( |
| 47 |
'label' => __( 'Privacy & usage data', 'xspeed' ), |
| 48 |
'icon' => 'ShieldCheck', |
| 49 |
'description' => __( 'Control the anonymous usage analytics you were asked about in the setup wizard.', 'xspeed' ), |
| 50 |
); |
| 51 |
} |
| 52 |
|
| 53 |
public function settings_schema(): array { |
| 54 |
return array( |
| 55 |
'usage_tracking' => array( |
| 56 |
'type' => 'bool', |
| 57 |
'default' => false, |
| 58 |
'label' => __( 'Share anonymous usage data', 'xspeed' ), |
| 59 |
'description' => __( 'Share anonymous basics — WordPress & PHP version, active theme & plugins, server type, and which features you enable. Never personal data or page content. Turning this off stops all collection and clears the scheduled send.', 'xspeed' ), |
| 60 |
), |
| 61 |
); |
| 62 |
} |
| 63 |
|
| 64 |
public function boot(): void { |
| 65 |
// Read the tracker's own state rather than our option row. The row is |
| 66 |
// still written (Settings_Manager owns that), but it is never the |
| 67 |
// source of truth: a site that opted in through the wizard has no |
| 68 |
// row at all, and would otherwise render as opted out. |
| 69 |
add_filter( 'xspeed_setting_external_source', array( $this, 'read_consent' ), 10, 3 ); |
| 70 |
add_action( 'xspeed_settings_saved', array( $this, 'apply_consent' ), 10, 2 ); |
| 71 |
} |
| 72 |
|
| 73 |
/** |
| 74 |
* Answer reads of `privacy.usage_tracking` from the tracker. |
| 75 |
* |
| 76 |
* @param mixed $value Value resolved so far (null = not ours). |
| 77 |
* @param string $slug Module slug being read. |
| 78 |
* @param string $key Setting key being read. |
| 79 |
* @return mixed |
| 80 |
*/ |
| 81 |
public function read_consent( $value, $slug, $key ) { |
| 82 |
if ( self::SLUG !== $slug || 'usage_tracking' !== $key ) { |
| 83 |
return $value; |
| 84 |
} |
| 85 |
$tracker = $this->tracker(); |
| 86 |
|
| 87 |
return ( $tracker && method_exists( $tracker, 'is_opted_in' ) ) ? (bool) $tracker->is_opted_in() : false; |
| 88 |
} |
| 89 |
|
| 90 |
/** |
| 91 |
* The consent authority. |
| 92 |
* |
| 93 |
* Filterable so this module can be exercised against a double — the |
| 94 |
* Plugin accessor is typed to the concrete Usage_Tracker, so there is |
| 95 |
* otherwise no seam that does not require booting the whole singleton. |
| 96 |
* |
| 97 |
* @return object|null |
| 98 |
*/ |
| 99 |
private function tracker() { |
| 100 |
/** |
| 101 |
* Filter the object that answers usage-analytics consent. |
| 102 |
* |
| 103 |
* @param object|null $tracker Usage_Tracker instance, or null. |
| 104 |
*/ |
| 105 |
return apply_filters( 'xspeed_usage_consent_tracker', Plugin::instance()->usage_tracker() ); |
| 106 |
} |
| 107 |
|
| 108 |
/** |
| 109 |
* Route a save through the tracker so the cron follows the flag. |
| 110 |
* |
| 111 |
* Only acts when the key was actually part of the save: a write to some |
| 112 |
* other module, or a partial save that never mentioned consent, must not |
| 113 |
* be read as the admin revoking it. |
| 114 |
* |
| 115 |
* @param string $slug Module whose settings were saved. |
| 116 |
* @param array<string,mixed> $clean Settings as stored. |
| 117 |
*/ |
| 118 |
public function apply_consent( $slug, $clean ): void { |
| 119 |
if ( self::SLUG !== $slug || ! is_array( $clean ) || ! array_key_exists( 'usage_tracking', $clean ) ) { |
| 120 |
return; |
| 121 |
} |
| 122 |
$tracker = $this->tracker(); |
| 123 |
if ( ! $tracker || ! method_exists( $tracker, 'opt_in' ) ) { |
| 124 |
return; |
| 125 |
} |
| 126 |
$wanted = ! empty( $clean['usage_tracking'] ); |
| 127 |
// opt_in( true ) sends immediately to register the install, so only |
| 128 |
// call it on an actual change — re-saving an unrelated field on this |
| 129 |
// panel must not fire a payload. |
| 130 |
if ( method_exists( $tracker, 'is_opted_in' ) && (bool) $tracker->is_opted_in() === $wanted ) { |
| 131 |
return; |
| 132 |
} |
| 133 |
$tracker->opt_in( $wanted ); |
| 134 |
} |
| 135 |
|
| 136 |
/** |
| 137 |
* CLI surface — and with it MCP, which dispatches to these same |
| 138 |
* callbacks. `status` is the minimum every module owes |
| 139 |
* tests/e2e/50-cli-mcp-coverage.spec.ts. |
| 140 |
*/ |
| 141 |
public function cli_commands(): array { |
| 142 |
return array( |
| 143 |
array( |
| 144 |
'name' => 'xspeed privacy', |
| 145 |
'callback' => array( $this, 'cli_privacy' ), |
| 146 |
'shortdesc' => 'Show or change usage-analytics consent.', |
| 147 |
'ai_hint' => 'Whether this site shares anonymous usage analytics, and the way to turn that on or off. Use for "am I sending telemetry", "stop sharing usage data", or any consent/privacy question about analytics.', |
| 148 |
'synopsis' => array( |
| 149 |
array( |
| 150 |
'type' => 'positional', |
| 151 |
'name' => 'action', |
| 152 |
'optional' => true, |
| 153 |
'options' => array( 'status', 'enable', 'disable' ), |
| 154 |
), |
| 155 |
), |
| 156 |
), |
| 157 |
); |
| 158 |
} |
| 159 |
|
| 160 |
/** |
| 161 |
* @param array<int,string> $args Positional args. |
| 162 |
* @param array<string,string> $assoc_args Flags. |
| 163 |
*/ |
| 164 |
public function cli_privacy( $args = array(), $assoc_args = array() ): void { |
| 165 |
unset( $assoc_args ); |
| 166 |
$action = isset( $args[0] ) ? (string) $args[0] : 'status'; |
| 167 |
$tracker = $this->tracker(); |
| 168 |
if ( ! $tracker ) { |
| 169 |
\WP_CLI::error( 'Usage tracker unavailable.' ); |
| 170 |
return; |
| 171 |
} |
| 172 |
|
| 173 |
if ( 'enable' === $action || 'disable' === $action ) { |
| 174 |
$this->update_settings( array( 'usage_tracking' => 'enable' === $action ) ); |
| 175 |
} |
| 176 |
|
| 177 |
$on = method_exists( $tracker, 'is_opted_in' ) && $tracker->is_opted_in(); |
| 178 |
\WP_CLI::log( 'usage analytics: ' . ( $on ? 'on' : 'off' ) ); |
| 179 |
\WP_CLI::log( 'scheduled send: ' . ( wp_next_scheduled( \XSpeed\Usage_Tracker::EVENT_HOOK ) ? 'scheduled' : 'none' ) ); |
| 180 |
} |
| 181 |
} |
| 182 |
|