diff --git a/classes/controllers/FrmFormActionsController.php b/classes/controllers/FrmFormActionsController.php index 05da258b6c..55a5910c70 100644 --- a/classes/controllers/FrmFormActionsController.php +++ b/classes/controllers/FrmFormActionsController.php @@ -54,6 +54,7 @@ public static function register_actions() { $action_classes = array( 'on_submit' => 'FrmOnSubmitAction', 'email' => 'FrmEmailAction', + 'gated_content' => 'FrmGatedContentAction', 'wppost' => 'FrmDefPostAction', 'register' => 'FrmDefRegAction', 'paypal' => 'FrmDefPayPalAction', diff --git a/classes/controllers/FrmGatedContentController.php b/classes/controllers/FrmGatedContentController.php new file mode 100644 index 0000000000..ad893b1e00 --- /dev/null +++ b/classes/controllers/FrmGatedContentController.php @@ -0,0 +1,621 @@ +is_main_query() || is_admin() ) { + return; + } + + $post_id = self::get_requested_post_id( $query ); + if ( ! $post_id ) { + return; + } + + if ( ! self::has_valid_token_for_post( $post_id ) ) { + return; + } + + $statuses = $query->get( 'post_status' ); + if ( ! is_array( $statuses ) ) { + $statuses = $statuses ? array( $statuses ) : array( 'publish' ); + } + if ( ! in_array( 'private', $statuses, true ) ) { + $statuses[] = 'private'; + $query->set( 'post_status', $statuses ); + } + } + + /** + * Attempt to unlock a gated post (password-protected or private) using a token. + * + * Hooked on 'wp' so get_queried_object_id() is available. Private pages are + * already in the query by this point (via maybe_include_private_pages), so + * only password-protected pages need the post_password_required filter. + * + * Resolution order: + * 1. URL query parameter access_code (raw token → hashed via obtain_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; + } + + // Detect whether the token arrived via URL param before falling back to cookies. + $access_code = FrmAppHelper::simple_get( 'access_code' ); + $from_url_param = is_string( $access_code ) && '' !== $access_code; + + // Try URL query parameter first (obtain_token with no action_id uses URL param only). + $hash = FrmGatedTokenHelper::obtain_token(); + + // No URL param — scan frm_gc_* cookies to find one that grants access to this page. + if ( ! $hash ) { + $hash = self::find_valid_cookie_hash_for_post( $post_id ); + } + + if ( ! $hash ) { + return; + } + + // Fetch the row once and reuse for both validation and cookie/redirect logic. + $row = FrmGatedTokenHelper::get_row_by_hash( $hash ); + + if ( FrmGatedTokenHelper::validate_hash( $hash, $post_id, 'page', $row ) ) { + $post = get_post( $post_id ); + + // Password-protected pages need an explicit filter; private pages are + // already accessible because maybe_include_private_pages widened the query. + if ( $post && '' !== $post->post_password ) { + self::$unlocked_post_id = $post_id; + add_filter( 'post_password_required', 'FrmGatedContentController::filter_password_required', 10, 2 ); + } + + // Refresh the frm_gc_ cookie so subsequent visits skip the URL param. + if ( $row ) { + FrmGatedTokenHelper::set_cookie( (int) $row->action_id, $hash, $row->expired_at ); + } + + // 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 ( $from_url_param ) { + wp_safe_redirect( remove_query_arg( 'access_code' ) ); + exit; + } + + return; + } + + // Token present but invalid (expired or revoked) — redirect if action configured it. + if ( ! $row ) { + return; + } + + $action = get_post( (int) $row->action_id ); + if ( ! $action ) { + return; + } + + $settings = FrmAppHelper::maybe_json_decode( $action->post_content ); + if ( ! empty( $settings['show_form_page'] ) ) { + $redirect_url = get_permalink( (int) $settings['show_form_page'] ); + if ( $redirect_url ) { + wp_safe_redirect( $redirect_url ); + exit; + } + } + } + + /** + * 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 ) { + if ( $post->ID === self::$unlocked_post_id ) { + return false; + } + return $required; + } + + /** + * Resolve the post ID being requested from query vars, including private posts. + * + * Called from maybe_include_private_pages() during pre_get_posts, before the + * DB query runs. Uses post_status => 'any' so private pages are returned + * regardless of whether the visitor is logged in. + * + * @param WP_Query $query Current query object. + * + * @return int Post ID, or 0 if it cannot be determined. + */ + private static function get_requested_post_id( $query ) { + if ( ! empty( $query->query_vars['page_id'] ) ) { + return (int) $query->query_vars['page_id']; + } + + if ( empty( $query->query_vars['pagename'] ) ) { + return 0; + } + + $pages = get_posts( + array( + 'pagename' => $query->query_vars['pagename'], + 'post_type' => 'page', + 'post_status' => 'any', + 'posts_per_page' => 1, + 'no_found_rows' => true, + ) + ); + + return ! empty( $pages ) ? $pages[0]->ID : 0; + } + + /** + * Check whether the current request carries any valid token for a given post. + * + * Checks the URL access_code parameter first, then frm_gc_* cookies. Used by + * maybe_include_private_pages() to decide whether to widen the query. + * + * @param int $post_id Post ID to validate against. + * + * @return bool True if a valid token is found. + */ + private static function has_valid_token_for_post( $post_id ) { + $access_code = FrmAppHelper::simple_get( 'access_code' ); + if ( '' !== $access_code ) { + $hash = hash( 'sha256', $access_code ); + if ( FrmGatedTokenHelper::validate_hash( $hash, $post_id, 'page' ) ) { + return true; + } + } + + return null !== self::find_valid_cookie_hash_for_post( $post_id ); + } + + /** + * Scan frm_gc_* cookies and return the first hash that validates against a post. + * + * Used as a fallback when no access_code URL parameter is present, allowing + * return visits to stay unlocked without re-clicking the emailed link. + * + * @param int $post_id Post ID to validate against. + * + * @return string|null Validated token hash, or null if none found. + */ + private static function find_valid_cookie_hash_for_post( $post_id ) { + foreach ( $_COOKIE as $name => $value ) { + if ( 0 !== strpos( $name, 'frm_gc_' ) ) { + continue; + } + $hash = sanitize_text_field( $value ); + if ( FrmGatedTokenHelper::validate_hash( $hash, $post_id, 'page' ) ) { + return $hash; + } + } + return null; + } + + /** + * 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 + */ + public static function on_action_deleted( $post_id, $post ) { + if ( 'frm_form_actions' !== $post->post_type || FrmGatedContentAction::$slug !== $post->post_excerpt ) { + return; + } + FrmGatedTokenHelper::delete_by_action( $post_id ); + } + + /** + * Generate a gated content token when a form action fires. + * + * @param object $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', 'update', or 'import'). + * + * @return void + */ + public static function trigger( $action, $entry, $form, $event ) { + $raw_user_id = get_current_user_id(); + $user_id = $raw_user_id ? $raw_user_id : null; + + // On update, revoke any existing tokens for this action+entry pair before + // issuing a fresh one — prevents unbounded row accumulation and ensures the + // old token cannot be used once the entry owner receives a new one. + // Skip deletion when the action is configured to keep the old token. + if ( 'update' === $event && empty( $action->post_content['keep_token_on_update'] ) ) { + FrmGatedTokenHelper::delete_by_action_and_entry( $action->ID, $entry->id ); + } + + FrmGatedTokenHelper::generate( $action->ID, $entry->id, $user_id ); + } + + /** + * Default attributes for the [frm_gated_content] shortcode. + * + * - id (required) Action post ID. + * - item (optional) 0-indexed item position. Omit to render the full item list. + * - show (optional) 'link' (default) | 'url' | 'access_token' | 'expired_time'. + * 'link' — link tag(s). Without item: