| @@ -15,11 +15,8 @@ | ||
| 15 | 15 | use Forge12\DoubleOptIn\FormSettings\FormSettingsDTO; |
| 16 | 16 | use Forge12\DoubleOptIn\FormSettings\FormSettingsService; |
| 17 | 17 | use Forge12\DoubleOptIn\FormSettings\FormSettingsValidator; |
| 18 | 18 | use Forge12\DoubleOptIn\Integration\SubmittedContent; |
| 19 | -use Forge12\DoubleOptIn\Service\ConfirmationMailResender; | |
| 20 | -use Forge12\DoubleOptIn\Service\ResendResult; | |
| 21 | -use Forge12\DoubleOptIn\Subscription\SubscriptionGroups; | |
| 22 | 19 | use Forge12\Shared\LoggerInterface; |
| 23 | 20 | |
| 24 | 21 | if ( ! defined( 'ABSPATH' ) ) { |
| 25 | 22 | exit; |
| @@ -33,14 +30,8 @@ | ||
| 33 | 30 | class AdminRestController { |
| 34 | 31 | |
| 35 | 32 | const API_NAMESPACE = 'f12-doi/v1'; |
| 36 | 33 | |
| 37 | - /** | |
| 38 | - * SQL form of OptIn::isOptedOut(): not confirmed, withdrawal IP and time | |
| 39 | - * recorded. A re-opt-in clears the time, so the row counts as confirmed again. | |
| 40 | - */ | |
| 41 | - private const REVOKED_SQL = "(doubleoptin = 0 AND ipaddr_optout IS NOT NULL AND ipaddr_optout <> '' AND optouttime IS NOT NULL AND optouttime NOT IN ('', '0'))"; | |
| 42 | - | |
| 43 | 34 | private LoggerInterface $logger; |
| 44 | 35 | private FormSettingsService $formService; |
| 45 | 36 | private FormSettingsValidator $formValidator; |
| 46 | 37 | |
| @@ -114,19 +105,8 @@ | ||
| 114 | 105 | 'permission_callback' => array( $this, 'checkPermission' ), |
| 115 | 106 | ) |
| 116 | 107 | ); |
| 117 | 108 | |
| 118 | - // ── Subscription groups (read-only; managed by an add-on) ── | |
| 119 | - register_rest_route( | |
| 120 | - self::API_NAMESPACE, | |
| 121 | - '/subscription-groups', | |
| 122 | - array( | |
| 123 | - 'methods' => \WP_REST_Server::READABLE, | |
| 124 | - 'callback' => array( $this, 'getSubscriptionGroups' ), | |
| 125 | - 'permission_callback' => array( $this, 'checkPermission' ), | |
| 126 | - ) | |
| 127 | - ); | |
| 128 | - | |
| 129 | 109 | // ── Opt-Ins ──────────────────────────────────────────────── |
| 130 | 110 | register_rest_route( |
| 131 | 111 | self::API_NAMESPACE, |
| 132 | 112 | '/optins', |
| @@ -529,8 +509,19 @@ | ||
| 529 | 509 | 'permission_callback' => array( $this, 'checkPermission' ), |
| 530 | 510 | ) |
| 531 | 511 | ); |
| 532 | 512 | |
| 513 | + // ── Database Export (Pro-extensible) ──────────────────────── | |
| 514 | + register_rest_route( | |
| 515 | + self::API_NAMESPACE, | |
| 516 | + '/database/export', | |
| 517 | + array( | |
| 518 | + 'methods' => \WP_REST_Server::CREATABLE, | |
| 519 | + 'callback' => array( $this, 'exportDatabase' ), | |
| 520 | + 'permission_callback' => array( $this, 'checkPermission' ), | |
| 521 | + ) | |
| 522 | + ); | |
| 523 | + | |
| 533 | 524 | // ── Addons manifest (UI mount-point system, plan §9) ──────── |
| 534 | 525 | register_rest_route( |
| 535 | 526 | self::API_NAMESPACE, |
| 536 | 527 | '/addons', |
| @@ -635,10 +626,9 @@ | ||
| 635 | 626 | $table = $wpdb->prefix . 'f12_cf7_doubleoptin'; |
| 636 | 627 | |
| 637 | 628 | $total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table}" ); |
| 638 | 629 | $confirmed = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table} WHERE doubleoptin = 1" ); |
| 639 | - $revoked = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table} WHERE " . self::REVOKED_SQL ); // phpcs:ignore WordPress.DB.PreparedSQL -- fixed SQL, no input. | |
| 640 | - $pending = max( 0, $total - $confirmed - $revoked ); | |
| 630 | + $pending = $total - $confirmed; | |
| 641 | 631 | $rate = $total > 0 ? round( ( $confirmed / $total ) * 100, 1 ) : 0; |
| 642 | 632 | |
| 643 | 633 | // Recent opt-ins (raw activity feed — not analytics). |
| 644 | 634 | // Time-bucketed activity, top-forms breakdown and the big |
| @@ -644,9 +634,9 @@ | ||
| 644 | 634 | // Time-bucketed activity, top-forms breakdown and the big |
| 645 | 635 | // conversion-rate card moved into addon-analytics, which |
| 646 | 636 | // renders them at the `dashboard.widget` mount point. |
| 647 | 637 | $recent = $wpdb->get_results( |
| 648 | - "SELECT id, email, cf_form_id, doubleoptin, createtime, ipaddr_optout, optouttime FROM {$table} ORDER BY id DESC LIMIT 5", | |
| 638 | + "SELECT id, email, cf_form_id, doubleoptin, createtime FROM {$table} ORDER BY id DESC LIMIT 5", | |
| 649 | 639 | ARRAY_A |
| 650 | 640 | ); |
| 651 | 641 | |
| 652 | 642 | foreach ( $recent as &$row ) { |
| @@ -652,18 +642,14 @@ | ||
| 652 | 642 | foreach ( $recent as &$row ) { |
| 653 | 643 | $post = get_post( (int) $row['cf_form_id'] ); |
| 654 | 644 | $row['formName'] = $post ? $post->post_title : sprintf( '#%d', $row['cf_form_id'] ); |
| 655 | 645 | $row['confirmed'] = (int) $row['doubleoptin'] === 1; |
| 656 | - $row['revoked'] = self::isRevokedRow( $row ); | |
| 657 | - unset( $row['ipaddr_optout'], $row['optouttime'] ); | |
| 658 | 646 | } |
| 659 | - unset( $row ); | |
| 660 | 647 | |
| 661 | 648 | $data = array( |
| 662 | 649 | 'totalOptins' => $total, |
| 663 | 650 | 'confirmed' => $confirmed, |
| 664 | 651 | 'pending' => $pending, |
| 665 | - 'revoked' => $revoked, | |
| 666 | 652 | 'conversionRate' => $rate, |
| 667 | 653 | 'recentOptins' => $recent ?: array(), |
| 668 | 654 | ); |
| 669 | 655 | |
| @@ -715,35 +701,8 @@ | ||
| 715 | 701 | // ═══════════════════════════════════════════════════════════════ |
| 716 | 702 | // OPT-INS |
| 717 | 703 | // ═══════════════════════════════════════════════════════════════ |
| 718 | 704 | |
| 719 | - /** | |
| 720 | - * Subscription groups for the filter in the opt-in list. `available` | |
| 721 | - * is false when no add-on provides groups, so the SPA hides the | |
| 722 | - * controls instead of showing an empty filter. | |
| 723 | - */ | |
| 724 | - public function getSubscriptionGroups( \WP_REST_Request $request ): \WP_REST_Response { | |
| 725 | - $groups = array(); | |
| 726 | - foreach ( SubscriptionGroups::resolver()->groups() as $group ) { | |
| 727 | - $groups[] = array( | |
| 728 | - 'key' => $group->getKey(), | |
| 729 | - 'label' => $group->getLabel(), | |
| 730 | - 'memberCount' => count( $group->getMembers() ), | |
| 731 | - ); | |
| 732 | - } | |
| 733 | - | |
| 734 | - return new \WP_REST_Response( | |
| 735 | - array( | |
| 736 | - 'success' => true, | |
| 737 | - 'data' => array( | |
| 738 | - 'available' => SubscriptionGroups::isAvailable(), | |
| 739 | - 'groups' => $groups, | |
| 740 | - ), | |
| 741 | - ), | |
| 742 | - 200 | |
| 743 | - ); | |
| 744 | - } | |
| 745 | - | |
| 746 | 705 | public function getOptins( \WP_REST_Request $request ): \WP_REST_Response { |
| 747 | 706 | $page = max( 1, (int) $request->get_param( 'page' ) ?: 1 ); |
| 748 | 707 | $perPage = max( 1, min( 100, (int) $request->get_param( 'per_page' ) ?: 20 ) ); |
| 749 | 708 | $search = sanitize_text_field( $request->get_param( 'search' ) ?? '' ); |
| @@ -771,11 +730,8 @@ | ||
| 771 | 730 | if ( $status === 'confirmed' ) { |
| 772 | 731 | $where[] = 'doubleoptin = 1'; |
| 773 | 732 | } elseif ( $status === 'pending' ) { |
| 774 | 733 | $where[] = '(doubleoptin = 0 OR doubleoptin IS NULL)'; |
| 775 | - $where[] = 'NOT ' . self::REVOKED_SQL; | |
| 776 | - } elseif ( $status === 'revoked' ) { | |
| 777 | - $where[] = self::REVOKED_SQL; | |
| 778 | 734 | } |
| 779 | 735 | |
| 780 | 736 | if ( $formId !== null && $formId !== '' ) { |
| 781 | 737 | $where[] = 'cf_form_id = %d'; |
| @@ -781,38 +737,8 @@ | ||
| 781 | 737 | $where[] = 'cf_form_id = %d'; |
| 782 | 738 | $params[] = (int) $formId; |
| 783 | 739 | } |
| 784 | 740 | |
| 785 | - // Subscription group (5.12.0). An unknown key selects nothing, so a | |
| 786 | - // stale link can never widen into the unfiltered list. | |
| 787 | - $groupKey = sanitize_text_field( (string) ( $request->get_param( 'group' ) ?? '' ) ); | |
| 788 | - if ( $groupKey !== '' ) { | |
| 789 | - $group = SubscriptionGroups::resolver()->findGroup( $groupKey ); | |
| 790 | - if ( $group === null ) { | |
| 791 | - $where[] = '1 = 0'; | |
| 792 | - } else { | |
| 793 | - list( $groupSql, $groupParams ) = $group->toSqlCondition(); | |
| 794 | - $where[] = $groupSql; | |
| 795 | - $params = array_merge( $params, $groupParams ); | |
| 796 | - } | |
| 797 | - } | |
| 798 | - | |
| 799 | - // Opt-ins whose confirmation mail could not be sent (5.8.0). | |
| 800 | - if ( sanitize_text_field( (string) ( $request->get_param( 'mail' ) ?? '' ) ) === 'failed' ) { | |
| 801 | - $where[] = 'mail_status = %s'; | |
| 802 | - $params[] = \Forge12\DoubleOptIn\Repository\OptInMailStatusRepository::FAILED; | |
| 803 | - } | |
| 804 | - | |
| 805 | - // Confirmed opt-ins whose follow-up actions failed or have an | |
| 806 | - // unknown outcome — the admin's "needs attention" list. | |
| 807 | - if ( sanitize_text_field( (string) ( $request->get_param( 'follow_up' ) ?? '' ) ) === 'problem' ) { | |
| 808 | - $followUpTable = $wpdb->prefix . \Forge12\DoubleOptIn\Repository\FollowUpSchema::TABLE_NAME; | |
| 809 | - $problems = \Forge12\DoubleOptIn\FollowUp\FollowUpStatus::problematic(); | |
| 810 | - $where[] = "EXISTS (SELECT 1 FROM {$followUpTable} fu WHERE fu.optin_id = {$table}.id AND fu.status IN (" | |
| 811 | - . implode( ', ', array_fill( 0, count( $problems ), '%s' ) ) . '))'; | |
| 812 | - $params = array_merge( $params, $problems ); | |
| 813 | - } | |
| 814 | - | |
| 815 | 741 | $whereClause = implode( ' AND ', $where ); |
| 816 | 742 | |
| 817 | 743 | // Count |
| 818 | 744 | $countQuery = "SELECT COUNT(*) FROM {$table} WHERE {$whereClause}"; |
| @@ -865,12 +791,8 @@ | ||
| 865 | 791 | } |
| 866 | 792 | |
| 867 | 793 | $data = $this->formatOptinRow( $row, true ); |
| 868 | 794 | |
| 869 | - // Other sign-ups of the same address in the same subscription | |
| 870 | - // group (5.12.0). The records stay separate; this only links them. | |
| 871 | - $data['linkedOptIns'] = $this->linkedOptIns( $row ); | |
| 872 | - | |
| 873 | 795 | // Dev-mode UI hint: surface whether the reset-confirmation |
| 874 | 796 | // endpoint is reachable for this request, so the React detail |
| 875 | 797 | // page can show/hide the "Reset to pending" button without |
| 876 | 798 | // having to probe the endpoint and handle a 403. Mirrors |
| @@ -911,9 +833,9 @@ | ||
| 911 | 833 | // Full row (id, hash, content, files, cf_form_id) so the |
| 912 | 834 | // pre-delete cascade hook from pre-doi-data-retention Step 1 |
| 913 | 835 | // can fire with a payload that lets listeners reach into |
| 914 | 836 | // integration storage. ARRAY_A — listener-friendly. |
| 915 | - $row = $wpdb->get_row( | |
| 837 | + $row = $wpdb->get_row( | |
| 916 | 838 | $wpdb->prepare( "SELECT id, hash, content, files, cf_form_id FROM {$table} WHERE id = %d", $id ), |
| 917 | 839 | ARRAY_A |
| 918 | 840 | ); |
| 919 | 841 | $hash = is_array( $row ) ? ( $row['hash'] ?? null ) : null; |
| @@ -971,32 +893,8 @@ | ||
| 971 | 893 | 200 |
| 972 | 894 | ); |
| 973 | 895 | } |
| 974 | 896 | |
| 975 | - /** | |
| 976 | - * The admin's answer for a resend that did not go out. | |
| 977 | - */ | |
| 978 | - private static function resendRefusal( string $reason ): \WP_REST_Response { | |
| 979 | - $map = array( | |
| 980 | - ResendResult::NOT_FOUND => array( __( 'Opt-In not found.', 'double-opt-in' ), 404 ), | |
| 981 | - ResendResult::CONFIRMED => array( __( 'Opt-In is already confirmed.', 'double-opt-in' ), 400 ), | |
| 982 | - ResendResult::OPTED_OUT => array( __( 'This contact has opted out. The confirmation email is not sent again.', 'double-opt-in' ), 400 ), | |
| 983 | - ResendResult::NO_BODY => array( __( 'No email data available for resend.', 'double-opt-in' ), 400 ), | |
| 984 | - ResendResult::NO_RECIPIENT => array( __( 'Email data is incomplete.', 'double-opt-in' ), 400 ), | |
| 985 | - ); | |
| 986 | - $entry = $map[ $reason ] ?? array( __( 'Failed to send email.', 'double-opt-in' ), 500 ); | |
| 987 | - $message = $entry[0]; | |
| 988 | - $status = $entry[1]; | |
| 989 | - | |
| 990 | - return new \WP_REST_Response( | |
| 991 | - array( | |
| 992 | - 'success' => false, | |
| 993 | - 'message' => $message, | |
| 994 | - ), | |
| 995 | - $status | |
| 996 | - ); | |
| 997 | - } | |
| 998 | - | |
| 999 | 897 | public function resendOptinEmail( \WP_REST_Request $request ): \WP_REST_Response { |
| 1000 | 898 | global $wpdb; |
| 1001 | 899 | $id = (int) $request->get_param( 'id' ); |
| 1002 | 900 | $table = $wpdb->prefix . 'f12_cf7_doubleoptin'; |
| @@ -1036,23 +934,96 @@ | ||
| 1036 | 934 | */ |
| 1037 | 935 | $result = apply_filters( 'f12_doi_rest_resend_optin_email', null, $optin, $row ); |
| 1038 | 936 | |
| 1039 | 937 | if ( $result === null ) { |
| 1040 | - $outcome = \Forge12\DoubleOptIn\Container\Container::getInstance() | |
| 1041 | - ->get( ConfirmationMailResender::class ) | |
| 1042 | - ->resend( $id ); | |
| 938 | + // Default resend logic: use stored mail data. | |
| 939 | + // | |
| 940 | + // `mail_optin` is shipped by every integration via | |
| 941 | + // {@see \forge12\contactform7\CF7DoubleOptIn\OptIn::set_mail_optin()}. | |
| 942 | + // That method takes a STRING (the rendered HTML body) — the | |
| 943 | + // admin opt-in-detail UI reads it as-is for the body | |
| 944 | + // preview. Earlier versions of this handler expected a | |
| 945 | + // serialized `['to' => ..., 'subject' => ..., 'body' => ...]` | |
| 946 | + // array and bailed with "Email data is incomplete" whenever | |
| 947 | + // the stored value was the (correct) plain body string — | |
| 948 | + // which is the production case for every free-version | |
| 949 | + // integration (CF7 / Avada / WPForms / Gravity / Elementor). | |
| 950 | + // User-reported 2026-05-13: clicking Resend yielded that | |
| 951 | + // error 100 % of the time. | |
| 952 | + // | |
| 953 | + // Both shapes are accepted now: the array form for Pro and | |
| 954 | + // any future caller that stores structured payloads, the | |
| 955 | + // plain string for the free-version integrations whose | |
| 956 | + // contract is documented in | |
| 957 | + // {@see \Forge12\DoubleOptIn\Wpforms\Tests\Unit\Integration\WPFormsSettingsApplyTest}. | |
| 958 | + $mailOptin = $row['mail_optin'] ?? ''; | |
| 959 | + if ( empty( $mailOptin ) ) { | |
| 960 | + return new \WP_REST_Response( | |
| 961 | + array( | |
| 962 | + 'success' => false, | |
| 963 | + 'message' => __( 'No email data available for resend.', 'double-opt-in' ), | |
| 964 | + ), | |
| 965 | + 400 | |
| 966 | + ); | |
| 967 | + } | |
| 1043 | 968 | |
| 1044 | - if ( ! $outcome->isSent() ) { | |
| 1045 | - return self::resendRefusal( $outcome->getReason() ); | |
| 969 | + $unserialized = maybe_unserialize( $mailOptin ); | |
| 970 | + | |
| 971 | + if ( is_array( $unserialized ) ) { | |
| 972 | + // Structured payload (Pro / future writers). | |
| 973 | + $to = $unserialized['to'] ?? ''; | |
| 974 | + $subject = $unserialized['subject'] ?? ''; | |
| 975 | + $body = $unserialized['body'] ?? ''; | |
| 976 | + $from = $unserialized['from'] ?? ''; | |
| 977 | + } else { | |
| 978 | + // Plain body string — the production case. Reconstruct | |
| 979 | + // `to` from the OptIn record's own `email` column and | |
| 980 | + // `subject` from the form's central settings. | |
| 981 | + $to = $row['email'] ?? ''; | |
| 982 | + $body = is_string( $unserialized ) ? $unserialized : (string) $mailOptin; | |
| 983 | + $subject = ''; | |
| 984 | + $from = ''; | |
| 985 | + | |
| 986 | + $formId = isset( $row['cf_form_id'] ) ? (int) $row['cf_form_id'] : 0; | |
| 987 | + if ( $formId > 0 && class_exists( '\\forge12\\contactform7\\CF7DoubleOptIn\\CF7DoubleOptIn' ) ) { | |
| 988 | + $formParam = \forge12\contactform7\CF7DoubleOptIn\CF7DoubleOptIn::getInstance()->getParameter( $formId ); | |
| 989 | + $subject = (string) ( $formParam['subject'] ?? '' ); | |
| 990 | + $senderEmail = (string) ( $formParam['sender'] ?? '' ); | |
| 991 | + $senderName = (string) ( $formParam['sender_name'] ?? '' ); | |
| 992 | + if ( $senderEmail !== '' ) { | |
| 993 | + $from = $senderName !== '' | |
| 994 | + ? $senderName . ' <' . $senderEmail . '>' | |
| 995 | + : $senderEmail; | |
| 996 | + } | |
| 997 | + } | |
| 1046 | 998 | } |
| 1047 | - $result = true; | |
| 1048 | - } else { | |
| 1049 | - // An extension sent it; record the outcome all the same. | |
| 1050 | - do_action( 'f12_doi_optin_mail_result', $id, (bool) $result, '' ); | |
| 999 | + | |
| 1000 | + if ( empty( $to ) || empty( $body ) ) { | |
| 1001 | + return new \WP_REST_Response( | |
| 1002 | + array( | |
| 1003 | + 'success' => false, | |
| 1004 | + 'message' => __( 'Email data is incomplete.', 'double-opt-in' ), | |
| 1005 | + ), | |
| 1006 | + 400 | |
| 1007 | + ); | |
| 1008 | + } | |
| 1009 | + | |
| 1010 | + $headers = array( 'Content-Type: text/html; charset=UTF-8' ); | |
| 1011 | + if ( ! empty( $from ) ) { | |
| 1012 | + $headers[] = 'From: ' . $from; | |
| 1013 | + } | |
| 1014 | + | |
| 1015 | + $result = wp_mail( $to, $subject !== '' ? $subject : __( 'Confirmation Email (resent)', 'double-opt-in' ), $body, $headers ); | |
| 1051 | 1016 | } |
| 1052 | 1017 | |
| 1053 | 1018 | if ( ! $result ) { |
| 1054 | - return self::resendRefusal( ResendResult::SEND_FAILED ); | |
| 1019 | + return new \WP_REST_Response( | |
| 1020 | + array( | |
| 1021 | + 'success' => false, | |
| 1022 | + 'message' => __( 'Failed to send email.', 'double-opt-in' ), | |
| 1023 | + ), | |
| 1024 | + 500 | |
| 1025 | + ); | |
| 1055 | 1026 | } |
| 1056 | 1027 | |
| 1057 | 1028 | AuditLogger::log( |
| 1058 | 1029 | AuditLogger::TYPE_EMAIL, |
| @@ -1949,41 +1920,22 @@ | ||
| 1949 | 1920 | ); |
| 1950 | 1921 | } |
| 1951 | 1922 | |
| 1952 | 1923 | // ═══════════════════════════════════════════════════════════════ |
| 1953 | - // ADD-ON ROUTES | |
| 1954 | - // Core owns the route; the data comes from the add-on through a | |
| 1955 | - // filter. Without a handler the answer is ADDON_INACTIVE. Core | |
| 1956 | - // itself never checks a licence here (wordpress.org guideline 5): | |
| 1957 | - // the functionality lives in the add-on, which only hooks in when | |
| 1958 | - // it runs licensed. | |
| 1924 | + // PRO-EXTENSIBLE STUBS | |
| 1925 | + // These return minimal responses; Pro overrides via filters or | |
| 1926 | + // registers its own REST routes that take precedence. | |
| 1959 | 1927 | // ═══════════════════════════════════════════════════════════════ |
| 1960 | 1928 | |
| 1961 | - /** | |
| 1962 | - * Answer for a route whose add-on is not running. | |
| 1963 | - * | |
| 1964 | - * 404 with `code` so the SPA can tell it from an unknown route | |
| 1965 | - * (`rest_no_route`); `ApiError` reads `body.code`. | |
| 1966 | - */ | |
| 1967 | - private function addonInactive( string $addonId, string $addonName ): \WP_REST_Response { | |
| 1968 | - return new \WP_REST_Response( | |
| 1969 | - array( | |
| 1970 | - 'success' => false, | |
| 1971 | - 'code' => 'ADDON_INACTIVE', | |
| 1972 | - 'addon' => $addonId, | |
| 1973 | - 'message' => sprintf( | |
| 1974 | - /* translators: %s: add-on name */ | |
| 1975 | - __( 'This feature is provided by the %s add-on. Install and activate the add-on with a valid license to use it.', 'double-opt-in' ), | |
| 1976 | - $addonName | |
| 1929 | + public function getAnalyticsOverview( \WP_REST_Request $request ): \WP_REST_Response { | |
| 1930 | + if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) { | |
| 1931 | + return new \WP_REST_Response( | |
| 1932 | + array( | |
| 1933 | + 'success' => false, | |
| 1934 | + 'message' => __( 'Pro version required.', 'double-opt-in' ), | |
| 1977 | 1935 | ), |
| 1978 | - ), | |
| 1979 | - 404 | |
| 1980 | - ); | |
| 1981 | - } | |
| 1982 | - | |
| 1983 | - public function getAnalyticsOverview( \WP_REST_Request $request ): \WP_REST_Response { | |
| 1984 | - if ( ! has_filter( 'f12_doi_rest_analytics_overview' ) ) { | |
| 1985 | - return $this->addonInactive( 'analytics', 'Analytics' ); | |
| 1936 | + 403 | |
| 1937 | + ); | |
| 1986 | 1938 | } |
| 1987 | 1939 | |
| 1988 | 1940 | $data = apply_filters( 'f12_doi_rest_analytics_overview', array(), $request ); |
| 1989 | 1941 | |
| @@ -1996,10 +1948,16 @@ | ||
| 1996 | 1948 | ); |
| 1997 | 1949 | } |
| 1998 | 1950 | |
| 1999 | 1951 | public function getAnalyticsForm( \WP_REST_Request $request ): \WP_REST_Response { |
| 2000 | - if ( ! has_filter( 'f12_doi_rest_analytics_form' ) ) { | |
| 2001 | - return $this->addonInactive( 'analytics', 'Analytics' ); | |
| 1952 | + if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) { | |
| 1953 | + return new \WP_REST_Response( | |
| 1954 | + array( | |
| 1955 | + 'success' => false, | |
| 1956 | + 'message' => __( 'Pro version required.', 'double-opt-in' ), | |
| 1957 | + ), | |
| 1958 | + 403 | |
| 1959 | + ); | |
| 2002 | 1960 | } |
| 2003 | 1961 | |
| 2004 | 1962 | $formId = (int) $request->get_param( 'form_id' ); |
| 2005 | 1963 | $data = apply_filters( 'f12_doi_rest_analytics_form', array(), $formId, $request ); |
| @@ -2013,10 +1971,16 @@ | ||
| 2013 | 1971 | ); |
| 2014 | 1972 | } |
| 2015 | 1973 | |
| 2016 | 1974 | public function getOptoutSettings( \WP_REST_Request $request ): \WP_REST_Response { |
| 2017 | - if ( ! has_filter( 'f12_doi_rest_optout_settings' ) ) { | |
| 2018 | - return $this->addonInactive( 'opt-out', 'Opt-Out' ); | |
| 1975 | + if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) { | |
| 1976 | + return new \WP_REST_Response( | |
| 1977 | + array( | |
| 1978 | + 'success' => false, | |
| 1979 | + 'message' => __( 'Pro version required.', 'double-opt-in' ), | |
| 1980 | + ), | |
| 1981 | + 403 | |
| 1982 | + ); | |
| 2019 | 1983 | } |
| 2020 | 1984 | |
| 2021 | 1985 | $data = apply_filters( 'f12_doi_rest_optout_settings', array(), $request ); |
| 2022 | 1986 | |
| @@ -2029,10 +1993,16 @@ | ||
| 2029 | 1993 | ); |
| 2030 | 1994 | } |
| 2031 | 1995 | |
| 2032 | 1996 | public function updateOptoutSettings( \WP_REST_Request $request ): \WP_REST_Response { |
| 2033 | - if ( ! has_filter( 'f12_doi_rest_optout_settings_save' ) ) { | |
| 2034 | - return $this->addonInactive( 'opt-out', 'Opt-Out' ); | |
| 1997 | + if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) { | |
| 1998 | + return new \WP_REST_Response( | |
| 1999 | + array( | |
| 2000 | + 'success' => false, | |
| 2001 | + 'message' => __( 'Pro version required.', 'double-opt-in' ), | |
| 2002 | + ), | |
| 2003 | + 403 | |
| 2004 | + ); | |
| 2035 | 2005 | } |
| 2036 | 2006 | |
| 2037 | 2007 | $data = apply_filters( 'f12_doi_rest_optout_settings_save', array(), $request ); |
| 2038 | 2008 | |
| @@ -2047,32 +2017,140 @@ | ||
| 2047 | 2017 | |
| 2048 | 2018 | /** |
| 2049 | 2019 | * POST /f12-doi/v1/optout/page/generate |
| 2050 | 2020 | * |
| 2051 | - * One-click generator for the opt-out landing page. The logic lives in | |
| 2052 | - * the opt-out add-on (OptOutPageGenerator, 1.4.0+), which answers through | |
| 2053 | - * the filter below; Core only owns the route. | |
| 2021 | + * One-click generator for the opt-out landing page. Eliminates the | |
| 2022 | + * onboarding-friction loop where the user has to manually create a | |
| 2023 | + * page and paste the shortcodes before opt-out works at all. | |
| 2054 | 2024 | * |
| 2025 | + * Algorithm: | |
| 2026 | + * 1. Idempotent fast-path — scan `published` pages for the list | |
| 2027 | + * shortcode. If one already exists, return its ID untouched | |
| 2028 | + * (no duplicate creation, no content overwrite). | |
| 2029 | + * 2. Title-collision safety — if a page named "Opt-Out" exists | |
| 2030 | + * but WITHOUT the list shortcode, refuse to auto-modify. The | |
| 2031 | + * user might have intentionally repurposed that title; we'd | |
| 2032 | + * rather show a 409 with a clear message than clobber. | |
| 2033 | + * 3. Insert a fresh page with both shortcodes (form + list) so | |
| 2034 | + * the page is functional end-to-end out of the box. | |
| 2035 | + * | |
| 2036 | + * Response shape (always 200 unless error): | |
| 2037 | + * { page_id, page_title, edit_url, view_url, created: bool } | |
| 2038 | + * | |
| 2055 | 2039 | * @return \WP_REST_Response |
| 2056 | 2040 | */ |
| 2057 | 2041 | public function generateOptoutPage( \WP_REST_Request $request ): \WP_REST_Response { |
| 2058 | - if ( ! has_filter( 'f12_doi_rest_optout_generate_page' ) ) { | |
| 2059 | - return $this->addonInactive( 'opt-out', 'Opt-Out' ); | |
| 2042 | + if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) { | |
| 2043 | + return new \WP_REST_Response( | |
| 2044 | + array( | |
| 2045 | + 'success' => false, | |
| 2046 | + 'message' => __( 'Pro version required.', 'double-opt-in' ), | |
| 2047 | + ), | |
| 2048 | + 403 | |
| 2049 | + ); | |
| 2060 | 2050 | } |
| 2061 | 2051 | |
| 2062 | - /** | |
| 2063 | - * Filter: answer the opt-out page generator request. | |
| 2064 | - * | |
| 2065 | - * @param \WP_REST_Response|null $response Null until a handler answers. | |
| 2066 | - * @param \WP_REST_Request $request The request. | |
| 2067 | - * | |
| 2068 | - * @since 5.8.0 | |
| 2069 | - */ | |
| 2070 | - $response = apply_filters( 'f12_doi_rest_optout_generate_page', null, $request ); | |
| 2052 | + if ( ! current_user_can( 'publish_pages' ) ) { | |
| 2053 | + return new \WP_REST_Response( | |
| 2054 | + array( | |
| 2055 | + 'success' => false, | |
| 2056 | + 'message' => __( 'You do not have permission to create pages.', 'double-opt-in' ), | |
| 2057 | + ), | |
| 2058 | + 403 | |
| 2059 | + ); | |
| 2060 | + } | |
| 2071 | 2061 | |
| 2072 | - return $response instanceof \WP_REST_Response | |
| 2073 | - ? $response | |
| 2074 | - : $this->addonInactive( 'opt-out', 'Opt-Out' ); | |
| 2062 | + $listShortcode = '[f12-cf7-doubleoptin-optout-list]'; | |
| 2063 | + $formShortcode = '[f12-cf7-doubleoptin-optout-form]'; | |
| 2064 | + | |
| 2065 | + // 1. Idempotent fast-path — first page with the list shortcode wins. | |
| 2066 | + $existing = get_posts( | |
| 2067 | + array( | |
| 2068 | + 'post_type' => 'page', | |
| 2069 | + 'post_status' => 'publish', | |
| 2070 | + 'posts_per_page' => 1, | |
| 2071 | + 's' => $listShortcode, | |
| 2072 | + 'fields' => 'ids', | |
| 2073 | + 'no_found_rows' => true, | |
| 2074 | + ) | |
| 2075 | + ); | |
| 2076 | + if ( ! empty( $existing ) ) { | |
| 2077 | + $pageId = (int) $existing[0]; | |
| 2078 | + return new \WP_REST_Response( | |
| 2079 | + array( | |
| 2080 | + 'success' => true, | |
| 2081 | + 'created' => false, | |
| 2082 | + 'page_id' => $pageId, | |
| 2083 | + 'page_title' => get_the_title( $pageId ), | |
| 2084 | + 'edit_url' => get_edit_post_link( $pageId, 'raw' ), | |
| 2085 | + 'view_url' => get_permalink( $pageId ), | |
| 2086 | + 'message' => __( 'An existing opt-out page was selected.', 'double-opt-in' ), | |
| 2087 | + ), | |
| 2088 | + 200 | |
| 2089 | + ); | |
| 2090 | + } | |
| 2091 | + | |
| 2092 | + // 2. Title collision — a page literally titled "Opt-Out" but | |
| 2093 | + // without the shortcode is the user's own content. Refuse | |
| 2094 | + // to silently modify it. | |
| 2095 | + $desiredTitle = __( 'Opt-Out', 'double-opt-in' ); | |
| 2096 | + $collisionPage = get_page_by_path( sanitize_title( $desiredTitle ), OBJECT, 'page' ); | |
| 2097 | + // Plain null check, not instanceof: this replaces `?->ID`, which only | |
| 2098 | + // short-circuits on null and does not care about the concrete class. | |
| 2099 | + $collisionId = is_object( $collisionPage ) ? (int) $collisionPage->ID : 0; | |
| 2100 | + if ( $collisionId > 0 ) { | |
| 2101 | + return new \WP_REST_Response( | |
| 2102 | + array( | |
| 2103 | + 'success' => false, | |
| 2104 | + 'code' => 'TITLE_COLLISION', | |
| 2105 | + 'page_id' => $collisionId, | |
| 2106 | + 'edit_url' => get_edit_post_link( $collisionId, 'raw' ), | |
| 2107 | + 'message' => sprintf( | |
| 2108 | + /* translators: %s = page title */ | |
| 2109 | + __( 'A page titled "%s" already exists but doesn\'t contain the opt-out shortcode. Add the shortcode manually, or rename the page, then try again.', 'double-opt-in' ), | |
| 2110 | + $desiredTitle | |
| 2111 | + ), | |
| 2112 | + ), | |
| 2113 | + 409 | |
| 2114 | + ); | |
| 2115 | + } | |
| 2116 | + | |
| 2117 | + // 3. Insert. | |
| 2118 | + $pageId = wp_insert_post( | |
| 2119 | + array( | |
| 2120 | + 'post_type' => 'page', | |
| 2121 | + 'post_status' => 'publish', | |
| 2122 | + 'post_title' => $desiredTitle, | |
| 2123 | + 'post_content' => $formShortcode . "\n\n" . $listShortcode, | |
| 2124 | + 'post_author' => get_current_user_id(), | |
| 2125 | + 'comment_status' => 'closed', | |
| 2126 | + 'ping_status' => 'closed', | |
| 2127 | + ), | |
| 2128 | + true | |
| 2129 | + ); | |
| 2130 | + | |
| 2131 | + if ( is_wp_error( $pageId ) ) { | |
| 2132 | + return new \WP_REST_Response( | |
| 2133 | + array( | |
| 2134 | + 'success' => false, | |
| 2135 | + 'message' => $pageId->get_error_message(), | |
| 2136 | + ), | |
| 2137 | + 500 | |
| 2138 | + ); | |
| 2139 | + } | |
| 2140 | + | |
| 2141 | + return new \WP_REST_Response( | |
| 2142 | + array( | |
| 2143 | + 'success' => true, | |
| 2144 | + 'created' => true, | |
| 2145 | + 'page_id' => (int) $pageId, | |
| 2146 | + 'page_title' => $desiredTitle, | |
| 2147 | + 'edit_url' => get_edit_post_link( (int) $pageId, 'raw' ), | |
| 2148 | + 'view_url' => get_permalink( (int) $pageId ), | |
| 2149 | + 'message' => __( 'Opt-out page created and selected.', 'double-opt-in' ), | |
| 2150 | + ), | |
| 2151 | + 200 | |
| 2152 | + ); | |
| 2075 | 2153 | } |
| 2076 | 2154 | |
| 2077 | 2155 | /** |
| 2078 | 2156 | * License gate for the User Creation endpoints. |
| @@ -2092,9 +2170,15 @@ | ||
| 2092 | 2170 | } |
| 2093 | 2171 | |
| 2094 | 2172 | public function getUserCreationSettings( \WP_REST_Request $request ): \WP_REST_Response { |
| 2095 | 2173 | if ( ! $this->userCreationAuthorized() ) { |
| 2096 | - return $this->addonInactive( 'user-registration', 'User Registration' ); | |
| 2174 | + return new \WP_REST_Response( | |
| 2175 | + array( | |
| 2176 | + 'success' => false, | |
| 2177 | + 'message' => __( 'User Registration addon is not licensed for this site.', 'double-opt-in' ), | |
| 2178 | + ), | |
| 2179 | + 403 | |
| 2180 | + ); | |
| 2097 | 2181 | } |
| 2098 | 2182 | |
| 2099 | 2183 | $data = apply_filters( 'f12_doi_rest_user_creation_settings', array(), $request ); |
| 2100 | 2184 | |
| @@ -2108,9 +2192,15 @@ | ||
| 2108 | 2192 | } |
| 2109 | 2193 | |
| 2110 | 2194 | public function updateUserCreationSettings( \WP_REST_Request $request ): \WP_REST_Response { |
| 2111 | 2195 | if ( ! $this->userCreationAuthorized() ) { |
| 2112 | - return $this->addonInactive( 'user-registration', 'User Registration' ); | |
| 2196 | + return new \WP_REST_Response( | |
| 2197 | + array( | |
| 2198 | + 'success' => false, | |
| 2199 | + 'message' => __( 'User Registration addon is not licensed for this site.', 'double-opt-in' ), | |
| 2200 | + ), | |
| 2201 | + 403 | |
| 2202 | + ); | |
| 2113 | 2203 | } |
| 2114 | 2204 | |
| 2115 | 2205 | $data = apply_filters( 'f12_doi_rest_user_creation_settings_save', array(), $request ); |
| 2116 | 2206 | |
| @@ -2123,10 +2213,16 @@ | ||
| 2123 | 2213 | ); |
| 2124 | 2214 | } |
| 2125 | 2215 | |
| 2126 | 2216 | public function getApiSettings( \WP_REST_Request $request ): \WP_REST_Response { |
| 2127 | - if ( ! has_filter( 'f12_doi_rest_api_settings' ) ) { | |
| 2128 | - return $this->addonInactive( 'cleverreach', 'CleverReach' ); | |
| 2217 | + if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) { | |
| 2218 | + return new \WP_REST_Response( | |
| 2219 | + array( | |
| 2220 | + 'success' => false, | |
| 2221 | + 'message' => __( 'Pro version required.', 'double-opt-in' ), | |
| 2222 | + ), | |
| 2223 | + 403 | |
| 2224 | + ); | |
| 2129 | 2225 | } |
| 2130 | 2226 | |
| 2131 | 2227 | $data = apply_filters( 'f12_doi_rest_api_settings', array(), $request ); |
| 2132 | 2228 | |
| @@ -2139,10 +2235,16 @@ | ||
| 2139 | 2235 | ); |
| 2140 | 2236 | } |
| 2141 | 2237 | |
| 2142 | 2238 | public function updateApiSettings( \WP_REST_Request $request ): \WP_REST_Response { |
| 2143 | - if ( ! has_filter( 'f12_doi_rest_api_settings_save' ) ) { | |
| 2144 | - return $this->addonInactive( 'cleverreach', 'CleverReach' ); | |
| 2239 | + if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) { | |
| 2240 | + return new \WP_REST_Response( | |
| 2241 | + array( | |
| 2242 | + 'success' => false, | |
| 2243 | + 'message' => __( 'Pro version required.', 'double-opt-in' ), | |
| 2244 | + ), | |
| 2245 | + 403 | |
| 2246 | + ); | |
| 2145 | 2247 | } |
| 2146 | 2248 | |
| 2147 | 2249 | $data = apply_filters( 'f12_doi_rest_api_settings_save', array(), $request ); |
| 2148 | 2250 | |
| @@ -2236,26 +2338,45 @@ | ||
| 2236 | 2338 | |
| 2237 | 2339 | return new \WP_REST_Response( $result, $status ); |
| 2238 | 2340 | } |
| 2239 | 2341 | |
| 2342 | + public function exportDatabase( \WP_REST_Request $request ): \WP_REST_Response { | |
| 2343 | + if ( ! apply_filters( 'f12_doi_is_pro_active', false ) ) { | |
| 2344 | + return new \WP_REST_Response( | |
| 2345 | + array( | |
| 2346 | + 'success' => false, | |
| 2347 | + 'message' => __( 'Pro version required.', 'double-opt-in' ), | |
| 2348 | + ), | |
| 2349 | + 403 | |
| 2350 | + ); | |
| 2351 | + } | |
| 2352 | + | |
| 2353 | + $input = $request->get_json_params(); | |
| 2354 | + | |
| 2355 | + /** | |
| 2356 | + * Filter to let Pro handle database export. | |
| 2357 | + * | |
| 2358 | + * @param array $result Result. | |
| 2359 | + * @param array $input Export parameters. | |
| 2360 | + * @since 4.2.0 | |
| 2361 | + */ | |
| 2362 | + $result = apply_filters( | |
| 2363 | + 'f12_doi_rest_database_export', | |
| 2364 | + array( | |
| 2365 | + 'success' => false, | |
| 2366 | + 'message' => __( 'Export not available.', 'double-opt-in' ), | |
| 2367 | + ), | |
| 2368 | + $input | |
| 2369 | + ); | |
| 2370 | + | |
| 2371 | + return new \WP_REST_Response( $result, ( $result['success'] ?? false ) ? 200 : 400 ); | |
| 2372 | + } | |
| 2373 | + | |
| 2240 | 2374 | // ═══════════════════════════════════════════════════════════════ |
| 2241 | 2375 | // HELPERS |
| 2242 | 2376 | // ═══════════════════════════════════════════════════════════════ |
| 2243 | 2377 | |
| 2244 | 2378 | /** |
| 2245 | - * PHP form of REVOKED_SQL for a raw table row. | |
| 2246 | - * | |
| 2247 | - * @param array<string, mixed> $row The database row. | |
| 2248 | - */ | |
| 2249 | - private static function isRevokedRow( array $row ): bool { | |
| 2250 | - $optOutTime = (string) ( $row['optouttime'] ?? '' ); | |
| 2251 | - | |
| 2252 | - return (int) ( $row['doubleoptin'] ?? 0 ) !== 1 | |
| 2253 | - && (string) ( $row['ipaddr_optout'] ?? '' ) !== '' | |
| 2254 | - && $optOutTime !== '' && $optOutTime !== '0'; | |
| 2255 | - } | |
| 2256 | - | |
| 2257 | - /** | |
| 2258 | 2379 | * Format an opt-in database row for the API response. |
| 2259 | 2380 | * |
| 2260 | 2381 | * @param array $row The database row. |
| 2261 | 2382 | * @param bool $detailed Whether to include full detail (content, mail data). |
| @@ -2261,74 +2382,8 @@ | ||
| 2261 | 2382 | * @param bool $detailed Whether to include full detail (content, mail data). |
| 2262 | 2383 | * |
| 2263 | 2384 | * @return array Formatted data. |
| 2264 | 2385 | */ |
| 2265 | - /** | |
| 2266 | - * @param array<string, mixed> $row A database row. | |
| 2267 | - * | |
| 2268 | - * @return array{key:string, label:string}|null | |
| 2269 | - */ | |
| 2270 | - private function subscriptionOfRow( array $row ): ?array { | |
| 2271 | - $group = SubscriptionGroups::resolver()->groupForForm( | |
| 2272 | - (int) ( $row['cf_form_id'] ?? 0 ), | |
| 2273 | - (string) ( $row['form_ref'] ?? '' ) | |
| 2274 | - ); | |
| 2275 | - | |
| 2276 | - return $group === null ? null : array( | |
| 2277 | - 'key' => $group->getKey(), | |
| 2278 | - 'label' => $group->getLabel(), | |
| 2279 | - ); | |
| 2280 | - } | |
| 2281 | - | |
| 2282 | - /** | |
| 2283 | - * The other records of the same address that belong to the same | |
| 2284 | - * subscription group as the given record. | |
| 2285 | - * | |
| 2286 | - * @param array<string, mixed> $row A database row. | |
| 2287 | - * | |
| 2288 | - * @return array<int, array<string, mixed>> | |
| 2289 | - */ | |
| 2290 | - private function linkedOptIns( array $row ): array { | |
| 2291 | - $group = SubscriptionGroups::resolver()->groupForForm( | |
| 2292 | - (int) ( $row['cf_form_id'] ?? 0 ), | |
| 2293 | - (string) ( $row['form_ref'] ?? '' ) | |
| 2294 | - ); | |
| 2295 | - $email = (string) ( $row['email'] ?? '' ); | |
| 2296 | - if ( $group === null || $email === '' ) { | |
| 2297 | - return array(); | |
| 2298 | - } | |
| 2299 | - | |
| 2300 | - global $wpdb; | |
| 2301 | - $table = $wpdb->prefix . 'f12_cf7_doubleoptin'; | |
| 2302 | - list( $groupSql, $groupParams ) = $group->toSqlCondition(); | |
| 2303 | - | |
| 2304 | - // The group condition is built from fixed column names and `%d`/`%s` | |
| 2305 | - // placeholders only; every value travels in the parameter list. | |
| 2306 | - $found = $wpdb->get_results( | |
| 2307 | - $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- placeholders come from SubscriptionGroup::toSqlCondition(). | |
| 2308 | - "SELECT * FROM {$table} WHERE email = %s AND id <> %d AND {$groupSql} ORDER BY id DESC LIMIT 50", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared | |
| 2309 | - array_merge( array( $email, (int) $row['id'] ), $groupParams ) | |
| 2310 | - ), | |
| 2311 | - ARRAY_A | |
| 2312 | - ); | |
| 2313 | - | |
| 2314 | - $linked = array(); | |
| 2315 | - foreach ( (array) $found as $other ) { | |
| 2316 | - $data = $this->formatOptinRow( $other ); | |
| 2317 | - $linked[] = array( | |
| 2318 | - 'id' => $data['id'], | |
| 2319 | - 'formId' => $data['formId'], | |
| 2320 | - 'formName' => $data['formName'], | |
| 2321 | - 'formRef' => $data['formRef'], | |
| 2322 | - 'confirmed' => $data['confirmed'], | |
| 2323 | - 'revoked' => $data['revoked'], | |
| 2324 | - 'createtime' => $data['createtime'], | |
| 2325 | - ); | |
| 2326 | - } | |
| 2327 | - | |
| 2328 | - return $linked; | |
| 2329 | - } | |
| 2330 | - | |
| 2331 | 2386 | private function formatOptinRow( array $row, bool $detailed = false ): array { |
| 2332 | 2387 | $post = get_post( (int) $row['cf_form_id'] ); |
| 2333 | 2388 | |
| 2334 | 2389 | $data = array( |
| @@ -2338,22 +2393,12 @@ | ||
| 2338 | 2393 | 'formId' => (int) $row['cf_form_id'], |
| 2339 | 2394 | 'formName' => $post ? $post->post_title : sprintf( '#%d', $row['cf_form_id'] ), |
| 2340 | 2395 | 'category' => (int) $row['category'], |
| 2341 | 2396 | 'confirmed' => (int) $row['doubleoptin'] === 1, |
| 2342 | - // Consent withdrawn via the opt-out (5.9.0). Not "pending". | |
| 2343 | - 'revoked' => self::isRevokedRow( $row ), | |
| 2344 | - // Confirmation mail: 'sent' (handed to the mail server), 'failed', | |
| 2345 | - // or '' (recorded before 5.8.0). Since 5.8.0. | |
| 2346 | - 'mailStatus' => (string) ( $row['mail_status'] ?? '' ), | |
| 2347 | 2397 | 'createtime' => $this->toSiteLocalTime( $row['createtime'] ), |
| 2348 | 2398 | 'updatetime' => $this->toSiteLocalTime( $row['updatetime'] ), |
| 2349 | 2399 | ); |
| 2350 | 2400 | |
| 2351 | - // Instance inside the form id, e.g. the Elementor widget (5.12.0). | |
| 2352 | - $data['formRef'] = (string) ( $row['form_ref'] ?? '' ); | |
| 2353 | - // Subscription group of the record, or null (5.12.0). | |
| 2354 | - $data['subscription'] = $this->subscriptionOfRow( $row ); | |
| 2355 | - | |
| 2356 | 2401 | if ( $detailed ) { |
| 2357 | 2402 | $data['ipRegister'] = $row['ipaddr_register']; |
| 2358 | 2403 | $data['ipConfirmation'] = $row['ipaddr_confirmation']; |
| 2359 | 2404 | $data['ipOptout'] = $row['ipaddr_optout']; |
| @@ -2360,10 +2405,8 @@ | ||
| 2360 | 2405 | $data['optouttime'] = $this->toSiteLocalTime( $row['optouttime'] ); |
| 2361 | 2406 | $data['consentText'] = $row['consent_text']; |
| 2362 | 2407 | $data['consentField'] = $row['consent_field'] ?? ''; |
| 2363 | 2408 | $data['reminderSentAt'] = $this->toSiteLocalTime( $row['reminder_sent_at'] ); |
| 2364 | - $data['mailError'] = (string) ( $row['mail_error'] ?? '' ); | |
| 2365 | - $data['mailStatusAt'] = $this->toSiteLocalTime( (string) ( $row['mail_status_at'] ?? '' ) ); | |
| 2366 | 2409 | |
| 2367 | 2410 | // Category name |
| 2368 | 2411 | $cat = \forge12\contactform7\CF7DoubleOptIn\Category::get_by_id( (int) $row['category'] ); |
| 2369 | 2412 | $data['categoryName'] = $cat ? $cat->get_name() : null; |
| @@ -2583,10 +2626,10 @@ | ||
| 2583 | 2626 | // First pass: every registered addon gets an entry, even if |
| 2584 | 2627 | // it contributes no UI. That lets the client show per-addon |
| 2585 | 2628 | // licensing/boot state without a second round-trip. |
| 2586 | 2629 | foreach ( $registered as $id => $addon ) { |
| 2587 | - $fragment = is_array( $fragments[ $id ] ?? null ) ? $fragments[ $id ] : array(); | |
| 2588 | - $addons[ $id ] = $this->buildAddonEntry( $id, $addon, $fragment ); | |
| 2630 | + $fragment = is_array( $fragments[ $id ] ?? null ) ? $fragments[ $id ] : array(); | |
| 2631 | + $addons[ $id ] = $this->buildAddonEntry( $id, $addon, $fragment ); | |
| 2589 | 2632 | unset( $fragments[ $id ] ); |
| 2590 | 2633 | } |
| 2591 | 2634 | |
| 2592 | 2635 | // Second pass: fragments for addons NOT in the registry |
| @@ -2824,18 +2867,11 @@ | ||
| 2824 | 2867 | } |
| 2825 | 2868 | |
| 2826 | 2869 | $activateUrl = null; |
| 2827 | 2870 | if ( $installed && ! $active ) { |
| 2828 | - // Not wp_nonce_url(): it HTML-escapes & to &, and this URL | |
| 2829 | - // goes as JSON into an href — "plugin" and "_wpnonce" then | |
| 2830 | - // arrived as "amp;plugin" and the activation failed. | |
| 2831 | - $activateUrl = add_query_arg( | |
| 2832 | - array( | |
| 2833 | - 'action' => 'activate', | |
| 2834 | - 'plugin' => rawurlencode( $pluginFile ), | |
| 2835 | - '_wpnonce' => wp_create_nonce( 'activate-plugin_' . $pluginFile ), | |
| 2836 | - ), | |
| 2837 | - self_admin_url( 'plugins.php' ) | |
| 2871 | + $activateUrl = wp_nonce_url( | |
| 2872 | + self_admin_url( 'plugins.php?action=activate&plugin=' . rawurlencode( $pluginFile ) ), | |
| 2873 | + 'activate-plugin_' . $pluginFile | |
| 2838 | 2874 | ); |
| 2839 | 2875 | } |
| 2840 | 2876 | |
| 2841 | 2877 | $registeredAddon = $registered[ $id ] ?? null; |