diff --git a/classes/controllers/FrmFormActionsController.php b/classes/controllers/FrmFormActionsController.php index 29d0d231f2..74a96640d7 100644 --- a/classes/controllers/FrmFormActionsController.php +++ b/classes/controllers/FrmFormActionsController.php @@ -55,6 +55,7 @@ public static function register_actions() { $action_classes = array( 'on_submit' => 'FrmOnSubmitAction', 'email' => 'FrmEmailAction', + 'gated_content' => 'FrmGatedContentAction', 'wppost' => 'FrmDefPostAction', 'register' => 'FrmDefRegAction', 'stripe' => 'FrmStripeLiteAction', @@ -134,6 +135,7 @@ private static function apply_default_action_descriptions() { 'api' => __( 'System integration', 'formidable' ), 'googlespreadsheet' => __( 'Spreadsheet sync', 'formidable' ), 'convertkit' => __( 'Broadcast publishing', 'formidable' ), + 'gated_content' => __( 'Access control', 'formidable' ), ); foreach ( self::$registered_actions->actions as $action ) { @@ -1126,7 +1128,7 @@ function ( $options ) { * @return string[] */ public static function get_lite_actions() { - return apply_filters( 'frm_lite_form_actions', array( 'on_submit', 'email', 'payment', 'stripe', 'square', 'paypal' ) ); + return apply_filters( 'frm_lite_form_actions', array( 'on_submit', 'email', 'payment', 'stripe', 'square', 'paypal', 'gated_content' ) ); } /** diff --git a/classes/controllers/FrmFormsController.php b/classes/controllers/FrmFormsController.php index 46bb4b0af0..02f4cc20b4 100644 --- a/classes/controllers/FrmFormsController.php +++ b/classes/controllers/FrmFormsController.php @@ -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 ); } /** diff --git a/classes/controllers/FrmGatedContentController.php b/classes/controllers/FrmGatedContentController.php new file mode 100644 index 0000000000..4d55ac9312 --- /dev/null +++ b/classes/controllers/FrmGatedContentController.php @@ -0,0 +1,334 @@ +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; + } + + $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(); + } + } + + /** + * 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 ); + } + + /** + * 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; + } +} diff --git a/classes/controllers/FrmGatedContentShortcodeController.php b/classes/controllers/FrmGatedContentShortcodeController.php new file mode 100644 index 0000000000..a230649bb9 --- /dev/null +++ b/classes/controllers/FrmGatedContentShortcodeController.php @@ -0,0 +1,240 @@ + link tag(s). Without item:
+ +
+
+
+ '.concat(e.message,"
Imported ').concat(n.data.name,"
"),e.find(".status").prepend(r),e.find(".status").show(),D.importQueue=jQuery.grep(D.importQueue,function(e){return e!=t}),D.imported++,0===D.importQueue.length?(e.find(".process-count").hide(),e.find(".forms-completed").text(D.imported),e.find(".process-completed").show()):(e.find(".form-current").text(D.imported+1),si(e)))})}function ci(e){e.preventDefault();var t=!1,n=jQuery('input[name="frm_export_forms[]"]');jQuery('input[name="frm_export_forms[]"]:checked').val()||(n.closest(".frm-table-box").addClass("frm_blank_field"),t="stop");var r=jQuery('input[name="type[]"]');if(jQuery('input[name="type[]"]:checked').val()||"checkbox"!==r.attr("type")||(r.closest("p").addClass("frm_blank_field"),t="stop"),"stop"===t)return!1;e.stopPropagation(),this.submit()}function di(){var e=jQuery(this).closest(".frm_blank_field");if(void 0!==e){var t=this.name;("type[]"===t&&jQuery('input[name="type[]"]:checked').val()||"frm_export_forms[]"===t&&jQuery(this).val())&&e.removeClass("frm_blank_field")}}function ui(){null!==jQuery(this).val().match(/\.csv$/i)?jQuery(".show_csv").fadeIn():jQuery(".show_csv").fadeOut()}function fi(){var e=document.querySelector('select[name="format"]');return e?e.value:""}function mi(e){var t,n,r=e.target.value;pi(r),_i.call(e.target),t=r,n=document.getElementById("frm-export-select-all"),"csv"===t?(n.checked=!1,n.disabled=!0):n.disabled=!1}function _i(){var e=jQuery(this),t=e.find(":selected"),n=t.data("support"),r=n.indexOf("|");jQuery('input[name="type[]"]').each(function(){this.checked=!1,n.includes(this.value)?(this.disabled=!1,-1===r&&(this.checked=!0)):this.disabled=!0}),"csv"===e.val()?(jQuery(".csv_opts").show(),jQuery(".xml_opts").hide()):(jQuery(".csv_opts").hide(),jQuery(".xml_opts").show());var o=t.data("count"),i=jQuery('input[name="frm_export_forms[]"]');"single"===o?(i.prop("multiple",!1),i.prop("checked",!1)):(i.prop("multiple",!0),i.prop("disabled",!1)),e.trigger("change")}function pi(e){if(""!==e){var t=document.querySelectorAll(".frm-is-repeater");t.length&&("csv"===e?t.forEach(function(e){e.classList.remove("frm_hidden")}):t.forEach(function(e){e.classList.add("frm_hidden")}),qi.call(document.querySelector(".frm-auto-search")))}}function gi(){var e=jQuery("select[name=format]").find(":selected").data("count"),t=jQuery('input[name="frm_export_forms[]"]');"single"===e&&this.checked?(t.prop("disabled",!0),this.removeAttribute("disabled")):t.prop("disabled",!1)}function vi(){jQuery(".frm_multiselect").hide().each(frmDom.bootstrap.multiselect.init)}function yi(e){e.preventDefault(),wi(this,"frm_multiple_addons")}function hi(e){e.preventDefault(),wi(this,"frm_activate_addon")}function bi(e){e.preventDefault(),wi(this,"frm_install_addon")}function wi(e,t){n(1105).toggleAddonState(e,t)}function ji(){Qi()}function xi(e){!function(e,t,n){var r=jQuery("#frm_leave_email_error");r.removeClass("frm_hidden").attr("frm-error",n),jQuery("#frm_leave_email").one("keyup",function(){r.addClass("frm_hidden")})}(0,0,e)}function Qi(){var e=document.getElementById("frmapi-email-form");jQuery.ajax({dataType:"json",url:e.getAttribute("data-url"),success:function(t){var n=t.renderedHtml;n=n.replace(/]*(formidableforms.css|action=frmpro_css)[^>]*>/gi,""),e.innerHTML=n}})}function ki(e){frmDom.autocomplete.initSelectionAutocomplete(e)}function Ei(e){var t=this.parentNode.parentNode,n=t.elements.type.value;e.preventDefault(),this.classList.add("frm_loading_button"),Ai(t,n,this)}function Si(e){var t=this.elements.type.value,n=this.querySelector("button");e.preventDefault(),n.classList.add("frm_loading_button"),Ai(this,t,n)}function Ai(e,t,n){var r=function(e){var t,n,r={},o=e.elements;for(n=0;n'.concat(e.message,"
Imported ').concat(r.data.name,"
"),e.find(".status").prepend(n),e.find(".status").show(),D.importQueue=jQuery.grep(D.importQueue,function(e){return e!=t}),D.imported++,0===D.importQueue.length?(e.find(".process-count").hide(),e.find(".forms-completed").text(D.imported),e.find(".process-completed").show()):(e.find(".form-current").text(D.imported+1),si(e)))})}function ci(e){e.preventDefault();var t=!1,r=jQuery('input[name="frm_export_forms[]"]');jQuery('input[name="frm_export_forms[]"]:checked').val()||(r.closest(".frm-table-box").addClass("frm_blank_field"),t="stop");var n=jQuery('input[name="type[]"]');if(jQuery('input[name="type[]"]:checked').val()||"checkbox"!==n.attr("type")||(n.closest("p").addClass("frm_blank_field"),t="stop"),"stop"===t)return!1;e.stopPropagation(),this.submit()}function di(){var e=jQuery(this).closest(".frm_blank_field");if(void 0!==e){var t=this.name;("type[]"===t&&jQuery('input[name="type[]"]:checked').val()||"frm_export_forms[]"===t&&jQuery(this).val())&&e.removeClass("frm_blank_field")}}function ui(){null!==jQuery(this).val().match(/\.csv$/i)?jQuery(".show_csv").fadeIn():jQuery(".show_csv").fadeOut()}function fi(){var e=document.querySelector('select[name="format"]');return e?e.value:""}function mi(e){var t,r,n=e.target.value;pi(n),_i.call(e.target),t=n,r=document.getElementById("frm-export-select-all"),"csv"===t?(r.checked=!1,r.disabled=!0):r.disabled=!1}function _i(){var e=jQuery(this),t=e.find(":selected"),r=t.data("support"),n=r.indexOf("|");jQuery('input[name="type[]"]').each(function(){this.checked=!1,r.includes(this.value)?(this.disabled=!1,-1===n&&(this.checked=!0)):this.disabled=!0}),"csv"===e.val()?(jQuery(".csv_opts").show(),jQuery(".xml_opts").hide()):(jQuery(".csv_opts").hide(),jQuery(".xml_opts").show());var o=t.data("count"),i=jQuery('input[name="frm_export_forms[]"]');"single"===o?(i.prop("multiple",!1),i.prop("checked",!1)):(i.prop("multiple",!0),i.prop("disabled",!1)),e.trigger("change")}function pi(e){if(""!==e){var t=document.querySelectorAll(".frm-is-repeater");t.length&&("csv"===e?t.forEach(function(e){e.classList.remove("frm_hidden")}):t.forEach(function(e){e.classList.add("frm_hidden")}),qi.call(document.querySelector(".frm-auto-search")))}}function gi(){var e=jQuery("select[name=format]").find(":selected").data("count"),t=jQuery('input[name="frm_export_forms[]"]');"single"===e&&this.checked?(t.prop("disabled",!0),this.removeAttribute("disabled")):t.prop("disabled",!1)}function vi(){jQuery(".frm_multiselect").hide().each(frmDom.bootstrap.multiselect.init)}function yi(e){e.preventDefault(),wi(this,"frm_multiple_addons")}function hi(e){e.preventDefault(),wi(this,"frm_activate_addon")}function bi(e){e.preventDefault(),wi(this,"frm_install_addon")}function wi(e,t){r(1105).toggleAddonState(e,t)}function ji(){Ei()}function xi(e){!function(e,t,r){var n=jQuery("#frm_leave_email_error");n.removeClass("frm_hidden").attr("frm-error",r),jQuery("#frm_leave_email").one("keyup",function(){n.addClass("frm_hidden")})}(0,0,e)}function Ei(){var e=document.getElementById("frmapi-email-form");jQuery.ajax({dataType:"json",url:e.getAttribute("data-url"),success:function(t){var r=t.renderedHtml;r=r.replace(/]*(formidableforms.css|action=frmpro_css)[^>]*>/gi,""),e.innerHTML=r}})}function Qi(e){frmDom.autocomplete.initSelectionAutocomplete(e)}function ki(e){var t=this.parentNode.parentNode,r=t.elements.type.value;e.preventDefault(),this.classList.add("frm_loading_button"),Ai(t,r,this)}function Si(e){var t=this.elements.type.value,r=this.querySelector("button");e.preventDefault(),r.classList.add("frm_loading_button"),Ai(this,t,r)}function Ai(e,t,r){var n=function(e){var t,r,n={},o=e.elements;for(r=0;r