PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 1.26.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v1.26.0
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 / seo / class-email-report-config.php

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

392 lines 14.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Email Report Config
4 *
5 * Persistence layer for the per-site Email Reporting settings. Stores a
6 * single associative array under the `thinkrank_email_report_config` option
7 * — one row per site is enough; we don't shard by user. Values are
8 * sanitized at the boundary and capability-clamped via Plan_Config so a
9 * free plan never accidentally persists Pro values that don't belong.
10 *
11 * Pro plugin can extend the saved schema by hooking
12 * `thinkrank_email_report_config_schema` (added fields are sanitized
13 * if a callback is provided).
14 *
15 * @package ThinkRank
16 * @subpackage SEO
17 * @since 1.9.0
18 */
19
20 declare(strict_types=1);
21
22 namespace ThinkRank\SEO;
23
24 use ThinkRank\Core\Plan_Config;
25
26 if (!defined('ABSPATH')) {
27 exit;
28 }
29
30 /**
31 * Email_Report_Config
32 *
33 * @since 1.9.0
34 */
35 final class Email_Report_Config {
36
37 private const OPTION_KEY = 'thinkrank_email_report_config';
38
39 /**
40 * Load defaults helper. Lazy-loads the config defaults file because
41 * it lives outside the autoloader path (it's procedural functions).
42 */
43 private function defaults(): array {
44 if (!function_exists('thinkrank_get_default_email_report_config')) {
45 require_once THINKRANK_PLUGIN_DIR . 'includes/config/email-report-settings-config.php';
46 }
47 return thinkrank_get_default_email_report_config();
48 }
49
50 /**
51 * Read the current config. Always merges over defaults so newly added
52 * keys (e.g. after a plugin update) are populated even on existing sites.
53 */
54 public function get(): array {
55 $stored = get_option(self::OPTION_KEY, []);
56 if (!is_array($stored)) {
57 $stored = [];
58 }
59 return array_merge($this->defaults(), $stored);
60 }
61
62 /**
63 * Save the config. Returns the post-sanitize array that was persisted
64 * so callers can echo it back to the client and avoid a second read.
65 *
66 * Sanitization happens here, not in the REST args layer — the REST
67 * layer accepts intent, this layer enforces invariants. That way the
68 * cron-driven path (which doesn't go through REST) gets the same guarantees.
69 */
70 public function save(array $input): array {
71 $sanitized = $this->sanitize($input);
72
73 // Defense-in-depth: re-clamp at save time even though sanitize() also clamps.
74 $sanitized['frequency_days'] = Plan_Config::clamp_email_report_frequency((int) $sanitized['frequency_days']);
75 $sanitized['recipients'] = Plan_Config::clamp_email_report_recipients($sanitized['recipients']);
76
77 // Seed next_scheduled_at on first enable so the UI shows a real
78 // "Next report" date immediately. The scheduler still re-seeds on
79 // its first tick for any other path that flips enable on.
80 if ($sanitized['enabled'] && empty($sanitized['next_scheduled_at'])) {
81 $sanitized['next_scheduled_at'] = wp_date(
82 'Y-m-d H:i:s',
83 strtotime('+' . max(1, (int) $sanitized['frequency_days']) . ' days')
84 );
85 }
86
87 update_option(self::OPTION_KEY, $sanitized, false);
88
89 /**
90 * Fires after Email Report config is saved.
91 *
92 * Pro plugin uses this to re-validate its own added fields, refresh
93 * an audit table, or trigger a re-schedule.
94 *
95 * @since 1.9.0
96 *
97 * @param array $sanitized The persisted config.
98 */
99 do_action('thinkrank_email_report_settings_saved', $sanitized);
100
101 return $sanitized;
102 }
103
104 /**
105 * Pure sanitization — no DB writes. Useful for previews and tests.
106 *
107 * Free vs. Pro behavior: Pro-only fields are accepted into the array
108 * even on free, but their values are coerced to defaults if the user
109 * isn't allowed to set them. Why keep them at all? So if the user
110 * upgrades, their previously-saved values aren't lost.
111 */
112 public function sanitize(array $input): array {
113 $defaults = $this->defaults();
114 $caps = Plan_Config::email_report();
115 $limits = function_exists('thinkrank_get_email_report_field_limits')
116 ? thinkrank_get_email_report_field_limits()
117 : [];
118
119 // Existing stored values are the baseline — partial updates (e.g.
120 // a toggle-only POST or a Pro field added later) merge over the
121 // saved config rather than reverting unsupplied keys to defaults.
122 $stored = get_option(self::OPTION_KEY, []);
123 if (!is_array($stored)) {
124 $stored = [];
125 }
126 $existing = array_merge($defaults, $stored);
127
128 $clean = [];
129
130 $clean['enabled'] = isset($input['enabled'])
131 ? !empty($input['enabled'])
132 : (bool) $existing['enabled'];
133
134 $clean['frequency_days'] = Plan_Config::clamp_email_report_frequency(
135 isset($input['frequency_days']) ? (int) $input['frequency_days'] : (int) $existing['frequency_days']
136 );
137
138 $clean['recipients'] = Plan_Config::clamp_email_report_recipients(
139 $this->normalize_recipients($input['recipients'] ?? $existing['recipients'])
140 );
141
142 // Subject: free plan always uses the default. Pro: keep existing
143 // when input doesn't include the key, accept new when it does.
144 if (!empty($caps['custom_subject']) && array_key_exists('subject_template', $input)) {
145 $subject = sanitize_text_field((string) $input['subject_template']);
146 if ($subject === '') {
147 $subject = (string) $defaults['subject_template'];
148 }
149 } elseif (!empty($caps['custom_subject'])) {
150 $subject = (string) $existing['subject_template'];
151 } else {
152 $subject = (string) $defaults['subject_template'];
153 }
154 $clean['subject_template'] = $this->trim_to($subject, $limits['subject_template'] ?? 200);
155
156 // Logo URL: free plan stays null. Pro: only overwrite when the key
157 // is present in input (so partial updates don't blank the logo).
158 $clean['logo_url'] = $this->resolve_optional_url(
159 $caps,
160 'custom_logo',
161 $input,
162 'logo_url',
163 $existing['logo_url'] ?? null,
164 $limits['logo_url'] ?? 2048
165 );
166
167 $clean['logo_link'] = $this->resolve_optional_url(
168 $caps,
169 'logo_link',
170 $input,
171 'logo_link',
172 $existing['logo_link'] ?? null,
173 $limits['logo_link'] ?? 2048
174 );
175
176 $clean['header_background'] = $this->resolve_optional_text(
177 $caps,
178 'header_background',
179 $input,
180 'header_background',
181 $existing['header_background'] ?? null,
182 $limits['header_background'] ?? 500
183 );
184
185 // Free is forced to the default toggle (true) so the dashboard CTA
186 // still appears. Pro: prefer input, fall back to existing, then default.
187 $clean['link_to_full_report'] = empty($caps['link_to_full_report'])
188 ? (bool) $defaults['link_to_full_report']
189 : (
190 array_key_exists('link_to_full_report', $input)
191 ? (bool) $input['link_to_full_report']
192 : (bool) $existing['link_to_full_report']
193 );
194
195 $clean['intro_text'] = $this->resolve_optional_rich_text(
196 $caps,
197 'intro_text',
198 $input,
199 'intro_text',
200 $existing['intro_text'] ?? null,
201 $limits['intro_text'] ?? 5000
202 );
203
204 $clean['footer_text'] = $this->resolve_optional_rich_text(
205 $caps,
206 'footer_text',
207 $input,
208 'footer_text',
209 $existing['footer_text'] ?? null,
210 $limits['footer_text'] ?? 5000
211 );
212
213 $clean['additional_css'] = empty($caps['additional_css'])
214 ? null
215 : (
216 array_key_exists('additional_css', $input)
217 ? $this->sanitize_css($input['additional_css'], $limits['additional_css'] ?? 20000)
218 : ($existing['additional_css'] ?? null)
219 );
220
221 // Sections: Free is locked to all-on. Pro user submits the list,
222 // falling back to existing when the key is missing.
223 if (empty($caps['sections_configurable'])) {
224 $clean['sections_enabled'] = (array) $defaults['sections_enabled'];
225 } elseif (array_key_exists('sections_enabled', $input)) {
226 $clean['sections_enabled'] = $this->sanitize_section_keys($input['sections_enabled']);
227 } else {
228 $clean['sections_enabled'] = (array) $existing['sections_enabled'];
229 }
230
231 // Schedule timestamps are server-managed — never trust client input.
232 $clean['next_scheduled_at'] = $stored['next_scheduled_at'] ?? null;
233 $clean['last_sent_at'] = $stored['last_sent_at'] ?? null;
234
235 /**
236 * Filter the sanitized config before persistence.
237 *
238 * Pro plugin uses this to sanitize fields it has added via
239 * `thinkrank_email_report_config_schema`. The filter receives the
240 * raw input alongside the sanitized output so consumers can read
241 * pro-only field intent without re-parsing the request.
242 *
243 * @since 1.9.0
244 *
245 * @param array $clean Sanitized config so far.
246 * @param array $input Raw input as received.
247 */
248 return apply_filters('thinkrank_email_report_config_sanitized', $clean, $input);
249 }
250
251 /**
252 * Update only the schedule timestamps. Called from the scheduler after
253 * a successful send so we don't round-trip the whole sanitize() flow
254 * (the rest of the config hasn't changed).
255 */
256 public function update_schedule(?string $last_sent_at, ?string $next_scheduled_at): array {
257 $current = $this->get();
258 $current['last_sent_at'] = $last_sent_at;
259 $current['next_scheduled_at'] = $next_scheduled_at;
260 update_option(self::OPTION_KEY, $current, false);
261 return $current;
262 }
263
264 /**
265 * Normalize a recipient input that might arrive as a string
266 * ("a@x.com, b@x.com") or as an array.
267 *
268 * @param mixed $raw
269 * @return string[]
270 */
271 private function normalize_recipients($raw): array {
272 if (is_string($raw)) {
273 $raw = preg_split('/[\s,;]+/', $raw) ?: [];
274 }
275 if (!is_array($raw)) {
276 return [];
277 }
278 $emails = [];
279 foreach ($raw as $candidate) {
280 if (!is_string($candidate)) {
281 continue;
282 }
283 $candidate = sanitize_email(trim($candidate));
284 if ($candidate !== '' && is_email($candidate)) {
285 $emails[] = strtolower($candidate);
286 }
287 }
288 return array_values(array_unique($emails));
289 }
290
291 /**
292 * Resolve an optional URL field with partial-update semantics.
293 * Free plan: always null. Pro: prefer input, fall back to existing.
294 */
295 private function resolve_optional_url(array $caps, string $cap_key, array $input, string $field, $existing, int $max_len): ?string {
296 if (empty($caps[$cap_key])) {
297 return null;
298 }
299 if (array_key_exists($field, $input)) {
300 return $this->sanitize_url($input[$field], $max_len);
301 }
302 return is_string($existing) && $existing !== '' ? $existing : null;
303 }
304
305 private function resolve_optional_text(array $caps, string $cap_key, array $input, string $field, $existing, int $max_len): ?string {
306 if (empty($caps[$cap_key])) {
307 return null;
308 }
309 if (array_key_exists($field, $input)) {
310 $raw = $input[$field];
311 return is_string($raw)
312 ? $this->trim_to(sanitize_text_field($raw), $max_len)
313 : null;
314 }
315 return is_string($existing) && $existing !== '' ? $existing : null;
316 }
317
318 private function resolve_optional_rich_text(array $caps, string $cap_key, array $input, string $field, $existing, int $max_len): ?string {
319 if (empty($caps[$cap_key])) {
320 return null;
321 }
322 if (array_key_exists($field, $input)) {
323 return $this->sanitize_rich_text($input[$field], $max_len);
324 }
325 return is_string($existing) && $existing !== '' ? $existing : null;
326 }
327
328 private function sanitize_url($raw, int $max_len): ?string {
329 if (!is_string($raw) || $raw === '') {
330 return null;
331 }
332 $url = esc_url_raw(trim($raw));
333 if ($url === '') {
334 return null;
335 }
336 return $this->trim_to($url, $max_len);
337 }
338
339 private function sanitize_rich_text($raw, int $max_len): ?string {
340 if (!is_string($raw) || $raw === '') {
341 return null;
342 }
343 $clean = wp_kses_post($raw);
344 return $this->trim_to($clean, $max_len);
345 }
346
347 private function sanitize_css($raw, int $max_len): ?string {
348 if (!is_string($raw) || $raw === '') {
349 return null;
350 }
351 // wp_strip_all_tags + length cap is enough — actual CSS-in-email
352 // safety is an email-client problem we can't solve server-side.
353 $clean = wp_strip_all_tags($raw);
354 return $this->trim_to($clean, $max_len);
355 }
356
357 /**
358 * @param mixed $raw
359 * @return string[]
360 */
361 private function sanitize_section_keys($raw): array {
362 if (!is_array($raw)) {
363 return [];
364 }
365 $allowed = function_exists('thinkrank_get_email_report_default_sections')
366 ? array_keys(thinkrank_get_email_report_default_sections())
367 : [];
368 $allowed = array_unique(array_merge(
369 $allowed,
370 (array) apply_filters('thinkrank_email_report_section_keys', [])
371 ));
372 $clean = [];
373 foreach ($raw as $key) {
374 if (!is_string($key)) {
375 continue;
376 }
377 $key = sanitize_key($key);
378 if (in_array($key, $allowed, true)) {
379 $clean[] = $key;
380 }
381 }
382 return array_values(array_unique($clean));
383 }
384
385 private function trim_to(string $value, int $max_len): string {
386 if (function_exists('mb_substr')) {
387 return mb_substr($value, 0, $max_len);
388 }
389 return substr($value, 0, $max_len);
390 }
391 }
392