Changeset 3650727 for subscription-system/trunk/subscription-system.php
- Timestamp:
- 08/17/2026 08:51:02 AM (2 months ago)
- File:
-
- 1 edited
-
subscription-system/trunk/subscription-system.php (modified) (9 diffs)
Legend:
- Unmodified
- Added
- Removed
-
subscription-system/trunk/subscription-system.php
r3633440 r3650727 17 17 * Plugin URI: https://mediahubster.in/subscription-system 18 18 * Description: A comprehensive subscription management system for WordPress. 19 * Version: 1.0.2 019 * Version: 1.0.21 20 20 * Author: Joe Youngblood 21 21 * Author URI: https://mediahubster.in … … 97 97 98 98 // Define plugin constants 99 define('SUBS_SYS_VERSION', '1.0.2 0');99 define('SUBS_SYS_VERSION', '1.0.21'); 100 100 define('SUBS_SYS_PLUGIN_DIR', plugin_dir_path(__FILE__)); 101 101 define('SUBS_SYS_PLUGIN_URL', plugin_dir_url(__FILE__)); … … 103 103 104 104 /** 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 */ 114 function 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 */ 135 function 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 */ 150 function 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 */ 171 function 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. 118 218 * @return int 119 219 */ 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. 220 function 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. 147 240 * @return string 148 241 */ 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 } 242 function 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 */ 258 function 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 */ 271 function 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 */ 284 function 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; 171 307 } 172 308 173 309 /** 174 310 * Shared helper: enqueue interest-subscribe modal assets + localize script vars. 175 * Called by both the shortcode handler and the floating-button /auto-openrenderer311 * Called by both the shortcode handler and the floating-button renderer 176 312 * so the JS object is always fully populated regardless of which trigger is used. 177 313 * … … 179 315 * @param string $version Plugin version string. 180 316 */ 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 */ 332 function 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 */ 354 function 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 */ 396 function 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 */ 424 function 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 181 431 function subs_sys_enqueue_interest_modal_assets( $plugin_name, $version ) { 182 432 static $done = false; … … 189 439 $plugin_name . '-interest-subscribe', 190 440 SUBS_SYS_PLUGIN_URL . 'public/css/interest-subscribe.css', 191 array( ),192 $version441 array( $plugin_name . '-tokens' ), 442 subs_sys_asset_version( 'public/css/interest-subscribe.css' ) 193 443 ); 194 444 … … 197 447 SUBS_SYS_PLUGIN_URL . 'public/js/interest-subscribe.js', 198 448 array( 'jquery' ), 199 $version,449 subs_sys_asset_version( 'public/js/interest-subscribe.js' ), 200 450 true 201 451 ); … … 210 460 'ajax_url' => admin_url( 'admin-ajax.php' ), 211 461 '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 218 476 'hide_fields_logged_in' => (int) get_option( 'subs_modal_hide_fields_logged_in', 1 ), 219 477 'is_logged_in' => $is_logged_in ? 1 : 0, 220 478 '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' ), 225 494 ), 226 495 ) … … 260 529 global $wpdb; 261 530 $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 263 544 // Create subscriptions table 264 545 $subscriptions_table = $wpdb->prefix . 'subscriptions'; … … 497 778 subs_sys_mh_run_plugin(); 498 779 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). 525 784 526 785 /**
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)