PluginProbe
Booking Calendar / 11.9
Booking Calendar v11.9
11.9 11.8.4 11.8.3 11.8.2 11.8.1 11.8 11.7 11.6.1 11.6 11.5 11.4.3 11.4.2 11.4.1 11.4 11.3 11.2.1 11.2 11.1 11.0 10.15.7 10.15.6 10.1.3 10.10 10.10.1 10.10.2 All 205 releases
booking / includes / page-setup-wizard / class-wpbc-setup-wizard-operation-lock.php

class-wpbc-setup-wizard-operation-lock.php in Booking Calendar 11.9, at includes/page-setup-wizard/class-wpbc-setup-wizard-operation-lock.php

135 lines 4.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * WordPress-backed Setup Wizard operation lock.
4 *
5 * @package Booking Calendar
6 */
7
8 if ( ! defined( 'ABSPATH' ) ) {
9 exit;
10 }
11
12 /**
13 * Serialize save operations for one storage context and step.
14 */
15 final class WPBC_Setup_Wizard_WordPress_Operation_Lock implements WPBC_Setup_Wizard_Operation_Lock {
16
17 const LOCK_TTL = 120;
18
19 /**
20 * Acquire one option-backed lock through WordPress' atomic add operation.
21 *
22 * Expired records are removed once and acquisition is retried. The option is
23 * non-autoloaded and contains only opaque hashes and timestamps.
24 *
25 * @param string $lock_scope Opaque checkpoint storage scope.
26 * @param string $step_id Stable step identifier.
27 * @param string $operation_id Stable operation identifier.
28 *
29 * @return string|WP_Error Opaque lock token or busy error.
30 */
31 public function acquire( $lock_scope, $step_id, $operation_id ) {
32 $option_name = $this->get_option_name( $lock_scope, $step_id );
33 $lock_token = function_exists( 'wp_generate_uuid4' ) ? wp_generate_uuid4() : uniqid( 'wpbc_', true );
34 $lock_record = array(
35 'token' => $lock_token,
36 'operation_id' => sanitize_key( $operation_id ),
37 'expires_at' => time() + self::LOCK_TTL,
38 );
39
40 if ( add_option( $option_name, $lock_record, '', false ) ) {
41 return $lock_token;
42 }
43
44 $existing_lock = get_option( $option_name, array() );
45 if ( $this->delete_expired_lock( $option_name, $existing_lock ) ) {
46 if ( add_option( $option_name, $lock_record, '', false ) ) {
47 return $lock_token;
48 }
49 }
50
51 return new WP_Error( 'wpbc_setup_wizard_operation_locked', __( 'This Setup Wizard step is already being saved. Wait for it to finish, then try again.', 'booking' ) );
52 }
53
54 /**
55 * Release a lock only when its opaque token still matches.
56 *
57 * @param string $lock_scope Opaque checkpoint storage scope.
58 * @param string $step_id Stable step identifier.
59 * @param string $lock_token Opaque token returned by acquire().
60 *
61 * @return void
62 */
63 public function release( $lock_scope, $step_id, $lock_token ) {
64 $option_name = $this->get_option_name( $lock_scope, $step_id );
65 $current_lock = get_option( $option_name, array() );
66
67 if ( is_array( $current_lock ) && isset( $current_lock['token'] ) && hash_equals( (string) $current_lock['token'], (string) $lock_token ) ) {
68 $this->delete_lock_record( $option_name, $current_lock );
69 }
70 }
71
72 /**
73 * Build a bounded site-option name without exposing context identifiers.
74 *
75 * @param string $lock_scope Opaque checkpoint storage scope.
76 * @param string $step_id Stable step identifier.
77 *
78 * @return string WordPress option name.
79 */
80 private function get_option_name( $lock_scope, $step_id ) {
81 return 'wpbc_setup_wizard_step_lock_' . md5( (string) $lock_scope . '|' . sanitize_key( $step_id ) );
82 }
83
84 /**
85 * Atomically remove the exact expired record observed by this request.
86 *
87 * A normal read-then-delete sequence could remove a fresh lock created by a
88 * competing request between those operations. Matching both option name and
89 * serialized value limits deletion to the stale record that was read. The
90 * option cache is cleared only after that exact row was removed.
91 *
92 * @param string $option_name Internal lock option name.
93 * @param mixed $lock_record Observed lock record candidate.
94 *
95 * @return bool True when this request removed the observed expired record.
96 */
97 private function delete_expired_lock( $option_name, $lock_record ) {
98 if ( ! is_array( $lock_record ) || ! isset( $lock_record['expires_at'] ) || (int) $lock_record['expires_at'] >= time() ) {
99 return false;
100 }
101
102 return $this->delete_lock_record( $option_name, $lock_record );
103 }
104
105 /**
106 * Atomically delete one exact lock record and clear its option cache.
107 *
108 * @param string $option_name Internal lock option name.
109 * @param array<string,mixed> $lock_record Exact record observed by the caller.
110 *
111 * @return bool True when the exact record was deleted.
112 */
113 private function delete_lock_record( $option_name, array $lock_record ) {
114 global $wpdb;
115
116 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Atomic compare-and-delete is required; the exact option cache is cleared below.
117 $deleted_rows = $wpdb->query(
118 $wpdb->prepare(
119 "DELETE FROM {$wpdb->options} WHERE option_name = %s AND option_value = %s",
120 $option_name,
121 maybe_serialize( $lock_record )
122 )
123 );
124
125 if ( 1 !== (int) $deleted_rows ) {
126 return false;
127 }
128
129 wp_cache_delete( $option_name, 'options' );
130 wp_cache_delete( 'notoptions', 'options' );
131
132 return true;
133 }
134 }
135