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 / controllers / FrmGatedContentController.php

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

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