PluginProbe
Formidable Forms – WordPress Form Builder for Contact Forms, Calculators, Quizzes & More / 6.33
Formidable Forms – WordPress Form Builder for Contact Forms, Calculators, Quizzes & More v6.33
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 6.33, at classes/controllers/FrmGatedContentController.php

335 lines 9.9 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 $post_id = get_queried_object_id();
148
149 if ( ! $post_id ) {
150 return;
151 }
152
153 $post = get_post( $post_id );
154
155 if ( ! $post ) {
156 return;
157 }
158
159 $is_password_protected = '' !== $post->post_password;
160 $is_restricted_private = 'private' === $post->post_status && ! current_user_can( 'read_private_posts', $post_id );
161 $access_code_from_url = FrmAppHelper::simple_get( 'access_code' );
162
163 // Nothing to unlock — post is publicly accessible.
164 if ( ! $is_password_protected && ! $is_restricted_private ) {
165 if ( $access_code_from_url && wp_safe_redirect( remove_query_arg( 'access_code' ) ) ) {
166 exit;
167 }
168
169 return;
170 }
171
172 $post_item = FrmGatedItem::make(
173 array(
174 'type' => $post->post_type,
175 'id' => $post_id,
176 )
177 );
178 $valid_token = FrmGatedTokenHelper::get_valid_token( $post_item );
179
180 if ( $valid_token ) {
181 // Password-protected posts need an explicit filter; private posts are
182 // already accessible because maybe_include_private_posts widened the query.
183 if ( $is_password_protected ) {
184 self::$unlocked_post_id = $post_id;
185 add_filter( 'post_password_required', 'FrmGatedContentController::filter_password_required', 10, 2 );
186 }
187
188 // Strip the raw token from the URL to prevent leakage via browser history,
189 // server logs, and Referer headers. The cookie set above grants access on
190 // the redirected request without the query parameter.
191 if ( $access_code_from_url && wp_safe_redirect( remove_query_arg( 'access_code' ) ) ) {
192 exit;
193 }
194
195 return;
196 }
197
198 // No valid token — force a 404 to prevent private posts from being exposed.
199 if ( $is_restricted_private ) {
200 self::force_404();
201 }
202 }
203
204 /**
205 * Force the current request to a 404 response.
206 *
207 * Used when a private post was widened into the main query by
208 * maybe_include_private_posts() but no valid token was found.
209 *
210 * @return void
211 */
212 private static function force_404() {
213 global $wp_query;
214 $wp_query->set_404();
215 status_header( 404 );
216 nocache_headers();
217 }
218
219 /**
220 * Filter callback: return false for the single post unlocked by maybe_unlock_post().
221 *
222 * Fires on the 'post_password_required' filter. Only overrides the result for
223 * the specific post ID stored in self::$unlocked_post_id — all other posts are
224 * passed through unchanged.
225 *
226 * @param bool $required Whether the password is required.
227 * @param WP_Post $post Post being checked.
228 *
229 * @return bool
230 */
231 public static function filter_password_required( $required, $post ) {
232 return $post->ID === self::$unlocked_post_id ? false : $required;
233 }
234
235 /**
236 * Delete all gated tokens linked to a gated content action when it is permanently deleted.
237 *
238 * Fires on 'before_delete_post'. Only acts on frm_form_actions posts whose
239 * post_excerpt identifies them as gated_content actions.
240 *
241 * @param int $post_id Post ID being deleted.
242 * @param WP_Post $post Post object being deleted.
243 *
244 * @return void
245 */
246 /**
247 * Clear the action-item membership cache when a gated content action is updated.
248 *
249 * Fires on 'save_post_frm_form_actions'. Only acts on updates (not creates)
250 * because the item list cannot change during initial creation.
251 *
252 * @param int $post_id Post ID of the saved action.
253 * @param WP_Post $post Saved post object.
254 * @param bool $update True when updating an existing post, false on create.
255 *
256 * @return void
257 */
258 public static function on_action_updated( $post_id, $post, $update ) {
259 if ( ! $update || FrmGatedContentAction::$slug !== $post->post_excerpt ) {
260 return;
261 }
262 FrmGatedTokenHelper::delete_action_item_cache( $post_id );
263 }
264
265 /**
266 * Clean up when a gated content action post is permanently deleted.
267 *
268 * Hooked to `before_delete_post`. Clears the action-item transient cache
269 * (while the post is still readable) then removes all associated tokens.
270 *
271 * @param int $post_id Post ID of the action being deleted.
272 * @param WP_Post $post The action post object.
273 *
274 * @return void
275 */
276 public static function on_action_deleted( int $post_id, WP_Post $post ) {
277 if ( 'frm_form_actions' !== $post->post_type || FrmGatedContentAction::$slug !== $post->post_excerpt ) {
278 return;
279 }
280 // Clear action-item cache first — the action post still exists at this
281 // point (before_delete_post) so its settings are still readable.
282 FrmGatedTokenHelper::delete_action_item_cache( $post_id );
283 FrmGatedTokenHelper::delete_by_action( $post_id );
284 }
285
286 /**
287 * Generate a gated content token when a form action fires.
288 *
289 * @param WP_Post $action Form action post object (post_excerpt = 'gated_content').
290 * @param object $entry Submitted form entry object.
291 * @param object $form Form object.
292 * @param string $event Trigger event ('create', 'payment-success', 'user_registration', …).
293 *
294 * @return void
295 */
296 public static function trigger( $action, $entry, $form, $event ) {
297 FrmGatedTokenHelper::generate( $action, $entry, $event );
298 }
299
300 /**
301 * Add [frm_gated_content id="…"] entries to the Advanced tab shortcode helpers box.
302 *
303 * One entry per gated content action attached to the current form. The left
304 * column shows the action name (post_title) and the right column shows the
305 * ready-to-paste shortcode.
306 *
307 * Hooked to `frm_helper_shortcodes` with 3 accepted args.
308 *
309 * @since 6.33
310 *
311 * @param array $shortcodes Existing shortcode helpers array (shortcode => label).
312 * @param string $settings_tab Active settings tab slug.
313 * @param int $form_id Current form ID.
314 *
315 * @return array
316 */
317 public static function add_shortcode_helper( $shortcodes, $settings_tab, $form_id ) {
318 if ( ! $form_id ) {
319 return $shortcodes;
320 }
321
322 $actions = FrmFormAction::get_action_for_form( $form_id, FrmGatedContentAction::$slug, array( 'post_status' => 'publish' ) );
323
324 if ( ! $actions ) {
325 return $shortcodes;
326 }
327
328 foreach ( $actions as $action ) {
329 $shortcodes[ 'frm_gated_content id="' . $action->ID . '"' ] = $action->post_title;
330 }
331
332 return $shortcodes;
333 }
334 }
335