Sanitized post IDs. */ private function get_cookie_post_ids($cookie_name) { $cookie_name = sanitize_key($cookie_name); if ($cookie_name === '') { return array(); } // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- JSON cookie payload requires raw read before decode. if (!isset($_COOKIE[$cookie_name])) { return array(); } // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- JSON cookie payload requires raw read before decode. $raw_cookie = sanitize_text_field(wp_unslash($_COOKIE[$cookie_name])); if ($raw_cookie === '') { return array(); } $decoded = json_decode($raw_cookie, true); if (!is_array($decoded)) { return array(); } $post_ids = array_map('absint', $decoded); $post_ids = array_filter($post_ids, function ($id) { return $id > 0; }); return array_values(array_unique($post_ids)); } /** * Get singleton instance */ public static function get_instance() { if (self::$instance === null) { self::$instance = new self(); } return self::$instance; } /** * Mark that Qixit assets are needed on this page. * Called from filter_content(), shortcodes, and block rendering so * the scripts/styles are only enqueued when actually required. */ public static function require_assets() { self::$assets_needed = true; } /** * Constructor */ private function __construct() { $this->api = Qixit_API::get_instance(); $this->logger = Qixit_Logger::get_instance(); if (function_exists('qixit_maybe_expire_visitor_session')) { qixit_maybe_expire_visitor_session(); } // Filter content to add paywall add_filter('the_content', array($this, 'filter_content'), 10); // Handle unlock requests add_action('template_redirect', array($this, 'handle_unlock')); // Enqueue assets in wp_footer only if this page actually needs them. // This avoids loading jQuery, the SDK, and CDN CSS on free pages. add_action('wp_footer', array($this, 'maybe_enqueue_frontend_assets'), 1); // AJAX handler for recording purchases (both logged-in and guest users) add_action('wp_ajax_qixit_record_purchase', array($this, 'ajax_record_purchase')); add_action('wp_ajax_nopriv_qixit_record_purchase', array($this, 'ajax_record_purchase')); // Server-to-server callback from Products Portal (ReportUrl) // This is called by the Products Portal after a successful purchase add_action('wp_ajax_nopriv_qixit_purchase_report', array($this, 'ajax_purchase_report')); add_action('wp_ajax_qixit_purchase_report', array($this, 'ajax_purchase_report')); } /** * Called from wp_footer. Only enqueues assets if this page rendered * at least one paywall, shortcode, or payment image (set via require_assets()). */ public function maybe_enqueue_frontend_assets() { if (self::$assets_needed) { $this->enqueue_frontend_assets(); } } /** * Enqueue frontend assets * * Tries to load branding CSS/JS from centralized server first. * Falls back to local files if server is unavailable. */ public function enqueue_frontend_assets() { // Try to get branding config from server (cached for 1 hour) $branding_config = null; if (function_exists('qixit_get_branding_config')) { $branding_config = qixit_get_branding_config(); } $branding_css_url = ''; $branding_js_url = ''; if (!empty($branding_config['urls']) && is_array($branding_config['urls'])) { $branding_css_url = !empty($branding_config['urls']['css']) ? $branding_config['urls']['css'] : ''; $branding_js_url = !empty($branding_config['urls']['js']) ? $branding_config['urls']['js'] : ''; } // Determine which CSS to use if (!empty($branding_css_url)) { // Use centralized server CSS (latest branding) $css_version = isset($branding_config['version']) ? $branding_config['version'] : QIXIT_VERSION; wp_enqueue_style( 'qixit-branding', $branding_css_url, array(), $css_version ); // Also enqueue local frontend CSS for plugin-specific styles not in branding wp_enqueue_style('qixit-frontend'); } else { // Fallback to local CSS only wp_enqueue_style('qixit-frontend'); } // Determine which JS to use if (!empty($branding_js_url)) { // Use centralized server embed.js (includes micro-frame functionality) $js_version = isset($branding_config['version']) ? $branding_config['version'] : QIXIT_VERSION; wp_enqueue_script( 'qixit-embed', $branding_js_url, array('jquery'), $js_version, true ); // Also enqueue local frontend script for plugin-specific functionality wp_enqueue_script('qixit-frontend'); } else { // Fallback to local JS only wp_enqueue_script('qixit-frontend'); } // Localize script with AJAX URL and nonce for purchase recording wp_localize_script('qixit-frontend', 'qixitFrontend', array( 'ajaxUrl' => admin_url('admin-ajax.php'), 'nonce' => wp_create_nonce('qixit_frontend_nonce'), 'brandingLoaded' => !empty($branding_config), 'brandingVersion' => isset($branding_config['version']) ? $branding_config['version'] : null, 'developmentMode' => (bool) get_option('qixit_development_mode', false), 'expectedProductionApiUrl' => 'https://products.qixit.com', // Prefer branding mark for image badges; fall back to local overlay SVG. 'overlayLogoUrl' => (function_exists('qixit_get_branding_logo_url') && qixit_get_branding_logo_url()) ? qixit_get_branding_logo_url() : (QIXIT_PLUGIN_URL . '/images/overlay-logo.svg'), 'brandingLogoUrl' => function_exists('qixit_get_branding_logo_url') ? qixit_get_branding_logo_url() : '', 'productsApiUrl' => function_exists('qixit_get_products_api_url') ? qixit_get_products_api_url() : 'https://products.qixit.com', 'productsWebUrl' => function_exists('qixit_get_products_web_url') ? qixit_get_products_web_url() : 'https://products.qixit.com', 'visitorSessionIdleHours' => defined('QIXIT_VISITOR_SESSION_IDLE_HOURS') ? (int) QIXIT_VISITOR_SESSION_IDLE_HOURS : 12, 'strings' => array( 'loading' => __('Loading...', '2waymicropay'), 'error' => __('An error occurred', '2waymicropay') ) )); } /** * AJAX handler to record purchases from JavaScript * Called after successful micro-frame payment */ public function ajax_record_purchase() { // Verify nonce if (!isset($_POST['nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['nonce'])), 'qixit_frontend_nonce')) { $this->logger->log("AJAX record_purchase: Invalid nonce", 'error'); wp_send_json_error(array('message' => 'Invalid security token')); return; } $post_id = isset($_POST['post_id']) ? intval(wp_unslash($_POST['post_id'])) : 0; $product_guid = isset($_POST['product_guid']) ? sanitize_text_field(wp_unslash($_POST['product_guid'])) : ''; $qixit_user = isset($_POST['qixit_user']) ? sanitize_text_field(wp_unslash($_POST['qixit_user'])) : ''; $amount = isset($_POST['amount']) ? floatval(wp_unslash($_POST['amount'])) : 0; $txn_id = isset($_POST['txn_id']) ? sanitize_text_field(wp_unslash($_POST['txn_id'])) : ''; $this->logger->log("AJAX record_purchase: Received - post_id={$post_id}, product_guid={$product_guid}, qixit_user={$qixit_user}, amount={$amount}, txn_id={$txn_id}"); if ($txn_id === '') { $this->logger->log("AJAX record_purchase: Missing transaction ID", 'error'); wp_send_json_error(array('message' => 'Missing transaction ID')); return; } // We need at least a user identifier if (empty($qixit_user) && empty(get_current_user_id())) { $this->logger->log("AJAX record_purchase: No user identifier", 'error'); wp_send_json_error(array('message' => 'No user identifier')); return; } // Get product from post_id or product_guid $product_manager = Qixit_Product::get_instance(); $product = null; $product_id = 0; if ($post_id) { $product = $product_manager->get_product_by_post_id($post_id); $this->logger->log("AJAX record_purchase: Looked up by post_id={$post_id}, found=" . ($product ? 'yes' : 'no')); } if (!$product && !empty($product_guid)) { $product = $product_manager->get_product_by_guid($product_guid); $this->logger->log("AJAX record_purchase: Looked up by guid={$product_guid}, found=" . ($product ? 'yes' : 'no')); } if ($product) { $product_id = $product->product_id; if ($amount <= 0) { $amount = $product->premium_post_cost; } if ($product_guid === '' && !empty($product->qixit_PID_guid)) { $product_guid = (string) $product->qixit_PID_guid; } } else { // Even without a local product, we can still record the purchase // This happens if the product was created directly in Products Portal $this->logger->log("AJAX record_purchase: Product not found locally, recording with available data"); } $verify_user = !empty($qixit_user) ? $qixit_user : ''; $verify = qixit_verify_purchase_with_portal($txn_id, $product_guid, $verify_user, $amount > 0 ? $amount : null); if (empty($verify['success'])) { $this->logger->log("AJAX record_purchase: Transaction verification failed — " . (isset($verify['message']) ? $verify['message'] : 'unknown'), 'error'); wp_send_json_error(array('message' => 'Purchase verification failed')); return; } $existing = Qixit_Payment::get_instance()->get_payment_by_txn_id($txn_id); if ($existing) { wp_send_json_success(array( 'message' => 'Purchase already recorded', 'payment_id' => intval($existing->payment_id), 'purchaser_id' => $existing->qixit_user_id, )); return; } // Determine user identifier $wp_user_id = get_current_user_id(); $purchaser_id = !empty($qixit_user) ? $qixit_user : ($wp_user_id ? 'wp_' . $wp_user_id : 'guest_' . uniqid()); // Set cookie for guest purchases (so we can track them) if (!empty($qixit_user)) { qixit_refresh_visitor_auth_cookies($qixit_user); } // Set qixit_premium_posts cookie so has_user_purchased() finds the fast path. // This mirrors the logic in Qixit_Product::record_purchase(). if ($product && $post_id) { $access_hours = isset($product->access_hours) ? intval($product->access_hours) : -1; if ($access_hours !== -2) { // Skip for pay-per-play if ($access_hours === 0) { // Session cookie $session_posts = $this->get_cookie_post_ids('qixit_session_posts'); $session_posts[] = $post_id; $session_posts = array_unique($session_posts); setcookie('qixit_session_posts', json_encode($session_posts), 0, '/'); } else { $purchased_posts = $this->get_cookie_post_ids('qixit_premium_posts'); $purchased_posts[] = $post_id; $purchased_posts = array_unique($purchased_posts); $cookie_seconds = ($access_hours < 0) ? intval(QIXIT_DEFAULT_COOKIE_DAYS) * 86400 : $access_hours * 3600; setcookie('qixit_premium_posts', json_encode($purchased_posts), time() + $cookie_seconds, '/'); } } } if ($amount <= 0 && !empty($verify['data']['amount'])) { $amount = floatval($verify['data']['amount']); } // Record the purchase $payment = Qixit_Payment::get_instance(); $payment_data = array( 'product_id' => $product_id, 'qixit_PID_guid' => $product_guid, 'qixit_user_id' => $purchaser_id, 'txn_id' => $txn_id, 'total' => $amount, 'payment_for' => 'view_post', 'wp_user_id' => $wp_user_id ); $this->logger->log("AJAX record_purchase: Recording payment - " . json_encode($payment_data)); $result = $payment->record_payment($payment_data); if ($result) { $this->logger->log("AJAX record_purchase: Success - payment_id={$result}"); wp_send_json_success(array( 'message' => 'Purchase recorded', 'payment_id' => $result, 'purchaser_id' => $purchaser_id )); } else { $this->logger->log("AJAX record_purchase: Failed to record payment", 'error'); wp_send_json_error(array('message' => 'Failed to record purchase')); } } /** * Server-to-server callback from Products Portal (ReportUrl) * This is called directly by the Products Portal after a successful purchase * No JavaScript/browser involved - this is reliable server-to-server communication * * Note for reviewers: wp_ajax_nopriv is required because Products Portal cannot * obtain a WordPress nonce. Authentication uses HMAC (X-Qixit-Signature) plus * Products Portal transaction verification via Site API Key. */ public function ajax_purchase_report() { // Log the incoming request $this->logger->log("REPORT URL: Received callback from Products Portal"); // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.NonceVerification.Recommended -- server-to-server Products Portal callback cannot carry WP nonce. $sanitized_request = map_deep(wp_unslash($_REQUEST), 'sanitize_text_field'); $this->logger->log("REPORT URL: REQUEST data = " . wp_json_encode($sanitized_request)); $pick_first = static function ($source, $keys, $default = '') { foreach ($keys as $key) { if (isset($source[$key]) && $source[$key] !== '') { return $source[$key]; } } return $default; }; // Extract purchase data from request (POST and GET), supporting legacy key shapes. $product_guid = sanitize_text_field((string) $pick_first($sanitized_request, array('product_guid', 'productGuid', 'productKeyId', 'ProductKeyId', 'PID'))); $product_id = intval($pick_first($sanitized_request, array('product_id', 'productId'), 0)); $product_description = sanitize_text_field((string) $pick_first($sanitized_request, array('product_description', 'productDescription'))); $qixit_user = sanitize_text_field((string) $pick_first($sanitized_request, array('qixit_user', 'qixitUser', 'user', 'userId', 'buyer', 'buyerUserId'))); $raw_amount = (string) $pick_first($sanitized_request, array('amount', 'total', 'cost', 'price'), '0'); $amount = floatval($raw_amount); $txn_id = sanitize_text_field((string) $pick_first($sanitized_request, array('txn_id', 'txn', 'transactionId', 'transaction_id', 'referenceId'))); $timestamp = sanitize_text_field((string) $pick_first($sanitized_request, array('timestamp', 'ts'))); $action = sanitize_text_field((string) $pick_first($sanitized_request, array('action'), 'qixit_purchase_report')); $signature = ''; if (isset($_SERVER['HTTP_X_QIXIT_SIGNATURE'])) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitized on next line. $signature = sanitize_text_field(wp_unslash($_SERVER['HTTP_X_QIXIT_SIGNATURE'])); } $sign_fields = array( 'action' => $action, 'product_guid' => $product_guid, 'product_id' => (string) $product_id, 'product_description' => $product_description, 'qixit_user' => $qixit_user, 'amount' => $raw_amount, 'txn_id' => $txn_id, 'timestamp' => $timestamp, ); if (!qixit_verify_report_signature($sign_fields, $signature)) { $this->logger->log("REPORT URL: Invalid or missing HMAC signature", 'error'); wp_send_json_error(array('message' => 'Unauthorized callback')); return; } if (!qixit_validate_report_timestamp($timestamp)) { $this->logger->log("REPORT URL: Timestamp missing or outside replay window", 'error'); wp_send_json_error(array('message' => 'Expired callback')); return; } $this->logger->log("REPORT URL: Parsed - guid={$product_guid}, user={$qixit_user}, amount={$amount}, txn={$txn_id}"); if (empty($qixit_user)) { $this->logger->log("REPORT URL: Missing qixit_user", 'error'); wp_send_json_error(array('message' => 'Missing buyer identity')); return; } if ($txn_id === '') { $this->logger->log("REPORT URL: Missing transaction ID", 'error'); wp_send_json_error(array('message' => 'Missing transaction identifier')); return; } $verify = qixit_verify_purchase_with_portal($txn_id, $product_guid, $qixit_user, $amount > 0 ? $amount : null); if (empty($verify['success'])) { $this->logger->log("REPORT URL: Transaction verification failed", 'error'); wp_send_json_error(array('message' => 'Purchase verification failed')); return; } $payment = Qixit_Payment::get_instance(); $existing = $payment->get_payment_by_txn_id($txn_id); if ($existing) { $this->logger->log("REPORT URL: Duplicate txn {$txn_id} — idempotent success"); wp_send_json_success(array( 'message' => 'Purchase already recorded via ReportUrl', 'payment_id' => intval($existing->payment_id), 'qixit_user' => $qixit_user, )); return; } // Try to find the local product by GUID $product_manager = Qixit_Product::get_instance(); $local_product = null; $local_product_id = 0; $post_id = 0; if (!empty($product_guid)) { $local_product = $product_manager->get_product_by_guid($product_guid); if ($local_product) { $local_product_id = $local_product->product_id; $post_id = $local_product->post_id; $this->logger->log("REPORT URL: Found local product - product_id={$local_product_id}, post_id={$post_id}"); } else { $this->logger->log("REPORT URL: No local product found for GUID {$product_guid}"); } } // Record the purchase if ($amount <= 0 && !empty($verify['data']['amount'])) { $amount = floatval($verify['data']['amount']); } $payment_data = array( 'product_id' => $local_product_id, 'qixit_PID_guid' => $product_guid, 'qixit_user_id' => $qixit_user, 'txn_id' => $txn_id, 'total' => $amount, 'payment_for' => 'view_post', 'wp_user_id' => 0, // Server callback, no WP user session 'date_purchased' => current_time('mysql') ); $this->logger->log("REPORT URL: Recording payment - " . json_encode($payment_data)); $result = $payment->record_payment($payment_data); if ($result) { $this->logger->log("REPORT URL: SUCCESS - payment_id={$result}"); wp_send_json_success(array( 'message' => 'Purchase recorded via ReportUrl', 'payment_id' => $result, 'qixit_user' => $qixit_user )); } else { $this->logger->log("REPORT URL: FAILED to record payment", 'error'); wp_send_json_error(array('message' => 'Failed to record purchase')); } } /** * Format access duration for display * * @param int $hours Number of hours * @return string Formatted string like "Access for 24 hours" or "Access for 7 days" */ private function format_access_duration($hours) { if ($hours <= 0) { return __('Access for this session only', '2waymicropay'); } // Less than a day if ($hours < 24) { /* translators: %d: number of hours */ return sprintf(_n('Access for %d hour', 'Access for %d hours', $hours, '2waymicropay'), $hours); } // Less than a week - show in days if ($hours < 168) { $days = floor($hours / 24); /* translators: %d: number of days */ return sprintf(_n('Access for %d day', 'Access for %d days', $days, '2waymicropay'), $days); } // Less than a month - show in weeks if ($hours < 720) { $weeks = floor($hours / 168); /* translators: %d: number of weeks */ return sprintf(_n('Access for %d week', 'Access for %d weeks', $weeks, '2waymicropay'), $weeks); } // Show in months $months = floor($hours / 720); /* translators: %d: number of months */ return sprintf(_n('Access for %d month', 'Access for %d months', $months, '2waymicropay'), $months); } /** * Filter post content to add paywall * * @param string $content Post content * @return string Filtered content */ public function filter_content($content) { // Apply to both posts and pages if (!is_singular(array('post', 'page')) || !in_the_loop() || !is_main_query()) { return $content; } // Plain purchase/payout links in normal content still need frontend JS/CSS // so the tooltip and link-enhancement layer can run. if ($this->content_has_qixit_payment_links($content)) { self::require_assets(); } global $post; $product = new Qixit_Product($post->ID); // Check if this is a premium post if (!$product->is_premium()) { return $content; } // This page needs Qixit assets (paywall UI, micro-frame JS, CSS) self::require_assets(); // Get product data to check for readmore setting $product_data = $product->get_data(); $use_readmore = isset($product_data->use_readmore) && $product_data->use_readmore; // Debug logging $this->logger->info("filter_content: post_id={$post->ID}, use_readmore=" . ($use_readmore ? '1' : '0')); // In read-more mode, a missing means there is no intentional split point. // Show full content instead of falling back to default word truncation. $has_more_tag = strpos($post->post_content, '') !== false; if ($use_readmore && !$has_more_tag) { $this->logger->info("filter_content: use_readmore enabled but no more tag; returning full content"); return $content; } // Check if user has purchased $has_purchased = $product->has_user_purchased(); $this->logger->info("filter_content: has_purchased=" . ($has_purchased ? 'YES' : 'NO')); if ($has_purchased) { $this->logger->info("filter_content: has_more_tag=" . ($has_more_tag ? 'YES' : 'NO')); // For Read More posts, we need to get the FULL content from $post->post_content // because $content might already be truncated at by the theme if ($use_readmore && $has_more_tag) { // Get full content and apply filters (excluding our own to avoid recursion) $full_content = $post->post_content; $this->logger->info("filter_content: raw content length=" . strlen($full_content)); // Remove the tag and any custom text after it $full_content = preg_replace('//', '', $full_content); $this->logger->info("filter_content: after removing more tag, length=" . strlen($full_content)); // Apply standard WordPress content filters (wpautop, wptexturize, etc.) // but avoid recursion by temporarily removing our filter // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WordPress core hook name. remove_filter('the_content', array($this, 'filter_content'), 10); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WordPress core hook name. $full_content = apply_filters('the_content', $full_content); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WordPress core hook name. add_filter('the_content', array($this, 'filter_content'), 10); $this->logger->info("filter_content: returning FULL content, final length=" . strlen($full_content)); return $this->wrap_premium_content($full_content, $product, true); } $this->logger->info("filter_content: returning original content (no readmore or no more tag)"); return $this->wrap_premium_content($content, $product, true); } // Show paywall $this->logger->info("filter_content: showing paywall"); return $this->show_paywall($content, $product, $use_readmore); } /** * Detects purchasefast/paymentfast links in post/page content. * * @param string $content Post content HTML * @return bool */ private function content_has_qixit_payment_links($content) { if (empty($content) || !is_string($content)) { return false; } return (bool) preg_match( '/https?:\/\/[^\s"\'<>]*?(?:2waymicropay\.(?:com|net)|qixit\.com|localhost(?::\d+)?)[^\s"\'<>]*?(?:purchasefast\?PID=|paymentfast\?PYID=)/i', $content ); } /** * Show paywall * * @param string $content Original content * @param Qixit_Product $product Product object * @param bool $use_readmore Use readmore tag as split point * @return string Content with paywall */ private function show_paywall($content, $product, $use_readmore = false) { global $post; // Get product data $product_data = $product->get_data(); $guid = $product->get_guid(); $access_hours = isset($product_data->access_hours) ? intval($product_data->access_hours) : -1; // PRODUCTS PORTAL IS SOURCE OF TRUTH // If we have a GUID, fetch the authoritative accessHours from Products Portal if (!empty($guid)) { $api_product = $this->api->get_public_product($guid); if ($api_product['success'] && isset($api_product['data']['accessHours'])) { $access_hours = intval($api_product['data']['accessHours']); } } $is_pay_per_view = ($access_hours === -2); // Check for tag if using readmore option if ($use_readmore) { $excerpt = $this->get_readmore_excerpt($content); // Use inline "Read more..." link instead of full paywall box $paywall_html = $this->render_readmore_link($product); } else { // Extract excerpt (first paragraph or 100 words) $excerpt = $this->get_content_excerpt($content); // Use full paywall box $paywall_html = $this->render_paywall($product); } // For pay-per-view, include the full content in a hidden container // JavaScript will decode and reveal it after payment (without page reload) if ($is_pay_per_view) { // Get the full content to hide $full_content = $content; // If using readmore, get the content after the more tag if ($use_readmore && strpos($post->post_content, '') !== false) { // Remove the tag and get full content $full_content = preg_replace('//', '', $post->post_content); // Apply content filters (excluding our own to avoid recursion) // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WordPress core hook name. remove_filter('the_content', array($this, 'filter_content'), 10); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WordPress core hook name. $full_content = apply_filters('the_content', $full_content); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WordPress core hook name. add_filter('the_content', array($this, 'filter_content'), 10); } // Base64 encode the full content for JavaScript reveal $encoded_content = base64_encode($full_content); $post_id = $product_data ? $product_data->post_id : $post->ID; $container_id = 'qixit-paywall-' . $post_id; // Add hidden content container after paywall. // For Read More + PPV, wrap the link so #qixit-paywall-{id} exists for JS reveal. $hidden_content_html = sprintf( '', esc_attr($container_id), esc_attr($encoded_content) ); if ($use_readmore) { $paywall_html = sprintf( '
%s
', esc_attr($container_id), $paywall_html ); } return $excerpt . $paywall_html . $hidden_content_html; } return $excerpt . $paywall_html; } /** * Get content up to the tag * * @param string $content Full content * @return string Content before more tag */ private function get_readmore_excerpt($content) { global $post; // Check for in post content if (strpos($post->post_content, '') !== false) { $parts = explode('', $post->post_content, 2); // Apply content filters but avoid recursion by removing our filter first // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WordPress core hook name. remove_filter('the_content', array($this, 'filter_content'), 10); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WordPress core hook name. $excerpt = apply_filters('the_content', $parts[0]); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WordPress core hook name. add_filter('the_content', array($this, 'filter_content'), 10); return $excerpt; } // Fallback to regular excerpt if no more tag return $this->get_content_excerpt($content); } /** * Get content excerpt * * @param string $content Full content * @return string Excerpt */ private function get_content_excerpt($content) { // Remove shortcodes $content = strip_shortcodes($content); // Get first paragraph or 100 words $paragraphs = explode('

', $content); if (!empty($paragraphs[0])) { return $paragraphs[0] . '

'; } // Fallback to word limit $words = explode(' ', wp_strip_all_tags($content)); $excerpt = implode(' ', array_slice($words, 0, 100)); return '

' . $excerpt . '...

'; } /** * Render paywall HTML * * @param Qixit_Product $product Product object * @return string Paywall HTML */ private function render_paywall($product) { $product_data = $product->get_data(); $post_id = $product_data ? $product_data->post_id : 0; $guid = $product->get_guid(); // Default values from local database $cost = $product->get_cost(); $access_hours = isset($product_data->access_hours) ? intval($product_data->access_hours) : -1; // PRODUCTS PORTAL IS SOURCE OF TRUTH // If we have a GUID, fetch the authoritative data from Products Portal if (!empty($guid)) { $api_product = $this->api->get_public_product($guid); if ($api_product['success'] && isset($api_product['data'])) { $portal_data = $api_product['data']; // Use Products Portal values as authoritative if (isset($portal_data['cost'])) { $cost = floatval($portal_data['cost']); } if (isset($portal_data['accessHours'])) { $access_hours = intval($portal_data['accessHours']); // Update local database if different (keep in sync) if (isset($product_data->access_hours) && intval($product_data->access_hours) !== $access_hours) { $this->logger->info("Syncing access_hours from Products Portal: local={$product_data->access_hours}, portal={$access_hours}"); $product_manager = Qixit_Product::get_instance(); $product_manager->update_local_product($product_data->product_id, array('access_hours' => $access_hours)); } } $this->logger->info("Using Products Portal as source of truth: cost={$cost}, accessHours={$access_hours}"); } } $purchase_url = $product->get_purchase_url(); $product_type = $product->get_type(); // Get delivery URL (the current post URL with unlock parameter) $delivery_url = add_query_arg(array('qixit_unlock' => '1'), get_permalink($post_id)); // Check if this is pay-per-view mode $is_pay_per_view = ($access_hours === -2); // Determine type text based on access_hours $type_text = ''; $paywall_title = __('Premium Content', '2waymicropay'); if ($access_hours === -2) { // Pay Per View - each viewing requires payment $type_text = __('Pay each time you view', '2waymicropay'); $paywall_title = __('Pay Per View', '2waymicropay'); } elseif ($access_hours === -1) { $type_text = __('Pay once, read anytime!', '2waymicropay'); } elseif ($access_hours === 0) { $type_text = __('Access for this session only', '2waymicropay'); } else { // Format hours nicely $type_text = $this->format_access_duration($access_hours); } // Format price for display $price_display = '$' . number_format($cost, 2); if ($cost < 0.10) { $cents = intval($cost * 100); $price_display = $cents . ' cent' . ($cents != 1 ? 's' : ''); } // Generate unique container ID for pay-per-view $container_id = 'qixit-paywall-' . $post_id; ob_start(); // Check for conflict: Page is Pay Per View AND contains Pay Per Play shortcodes // Show admin warning so vendor can fix the configuration if ($is_pay_per_view && current_user_can('manage_options')) { $post = get_post($post_id); if ($post && function_exists('qixit_content_has_payperplay_shortcode') && qixit_content_has_payperplay_shortcode($post->post_content)) { ?>
⚠️ Admin Notice: This Pay Per View page contains [qixit_payperplay] embeds. Visitors must pay for page access before seeing the games. Edit Page Settings
data-is-ppv="true">

data-container-id="" data-is-ppv="true" title=". Click to Pay.">

get_cost(); $purchase_url = $product->get_purchase_url(); $product_data = $product->get_data(); $post_id = $product_data ? $product_data->post_id : 0; $guid = $product->get_guid(); $access_hours = isset($product_data->access_hours) ? intval($product_data->access_hours) : -1; // Prefer Products Portal accessHours when available (source of truth for PPV). if (!empty($guid)) { $api_product = $this->api->get_public_product($guid); if (!empty($api_product['success']) && isset($api_product['data']['accessHours'])) { $access_hours = intval($api_product['data']['accessHours']); } } $is_pay_per_view = ($access_hours === -2); $container_id = $post_id ? ('qixit-paywall-' . $post_id) : ''; // Format price for display $price_display = '$' . number_format($cost, 2); if ($cost < 0.10) { $cents = intval($cost * 100); $price_display = $cents . ' cent' . ($cents != 1 ? 's' : ''); } ob_start(); if (empty($purchase_url)) { // Fallback if product not synced ?> data-container-id="" data-is-ppv="true" title=". Click to Pay."> to continue reading', '2waymicropay'), esc_html($price_display))); ?>
ID)) { $post_id = intval($post->ID); } if ($post_id <= 0) { $post_id = intval(get_queried_object_id()); } // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- external payment callback URL cannot carry WP nonce. if ($post_id <= 0 && isset($_GET['post_id'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- external payment callback URL cannot carry WP nonce. $post_id = intval(wp_unslash($_GET['post_id'])); } // Handle session-only callback (time-limited) // This is used for session-only products to prevent bookmark-based bypass // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- external payment callback URL cannot carry WP nonce. if (isset($_GET['qixit_session_callback'])) { if ($post_id > 0) { $this->handle_session_callback($post_id); } return; } // Support callback shapes that include unlock marker and/or transaction id. $has_unlock = isset($raw_get['qixit_unlock']); $txn_id = sanitize_text_field((string) $pick_first($raw_get, array('txn', 'txn_id', 'transactionId', 'transaction_id', 'referenceId'))); if (!$has_unlock && $txn_id === '') { return; } // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- external payment callback URL cannot carry WP nonce. $callback_token = isset($_GET['qixit_cb']) ? sanitize_text_field(wp_unslash($_GET['qixit_cb'])) : ''; if (!qixit_require_callback_token($callback_token, 'post_unlock', array('post_id' => intval($post_id)), true)) { if ($post_id > 0) { wp_safe_redirect(get_permalink($post_id)); exit; } return; } // Get user ID from various sources $user_id = get_current_user_id(); // Also check for qixit_user parameter (passed from purchase) // This is the Qixit username (e.g., "dave70"), which we prefer for customer tracking $qixit_user = ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- external payment callback URL cannot carry WP nonce. if (isset($raw_get['qixit_user'])) { $qixit_user = sanitize_text_field((string) $raw_get['qixit_user']); // Store in session/cookie for verification and customer tracking if (!empty($qixit_user)) { qixit_refresh_visitor_auth_cookies($qixit_user); } } $product = null; $is_premium_product = false; if ($post_id > 0) { $product = new Qixit_Product($post_id); $is_premium_product = $product->is_premium(); } // Validate trusted callback token before recording any purchase. if ($txn_id === '') { if ($post_id > 0) { wp_safe_redirect(get_permalink($post_id)); exit; } return; } $product_guid = ''; if ($is_premium_product && $product) { $product_data = $product->get_data(); $product_guid = isset($product_data->qixit_PID_guid) ? (string) $product_data->qixit_PID_guid : ''; } if ($product_guid === '') { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- external payment callback URL cannot carry WP nonce. $product_guid = sanitize_text_field((string) $pick_first($raw_get, array('PID', 'product_guid', 'productGuid', 'productKeyId', 'ProductKeyId'))); } $verify_user = !empty($qixit_user) ? $qixit_user : ''; $amount = floatval($pick_first($raw_get, array('amount', 'total', 'cost', 'price'), 0)); if ($amount <= 0 && $is_premium_product && $product) { $amount = $product->get_cost(); } $verify = qixit_verify_purchase_with_portal($txn_id, $product_guid, $verify_user, $amount); if (empty($verify['success'])) { $this->logger->warning('Unlock callback rejected — transaction verification failed for post ' . $post_id); if ($post_id > 0) { wp_safe_redirect(get_permalink($post_id)); exit; } return; } // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- external payment callback URL cannot carry WP nonce. if (!empty($txn_id)) { // Record the purchase $amount = floatval($pick_first($raw_get, array('amount', 'total', 'cost', 'price'), 0)); if ($amount <= 0 && $is_premium_product && $product) { $amount = $product->get_cost(); } // IMPORTANT: Prefer Qixit username for customer tracking (displays properly in customer list) // Priority: 1. Qixit username from URL, 2. Qixit username from cookie, 3. WordPress username, 4. Guest ID if (!empty($qixit_user)) { $purchaser_id = $qixit_user; } elseif (isset($_COOKIE['qixit_purchaser']) && !empty($_COOKIE['qixit_purchaser'])) { $purchaser_id = sanitize_text_field(wp_unslash($_COOKIE['qixit_purchaser'])); } elseif ($user_id) { // WordPress user is logged in - use their username for display $wp_user = get_userdata($user_id); $purchaser_id = $wp_user ? $wp_user->user_login : 'wp_' . $user_id; } else { $purchaser_id = 'guest_' . session_id(); } if ($is_premium_product) { $product->record_purchase($purchaser_id, $amount, $txn_id); } else { // Legacy fallback: some old products may not exist in local qixit_products. // Still capture payment activity when trusted callback markers are present. // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- external payment callback URL cannot carry WP nonce. $guid = sanitize_text_field((string) $pick_first($raw_get, array('PID', 'product_guid', 'productGuid', 'productKeyId', 'ProductKeyId'))); Qixit_Payment::get_instance()->record_payment(array( 'product_id' => 0, 'qixit_PID_guid' => $guid, 'qixit_user_id' => $purchaser_id, 'txn_id' => $txn_id, 'total' => $amount > 0 ? $amount : 0, 'payment_for' => 'view_post', 'wp_user_id' => $user_id ? intval($user_id) : 0, 'date_purchased' => current_time('mysql'), )); $this->logger->info("Purchase fallback-recorded: post_id={$post_id}, user={$purchaser_id}, txn={$txn_id}, guid={$guid}"); } $this->logger->info("Purchase recorded: post_id={$post_id}, user={$purchaser_id}, txn={$txn_id}"); // Redirect to clean URL (removes unlock parameters) if ($post_id > 0) { wp_safe_redirect(get_permalink($post_id)); exit; } } // If just qixit_unlock=1 without verification, check if they have a valid purchase // This happens when returning from micro-frame if ($is_premium_product && $product->has_user_purchased()) { // Already purchased, redirect to clean URL wp_safe_redirect(get_permalink($post_id)); exit; } } /** * Handle session-only callback from purchase * * For session-only products, we use a time-limited callback instead of qixit_verified * to prevent users from bookmarking the unlock URL and bypassing the session restriction. * * @param int $post_id Post ID */ private function handle_session_callback($post_id) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- external payment callback URL cannot carry WP nonce. $callback_token = isset($_GET['qixit_cb']) ? sanitize_text_field(wp_unslash($_GET['qixit_cb'])) : ''; if (!qixit_require_callback_token($callback_token, 'session_unlock', array('post_id' => intval($post_id)), true)) { $this->logger->warning("Session callback token invalid for post {$post_id}"); wp_safe_redirect(get_permalink($post_id)); exit; } // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- external payment callback URL cannot carry WP nonce. $txn_id = isset($_GET['txn']) ? sanitize_text_field(wp_unslash($_GET['txn'])) : ''; if ($txn_id !== '') { $product_data = (new Qixit_Product($post_id))->get_data(); $product_guid = isset($product_data->qixit_PID_guid) ? (string) $product_data->qixit_PID_guid : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- external payment callback URL cannot carry WP nonce. $qixit_user = isset($_GET['qixit_user']) ? sanitize_text_field(wp_unslash($_GET['qixit_user'])) : ''; $verify = qixit_verify_purchase_with_portal($txn_id, $product_guid, $qixit_user, null); if (empty($verify['success'])) { $this->logger->warning("Session callback payment verification failed for post {$post_id}"); wp_safe_redirect(get_permalink($post_id)); exit; } } // Verify timestamp is present and recent (within 120 seconds) // This prevents bookmarking the URL for later use // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- external payment callback URL cannot carry WP nonce. if (!isset($_GET['qixit_ts'])) { $this->logger->warning("Session callback missing timestamp for post {$post_id}"); wp_safe_redirect(get_permalink($post_id)); exit; } // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- external payment callback URL cannot carry WP nonce. $timestamp = intval(wp_unslash($_GET['qixit_ts'])); $now = time(); $max_age = 120; // 2 minutes to complete redirect if ($timestamp < ($now - $max_age) || $timestamp > ($now + 60)) { // Timestamp is too old or in the future - likely a bookmark or manipulated URL $this->logger->warning("Session callback expired/invalid timestamp for post {$post_id}: ts={$timestamp}, now={$now}"); wp_safe_redirect(get_permalink($post_id)); exit; } $product = new Qixit_Product($post_id); if (!$product->is_premium()) { wp_safe_redirect(get_permalink($post_id)); exit; } // Verify this is indeed a session-only product $product_data = $product->get_data(); $access_hours = isset($product_data->access_hours) ? intval($product_data->access_hours) : -1; if ($access_hours !== 0) { // Not a session-only product - shouldn't be using this callback $this->logger->warning("Session callback used for non-session product {$post_id}"); wp_safe_redirect(get_permalink($post_id)); exit; } // Get user info - prefer Qixit username for customer tracking $user_id = get_current_user_id(); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- external payment callback URL cannot carry WP nonce. $qixit_user = isset($_GET['qixit_user']) ? sanitize_text_field(wp_unslash($_GET['qixit_user'])) : ''; // Store Qixit username in cookie for future reference if (!empty($qixit_user)) { qixit_refresh_visitor_auth_cookies($qixit_user); } // IMPORTANT: Prefer Qixit username for customer tracking (displays properly in customer list) // Priority: 1. Qixit username from URL, 2. Qixit username from cookie, 3. WordPress username, 4. Guest ID if (!empty($qixit_user)) { $purchaser_id = $qixit_user; } elseif (isset($_COOKIE['qixit_purchaser']) && !empty($_COOKIE['qixit_purchaser'])) { $purchaser_id = sanitize_text_field(wp_unslash($_COOKIE['qixit_purchaser'])); } elseif ($user_id) { $wp_user = get_userdata($user_id); $purchaser_id = $wp_user ? $wp_user->user_login : 'wp_' . $user_id; } else { $purchaser_id = 'guest_' . session_id(); } // Record the session-only purchase (this sets the session cookie) $product->record_purchase($purchaser_id, $product->get_cost(), 'session_' . $timestamp); $this->logger->info("Session-only purchase recorded: post_id={$post_id}, user={$purchaser_id}"); // Redirect to clean URL wp_safe_redirect(get_permalink($post_id)); exit; } /** * Get paywall for post (for shortcode use) * * @param int $post_id Post ID * @return string Paywall HTML */ public function get_paywall_for_post($post_id) { $product = new Qixit_Product($post_id); if (!$product->is_premium()) { return ''; } if ($product->has_user_purchased()) { return '
' . esc_html__('✓ You have access to this content', '2waymicropay') . '
'; } return $this->render_paywall($product); } } // Initialize frontend if (!is_admin()) { Qixit_Frontend::get_instance(); }