PluginProbe
Fluent Booking – The Ultimate Appointments Scheduling, Events Booking, Events Calendar Solution / 2.5.0
Fluent Booking – The Ultimate Appointments Scheduling, Events Booking, Events Calendar Solution v2.5.0
2.5.0 2.4.0 2.3.0 2.2.5 2.2.0 2.1.2 2.1.1 trunk 1.10.0 1.10.01 1.10.02 1.5.0 1.5.01 1.5.02 1.5.1 1.5.10 1.5.20 1.5.21 1.5.22 1.5.23 1.5.24 1.5.25 1.6.0 1.7.0 1.7.1 All 34 releases
fluent-booking / app / Modules / MCP / Support / WriteGuard.php

WriteGuard.php in Fluent Booking – The Ultimate Appointments Scheduling, Events Booking, Events Calendar Solution 2.5.0, at app/Modules/MCP/Support/WriteGuard.php

587 lines 19.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentBooking\App\Modules\MCP\Support;
4
5 use FluentBooking\Framework\Support\Arr;
6
7 defined('ABSPATH') || exit;
8
9 /**
10 * Safety rails for destructive MCP writes. Tool annotations are only hints;
11 * the real protection is here.
12 *
13 * 1. Confirm tokens. A dry_run returns a preview and a token bound to the
14 * record's current state and to the exact parameters previewed. Executing
15 * needs the token back with the same parameters, so an agent can't act on a
16 * record that has since changed, or run a different change than the one
17 * that was approved.
18 * 2. Idempotency keys. A retry with the same key returns the first result
19 * instead of booking twice. This must wrap the token check, see idempotent().
20 *
21 * Records live in wp_options rather than transients: INSERT IGNORE gives us an
22 * atomic claim (get + delete transient is a race), and an object-cache flush
23 * can't drop an idempotency record and let a duplicate write through.
24 *
25 * Contract: every ability annotated `destructive => true`, including Pro's,
26 * must treat dry_run as non-mutating and pass each write through confirm().
27 * create-booking and manage-booking are the reference. The mcp:permissions
28 * gate dry-runs every destructive action and fails if any row count moves.
29 *
30 * @since 2.2.6
31 */
32 class WriteGuard
33 {
34 const CONFIRM_TTL = 300; // 5 minutes to confirm a previewed action.
35
36 const IDEM_TTL = 86400; // remember an idempotency key for a day.
37
38 // Kept short: option_name is indexed at 191 chars and every key ends in an md5.
39 const STORE_PREFIX = 'fcal_mcp_g_';
40
41 const CONFIRM_NEXT_STEP = 'Call this tool again with EXACTLY the same parameters plus confirm_token (and an idempotency_key) to execute. Changing any parameter invalidates the token.';
42
43 /**
44 * Build a dry-run preview with a confirm token.
45 *
46 * @param string $tool Ability name.
47 * @param string $entityKey Target id, e.g. "booking:42".
48 * @param string $fingerprint The target's mutable state; a change rejects the token.
49 * @param array $preview The preview payload.
50 * @param string $paramsDigest From paramsDigest(); different parameters reject the token.
51 *
52 * @return array
53 */
54 public static function preview($tool, $entityKey, $fingerprint, array $preview, $paramsDigest = '')
55 {
56 // wp_generate_password() uses random_int; wp_generate_uuid4() can fall back to mt_rand.
57 $token = substr(wp_hash($tool . '|' . $entityKey . '|' . $fingerprint . '|' . wp_generate_password(32, false, false)), 0, 32);
58
59 self::write(self::confirmKey($tool, $entityKey), [
60 'token' => $token,
61 'fingerprint' => (string) $fingerprint,
62 'params' => (string) $paramsDigest,
63 ], self::CONFIRM_TTL);
64
65 return [
66 'dry_run' => true,
67 'preview' => $preview,
68 'confirm_token' => $token,
69 'expires_in_seconds' => self::CONFIRM_TTL,
70 ];
71 }
72
73 /**
74 * Check a confirm_token against the target's current state and parameters.
75 *
76 * @param string $tool
77 * @param string $entityKey
78 * @param string $currentFingerprint
79 * @param string $token
80 * @param string $paramsDigest Digest of the parameters being executed.
81 *
82 * @return true|\WP_Error
83 */
84 public static function confirm($tool, $entityKey, $currentFingerprint, $token, $paramsDigest = '')
85 {
86 if (empty($token)) {
87 return MCPHelper::error(
88 'confirmation_required',
89 __('This action cannot be undone. Call again with dry_run:true to preview it, then pass the returned confirm_token to execute.', 'fluent-booking'),
90 ['next_step' => 'set dry_run:true']
91 );
92 }
93
94 $key = self::confirmKey($tool, $entityKey);
95 $stored = self::read($key);
96
97 if (!is_array($stored) || empty($stored['token'])) {
98 return MCPHelper::error(
99 'confirmation_expired',
100 __('Your confirmation has expired. Run a fresh dry_run to preview and get a new confirm_token.', 'fluent-booking'),
101 ['next_step' => 'set dry_run:true']
102 );
103 }
104
105 if (!hash_equals((string) $stored['token'], (string) $token)) {
106 return MCPHelper::error(
107 'confirmation_invalid',
108 __('The confirm_token does not match. Run a fresh dry_run.', 'fluent-booking'),
109 ['next_step' => 'set dry_run:true']
110 );
111 }
112
113 if ((string) $stored['fingerprint'] !== (string) $currentFingerprint) {
114 self::delete($key);
115 return MCPHelper::error(
116 'state_changed',
117 __('The record changed since you previewed it. Run a fresh dry_run to see the current state before executing.', 'fluent-booking'),
118 ['next_step' => 'set dry_run:true']
119 );
120 }
121
122 // The token approves the previewed change, not just the record. Otherwise
123 // a preview without a refund could be executed with refund_payment:true.
124 if ((string) $stored['params'] !== (string) $paramsDigest) {
125 self::delete($key);
126 return MCPHelper::error(
127 'parameters_changed',
128 __('These are not the parameters you previewed. A confirm_token authorises the exact change it was issued for. Run a fresh dry_run with the parameters you actually want.', 'fluent-booking'),
129 ['next_step' => 'set dry_run:true']
130 );
131 }
132
133 // Single use, claimed atomically so only one of two concurrent requests
134 // gets through. Keyed on the token rather than the booking, so a fresh
135 // token for the same booking isn't blocked for the rest of the TTL.
136 if (!self::claim(self::usedKey($token), 1, self::CONFIRM_TTL)) {
137 return MCPHelper::error(
138 'confirmation_expired',
139 __('That confirm_token has already been used. Run a fresh dry_run.', 'fluent-booking'),
140 ['next_step' => 'set dry_run:true']
141 );
142 }
143
144 self::delete($key);
145
146 return true;
147 }
148
149 /**
150 * Run $fn at most once per idempotency key, scoped to user, tool and
151 * entity. Without a key, $fn just runs.
152 *
153 * This must be the outermost wrapper, with confirm() inside $fn. The other
154 * way round, confirm() consumes the token and the retry fails before it
155 * ever reaches the cached result.
156 *
157 * Only a reference to the result is stored, never the response itself: the
158 * response holds attendee PII, and wp_options ends up in exports and
159 * staging clones. $replay rebuilds the response from the live record.
160 *
161 * @param string $tool
162 * @param string $entityKey
163 * @param string $key
164 * @param callable $fn
165 * @param string $paramsDigest
166 * @param callable|null $replay Rebuilds the response from the stored reference.
167 *
168 * @return mixed
169 */
170 public static function idempotent($tool, $entityKey, $key, callable $fn, $paramsDigest = '', $replay = null)
171 {
172 if (empty($key)) {
173 return $fn();
174 }
175
176 $cacheKey = self::idemKey($tool, $entityKey, $key);
177 $lockKey = $cacheKey . '_lock';
178
179 $cached = self::read($cacheKey);
180
181 if (is_array($cached) && array_key_exists('ref', $cached)) {
182 // Same key, different parameters: refuse, or a second reschedule
183 // would report success without moving anything.
184 if ((string) Arr::get($cached, 'params', '') !== (string) $paramsDigest) {
185 return MCPHelper::error(
186 'idempotency_conflict',
187 __('This idempotency_key was already used for a different request. Use a fresh key for a new change; reuse a key only when retrying the identical call.', 'fluent-booking'),
188 ['next_step' => 'retry with a new idempotency_key']
189 );
190 }
191
192 $ref = (array) $cached['ref'];
193
194 if (is_callable($replay)) {
195 $rebuilt = call_user_func($replay, $ref);
196
197 if (is_array($rebuilt)) {
198 return self::flagReplay($rebuilt);
199 }
200 }
201
202 return self::flagReplay(MCPHelper::success($ref));
203 }
204
205 // Claim before running, so two concurrent retries can't both execute.
206 if (!self::claim($lockKey, 1, 120)) {
207 return MCPHelper::error(
208 'in_progress',
209 __('Another call with this idempotency_key is still running. Wait for it to finish rather than retrying — retrying is what this key exists to make safe.', 'fluent-booking'),
210 ['next_step' => 'poll with get-booking, or retry in a few seconds']
211 );
212 }
213
214 try {
215 $result = $fn();
216
217 // Only successes are recorded, so a failure stays retryable. If the
218 // record can't be written, say so rather than invite a duplicate retry.
219 if (!is_wp_error($result)) {
220 if (!self::write($cacheKey, ['ref' => self::resultRef($result), 'params' => (string) $paramsDigest], self::IDEM_TTL)
221 && is_array($result)) {
222 $result['idempotency_warning'] = __('This change was applied, but the idempotency record could not be stored. Do not retry with the same key — check the result before acting again.', 'fluent-booking');
223 }
224 }
225
226 return $result;
227 } finally {
228 self::delete($lockKey);
229 }
230 }
231
232 /**
233 * Mark a response as a replay. Always in `meta`, so agents check one place.
234 *
235 * @param array $response
236 *
237 * @return array
238 */
239 private static function flagReplay($response)
240 {
241 if (!isset($response['meta']) || !is_array($response['meta'])) {
242 $response['meta'] = [];
243 }
244
245 $response['meta']['idempotent_replay'] = true;
246
247 return $response;
248 }
249
250 /**
251 * Reduce a write's response to the identifiers a replay can rebuild from.
252 *
253 * @param mixed $result
254 *
255 * @return array
256 */
257 private static function resultRef($result)
258 {
259 if (!is_array($result)) {
260 return [];
261 }
262
263 $ref = [];
264
265 // Identity and outcome fields only, no attendee data.
266 foreach (['id', 'action', 'created', 'message'] as $key) {
267 if (isset($result['data'][$key]) && is_scalar($result['data'][$key])) {
268 $ref[$key] = $result['data'][$key];
269 }
270 }
271
272 $bookingId = Arr::get($result, 'data.booking.id');
273
274 if ($bookingId) {
275 $ref['booking_id'] = (int) $bookingId;
276 }
277
278 return $ref;
279 }
280
281 /**
282 * A stable digest of the parameters that change what a call does. The
283 * control parameters are left out, since they differ between a preview,
284 * its execution and a retry.
285 *
286 * @param array $params
287 * @param array $ignore Extra keys to exclude.
288 *
289 * @return string
290 */
291 public static function paramsDigest($params, $ignore = [])
292 {
293 $params = (array) $params;
294
295 foreach (array_merge(['dry_run', 'confirm_token', 'idempotency_key'], (array) $ignore) as $key) {
296 unset($params[$key]);
297 }
298
299 return md5((string) wp_json_encode(self::canonicalize($params)));
300 }
301
302 /**
303 * Sort keys recursively so parameter order doesn't break the match.
304 *
305 * @param mixed $value
306 * @return mixed
307 */
308 private static function canonicalize($value)
309 {
310 if (!is_array($value)) {
311 return is_scalar($value) || $value === null ? $value : (string) wp_json_encode($value);
312 }
313
314 $out = [];
315
316 foreach ($value as $key => $item) {
317 $out[$key] = self::canonicalize($item);
318 }
319
320 // Associative arrays are order-insensitive; lists are not.
321 if (array_keys($out) !== range(0, count($out) - 1)) {
322 ksort($out);
323 }
324
325 return $out;
326 }
327
328 /**
329 * A booking's fingerprint. updated_at catches edits we don't track
330 * separately, like a note or payment change.
331 *
332 * @param \FluentBooking\App\Models\Booking $booking
333 *
334 * @return string
335 */
336 public static function bookingFingerprint($booking)
337 {
338 return implode('|', [
339 $booking->status,
340 $booking->start_time,
341 $booking->updated_at instanceof \DateTimeInterface
342 ? $booking->updated_at->format('Y-m-d H:i:s')
343 : $booking->updated_at,
344 ]);
345 }
346
347 // Batch size x passes caps one run, to stay inside a 30s Action Scheduler tick.
348 const PURGE_BATCH = 500;
349
350 const PURGE_MAX_PASSES = 40;
351
352 /**
353 * Delete expired records. Runs daily, since nothing else prunes options.
354 *
355 * @return int rows removed
356 */
357 public static function purgeExpired()
358 {
359 global $wpdb;
360
361 $removed = 0;
362 $now = time();
363 $offset = 0;
364
365 for ($pass = 0; $pass < self::PURGE_MAX_PASSES; $pass++) {
366 // Read values in the same query instead of get_option() per row.
367 $rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
368 $wpdb->prepare(
369 "SELECT option_name, option_value FROM {$wpdb->options} WHERE option_name LIKE %s OR option_name LIKE %s ORDER BY option_id ASC LIMIT %d OFFSET %d",
370 $wpdb->esc_like(self::STORE_PREFIX) . '%',
371 $wpdb->esc_like(SlotLock::PREFIX) . '%',
372 self::PURGE_BATCH,
373 $offset
374 ),
375 ARRAY_A
376 );
377
378 if (!$rows) {
379 break;
380 }
381
382 $expired = [];
383
384 foreach ($rows as $row) {
385 $record = maybe_unserialize($row['option_value']);
386
387 // Keep live records. Rows without a valid envelope can never
388 // be used, so they go too.
389 if (is_array($record) && !empty($record['expires']) && $record['expires'] >= $now) {
390 continue;
391 }
392
393 $expired[] = $row['option_name'];
394 }
395
396 if ($expired) {
397 $placeholders = implode(', ', array_fill(0, count($expired), '%s'));
398
399 $removed += (int) $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
400 $wpdb->prepare("DELETE FROM {$wpdb->options} WHERE option_name IN ({$placeholders})", $expired) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- %s placeholders built by array_fill(); values passed to prepare()
401 );
402
403 foreach ($expired as $name) {
404 self::forgetCached($name);
405 }
406 }
407
408 // Skip past the rows we kept.
409 $offset += count($rows) - count($expired);
410
411 if (count($rows) < self::PURGE_BATCH) {
412 break;
413 }
414 }
415
416 wp_cache_delete('alloptions', 'options');
417
418 return $removed;
419 }
420
421 /**
422 * Take a short exclusive window for one repeatable action.
423 *
424 * @param string $key caller-scoped identifier
425 * @param int $ttl seconds the window lasts
426 *
427 * @return bool true when the caller may proceed
428 */
429 public static function cooldown($key, $ttl)
430 {
431 return self::claim(self::STORE_PREFIX . 'cd_' . md5($key), 1, $ttl);
432 }
433
434 /**
435 * Atomic claim: exactly one of N concurrent callers gets true.
436 *
437 * Uses INSERT IGNORE, as core's WP_Upgrader::create_lock() does. Not
438 * add_option(): it runs INSERT ... ON DUPLICATE KEY UPDATE, so a second
439 * caller updates the row and is also told it succeeded.
440 *
441 * @param string $key
442 * @param mixed $value
443 * @param int $ttl
444 *
445 * @return bool true when this caller took the claim
446 */
447 private static function claim($key, $value, $ttl)
448 {
449 // Clear an expired claim before inserting, never after, so we can't
450 // remove a lock someone else just took.
451 $existing = self::readRaw($key);
452
453 if (is_array($existing) && !empty($existing['expires']) && $existing['expires'] < time()) {
454 self::delete($key);
455 }
456
457 return self::insertIgnore($key, ['value' => $value, 'expires' => time() + (int) $ttl]);
458 }
459
460 /**
461 * @param string $key
462 * @param array $record
463 * @return bool true when the row did not exist and this call created it
464 */
465 private static function insertIgnore($key, $record)
466 {
467 global $wpdb;
468
469 $inserted = $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
470 $wpdb->prepare(
471 "INSERT IGNORE INTO `{$wpdb->options}` (`option_name`, `option_value`, `autoload`) VALUES (%s, %s, 'no')",
472 $key,
473 maybe_serialize($record)
474 )
475 );
476
477 // We bypassed the options API, so clear any stale `notoptions` entry.
478 self::forgetCached($key);
479
480 return (bool) $inserted;
481 }
482
483 /**
484 * Drop an option from the object cache, including the negative cache.
485 *
486 * @param string $key
487 */
488 private static function forgetCached($key)
489 {
490 wp_cache_delete($key, 'options');
491
492 $notoptions = wp_cache_get('notoptions', 'options');
493
494 if (is_array($notoptions) && isset($notoptions[$key])) {
495 unset($notoptions[$key]);
496 wp_cache_set('notoptions', $notoptions, 'options');
497 }
498 }
499
500 /**
501 * The stored record and its envelope, without read()'s expiry check.
502 *
503 * @param string $key
504 * @return array|null
505 */
506 private static function readRaw($key)
507 {
508 $record = get_option($key);
509
510 return is_array($record) ? $record : null;
511 }
512
513 /**
514 * @param string $key
515 * @return array|null
516 */
517 private static function read($key)
518 {
519 $record = get_option($key);
520
521 if (!is_array($record) || empty($record['expires'])) {
522 return null;
523 }
524
525 if ($record['expires'] < time()) {
526 delete_option($key);
527 return null;
528 }
529
530 return isset($record['value']) && is_array($record['value']) ? $record['value'] : null;
531 }
532
533 /**
534 * @param string $key
535 * @param array $value
536 * @param int $ttl
537 */
538 private static function write($key, $value, $ttl)
539 {
540 $record = ['value' => $value, 'expires' => time() + (int) $ttl];
541
542 if (self::insertIgnore($key, $record)) {
543 return true;
544 }
545
546 $updated = update_option($key, $record, false);
547
548 self::forgetCached($key);
549
550 // update_option() returns false for an unchanged value, so read it back.
551 if ($updated) {
552 return true;
553 }
554
555 $stored = self::readRaw($key);
556
557 return is_array($stored) && isset($stored['value']) && $stored['value'] === $value;
558 }
559
560 /**
561 * @param string $key
562 */
563 private static function delete($key)
564 {
565 delete_option($key);
566
567 self::forgetCached($key);
568 }
569
570 private static function confirmKey($tool, $entityKey)
571 {
572 // Per user, so one user can't consume another user's token.
573 return self::STORE_PREFIX . 'c' . get_current_user_id() . '_' . md5($tool . '|' . $entityKey);
574 }
575
576 private static function idemKey($tool, $entityKey, $key)
577 {
578 return self::STORE_PREFIX . 'i' . get_current_user_id() . '_' . md5($tool . '|' . $entityKey . '|' . $key);
579 }
580
581 // Tokens are unguessable and per user, so the token alone is a unique key.
582 private static function usedKey($token)
583 {
584 return self::STORE_PREFIX . 'u_' . md5((string) $token);
585 }
586 }
587