PluginProbe
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification / 5.13.0
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification v5.13.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 +128 -0 5.9.0 → 5.13.0 View file →
@@ -17,8 +17,9 @@
17 17 use Forge12\DoubleOptIn\FormSettings\FormSettingsValidator;
18 18 use Forge12\DoubleOptIn\Integration\SubmittedContent;
19 19 use Forge12\DoubleOptIn\Service\ConfirmationMailResender;
20 20 use Forge12\DoubleOptIn\Service\ResendResult;
21 +use Forge12\DoubleOptIn\Subscription\SubscriptionGroups;
21 22 use Forge12\Shared\LoggerInterface;
22 23
23 24 if ( ! defined( 'ABSPATH' ) ) {
24 25 exit;
@@ -113,8 +114,19 @@
113 114 'permission_callback' => array( $this, 'checkPermission' ),
114 115 )
115 116 );
116 117
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 +
117 129 // ── Opt-Ins ────────────────────────────────────────────────
118 130 register_rest_route(
119 131 self::API_NAMESPACE,
120 132 '/optins',
@@ -703,8 +715,35 @@
703 715 // ═══════════════════════════════════════════════════════════════
704 716 // OPT-INS
705 717 // ═══════════════════════════════════════════════════════════════
706 718
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 +
707 746 public function getOptins( \WP_REST_Request $request ): \WP_REST_Response {
708 747 $page = max( 1, (int) $request->get_param( 'page' ) ?: 1 );
709 748 $perPage = max( 1, min( 100, (int) $request->get_param( 'per_page' ) ?: 20 ) );
710 749 $search = sanitize_text_field( $request->get_param( 'search' ) ?? '' );
@@ -742,8 +781,22 @@
742 781 $where[] = 'cf_form_id = %d';
743 782 $params[] = (int) $formId;
744 783 }
745 784
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 +
746 799 // Opt-ins whose confirmation mail could not be sent (5.8.0).
747 800 if ( sanitize_text_field( (string) ( $request->get_param( 'mail' ) ?? '' ) ) === 'failed' ) {
748 801 $where[] = 'mail_status = %s';
749 802 $params[] = \Forge12\DoubleOptIn\Repository\OptInMailStatusRepository::FAILED;
@@ -812,8 +865,12 @@
812 865 }
813 866
814 867 $data = $this->formatOptinRow( $row, true );
815 868
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 +
816 873 // Dev-mode UI hint: surface whether the reset-confirmation
817 874 // endpoint is reachable for this request, so the React detail
818 875 // page can show/hide the "Reset to pending" button without
819 876 // having to probe the endpoint and handle a 403. Mirrors
@@ -2204,8 +2261,74 @@
2204 2261 * @param bool $detailed Whether to include full detail (content, mail data).
2205 2262 *
2206 2263 * @return array Formatted data.
2207 2264 */
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 +
2208 2331 private function formatOptinRow( array $row, bool $detailed = false ): array {
2209 2332 $post = get_post( (int) $row['cf_form_id'] );
2210 2333
2211 2334 $data = array(
@@ -2223,8 +2346,13 @@
2223 2346 'mailStatus' => (string) ( $row['mail_status'] ?? '' ),
2224 2347 'createtime' => $this->toSiteLocalTime( $row['createtime'] ),
2225 2348 'updatetime' => $this->toSiteLocalTime( $row['updatetime'] ),
2226 2349 );
2350 +
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 );
2227 2355
2228 2356 if ( $detailed ) {
2229 2357 $data['ipRegister'] = $row['ipaddr_register'];
2230 2358 $data['ipConfirmation'] = $row['ipaddr_confirmation'];