| @@ -229,24 +229,113 @@ | ||
| 229 | 229 | return (bool) apply_filters( 'woocommerce_pos_is_pro_active', \defined( 'WCPOS\WooCommercePOSPro\VERSION' ) ); |
| 230 | 230 | } |
| 231 | 231 | } |
| 232 | 232 | |
| 233 | +if ( ! \function_exists( 'wcpos_get_site_identity_home' ) ) { | |
| 234 | + /** | |
| 235 | + * Get the site address as an identity: no scheme, no `www.`, no trailing | |
| 236 | + * slash; the host lower-cased; the port and the case-sensitive path kept, | |
| 237 | + * so `example.com/staging` is another site and `www.example.com` is not. | |
| 238 | + * | |
| 239 | + * @return string Normalised home address. | |
| 240 | + */ | |
| 241 | + function wcpos_get_site_identity_home(): string { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedFunctionFound -- uses wcpos_ prefix. | |
| 242 | + $parts = wp_parse_url( '//' . preg_replace( '#^(?:https?:)?//#i', '', home_url() ) ); | |
| 243 | + $host = preg_replace( '#^www\.#', '', strtolower( (string) ( $parts['host'] ?? '' ) ) ); | |
| 244 | + $port = isset( $parts['port'] ) ? ':' . $parts['port'] : ''; | |
| 245 | + | |
| 246 | + return untrailingslashit( $host . $port . ( $parts['path'] ?? '' ) ); | |
| 247 | + } | |
| 248 | +} | |
| 249 | + | |
| 250 | +if ( ! \function_exists( 'wcpos_site_identity_home_row' ) ) { | |
| 251 | + /** | |
| 252 | + * Read the persisted home marker straight from the options table. | |
| 253 | + * | |
| 254 | + * The option cache answers get_option() for the rest of the request, and a | |
| 255 | + * same-value update_option() leaves it untouched; this is the only read that can tell a | |
| 256 | + * marker another request already advanced from one whose write failed. | |
| 257 | + * | |
| 258 | + * @return string|null The stored marker, or null when absent. | |
| 259 | + */ | |
| 260 | + function wcpos_site_identity_home_row(): ?string { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedFunctionFound -- uses wcpos_ prefix. | |
| 261 | + global $wpdb; | |
| 262 | + $row = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- bypasses the option cache on purpose, see docblock. | |
| 263 | + $wpdb->prepare( "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1", 'woocommerce_pos_uuid_home' ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- core table name. | |
| 264 | + ); | |
| 265 | + | |
| 266 | + return \is_string( $row ) ? $row : null; | |
| 267 | + } | |
| 268 | +} | |
| 269 | + | |
| 233 | 270 | /* |
| 234 | - * Get the site UUID (Plugin State), generating and persisting it on first use. | |
| 271 | + * Get the site UUID (Plugin State), tied to the site address. | |
| 235 | 272 | * |
| 236 | 273 | * @return string Site UUID. |
| 237 | 274 | */ |
| 238 | 275 | if ( ! \function_exists( 'wcpos_get_site_uuid' ) ) { |
| 239 | 276 | /** |
| 240 | - * Get the site UUID, generating and persisting it on first use. | |
| 277 | + * Get the site UUID, generating it on first use or when the address changes. | |
| 241 | 278 | * |
| 242 | 279 | * Single owner for the woocommerce_pos_uuid option — the |
| 243 | 280 | * generate-if-missing logic previously lived in three places (REST index, |
| 244 | 281 | * POS frontend, analytics) and could race. |
| 282 | + * The saved home prevents database clones sharing a live store's identity. | |
| 283 | + * On upgrade, an absent home is recorded without changing the existing UUID. | |
| 245 | 284 | * |
| 246 | 285 | * @return string Site UUID. |
| 247 | 286 | */ |
| 248 | 287 | function wcpos_get_site_uuid(): string { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedFunctionFound -- uses wcpos_ prefix. |
| 288 | + $home = wcpos_get_site_identity_home(); | |
| 289 | + $stored_home = get_option( 'woocommerce_pos_uuid_home' ); | |
| 290 | + if ( false === $stored_home ) { | |
| 291 | + // Keep the first writer's home if another request wins the race. | |
| 292 | + $stored_home = add_option( 'woocommerce_pos_uuid_home', $home ) | |
| 293 | + ? $home | |
| 294 | + : get_option( 'woocommerce_pos_uuid_home' ); | |
| 295 | + } | |
| 296 | + | |
| 297 | + if ( $stored_home !== $home ) { | |
| 298 | + // A moved address is a new identity. Two requests can both land | |
| 299 | + // here; the last write wins and each returns what is stored, so the | |
| 300 | + // REST response never advertises a uuid the database does not hold. | |
| 301 | + // The home marker advances only behind a persisted uuid: a vetoed or | |
| 302 | + // failed write would otherwise hand out an identity that the next | |
| 303 | + // call, seeing a matching home, no longer returns. | |
| 304 | + $previous = get_option( 'woocommerce_pos_uuid', '' ); | |
| 305 | + $minted = \Ramsey\Uuid\Uuid::uuid4()->toString(); | |
| 306 | + if ( update_option( 'woocommerce_pos_uuid', $minted ) ) { | |
| 307 | + // update_option() also answers false when the row already holds | |
| 308 | + // $home: a concurrent request finished the move between this one's | |
| 309 | + // read and its write. That is a completed move, not a failed one, | |
| 310 | + // and rolling back would leave the old uuid behind the new marker | |
| 311 | + // for good, since every later call sees a matching home. This | |
| 312 | + // request's option cache still holds the old marker (the same-value | |
| 313 | + // UPDATE touched no row, so nothing refreshed it), so the check | |
| 314 | + // reads the row itself and, when the move is confirmed, drops the | |
| 315 | + // stale cache so a later call in this request does not mint again, | |
| 316 | + // and drops the uuid this request cached from its own write so the | |
| 317 | + // value returned below is the one the other request left stored. | |
| 318 | + $marker_moved = update_option( 'woocommerce_pos_uuid_home', $home ) || wcpos_site_identity_home_row() === $home; | |
| 319 | + if ( $marker_moved && get_option( 'woocommerce_pos_uuid_home' ) !== $home ) { | |
| 320 | + wp_cache_delete( 'woocommerce_pos_uuid', 'options' ); | |
| 321 | + wp_cache_delete( 'woocommerce_pos_uuid_home', 'options' ); | |
| 322 | + wp_cache_delete( 'alloptions', 'options' ); | |
| 323 | + } | |
| 324 | + if ( ! $marker_moved && \is_string( $previous ) && '' !== $previous ) { | |
| 325 | + // The marker did not move: put the previous uuid back so the next | |
| 326 | + // call retries the whole move, rather than minting again on top of | |
| 327 | + // a rotation the marker never recorded. | |
| 328 | + update_option( 'woocommerce_pos_uuid', $previous ); | |
| 329 | + } | |
| 330 | + } | |
| 331 | + | |
| 332 | + $stored = get_option( 'woocommerce_pos_uuid', '' ); | |
| 333 | + if ( \is_string( $stored ) && '' !== $stored ) { | |
| 334 | + return $stored; | |
| 335 | + } | |
| 336 | + } | |
| 337 | + | |
| 249 | 338 | $uuid = get_option( 'woocommerce_pos_uuid', '' ); |
| 250 | 339 | if ( \is_string( $uuid ) && '' !== $uuid ) { |
| 251 | 340 | return $uuid; |
| 252 | 341 | } |