-
Notifications
You must be signed in to change notification settings - Fork 41
Rock gated content #3088
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Rock gated content #3088
Changes from all commits
30b8547
56ccb14
347fa2d
b84c0b4
709c312
60852cd
e0e06c1
b0a3830
69ebb2f
f12e90c
3d338a6
9b550ac
32eeae2
5b5b5b8
334d5f9
6f0f023
20314c4
b95e703
27dab71
e7b8988
2c012f7
d4c35aa
ccc67e3
2a8d369
877e9e6
872adcc
ae4744a
cf17c3b
0d917fc
18c8086
0bb1615
d8e35dc
a559c55
d9910b0
91d6480
1e6b04a
dcdc4ab
29a1e45
b9603d7
432fb4f
ede69c0
418bc99
010c8b0
bb0487d
6a7eb60
2848b6f
753612e
21242ec
bbe8e5b
6cd6c74
43bbf89
e374054
ca7f657
0ba0afc
5cf9fd0
e3d33cd
c45c1cc
db257d3
b963933
e428393
436f767
5b90719
0de960e
79f9a3e
9bc7edf
5ce30a2
7cc2798
c50aa62
eeb003b
aefcbd6
d4812a6
f7c62d3
7420ce8
f6f1b1f
6fd7e06
5cd1f21
130ee65
7fcc076
1d015d5
f964005
8f17d13
f8b738c
d17fca3
7002d7a
fdf8cb2
66d2f25
cf67061
eff7f69
ba4531d
819a01e
b980518
45e88fc
c8b525f
207f0e5
d611cde
0227fb1
07c2ca5
aeb7e04
0ee2321
ddb131c
e0ca044
74ce8d9
4a68b9b
a2dc00d
a27c9aa
3b627c3
2520f2b
57aaa77
0292812
00dc257
66ea7c3
7eae149
c1af7da
f211380
e638fc5
2979f16
005a41d
3301764
d3d99b3
cecf632
1edd47e
e90f6d1
22cc881
0e65854
2bd0db9
c315a73
6dc0cd5
76f3667
cdd8856
3a239e5
2b694af
1e4eb91
353e3dc
72f782a
eae299d
d47483e
801f00e
943d5a1
c48997f
ae20223
6478116
0b65fda
fa85fcc
9429842
9ce7358
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1754,7 +1754,7 @@ public static function mb_tags_box( $form_id, $class = '', $template_path = 'def | |
| $col = 'one'; | ||
| $settings_tab = FrmAppHelper::is_admin_page( 'formidable' ); | ||
| $cond_shortcodes = apply_filters( 'frm_conditional_shortcodes', array() ); | ||
| $entry_shortcodes = self::get_shortcode_helpers( $settings_tab ); | ||
| $entry_shortcodes = self::get_shortcode_helpers( $settings_tab, $form_id ); | ||
| $advanced_helpers = self::advanced_helpers( compact( 'fields', 'form_id' ) ); | ||
|
|
||
| if ( 'default' === $template_path || ! file_exists( $template_path ) ) { | ||
|
|
@@ -1863,10 +1863,11 @@ private static function user_shortcodes() { | |
| * @since 2.0.6 | ||
| * | ||
| * @param bool $settings_tab | ||
| * @param int $form_id | ||
| * | ||
| * @return array | ||
| */ | ||
| private static function get_shortcode_helpers( $settings_tab ) { | ||
| private static function get_shortcode_helpers( $settings_tab, $form_id = 0 ) { | ||
| $entry_shortcodes = array( | ||
| 'id' => __( 'Entry ID', 'formidable' ), | ||
| 'key' => __( 'Entry Key', 'formidable' ), | ||
|
|
@@ -1894,8 +1895,9 @@ private static function get_shortcode_helpers( $settings_tab ) { | |
| * | ||
| * @param array $entry_shortcodes | ||
| * @param bool $settings_tab | ||
| * @param int $form_id | ||
| */ | ||
| return apply_filters( 'frm_helper_shortcodes', $entry_shortcodes, $settings_tab ); | ||
| return apply_filters( 'frm_helper_shortcodes', $entry_shortcodes, $settings_tab, $form_id ); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| } | ||
|
|
||
| /** | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,334 @@ | ||
| <?php | ||
| /** | ||
| * Gated Content Controller | ||
| * | ||
| * @package Formidable | ||
| * | ||
| * @since x.x | ||
| */ | ||
|
|
||
| if ( ! defined( 'ABSPATH' ) ) { | ||
| die( 'You are not allowed to call this page directly.' ); | ||
| } | ||
|
|
||
| class FrmGatedContentController { | ||
|
|
||
| /** | ||
| * Post ID unlocked for the current request by maybe_unlock_post(). | ||
| * | ||
| * Stored here so filter_password_required() can reference it without a | ||
| * closure (closures are forbidden as action/filter callbacks). | ||
| * | ||
| * @var int | ||
| */ | ||
| private static $unlocked_post_id = 0; | ||
|
|
||
| /** | ||
| * Allow private posts into the main query when a valid gated-content token is present. | ||
| * | ||
| * Hooked on 'pre_get_posts', which fires before WP_Query runs its DB query. | ||
| * Private posts are excluded at the query level by WordPress, so the 'wp' | ||
| * hook is too late — get_queried_object_id() returns 0 for a 404'd private post. | ||
| * | ||
| * Token validation is intentionally deferred to maybe_unlock_post() (the 'wp' | ||
| * hook): by that time get_queried_object_id() is reliable and we can validate | ||
| * the token against the exact post that was resolved. If no valid token exists | ||
| * for the private post, maybe_unlock_post() forces a 404. | ||
| * | ||
| * @param WP_Query $query Current query object. | ||
| * | ||
| * @return void | ||
| */ | ||
| public static function maybe_include_private_posts( $query ) { | ||
| if ( ! $query->is_main_query() || is_admin() ) { | ||
| return; | ||
| } | ||
|
|
||
| // Only widen singular requests — archives/lists must never expose private posts. | ||
| if ( ! $query->is_singular ) { | ||
| return; | ||
| } | ||
|
|
||
| $statuses = $query->get( 'post_status' ); | ||
|
|
||
| if ( ! is_array( $statuses ) ) { | ||
| $statuses = $statuses ? array( $statuses ) : array( 'publish' ); | ||
| } | ||
|
|
||
| // Already includes private — nothing to widen. | ||
| if ( in_array( 'private', $statuses, true ) ) { | ||
| return; | ||
| } | ||
|
|
||
| $queried_post = self::get_queried_post( $query ); | ||
|
|
||
| if ( ! $queried_post ) { | ||
| return; | ||
| } | ||
|
|
||
| $post_item = FrmGatedItem::make( | ||
| array( | ||
| 'type' => $queried_post->post_type, | ||
| 'id' => $queried_post->ID, | ||
| ) | ||
| ); | ||
|
|
||
| if ( ! FrmGatedTokenHelper::get_valid_token( $post_item ) ) { | ||
| return; | ||
| } | ||
|
|
||
| $statuses[] = 'private'; | ||
| $query->set( 'post_status', $statuses ); | ||
| } | ||
|
|
||
| /** | ||
| * Resolve the requested WP_Post from the query vars at pre_get_posts time. | ||
| * | ||
| * Get_queried_object_id() is not available at pre_get_posts because the query | ||
| * has not run yet. For numeric-ID URLs the post comes from get_post(); for | ||
| * pretty-permalink slugs, get_page_by_path() resolves the slug including | ||
| * private posts (it queries all statuses except trash/auto-draft). | ||
| * | ||
| * Post types searched are derived from the enabled item type configs that have | ||
| * a 'post_type' key, so add-ons only need to register their item type once. | ||
| * | ||
| * @param WP_Query $query Main query object. | ||
| * | ||
| * @return WP_Post|null Resolved post, or null if it cannot be determined. | ||
| */ | ||
| private static function get_queried_post( $query ) { | ||
| $post_id = (int) $query->get( 'p' ); | ||
|
|
||
| if ( ! $post_id ) { | ||
| $post_id = (int) $query->get( 'page_id' ); | ||
| } | ||
|
|
||
| if ( $post_id ) { | ||
| $post = get_post( $post_id ); | ||
| return $post ? $post : null; | ||
| } | ||
|
|
||
| $slug = $query->get( 'pagename' ); | ||
|
|
||
| if ( ! $slug ) { | ||
| $slug = $query->get( 'name' ); | ||
| } | ||
|
|
||
| if ( ! $slug ) { | ||
| return null; | ||
| } | ||
|
|
||
| $post_types = array(); | ||
|
|
||
| foreach ( FrmGatedContentAction::get_types() as $type_key => $type_config ) { | ||
| if ( empty( $type_config['disabled'] ) && post_type_exists( $type_key ) ) { | ||
| $post_types[] = $type_key; | ||
| } | ||
| } | ||
|
|
||
| $post = get_page_by_path( $slug, OBJECT, $post_types ); | ||
| return $post instanceof WP_Post ? $post : null; | ||
| } | ||
|
|
||
| /** | ||
| * Attempt to unlock a gated post (password-protected or private) using a token. | ||
| * | ||
| * Hooked on 'wp' so get_queried_object_id() is available. Private posts are | ||
| * already in the query by this point (via maybe_include_private_posts), so | ||
| * only password-protected posts need the post_password_required filter. | ||
| * | ||
| * Resolution order: | ||
| * 1. URL query parameter access_code (raw token → hashed via get_valid_token). | ||
| * 2. Any frm_gc_* cookie whose hash validates against the current post. | ||
| * | ||
| * @return void | ||
| */ | ||
| public static function maybe_unlock_post() { | ||
| $post_id = get_queried_object_id(); | ||
|
|
||
| if ( ! $post_id ) { | ||
| return; | ||
| } | ||
|
|
||
| $post = get_post( $post_id ); | ||
|
|
||
| if ( ! $post ) { | ||
| return; | ||
| } | ||
|
|
||
| $is_password_protected = '' !== $post->post_password; | ||
| $is_restricted_private = 'private' === $post->post_status && ! current_user_can( 'read_private_posts', $post_id ); | ||
| $access_code_from_url = FrmAppHelper::simple_get( 'access_code' ); | ||
|
|
||
| // Nothing to unlock — post is publicly accessible. | ||
| if ( ! $is_password_protected && ! $is_restricted_private ) { | ||
| if ( $access_code_from_url && wp_safe_redirect( remove_query_arg( 'access_code' ) ) ) { | ||
| exit; | ||
| } | ||
|
|
||
| return; | ||
|
truongwp marked this conversation as resolved.
|
||
| } | ||
|
|
||
| $post_item = FrmGatedItem::make( | ||
| array( | ||
| 'type' => $post->post_type, | ||
| 'id' => $post_id, | ||
| ) | ||
| ); | ||
| $valid_token = FrmGatedTokenHelper::get_valid_token( $post_item ); | ||
|
|
||
| if ( $valid_token ) { | ||
| // Password-protected posts need an explicit filter; private posts are | ||
| // already accessible because maybe_include_private_posts widened the query. | ||
| if ( $is_password_protected ) { | ||
| self::$unlocked_post_id = $post_id; | ||
| add_filter( 'post_password_required', 'FrmGatedContentController::filter_password_required', 10, 2 ); | ||
| } | ||
|
|
||
| // Strip the raw token from the URL to prevent leakage via browser history, | ||
| // server logs, and Referer headers. The cookie set above grants access on | ||
| // the redirected request without the query parameter. | ||
| if ( $access_code_from_url && wp_safe_redirect( remove_query_arg( 'access_code' ) ) ) { | ||
| exit; | ||
| } | ||
|
|
||
| return; | ||
| } | ||
|
|
||
| // No valid token — force a 404 to prevent private posts from being exposed. | ||
| if ( $is_restricted_private ) { | ||
| self::force_404(); | ||
|
Crabcyborg marked this conversation as resolved.
|
||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Force the current request to a 404 response. | ||
| * | ||
| * Used when a private post was widened into the main query by | ||
| * maybe_include_private_posts() but no valid token was found. | ||
| * | ||
| * @return void | ||
| */ | ||
| private static function force_404() { | ||
| global $wp_query; | ||
| $wp_query->set_404(); | ||
| status_header( 404 ); | ||
| nocache_headers(); | ||
| } | ||
|
|
||
| /** | ||
| * Filter callback: return false for the single post unlocked by maybe_unlock_post(). | ||
| * | ||
| * Fires on the 'post_password_required' filter. Only overrides the result for | ||
| * the specific post ID stored in self::$unlocked_post_id — all other posts are | ||
| * passed through unchanged. | ||
| * | ||
| * @param bool $required Whether the password is required. | ||
| * @param WP_Post $post Post being checked. | ||
| * | ||
| * @return bool | ||
| */ | ||
| public static function filter_password_required( $required, $post ) { | ||
| return $post->ID === self::$unlocked_post_id ? false : $required; | ||
| } | ||
|
|
||
| /** | ||
| * Delete all gated tokens linked to a gated content action when it is permanently deleted. | ||
| * | ||
| * Fires on 'before_delete_post'. Only acts on frm_form_actions posts whose | ||
| * post_excerpt identifies them as gated_content actions. | ||
| * | ||
| * @param int $post_id Post ID being deleted. | ||
| * @param WP_Post $post Post object being deleted. | ||
| * | ||
| * @return void | ||
| */ | ||
| /** | ||
| * Clear the action-item membership cache when a gated content action is updated. | ||
| * | ||
| * Fires on 'save_post_frm_form_actions'. Only acts on updates (not creates) | ||
| * because the item list cannot change during initial creation. | ||
| * | ||
| * @param int $post_id Post ID of the saved action. | ||
| * @param WP_Post $post Saved post object. | ||
| * @param bool $update True when updating an existing post, false on create. | ||
| * | ||
| * @return void | ||
| */ | ||
| public static function on_action_updated( $post_id, $post, $update ) { | ||
| if ( ! $update || FrmGatedContentAction::$slug !== $post->post_excerpt ) { | ||
| return; | ||
| } | ||
| FrmGatedTokenHelper::delete_action_item_cache( $post_id ); | ||
| } | ||
|
|
||
| /** | ||
| * Clean up when a gated content action post is permanently deleted. | ||
| * | ||
| * Hooked to `before_delete_post`. Clears the action-item transient cache | ||
| * (while the post is still readable) then removes all associated tokens. | ||
| * | ||
| * @param int $post_id Post ID of the action being deleted. | ||
| * @param WP_Post $post The action post object. | ||
| * | ||
| * @return void | ||
| */ | ||
| public static function on_action_deleted( int $post_id, WP_Post $post ) { | ||
| if ( 'frm_form_actions' !== $post->post_type || FrmGatedContentAction::$slug !== $post->post_excerpt ) { | ||
| return; | ||
| } | ||
| // Clear action-item cache first — the action post still exists at this | ||
| // point (before_delete_post) so its settings are still readable. | ||
| FrmGatedTokenHelper::delete_action_item_cache( $post_id ); | ||
| FrmGatedTokenHelper::delete_by_action( $post_id ); | ||
| } | ||
|
|
||
| /** | ||
| * Generate a gated content token when a form action fires. | ||
| * | ||
| * @param WP_Post $action Form action post object (post_excerpt = 'gated_content'). | ||
| * @param object $entry Submitted form entry object. | ||
| * @param object $form Form object. | ||
| * @param string $event Trigger event ('create', 'payment-success', 'user_registration', …). | ||
| * | ||
| * @return void | ||
| */ | ||
| public static function trigger( $action, $entry, $form, $event ) { | ||
| FrmGatedTokenHelper::generate( $action, $entry, $event ); | ||
|
Comment on lines
+296
to
+297
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Tighten
Suggested fix- * `@param` object $action Form action post object (post_excerpt = 'gated_content').
+ * `@param` WP_Post $action Form action post object (post_excerpt = 'gated_content').
@@
public static function trigger( $action, $entry, $form, $event ) {
+ if ( ! $action instanceof WP_Post ) {
+ return;
+ }
FrmGatedTokenHelper::generate( $action, $entry, $event );
}🧰 Tools🪛 GitHub Check: PHPStan[failure] 240-240: 🪛 PHPMD (2.15.0)[warning] 239-239: Avoid unused parameters such as '$form'. (undefined) (UnusedFormalParameter) 🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| /** | ||
| * Add [frm_gated_content id="…"] entries to the Advanced tab shortcode helpers box. | ||
| * | ||
| * One entry per gated content action attached to the current form. The left | ||
| * column shows the action name (post_title) and the right column shows the | ||
| * ready-to-paste shortcode. | ||
| * | ||
| * Hooked to `frm_helper_shortcodes` with 3 accepted args. | ||
| * | ||
| * @since x.x | ||
| * | ||
| * @param array $shortcodes Existing shortcode helpers array (shortcode => label). | ||
| * @param string $settings_tab Active settings tab slug. | ||
| * @param int $form_id Current form ID. | ||
| * | ||
| * @return array | ||
| */ | ||
| public static function add_shortcode_helper( $shortcodes, $settings_tab, $form_id ) { | ||
| if ( ! $form_id ) { | ||
| return $shortcodes; | ||
| } | ||
|
|
||
| $actions = FrmFormAction::get_action_for_form( $form_id, FrmGatedContentAction::$slug, array( 'post_status' => 'publish' ) ); | ||
|
|
||
| if ( ! $actions ) { | ||
| return $shortcodes; | ||
| } | ||
|
|
||
| foreach ( $actions as $action ) { | ||
| $shortcodes[ 'frm_gated_content id="' . $action->ID . '"' ] = $action->post_title; | ||
| } | ||
|
|
||
| return $shortcodes; | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.