diff --git a/.wordpress-org/banner-1544x500.png b/.wordpress-org/banner-1544x500.png new file mode 100644 index 000000000..3d5e7ab98 Binary files /dev/null and b/.wordpress-org/banner-1544x500.png differ diff --git a/.wordpress-org/banner-772x250.png b/.wordpress-org/banner-772x250.png new file mode 100644 index 000000000..4d104e2b5 Binary files /dev/null and b/.wordpress-org/banner-772x250.png differ diff --git a/.wordpress-org/icon-128x128.png b/.wordpress-org/icon-128x128.png new file mode 100644 index 000000000..e3540e17a Binary files /dev/null and b/.wordpress-org/icon-128x128.png differ diff --git a/.wordpress-org/icon-256x256.png b/.wordpress-org/icon-256x256.png new file mode 100644 index 000000000..b9938d949 Binary files /dev/null and b/.wordpress-org/icon-256x256.png differ diff --git a/.wordpress-org/icon.svg b/.wordpress-org/icon.svg new file mode 100644 index 000000000..3a654762a --- /dev/null +++ b/.wordpress-org/icon.svg @@ -0,0 +1,52 @@ + + + + diff --git a/.wordpress-org/screenshot-1.jpg b/.wordpress-org/screenshot-1.jpg new file mode 100644 index 000000000..6d84a31db Binary files /dev/null and b/.wordpress-org/screenshot-1.jpg differ diff --git a/.wordpress-org/screenshot-2.jpg b/.wordpress-org/screenshot-2.jpg new file mode 100644 index 000000000..efc729869 Binary files /dev/null and b/.wordpress-org/screenshot-2.jpg differ diff --git a/.wordpress-org/screenshot-3.jpg b/.wordpress-org/screenshot-3.jpg new file mode 100644 index 000000000..3b7824e3a Binary files /dev/null and b/.wordpress-org/screenshot-3.jpg differ diff --git a/.wordpress-org/screenshot-4.jpg b/.wordpress-org/screenshot-4.jpg new file mode 100644 index 000000000..856b89654 Binary files /dev/null and b/.wordpress-org/screenshot-4.jpg differ diff --git a/.wordpress-org/screenshot-5.jpg b/.wordpress-org/screenshot-5.jpg new file mode 100644 index 000000000..b9dd30e67 Binary files /dev/null and b/.wordpress-org/screenshot-5.jpg differ diff --git a/.wordpress-org/screenshot-6.jpg b/.wordpress-org/screenshot-6.jpg new file mode 100644 index 000000000..9b587cb11 Binary files /dev/null and b/.wordpress-org/screenshot-6.jpg differ diff --git a/.wordpress-org/screenshot-7.jpg b/.wordpress-org/screenshot-7.jpg new file mode 100644 index 000000000..3522dbbb0 Binary files /dev/null and b/.wordpress-org/screenshot-7.jpg differ diff --git a/changelog.txt b/changelog.txt index 45596109c..3d0322f88 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,4 +1,15 @@ == Changelog == += 4.5.1 - 2026-11-12 = +* ENHANCEMENT - fresh, unconfigured activations and the Plugins screen lead directly to Stateless Connect; manual bucket and service-account settings remain available under Advanced. +* ENHANCEMENT - the recommended setup creates a project and US multi-region bucket with CDN mode, while existing projects, buckets, locations and modes remain available under Advanced. +* ENHANCEMENT - Google sign-in stays in the same tab, setup explains billing and key custody, and missing billing has a Check again path that preserves the storage choices. +* ENHANCEMENT - storage setup shows its progress on the Connect screen and no longer depends on one long request: it survives slow Google operations, proxy timeouts and page reloads, and an interrupted setup resumes its saved project, bucket and key instead of starting over. +* NEW - privacy-limited setup events measure actual Connect clicks through provisioning and the first verified upload without recording site addresses, email addresses, project IDs, bucket names or free-form errors. +* FIX - storage drafts restore the selected project and bucket after a failed attempt, and new project names are generated in Google's accepted format. +* FIX - the result of a storage setup, including the explanation when it fails, stays on the Connect screen until the next action, so reloading the page no longer loses it. +* FIX - a storage setup that is interrupted or replaced by another no longer leaves a service account key behind: the next setup or a disconnect revokes it, and a revocation that fails is retried. +* SECURITY - stateless.ci excludes OAuth callback query parameters from its load-balancer log sinks and retains only pseudonymous, short-lived setup-funnel records. + = 4.5.0 - 2026-11-02 = * NEW - Stateless Connect replaces the setup wizard: sign in with Google from wp-admin, confirm your site on stateless.ci, and WP-Stateless creates the project, bucket, service account and key inside your own Google Cloud account. No iframe, no copying of keys. * NEW - setup works from Network Admin on multisite, with network and per-site connections kept separate. diff --git a/changes.md b/changes.md index e79822962..6ba8485b2 100644 --- a/changes.md +++ b/changes.md @@ -1,3 +1,14 @@ +#### 4.5.1 - 2026-11-12 +* ENHANCEMENT - fresh, unconfigured activations and the Plugins screen lead directly to Stateless Connect; manual bucket and service-account settings remain available under Advanced. +* ENHANCEMENT - the recommended setup creates a project and US multi-region bucket with CDN mode, while existing projects, buckets, locations and modes remain available under Advanced. +* ENHANCEMENT - Google sign-in stays in the same tab, setup explains billing and key custody, and missing billing has a Check again path that preserves the storage choices. +* ENHANCEMENT - storage setup shows its progress on the Connect screen and no longer depends on one long request: it survives slow Google operations, proxy timeouts and page reloads, and an interrupted setup resumes its saved project, bucket and key instead of starting over. +* NEW - privacy-limited setup events measure actual Connect clicks through provisioning and the first verified upload without recording site addresses, email addresses, project IDs, bucket names or free-form errors. +* FIX - storage drafts restore the selected project and bucket after a failed attempt, and new project names are generated in Google's accepted format. +* FIX - the result of a storage setup, including the explanation when it fails, stays on the Connect screen until the next action, so reloading the page no longer loses it. +* FIX - a storage setup that is interrupted or replaced by another no longer leaves a service account key behind: the next setup or a disconnect revokes it, and a revocation that fails is retried. +* SECURITY - stateless.ci excludes OAuth callback query parameters from its load-balancer log sinks and retains only pseudonymous, short-lived setup-funnel records. + #### 4.5.0 - 2026-11-02 * NEW - Stateless Connect replaces the setup wizard: sign in with Google from wp-admin, confirm your site on stateless.ci, and WP-Stateless creates the project, bucket, service account and key inside your own Google Cloud account. No iframe, no copying of keys. * NEW - setup works from Network Admin on multisite, with network and per-site connections kept separate. diff --git a/lib/classes/class-bootstrap.php b/lib/classes/class-bootstrap.php index 9be112c39..d35a17494 100644 --- a/lib/classes/class-bootstrap.php +++ b/lib/classes/class-bootstrap.php @@ -1760,35 +1760,30 @@ public function wp_delete_site($old_site) { * @param string $plugin */ public function redirect_to_splash($plugin = '') { - // $this->settings = new Settings(); + $configured = $this->settings->get('sm.key_json') && $this->settings->get('sm.bucket'); - if (defined('WP_CLI') || $this->settings->get('sm.key_json') || isset($_POST['checked']) && count($_POST['checked']) > 1) { + if (defined('WP_CLI') || $configured || isset($_POST['checked']) && count($_POST['checked']) > 1) { + return; + } + + if (GoogleConnect::hidden_for_site() && !is_network_admin()) { return; } if ( - !$this->settings->get('sm.key_json') && defined('WP_STATELESS_MEDIA_HIDE_SETUP_ASSISTANT') && WP_STATELESS_MEDIA_HIDE_SETUP_ASSISTANT == true && defined('WP_STATELESS_MEDIA_HIDE_SETTINGS_PANEL') && WP_STATELESS_MEDIA_HIDE_SETTINGS_PANEL == true ) { return; } - if (!$this->settings->get('sm.key_json') && defined('WP_STATELESS_MEDIA_HIDE_SETUP_ASSISTANT') && WP_STATELESS_MEDIA_HIDE_SETUP_ASSISTANT == true) { + if (defined('WP_STATELESS_MEDIA_HIDE_SETUP_ASSISTANT') && WP_STATELESS_MEDIA_HIDE_SETUP_ASSISTANT == true) { $url = $this->get_settings_page_url('?page=stateless-settings'); - exit(wp_redirect($url)); + exit(wp_safe_redirect($url)); } if ($plugin == plugin_basename($this->boot_file)) { - // #152 - // $url = $this->get_settings_page_url('?page=stateless-setup&step=splash-screen'); - $url = $this->get_settings_page_url('?page=stateless-settings'); - - if (json_decode($this->settings->get('sm.key_json'))) { - $url = $this->get_settings_page_url('?page=stateless-settings'); - } - - exit( wp_redirect($url) ); + exit(wp_safe_redirect(GoogleConnect::instance()->page_url())); } } diff --git a/lib/classes/class-google-connect.php b/lib/classes/class-google-connect.php index 20288a9eb..b8b634821 100644 --- a/lib/classes/class-google-connect.php +++ b/lib/classes/class-google-connect.php @@ -24,6 +24,11 @@ class GoogleConnect { const PROOFS_OPTION = 'sm_gc_proofs'; const LAST_OPTION = 'sm_gc_last'; const PENDING_KEYS_OPTION = 'sm_gc_pending_key_revocations'; + /** A key Google issued during a setup that has not finished yet, kept encrypted so a resumed setup reuses it. */ + const SETUP_KEY_OPTION = 'sm_gc_pending_setup_key'; + /** Seconds after which a claim on a setup scope runs out where the database cannot tell that its owner died. */ + const PROVISION_STALE = 900; + const FUNNEL_OPTION = 'sm_gc_funnel'; const BROKER = 'https://stateless.ci'; const SCOPES = 'openid email https://www.googleapis.com/auth/cloud-platform'; const AUTH_ENDPOINT = 'https://accounts.google.com/o/oauth2/v2/auth'; @@ -34,22 +39,31 @@ class GoogleConnect { /** @var string|null */ private $page_hook = null; private $network_hook = null; + private $provision_job = null; + private $provision_lock = false; + private $scope_taken = false; + private $scope_since = 0; /** @var string Why the hosted sign-in could not be prepared, for the page to show. */ public $start_error = ''; protected function __construct() { add_action('rest_api_init', array($this, 'rest_routes')); + add_action('sm_gc_funnel_cleanup', array($this, 'clear_expired_funnel'), 10, 2); + add_filter('wp_stateless_media_synced', array($this, 'track_first_upload'), 999, 4); if (!is_admin()) return; add_action('admin_init', array($this, 'redirect_legacy_setup')); add_action('admin_menu', array($this, 'admin_menu'), 20); add_action('network_admin_menu', array($this, 'network_admin_menu'), 20); add_action('admin_enqueue_scripts', array($this, 'enqueue')); + add_action('admin_post_sm_gc_begin', array($this, 'handle_begin')); add_action('admin_post_sm_gc_client', array($this, 'handle_client')); add_action('admin_post_sm_gc_finish', array($this, 'handle_finish')); add_action('admin_post_sm_gc_provision', array($this, 'handle_provision')); + add_action('admin_post_sm_gc_billing_recheck', array($this, 'handle_billing_recheck')); add_action('admin_post_sm_gc_disconnect', array($this, 'handle_disconnect')); add_action('admin_post_sm_gc_restart', array($this, 'handle_restart')); + add_action('wp_ajax_sm_gc_provision_status', array($this, 'ajax_provision_status')); add_action('wp_ajax_sm_gc_buckets', array($this, 'ajax_buckets')); add_action('wp_ajax_sm_gc_project_billing', array($this, 'ajax_project_billing')); } @@ -112,13 +126,24 @@ private function read_option($key, $default = false) { } private function write_option($key, $value, $autoload = false) { + $this->assert_provision_lock(); return $this->network_scope() ? update_network_option(get_current_network_id(), $key, $value) : update_option($key, $value, $autoload); } private function remove_option($key) { + $this->assert_provision_lock(); return $this->network_scope() ? delete_network_option(get_current_network_id(), $key) : delete_option($key); } + /** + * Makes the next read of an option come from the database. WordPress answers option reads from the copy a + * request already has, which is wrong for the few records that requests use to learn what another one did + * in the meantime. + */ + private function reread_option($key, $network_id = 0) { + $network_id ? wp_cache_delete($network_id . ':' . $key, 'site-options') : wp_cache_delete($key, 'options'); + } + private function state_key($key) { if (!is_multisite()) return 'sm_gc_' . $key; return 'sm_gc_' . ($this->network_scope() ? 'network_' . get_current_network_id() : 'site_' . get_current_blog_id()) . '_' . $key; @@ -163,10 +188,7 @@ public function render_page() { $client = $this->get_client(); $connection = $this->get_connection(); $uid = get_current_user_id(); - $log = $this->state_get('log', $uid); - $error = $this->state_get('error', $uid); - $this->state_delete('log', $uid); - $this->state_delete('error', $uid); + list($log, $error) = $this->last_result($uid); $auth_url = ''; $projects = array(); @@ -223,10 +245,21 @@ public function render_page() { $requires_stateless = $this->requires_stateless_mode(); $draft = $this->state_get('draft'); + if (is_array($draft)) { + if (isset($draft['project'])) $default_project = $draft['project']; + if (isset($draft['bucket_name'])) $default_bucket = $draft['bucket_name']; + } $settings = (object) ud_get_stateless_media()->get('sm'); if ($this->network_scope()) { foreach (array('bucket', 'key_json', 'mode') as $key) $settings->$key = $this->read_option('sm_' . $key, $key === 'mode' ? 'disabled' : ''); } + if ($default_project === '' && (empty($settings->key_json) || empty($settings->bucket))) $default_project = '__new__'; + $suggested_project = preg_replace('/[^a-zA-Z0-9\x20\x21\x22\x27-]/', '-', get_bloginfo('name')); + $suggested_project = trim(substr(preg_replace('/\s+/', ' ', $suggested_project), 0, 30)); + if (strlen($suggested_project) < 4) $suggested_project = trim(substr($suggested_project . ' media', 0, 30)); + if (strlen($suggested_project) < 4) $suggested_project = 'WordPress media'; + $billing_recovery = is_array($draft) && !empty($draft['billing_required']) && !empty($draft['project']) && $draft['project'] !== '__new__'; + $billing_unavailable = $default_project === '__new__' && !$billing; include ud_get_stateless_media()->path('static/views/connect.php', 'dir'); } @@ -448,8 +481,262 @@ public static function is_hosted_redirect($uri) { return strpos($uri, 'http://127.0.0.1') !== 0 && strpos($uri, 'http://localhost') !== 0 && strpos($uri, 'http://[::1]') !== 0; } + public function handle_begin() { + $this->guard(); + $this->clear_result(); + if ($this->get_connection()) $this->done(array('sm_gc' => 'connected')); + $client = $this->get_client(); + if (!$client) $this->fail(__('OAuth client is not configured.', ud_get_stateless_media()->domain)); + $auth_url = $this->build_auth_url($client, get_current_user_id()); + if (!$auth_url || strpos($auth_url, self::AUTH_ENDPOINT . '?') !== 0) { + $this->fail($this->start_error ?: __('The sign-in could not be prepared. Check the connection to the sign-in service and try again.', ud_get_stateless_media()->domain)); + } + $this->track_event('clicked'); + wp_redirect($auth_url, 303); + exit; + } + + private function funnel_valid($state) { + if (!is_array($state) || empty($state['txn']) || empty($state['token']) || empty($state['created']) || !is_numeric($state['created'])) return false; + if (!is_string($state['txn']) || !is_string($state['token'])) return false; + if (!preg_match('/^[A-Za-z0-9_-]{32}$/', $state['txn']) || !preg_match('/^[A-Za-z0-9_-]{32}$/', $state['token'])) return false; + return (int) $state['created'] >= time() - DAY_IN_SECONDS; + } + + private function funnel_option($uid = 0) { + return $uid ? self::FUNNEL_OPTION . '_u_' . (int) $uid : self::FUNNEL_OPTION; + } + + /** The sign-in attempt on record for this scope, or for one administrator, as it was saved. Null when there is none. */ + private function funnel_stored($network_id = 0, $uid = 0) { + $option = $this->funnel_option($uid); + return $network_id && is_multisite() ? get_network_option($network_id, $option, null) : get_option($option, null); + } + + private function funnel_state($network_id = 0, $uid = 0) { + $state = $this->funnel_stored($network_id, $uid); + if ($state === null) return null; + if (!$this->funnel_valid($state)) { + $this->forget_funnel($network_id, $state, true, $uid); + return null; + } + return $state; + } + + /** + * Removes the sign-in attempt on record. With $conditional only while it still is $expected, so that an + * attempt another request saved in the meantime stays. + */ + private function forget_funnel($network_id = 0, $expected = null, $conditional = false, $uid = 0) { + $option = $this->funnel_option($uid); + $network = $network_id && is_multisite(); + if ($conditional) { + // Compare with the record as it is now, not as this request first saw it: a bulk sync runs for minutes. + // What remains is the instant between this look and the removal, and what is at stake is one measurement. + $this->reread_option($option, $network ? $network_id : 0); + $stored = $this->funnel_stored($network_id, $uid); + if ($stored === null || maybe_serialize($stored) !== maybe_serialize($expected)) return false; + return $network ? delete_network_option($network_id, $option) : delete_option($option); + } + $network ? delete_network_option($network_id, $option) : delete_option($option); + return true; + } + + private function schedule_funnel_cleanup($created, $network_id, $uid = 0) { + if (!wp_next_scheduled('sm_gc_funnel_cleanup', array($network_id, $uid))) { + wp_schedule_single_event((int) $created + DAY_IN_SECONDS, 'sm_gc_funnel_cleanup', array($network_id, $uid)); + } + } + + public function clear_expired_funnel($network_id = 0, $uid = 0) { + $stored = $this->funnel_stored((int) $network_id, $uid); + if ($stored === null) return; + if (!$this->funnel_valid($stored)) { + if (!$this->forget_funnel($network_id, $stored, true, $uid)) { + $current = $this->funnel_state($network_id, $uid); + if ($current) $this->schedule_funnel_cleanup($current['created'], $network_id, $uid); + } + return; + } + $this->schedule_funnel_cleanup($stored['created'], $network_id, $uid); + } + + private function remember_funnel($txn, $token) { + $network_id = $this->network_scope() ? get_current_network_id() : 0; + $uid = get_current_user_id(); + $state = array('txn' => $txn, 'token' => $token, 'created' => time()); + $this->write_option($this->funnel_option($uid), $state, false); + $this->schedule_funnel_cleanup($state['created'], $network_id, $uid); + } + + private function track_event($event, $reason = '', $network_id = null, $expected = null) { + if ($network_id === null) $network_id = $this->network_scope() ? get_current_network_id() : 0; + if ($expected === null && in_array($event, array('viewed', 'clicked'), true)) { + // Viewing and clicking belong to this administrator's own sign-in attempt. Every later milestone + // belongs to the attempt that connected the site, whoever is signed in when it happens. + $state = $this->funnel_state($network_id, get_current_user_id()); + } else { + $state = $this->funnel_state($network_id); + } + if (!$state) return false; + if ($expected !== null) { + if (!is_array($expected) || empty($expected['txn']) || empty($expected['token']) || !is_string($expected['txn']) || !is_string($expected['token'])) return false; + if (!hash_equals($expected['txn'], $state['txn']) || !hash_equals($expected['token'], $state['token'])) return false; + } + $body = array('txn' => $state['txn'], 'verify_token' => $state['token'], 'event' => $event); + if ($reason !== '') $body['reason'] = $reason; + $response = wp_remote_post(self::broker_url() . '/connect/google/event', array( + 'timeout' => 2, + 'user-agent' => 'WP-Stateless/' . Bootstrap::$version, + 'body' => $body, + )); + if (is_wp_error($response) || (int) wp_remote_retrieve_response_code($response) !== 200) return false; + $data = json_decode(wp_remote_retrieve_body($response), true); + return is_array($data) && !empty($data['ok']); + } + + /** + * Sync attempts seen in this request while a setup funnel is open. WordPress saves attachment metadata + * several times while it generates image sizes, so the outcome is judged once, after the last pass. + */ + private $funnel_uploads = array(); + + /** @var array Setup attempts already told about a failed upload by this process. */ + private $funnel_failures = array(); + + /** @var array Setup attempts whose first upload this process has already reported, confirmed or not. */ + private $funnel_done = array(); + + /** @var int Sync attempts this process has judged. A bulk run is no longer measured after 100. */ + private $funnel_judged = 0; + + /** @var array When this process last found no setup attempt to measure on a site, keyed by blog ID. */ + private $funnel_idle = array(); + + public function track_first_upload($metadata, $attachment_id, $force = false, $args = array()) { + $blog_id = get_current_blog_id(); + if ($this->funnel_judged >= 100) return $metadata; + // A site with no setup attempt open is looked up at most once a minute by a process, not on every sync. + if (isset($this->funnel_idle[$blog_id]) && time() - $this->funnel_idle[$blog_id] < MINUTE_IN_SECONDS) return $metadata; + $network_id = $this->network_scope() ? get_current_network_id() : 0; + $state = $this->funnel_state($network_id); + if (!$state && is_multisite()) { + $candidate = get_current_network_id(); + $network_bucket = get_network_option($candidate, 'sm_bucket', ''); + if ($network_bucket !== '' && $network_bucket === ud_get_stateless_media()->get('sm.bucket')) { + $state = $this->funnel_state($candidate); + if ($state) $network_id = $candidate; + } + } + if (!$state) { + $this->funnel_idle[$blog_id] = time(); + return $metadata; + } + $attempt = $network_id . ':' . $state['txn']; + if (!empty($this->funnel_done[$attempt])) return $metadata; + $key = $blog_id . ':' . (int) $attachment_id; + // A bulk sync can touch thousands of attachments in one process. The ones seen earlier are finished + // by the time another arrives, so judge those now and keep the list short. + if (!isset($this->funnel_uploads[$key]) && count($this->funnel_uploads) >= 10) { + $this->report_first_upload(); + if ($this->funnel_judged >= 100 || !empty($this->funnel_done[$attempt])) return $metadata; + } + if (!$this->funnel_uploads) add_action('shutdown', array($this, 'report_first_upload'), 1000); + $this->funnel_uploads[$key] = array($blog_id, (int) $attachment_id, $network_id, $state); + return $metadata; + } + + public function report_first_upload() { + $pending = $this->funnel_uploads; + $this->funnel_uploads = array(); + // Send the response first where the server allows it, so the checks below never hold up an upload. + if ($pending && function_exists('fastcgi_finish_request') && doing_action('shutdown')) fastcgi_finish_request(); + foreach ($pending as $upload) { + list($blog_id, $attachment_id, $network_id, $state) = $upload; + $attempt = $network_id . ':' . $state['txn']; + if (!empty($this->funnel_done[$attempt])) continue; + $this->funnel_judged++; + $switched = is_multisite() && get_current_blog_id() !== $blog_id; + if ($switched) switch_to_blog($blog_id); + try { + try { + $result = $this->first_upload_result($attachment_id, $state); + } catch (\Throwable $e) { + $result = 'failed'; + } + if ($result === 'failed' && empty($this->funnel_failures[$attempt])) { + $this->funnel_failures[$attempt] = true; + $this->track_event('upload_failed', 'storage', $network_id, $state); + } + if ($result === 'uploaded') { + // One report per process. If it is not confirmed the record stays open for a later request. + $this->funnel_done[$attempt] = true; + if ($this->track_event('first_upload', '', $network_id, $state)) $this->forget_funnel($network_id, $state, true); + } + } catch (\Throwable $e) { + // Setup measurement must never interrupt a sync. + } finally { + if ($switched) restore_current_blog(); + } + } + } + + /** + * 'uploaded' when the original and every generated size are in the bucket and the original was written + * after setup started, 'failed' when an upload failed or an object is missing, '' when nothing new was sent. + */ + private function first_upload_result($attachment_id, $state) { + if (Utility::upload_errors($attachment_id)) return 'failed'; + $cloud = get_post_meta($attachment_id, 'sm_cloud', true); + if (empty($cloud['name']) || empty($cloud['bucket'])) return ''; + // Syncing media that was already in the bucket is not a first upload. Allow for clock drift. + $object = ud_get_stateless_media()->get_client()->get_media($cloud['name']); + if (!$object) return 'failed'; + $written = !empty($object->timeCreated) ? strtotime($object->timeCreated) : 0; + if (!$written || $written < (int) $state['created'] - 5 * MINUTE_IN_SECONDS) return ''; + return Utility::missing_cloud_object($attachment_id) !== '' ? 'failed' : 'uploaded'; + } + + public function handle_billing_recheck() { + $this->guard(); + $this->clear_result(); + if (!$this->get_connection()) $this->fail(__('Sign in to Google first.', ud_get_stateless_media()->domain)); + $uid = get_current_user_id(); + $draft = $this->state_get('draft', $uid); + $project = is_array($draft) && !empty($draft['project']) ? $draft['project'] : ''; + if (!preg_match('/^[a-z][a-z0-9-]{4,29}$/', $project)) { + $this->fail(__('Choose a Google Cloud project before checking its billing status.', ud_get_stateless_media()->domain)); + } + $info = $this->api('GET', 'https://cloudbilling.googleapis.com/v1/projects/' . rawurlencode($project) . '/billingInfo'); + if (is_wp_error($info)) { + $this->fail(__('Billing status could not be checked. Confirm the project billing in Google Cloud, then try again.', ud_get_stateless_media()->domain)); + } + if (empty($info['billingEnabled'])) { + $draft['billing_required'] = true; + $this->state_set('draft', $draft, DAY_IN_SECONDS, $uid); + $this->fail(__('Billing is not enabled for this project yet. Enable it in Google Cloud, then choose Check again. Your storage choices are saved.', ud_get_stateless_media()->domain)); + } + unset($draft['billing_required']); + $this->state_set('draft', $draft, DAY_IN_SECONDS, $uid); + $this->state_delete('error', $uid); + $this->done(array('sm_gc' => 'billing-ready')); + } + + private function provisioning_reason($e) { + if ($e->getCode() === 1001) return 'billing_missing'; + $message = strtolower($e->getMessage()); + if (strpos($message, 'billing') !== false) return 'billing_missing'; + if (strpos($message, 'already taken') !== false) return 'bucket_taken'; + if (strpos($message, 'bucket name') !== false) return 'bucket_name'; + if (strpos($message, 'public access') !== false) return 'public_access'; + if (strpos($message, 'forbidden') !== false || strpos($message, 'permission') !== false) return 'permission_denied'; + if (strpos($message, 'credential') !== false || strpos($message, 'key') !== false) return 'credentials'; + return 'google_api'; + } + public function handle_client() { $this->guard(); + $this->clear_result(); $id = isset($_POST['client_id']) ? trim(sanitize_text_field($_POST['client_id'])) : ''; $secret = isset($_POST['client_secret']) ? trim(sanitize_text_field($_POST['client_secret'])) : ''; if (!$id || !$secret) { @@ -548,6 +835,8 @@ private function broker_auth_url($uid) { 'auth_url' => $r['auth_url'], 'registered' => time(), ), 20 * MINUTE_IN_SECONDS, $uid); + $this->remember_funnel($r['txn'], $r['verify_token']); + $this->track_event('viewed'); return $r['auth_url']; } @@ -611,7 +900,7 @@ public function handle_restart() { $pkce = $this->state_get('pkce'); if (!empty($pkce['site_verifier']) && !empty($pkce['state'])) $this->proof_delete($pkce['state']); $this->state_delete('pkce'); - $this->state_delete('error'); + $this->clear_result(); $this->done(array()); } @@ -636,9 +925,9 @@ public function maybe_finish_from_return() { } private function finish($pasted) { + $this->clear_result(); // A stale form or a second tab must not turn an established connection into an error. if ($this->get_connection()) { - $this->state_delete('error'); $this->done(array('sm_gc' => 'connected')); } $client = $this->get_client(); @@ -730,6 +1019,13 @@ private function store_connection($client, $token, $email, $broker_conn, $uid) { $connection['broker_conn'] = $this->encrypt(wp_json_encode($broker_conn)); } $this->write_option(self::OPTION, $connection, false); + $network_id = $this->network_scope() ? get_current_network_id() : 0; + $pending = $this->funnel_state($network_id, $uid); + if ($pending) { + $this->write_option(self::FUNNEL_OPTION, $pending, false); + $this->schedule_funnel_cleanup($pending['created'], $network_id); + $this->forget_funnel($network_id, $pending, true, $uid); + } $this->state_delete('pkce', $uid); $this->state_delete('error', $uid); $this->done(array('sm_gc' => 'connected')); @@ -751,16 +1047,37 @@ public function connection_unreadable($connection = null) { public function handle_disconnect() { $this->guard(); + $this->clear_result(); $connection = $this->get_connection(); $domain = ud_get_stateless_media()->domain; + // A disconnect and a setup worker must not overlap: disconnecting removes the key an unfinished setup saved, + // which a worker in flight could be about to store as the site's configuration. So the disconnect takes the + // scope the way a setup does and keeps it until this request ends; no setup can start meanwhile either. + if (!$this->take_scope(2)) { + $this->fail(__('A storage setup is still running. Wait for it to finish, then disconnect.', $domain)); + } $unreadable = $connection && $this->connection_unreadable($connection); if ($unreadable && empty($_POST['sm_gc_force'])) { // Nothing can be revoked from here, so do not report that it was. $this->fail(__('The stored Google credentials cannot be read, usually because the WordPress authentication salts changed or the sodium extension is gone, so this site cannot revoke its own access. Restore the previous salts and disconnect again, or revoke access under Third-party apps in your Google Account and then remove the connection without revoking.', $domain)); } + $stuck = array(); if ($connection && !$unreadable) { + // Before this site gives up its access, it removes the keys it still has on record at Google: the key of + // a setup that stopped after Google issued it (disconnecting ends that setup), and keys whose removal + // failed before. When one cannot be removed for a reason that passes, the connection is kept, so that + // disconnecting again finishes the job instead of leaving a key that nothing can remove any more. + $this->discard_setup_key(true); + $steps = array(); + $stuck = $this->retry_pending_revocations($steps); + foreach ($stuck as $error) { + if (self::worth_retrying($error)) { + $this->fail(sprintf(__('A service account key this site no longer uses could not be removed at Google yet (%s). The connection was kept so you can try again.', $domain), $error->get_error_message())); + } + } $problems = array(); $refresh = $this->decrypt($connection['refresh_token']); + $this->assert_provision_lock(); if ($refresh) { // 200 means revoked. Google answers 400 invalid_token for a grant that is already gone, which is the same outcome. $res = wp_remote_post(self::REVOKE_ENDPOINT, array('body' => array('token' => $refresh), 'timeout' => 20)); @@ -771,6 +1088,7 @@ public function handle_disconnect() { } } $conn = $this->broker_conn($connection); + $this->assert_provision_lock(); if ($conn) { $r = $this->broker_request('/connect/google/disconnect', array('connection_id' => $conn['id'], 'connection_secret' => $conn['secret'])); // ok, or 401 invalid_connection when the broker already forgot this connection. @@ -784,8 +1102,23 @@ public function handle_disconnect() { // Keep the connection so the revocation can be retried instead of reporting success while access remains. $this->fail(sprintf(__('Access could not be revoked yet: %s. The connection was kept so you can try again.', $domain), implode('; ', $problems))); } + } else { + $this->discard_setup_key(false); // without a readable connection only the stored copy can be removed } $this->remove_option(self::OPTION); + $network_id = $this->network_scope() ? get_current_network_id() : 0; + $this->forget_funnel($network_id); + $this->forget_funnel($network_id, null, false, get_current_user_id()); + if ($stuck) { + // Google refused to remove a key and repeating the request would not change that, so the disconnect went + // ahead. The key stays on record for the next setup; meanwhile say which one it is. + $names = array(); + foreach (array_keys($stuck) as $name) { + list($email, $key_id) = array_pad(explode('/', $name, 2), 2, ''); + $names[] = sprintf('%1$s (%2$s)', $key_id, $email); + } + $this->fail(sprintf(__('Google account disconnected and access revoked. Google did not let this site remove a service account key it no longer uses (%1$s). Delete it in the Google Cloud console under IAM, Service Accounts: %2$s.', $domain), current($stuck)->get_error_message(), implode(', ', $names))); + } $this->done(array('sm_gc' => $unreadable ? 'disconnected-unrevoked' : 'disconnected')); } @@ -794,9 +1127,24 @@ public function handle_disconnect() { /* Lives in user meta, not transients: a misconfigured object cache */ /* drops transients between requests (seen on a dev pod whose memcached */ /* was unreachable), and the PKCE verifier must survive from the */ - /* sign-in click to the return. The error and log are read once. */ + /* sign-in click to the return. */ /* ------------------------------------------------------------------ */ + /** + * The outcome of the last action, for the screen: the steps of a setup and, when it failed, the explanation. + * Showing it does not use it up. It stays until the next action starts or it expires, so neither a reload nor + * a reload that crosses the end of a setup loses the explanation or a warning in the steps. + */ + private function last_result($uid) { + return array($this->state_get('log', $uid), $this->state_get('error', $uid)); + } + + /** Every action starts by dropping the previous outcome; it reports its own when it ends. */ + private function clear_result() { + $this->state_delete('error'); + $this->state_delete('log'); + } + private function state_get($key, $uid = null) { $uid = $uid ? $uid : get_current_user_id(); $rec = get_user_meta($uid, $this->state_key($key), true); @@ -809,11 +1157,13 @@ private function state_get($key, $uid = null) { } private function state_set($key, $value, $ttl, $uid = null) { + $this->assert_provision_lock(); $uid = $uid ? $uid : get_current_user_id(); update_user_meta($uid, $this->state_key($key), array('v' => $value, 'exp' => time() + $ttl)); } private function state_delete($key, $uid = null) { + $this->assert_provision_lock(); $uid = $uid ? $uid : get_current_user_id(); delete_user_meta($uid, $this->state_key($key)); } @@ -908,6 +1258,7 @@ private function access_token() { /* ------------------------------------------------------------------ */ public function api($method, $url, $body = null) { + $this->assert_provision_lock(); $token = $this->access_token(); if (is_wp_error($token)) return $token; $args = array( @@ -921,6 +1272,7 @@ public function api($method, $url, $body = null) { ); if ($body !== null) $args['body'] = wp_json_encode($body); $res = wp_remote_request($url, $args); + $this->assert_provision_lock(); if (is_wp_error($res)) return $res; $code = wp_remote_retrieve_response_code($res); $raw = wp_remote_retrieve_body($res); @@ -949,12 +1301,268 @@ private function wait_operation($url, $seconds = 90) { return new WP_Error('timeout', __('Google operation did not finish in time.', ud_get_stateless_media()->domain)); } + /** Waits until a project that Google is still creating becomes readable. */ + private function wait_project($project_id, $seconds = 90) { + $deadline = time() + $seconds; + do { + $project = $this->api('GET', 'https://cloudresourcemanager.googleapis.com/v1/projects/' . rawurlencode($project_id)); + if (!is_wp_error($project)) return $project; + if (!in_array($project->get_error_code(), array('google_403', 'google_404'), true)) return $project; + sleep(3); + } while (time() < $deadline); + return new WP_Error('timeout', __('Google operation did not finish in time.', ud_get_stateless_media()->domain)); + } + /* ------------------------------------------------------------------ */ /* Provisioning: project, APIs, bucket, service account, role, key */ /* ------------------------------------------------------------------ */ + /** + * One setup at a time. Nothing here queries the options table: the claim on a scope is WordPress core's own + * lock, and the job record is an ordinary option. + */ + + /** Names the scope a setup runs in, this site or the network: its claim, its lock and its job record. */ + private function provision_job_key() { + return 'sm_gc_job_' . ($this->network_scope() ? 'network_' . get_current_network_id() : 'site_' . get_current_blog_id()); + } + + /** + * The job record: who is setting up storage in this scope, how far it got and how it ended. Always read from + * the database, because a status check, a second submission and a worker all act on what another request + * wrote a moment ago. + */ + private function provision_job_read() { + $key = $this->provision_job_key(); + $this->reread_option($key, $this->network_scope() ? get_current_network_id() : 0); + $job = $this->read_option($key, null); + return is_array($job) && isset($job['id'], $job['status']) ? $job : null; + } + + /** Saves the job record. Not through write_option(), whose ownership check reads this very record. */ + private function provision_job_save($job) { + $key = $this->provision_job_key(); + return $this->network_scope() ? update_network_option(get_current_network_id(), $key, $job) : update_option($key, $job, false); + } + + private function provision_job_write($changes, $guard = true) { + if (!$this->provision_job) return; + if ($guard) $this->assert_provision_lock(); + $job = $this->provision_job_read(); + if (!is_array($job) || $job['id'] !== $this->provision_job) return; + $this->provision_job_save(array_merge($job, $changes, array('updated' => time()))); + } + + /** + * Claims or gives up a scope with the lock WordPress core uses for its own updaters, which holds on every + * database. A network setup keeps its claim with the network's main site. + */ + private function scope_claim($take) { + if (!class_exists('WP_Upgrader')) require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php'; + $elsewhere = $this->network_scope() && get_current_blog_id() !== get_main_site_id(); + if ($elsewhere) switch_to_blog(get_main_site_id()); + $done = $take ? \WP_Upgrader::create_lock($this->provision_job_key(), self::PROVISION_STALE) : \WP_Upgrader::release_lock($this->provision_job_key()); + if ($elsewhere) restore_current_blog(); + return (bool) $done; + } + + /** The name of the database lock for this scope. Such locks are shared by everything on the server. */ + private function provision_lock_name() { + global $wpdb; + return 'sm_gc_' . md5(DB_NAME . ':' . $wpdb->base_prefix . ':' . $this->provision_job_key()); + } + + /** + * Takes the database's connection-owned lock for this scope, waiting up to $wait seconds. The database frees + * it the moment its owner's connection ends, however the owner died, and that is the only way to know at once + * that a setup worker is gone. WordPress has no API for it, hence these three direct calls; none touches a + * table. Returns '1' when this connection owns the lock, '0' when another connection does, and null when the + * database has no such locks (SQLite, some clusters). Then a claim only runs out with time. + */ + private function take_provision_lock($wait = 0) { + global $wpdb; + // On a replicated database the lock must live on the primary, or two workers could each take it. + if (method_exists($wpdb, 'send_reads_to_masters')) $wpdb->send_reads_to_masters(); + $quiet = $wpdb->suppress_errors(true); // an unsupported lock function is an answer here, not an error to print + $got = $wpdb->get_var($wpdb->prepare('SELECT GET_LOCK(%s, %d)', $this->provision_lock_name(), $wait)); + $owned = (string) $got === '1' && $this->owns_provision_lock(); + $wpdb->suppress_errors($quiet); + if ((string) $got === '0') return '0'; + return $owned ? '1' : null; + } + + private function owns_provision_lock() { + global $wpdb; + return (string) $wpdb->get_var($wpdb->prepare('SELECT IS_USED_LOCK(%s) = CONNECTION_ID()', $this->provision_lock_name())) === '1'; + } + + private function release_provision_lock() { + global $wpdb; + $wpdb->get_var($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $this->provision_lock_name())); + $this->provision_lock = false; + } + + /** + * Takes this scope for the request, a setup or a disconnect. Returns false while a live owner exists. + * Where the database has connection-owned locks the owner holds that lock as well, so a claim whose owner + * died is recognised here at once and cleared, instead of blocking the scope until it runs out. + */ + private function take_scope($wait = 0) { + $lock = $this->take_provision_lock($wait); + if ($lock === '0') return false; + if ($lock === '1') { + $this->provision_lock = true; + $this->scope_claim(false); // nobody alive holds the lock, so a claim that is still there was left behind + } + if (!$this->scope_claim(true)) { + if ($this->provision_lock) $this->release_provision_lock(); + return false; + } + $this->scope_taken = true; + $this->scope_since = time(); + register_shutdown_function(array($this, 'release_scope')); + // This request owns the scope now, so a job that is still recorded as running has no worker any more. + $left = $this->provision_job_read(); + if (is_array($left) && $left['status'] === 'running') $this->provision_job_interrupt($left); + return true; + } + + /** + * Whether this request still holds the scope it took. With the database lock that is known exactly. Without + * it, a claim cannot have been taken over before it ran out, so a younger one is still this request's own. + */ + private function holds_scope() { + if (!$this->scope_taken) return false; + return $this->provision_lock ? $this->owns_provision_lock() : time() - $this->scope_since < self::PROVISION_STALE - 10; + } + + /** + * Gives the scope back: when the request ends, and earlier when a setup finishes. A claim that may be + * another request's by now is left alone. + */ + public function release_scope() { + if ($this->scope_taken) { + $own = $this->holds_scope(); + $this->scope_taken = false; + if ($own) $this->scope_claim(false); + } + if ($this->provision_lock) $this->release_provision_lock(); + } + + /** + * Stops a request that no longer holds the scope it took, a setup worker or a disconnect. Checked around + * everything such a request does at Google and around what it saves or removes. + */ + private function assert_provision_lock() { + if (!$this->scope_taken) return; + $domain = ud_get_stateless_media()->domain; + if ($this->provision_job && !$this->provision_lock) { + // Without a database lock a claim runs out with time. A worker whose scope was taken over stops, and so + // does one that is about to run out, which then hands the scope back itself. + $job = $this->provision_job_read(); + if (!is_array($job) || $job['id'] !== $this->provision_job || $job['status'] !== 'running') { + $this->provision_job = null; + $this->scope_taken = false; + wp_die(__('This setup stopped responding and another one took over. Reload to see its progress.', $domain)); + } + if (time() - (int) $job['started'] > self::PROVISION_STALE - 3 * MINUTE_IN_SECONDS) { + wp_die(__('This setup ran out of time. Reload to resume the saved setup.', $domain)); + } + } + if ($this->holds_scope()) return; + // wpdb can reconnect silently, and with the old connection the lock is gone. A former owner must never + // continue, and neither the lock nor the claim is its own to give up any more. + $worker = (bool) $this->provision_job; + $this->provision_job = null; + $this->provision_lock = false; + $this->scope_taken = false; + wp_die($worker + ? __('The setup worker lost its database connection. Reload to resume the saved setup.', $domain) + : __('The database connection was lost before this request finished. Reload and try again.', $domain)); + } + + /** Records that the worker of a job is gone, so the screen offers to resume. */ + private function provision_job_interrupt($job) { + $job['status'] = 'interrupted'; + $job['message'] = __('The setup worker stopped. Your saved storage choices are ready to resume.', ud_get_stateless_media()->domain); + $job['updated'] = time(); + $this->provision_job_save($job); + return $job; + } + + /** + * Marks a job recorded as running as interrupted once its worker is gone. With a database lock that is known + * at once: the lock can be taken only after its owner's connection ended, and holding it while the record + * changes keeps a new setup from starting in between. Without one, only time can tell. + */ + private function recover_provision_job($job) { + if (!is_array($job) || $job['status'] !== 'running') return $job; + $lock = !empty($job['worker_lock']) ? $this->take_provision_lock(0) : null; + if ($lock === '0') return $job; // its worker is alive + if ($lock === '1' || time() - (int) $job['started'] >= self::PROVISION_STALE) { + $current = $this->provision_job_read(); + $job = is_array($current) && $current['id'] === $job['id'] && $current['status'] === 'running' ? $this->provision_job_interrupt($current) : $current; + } + if ($lock === '1') { + $this->scope_claim(false); // its owner is gone for certain, so the claim it left goes too + $this->release_provision_lock(); + } + return $job; + } + + private function begin_provision_job() { + $domain = ud_get_stateless_media()->domain; + $id = isset($_POST['sm_gc_job']) ? sanitize_text_field(wp_unslash($_POST['sm_gc_job'])) : wp_generate_uuid4(); + if (!preg_match('/^[a-zA-Z0-9-]{16,64}$/D', $id)) wp_die(__('Invalid setup request.', $domain)); + register_shutdown_function(array($this, 'release_provision_worker')); + // A status poll can hold the database lock for a moment, so wait briefly instead of refusing a genuine submission. + if (!$this->take_scope(3)) { + wp_send_json_error(array('message' => __('Another setup worker is active. Checking its progress.', $domain)), 409); + } + // A repeated POST never repeats Google mutations, even after the first request finished. + $previous = $this->provision_job_read(); + if (is_array($previous) && $previous['id'] === $id) { + wp_send_json_error(array('message' => __('A storage setup is already recorded. Checking its progress.', $domain)), 409); + } + $this->provision_job_save(array('id' => $id, 'uid' => get_current_user_id(), 'status' => 'running', 'worker_lock' => (bool) $this->provision_lock, 'started' => time(), 'updated' => time(), 'message' => __('Preparing your storage.', $domain))); + $this->provision_job = $id; + // Reverse proxies may close the HTTP connection while Google is still working. + ignore_user_abort(true); + } + + public function release_provision_worker() { + if ($this->provision_job) { + // Unguarded: this runs at shutdown, and the write only ever touches this worker's own record. + $this->provision_job_write(array('status' => 'interrupted', 'message' => __('Setup stopped before completion. Reload to resume the saved storage choices.', ud_get_stateless_media()->domain)), false); + $this->provision_job = null; + } + $this->release_scope(); + } + + public function ajax_provision_status() { + if (!current_user_can($this->capability())) wp_send_json_error(array('message' => __('Insufficient permissions.')), 403); + check_ajax_referer($this->nonce_action(), 'nonce'); + // Only a job recorded as running needs a liveness check: an idle poll must not contend for the lock with a + // new submission. Taking the lock proves no live worker owns this scope; age alone proves nothing. + $job = $this->recover_provision_job($this->provision_job_read()); + if (!is_array($job)) wp_send_json_success(array('status' => 'none')); + if ((int) $job['uid'] !== get_current_user_id()) { + wp_send_json_success(array('status' => $job['status'] === 'running' ? 'busy' : 'none')); + } + $requested = isset($_GET['job']) ? sanitize_text_field(wp_unslash($_GET['job'])) : ''; + if ($requested !== $job['id'] && $job['status'] !== 'running') wp_send_json_success(array('status' => 'none')); + // Never automatically resubmit a long-running or lost request: it may have created a project. + if ($job['status'] === 'running' && time() - $job['started'] > 360) { + $job['message'] = __('Google setup is taking longer than expected. Keep this page open; do not start another setup.', ud_get_stateless_media()->domain); + } + unset($job['uid']); + wp_send_json_success($job); + } + public function handle_provision() { $this->guard(); + $this->begin_provision_job(); + $this->clear_result(); if (!$this->get_connection()) $this->fail(__('Not connected to Google.', ud_get_stateless_media()->domain)); $blocked = $this->storage_blocked(); if ($blocked) $this->fail($blocked); @@ -970,8 +1578,8 @@ public function handle_provision() { $bucket_choice = isset($post['bucket_choice']) ? sanitize_text_field($post['bucket_choice']) : 'new'; $bucket = isset($post['bucket_name']) ? strtolower(trim(sanitize_text_field($post['bucket_name']))) : ''; $location = isset($post['location']) ? sanitize_text_field($post['location']) : 'US'; - $mode = isset($post['mode']) ? sanitize_text_field($post['mode']) : 'ephemeral'; - if (!in_array($mode, array('backup', 'cdn', 'ephemeral', 'stateless'), true)) $mode = 'ephemeral'; + $mode = isset($post['mode']) ? sanitize_text_field($post['mode']) : 'cdn'; + if (!in_array($mode, array('backup', 'cdn', 'ephemeral', 'stateless'), true)) $mode = 'cdn'; $locked_mode = $this->locked_mode(); if ($locked_mode) $mode = $locked_mode['mode']; // the form shows the locked mode; never trust a posted one @@ -981,8 +1589,13 @@ public function handle_provision() { if (is_array($previous) && !empty($previous['created_project']) && $previous['created_project'] === $project_choice) { $draft['created_project'] = $previous['created_project']; $draft['pending_billing'] = isset($previous['pending_billing']) ? (string) $previous['pending_billing'] : ''; + if (!empty($previous['project_create_name'])) $draft['project_create_name'] = $previous['project_create_name']; + if (!empty($previous['project_create_op'])) $draft['project_create_op'] = $previous['project_create_op']; + } + if (is_array($previous) && isset($previous['bucket_name'], $previous['project']) && $previous['bucket_name'] === $bucket && $previous['project'] === $project_choice && !empty($previous['bucket_create_marker'])) { + $draft['bucket_create_marker'] = $previous['bucket_create_marker']; } - $this->state_set('draft', $draft, HOUR_IN_SECONDS, $uid); + $this->state_set('draft', $draft, DAY_IN_SECONDS, $uid); if ($this->requires_stateless_mode() && $mode !== 'stateless') { $this->fail($locked_mode ? sprintf(__('This environment requires Stateless mode, but the storage mode is set to %1$s by %2$s. Change it there before continuing.', ud_get_stateless_media()->domain), $mode, $locked_mode['source']) @@ -997,40 +1610,67 @@ public function handle_provision() { if (!in_array($bucket_choice, array('new', 'existing'), true)) $this->fail(__('Choose whether to create or reuse a bucket.', ud_get_stateless_media()->domain)); try { + $this->provision_job_write(array('message' => __('Preparing your Google Cloud project and billing.', ud_get_stateless_media()->domain))); /* 1. Project */ if ($project_choice === '__new__') { if (!$new_project) throw new Exception(__('Give the new project a name.', ud_get_stateless_media()->domain)); $project_id = substr(preg_replace('/[^a-z0-9-]/', '-', strtolower($new_project)), 0, 20) . '-' . substr(md5(uniqid('', true)), 0, 6); $project_id = trim(preg_replace('/-+/', '-', $project_id), '-'); if (!preg_match('/^[a-z]/', $project_id)) $project_id = 'wp-' . $project_id; + $draft['project'] = $project_id; + $draft['new_project_name'] = ''; + $draft['created_project'] = $project_id; + $draft['pending_billing'] = $billing_choice; + $draft['project_create_name'] = $new_project; + $this->state_set('draft', $draft, DAY_IN_SECONDS, $uid); $op = $this->api('POST', 'https://cloudresourcemanager.googleapis.com/v1/projects', array( 'projectId' => $project_id, 'name' => $new_project, )); $this->check($op, 'Create project'); $log[] = sprintf('Creating project %s (%s)', $new_project, $project_id); + // Save the accepted operation before polling: an interrupted or slow wait resumes it instead of creating another project. + $draft['project_create_op'] = isset($op['name']) ? (string) $op['name'] : ''; + $this->state_set('draft', $draft, DAY_IN_SECONDS, $uid); $this->check($this->wait_operation('https://cloudresourcemanager.googleapis.com/v1/' . $op['name']), 'Create project'); $log[] = 'Project created'; - // Checkpoint before billing: the project exists now, so a retry after a billing failure must - // resume it (and link the chosen billing account below) instead of creating a second project. - $draft['project'] = $project_id; - $draft['new_project_name'] = ''; - $draft['created_project'] = $project_id; - $draft['pending_billing'] = $billing_choice; - $this->state_set('draft', $draft, HOUR_IN_SECONDS, $uid); if ($billing_choice) { $this->link_billing($project_id, $billing_choice); $log[] = 'Billing account linked'; $draft['billing_account'] = ''; $draft['pending_billing'] = ''; - $this->state_set('draft', $draft, HOUR_IN_SECONDS, $uid); + $this->state_set('draft', $draft, DAY_IN_SECONDS, $uid); } else { $log[] = 'No billing account linked. Google requires one before a bucket can be created.'; } } else { $project_id = $project_choice; if (!preg_match('/^[a-z][a-z0-9-]{4,29}$/', $project_id)) throw new Exception(__('Pick a project.', ud_get_stateless_media()->domain)); + if (!empty($draft['project_create_name'])) { + // The project an earlier attempt asked Google to create. Google answers 403, not 404, for a project + // that does not exist yet, so an unreadable one is either still being created or was never accepted. + $project = $this->api('GET', 'https://cloudresourcemanager.googleapis.com/v1/projects/' . rawurlencode($project_id)); + if (is_wp_error($project) && in_array($project->get_error_code(), array('google_403', 'google_404'), true)) { + if (!empty($draft['project_create_op'])) { + $this->check($this->wait_operation('https://cloudresourcemanager.googleapis.com/v1/' . $draft['project_create_op']), 'Resume project creation'); + } else { + $op = $this->api('POST', 'https://cloudresourcemanager.googleapis.com/v1/projects', array('projectId' => $project_id, 'name' => $draft['project_create_name'])); + if (is_wp_error($op) && $op->get_error_code() === 'google_409') { + // The interrupted request did reach Google: wait for that project instead of asking for another. + $this->check($this->wait_project($project_id), 'Resume project creation'); + } else { + $this->check($op, 'Resume project creation'); + $draft['project_create_op'] = isset($op['name']) ? (string) $op['name'] : ''; + $this->state_set('draft', $draft, DAY_IN_SECONDS, $uid); + $this->check($this->wait_operation('https://cloudresourcemanager.googleapis.com/v1/' . $op['name']), 'Resume project creation'); + } + } + $log[] = 'Project created'; + } else { + $this->check($project, 'Find saved project'); + } + } $log[] = sprintf('Using project %s', $project_id); // Only a project this attempt created, whose billing link failed, gets billing linked here, and only // to the account the administrator chose for it then. Billing of other existing projects is never changed: @@ -1043,10 +1683,22 @@ public function handle_provision() { $log[] = 'Billing account linked'; } $draft['pending_billing'] = ''; - $this->state_set('draft', $draft, HOUR_IN_SECONDS, $uid); + $this->state_set('draft', $draft, DAY_IN_SECONDS, $uid); } } + $this->provision_job_write(array('message' => __('Checking billing and preparing bucket access.', ud_get_stateless_media()->domain))); + if ($bucket_choice === 'new') { + $billing_info = $this->api('GET', 'https://cloudbilling.googleapis.com/v1/projects/' . rawurlencode($project_id) . '/billingInfo'); + if (!is_wp_error($billing_info) && empty($billing_info['billingEnabled'])) { + $draft['billing_required'] = true; + $this->state_set('draft', $draft, DAY_IN_SECONDS, $uid); + throw new Exception(__('Google Cloud billing is not enabled for this project. Enable billing, then choose Check again. Your storage choices are saved.', ud_get_stateless_media()->domain), 1001); + } + unset($draft['billing_required']); + $this->state_set('draft', $draft, DAY_IN_SECONDS, $uid); + } + // Check a manually entered bucket before creating accounts, keys or changing permissions. if ($bucket_choice === 'existing') { $existing_bucket = $this->api('GET', 'https://storage.googleapis.com/storage/v1/b/' . rawurlencode($bucket)); @@ -1099,17 +1751,27 @@ public function handle_provision() { /* 3. Bucket */ if ($bucket_choice === 'new') { + if (empty($draft['bucket_create_marker'])) $draft['bucket_create_marker'] = str_replace('-', '', wp_generate_uuid4()); + $this->state_set('draft', $draft, DAY_IN_SECONDS, $uid); $r = $this->api('POST', 'https://storage.googleapis.com/storage/v1/b?project=' . rawurlencode($project_id) . '&predefinedDefaultObjectAcl=publicRead', array( 'name' => $bucket, 'location' => $location, 'storageClass' => 'STANDARD', + 'labels' => array('wp-stateless-setup' => $draft['bucket_create_marker']), 'iamConfiguration' => array( 'uniformBucketLevelAccess' => array('enabled' => false), 'publicAccessPrevention' => 'inherited', ), )); if (is_wp_error($r) && $r->get_error_code() === 'google_409') { - throw new Exception(sprintf(__('The bucket name %s is already taken. Bucket names are global across Google Cloud, pick another.', ud_get_stateless_media()->domain), $bucket)); + $saved_bucket = $this->api('GET', 'https://storage.googleapis.com/storage/v1/b/' . rawurlencode($bucket)); + $saved_project = $this->api('GET', 'https://cloudresourcemanager.googleapis.com/v1/projects/' . rawurlencode($project_id)); + if (is_wp_error($saved_bucket) || is_wp_error($saved_project) || empty($saved_bucket['labels']['wp-stateless-setup']) + || $saved_bucket['labels']['wp-stateless-setup'] !== $draft['bucket_create_marker'] + || empty($saved_project['projectNumber']) || (string) $saved_bucket['projectNumber'] !== (string) $saved_project['projectNumber']) { + throw new Exception(sprintf(__('The bucket name %s is already taken. Bucket names are global across Google Cloud, pick another.', ud_get_stateless_media()->domain), $bucket)); + } + $r = $saved_bucket; } if (is_wp_error($r) && $r->get_error_code() === 'google_412') { throw new Exception(self::public_access_prevention_message($project_id)); @@ -1118,7 +1780,7 @@ public function handle_provision() { $log[] = sprintf('Bucket %s created in %s, new objects public by default', $bucket, $location); // Checkpoint: the name is taken now, so a retry must reuse the bucket rather than create it again. $draft['bucket_choice'] = 'existing'; - $this->state_set('draft', $draft, HOUR_IN_SECONDS, $uid); + $this->state_set('draft', $draft, DAY_IN_SECONDS, $uid); // Serving modes need anonymous reads. An organization policy can enforce public access prevention on // the new bucket even though it was created as "inherited", so prove a public read before continuing. if ($mode !== 'backup' && (!defined('WP_STATELESS_SKIP_ACL_SET') || !WP_STATELESS_SKIP_ACL_SET)) { @@ -1172,22 +1834,53 @@ public function handle_provision() { $log[] = 'Service account granted Storage Admin on this bucket only'; /* 6. Key. Remember the key this site used before so it can be revoked once the new one is saved. */ - $previous_key = json_decode((string) $this->read_option('sm_key_json', ''), true); - $attempt = 0; - do { - $attempt++; - $r = $this->api('POST', 'https://iam.googleapis.com/v1/projects/-/serviceAccounts/' . rawurlencode($sa_email) . '/keys', array( - 'privateKeyType' => 'TYPE_GOOGLE_CREDENTIALS_FILE', - )); - if (is_wp_error($r) && $attempt < 6 && $r->get_error_code() === 'google_404') { - sleep(4); - continue; + $configured_key = json_decode((string) $this->read_option('sm_key_json', ''), true); + $previous_key = $configured_key; + $checkpoint = json_decode($this->decrypt((string) $this->read_option(self::SETUP_KEY_OPTION, '')), true); + if (is_array($checkpoint) && isset($checkpoint['bucket'], $checkpoint['sa_email'], $checkpoint['key']) && $checkpoint['bucket'] === $bucket && $checkpoint['sa_email'] === $sa_email) { + // An interrupted attempt for this same storage already holds a key: reuse it instead of issuing another. + $key_json = $checkpoint['key']; + $previous_key = $this->resumed_previous_key($checkpoint, $configured_key); + // That can be another key than the one on record, when the configuration was changed by hand after the + // attempt stopped. It goes on record first, so that it is still known should this attempt stop as well. + $key_id = function ($key) { return is_array($key) && !empty($key['private_key_id']) ? $key['private_key_id'] : ''; }; + $recorded = isset($checkpoint['previous']) ? $checkpoint['previous'] : null; + if ($key_id($recorded) !== $key_id($previous_key)) { + $checkpoint['previous'] = $previous_key; + $this->write_option(self::SETUP_KEY_OPTION, $this->encrypt(wp_json_encode($checkpoint)), false); } - $this->check($r, 'Create service account key'); - break; - } while (true); - $key_json = base64_decode($r['privateKeyData']); - if (!json_decode($key_json)) throw new Exception(__('Google returned an unreadable key.', ud_get_stateless_media()->domain)); + } else { + // A key saved by an attempt for different storage is abandoned now. It must not stay active, and it must + // not stay reusable: once queued for revocation, a later reuse would end with the site's key deleted. + // If that attempt had already replaced the site's key, the key it replaced is revoked here as well. + if (is_array($checkpoint) && !empty($checkpoint['key'])) { + foreach ($this->setup_leftover_keys($checkpoint, $configured_key) as $leftover) { + $removed = $this->revoke_key($leftover['email'], $leftover['id']); // a failure is queued and retried + if ($leftover['kind'] === 'unused') { + $log[] = $removed === true ? 'Unused key from an earlier attempt removed' : 'Unused key from an earlier attempt could not be removed yet; it will be retried'; + } else { + $log[] = $removed === true ? 'Key replaced by an earlier attempt revoked' : 'Key replaced by an earlier attempt could not be revoked yet; it will be retried'; + } + } + $this->remove_option(self::SETUP_KEY_OPTION); + } + $attempt = 0; + do { + $attempt++; + $r = $this->api('POST', 'https://iam.googleapis.com/v1/projects/-/serviceAccounts/' . rawurlencode($sa_email) . '/keys', array( + 'privateKeyType' => 'TYPE_GOOGLE_CREDENTIALS_FILE', + )); + if (is_wp_error($r) && $attempt < 6 && $r->get_error_code() === 'google_404') { + sleep(4); + continue; + } + $this->check($r, 'Create service account key'); + break; + } while (true); + $key_json = base64_decode($r['privateKeyData']); + if (!json_decode($key_json)) throw new Exception(__('Google returned an unreadable key.', ud_get_stateless_media()->domain)); + $this->write_option(self::SETUP_KEY_OPTION, $this->encrypt(wp_json_encode(array('bucket' => $bucket, 'sa_email' => $sa_email, 'key' => $key_json, 'previous' => $previous_key))), false); + } $log[] = 'Key issued to this site'; /* 7. Verify with the new key itself before anything is replaced (IAM can lag a few seconds). A site that @@ -1195,14 +1888,22 @@ public function handle_provision() { * because there is nothing to lose and the check usually passes a moment later. */ $verified = $this->verify_key($key_json, $bucket, 10); $new_key = json_decode($key_json, true); + // An interrupted attempt may already have saved this key as the site's configuration. The site runs on it + // then, so nothing is revoked while it cannot be verified, and the saved setup stays resumable. + $committed = is_array($configured_key) && !empty($configured_key['private_key_id']) && $configured_key['private_key_id'] === $new_key['private_key_id']; + if ($verified !== true && $committed) { + throw new Exception(sprintf(__('The saved credentials could not reach the bucket (%s). Nothing was changed. IAM changes can take a minute to apply; try again shortly.', ud_get_stateless_media()->domain), $verified)); + } $reconfiguring = is_array($previous_key) && !empty($previous_key['private_key_id']) && (string) $this->read_option('sm_bucket', '') !== ''; if ($verified !== true && $reconfiguring) { $removed = $this->revoke_key($sa_email, $new_key['private_key_id']); + $this->remove_option(self::SETUP_KEY_OPTION); $log[] = $removed === true ? 'New key removed again' : 'New key could not be removed yet; it will be retried on the next setup'; throw new Exception(sprintf(__('The new credentials could not reach the bucket (%s), so the current configuration was left unchanged. IAM changes can take a minute to apply; try again shortly.', ud_get_stateless_media()->domain), $verified)); } /* 8. Save plugin settings */ + $this->assert_provision_lock(); $this->write_option('sm_bucket', $bucket); $this->write_option('sm_key_json', $key_json); $this->write_option('sm_mode', $mode); @@ -1229,6 +1930,7 @@ public function handle_provision() { : 'Previous key for this site could not be revoked yet (' . $revoked->get_error_message() . '); it will be retried on the next setup'; } $this->retry_pending_revocations($log); + $this->remove_option(self::SETUP_KEY_OPTION); $log[] = $verified === true ? 'Verified: the site reached the bucket with its own credentials' @@ -1236,11 +1938,13 @@ public function handle_provision() { $this->state_set('log', $log, 10 * MINUTE_IN_SECONDS, $uid); $this->state_delete('draft', $uid); + $this->track_event('provisioned'); $this->done(array('sm_gc' => 'provisioned')); } catch (Exception $e) { $log[] = 'Stopped: ' . $e->getMessage(); $this->state_set('log', $log, 10 * MINUTE_IN_SECONDS, $uid); - $this->fail($e->getMessage()); + $this->track_event('provision_failed', $this->provisioning_reason($e)); + $this->fail($e->getMessage(), true); } } @@ -1266,15 +1970,97 @@ private function revoke_key($sa_email, $key_id) { return true; } + private function forget_pending_revocation($name) { + $pending = $this->read_option(self::PENDING_KEYS_OPTION, array()); + if (!is_array($pending) || !isset($pending[$name])) return; + unset($pending[$name]); + $pending ? $this->write_option(self::PENDING_KEYS_OPTION, $pending, false) : $this->remove_option(self::PENDING_KEYS_OPTION); + } + + /** + * The keys a stored setup left behind that the site does not run on. Either the key that setup issued, when + * it never became the site's configuration. Or, when it did and the setup stopped before its last step, the + * key the site held until then; as in a completed setup, only a key of the same service account, so a key + * someone configured by hand is never touched. The key the site is configured with is never returned. + */ + private function setup_leftover_keys($checkpoint, $current) { + $issued = is_array($checkpoint) && !empty($checkpoint['key']) ? json_decode($checkpoint['key'], true) : null; + if (!is_array($issued) || empty($issued['private_key_id']) || empty($issued['client_email'])) return array(); + $current_id = is_array($current) && !empty($current['private_key_id']) ? $current['private_key_id'] : ''; + if ($issued['private_key_id'] !== $current_id) { + return array(array('email' => $issued['client_email'], 'id' => $issued['private_key_id'], 'kind' => 'unused')); + } + $previous = isset($checkpoint['previous']) ? $checkpoint['previous'] : null; + if (is_array($previous) && !empty($previous['private_key_id']) && !empty($previous['client_email']) + && $previous['client_email'] === $issued['client_email'] && $previous['private_key_id'] !== $current_id) { + return array(array('email' => $previous['client_email'], 'id' => $previous['private_key_id'], 'kind' => 'replaced')); + } + return array(); + } + + /** + * The key a resumed setup replaces: the one the site is configured with now, which may have changed since + * the setup stopped. Only when the saved key already is the site's configuration did the stopped setup + * replace an older key, and that one is on record. So a configuration entered by hand in between is + * protected like any working configuration when the saved key cannot be verified. + */ + private function resumed_previous_key($checkpoint, $configured_key) { + $issued = is_array($checkpoint) && !empty($checkpoint['key']) ? json_decode($checkpoint['key'], true) : null; + $live = is_array($issued) && !empty($issued['private_key_id']) && is_array($configured_key) + && !empty($configured_key['private_key_id']) && $issued['private_key_id'] === $configured_key['private_key_id']; + if (!$live) return $configured_key; + return isset($checkpoint['previous']) ? $checkpoint['previous'] : null; + } + + /** True when repeating a failed Google request can succeed: the network or Google failed, not the request. */ + private static function worth_retrying($error) { + $code = (string) $error->get_error_code(); + if (strpos($code, 'google_') !== 0) return $code === 'http_request_failed'; + $status = (int) substr($code, 7); + return $status === 429 || $status >= 500; + } + + /** + * Ends a setup that stopped after Google issued its key: with $revoke the keys it left at Google go on the + * list of keys to remove, then the stored copy is dropped. So such a key is never both awaiting removal and + * still reusable, and none stays active without a record. The caller removes what is on the list. + */ + private function discard_setup_key($revoke) { + $stored = (string) $this->read_option(self::SETUP_KEY_OPTION, ''); + if ($stored === '') return; + if ($revoke) { + $current = json_decode((string) $this->read_option('sm_key_json', ''), true); + $leftovers = $this->setup_leftover_keys(json_decode($this->decrypt($stored), true), $current); + if ($leftovers) { + $pending = $this->read_option(self::PENDING_KEYS_OPTION, array()); + if (!is_array($pending)) $pending = array(); + foreach ($leftovers as $leftover) $pending[$leftover['email'] . '/' . $leftover['id']] = time(); + $this->write_option(self::PENDING_KEYS_OPTION, $pending, false); + } + } + $this->remove_option(self::SETUP_KEY_OPTION); + } + + /** Tries once to remove every key on the list. Returns the errors of those still there, by key name. */ private function retry_pending_revocations(&$log) { + $failed = array(); $pending = $this->read_option(self::PENDING_KEYS_OPTION, array()); - if (!is_array($pending) || !$pending) return; + if (!is_array($pending) || !$pending) return $failed; + // Never revoke the key the site is configured with, however it got onto this list. + $current = json_decode((string) $this->read_option('sm_key_json', ''), true); + $in_use = is_array($current) && !empty($current['client_email']) && !empty($current['private_key_id']) ? $current['client_email'] . '/' . $current['private_key_id'] : ''; foreach (array_keys($pending) as $name) { + if ($name === $in_use) { + $this->forget_pending_revocation($name); + continue; + } list($email, $key_id) = array_pad(explode('/', $name, 2), 2, ''); if ($email === '' || $key_id === '') continue; $r = $this->revoke_key($email, $key_id); + if ($r !== true) $failed[$name] = $r; $log[] = $r === true ? 'Earlier key ' . $key_id . ' revoked' : 'Earlier key ' . $key_id . ' still pending revocation: ' . $r->get_error_message(); } + return $failed; } private function verify_key($key_json, $bucket, $attempts = 6) { @@ -1346,11 +2132,27 @@ private function guard() { } private function done($args) { + // What stays on the screen belongs to the action that just ended. One that ends well leaves no earlier + // failure behind, such as a disconnect that was refused in another tab while this setup was running, and + // only a finished setup leaves its steps. + $outcome = isset($args['sm_gc']) ? $args['sm_gc'] : ''; + if ($outcome !== 'error') { + $this->state_delete('error'); + if ($outcome !== 'provisioned') $this->state_delete('log'); + } + if ($this->provision_job) { + $this->provision_job_write(array('status' => isset($args['sm_gc']) && $args['sm_gc'] === 'provisioned' ? 'complete' : 'failed', 'url' => $this->page_url($args))); + $this->provision_job = null; + $this->release_scope(); + if (isset($_POST['sm_gc_job'])) wp_send_json_success(array('finished' => true)); + } wp_safe_redirect($this->page_url($args)); exit; } - private function fail($message) { + /** Ends an action with an explanation. $with_steps keeps the steps a stopped setup saved just before. */ + private function fail($message, $with_steps = false) { + if (!$with_steps) $this->state_delete('log'); // steps of another action must not appear under this explanation $this->state_set('error', wp_strip_all_tags((string) $message), 10 * MINUTE_IN_SECONDS); $this->done(array('sm_gc' => 'error')); } diff --git a/lib/classes/class-settings.php b/lib/classes/class-settings.php index 880aea00c..c639cdab8 100644 --- a/lib/classes/class-settings.php +++ b/lib/classes/class-settings.php @@ -702,10 +702,16 @@ public function plugin_action_links($actions, $file) { return $actions; } - $url = ud_get_stateless_media()->get_settings_page_url('?page=stateless-settings'); + $configured = $this->get('sm.key_json') && $this->get('sm.bucket'); + $connect_hidden = GoogleConnect::hidden_for_site() && !GoogleConnect::instance()->network_scope(); + if (defined('WP_STATELESS_MEDIA_HIDE_SETUP_ASSISTANT') && WP_STATELESS_MEDIA_HIDE_SETUP_ASSISTANT == true) $connect_hidden = true; + $use_settings = $configured || $connect_hidden; + $url = $use_settings + ? ud_get_stateless_media()->get_settings_page_url('?page=stateless-settings') + : GoogleConnect::instance()->page_url(); $settings = [ - 'settings' => sprintf( '%s', $url, __('Settings', ud_get_stateless_media()->domain) ), + 'settings' => sprintf( '%s', esc_url($url), $use_settings ? __('Settings', ud_get_stateless_media()->domain) : __('Connect', ud_get_stateless_media()->domain) ), ]; $actions['addons'] = sprintf( '%s', ud_get_stateless_media()->get_docs_page_url('addons/'), __('Addons', ud_get_stateless_media()->domain) ); diff --git a/readme.md b/readme.md index c15ae00c5..602bd56dc 100644 --- a/readme.md +++ b/readme.md @@ -57,10 +57,10 @@ Looking for a unique feature for your next project? [Hire us!](https://udx.io/) ## Installation -1. Search, install, and activate the *WP-Stateless* plugin via your WordPress dashboard. -2. Open *Media > Stateless Connect* and click *Sign in with Google*. Confirm the connection for your site on stateless.ci; the page brings you back, or you type the eight character code it shows. -3. Choose a Google Cloud project, billing account, bucket, location and storage mode, and submit the form. WP-Stateless creates the bucket, a service account with access to that bucket only, and its key inside your own Google Cloud account. -4. Setup is now complete. Visit *Media > Stateless Settings* for more options. +1. Search, install, and activate the *WP-Stateless* plugin via your WordPress dashboard. Unconfigured sites open Stateless Connect automatically; you can also open *Media > Stateless Connect*. +2. Click *Sign in with Google* and confirm the connection for your site on stateless.ci. You return to the same tab, or can use the eight character pairing code on another device. +3. Choose *Create my storage* for the recommended project, bucket and CDN setup, or open *Advanced storage options* to choose an existing project or bucket, another location or another mode. WP-Stateless creates a service account with access to that bucket only, and Google sends its key directly to your site. +4. Upload a test image to confirm your storage is working. Visit *Media > Stateless Settings* for more options. For a more detailed installation and setup walkthrough, please see the [setup guide](https://stateless.ci/setup/) and the [manual setup instructions](https://stateless.ci/docs/manual-setup/). @@ -95,3 +95,20 @@ To ensure new releases cause as little disruption as possible, we rely on a numb ### Who maintains this plugin? [UDX](https://udx.io/) maintains this plugin by continuing development through its own staff, reviewing pull requests, testing, and steering the overall release schedule. UDX is located in Durham, North Carolina, and provides WordPress engineering and hosting services to clients throughout the United States. + +## External services + +WP-Stateless stores and serves your media from Google Cloud Storage in your own Google account. It relies on the services below. Your media files are sent only to Google Cloud Storage. + +### Google Cloud +The plugin uploads, reads and deletes media in the Google Cloud Storage bucket you configure, using your own service account. During Stateless Connect it also uses Google sign-in and Google Cloud APIs (projects, billing, IAM, service usage and storage) with the permissions you approve, to create or select the project, bucket and service account. This service is provided by Google: [Terms of Service](https://cloud.google.com/terms), [Privacy Policy](https://policies.google.com/privacy). + +### stateless.ci (Stateless Connect) +Stateless Connect uses stateless.ci, operated by UDX, to handle Google sign-in for the plugin. It is contacted when an administrator opens the Stateless Connect screen, which also opens once after the plugin is activated on a site that has no storage configured. Opening the screen sends the site's WordPress admin return address and a one-time challenge so that a sign-in can be prepared. Signing in passes the Google authorization through stateless.ci for one-time delivery to your site, and later token refreshes also pass through it. The service account key is created in your own Google Cloud project and never passes through stateless.ci. + +To learn whether setup works, the plugin also reports setup progress for that sign-in attempt: screen viewed, sign-in started, storage created or failed with a general reason, and whether the first upload succeeded. These records do not contain the site address, email address, project ID or bucket name, and they expire after seven days. + +Sites configured by hand with a bucket and service account JSON under Stateless Settings do not need Stateless Connect, and the `WP_STATELESS_MEDIA_HIDE_SETUP_ASSISTANT` constant keeps the screen from opening. This service is provided by UDX: [Privacy Policy](https://stateless.ci/privacy-policy/). + +### UDX product check-in +While you use the WordPress admin, the plugin contacts UDX (usabilitydynamics.com) at most once a day to check for product notices. The request includes your site address, the plugin version and the UDX products detected on the site. This service is provided by UDX: [Privacy Policy](https://udx.io/privacy-policy). diff --git a/readme.txt b/readme.txt index 5918a6a2a..1b9a6ff44 100644 --- a/readme.txt +++ b/readme.txt @@ -6,7 +6,7 @@ License: GPLv2 or later Requires PHP: 8.1 Requires at least: 5.0 Tested up to: 6.9 -Stable tag: 4.5.0 +Stable tag: 4.5.1 Upload and serve your WordPress media files from Google Cloud Storage. @@ -67,21 +67,21 @@ Looking for a unique feature for your next project? [Hire us!](https://udx.io/) == Installation == -1. Search, install, and activate the *WP-Stateless* plugin via your WordPress dashboard. -2. Open *Media > Stateless Connect* and click *Sign in with Google*. Confirm the connection for your site on stateless.ci; the page brings you back, or you type the eight character code it shows. -3. Choose a Google Cloud project, billing account, bucket, location and storage mode, and submit the form. WP-Stateless creates the bucket, a service account with access to that bucket only, and its key inside your own Google Cloud account. -4. Setup is now complete. Visit *Media > Stateless Settings* for more options. +1. Search, install, and activate the *WP-Stateless* plugin via your WordPress dashboard. Unconfigured sites open Stateless Connect automatically; you can also open *Media > Stateless Connect*. +2. Click *Sign in with Google* and confirm the connection for your site on stateless.ci. You return to the same tab, or can use the eight character pairing code on another device. +3. Choose *Create my storage* for the recommended project, bucket and CDN setup, or open *Advanced storage options* to choose an existing project or bucket, another location or another mode. WP-Stateless creates a service account with access to that bucket only, and Google sends its key directly to your site. +4. Upload a test image to confirm your storage is working. Visit *Media > Stateless Settings* for more options. For a more detailed installation and setup walkthrough, please see the [setup guide](https://stateless.ci/setup/) and the [manual setup instructions](https://stateless.ci/docs/manual-setup/). == Screenshots == -1. Settings Panel: Supports network setting and wp-config constant overrides. -2. Setup Assistant -3. Setup Assistant: Google Login -4. Setup Assistant: Approve Permissions -5. Setup Assistant: Project & Bucket -6. Setup Assistant: Complete -7. Edit Media: Image stored on Google Cloud Storage. +1. Settings: choose a media storage mode and configure URL replacement. +2. Stateless Connect: sign in with Google to start the setup wizard. +3. Pairing code: finish sign-in when you use another device. +4. Recommended setup: create cloud storage with sensible defaults. +5. Advanced setup: choose an existing project and bucket, and compare storage modes. +6. Ready to upload: return to your Media Library after connecting storage. +7. Edit Media: view an image stored in Google Cloud Storage. == Frequently Asked Questions == @@ -116,6 +116,24 @@ To ensure new releases cause as little disruption as possible, we rely on a numb [UDX](https://udx.io/) maintains this plugin by continuing development through it's own staff, reviewing pull requests, testing, and steering the overall release schedule. UDX is located in Durham, North Carolina and provides WordPress engineering and hosting services to clients throughout the United States. +== External services == + +WP-Stateless stores and serves your media from Google Cloud Storage in your own Google account. It relies on the services below. Your media files are sent only to Google Cloud Storage. + += Google Cloud = +The plugin uploads, reads and deletes media in the Google Cloud Storage bucket you configure, using your own service account. During Stateless Connect it also uses Google sign-in and Google Cloud APIs (projects, billing, IAM, service usage and storage) with the permissions you approve, to create or select the project, bucket and service account. This service is provided by Google: [Terms of Service](https://cloud.google.com/terms), [Privacy Policy](https://policies.google.com/privacy). + += stateless.ci (Stateless Connect) = +Stateless Connect uses stateless.ci, operated by UDX, to handle Google sign-in for the plugin. It is contacted when an administrator opens the Stateless Connect screen, which also opens once after the plugin is activated on a site that has no storage configured. Opening the screen sends the site's WordPress admin return address and a one-time challenge so that a sign-in can be prepared. Signing in passes the Google authorization through stateless.ci for one-time delivery to your site, and later token refreshes also pass through it. The service account key is created in your own Google Cloud project and never passes through stateless.ci. + +To learn whether setup works, the plugin also reports setup progress for that sign-in attempt: screen viewed, sign-in started, storage created or failed with a general reason, and whether the first upload succeeded. These records do not contain the site address, email address, project ID or bucket name, and they expire after seven days. + +Sites configured by hand with a bucket and service account JSON under Stateless Settings do not need Stateless Connect, and the `WP_STATELESS_MEDIA_HIDE_SETUP_ASSISTANT` constant keeps the screen from opening. This service is provided by UDX: [Privacy Policy](https://stateless.ci/privacy-policy/). + += UDX product check-in = +While you use the WordPress admin, the plugin contacts UDX (usabilitydynamics.com) at most once a day to check for product notices. The request includes your site address, the plugin version and the UDX products detected on the site. This service is provided by UDX: [Privacy Policy](https://udx.io/privacy-policy). + + == Upgrade Notice == = 4.1.0 = You will be prompted to run data optimization after upgrade. Please make a backup copy of your database. @@ -135,6 +153,17 @@ Before upgrading to WP-Stateless 3.2.0, please, make sure you use PHP 7.2 or abo Before upgrading to WP-Stateless 3.0, please, make sure you tested it on your development environment. == Changelog == += 4.5.1 - 2026-11-12 = +* ENHANCEMENT - fresh, unconfigured activations and the Plugins screen lead directly to Stateless Connect; manual bucket and service-account settings remain available under Advanced. +* ENHANCEMENT - the recommended setup creates a project and US multi-region bucket with CDN mode, while existing projects, buckets, locations and modes remain available under Advanced. +* ENHANCEMENT - Google sign-in stays in the same tab, setup explains billing and key custody, and missing billing has a Check again path that preserves the storage choices. +* ENHANCEMENT - storage setup shows its progress on the Connect screen and no longer depends on one long request: it survives slow Google operations, proxy timeouts and page reloads, and an interrupted setup resumes its saved project, bucket and key instead of starting over. +* NEW - privacy-limited setup events measure actual Connect clicks through provisioning and the first verified upload without recording site addresses, email addresses, project IDs, bucket names or free-form errors. +* FIX - storage drafts restore the selected project and bucket after a failed attempt, and new project names are generated in Google's accepted format. +* FIX - the result of a storage setup, including the explanation when it fails, stays on the Connect screen until the next action, so reloading the page no longer loses it. +* FIX - a storage setup that is interrupted or replaced by another no longer leaves a service account key behind: the next setup or a disconnect revokes it, and a revocation that fails is retried. +* SECURITY - stateless.ci excludes OAuth callback query parameters from its load-balancer log sinks and retains only pseudonymous, short-lived setup-funnel records. + = 4.5.0 - 2026-11-02 = * NEW - Stateless Connect replaces the setup wizard: sign in with Google from wp-admin, confirm your site on stateless.ci, and WP-Stateless creates the project, bucket, service account and key inside your own Google Cloud account. No iframe, no copying of keys. * NEW - setup works from Network Admin on multisite, with network and per-site connections kept separate. diff --git a/static/scripts/stateless-connect.js b/static/scripts/stateless-connect.js index 8314b9faa..df6fd05f6 100644 --- a/static/scripts/stateless-connect.js +++ b/static/scripts/stateless-connect.js @@ -93,7 +93,7 @@ buckets = Array.from(new Map(buckets.map(b => [b.name,b])).values()); next = data.next; loadedProject = id; renderBuckets(); more.hidden = !next; - status.textContent = buckets.length ? buckets.length + ' buckets loaded.' + (next ? ' Load more to search additional buckets.' : '') : 'No buckets found in this project. Create a new bucket or enter a known name below.'; + status.textContent = buckets.length ? (buckets.length === 1 ? '1 bucket loaded.' : buckets.length + ' buckets loaded.') + (next ? ' Load more to search additional buckets.' : '') : 'No buckets found in this project. Create a new bucket or enter a known name below.'; } catch (error) { if (version !== generation) return; status.textContent = error.message + ' You can also enter a known bucket name below.'; retry.hidden = false; @@ -115,7 +115,7 @@ billing.hidden = isNew || !project.value; billingLink.hidden = isNew || !project.value; const currentChoice = newBucket ? 'new' : 'existing'; if (currentChoice !== bucketChoice) { names[bucketChoice] = name.value; name.value = names[currentChoice]; bucketChoice = currentChoice; renderBuckets(); } - go.value = newBucket ? 'Create bucket and connect this site' : 'Connect this bucket'; + go.value = newBucket ? 'Create my storage' : 'Connect this bucket'; go.disabled = !project.value || isNew && !form.querySelector('[name=billing_account]').value; document.getElementById('sm-bucket-help').textContent = newBucket ? 'Names must be globally unique. Use lowercase letters, numbers, dashes or underscores.' : 'Choose above, or enter the exact name if you cannot list buckets. Its location stays unchanged.'; if (project.value && !newBucket && loadedProject !== project.value) load(false); @@ -126,6 +126,9 @@ renderBuckets(); sync(); if (!project.value || project.value === '__new__') return; const version = generation; + // A project this setup asked Google to create cannot be queried yet; submitting resumes it. + const chosen = project.selectedOptions[0]; + if (chosen && chosen.dataset.pending) { billing.textContent = 'Google is still creating this project. Continue to finish the saved setup.'; return; } billing.textContent = 'Checking project billing…'; try { const data = await request('sm_gc_project_billing', {project:project.value}); @@ -141,6 +144,110 @@ name.addEventListener('input',renderBuckets); search.addEventListener('input',renderBuckets); more.addEventListener('click', () => load(true)); retry.addEventListener('click', () => load(!!next)); - form.addEventListener('submit', () => { go.value = 'Connecting storage, please wait…'; go.classList.add('disabled'); }); changeProject(false); })(); + +// Keep the document alive while provisioning runs. A proxy timeout is not a failed setup. +(function () { + 'use strict'; + const form = document.getElementById('sm-gc-provision'); + const config = window.smConnectPicker; + if (!config) return; + const storageKey = 'stateless-provision:' + location.pathname + ':' + new URL(config.url).searchParams.get('sm_gc_network'); + let job = '', polling = false, misses = 0; + try { job = sessionStorage.getItem(storageKey) || ''; } catch (_) {} + const panel = document.createElement('section'); + panel.className = 'sm-connect-provision-progress card'; + panel.hidden = true; + panel.setAttribute('role', 'status'); + panel.setAttribute('aria-live', 'polite'); + const title = document.createElement('h2'); + title.textContent = 'Connecting your storage'; + const message = document.createElement('p'); + const help = document.createElement('p'); + help.textContent = 'Google can take a few minutes. You can reload this page without starting over.'; + panel.append(title, message, help); + document.querySelector('.sm-connect-steps').after(panel); + function remember(value) { + job = value; + try { if (value) sessionStorage.setItem(storageKey, value); else sessionStorage.removeItem(storageKey); } catch (_) {} + } + function show(text) { + panel.hidden = false; + message.textContent = text; + if (form) form.querySelectorAll('input[type=submit], button[type=submit]').forEach(button => { button.disabled = true; }); + } + async function poll() { + if (polling) return; + polling = true; + try { + const url = new URL(config.url); + url.searchParams.set('action', 'sm_gc_provision_status'); + url.searchParams.set('nonce', config.nonce); + if (job) url.searchParams.set('job', job); + const response = await fetch(url, {credentials: 'same-origin', cache: 'no-store'}); + if (!response.ok) throw new Error('Status unavailable'); + const result = await response.json(); + if (!result.success) throw new Error('Status unavailable'); + const data = result.data; + if (data.status === 'none') { + if (!job) { + if (!panel.hidden) location.reload(); + return; + } + misses++; + show('Waiting for the setup request to reach WordPress. Do not submit it again.'); + if (misses > 20) { + show('WordPress has not confirmed this request. Reload to review your choices before trying again.'); + remember(''); + return; + } + } else if (data.status === 'busy') { + show('Another administrator is connecting storage. Waiting for that setup to finish.'); + } else { + misses = 0; + remember(data.id); + if (data.status === 'complete' || data.status === 'failed') { + remember(''); + // A reload can land after the setup failed: this page then already shows the explanation, so there + // is nothing to navigate to. + if (data.status === 'failed' && panel.hidden && document.querySelector('.sm-connect-result')) return; + location.assign(data.url); + return; + } + if (data.status === 'interrupted') { + show(data.message); + help.textContent = 'Your completed steps are saved. Review them before continuing.'; + const resume = document.createElement('button'); + resume.type = 'button'; + resume.className = 'button button-primary'; + resume.textContent = 'Resume setup'; + resume.addEventListener('click', () => location.reload()); + panel.append(resume); + remember(''); + return; + } + show(data.message); + } + } catch (_) { + if (!job && panel.hidden) return; + show('Still checking your setup. The connection was interrupted; your setup request has not been repeated.'); + } finally { polling = false; } + window.setTimeout(poll, 2000); + } + if (form) form.addEventListener('submit', event => { + event.preventDefault(); + if (job || !panel.hidden) return; + const bytes = new Uint8Array(16); + crypto.getRandomValues(bytes); + remember(Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('')); + const body = new FormData(form); + body.set('sm_gc_job', job); + show('Starting your Google Cloud setup.'); + panel.scrollIntoView({block: 'center', behavior: 'smooth'}); + // Do not retry this POST. Poll the persisted result even if the proxy drops its response. + fetch(form.getAttribute('action'), {method: 'POST', body, credentials: 'same-origin'}).catch(() => {}); + poll(); + }); + poll(); +})(); diff --git a/static/styles/stateless-connect.css b/static/styles/stateless-connect.css index 34c72aa9e..65cc0bf4f 100644 --- a/static/styles/stateless-connect.css +++ b/static/styles/stateless-connect.css @@ -43,3 +43,9 @@ .sm-connect .sm-location-types label:has(:checked){background:#f3f4ff;border-color:#303fe9} .sm-connect .sm-mode-option:has(:disabled){opacity:.55;cursor:not-allowed} @media(max-width:1000px){.sm-connect .form-table .sm-location-types{grid-template-columns:1fr}} +.sm-connect-recommended{padding:20px 22px;margin:0 0 20px;background:#f7f8fc;border:1px solid #e0e4ef;border-radius:4px}.sm-connect-recommended h3{margin:0 0 10px;font-size:17px}.sm-connect-recommended p{margin:0 0 10px;line-height:1.65}.sm-connect-recommended p:last-child{margin-bottom:0}.sm-connect .sm-connect-advanced{max-width:none;border-top:0;padding:0;margin:0 0 8px}.sm-connect .sm-connect-advanced>summary{display:inline-block;margin:0 0 16px;font-size:14px}.sm-connect .sm-connect-advanced[open]{padding-bottom:0}.sm-connect .sm-billing-recovery{padding:12px 16px}.sm-connect .sm-billing-recovery form{margin:0}.sm-connect .sm-billing-recovery .submit{margin:0;padding:0;border:0} + +/* Keep supporting copy separate from the help and restart actions. */ +.sm-connect .sm-connect-help{display:block}.sm-connect-help p{margin:0 0 8px;font-size:inherit;line-height:inherit}.sm-connect-help-actions{display:flex;flex-wrap:wrap;align-items:baseline;gap:8px 24px}.sm-connect-help-actions>a,.sm-connect-help-actions .button-link{display:inline-block;white-space:nowrap;line-height:1.6}.sm-connect-help-actions form{margin:0} + +.sm-connect-log{list-style:none;margin:16px 0 20px;padding:0}.sm-connect-log li{display:flex;align-items:flex-start;gap:10px;margin:8px 0;line-height:1.6;overflow-wrap:anywhere}.sm-connect-log-mark{flex:0 0 16px;width:16px;height:16px;box-sizing:border-box;margin-top:3px;text-align:center;line-height:14px;font-size:12px;font-weight:600;border:1px solid #b4c9bc;border-radius:2px;color:#39734d;background:#f3f8f4}.sm-connect-log-warning .sm-connect-log-mark{color:#8a5919;border-color:#d5bd96;background:#fbf7ef}.sm-connect-log-info .sm-connect-log-mark{color:#687086;border-color:transparent;background:transparent} diff --git a/static/views/connect.php b/static/views/connect.php index 1c9e565e4..7ec26ea01 100644 --- a/static/views/connect.php +++ b/static/views/connect.php @@ -25,7 +25,7 @@