Plugin Directory


Ignore:
Timestamp:
08/17/2026 08:51:02 AM (2 months ago)
Author:
subscriptionplugin
Message:

1.0.21: Content paywall + unified Plans system, consolidated Stripe/webhook flow, and security fixes (removed client-side Stripe secret key, fixed subscription/admin authorization gaps)

File:
1 edited

Legend:

Unmodified
Added
Removed
  • subscription-system/trunk/subscription-system.php

    r3633440 r3650727  
    1717 * Plugin URI:        https://mediahubster.in/subscription-system
    1818 * Description:       A comprehensive subscription management system for WordPress.
    19  * Version:           1.0.20
     19 * Version:           1.0.21
    2020 * Author:            Joe Youngblood
    2121 * Author URI:        https://mediahubster.in
    … …  
    9797
    9898// Define plugin constants
    99 define('SUBS_SYS_VERSION', '1.0.20');
     99define('SUBS_SYS_VERSION', '1.0.21');
    100100define('SUBS_SYS_PLUGIN_DIR', plugin_dir_path(__FILE__));
    101101define('SUBS_SYS_PLUGIN_URL', plugin_dir_url(__FILE__));
    … …  
    103103
    104104/**
    105  * Valid cooldown units for the subscribe modal re-show delay.
    106  *
    107  * @return string[]
    108  */
    109 function subs_sys_get_modal_cooldown_units() {
    110     return array( 'minutes', 'days', 'weeks', 'months' );
    111 }
    112 
    113 /**
    114  * Convert modal cooldown settings to milliseconds for frontend use.
    115  *
    116  * @param int|null    $value Cooldown amount.
    117  * @param string|null $unit  Cooldown unit.
     105 * Display name for a plan category row.
     106 *
     107 * The categories table carries both `category_name` and `name` — two columns
     108 * for one idea, filled inconsistently depending on which screen created the
     109 * row. Prefer whichever actually holds text so a category never renders blank.
     110 *
     111 * @param object $category Category row.
     112 * @return string
     113 */
     114function subs_sys_plan_category_label( $category ) {
     115    foreach ( array( 'category_name', 'name' ) as $field ) {
     116        if ( ! empty( $category->$field ) && '' !== trim( (string) $category->$field ) ) {
     117            return trim( (string) $category->$field );
     118        }
     119    }
     120
     121    return __( '(untitled category)', 'subscription-system' );
     122}
     123
     124/**
     125 * The call-to-action label for a plan's button, e.g. "Subscribe Now" or "Buy Now".
     126 *
     127 * Site owners sell very different things through this plugin — a membership, a
     128 * one-off report, an SEO retainer — and "Subscribe Now" is wrong for some of
     129 * them. Each plan may override the wording; plans that don't fall back to the
     130 * default, so existing plans read exactly as they did before.
     131 *
     132 * @param object $plan Plan row (legacy or unified shape).
     133 * @return string Label ready to print.
     134 */
     135function subs_sys_plan_button_label( $plan ) {
     136    $label = isset( $plan->button_label ) ? trim( (string) $plan->button_label ) : '';
     137
     138    if ( '' !== $label ) {
     139        return $label;
     140    }
     141
     142    return subs_sys_default_button_label();
     143}
     144
     145/**
     146 * The wording used for any plan that hasn't overridden its button.
     147 *
     148 * @return string
     149 */
     150function subs_sys_default_button_label() {
     151    return __( 'Subscribe Now', 'subscription-system' );
     152}
     153
     154/**
     155 * Billing-period label for a plan price, e.g. "/month" or "/3 months".
     156 *
     157 * The pricing templates previously hardcoded "/month" for every plan, so an
     158 * annual plan advertised itself as "$99.00/month" — wrong, and misleading to
     159 * someone about to pay.
     160 *
     161 * The legacy plans table stores the period as two columns:
     162 *   duration      = the interval COUNT (Subs_Sys_Plan_Repository writes
     163 *                   `interval_count` here, minimum 1)
     164 *   duration_unit = the interval unit, stored as a plural ("months", "years")
     165 * Older rows may predate `duration_unit`, in which case the column default
     166 * ("days") applies — so fall back to days rather than assuming months.
     167 *
     168 * @param object $plan Plan row.
     169 * @return string Translated label including the leading slash, or '' if none.
     170 */
     171function subs_sys_plan_period_label( $plan ) {
     172    $count = isset( $plan->duration ) ? (int) $plan->duration : 0;
     173
     174    if ( $count <= 0 ) {
     175        // 0 means unlimited/one-off in the admin form — don't invent a period.
     176        return '';
     177    }
     178
     179    $unit = isset( $plan->duration_unit ) ? strtolower( trim( (string) $plan->duration_unit ) ) : 'days';
     180    $unit = rtrim( $unit, 's' ); // stored plural; compare singular
     181
     182    $singular = array(
     183        'day'   => __( '/day', 'subscription-system' ),
     184        'week'  => __( '/week', 'subscription-system' ),
     185        'month' => __( '/month', 'subscription-system' ),
     186        'year'  => __( '/year', 'subscription-system' ),
     187    );
     188
     189    if ( 1 === $count ) {
     190        return isset( $singular[ $unit ] ) ? $singular[ $unit ] : '';
     191    }
     192
     193    $plural = array(
     194        'day'   => _n_noop( '/%s day', '/%s days', 'subscription-system' ),
     195        'week'  => _n_noop( '/%s week', '/%s weeks', 'subscription-system' ),
     196        'month' => _n_noop( '/%s month', '/%s months', 'subscription-system' ),
     197        'year'  => _n_noop( '/%s year', '/%s years', 'subscription-system' ),
     198    );
     199
     200    if ( ! isset( $plural[ $unit ] ) ) {
     201        return '';
     202    }
     203
     204    return sprintf(
     205        translate_nooped_plural( $plural[ $unit ], $count, 'subscription-system' ),
     206        number_format_i18n( $count )
     207    );
     208}
     209
     210/**
     211 * Index of the plan that should carry the "featured" highlight.
     212 *
     213 * ceil() returns a FLOAT in PHP, and the templates compared it to the loop's
     214 * integer index with `===` — so `1 === 1.0` was false and the featured badge
     215 * never rendered on any layout. Cast once, here, so all three agree.
     216 *
     217 * @param array $plans Plans being rendered.
    118218 * @return int
    119219 */
    120 function subs_sys_get_modal_cooldown_ms( $value = null, $unit = null ) {
    121     $value = null !== $value ? absint( $value ) : absint( get_option( 'subs_modal_auto_open_cooldown', 60 ) );
    122     $unit  = null !== $unit ? sanitize_key( $unit ) : sanitize_key( get_option( 'subs_modal_auto_open_cooldown_unit', 'minutes' ) );
    123 
    124     if ( $value <= 0 ) {
    125         return 0;
    126     }
    127 
    128     $seconds_per_unit = array(
    129         'minutes' => MINUTE_IN_SECONDS,
    130         'days'    => DAY_IN_SECONDS,
    131         'weeks'   => WEEK_IN_SECONDS,
    132         'months'  => 30 * DAY_IN_SECONDS,
    133     );
    134 
    135     if ( ! isset( $seconds_per_unit[ $unit ] ) ) {
    136         $unit = 'minutes';
    137     }
    138 
    139     return $value * $seconds_per_unit[ $unit ] * 1000;
    140 }
    141 
    142 /**
    143  * Human-readable label for the modal cooldown period.
    144  *
    145  * @param int|null    $value Cooldown amount.
    146  * @param string|null $unit  Cooldown unit.
     220function subs_sys_featured_plan_index( $plans ) {
     221    $count = is_array( $plans ) ? count( $plans ) : 0;
     222
     223    if ( $count < 3 ) {
     224        // With one or two plans there is no meaningful "middle" to promote.
     225        return -1;
     226    }
     227
     228    return (int) ceil( $count / 2 ) - 1;
     229}
     230
     231/**
     232 * The plugin's configured Login page (Settings > Pages Settings), falling back
     233 * to WordPress's own wp-login.php only if no custom page has been assigned.
     234 * Several places in the plugin used to call wp_login_url() directly regardless
     235 * of this setting, so a site owner who'd correctly filled in Pages Settings
     236 * would still see visitors bounced to the default WP login screen from some
     237 * buttons/modals and the custom one from others.
     238 *
     239 * @param string $redirect_to Optional URL to return to after login.
    147240 * @return string
    148241 */
    149 function subs_sys_get_modal_cooldown_label( $value = null, $unit = null ) {
    150     $value = null !== $value ? absint( $value ) : absint( get_option( 'subs_modal_auto_open_cooldown', 60 ) );
    151     $unit  = null !== $unit ? sanitize_key( $unit ) : sanitize_key( get_option( 'subs_modal_auto_open_cooldown_unit', 'minutes' ) );
    152 
    153     if ( $value <= 0 ) {
    154         return '';
    155     }
    156 
    157     switch ( $unit ) {
    158         case 'days':
    159             /* translators: %d: number of days */
    160             return sprintf( _n( '%d day', '%d days', $value, 'subscription-system' ), $value );
    161         case 'weeks':
    162             /* translators: %d: number of weeks */
    163             return sprintf( _n( '%d week', '%d weeks', $value, 'subscription-system' ), $value );
    164         case 'months':
    165             /* translators: %d: number of months */
    166             return sprintf( _n( '%d month', '%d months', $value, 'subscription-system' ), $value );
    167         default:
    168             /* translators: %d: number of minutes */
    169             return sprintf( _n( '%d minute', '%d minutes', $value, 'subscription-system' ), $value );
    170     }
     242function subs_sys_get_login_url( $redirect_to = '' ) {
     243    $login_page_id = (int) get_option( 'login_page', 0 );
     244    $base = $login_page_id ? get_permalink( $login_page_id ) : '';
     245    if ( ! $base ) {
     246        return wp_login_url( $redirect_to );
     247    }
     248    return $redirect_to ? add_query_arg( 'redirect_to', rawurlencode( $redirect_to ), $base ) : $base;
     249}
     250
     251/**
     252 * The plugin's configured Register page, falling back to WordPress's own
     253 * registration URL only if no custom page has been assigned. See
     254 * subs_sys_get_login_url() above for why this exists.
     255 *
     256 * @return string
     257 */
     258function subs_sys_get_register_url() {
     259    $register_page_id = (int) get_option( 'register_page', 0 );
     260    $base = $register_page_id ? get_permalink( $register_page_id ) : '';
     261    return $base ?: wp_registration_url();
     262}
     263
     264/**
     265 * Simple honeypot check for public signup forms (registration, free interest
     266 * subscription). A hidden field that only a bot would fill in — humans never see it.
     267 *
     268 * @param string $field_name POST field name to check.
     269 * @return bool True if the honeypot was triggered (i.e. request looks bot-driven).
     270 */
     271function subs_sys_honeypot_triggered( $field_name = 'subs_sys_hp' ) {
     272    return ! empty( $_POST[ $field_name ] ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- honeypot check, not an authorization boundary.
     273}
     274
     275/**
     276 * Lightweight transient-based rate limit, keyed by client IP + an action-specific key.
     277 * Not a substitute for a real WAF/CAPTCHA, but stops naive scripted signup floods.
     278 *
     279 * @param string $action_key    Unique key for the action being limited (e.g. 'register').
     280 * @param int    $max_attempts  Max attempts allowed within the window.
     281 * @param int    $window_seconds Window length in seconds.
     282 * @return bool True if the caller has exceeded the limit and should be blocked.
     283 */
     284function subs_sys_is_rate_limited( $action_key, $max_attempts = 5, $window_seconds = 300 ) {
     285    $ip = '';
     286    if ( ! empty( $_SERVER['REMOTE_ADDR'] ) ) {
     287        $ip = sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) );
     288    }
     289    if ( empty( $ip ) ) {
     290        return false; // Can't key a limit without an IP; fail open rather than blocking everyone behind a shared/missing IP.
     291    }
     292
     293    $transient_key = 'ss_rl_' . $action_key . '_' . md5( $ip );
     294    $attempts       = (int) get_transient( $transient_key );
     295
     296    if ( $attempts >= $max_attempts ) {
     297        return true;
     298    }
     299
     300    if ( 0 === $attempts ) {
     301        set_transient( $transient_key, 1, $window_seconds );
     302    } else {
     303        set_transient( $transient_key, $attempts + 1, $window_seconds );
     304    }
     305
     306    return false;
    171307}
    172308
    173309/**
    174310 * Shared helper: enqueue interest-subscribe modal assets + localize script vars.
    175  * Called by both the shortcode handler and the floating-button/auto-open renderer
     311 * Called by both the shortcode handler and the floating-button renderer
    176312 * so the JS object is always fully populated regardless of which trigger is used.
    177313 *
    … …  
    179315 * @param string $version     Plugin version string.
    180316 */
     317/**
     318 * Whether a "broad posts" display rule is being saved / is active.
     319 *
     320 * "All Posts" (any singular post type except pages) and "Single Posts Only"
     321 * both already cover posts wholesale, which makes term-level targeting
     322 * (categories / tags / custom-taxonomy terms) and per-CPT targeting
     323 * contradictory. This is the single check both the server-side sanitizers and
     324 * the live UI conflict logic derive from.
     325 *
     326 * Reads the incoming $_POST during a settings save so the check reflects what is
     327 * about to be stored, not the stale saved value; falls back to the saved
     328 * options outside a save.
     329 *
     330 * @return bool
     331 */
     332function subs_sys_display_broad_posts_enabled() {
     333    // phpcs:disable WordPress.Security.NonceVerification.Missing -- the Settings API verifies the nonce before sanitizers run; this only reads flags.
     334    if ( isset( $_POST['option_page'] ) && 'subscription_modal_settings' === $_POST['option_page'] ) {
     335        return ! empty( $_POST['subs_modal_display_all_posts'] ) || ! empty( $_POST['subs_modal_display_single_posts'] );
     336    }
     337    // phpcs:enable WordPress.Security.NonceVerification.Missing
     338
     339    return (bool) get_option( 'subs_modal_display_all_posts', 0 ) || (bool) get_option( 'subs_modal_display_single_posts', 0 );
     340}
     341
     342/**
     343 * The complete conflict map for the General & Display settings.
     344 *
     345 * One definition, consumed by both the server (to refuse contradictory saves)
     346 * and the browser (to disable the conflicting controls and say why). Each entry
     347 * names a rule that, when ON, makes a set of other rules contradictory, with
     348 * the exact reason and the setting to change first.
     349 *
     350 * Keys are the checkbox `name` attributes (array fields use the base name).
     351 *
     352 * @return array[] list of {when: string, disables: string[], reason: string}
     353 */
     354function subs_sys_display_conflicts() {
     355    return array(
     356        array(
     357            'when'     => 'subs_modal_display_all_posts',
     358            'disables' => array(
     359                'subs_modal_display_single_posts',
     360                'subs_modal_display_post_types',
     361                'subs_modal_display_posts_with_cats',
     362                'subs_modal_display_posts_with_tags',
     363            ),
     364            'reason'   => __( '“Show on All Posts” already covers every post and custom post type. Turn it off first to target custom post types or posts with categories/tags. (Category and tag archive pages are separate and unaffected.)', 'subscription-system' ),
     365        ),
     366        array(
     367            'when'     => 'subs_modal_display_single_posts',
     368            'disables' => array(
     369                'subs_modal_display_posts_with_cats',
     370                'subs_modal_display_posts_with_tags',
     371            ),
     372            'reason'   => __( '“Show on Single Posts Only” already covers every single post, so “posts with categories” and “posts with tags” add nothing. Turn it off first to use those instead.', 'subscription-system' ),
     373        ),
     374        array(
     375            'when'     => 'subs_modal_display_all_pages',
     376            'disables' => array(
     377                'subs_modal_display_single_pages',
     378                'subs_modal_display_specific_pages',
     379            ),
     380            'reason'   => __( '“Show on All Pages” already covers every page. Turn it off first to target single pages or specific pages.', 'subscription-system' ),
     381        ),
     382    );
     383}
     384
     385/**
     386 * Order a term list so the current page's terms come first.
     387 *
     388 * The modal shows every available category/tag with the contextual ones
     389 * pre-checked; sorting those to the top keeps the pre-checked boxes visible
     390 * without scrolling instead of scattered through an alphabetical list.
     391 *
     392 * @param array $terms       Term arrays ({id, name, slug}).
     393 * @param int[] $context_ids Term ids detected from the current page.
     394 * @return array
     395 */
     396function subs_sys_sort_context_first( array $terms, array $context_ids ) {
     397    if ( empty( $context_ids ) || empty( $terms ) ) {
     398        return $terms;
     399    }
     400
     401    $context = array();
     402    $rest    = array();
     403
     404    foreach ( $terms as $term ) {
     405        if ( in_array( (int) $term['id'], $context_ids, true ) ) {
     406            $context[] = $term;
     407        } else {
     408            $rest[] = $term;
     409        }
     410    }
     411
     412    return array_merge( $context, $rest );
     413}
     414
     415/**
     416 * Cache-busting version for a plugin asset: the plugin version plus the file's
     417 * mtime. Browsers were serving week-old JS because every release of a file
     418 * shipped under the same "1.2.2" query string — an edited file now gets a new
     419 * URL automatically.
     420 *
     421 * @param string $relative Path under the plugin directory, e.g. 'public/js/foo.js'.
     422 * @return string
     423 */
     424function subs_sys_asset_version( $relative ) {
     425    $file  = SUBS_SYS_PLUGIN_DIR . ltrim( $relative, '/' );
     426    $mtime = file_exists( $file ) ? filemtime( $file ) : 0;
     427
     428    return $mtime ? SUBS_SYS_VERSION . '.' . $mtime : SUBS_SYS_VERSION;
     429}
     430
    181431function subs_sys_enqueue_interest_modal_assets( $plugin_name, $version ) {
    182432    static $done = false;
    … …  
    189439        $plugin_name . '-interest-subscribe',
    190440        SUBS_SYS_PLUGIN_URL . 'public/css/interest-subscribe.css',
    191         array(),
    192         $version
     441        array( $plugin_name . '-tokens' ),
     442        subs_sys_asset_version( 'public/css/interest-subscribe.css' )
    193443    );
    194444
    … …  
    197447        SUBS_SYS_PLUGIN_URL . 'public/js/interest-subscribe.js',
    198448        array( 'jquery' ),
    199         $version,
     449        subs_sys_asset_version( 'public/js/interest-subscribe.js' ),
    200450        true
    201451    );
    … …  
    210460            'ajax_url'              => admin_url( 'admin-ajax.php' ),
    211461            'nonce'                 => wp_create_nonce( 'interest_subscription_nonce' ),
    212             'auto_open'                => (int) get_option( 'subs_modal_auto_open', 0 ),
    213             'auto_open_delay'          => absint( get_option( 'subs_modal_auto_open_delay', 3 ) ),
    214             'auto_open_cooldown'       => absint( get_option( 'subs_modal_auto_open_cooldown', 60 ) ),
    215             'auto_open_cooldown_unit'  => sanitize_key( get_option( 'subs_modal_auto_open_cooldown_unit', 'minutes' ) ),
    216             'auto_open_cooldown_ms'    => subs_sys_get_modal_cooldown_ms(),
    217             'auto_open_cooldown_label' => subs_sys_get_modal_cooldown_label(),
     462
     463            /*
     464             * The in-modal flow: subscribe via the unified content pipeline,
     465             * authenticate via the same AJAX endpoints the standalone Login and
     466             * Register pages post to — one backend, two front doors.
     467             *
     468             * content_nonce is only valid for the CURRENT session; after an
     469             * in-modal login/register the auth response carries a fresh one,
     470             * which the JS swaps in before submitting the subscription.
     471             */
     472            'content_nonce'         => wp_create_nonce( 'ss_content_public' ),
     473            'login_nonce'           => wp_create_nonce( 'subs_sys_login_nonce' ),
     474            'register_nonce'        => wp_create_nonce( 'subs_sys_register_nonce' ),
     475
    218476            'hide_fields_logged_in'    => (int) get_option( 'subs_modal_hide_fields_logged_in', 1 ),
    219477            'is_logged_in'             => $is_logged_in ? 1 : 0,
    220478            'i18n'                     => array(
    221                 'processing'  => __( 'Processing...', 'subscription-system' ),
    222                 'submit'      => __( 'Subscribe', 'subscription-system' ),
    223                 'error'       => __( 'Something went wrong. Please try again.', 'subscription-system' ),
    224                 'select_plan' => __( 'Please select a subscription plan.', 'subscription-system' ),
     479                'processing'      => __( 'Processing...', 'subscription-system' ),
     480                'submit'          => __( 'Subscribe', 'subscription-system' ),
     481                'error'           => __( 'Something went wrong. Please try again.', 'subscription-system' ),
     482                'select_plan'     => __( 'Please select a subscription plan.', 'subscription-system' ),
     483                'select_interest' => __( 'Choose at least one category or tag to follow.', 'subscription-system' ),
     484                'continue'        => __( 'Continue', 'subscription-system' ),
     485                'logging_in'      => __( 'Signing in…', 'subscription-system' ),
     486                'registering'     => __( 'Creating your account…', 'subscription-system' ),
     487                'finishing'         => __( 'Setting up your subscription…', 'subscription-system' ),
     488                'to_checkout'       => __( 'Taking you to secure checkout…', 'subscription-system' ),
     489                'auth_required'     => __( 'Please log in or create an account to continue.', 'subscription-system' ),
     490                'login_subscribe'   => __( 'Log in & Subscribe', 'subscription-system' ),
     491                'register_subscribe' => __( 'Create account & Subscribe', 'subscription-system' ),
     492                'update_topics'      => __( 'Update topics', 'subscription-system' ),
     493                'switch_plan'        => __( 'Switch to this plan', 'subscription-system' ),
    225494            ),
    226495        )
    … …  
    260529    global $wpdb;
    261530    $charset_collate = $wpdb->get_charset_collate();
    262    
     531
     532    /*
     533     * Create the dedicated subscription-member role on activation, so it exists
     534     * before any registration can happen. Safe on update too: create_role() is
     535     * a no-op when the role is already present, and it never modifies existing
     536     * users. The one-time migration of existing subscribers runs separately,
     537     * gated on its own option, from Subs_Sys_Roles.
     538     */
     539    if ( ! class_exists( 'Subs_Sys_Roles' ) ) {
     540        require_once __DIR__ . '/includes/class-subs-sys-roles.php';
     541    }
     542    Subs_Sys_Roles::create_role();
     543
    263544    // Create subscriptions table
    264545    $subscriptions_table = $wpdb->prefix . 'subscriptions';
    … …  
    497778subs_sys_mh_run_plugin();
    498779
    499 /**
    500  * Delete plugin tables and options on plugin uninstall
    501  */
    502 function subs_sys_mh_uninstall() {
    503     global $wpdb;
    504    
    505     // Drop tables
    506     $wpdb->query("DROP TABLE IF EXISTS {$wpdb->prefix}subscriptions");
    507     $wpdb->query("DROP TABLE IF EXISTS {$wpdb->prefix}subscription_orders");
    508     $wpdb->query("DROP TABLE IF EXISTS {$wpdb->prefix}subscription_plans");
    509     $wpdb->query("DROP TABLE IF EXISTS {$wpdb->prefix}subscription_plan_categories");
    510     $wpdb->query("DROP TABLE IF EXISTS {$wpdb->prefix}subscription_channels");
    511     $wpdb->query("DROP TABLE IF EXISTS {$wpdb->prefix}subscription_work_items");
    512     $wpdb->query("DROP TABLE IF EXISTS {$wpdb->prefix}subscription_work_logs");
    513     $wpdb->query("DROP TABLE IF EXISTS {$wpdb->prefix}subscription_content_plans");
    514     $wpdb->query("DROP TABLE IF EXISTS {$wpdb->prefix}subscription_content_subscriptions");
    515     $wpdb->query("DROP TABLE IF EXISTS {$wpdb->prefix}subscription_content_notifications");
    516     $wpdb->query("DROP TABLE IF EXISTS {$wpdb->prefix}subscription_interest_subscriptions");
    517 
    518     // Delete options
    519     delete_option('subs_sys_db_version');
    520     delete_option('subs_sys_settings');
    521 }
    522 
    523 // Register uninstall hook
    524 register_uninstall_hook(__FILE__, 'subs_sys_mh_uninstall');
     780// Uninstall cleanup lives in uninstall.php (WordPress ignores register_uninstall_hook()
     781// callbacks when a plugin ships an uninstall.php, so keeping both was dead code that
     782// only invited the two to drift out of sync — see that file for the full, current list
     783// of tables/options this plugin removes).
    525784
    526785/**
Note: See TracChangeset for help on using the changeset viewer.