| @@ -15,10 +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 | 19 | use Forge12\Shared\LoggerInterface; |
| 22 | 20 | |
| 23 | 21 | if ( ! defined( 'ABSPATH' ) ) { |
| 24 | 22 | exit; |
| @@ -32,14 +30,8 @@ | ||
| 32 | 30 | class AdminRestController { |
| 33 | 31 | |
| 34 | 32 | const API_NAMESPACE = 'f12-doi/v1'; |
| 35 | 33 | |
| 36 | - /** | |
| 37 | - * SQL form of OptIn::isOptedOut(): not confirmed, withdrawal IP and time | |
| 38 | - * recorded. A re-opt-in clears the time, so the row counts as confirmed again. | |
| 39 | - */ | |
| 40 | - 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'))"; | |
| 41 | - | |
| 42 | 34 | private LoggerInterface $logger; |
| 43 | 35 | private FormSettingsService $formService; |
| 44 | 36 | private FormSettingsValidator $formValidator; |
| 45 | 37 | |
| @@ -517,8 +509,19 @@ | ||
| 517 | 509 | 'permission_callback' => array( $this, 'checkPermission' ), |
| 518 | 510 | ) |
| 519 | 511 | ); |
| 520 | 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 | + | |
| 521 | 524 | // ── Addons manifest (UI mount-point system, plan §9) ──────── |
| 522 | 525 | register_rest_route( |
| 523 | 526 | self::API_NAMESPACE, |
| 524 | 527 | '/addons', |
| @@ -623,10 +626,9 @@ | ||
| 623 | 626 | $table = $wpdb->prefix . 'f12_cf7_doubleoptin'; |
| 624 | 627 | |
| 625 | 628 | $total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table}" ); |
| 626 | 629 | $confirmed = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table} WHERE doubleoptin = 1" ); |
| 627 | - $revoked = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table} WHERE " . self::REVOKED_SQL ); // phpcs:ignore WordPress.DB.PreparedSQL -- fixed SQL, no input. | |
| 628 | - $pending = max( 0, $total - $confirmed - $revoked ); | |
| 630 | + $pending = $total - $confirmed; | |
| 629 | 631 | $rate = $total > 0 ? round( ( $confirmed / $total ) * 100, 1 ) : 0; |
| 630 | 632 | |
| 631 | 633 | // Recent opt-ins (raw activity feed — not analytics). |
| 632 | 634 | // Time-bucketed activity, top-forms breakdown and the big |
| @@ -632,9 +634,9 @@ | ||
| 632 | 634 | // Time-bucketed activity, top-forms breakdown and the big |
| 633 | 635 | // conversion-rate card moved into addon-analytics, which |
| 634 | 636 | // renders them at the `dashboard.widget` mount point. |
| 635 | 637 | $recent = $wpdb->get_results( |
| 636 | - "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", | |
| 637 | 639 | ARRAY_A |
| 638 | 640 | ); |
| 639 | 641 | |
| 640 | 642 | foreach ( $recent as &$row ) { |
| @@ -640,18 +642,14 @@ | ||
| 640 | 642 | foreach ( $recent as &$row ) { |
| 641 | 643 | $post = get_post( (int) $row['cf_form_id'] ); |
| 642 | 644 | $row['formName'] = $post ? $post->post_title : sprintf( '#%d', $row['cf_form_id'] ); |
| 643 | 645 | $row['confirmed'] = (int) $row['doubleoptin'] === 1; |
| 644 | - $row['revoked'] = self::isRevokedRow( $row ); | |
| 645 | - unset( $row['ipaddr_optout'], $row['optouttime'] ); | |
| 646 | 646 | } |
| 647 | - unset( $row ); | |
| 648 | 647 | |
| 649 | 648 | $data = array( |
| 650 | 649 | 'totalOptins' => $total, |
| 651 | 650 | 'confirmed' => $confirmed, |
| 652 | 651 | 'pending' => $pending, |
| 653 | - 'revoked' => $revoked, | |
| 654 | 652 | 'conversionRate' => $rate, |
| 655 | 653 | 'recentOptins' => $recent ?: array(), |
| 656 | 654 | ); |
| 657 | 655 | |
| @@ -732,11 +730,8 @@ | ||
| 732 | 730 | if ( $status === 'confirmed' ) { |
| 733 | 731 | $where[] = 'doubleoptin = 1'; |
| 734 | 732 | } elseif ( $status === 'pending' ) { |
| 735 | 733 | $where[] = '(doubleoptin = 0 OR doubleoptin IS NULL)'; |
| 736 | - $where[] = 'NOT ' . self::REVOKED_SQL; | |
| 737 | - } elseif ( $status === 'revoked' ) { | |
| 738 | - $where[] = self::REVOKED_SQL; | |
| 739 | 734 | } |
| 740 | 735 | |
| 741 | 736 | if ( $formId !== null && $formId !== '' ) { |
| 742 | 737 | $where[] = 'cf_form_id = %d'; |
| @@ -742,24 +737,8 @@ | ||
| 742 | 737 | $where[] = 'cf_form_id = %d'; |
| 743 | 738 | $params[] = (int) $formId; |
| 744 | 739 | } |
| 745 | 740 | |
| 746 | - // Opt-ins whose confirmation mail could not be sent (5.8.0). | |
| 747 | - if ( sanitize_text_field( (string) ( $request->get_param( 'mail' ) ?? '' ) ) === 'failed' ) { | |
| 748 | - $where[] = 'mail_status = %s'; | |
| 749 | - $params[] = \Forge12\DoubleOptIn\Repository\OptInMailStatusRepository::FAILED; | |
| 750 | - } | |
| 751 | - | |
| 752 | - // Confirmed opt-ins whose follow-up actions failed or have an | |
| 753 | - // unknown outcome — the admin's "needs attention" list. | |
| 754 | - if ( sanitize_text_field( (string) ( $request->get_param( 'follow_up' ) ?? '' ) ) === 'problem' ) { | |
| 755 | - $followUpTable = $wpdb->prefix . \Forge12\DoubleOptIn\Repository\FollowUpSchema::TABLE_NAME; | |
| 756 | - $problems = \Forge12\DoubleOptIn\FollowUp\FollowUpStatus::problematic(); | |
| 757 | - $where[] = "EXISTS (SELECT 1 FROM {$followUpTable} fu WHERE fu.optin_id = {$table}.id AND fu.status IN (" | |
| 758 | - . implode( ', ', array_fill( 0, count( $problems ), '%s' ) ) . '))'; | |
| 759 | - $params = array_merge( $params, $problems ); | |
| 760 | - } | |
| 761 | - | |
| 762 | 741 | $whereClause = implode( ' AND ', $where ); |
| 763 | 742 | |
| 764 | 743 | // Count |
| 765 | 744 | $countQuery = "SELECT COUNT(*) FROM {$table} WHERE {$whereClause}"; |
| @@ -854,9 +833,9 @@ | ||
| 854 | 833 | // Full row (id, hash, content, files, cf_form_id) so the |
| 855 | 834 | // pre-delete cascade hook from pre-doi-data-retention Step 1 |
| 856 | 835 | // can fire with a payload that lets listeners reach into |
| 857 | 836 | // integration storage. ARRAY_A — listener-friendly. |
| 858 | - $row = $wpdb->get_row( | |
| 837 | + $row = $wpdb->get_row( | |
| 859 | 838 | $wpdb->prepare( "SELECT id, hash, content, files, cf_form_id FROM {$table} WHERE id = %d", $id ), |
| 860 | 839 | ARRAY_A |
| 861 | 840 | ); |
| 862 | 841 | $hash = is_array( $row ) ? ( $row['hash'] ?? null ) : null; |
| @@ -914,32 +893,8 @@ | ||
| 914 | 893 | 200 |
| 915 | 894 | ); |
| 916 | 895 | } |
| 917 | 896 | |
| 918 | - /** | |
| 919 | - * The admin's answer for a resend that did not go out. | |
| 920 | - */ | |
| 921 | - private static function resendRefusal( string $reason ): \WP_REST_Response { | |
| 922 | - $map = array( | |
| 923 | - ResendResult::NOT_FOUND => array( __( 'Opt-In not found.', 'double-opt-in' ), 404 ), | |
| 924 | - ResendResult::CONFIRMED => array( __( 'Opt-In is already confirmed.', 'double-opt-in' ), 400 ), | |
| 925 | - ResendResult::OPTED_OUT => array( __( 'This contact has opted out. The confirmation email is not sent again.', 'double-opt-in' ), 400 ), | |
| 926 | - ResendResult::NO_BODY => array( __( 'No email data available for resend.', 'double-opt-in' ), 400 ), | |
| 927 | - ResendResult::NO_RECIPIENT => array( __( 'Email data is incomplete.', 'double-opt-in' ), 400 ), | |
| 928 | - ); | |
| 929 | - $entry = $map[ $reason ] ?? array( __( 'Failed to send email.', 'double-opt-in' ), 500 ); | |
| 930 | - $message = $entry[0]; | |
| 931 | - $status = $entry[1]; | |
| 932 | - | |
| 933 | - return new \WP_REST_Response( | |
| 934 | - array( | |
| 935 | - 'success' => false, | |
| 936 | - 'message' => $message, | |
| 937 | - ), | |
| 938 | - $status | |
| 939 | - ); | |
| 940 | - } | |
| 941 | - | |
| 942 | 897 | public function resendOptinEmail( \WP_REST_Request $request ): \WP_REST_Response { |
| 943 | 898 | global $wpdb; |
| 944 | 899 | $id = (int) $request->get_param( 'id' ); |
| 945 | 900 | $table = $wpdb->prefix . 'f12_cf7_doubleoptin'; |
| @@ -979,23 +934,96 @@ | ||
| 979 | 934 | */ |
| 980 | 935 | $result = apply_filters( 'f12_doi_rest_resend_optin_email', null, $optin, $row ); |
| 981 | 936 | |
| 982 | 937 | if ( $result === null ) { |
| 983 | - $outcome = \Forge12\DoubleOptIn\Container\Container::getInstance() | |
| 984 | - ->get( ConfirmationMailResender::class ) | |
| 985 | - ->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 | + } | |
| 986 | 968 | |
| 987 | - if ( ! $outcome->isSent() ) { | |
| 988 | - 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 | + } | |
| 989 | 998 | } |
| 990 | - $result = true; | |
| 991 | - } else { | |
| 992 | - // An extension sent it; record the outcome all the same. | |
| 993 | - 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 ); | |
| 994 | 1016 | } |
| 995 | 1017 | |
| 996 | 1018 | if ( ! $result ) { |
| 997 | - 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 | + ); | |
| 998 | 1026 | } |
| 999 | 1027 | |
| 1000 | 1028 | AuditLogger::log( |
| 1001 | 1029 | AuditLogger::TYPE_EMAIL, |
| @@ -1892,41 +1920,22 @@ | ||
| 1892 | 1920 | ); |
| 1893 | 1921 | } |
| 1894 | 1922 | |
| 1895 | 1923 | // ═══════════════════════════════════════════════════════════════ |
| 1896 | - // ADD-ON ROUTES | |
| 1897 | - // Core owns the route; the data comes from the add-on through a | |
| 1898 | - // filter. Without a handler the answer is ADDON_INACTIVE. Core | |
| 1899 | - // itself never checks a licence here (wordpress.org guideline 5): | |
| 1900 | - // the functionality lives in the add-on, which only hooks in when | |
| 1901 | - // 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. | |
| 1902 | 1927 | // ═══════════════════════════════════════════════════════════════ |
| 1903 | 1928 | |
| 1904 | - /** | |
| 1905 | - * Answer for a route whose add-on is not running. | |
| 1906 | - * | |
| 1907 | - * 404 with `code` so the SPA can tell it from an unknown route | |
| 1908 | - * (`rest_no_route`); `ApiError` reads `body.code`. | |
| 1909 | - */ | |
| 1910 | - private function addonInactive( string $addonId, string $addonName ): \WP_REST_Response { | |
| 1911 | - return new \WP_REST_Response( | |
| 1912 | - array( | |
| 1913 | - 'success' => false, | |
| 1914 | - 'code' => 'ADDON_INACTIVE', | |
| 1915 | - 'addon' => $addonId, | |
| 1916 | - 'message' => sprintf( | |
| 1917 | - /* translators: %s: add-on name */ | |
| 1918 | - __( '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' ), | |
| 1919 | - $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' ), | |
| 1920 | 1935 | ), |
| 1921 | - ), | |
| 1922 | - 404 | |
| 1923 | - ); | |
| 1924 | - } | |
| 1925 | - | |
| 1926 | - public function getAnalyticsOverview( \WP_REST_Request $request ): \WP_REST_Response { | |
| 1927 | - if ( ! has_filter( 'f12_doi_rest_analytics_overview' ) ) { | |
| 1928 | - return $this->addonInactive( 'analytics', 'Analytics' ); | |
| 1936 | + 403 | |
| 1937 | + ); | |
| 1929 | 1938 | } |
| 1930 | 1939 | |
| 1931 | 1940 | $data = apply_filters( 'f12_doi_rest_analytics_overview', array(), $request ); |
| 1932 | 1941 | |
| @@ -1939,10 +1948,16 @@ | ||
| 1939 | 1948 | ); |
| 1940 | 1949 | } |
| 1941 | 1950 | |
| 1942 | 1951 | public function getAnalyticsForm( \WP_REST_Request $request ): \WP_REST_Response { |
| 1943 | - if ( ! has_filter( 'f12_doi_rest_analytics_form' ) ) { | |
| 1944 | - 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 | + ); | |
| 1945 | 1960 | } |
| 1946 | 1961 | |
| 1947 | 1962 | $formId = (int) $request->get_param( 'form_id' ); |
| 1948 | 1963 | $data = apply_filters( 'f12_doi_rest_analytics_form', array(), $formId, $request ); |
| @@ -1956,10 +1971,16 @@ | ||
| 1956 | 1971 | ); |
| 1957 | 1972 | } |
| 1958 | 1973 | |
| 1959 | 1974 | public function getOptoutSettings( \WP_REST_Request $request ): \WP_REST_Response { |
| 1960 | - if ( ! has_filter( 'f12_doi_rest_optout_settings' ) ) { | |
| 1961 | - 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 | + ); | |
| 1962 | 1983 | } |
| 1963 | 1984 | |
| 1964 | 1985 | $data = apply_filters( 'f12_doi_rest_optout_settings', array(), $request ); |
| 1965 | 1986 | |
| @@ -1972,10 +1993,16 @@ | ||
| 1972 | 1993 | ); |
| 1973 | 1994 | } |
| 1974 | 1995 | |
| 1975 | 1996 | public function updateOptoutSettings( \WP_REST_Request $request ): \WP_REST_Response { |
| 1976 | - if ( ! has_filter( 'f12_doi_rest_optout_settings_save' ) ) { | |
| 1977 | - 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 | + ); | |
| 1978 | 2005 | } |
| 1979 | 2006 | |
| 1980 | 2007 | $data = apply_filters( 'f12_doi_rest_optout_settings_save', array(), $request ); |
| 1981 | 2008 | |
| @@ -1990,32 +2017,140 @@ | ||
| 1990 | 2017 | |
| 1991 | 2018 | /** |
| 1992 | 2019 | * POST /f12-doi/v1/optout/page/generate |
| 1993 | 2020 | * |
| 1994 | - * One-click generator for the opt-out landing page. The logic lives in | |
| 1995 | - * the opt-out add-on (OptOutPageGenerator, 1.4.0+), which answers through | |
| 1996 | - * 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. | |
| 1997 | 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 | + * | |
| 1998 | 2039 | * @return \WP_REST_Response |
| 1999 | 2040 | */ |
| 2000 | 2041 | public function generateOptoutPage( \WP_REST_Request $request ): \WP_REST_Response { |
| 2001 | - if ( ! has_filter( 'f12_doi_rest_optout_generate_page' ) ) { | |
| 2002 | - 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 | + ); | |
| 2003 | 2050 | } |
| 2004 | 2051 | |
| 2005 | - /** | |
| 2006 | - * Filter: answer the opt-out page generator request. | |
| 2007 | - * | |
| 2008 | - * @param \WP_REST_Response|null $response Null until a handler answers. | |
| 2009 | - * @param \WP_REST_Request $request The request. | |
| 2010 | - * | |
| 2011 | - * @since 5.8.0 | |
| 2012 | - */ | |
| 2013 | - $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 | + } | |
| 2014 | 2061 | |
| 2015 | - return $response instanceof \WP_REST_Response | |
| 2016 | - ? $response | |
| 2017 | - : $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 | + ); | |
| 2018 | 2153 | } |
| 2019 | 2154 | |
| 2020 | 2155 | /** |
| 2021 | 2156 | * License gate for the User Creation endpoints. |
| @@ -2035,9 +2170,15 @@ | ||
| 2035 | 2170 | } |
| 2036 | 2171 | |
| 2037 | 2172 | public function getUserCreationSettings( \WP_REST_Request $request ): \WP_REST_Response { |
| 2038 | 2173 | if ( ! $this->userCreationAuthorized() ) { |
| 2039 | - 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 | + ); | |
| 2040 | 2181 | } |
| 2041 | 2182 | |
| 2042 | 2183 | $data = apply_filters( 'f12_doi_rest_user_creation_settings', array(), $request ); |
| 2043 | 2184 | |
| @@ -2051,9 +2192,15 @@ | ||
| 2051 | 2192 | } |
| 2052 | 2193 | |
| 2053 | 2194 | public function updateUserCreationSettings( \WP_REST_Request $request ): \WP_REST_Response { |
| 2054 | 2195 | if ( ! $this->userCreationAuthorized() ) { |
| 2055 | - 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 | + ); | |
| 2056 | 2203 | } |
| 2057 | 2204 | |
| 2058 | 2205 | $data = apply_filters( 'f12_doi_rest_user_creation_settings_save', array(), $request ); |
| 2059 | 2206 | |
| @@ -2066,10 +2213,16 @@ | ||
| 2066 | 2213 | ); |
| 2067 | 2214 | } |
| 2068 | 2215 | |
| 2069 | 2216 | public function getApiSettings( \WP_REST_Request $request ): \WP_REST_Response { |
| 2070 | - if ( ! has_filter( 'f12_doi_rest_api_settings' ) ) { | |
| 2071 | - 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 | + ); | |
| 2072 | 2225 | } |
| 2073 | 2226 | |
| 2074 | 2227 | $data = apply_filters( 'f12_doi_rest_api_settings', array(), $request ); |
| 2075 | 2228 | |
| @@ -2082,10 +2235,16 @@ | ||
| 2082 | 2235 | ); |
| 2083 | 2236 | } |
| 2084 | 2237 | |
| 2085 | 2238 | public function updateApiSettings( \WP_REST_Request $request ): \WP_REST_Response { |
| 2086 | - if ( ! has_filter( 'f12_doi_rest_api_settings_save' ) ) { | |
| 2087 | - 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 | + ); | |
| 2088 | 2247 | } |
| 2089 | 2248 | |
| 2090 | 2249 | $data = apply_filters( 'f12_doi_rest_api_settings_save', array(), $request ); |
| 2091 | 2250 | |
| @@ -2179,26 +2338,45 @@ | ||
| 2179 | 2338 | |
| 2180 | 2339 | return new \WP_REST_Response( $result, $status ); |
| 2181 | 2340 | } |
| 2182 | 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 | + | |
| 2183 | 2374 | // ═══════════════════════════════════════════════════════════════ |
| 2184 | 2375 | // HELPERS |
| 2185 | 2376 | // ═══════════════════════════════════════════════════════════════ |
| 2186 | 2377 | |
| 2187 | 2378 | /** |
| 2188 | - * PHP form of REVOKED_SQL for a raw table row. | |
| 2189 | - * | |
| 2190 | - * @param array<string, mixed> $row The database row. | |
| 2191 | - */ | |
| 2192 | - private static function isRevokedRow( array $row ): bool { | |
| 2193 | - $optOutTime = (string) ( $row['optouttime'] ?? '' ); | |
| 2194 | - | |
| 2195 | - return (int) ( $row['doubleoptin'] ?? 0 ) !== 1 | |
| 2196 | - && (string) ( $row['ipaddr_optout'] ?? '' ) !== '' | |
| 2197 | - && $optOutTime !== '' && $optOutTime !== '0'; | |
| 2198 | - } | |
| 2199 | - | |
| 2200 | - /** | |
| 2201 | 2379 | * Format an opt-in database row for the API response. |
| 2202 | 2380 | * |
| 2203 | 2381 | * @param array $row The database row. |
| 2204 | 2382 | * @param bool $detailed Whether to include full detail (content, mail data). |
| @@ -2215,13 +2393,8 @@ | ||
| 2215 | 2393 | 'formId' => (int) $row['cf_form_id'], |
| 2216 | 2394 | 'formName' => $post ? $post->post_title : sprintf( '#%d', $row['cf_form_id'] ), |
| 2217 | 2395 | 'category' => (int) $row['category'], |
| 2218 | 2396 | 'confirmed' => (int) $row['doubleoptin'] === 1, |
| 2219 | - // Consent withdrawn via the opt-out (5.9.0). Not "pending". | |
| 2220 | - 'revoked' => self::isRevokedRow( $row ), | |
| 2221 | - // Confirmation mail: 'sent' (handed to the mail server), 'failed', | |
| 2222 | - // or '' (recorded before 5.8.0). Since 5.8.0. | |
| 2223 | - 'mailStatus' => (string) ( $row['mail_status'] ?? '' ), | |
| 2224 | 2397 | 'createtime' => $this->toSiteLocalTime( $row['createtime'] ), |
| 2225 | 2398 | 'updatetime' => $this->toSiteLocalTime( $row['updatetime'] ), |
| 2226 | 2399 | ); |
| 2227 | 2400 | |
| @@ -2232,10 +2405,8 @@ | ||
| 2232 | 2405 | $data['optouttime'] = $this->toSiteLocalTime( $row['optouttime'] ); |
| 2233 | 2406 | $data['consentText'] = $row['consent_text']; |
| 2234 | 2407 | $data['consentField'] = $row['consent_field'] ?? ''; |
| 2235 | 2408 | $data['reminderSentAt'] = $this->toSiteLocalTime( $row['reminder_sent_at'] ); |
| 2236 | - $data['mailError'] = (string) ( $row['mail_error'] ?? '' ); | |
| 2237 | - $data['mailStatusAt'] = $this->toSiteLocalTime( (string) ( $row['mail_status_at'] ?? '' ) ); | |
| 2238 | 2409 | |
| 2239 | 2410 | // Category name |
| 2240 | 2411 | $cat = \forge12\contactform7\CF7DoubleOptIn\Category::get_by_id( (int) $row['category'] ); |
| 2241 | 2412 | $data['categoryName'] = $cat ? $cat->get_name() : null; |
| @@ -2455,10 +2626,10 @@ | ||
| 2455 | 2626 | // First pass: every registered addon gets an entry, even if |
| 2456 | 2627 | // it contributes no UI. That lets the client show per-addon |
| 2457 | 2628 | // licensing/boot state without a second round-trip. |
| 2458 | 2629 | foreach ( $registered as $id => $addon ) { |
| 2459 | - $fragment = is_array( $fragments[ $id ] ?? null ) ? $fragments[ $id ] : array(); | |
| 2460 | - $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 ); | |
| 2461 | 2632 | unset( $fragments[ $id ] ); |
| 2462 | 2633 | } |
| 2463 | 2634 | |
| 2464 | 2635 | // Second pass: fragments for addons NOT in the registry |
| @@ -2696,18 +2867,11 @@ | ||
| 2696 | 2867 | } |
| 2697 | 2868 | |
| 2698 | 2869 | $activateUrl = null; |
| 2699 | 2870 | if ( $installed && ! $active ) { |
| 2700 | - // Not wp_nonce_url(): it HTML-escapes & to &, and this URL | |
| 2701 | - // goes as JSON into an href — "plugin" and "_wpnonce" then | |
| 2702 | - // arrived as "amp;plugin" and the activation failed. | |
| 2703 | - $activateUrl = add_query_arg( | |
| 2704 | - array( | |
| 2705 | - 'action' => 'activate', | |
| 2706 | - 'plugin' => rawurlencode( $pluginFile ), | |
| 2707 | - '_wpnonce' => wp_create_nonce( 'activate-plugin_' . $pluginFile ), | |
| 2708 | - ), | |
| 2709 | - 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 | |
| 2710 | 2874 | ); |
| 2711 | 2875 | } |
| 2712 | 2876 | |
| 2713 | 2877 | $registeredAddon = $registered[ $id ] ?? null; |