Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion connect/flow.php
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ function confirm($context_slug = '') {
/** Everything that should happen once a site is connected. */
function after_connected($plugin_name) {
if ($plugin_name !== '' && function_exists('tangible\\onboarding\\mark')) {
\tangible\onboarding\mark($plugin_name, 'connect', 'done');
\tangible\onboarding\mark($plugin_name, STEP_ID, 'done');
}
// The update check is cached ~12h; refresh soon so the onboarding block
// arrives — in cron, not in this request (which is already waiting on the
Expand Down
41 changes: 35 additions & 6 deletions connect/index.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,16 +31,45 @@
const HEADER = 'X-Tangible-Site-Token';
const FLOW_TTL = 15 * MINUTE_IN_SECONDS;

/** API host. Override per site with TANGIBLE_CLOUD_API in wp-config.php. */
/**
* The origin of TANGIBLE_CLOUD_URL — the updater's switch, e.g.
* `https://api.staging.tangible.one/api/edd` → `https://api.staging.tangible.one`.
* A site pointed at staging for licences must not Connect to prod by default.
*/
function cloud_url_origin() {
return defined('TANGIBLE_CLOUD_URL') ? url_origin((string) TANGIBLE_CLOUD_URL) : '';
}

/** scheme://host of a URL, or '' when it has neither. */
function url_origin($url) {
$parts = wp_parse_url($url);
if (empty($parts['scheme']) || empty($parts['host'])) return '';
return $parts['scheme'] . '://' . $parts['host'];
}

/** The app for an API origin: its `api.` label dropped. */
function app_for_api_origin($origin) {
return preg_replace('#^(https?://)api\.#', '$1', $origin);
}

/**
* API host. Override per site with TANGIBLE_CLOUD_API in wp-config.php; else
* the origin of TANGIBLE_CLOUD_URL; else production.
*/
function api_base() {
$base = defined('TANGIBLE_CLOUD_API') ? TANGIBLE_CLOUD_API : 'https://api.tangible.one/';
return rtrim((string) $base, '/');
if (defined('TANGIBLE_CLOUD_API')) return rtrim((string) TANGIBLE_CLOUD_API, '/');
return cloud_url_origin() ?: 'https://api.tangible.one';
}

/** The tangible.one app (consent screen). Override with TANGIBLE_CONNECT_APP_URL. */
/**
* The tangible.one app (consent screen). Override with TANGIBLE_CONNECT_APP_URL;
* else TANGIBLE_CLOUD_URL's host with its `api.` label dropped
* (api.staging.tangible.one → staging.tangible.one); else production.
*/
function app_base() {
$base = defined('TANGIBLE_CONNECT_APP_URL') ? TANGIBLE_CONNECT_APP_URL : 'https://tangible.one';
return rtrim((string) $base, '/');
if (defined('TANGIBLE_CONNECT_APP_URL')) return rtrim((string) TANGIBLE_CONNECT_APP_URL, '/');
$origin = cloud_url_origin();
return $origin ? app_for_api_origin($origin) : 'https://tangible.one';
}

function get_state() {
Expand Down
54 changes: 49 additions & 5 deletions connect/step.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,21 +11,42 @@
* leaves wp-admin by top-level navigation. It returns false so nothing is
* recorded yet; the return handler marks the step done once the site is
* actually connected.
*
* Pending — approved on tangible.one, but the platform could not reach this
* site to verify it (a local dev site, a blocked REST API) — is its own state:
* the step says so plainly, offers "Check again", and Skip moves on without
* dropping the pending connection, which completes whenever verification does.
*
* The id is `tangible_connect`, not `connect`: plugins own the short names, and
* SearchSync's backend step is already called `connect` (two steps with one id
* is a fatal in resolve_plan).
*/
namespace tangible\connect;

use tangible\framework;
use tangible\onboarding;

const STEP_ID = 'tangible_connect';

/** Why a pending connection is still pending, in words. */
function pending_reason() {
$host = strtolower((string) wp_parse_url(home_url(), PHP_URL_HOST));
$local = function_exists('tangible\\onboarding\\is_dev_site') && onboarding\is_dev_site();
if ($local) {
return sprintf('tangible.one has to reach %s to verify it, and a local development site is not reachable from the internet.', $host);
}
return sprintf('tangible.one could not reach %s/wp-json/ to verify it. Check that the site is public and its REST API is not blocked.', $host);
}

add_filter('tangible_onboarding_steps', function ($steps, $facts, $plugin_name = '') {
if (!function_exists('tangible\\onboarding\\registered_wizards')) return $steps;
if (empty(onboarding\registered_wizards()[$plugin_name])) return $steps;
$plugin = function_exists('tangible\\framework\\get_plugin') ? framework\get_plugin($plugin_name) : null;
if (!onboarding\is_free_distribution($plugin)) return $steps;

$steps[] = [
'id' => 'connect',
'label' => 'connect',
'id' => STEP_ID,
'label' => 'tangible.one',
'weight' => 15, // after licence (10), before consent (20)
'scope' => 'site',
'skippable' => true,
Expand All @@ -34,6 +55,23 @@
'submit_label' => 'Connect to tangible.one',
'render' => function () {
$pending = get_token() !== '' && !is_connected();
if ($pending) :
$account = (string) (get_state()['account_name'] ?? '');
?>
<h2>Approved — waiting for tangible.one to verify this site</h2>
<p class="step-intro">You approved the connection<?php
echo $account !== '' ? ' to <b>' . esc_html($account) . '</b>' : ''; ?>. One step is left:
<?php echo esc_html(pending_reason()); ?></p>
<div class="tgbl-dbl"></div>
<ul class="tgbl-next">
<li>Check again once the site is reachable.</li>
<li>Or skip for now: the connection stays waiting and finishes by itself when verification succeeds.</li>
<li>Connecting again starts a fresh request.</li>
</ul>
<p><button type="submit" class="button" name="connect_check" value="1">Check again</button></p>
<?php
return;
endif;
?>
<h2>Connect this site to tangible.one</h2>
<p class="step-intro">Optional. Connecting links this site — and every free Tangible plugin
Expand All @@ -45,12 +83,18 @@
<li>No licence key, and licensed plugins are not affected.</li>
<li>Disconnect any time from the Tangible Home page or your tangible.one account.</li>
</ul>
<?php if ($pending) : ?>
<p class="whisper">A connection is waiting for tangible.one to verify this site. Continuing starts a fresh one.</p>
<?php endif;
<?php
},
'handle' => function ($plugin) use ($plugin_name) {
if (!current_user_can('manage_options')) return false;
// "Check again" on a pending connection: ask the platform to verify.
if (!empty($_POST['connect_check']) && get_token() !== '' && !is_connected()) {
if (confirm($plugin_name) === 'active') {
after_connected($plugin_name);
return true;
}
return new \WP_Error('tangible_connect_pending', 'Still waiting: ' . pending_reason());
}
$return_to = function_exists('tangible\\onboarding\\get_setup_url') && $plugin
? onboarding\get_setup_url($plugin) : admin_url('admin.php?page=tangible-home');
onboarding\pending_redirect(start_flow(get_current_user_id(), $return_to, $plugin_name));
Expand Down
4 changes: 4 additions & 0 deletions hub/page.php
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,10 @@ function render_site_block($steward, $connect) {
<?php post_form('tangible_hub_steward', 'A client', 'hy-link', [ 'steward' => 'client' ]); ?>
</div>
<?php endif; ?>
<?php // The per-site usage-data refusal (ADR-011 §3), one answer for every Tangible plugin here.
if (function_exists('tangible\\onboarding\\render_site_telemetry_setting')) {
\tangible\onboarding\render_site_telemetry_setting('steward', 'hy-link');
} ?>
</div>
<div class="blk">
<div class="top"><span class="hy-lbl">tangible.one</span></div>
Expand Down
1 change: 1 addition & 0 deletions onboarding/index.php
Original file line number Diff line number Diff line change
Expand Up @@ -284,3 +284,4 @@ function unmark($plugin_name, $step_id) {

require_once __DIR__ . '/wizard.php';
require_once __DIR__ . '/steps.php';
require_once __DIR__ . '/site-telemetry.php';
78 changes: 78 additions & 0 deletions onboarding/site-telemetry.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
<?php
/**
* "Don't send usage data from this site" — the per-site refusal (ADR-011 §3).
*
* One answer per site, shared by every Tangible plugin on it, kept in the same
* site-wide consent outbox entry the onboarder's consent step writes
* (`telemetry_extended`, the wire key's old name). Changing it takes effect
* locally at once and is delivered to tangible.one like any consent answer:
* keyed and connected sites report it as the site's exclusion for the account,
* an anonymous site as evidence.
*
* Surfaces: the "This site" block in Tangible Home, and any plugin's own
* settings through `render_site_telemetry_setting()` — the refusal must be as
* easy to find as the plugin's settings.
*/
namespace tangible\onboarding;

use tangible\framework;

const SITE_TELEMETRY_ACTION = 'tangible_site_telemetry';

const SITE_TELEMETRY_ON_TEXT =
'Send usage data from this site to Tangible (site setting, telemetry v2)';
const SITE_TELEMETRY_OFF_TEXT =
"Don't send usage data from this site to Tangible (site setting, telemetry v2)";

/** The site said no, in the wizard or in a setting. */
function site_telemetry_refused() {
return get_consent_answer('telemetry_extended') === 'declined';
}

/**
* Record the site's answer and try to deliver it now through any registered
* Tangible plugin (the outbox is site-wide; the first delivery marks it synced).
*/
function set_site_telemetry($send) {
record_consent_answer(
'telemetry_extended',
$send ? 'granted' : 'declined',
$send ? SITE_TELEMETRY_ON_TEXT : SITE_TELEMETRY_OFF_TEXT
);
if (!function_exists('tangible\\framework\\get_plugin')) return;
foreach (array_keys(registered_wizards()) as $name) {
$plugin = framework\get_plugin($name);
if ($plugin) attempt_consent_sync($plugin);
}
}

/**
* The setting, for a plugin's settings page or Tangible Home: one line of
* state and one button that flips it. Posts to admin-post.php and returns to
* the page it was rendered on.
*/
function render_site_telemetry_setting($text_class = 'tgbl-site-telemetry', $button_class = 'button-link') {
$refused = site_telemetry_refused();
?>
<p class="<?php echo esc_attr($text_class); ?>">
<?php echo $refused
? 'Usage data is <b>not sent</b> from this site, by any Tangible plugin.'
: 'Tangible plugins on this site send usage data — performance timings, which features are used, content counts. Never content, users or visitors.'; ?>
</p>
<form class="inline" method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
<input type="hidden" name="action" value="<?php echo esc_attr(SITE_TELEMETRY_ACTION); ?>" />
<input type="hidden" name="send" value="<?php echo $refused ? '1' : '0'; ?>" />
<?php wp_nonce_field(SITE_TELEMETRY_ACTION); ?>
<button type="submit" class="<?php echo esc_attr($button_class); ?>"><?php
echo esc_html($refused ? 'Send usage data from this site' : "Don't send usage data from this site"); ?></button>
</form>
<?php
}

add_action('admin_post_' . SITE_TELEMETRY_ACTION, function () {
if (!current_user_can('manage_options')) wp_die('Nope', 403);
check_admin_referer(SITE_TELEMETRY_ACTION);
set_site_telemetry(($_POST['send'] ?? '') === '1');
wp_safe_redirect(wp_get_referer() ?: admin_url());
exit;
});
37 changes: 18 additions & 19 deletions onboarding/steps.php
Original file line number Diff line number Diff line change
Expand Up @@ -256,8 +256,9 @@ function is_dev_site() {
}

const TELEMETRY_CONSENT_TEXT =
'Share anonymous performance and usage data — execution times, which features are used, '
. 'content counts, a role histogram. Never content, names, or visitor data. (extended telemetry v1)';
'Share usage data from this site — performance timings, which features are used, content counts, '
. 'a role histogram and version basics. Never content, names, or visitor data. One answer for every '
. 'Tangible plugin on the site. (telemetry v2, site answer)';

const MARKETING_CONSENT_TEXT =
'Email me release notes and product updates from Tangible. Unsubscribe any time. '
Expand Down Expand Up @@ -418,7 +419,13 @@ function render_answer_pair($field, $question, $detail) {
}

// ── Consent: one step, two asks, asked separately, never bundled ─────────
$ask_telemetry = ($facts->ask['telemetry_extended'] ?? null) === 'ask';
// Telemetry (ADR-011 §4): only an UNCONNECTED FREE plugin is asked, once
// per site. A licensed or connected site's account accepted the Terms, which
// are the grant — whatever an older server's block says. Its refusal is the
// per-site setting (site-telemetry.php), not a wizard question.
$wizard_plugin = function_exists('tangible\\framework\\get_plugin') ? framework\get_plugin($plugin_name) : null;
$ask_telemetry = is_anonymous($wizard_plugin)
&& ($facts->ask['telemetry_extended'] ?? null) === 'ask';
$ask_marketing = ($facts->ask['marketing'] ?? null) === 'ask';

$steps[] = [
Expand All @@ -434,11 +441,8 @@ function render_answer_pair($field, $question, $detail) {
},
'skip_note' => 'on file',
'render' => function ($plugin) use ($ask_telemetry, $ask_marketing) {
// The tier boundary, kept visibly straight: on a paid build, BASIC
// telemetry (WP/PHP/plugin versions, site URL) is already held as a
// term of purchase — checkout was the portal for it. This step asks
// for EXTENDED — usage data — so the payload shown is the extended
// payload: real values from this site, none of them version basics.
// The payload shown is real values from this site: the facts that
// would be sent, not a policy link.
$roles = function_exists('wp_roles') ? count(wp_roles()->roles) : 0;
$posts = wp_count_posts('post'); $pages = wp_count_posts('page');
$payload = [
Expand All @@ -447,18 +451,17 @@ function render_answer_pair($field, $question, $detail) {
'active plugins' => number_format_i18n(count((array) get_option('active_plugins', []))),
'locale' => get_locale(),
];
$free = is_free_distribution($plugin);
$paid = !empty($plugin->cloud_id) && !$free;
$both = $ask_telemetry && $ask_marketing;
?>
<h2>Two optional things</h2>
<p class="step-intro"><strong>No</strong> is a complete answer to both — the plugin works
<h2><?php echo $both ? 'Two optional things' : 'One optional thing'; ?></h2>
<p class="step-intro"><strong>No</strong> is a complete answer<?php echo $both ? ' to both' : ''; ?> — the plugin works
exactly the same either way. We ask rather than assume, so you do have to answer.</p>
<div class="tgbl-dbl"></div>

<?php if ($ask_telemetry) {
render_answer_pair('telemetry_extended',
'Share performance and usage data?',
'Execution times and performance timings, which features you use, content counts, a role histogram. Never your content, your users, or your visitors.');
'Share usage data from this site?',
'Performance timings, which features you use, content counts, a role histogram, and version basics (WordPress, PHP, plugin). Never your content, your users, or your visitors. One answer for every Tangible plugin here — change it any time in Tangible Home.');
// Show the payload, not a policy link — this site's actual numbers.
?>
<table style="margin:10px 0 0; border-collapse:collapse">
Expand All @@ -469,11 +472,7 @@ function render_answer_pair($field, $question, $detail) {
</tr>
<?php endforeach; ?>
</table>
<?php if ($paid) : ?>
<p class="whisper">Version and environment basics (WordPress, PHP,
plugin version) are already shared under your licence terms — this question is about
the rest.</p>
<?php endif;
<?php
} ?>

<?php if ($ask_marketing) {
Expand Down
Loading
Loading