PluginProbe
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification / 5.5.0
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification v5.5.0
5.12.0 5.13.0 5.13.1 5.11.0 5.10.0 5.9.0 5.8.0 5.8.1 5.7.0 5.6.2 5.6.3 5.6.1 5.6.0 5.5.0 5.4.0 5.3.2 5.3.1 5.1.6 5.1.5 trunk 2.1.5 2.11 2.12 2.13 2.15 All 47 releases
← All changes | src/Admin/AdminRestController.php +324 -288 5.13.1 → 5.5.0 View file →
@@ -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 &amp;, 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;