PluginProbe
Formidable Forms – WordPress Form Builder for Contact Forms, Calculators, Quizzes & More / trunk
Formidable Forms – WordPress Form Builder for Contact Forms, Calculators, Quizzes & More vtrunk
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 / controllers / FrmGatedContentController.php

FrmGatedContentController.php in Formidable Forms – WordPress Form Builder for Contact Forms, Calculators, Quizzes & More trunk, at classes/controllers/FrmGatedContentController.php

384 lines 11.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Gated Content Controller
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 FrmGatedContentController {
15
16 /**
17 * Post ID unlocked for the current request by maybe_unlock_post().
18 *
19 * Stored here so filter_password_required() can reference it without a
20 * closure (closures are forbidden as action/filter callbacks).
21 *
22 * @var int
23 */
24 private static $unlocked_post_id = 0;
25
26 /**
27 * Allow private posts into the main query when a valid gated-content token is present.
28 *
29 * Hooked on 'pre_get_posts', which fires before WP_Query runs its DB query.
30 * Private posts are excluded at the query level by WordPress, so the 'wp'
31 * hook is too late — get_queried_object_id() returns 0 for a 404'd private post.
32 *
33 * Token validation is intentionally deferred to maybe_unlock_post() (the 'wp'
34 * hook): by that time get_queried_object_id() is reliable and we can validate
35 * the token against the exact post that was resolved. If no valid token exists
36 * for the private post, maybe_unlock_post() forces a 404.
37 *
38 * @param WP_Query $query Current query object.
39 *
40 * @return void
41 */
42 public static function maybe_include_private_posts( $query ) {
43 if ( ! $query->is_main_query() || is_admin() ) {
44 return;
45 }
46
47 // Only widen singular requests — archives/lists must never expose private posts.
48 if ( ! $query->is_singular ) {
49 return;
50 }
51
52 $statuses = $query->get( 'post_status' );
53
54 if ( ! is_array( $statuses ) ) {
55 $statuses = $statuses ? array( $statuses ) : array( 'publish' );
56 }
57
58 // Already includes private — nothing to widen.
59 if ( in_array( 'private', $statuses, true ) ) {
60 return;
61 }
62
63 $queried_post = self::get_queried_post( $query );
64
65 if ( ! $queried_post ) {
66 return;
67 }
68
69 $post_item = FrmGatedItem::make(
70 array(
71 'type' => $queried_post->post_type,
72 'id' => $queried_post->ID,
73 )
74 );
75
76 if ( ! FrmGatedTokenHelper::get_valid_token( $post_item ) ) {
77 return;
78 }
79
80 $statuses[] = 'private';
81 $query->set( 'post_status', $statuses );
82 }
83
84 /**
85 * Resolve the requested WP_Post from the query vars at pre_get_posts time.
86 *
87 * Get_queried_object_id() is not available at pre_get_posts because the query
88 * has not run yet. For numeric-ID URLs the post comes from get_post(); for
89 * pretty-permalink slugs, get_page_by_path() resolves the slug including
90 * private posts (it queries all statuses except trash/auto-draft).
91 *
92 * Post types searched are derived from the enabled item type configs that have
93 * a 'post_type' key, so add-ons only need to register their item type once.
94 *
95 * @param WP_Query $query Main query object.
96 *
97 * @return WP_Post|null Resolved post, or null if it cannot be determined.
98 */
99 private static function get_queried_post( $query ) {
100 $post_id = (int) $query->get( 'p' );
101
102 if ( ! $post_id ) {
103 $post_id = (int) $query->get( 'page_id' );
104 }
105
106 if ( $post_id ) {
107 $post = get_post( $post_id );
108 return $post ? $post : null;
109 }
110
111 $slug = $query->get( 'pagename' );
112
113 if ( ! $slug ) {
114 $slug = $query->get( 'name' );
115 }
116
117 if ( ! $slug ) {
118 return null;
119 }
120
121 $post_types = array();
122
123 foreach ( FrmGatedContentAction::get_types() as $type_key => $type_config ) {
124 if ( empty( $type_config['disabled'] ) && post_type_exists( $type_key ) ) {
125 $post_types[] = $type_key;
126 }
127 }
128
129 $post = get_page_by_path( $slug, OBJECT, $post_types );
130 return $post instanceof WP_Post ? $post : null;
131 }
132
133 /**
134 * Attempt to unlock a gated post (password-protected or private) using a token.
135 *
136 * Hooked on 'wp' so get_queried_object_id() is available. Private posts are
137 * already in the query by this point (via maybe_include_private_posts), so
138 * only password-protected posts need the post_password_required filter.
139 *
140 * Resolution order:
141 * 1. URL query parameter access_code (raw token → hashed via get_valid_token).
142 * 2. Any frm_gc_* cookie whose hash validates against the current post.
143 *
144 * @return void
145 */
146 public static function maybe_unlock_post() {
147 if ( ! is_singular() ) {
148 return;
149 }
150
151 $post_id = get_queried_object_id();
152
153 if ( ! $post_id ) {
154 return;
155 }
156
157 $post = get_post( $post_id );
158
159 if ( ! $post ) {
160 return;
161 }
162
163 $post_item = FrmGatedItem::make(
164 array(
165 'type' => $post->post_type,
166 'id' => $post_id,
167 )
168 );
169
170 // Only act on posts that are registered in an active gated content action.
171 // Posts unrelated to gated content must not have their access interfered with.
172 if ( ! self::has_gated_action_for_item( $post_item ) ) {
173 return;
174 }
175
176 $is_password_protected = '' !== $post->post_password;
177 $post_type_obj = get_post_type_object( $post->post_type );
178 $read_private_cap = $post_type_obj ? $post_type_obj->cap->read_private_posts : 'read_private_posts';
179 $is_restricted_private = 'private' === $post->post_status && ! current_user_can( $read_private_cap, $post_id );
180 $access_code_from_url = FrmAppHelper::simple_get( 'access_code' );
181
182 // Nothing to unlock — post is publicly accessible.
183 if ( ! $is_password_protected && ! $is_restricted_private ) {
184 if ( $access_code_from_url && wp_safe_redirect( remove_query_arg( 'access_code' ) ) ) {
185 exit;
186 }
187
188 return;
189 }
190
191 $valid_token = FrmGatedTokenHelper::get_valid_token( $post_item );
192
193 if ( $valid_token ) {
194 // Password-protected posts need an explicit filter; private posts are
195 // already accessible because maybe_include_private_posts widened the query.
196 if ( $is_password_protected ) {
197 self::$unlocked_post_id = $post_id;
198 add_filter( 'post_password_required', 'FrmGatedContentController::filter_password_required', 10, 2 );
199 }
200
201 // Strip the raw token from the URL to prevent leakage via browser history,
202 // server logs, and Referer headers. The cookie set above grants access on
203 // the redirected request without the query parameter.
204 if ( $access_code_from_url && wp_safe_redirect( remove_query_arg( 'access_code' ) ) ) {
205 exit;
206 }
207
208 return;
209 }
210
211 // No valid token — force a 404 to prevent private posts from being exposed.
212 if ( $is_restricted_private ) {
213 self::force_404();
214 }
215 }
216
217 /**
218 * Check whether a content item is registered in at least one active gated content action.
219 *
220 * Used by maybe_unlock_post() to avoid interfering with private posts that are unrelated
221 * to gated content — only items explicitly listed in a published gated content action
222 * should have their access controlled by this plugin.
223 *
224 * @param FrmGatedItem $item Content item to look up.
225 *
226 * @return bool True if any published gated content action lists this item.
227 */
228 private static function has_gated_action_for_item( FrmGatedItem $item ): bool {
229 global $wpdb;
230
231 // Direct query — FrmDb caches results per-request which would hide newly
232 // created actions until the cache expires. action_contains_item() already
233 // handles per-action caching via transients.
234 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
235 $action_ids = $wpdb->get_col(
236 $wpdb->prepare(
237 "SELECT ID FROM %i WHERE post_type = %s AND post_excerpt = %s AND post_status = 'publish'",
238 $wpdb->posts,
239 FrmFormActionsController::$action_post_type,
240 FrmGatedContentAction::$slug
241 )
242 );
243
244 foreach ( $action_ids as $action_id ) {
245 if ( FrmGatedTokenHelper::action_contains_item( (int) $action_id, $item ) ) {
246 return true;
247 }
248 }
249
250 return false;
251 }
252
253 /**
254 * Force the current request to a 404 response.
255 *
256 * Used when a private post was widened into the main query by
257 * maybe_include_private_posts() but no valid token was found.
258 *
259 * @return void
260 */
261 private static function force_404() {
262 global $wp_query;
263 $wp_query->set_404();
264 status_header( 404 );
265 nocache_headers();
266 }
267
268 /**
269 * Filter callback: return false for the single post unlocked by maybe_unlock_post().
270 *
271 * Fires on the 'post_password_required' filter. Only overrides the result for
272 * the specific post ID stored in self::$unlocked_post_id — all other posts are
273 * passed through unchanged.
274 *
275 * @param bool $required Whether the password is required.
276 * @param WP_Post $post Post being checked.
277 *
278 * @return bool
279 */
280 public static function filter_password_required( $required, $post ) {
281 return $post->ID === self::$unlocked_post_id ? false : $required;
282 }
283
284 /**
285 * Delete all gated tokens linked to a gated content action when it is permanently deleted.
286 *
287 * Fires on 'before_delete_post'. Only acts on frm_form_actions posts whose
288 * post_excerpt identifies them as gated_content actions.
289 *
290 * @param int $post_id Post ID being deleted.
291 * @param WP_Post $post Post object being deleted.
292 *
293 * @return void
294 */
295 /**
296 * Clear the action-item membership cache when a gated content action is updated.
297 *
298 * Fires on 'save_post_frm_form_actions'. Only acts on updates (not creates)
299 * because the item list cannot change during initial creation.
300 *
301 * @param int $post_id Post ID of the saved action.
302 * @param WP_Post $post Saved post object.
303 * @param bool $update True when updating an existing post, false on create.
304 *
305 * @return void
306 */
307 public static function on_action_updated( $post_id, $post, $update ) {
308 if ( ! $update || FrmGatedContentAction::$slug !== $post->post_excerpt ) {
309 return;
310 }
311 FrmGatedTokenHelper::delete_action_item_cache( $post_id );
312 }
313
314 /**
315 * Clean up when a gated content action post is permanently deleted.
316 *
317 * Hooked to `before_delete_post`. Clears the action-item transient cache
318 * (while the post is still readable) then removes all associated tokens.
319 *
320 * @param int $post_id Post ID of the action being deleted.
321 * @param WP_Post $post The action post object.
322 *
323 * @return void
324 */
325 public static function on_action_deleted( int $post_id, WP_Post $post ) {
326 if ( 'frm_form_actions' !== $post->post_type || FrmGatedContentAction::$slug !== $post->post_excerpt ) {
327 return;
328 }
329 // Clear action-item cache first — the action post still exists at this
330 // point (before_delete_post) so its settings are still readable.
331 FrmGatedTokenHelper::delete_action_item_cache( $post_id );
332 FrmGatedTokenHelper::delete_by_action( $post_id );
333 }
334
335 /**
336 * Generate a gated content token when a form action fires.
337 *
338 * @param WP_Post $action Form action post object (post_excerpt = 'gated_content').
339 * @param object $entry Submitted form entry object.
340 * @param object $form Form object.
341 * @param string $event Trigger event ('create', 'payment-success', 'user_registration', …).
342 *
343 * @return void
344 */
345 public static function trigger( $action, $entry, $form, $event ) {
346 FrmGatedTokenHelper::generate( $action, $entry, $event );
347 }
348
349 /**
350 * Add [frm_gated_content id="…"] entries to the Advanced tab shortcode helpers box.
351 *
352 * One entry per gated content action attached to the current form. The left
353 * column shows the action name (post_title) and the right column shows the
354 * ready-to-paste shortcode.
355 *
356 * Hooked to `frm_helper_shortcodes` with 3 accepted args.
357 *
358 * @since 6.33
359 *
360 * @param array $shortcodes Existing shortcode helpers array (shortcode => label).
361 * @param string $settings_tab Active settings tab slug.
362 * @param int $form_id Current form ID.
363 *
364 * @return array
365 */
366 public static function add_shortcode_helper( $shortcodes, $settings_tab, $form_id ) {
367 if ( ! $form_id ) {
368 return $shortcodes;
369 }
370
371 $actions = FrmFormAction::get_action_for_form( $form_id, FrmGatedContentAction::$slug, array( 'post_status' => 'publish' ) );
372
373 if ( ! $actions ) {
374 return $shortcodes;
375 }
376
377 foreach ( $actions as $action ) {
378 $shortcodes[ 'frm_gated_content id="' . $action->ID . '"' ] = $action->post_title;
379 }
380
381 return $shortcodes;
382 }
383 }
384