PluginProbe
Formidable Forms – WordPress Form Builder for Contact Forms, Calculators, Quizzes & More / 6.33.1
Formidable Forms – WordPress Form Builder for Contact Forms, Calculators, Quizzes & More v6.33.1
6.35 6.34 6.33.1 6.33 6.32.1 6.32 6.31 6.25 6.25.1 6.26 6.26.1 6.27 6.28 6.29 6.3 6.3.1 6.3.2 6.30 6.4 6.4.1 6.4.2 6.5 6.5.1 6.5.2 6.5.3 All 141 releases
formidable / classes / helpers / FrmGatedTokenHelper.php

FrmGatedTokenHelper.php in Formidable Forms – WordPress Form Builder for Contact Forms, Calculators, Quizzes & More 6.33.1, at classes/helpers/FrmGatedTokenHelper.php

587 lines 18.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Gated Token Helper
4 *
5 * @package Formidable
6 *
7 * @since 6.33
8 */
9
10 if ( ! defined( 'ABSPATH' ) ) {
11 die( 'You are not allowed to call this page directly.' );
12 }
13
14 class FrmGatedTokenHelper {
15
16 /**
17 * Per-request cache of raw tokens indexed by action_id.
18 * Populated by generate() so the same request can resolve the token
19 * without a transient round-trip.
20 *
21 * @var array<int, string>
22 */
23 private static $generated_tokens = array();
24
25 /**
26 * Per-request cache of token rows indexed by SHA-256 hash.
27 * Populated by get_row_by_hash() so multiple shortcodes for the same
28 * action on one page do not each issue a separate DB query.
29 *
30 * @var array<string, object|null>
31 */
32 private static $row_cache = array();
33
34 /**
35 * Generate a new access token for a gated content action and persist it.
36 *
37 * @param WP_Post $action Form action post object.
38 * @param object $entry Submitted form entry object (must have ->id).
39 * @param string $event Trigger event slug ('create', 'update', …).
40 *
41 * @return string Raw 32-character token. Only ever stored in URLs or emails — never in the DB.
42 */
43 public static function generate( $action, $entry, $event ) {
44 global $wpdb;
45
46 $action_id = $action->ID;
47 $entry_id = $entry->id;
48
49 $raw_token = wp_generate_password( 32, false );
50 $now = time();
51 $expired_at = null;
52 $settings = FrmAppHelper::maybe_json_decode( $action->post_content );
53
54 if ( is_array( $settings ) && ! empty( $settings['expired_hours'] ) ) {
55 $expired_at = $now + $settings['expired_hours'] * HOUR_IN_SECONDS;
56 }
57
58 $raw_user_id = get_current_user_id();
59 $user_id = $raw_user_id ? $raw_user_id : null;
60
61 $data = array(
62 'token_hash' => self::hash_token( $raw_token ),
63 'action_id' => $action_id,
64 'entry_id' => $entry_id,
65 'ip_address' => FrmAppHelper::get_ip_address(),
66 'created_at' => $now,
67 );
68
69 // Only include nullable columns when they carry a value — passing null
70 // with a %d format would insert 0 instead of NULL.
71 if ( null !== $user_id ) {
72 $data['user_id'] = $user_id;
73 }
74
75 if ( null !== $expired_at ) {
76 $data['expired_at'] = $expired_at;
77 }
78
79 /**
80 * Filter the token row data before inserting into the database.
81 *
82 * Add-ons can use this to override or extend the data inserted for a generated
83 * token — for example, Formidable Registration hooks here to set the correct
84 * user_id when the registrant is not yet logged in at trigger time.
85 *
86 * @since 6.33
87 *
88 * @param array $data Row data to insert into wp_frm_gated_tokens.
89 * @param array $args {
90 *
91 * @type WP_Post $action Form action post object.
92 * @type object $entry Submitted form entry object.
93 * @type string $event Trigger event slug.
94 * }
95 */
96 $data = apply_filters( 'frm_gated_content_token_data', $data, compact( 'action', 'entry', 'event' ) );
97
98 // Derive format from known column types — supports extra keys added by filters.
99 $type_map = array(
100 'token_hash' => '%s',
101 'action_id' => '%d',
102 'entry_id' => '%d',
103 'ip_address' => '%s',
104 'created_at' => '%d',
105 'user_id' => '%d',
106 'expired_at' => '%d',
107 );
108 $format = array_values( array_intersect_key( $type_map, $data ) );
109
110 $wpdb->insert( $wpdb->prefix . 'frm_gated_tokens', $data, $format );
111
112 // Cache in static variable for same-request shortcode rendering (no DB/cache round-trip).
113 self::$generated_tokens[ $action_id ] = $raw_token;
114
115 // Persist for shortcode rendering in a subsequent redirect request (5-min TTL).
116 set_transient( self::get_token_transient_key( $action_id ), $raw_token, 5 * MINUTE_IN_SECONDS );
117
118 return $raw_token;
119 }
120
121 /**
122 * Delete all tokens associated with a gated content action.
123 *
124 * Called when the action post is permanently deleted so orphaned token rows
125 * do not accumulate in wp_frm_gated_tokens.
126 *
127 * @param int $action_id ID of the frm_form_actions post being deleted.
128 *
129 * @return void
130 */
131 public static function delete_by_action( $action_id ) {
132 global $wpdb;
133
134 $wpdb->delete(
135 $wpdb->prefix . 'frm_gated_tokens',
136 array( 'action_id' => $action_id ),
137 array( '%d' )
138 );
139 }
140
141 /**
142 * Delete all expired tokens from the database.
143 *
144 * Intended to be called by WP Cron on a scheduled interval.
145 *
146 * @return void
147 */
148 public static function cleanup_expired() {
149 global $wpdb;
150
151 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
152 $wpdb->query(
153 $wpdb->prepare(
154 'DELETE FROM %i WHERE expired_at IS NOT NULL AND expired_at < %d',
155 $wpdb->prefix . 'frm_gated_tokens',
156 time()
157 )
158 );
159 }
160
161 /**
162 * Get all token rows for a given user.
163 *
164 * @param int $user_id WordPress user ID.
165 *
166 * @return object[]
167 */
168 public static function get_tokens_for_user( $user_id ) {
169 global $wpdb;
170
171 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
172 $results = $wpdb->get_results(
173 $wpdb->prepare(
174 'SELECT * FROM %i WHERE user_id = %d',
175 $wpdb->prefix . 'frm_gated_tokens',
176 $user_id
177 )
178 );
179
180 /** @var object[] $results */
181 return is_array( $results ) ? $results : array();
182 }
183
184 /**
185 * Hash a raw access token using SHA-256.
186 *
187 * @param string $raw_token Raw access token.
188 *
189 * @return string Hex-encoded SHA-256 hash.
190 */
191 public static function hash_token( $raw_token ) {
192 return hash( 'sha256', $raw_token );
193 }
194
195 /**
196 * Retrieve a single token row by raw token string.
197 *
198 * @param string $token Raw access token.
199 *
200 * @return object|null Token row object, or null if not found or token has no match.
201 */
202 public static function get_row_by_token( $token ) {
203 return self::get_row_by_hash( self::hash_token( $token ) );
204 }
205
206 /**
207 * Retrieve a single token row by pre-computed SHA-256 hash.
208 *
209 * Use this when you already hold a hash (e.g. from a cookie) to avoid
210 * double-hashing.
211 *
212 * @param string $hash Hex-encoded SHA-256 hash of the raw token.
213 *
214 * @return object|null Token row object, or null if not found.
215 */
216 public static function get_row_by_hash( $hash ) {
217 if ( array_key_exists( $hash, self::$row_cache ) ) {
218 return self::$row_cache[ $hash ];
219 }
220
221 global $wpdb;
222
223 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
224 $row = $wpdb->get_row(
225 $wpdb->prepare(
226 'SELECT * FROM %i WHERE token_hash = %s LIMIT 1',
227 $wpdb->prefix . 'frm_gated_tokens',
228 $hash
229 )
230 );
231
232 self::$row_cache[ $hash ] = is_object( $row ) ? $row : null;
233
234 return self::$row_cache[ $hash ];
235 }
236
237 /**
238 * Remove a token row from the per-request cache.
239 *
240 * Call this after mutating a token row (e.g. extending expiry or renewing
241 * the hash) so subsequent get_row_by_hash() calls re-fetch from the DB.
242 *
243 * @param string $hash SHA-256 hex hash of the token row to evict.
244 *
245 * @return void
246 */
247 public static function forget_cached_row( $hash ) {
248 unset( self::$row_cache[ $hash ] );
249 }
250
251 /**
252 * Validate a raw access code against a specific gated content item.
253 *
254 * Hashes the code, fetches the matching DB row, then delegates to
255 * FrmGatedToken::validate() — which enforces expiry, item-membership, and the
256 * frm_gated_content_is_valid filter.
257 *
258 * @param string $access_code Raw access token (same value as the access_code URL parameter).
259 * @param FrmGatedItem $item Content item to validate against, or null to skip item check.
260 *
261 * @return FrmGatedToken|null Validated token object, or null if the code is invalid or does not grant access.
262 */
263 public static function validate_access_code( $access_code, FrmGatedItem $item ) {
264 $row = self::get_row_by_hash( self::hash_token( $access_code ) );
265
266 if ( null === $row ) {
267 return null;
268 }
269
270 $token = new FrmGatedToken( $row );
271 return $token->validate( $item ) ? $token : null;
272 }
273
274 /**
275 * Build the transient key for an action + item membership result.
276 *
277 * Uses {@see FrmGatedItem::get_transient_key()} for the item-specific segment
278 * so subclasses can widen the scope (e.g. include entry ID) without touching
279 * this helper.
280 *
281 * @param int $action_id ID of the frm_form_actions post.
282 * @param FrmGatedItem $item Content item (type slug + ID).
283 *
284 * @return string
285 */
286 private static function get_action_item_transient_key( $action_id, FrmGatedItem $item ) {
287 return 'frm_gc_ac_' . $action_id . '_' . $item->get_transient_key();
288 }
289
290 /**
291 * Delete the cached membership result for every item listed in an action's settings.
292 *
293 * Call this when an action is updated or deleted so stale results are not served.
294 *
295 * @param int $action_id ID of the frm_form_actions post.
296 *
297 * @return void
298 */
299 public static function delete_action_item_cache( $action_id ) {
300 $action = get_post( $action_id );
301
302 if ( ! $action ) {
303 return;
304 }
305
306 $settings = FrmAppHelper::maybe_json_decode( $action->post_content );
307
308 if ( ! is_array( $settings ) || empty( $settings['items'] ) ) {
309 return;
310 }
311
312 foreach ( $settings['items'] as $item ) {
313 if ( is_array( $item ) && ! empty( $item['type'] ) && ! empty( $item['id'] ) ) {
314 delete_transient( self::get_action_item_transient_key( $action_id, FrmGatedItem::make( $item ) ) );
315 }
316 }
317 }
318
319 /**
320 * Check whether an action's settings include a specific content item.
321 *
322 * The result is cached in a transient keyed to the action + item pair so
323 * subsequent requests skip the DB lookup. TTL matches the token's remaining
324 * lifetime, or DAY_IN_SECONDS when no expiry is provided.
325 *
326 * @param int $action_id ID of the frm_form_actions post.
327 * @param FrmGatedItem $item Content item to look for.
328 * @param int|null $expired_at Token expiry timestamp used to set TTL, or null for no expiry.
329 *
330 * @return bool True if the item is listed in the action's items setting.
331 */
332 public static function action_contains_item( $action_id, FrmGatedItem $item, $expired_at = null ) {
333 $key = self::get_action_item_transient_key( $action_id, $item );
334 $cached = get_transient( $key );
335
336 if ( false !== $cached ) {
337 return (bool) $cached;
338 }
339
340 $action = get_post( $action_id );
341
342 if ( ! $action ) {
343 return false;
344 }
345
346 $settings = FrmAppHelper::maybe_json_decode( $action->post_content );
347 $result = false;
348
349 if ( is_array( $settings ) && ! empty( $settings['items'] ) ) {
350 foreach ( $settings['items'] as $raw_item ) {
351 if ( $item->matches( $raw_item ) ) {
352 $result = true;
353 break;
354 }
355 }
356 }
357
358 $ttl = null !== $expired_at ? max( 1, $expired_at - time() ) : DAY_IN_SECONDS;
359 set_transient( $key, $result, $ttl );
360
361 return $result;
362 }
363
364 /**
365 * Set an HttpOnly cookie that stores a raw access token for a gated content item.
366 *
367 * Cookie name : frm_gc_{item_type}_{item_id}
368 * Cookie value : raw access token (same value passed in the access_code URL parameter).
369 *
370 * Storing the raw token lets users verify that the cookie matches the link they
371 * received, and lets get_valid_token_from_cookies() look up the DB by
372 * hash('sha256', $raw_token) — the same query as the URL-param path.
373 * The token is always re-validated against the DB on every request; the cookie
374 * is used only as a transport, never trusted on its own.
375 *
376 * @param string $raw_token Raw access token to store.
377 * @param FrmGatedItem $item Content item the cookie is scoped to.
378 * @param int|null $expired_at Unix timestamp for cookie expiry, or null for 1-year TTL.
379 *
380 * @return void
381 */
382 public static function set_cookie( $raw_token, FrmGatedItem $item, $expired_at = null ) {
383 $expiry = $expired_at ?? time() + YEAR_IN_SECONDS;
384
385 setcookie( // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.cookies_setcookie
386 $item->get_cookie_name(),
387 $raw_token,
388 array(
389 'expires' => $expiry,
390 'path' => '/',
391 'secure' => is_ssl(),
392 'httponly' => true,
393 'samesite' => 'Lax',
394 )
395 );
396 }
397
398 /**
399 * Build the transient key for a generated token, scoped to the current user or IP.
400 *
401 * Logged-in users are keyed by user ID; guests are keyed by an MD5 of their IP so
402 * that two users submitting the same form simultaneously cannot read each other's
403 * pending token.
404 *
405 * @param int $action_id Action post ID.
406 *
407 * @return string Transient key (~30 chars for logged-in users, ~90 chars for guests).
408 */
409 private static function get_token_transient_key( $action_id ) {
410 $user_id = get_current_user_id();
411 $scope = $user_id ? (string) $user_id : hash( 'sha256', FrmAppHelper::get_ip_address() );
412 return 'frm_gc_token_' . $action_id . '_' . $scope;
413 }
414
415 /**
416 * Retrieve the raw token most recently generated for a given action in this session.
417 *
418 * Reads the 5-minute transient set by generate(). Returns null when no pending
419 * token exists — e.g. a different user, a different browser/IP, or the TTL has
420 * expired.
421 *
422 * @param int $action_id Action post ID.
423 *
424 * @return string|null Raw 48-char token, or null if unavailable.
425 */
426 public static function get_raw_token_for_action( $action_id ) {
427 if ( isset( self::$generated_tokens[ $action_id ] ) ) {
428 return self::$generated_tokens[ $action_id ];
429 }
430
431 $token = get_transient( self::get_token_transient_key( $action_id ) );
432 return false !== $token ? (string) $token : null;
433 }
434
435 /**
436 * Find the first valid access token for a gated content item.
437 *
438 * Checks sources in priority order, returning immediately on the first match:
439 * 1. `access_code` URL query parameter (raw token → hashed → validated).
440 * 2. HttpOnly `frm_gc_*` cookies — one cookie per gated content item, keyed by
441 * item type and ID. When both are known, a single named lookup is used;
442 * otherwise all matching cookies are scanned.
443 * 3. All active DB tokens for the current logged-in user. Supports registration-
444 * gated content where the Registration add-on stores the new user ID on the
445 * token so returning visitors are recognised without a URL param or cookie.
446 * 4. `frm_obtain_gated_token` filter — add-ons can supply a token from other sources.
447 *
448 * Note: the 5-minute static-cache / transient path is intentionally excluded —
449 * it is action-scoped and only meaningful for shortcode rendering immediately
450 * after token generation. Use get_raw_token_for_action() for that purpose.
451 *
452 * @param FrmGatedItem $item Content item to find a valid token for.
453 *
454 * @return FrmGatedToken|null First valid token, or null if none found.
455 */
456 public static function get_valid_token( FrmGatedItem $item ) {
457 // 1. URL query parameter — definitive.
458 $token = self::get_valid_token_from_url_param( $item );
459
460 if ( null !== $token ) {
461 return $token;
462 }
463
464 // 2 & 3. Cookies then user DB (shared dedup).
465 $seen_hashes = array();
466
467 $token = self::get_valid_token_from_cookies( $item, $seen_hashes );
468
469 if ( null !== $token ) {
470 return $token;
471 }
472
473 $token = self::get_valid_token_from_user( $item, $seen_hashes );
474
475 if ( null !== $token ) {
476 return $token;
477 }
478
479 /**
480 * Filter the resolved valid token for a gated content item.
481 *
482 * Fires after URL param, cookies, and user DB have all been checked without finding
483 * a valid token. Add-ons can return a validated FrmGatedToken to grant access from
484 * alternative sources, or null to indicate no token is available.
485 *
486 * @since 6.33
487 *
488 * @param FrmGatedToken|null $token Null — no valid token found by core.
489 * @param array{item: FrmGatedItem} $args Array containing the content item being accessed.
490 */
491 /** @var FrmGatedToken|null */
492 return apply_filters( 'frm_obtain_gated_token', null, array( 'item' => $item ) );
493 }
494
495 /**
496 * Find a valid token from the `access_code` URL query parameter.
497 *
498 * On success, sets an frm_gc_* cookie keyed to the validated item so that
499 * subsequent requests can skip this path entirely. The raw token is stored
500 * as the cookie value so users can verify it matches their access link.
501 *
502 * @param FrmGatedItem $item Content item to validate against.
503 *
504 * @return FrmGatedToken|null
505 */
506 private static function get_valid_token_from_url_param( FrmGatedItem $item ) {
507 $url_token = FrmAppHelper::simple_get( 'access_code' );
508
509 if ( '' === $url_token ) {
510 return null;
511 }
512
513 $token = self::validate_access_code( $url_token, $item );
514
515 if ( null === $token ) {
516 return null;
517 }
518
519 // Cookie is set after validation so the name is scoped to the exact item
520 // that was just granted access. Store the raw token so it matches the
521 // access_code URL parameter — easier to verify and simpler to look up.
522 if ( ! headers_sent() ) {
523 self::set_cookie( $url_token, $item, $token->get_expired_at() );
524 }
525
526 return $token;
527 }
528
529 /**
530 * Find a valid token from frm_gc_* cookies.
531 *
532 * Cookie names follow the format frm_gc_{item_type}_{item_id}. Both type and ID
533 * must be known to perform a direct O(1) lookup — no iteration required.
534 * Returns null immediately when either is missing.
535 *
536 * Populates $seen_hashes with every hash examined so the caller can pass it to
537 * subsequent sources to avoid processing the same row twice.
538 *
539 * @param FrmGatedItem $item Content item to validate against.
540 * @param array $seen_hashes Dedup map passed by reference.
541 *
542 * @return FrmGatedToken|null
543 */
544 private static function get_valid_token_from_cookies( FrmGatedItem $item, &$seen_hashes ) {
545 $cookie_name = $item->get_cookie_name();
546
547 if ( ! isset( $_COOKIE[ $cookie_name ] ) ) {
548 return null;
549 }
550
551 $raw_token = sanitize_text_field( $_COOKIE[ $cookie_name ] );
552 $seen_hashes[ self::hash_token( $raw_token ) ] = true;
553 return self::validate_access_code( $raw_token, $item );
554 }
555
556 /**
557 * Find a valid token from DB rows belonging to the current logged-in user.
558 *
559 * Skips hashes already seen in earlier sources via $seen_hashes.
560 *
561 * @param FrmGatedItem $item Content item to validate against.
562 * @param array $seen_hashes Dedup map passed by reference.
563 *
564 * @return FrmGatedToken|null
565 */
566 private static function get_valid_token_from_user( FrmGatedItem $item, &$seen_hashes ) {
567 if ( ! is_user_logged_in() ) {
568 return null;
569 }
570
571 foreach ( self::get_tokens_for_user( get_current_user_id() ) as $row ) {
572 if ( isset( $seen_hashes[ $row->token_hash ] ) ) {
573 continue;
574 }
575
576 $seen_hashes[ $row->token_hash ] = true;
577 $token = new FrmGatedToken( $row );
578
579 if ( $token->validate( $item ) ) {
580 return $token;
581 }
582 }
583
584 return null;
585 }
586 }
587