PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.1.1
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.1.1
2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.10.0 All 48 releases
thinkrank / includes / api / class-email-report-endpoint.php

class-email-report-endpoint.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.1.1, at includes/api/class-email-report-endpoint.php

262 lines 9.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Email Report REST Endpoint
4 *
5 * Three routes:
6 * GET /thinkrank/v1/email-report/config — returns config + capability map + section catalog
7 * POST /thinkrank/v1/email-report/config — saves config (sanitized + capability-clamped)
8 * POST /thinkrank/v1/email-report/test-send — triggers an immediate one-off send
9 *
10 * Permissions: admin (`manage_options`) + valid REST nonce. Pro-only
11 * fields submitted on a free plan are silently dropped by
12 * Email_Report_Config::sanitize() — we don't 403 on those, since the
13 * client may not know its current capabilities yet (e.g. mid-downgrade).
14 *
15 * @package ThinkRank
16 * @subpackage API
17 * @since 1.9.0
18 */
19
20 declare(strict_types=1);
21
22 namespace ThinkRank\API;
23
24 use ThinkRank\API\Traits\CSRF_Protection;
25 use ThinkRank\Core\Plan_Config;
26 use ThinkRank\SEO\Email_Report_Manager;
27 use WP_REST_Controller;
28 use WP_REST_Request;
29 use WP_REST_Response;
30 use WP_Error;
31
32 require_once THINKRANK_PLUGIN_DIR . 'includes/api/traits/trait-csrf-protection.php';
33
34 if (!defined('ABSPATH')) {
35 exit;
36 }
37
38 /**
39 * Email_Report_Endpoint
40 *
41 * @since 1.9.0
42 */
43 final class Email_Report_Endpoint extends WP_REST_Controller {
44 use CSRF_Protection;
45
46 protected $namespace = 'thinkrank/v1';
47 protected $rest_base = 'email-report';
48
49 private ?Email_Report_Manager $manager = null;
50
51 public function register_routes(): void {
52 register_rest_route(
53 $this->namespace,
54 '/' . $this->rest_base . '/config',
55 [
56 [
57 'methods' => 'GET',
58 'callback' => [$this, 'get_config'],
59 'permission_callback' => [$this, 'check_admin_read_permissions'],
60 ],
61 [
62 'methods' => 'POST',
63 'callback' => [$this, 'save_config'],
64 'permission_callback' => [$this, 'check_admin_csrf_permissions'],
65 'args' => $this->save_args(),
66 ],
67 ]
68 );
69
70 register_rest_route(
71 $this->namespace,
72 '/' . $this->rest_base . '/test-send',
73 [
74 [
75 'methods' => 'POST',
76 'callback' => [$this, 'test_send'],
77 'permission_callback' => [$this, 'check_admin_csrf_permissions'],
78 ],
79 ]
80 );
81 }
82
83 /**
84 * GET /email-report/config
85 *
86 * Returns the current per-site config along with the plan's capability
87 * map and the section catalog so the React panel can render the right
88 * fields without a second round-trip.
89 */
90 public function get_config(WP_REST_Request $request): WP_REST_Response {
91 $manager = $this->resolve_manager();
92 if ($manager === null) {
93 return new WP_REST_Response([
94 'error' => __('Email Report Manager unavailable.', 'thinkrank'),
95 ], 500);
96 }
97
98 return new WP_REST_Response([
99 'config' => $manager->config()->get(),
100 'capabilities' => Plan_Config::email_report(),
101 'sections' => $manager->registry()->describe_for_ui(),
102 'next_run' => $manager->scheduler()->next_run_iso(),
103 'tokens' => $this->supported_tokens(),
104 ]);
105 }
106
107 /**
108 * POST /email-report/config
109 */
110 public function save_config(WP_REST_Request $request): WP_REST_Response {
111 $manager = $this->resolve_manager();
112 if ($manager === null) {
113 return new WP_REST_Response([
114 'success' => false,
115 'message' => __('Email Report Manager unavailable.', 'thinkrank'),
116 ], 500);
117 }
118
119 $input = $request->get_json_params();
120 if (!is_array($input)) {
121 $input = $request->get_params();
122 }
123
124 $saved = $manager->config()->save(is_array($input) ? $input : []);
125
126 return new WP_REST_Response([
127 'success' => true,
128 'config' => $saved,
129 'capabilities' => Plan_Config::email_report(),
130 'next_run' => $manager->scheduler()->next_run_iso(),
131 ]);
132 }
133
134 /**
135 * POST /email-report/test-send
136 */
137 public function test_send(WP_REST_Request $request): WP_REST_Response {
138 $manager = $this->resolve_manager();
139 if ($manager === null) {
140 return new WP_REST_Response([
141 'success' => false,
142 'message' => __('Email Report Manager unavailable.', 'thinkrank'),
143 ], 500);
144 }
145
146 $result = $manager->generator()->generate_test();
147
148 $status = !empty($result['success']) ? 200 : 400;
149 return new WP_REST_Response([
150 'success' => (bool) ($result['success'] ?? false),
151 'result' => $result,
152 ], $status);
153 }
154
155 /**
156 * Permission for read endpoints. Same admin gate, but no CSRF
157 * (GET requests don't require it).
158 */
159 public function check_admin_read_permissions(WP_REST_Request $request) {
160 if (!is_user_logged_in()) {
161 return new WP_Error('rest_forbidden', __('Not logged in.', 'thinkrank'), ['status' => 401]);
162 }
163 if (!current_user_can('manage_options')) {
164 return new WP_Error('rest_forbidden', __('Insufficient permissions.', 'thinkrank'), ['status' => 403]);
165 }
166 return true;
167 }
168
169 /**
170 * Permission for the state-changing POST endpoints (save config / test-send).
171 *
172 * These write the site-global report config and can trigger a send of private
173 * analytics, so they require admin (manage_options) plus CSRF verification —
174 * NOT the shared edit_posts-level check_csrf_permissions() trait, which would
175 * let a Contributor overwrite the config and exfiltrate the report. Matches the
176 * manage_options gate on the GET route.
177 */
178 public function check_admin_csrf_permissions(WP_REST_Request $request) {
179 if (!is_user_logged_in()) {
180 return new WP_Error('rest_forbidden', __('Not logged in.', 'thinkrank'), ['status' => 401]);
181 }
182 if (!current_user_can('manage_options')) {
183 return new WP_Error('rest_forbidden', __('Insufficient permissions.', 'thinkrank'), ['status' => 403]);
184 }
185 if (!$this->verify_request_nonce($request)) {
186 return new WP_Error('rest_forbidden', __('Invalid security token. Please refresh the page and try again.', 'thinkrank'), ['status' => 403]);
187 }
188 return true;
189 }
190
191 /**
192 * Reach into the plugin DI container for the Email_Report_Manager
193 * instance built at boot. Falls back to creating one on demand if
194 * the function doesn't exist yet (defensive — shouldn't happen).
195 */
196 private function resolve_manager(): ?Email_Report_Manager {
197 if ($this->manager !== null) {
198 return $this->manager;
199 }
200 if (function_exists('thinkrank')) {
201 $component = thinkrank()->get_component('email_report');
202 if ($component instanceof Email_Report_Manager) {
203 $this->manager = $component;
204 return $this->manager;
205 }
206 }
207 return null;
208 }
209
210 /**
211 * REST args: permissive on type so we accept the full config object
212 * the panel sends back (including nulls for paid fields the user
213 * isn't allowed to set). Heavy sanitization happens in
214 * Email_Report_Config::sanitize() so the cron path benefits too.
215 *
216 * Don't add `sanitize_callback` here for nullable fields — the
217 * sanitized value is what reaches the handler, and
218 * `esc_url_raw(null)` coerces to '', defeating the point of
219 * preserving "unset". (WP_REST_Server::respond_to_request runs
220 * has_valid_params() first and sanitize_params() second, so the
221 * ['string','null'] type above is what admits the null; sanitizing
222 * afterwards would throw it away.)
223 */
224 private function save_args(): array {
225 $nullable_string = ['type' => ['string', 'null']];
226 return [
227 'enabled' => [
228 'type' => 'boolean',
229 'sanitize_callback' => 'rest_sanitize_boolean',
230 ],
231 'frequency_days' => [
232 'type' => 'integer',
233 'sanitize_callback' => 'absint',
234 ],
235 'recipients' => [
236 'type' => ['array', 'string', 'null'],
237 ],
238 'subject_template' => $nullable_string,
239 'logo_url' => $nullable_string,
240 'logo_link' => $nullable_string,
241 'header_background' => $nullable_string,
242 'link_to_full_report' => [
243 'type' => 'boolean',
244 'sanitize_callback' => 'rest_sanitize_boolean',
245 ],
246 'intro_text' => $nullable_string,
247 'sections_enabled' => [
248 'type' => ['array', 'null'],
249 ],
250 'footer_text' => $nullable_string,
251 'additional_css' => $nullable_string,
252 ];
253 }
254
255 private function supported_tokens(): array {
256 if (!function_exists('thinkrank_get_email_report_tokens')) {
257 require_once THINKRANK_PLUGIN_DIR . 'includes/config/email-report-settings-config.php';
258 }
259 return thinkrank_get_email_report_tokens();
260 }
261 }
262