PluginProbe
King Addons for Elementor – 100+ Elementor Widgets, 4 000+ Elementor Templates, WooCommerce Builder, Mega Menu, Popup Builder / 51.1.86
King Addons for Elementor – 100+ Elementor Widgets, 4 000+ Elementor Templates, WooCommerce Builder, Mega Menu, Popup Builder v51.1.86
51.1.86 51.1.84 51.1.85 51.1.83 51.1.82 51.1.81 51.1.79 51.1.78 51.1.77 51.1.76 51.1.74 51.1.75 51.1.65 51.1.64 51.1.63 trunk 51.1.14 51.1.2 51.1.35 51.1.36 51.1.37 51.1.38 51.1.39 51.1.44 51.1.45 All 40 releases
← All changes | includes/extensions/Maintenance_Mode/Maintenance_Mode.php +1059 -17 51.1.44 → 51.1.86 View file →
@@ -13,9 +13,17 @@
13 13
14 14 class Maintenance_Mode
15 15 {
16 16 private const OPTION_NAME = 'kng_maintenance_settings';
17 + private const ANALYTICS_OPTION = 'kng_maintenance_analytics';
18 + private const ANALYTICS_TRANSIENT_24H = 'kng_maintenance_analytics_24h';
17 19
20 + private const PRIVATE_ACCESS_COOKIE = 'kng_maintenance_private_access';
21 + private const PRIVATE_ACCESS_QUERY_PARAM = 'kng_maintenance_token';
22 + private const PRIVATE_ACCESS_POST_FLAG = 'kng_maintenance_private_submit';
23 +
24 + private const DEFAULT_TIMEZONE = 'site';
25 +
18 26 private static ?Maintenance_Mode $instance = null;
19 27
20 28 /**
21 29 * Cached settings.
@@ -23,8 +31,11 @@
23 31 * @var array<string, mixed>
24 32 */
25 33 private array $settings = [];
26 34
35 + private string $private_access_error = '';
36 + private string $private_access_redirect_to = '';
37 +
27 38 public static function instance(): Maintenance_Mode
28 39 {
29 40 if (self::$instance === null) {
30 41 self::$instance = new self();
@@ -43,9 +54,14 @@
43 54 add_action('template_redirect', [$this, 'maybe_render_maintenance'], 1);
44 55
45 56 add_action('admin_post_kng_maintenance_export', [$this, 'handle_export']);
46 57 add_action('admin_post_kng_maintenance_import', [$this, 'handle_import']);
58 + add_action('admin_post_kng_maintenance_reset_analytics', [$this, 'handle_reset_analytics']);
47 59
60 + add_action('admin_post_kng_maintenance_generate_token', [$this, 'handle_generate_private_token']);
61 + add_action('admin_post_kng_maintenance_revoke_token', [$this, 'handle_revoke_private_token']);
62 + add_action('admin_post_kng_maintenance_revoke_password', [$this, 'handle_revoke_private_password']);
63 +
48 64 add_shortcode('kng_maintenance_page', [$this, 'render_shortcode']);
49 65 }
50 66
51 67 public function register_admin_menu(): void
@@ -124,16 +140,38 @@
124 140 if (!$this->is_mode_active()) {
125 141 return;
126 142 }
127 143
128 - if ($this->should_bypass_request()) {
144 + $bypass_reason = $this->get_bypass_reason();
145 + if ($bypass_reason !== '') {
146 + $this->track_bypass($bypass_reason);
147 +
148 + if ($this->private_access_redirect_to !== '') {
149 + wp_safe_redirect($this->private_access_redirect_to);
150 + exit;
151 + }
129 152 return;
130 153 }
131 154
155 + $this->track_blocked_visit();
132 156 $this->render_maintenance_response(false);
133 157 exit;
134 158 }
135 159
160 + public function handle_reset_analytics(): void
161 + {
162 + if (!current_user_can('manage_options')) {
163 + wp_die(esc_html__('Unauthorized request.', 'king-addons'));
164 + }
165 +
166 + check_admin_referer('kng_maintenance_reset_analytics');
167 +
168 + delete_option(self::ANALYTICS_OPTION);
169 + delete_transient(self::ANALYTICS_TRANSIENT_24H);
170 +
171 + $this->redirect_with_message('analytics', 'analytics_reset');
172 + }
173 +
136 174 private function is_mode_active(): bool
137 175 {
138 176 if (empty($this->settings['enabled'])) {
139 177 return false;
@@ -142,12 +180,41 @@
142 180 if (empty($this->settings['schedule_enabled'])) {
143 181 return true;
144 182 }
145 183
184 + return $this->is_any_schedule_window_active() || $this->is_any_recurring_rule_active();
185 + }
186 +
187 + private function is_any_schedule_window_active(): bool
188 + {
189 + $now = current_time('timestamp', true);
190 +
191 + $windows = $this->settings['schedule_windows'] ?? [];
192 + if (!is_array($windows)) {
193 + $windows = [];
194 + }
195 +
196 + foreach ($windows as $window) {
197 + if (!is_array($window)) {
198 + continue;
199 + }
200 +
201 + $start = $this->parse_schedule_time((string) ($window['start'] ?? ''));
202 + $end = $this->parse_schedule_time((string) ($window['end'] ?? ''));
203 +
204 + if ($this->is_time_range_active($now, $start, $end)) {
205 + return true;
206 + }
207 + }
208 +
146 209 $start = $this->parse_schedule_time($this->settings['schedule_start'] ?? '');
147 210 $end = $this->parse_schedule_time($this->settings['schedule_end'] ?? '');
148 - $now = current_time('timestamp', true);
149 211
212 + return $this->is_time_range_active($now, $start, $end);
213 + }
214 +
215 + private function is_time_range_active(int $now, int $start, int $end): bool
216 + {
150 217 if ($start && $end) {
151 218 return $now >= $start && $now <= $end;
152 219 }
153 220
@@ -161,56 +228,713 @@
161 228
162 229 return false;
163 230 }
164 231
232 + private function is_any_recurring_rule_active(): bool
233 + {
234 + if (empty($this->settings['recurring_enabled'])) {
235 + return false;
236 + }
237 +
238 + $rules = $this->settings['recurring_rules'] ?? [];
239 + if (!is_array($rules) || $rules === []) {
240 + return false;
241 + }
242 +
243 + $nowTs = current_time('timestamp', true);
244 + $nowUtc = (new \DateTimeImmutable('@' . $nowTs))->setTimezone(new \DateTimeZone('UTC'));
245 +
246 + foreach ($rules as $rule) {
247 + if (!is_array($rule)) {
248 + continue;
249 + }
250 +
251 + if ($this->is_recurring_rule_active($rule, $nowUtc)) {
252 + return true;
253 + }
254 + }
255 +
256 + return false;
257 + }
258 +
259 + private function is_recurring_rule_active(array $rule, \DateTimeImmutable $nowUtc): bool
260 + {
261 + $freq = isset($rule['frequency']) ? sanitize_key((string) $rule['frequency']) : '';
262 + if (!in_array($freq, ['daily', 'weekly', 'monthly'], true)) {
263 + return false;
264 + }
265 +
266 + $tzString = isset($rule['timezone']) ? sanitize_text_field((string) $rule['timezone']) : self::DEFAULT_TIMEZONE;
267 + $tz = $this->resolve_timezone($tzString);
268 + $local = $nowUtc->setTimezone($tz);
269 +
270 + $startMinutes = $this->parse_time_minutes((string) ($rule['start_time'] ?? ''));
271 + $endMinutes = $this->parse_time_minutes((string) ($rule['end_time'] ?? ''));
272 + if ($startMinutes < 0 || $endMinutes < 0) {
273 + return false;
274 + }
275 +
276 + $nowMinutes = ((int) $local->format('G')) * 60 + (int) $local->format('i');
277 + $inTime = $this->is_minutes_in_range($nowMinutes, $startMinutes, $endMinutes);
278 + if (!$inTime) {
279 + return false;
280 + }
281 +
282 + if ($freq === 'daily') {
283 + return true;
284 + }
285 +
286 + if ($freq === 'weekly') {
287 + $days = $rule['days_of_week'] ?? [];
288 + if (!is_array($days) || $days === []) {
289 + return false;
290 + }
291 +
292 + $dow = (int) $local->format('N');
293 + return in_array($dow, array_map('intval', $days), true);
294 + }
295 +
296 + $days = $rule['days_of_month'] ?? [];
297 + if (!is_array($days) || $days === []) {
298 + return false;
299 + }
300 +
301 + $dom = (int) $local->format('j');
302 + return in_array($dom, array_map('intval', $days), true);
303 + }
304 +
305 + private function is_minutes_in_range(int $value, int $start, int $end): bool
306 + {
307 + if ($start === $end) {
308 + return false;
309 + }
310 +
311 + if ($start < $end) {
312 + return $value >= $start && $value <= $end;
313 + }
314 +
315 + return $value >= $start || $value <= $end;
316 + }
317 +
318 + private function parse_time_minutes(string $value): int
319 + {
320 + $value = trim($value);
321 + if (!preg_match('/^(\d{1,2}):(\d{2})$/', $value, $m)) {
322 + return -1;
323 + }
324 +
325 + $h = (int) $m[1];
326 + $i = (int) $m[2];
327 + if ($h < 0 || $h > 23 || $i < 0 || $i > 59) {
328 + return -1;
329 + }
330 +
331 + return $h * 60 + $i;
332 + }
333 +
334 + private function resolve_timezone(string $timezone): \DateTimeZone
335 + {
336 + $timezone = $timezone !== '' ? $timezone : self::DEFAULT_TIMEZONE;
337 + if ($timezone === self::DEFAULT_TIMEZONE) {
338 + return function_exists('wp_timezone') ? wp_timezone() : new \DateTimeZone('UTC');
339 + }
340 +
341 + try {
342 + return new \DateTimeZone($timezone);
343 + } catch (\Exception $e) {
344 + return function_exists('wp_timezone') ? wp_timezone() : new \DateTimeZone('UTC');
345 + }
346 + }
347 +
165 348 private function should_bypass_request(): bool
166 349 {
350 + return $this->get_bypass_reason() !== '';
351 + }
352 +
353 + private function get_bypass_reason(): string
354 + {
167 355 if (defined('WP_CLI') && WP_CLI) {
168 - return true;
356 + return 'wp_cli';
169 357 }
170 358
171 359 if (wp_doing_cron()) {
172 - return true;
360 + return 'cron';
173 361 }
174 362
175 363 if (is_admin()) {
176 - return true;
364 + return 'wp_admin';
177 365 }
178 366
179 367 if (wp_doing_ajax() && !empty($this->settings['allow_admin_ajax'])) {
180 - return true;
368 + return 'admin_ajax_allowed';
181 369 }
182 370
183 371 if ($this->is_login_request()) {
184 - return true;
372 + return 'login_page';
185 373 }
186 374
187 375 if (!empty($this->settings['disable_elementor_editor']) && $this->is_elementor_editor()) {
188 - return true;
376 + return 'elementor_editor';
189 377 }
190 378
379 + $private_reason = $this->get_private_access_bypass_reason();
380 + if ($private_reason !== '') {
381 + return $private_reason;
382 + }
383 +
191 384 if ($this->is_rest_request()) {
192 385 if (is_user_logged_in()) {
193 - return true;
386 + return 'rest_logged_in';
194 387 }
195 - return !empty($this->settings['allow_rest']);
388 + return !empty($this->settings['allow_rest']) ? 'rest_allowed' : '';
196 389 }
197 390
198 391 if ($this->is_user_allowed()) {
199 - return true;
392 + return 'user_allowed';
200 393 }
201 394
202 395 if ($this->is_ip_whitelisted()) {
203 - return true;
396 + return 'ip_whitelist';
204 397 }
205 398
206 399 if ($this->is_path_whitelisted()) {
207 - return true;
400 + return 'path_whitelist';
208 401 }
209 402
210 - return false;
403 + return '';
211 404 }
212 405
406 + private function is_private_access_enabled(): bool
407 + {
408 + if (!$this->is_pro()) {
409 + return false;
410 + }
411 +
412 + return !empty($this->settings['private_password_hash']) || !empty($this->settings['private_token']);
413 + }
414 +
415 + private function get_private_access_bypass_reason(): string
416 + {
417 + if (!$this->is_private_access_enabled()) {
418 + return '';
419 + }
420 +
421 + if ($this->has_private_access_cookie()) {
422 + return 'private_cookie';
423 + }
424 +
425 + $token = isset($_GET[self::PRIVATE_ACCESS_QUERY_PARAM])
426 + ? sanitize_text_field(wp_unslash($_GET[self::PRIVATE_ACCESS_QUERY_PARAM]))
427 + : '';
428 +
429 + if ($token !== '' && $this->is_private_token_valid($token)) {
430 + $this->set_private_access_cookie();
431 + $this->private_access_redirect_to = $this->get_current_url_without_private_params();
432 + return 'private_token';
433 + }
434 +
435 + if ($this->maybe_accept_private_password()) {
436 + $this->private_access_redirect_to = $this->get_current_url_without_private_params();
437 + return 'private_password';
438 + }
439 +
440 + return '';
441 + }
442 +
443 + private function maybe_accept_private_password(): bool
444 + {
445 + if (empty($this->settings['private_password_hash'])) {
446 + return false;
447 + }
448 +
449 + if (!isset($_POST[self::PRIVATE_ACCESS_POST_FLAG])) {
450 + return false;
451 + }
452 +
453 + $nonce = isset($_POST['_kng_private_nonce']) ? sanitize_text_field(wp_unslash($_POST['_kng_private_nonce'])) : '';
454 + if ($nonce === '' || !wp_verify_nonce($nonce, 'kng_maintenance_private_access')) {
455 + $this->private_access_error = 'invalid_nonce';
456 + return false;
457 + }
458 +
459 + $password = isset($_POST['kng_maintenance_private_password'])
460 + ? (string) wp_unslash($_POST['kng_maintenance_private_password'])
461 + : '';
462 +
463 + $password = trim($password);
464 + if ($password === '') {
465 + $this->private_access_error = 'invalid_password';
466 + return false;
467 + }
468 +
469 + $hash = (string) ($this->settings['private_password_hash'] ?? '');
470 + if ($hash === '' || !function_exists('wp_check_password')) {
471 + $this->private_access_error = 'invalid_password';
472 + return false;
473 + }
474 +
475 + if (!wp_check_password($password, $hash)) {
476 + $this->private_access_error = 'invalid_password';
477 + return false;
478 + }
479 +
480 + $this->set_private_access_cookie();
481 + return true;
482 + }
483 +
484 + private function is_private_token_valid(string $token): bool
485 + {
486 + $saved = (string) ($this->settings['private_token'] ?? '');
487 + if ($saved === '' || $token === '') {
488 + return false;
489 + }
490 +
491 + return hash_equals($saved, $token);
492 + }
493 +
494 + private function get_private_access_cookie_expected(): string
495 + {
496 + $key = defined('AUTH_SALT') && AUTH_SALT ? AUTH_SALT : (defined('NONCE_SALT') && NONCE_SALT ? NONCE_SALT : 'kng');
497 + $hash = (string) ($this->settings['private_password_hash'] ?? '');
498 + $token = (string) ($this->settings['private_token'] ?? '');
499 + $material = $hash . '|' . $token;
500 + return hash_hmac('sha256', 'private_access|' . $material, $key);
501 + }
502 +
503 + private function has_private_access_cookie(): bool
504 + {
505 + $expected = $this->get_private_access_cookie_expected();
506 + if ($expected === '') {
507 + return false;
508 + }
509 +
510 + $cookie = isset($_COOKIE[self::PRIVATE_ACCESS_COOKIE]) ? (string) wp_unslash($_COOKIE[self::PRIVATE_ACCESS_COOKIE]) : '';
511 + if ($cookie === '') {
512 + return false;
513 + }
514 +
515 + return hash_equals($expected, $cookie);
516 + }
517 +
518 + private function set_private_access_cookie(): void
519 + {
520 + $value = $this->get_private_access_cookie_expected();
521 + if ($value === '') {
522 + return;
523 + }
524 +
525 + $expires = time() + (int) apply_filters('kng_maintenance_private_cookie_ttl', 7 * DAY_IN_SECONDS);
526 +
527 + $path = defined('COOKIEPATH') ? (string) COOKIEPATH : '/';
528 + if ($path === '') {
529 + $path = '/';
530 + }
531 +
532 + $args = [
533 + 'expires' => $expires,
534 + 'path' => $path,
535 + 'secure' => is_ssl(),
536 + 'httponly' => true,
537 + 'samesite' => 'Lax',
538 + ];
539 +
540 + $domain = defined('COOKIE_DOMAIN') ? (string) COOKIE_DOMAIN : '';
541 + if ($domain !== '') {
542 + $args['domain'] = $domain;
543 + }
544 +
545 + setcookie(self::PRIVATE_ACCESS_COOKIE, $value, $args);
546 + $_COOKIE[self::PRIVATE_ACCESS_COOKIE] = $value;
547 + }
548 +
549 + private function get_current_url_without_private_params(): string
550 + {
551 + $request_uri = isset($_SERVER['REQUEST_URI']) ? (string) wp_unslash($_SERVER['REQUEST_URI']) : '/';
552 + $url = home_url($request_uri);
553 + $url = remove_query_arg([self::PRIVATE_ACCESS_QUERY_PARAM], $url);
554 + return $url;
555 + }
556 +
557 + private function track_blocked_visit(): void
558 + {
559 + if ($this->is_preview_request()) {
560 + return;
561 + }
562 +
563 + if (is_admin() || wp_doing_cron() || (defined('WP_CLI') && WP_CLI)) {
564 + return;
565 + }
566 +
567 + $analytics = $this->get_analytics();
568 + $analytics['blocked_total'] = (int) ($analytics['blocked_total'] ?? 0) + 1;
569 + $analytics['updated_at'] = time();
570 + update_option(self::ANALYTICS_OPTION, $analytics, false);
571 +
572 + $this->update_24h_analytics('blocked', '', $this->get_masked_request_path());
573 +
574 + $ip = $this->get_client_ip();
575 + if ($ip !== '') {
576 + $this->update_24h_unique_ip($ip);
577 + }
578 + }
579 +
580 + private function track_bypass(string $reason = ''): void
581 + {
582 + if ($this->is_preview_request()) {
583 + return;
584 + }
585 +
586 + if (is_admin() || wp_doing_cron() || (defined('WP_CLI') && WP_CLI)) {
587 + return;
588 + }
589 +
590 + $analytics = $this->get_analytics();
591 + $analytics['bypass_total'] = (int) ($analytics['bypass_total'] ?? 0) + 1;
592 +
593 + if ($reason !== '') {
594 + if (!isset($analytics['bypass_by_reason']) || !is_array($analytics['bypass_by_reason'])) {
595 + $analytics['bypass_by_reason'] = [];
596 + }
597 + $analytics['bypass_by_reason'][$reason] = (int) ($analytics['bypass_by_reason'][$reason] ?? 0) + 1;
598 + }
599 +
600 + $analytics['updated_at'] = time();
601 + update_option(self::ANALYTICS_OPTION, $analytics, false);
602 +
603 + $this->update_24h_analytics('bypass', $reason, $this->get_masked_request_path());
604 + }
605 +
606 + private function get_analytics(): array
607 + {
608 + $saved = get_option(self::ANALYTICS_OPTION, []);
609 + if (!is_array($saved)) {
610 + $saved = [];
611 + }
612 +
613 + $defaults = [
614 + 'blocked_total' => 0,
615 + 'bypass_total' => 0,
616 + 'bypass_by_reason' => [],
617 + 'created_at' => time(),
618 + 'updated_at' => time(),
619 + ];
620 +
621 + $data = wp_parse_args($saved, $defaults);
622 + if (!is_array($data['bypass_by_reason'])) {
623 + $data['bypass_by_reason'] = [];
624 + }
625 +
626 + return $data;
627 + }
628 +
629 + private function get_analytics_24h(): array
630 + {
631 + $saved = get_transient(self::ANALYTICS_TRANSIENT_24H);
632 + if (!is_array($saved)) {
633 + $saved = [];
634 + }
635 +
636 + $defaults = [
637 + 'blocked' => 0,
638 + 'bypass' => 0,
639 + 'bypass_by_reason' => [],
640 + 'unique' => [],
641 + 'paths' => [
642 + 'blocked' => [],
643 + 'bypass' => [],
644 + ],
645 + ];
646 +
647 + $data = wp_parse_args($saved, $defaults);
648 + if (!is_array($data['unique'])) {
649 + $data['unique'] = [];
650 + }
651 +
652 + if (!is_array($data['bypass_by_reason'])) {
653 + $data['bypass_by_reason'] = [];
654 + }
655 +
656 + if (!isset($data['paths']) || !is_array($data['paths'])) {
657 + $data['paths'] = ['blocked' => [], 'bypass' => []];
658 + }
659 + if (!isset($data['paths']['blocked']) || !is_array($data['paths']['blocked'])) {
660 + $data['paths']['blocked'] = [];
661 + }
662 + if (!isset($data['paths']['bypass']) || !is_array($data['paths']['bypass'])) {
663 + $data['paths']['bypass'] = [];
664 + }
665 +
666 + $this->prune_24h_unique($data);
667 + $this->prune_24h_paths($data);
668 +
669 + return $data;
670 + }
671 +
672 + private function save_analytics_24h(array $data): void
673 + {
674 + $this->prune_24h_unique($data);
675 + $this->prune_24h_paths($data);
676 + set_transient(self::ANALYTICS_TRANSIENT_24H, $data, DAY_IN_SECONDS + HOUR_IN_SECONDS);
677 + }
678 +
679 + private function prune_24h_unique(array &$data): void
680 + {
681 + $cutoff = time() - DAY_IN_SECONDS;
682 + if (!isset($data['unique']) || !is_array($data['unique'])) {
683 + $data['unique'] = [];
684 + return;
685 + }
686 +
687 + foreach ($data['unique'] as $hash => $ts) {
688 + if ((int) $ts < $cutoff) {
689 + unset($data['unique'][$hash]);
690 + }
691 + }
692 +
693 + $max = 10000;
694 + if (count($data['unique']) > $max) {
695 + $data['unique'] = array_slice($data['unique'], -$max, null, true);
696 + }
697 + }
698 +
699 + private function prune_24h_paths(array &$data): void
700 + {
701 + $cutoff = time() - DAY_IN_SECONDS;
702 + if (!isset($data['paths']) || !is_array($data['paths'])) {
703 + $data['paths'] = ['blocked' => [], 'bypass' => []];
704 + return;
705 + }
706 +
707 + foreach (['blocked', 'bypass'] as $bucket) {
708 + if (!isset($data['paths'][$bucket]) || !is_array($data['paths'][$bucket])) {
709 + $data['paths'][$bucket] = [];
710 + continue;
711 + }
712 +
713 + foreach ($data['paths'][$bucket] as $hash => $row) {
714 + $ts = is_array($row) ? (int) ($row['t'] ?? 0) : 0;
715 + if ($ts < $cutoff) {
716 + unset($data['paths'][$bucket][$hash]);
717 + }
718 + }
719 +
720 + $max = 400;
721 + if (count($data['paths'][$bucket]) > $max) {
722 + uasort($data['paths'][$bucket], static function ($a, $b) {
723 + $ta = is_array($a) ? (int) ($a['t'] ?? 0) : 0;
724 + $tb = is_array($b) ? (int) ($b['t'] ?? 0) : 0;
725 + return $ta <=> $tb;
726 + });
727 + $data['paths'][$bucket] = array_slice($data['paths'][$bucket], -$max, null, true);
728 + }
729 + }
730 + }
731 +
732 + private function update_24h_analytics(string $type, string $reason = '', string $masked_path = ''): void
733 + {
734 + $data = $this->get_analytics_24h();
735 + if ($type === 'blocked') {
736 + $data['blocked'] = (int) ($data['blocked'] ?? 0) + 1;
737 + } elseif ($type === 'bypass') {
738 + $data['bypass'] = (int) ($data['bypass'] ?? 0) + 1;
739 + if ($reason !== '') {
740 + if (!isset($data['bypass_by_reason'][$reason])) {
741 + $data['bypass_by_reason'][$reason] = 0;
742 + }
743 + $data['bypass_by_reason'][$reason] = (int) $data['bypass_by_reason'][$reason] + 1;
744 + }
745 + }
746 +
747 + if ($masked_path !== '' && in_array($type, ['blocked', 'bypass'], true)) {
748 + if (!isset($data['paths']) || !is_array($data['paths'])) {
749 + $data['paths'] = ['blocked' => [], 'bypass' => []];
750 + }
751 + if (!isset($data['paths'][$type]) || !is_array($data['paths'][$type])) {
752 + $data['paths'][$type] = [];
753 + }
754 +
755 + $hash = $this->hash_string($masked_path);
756 + if ($hash !== '') {
757 + if (!isset($data['paths'][$type][$hash]) || !is_array($data['paths'][$type][$hash])) {
758 + $data['paths'][$type][$hash] = ['c' => 0, 'm' => $masked_path, 't' => time()];
759 + }
760 + $data['paths'][$type][$hash]['c'] = (int) ($data['paths'][$type][$hash]['c'] ?? 0) + 1;
761 + $data['paths'][$type][$hash]['m'] = $masked_path;
762 + $data['paths'][$type][$hash]['t'] = time();
763 + }
764 + }
765 +
766 + $this->save_analytics_24h($data);
767 + }
768 +
769 + private function hash_string(string $value): string
770 + {
771 + $value = trim($value);
772 + if ($value === '') {
773 + return '';
774 + }
775 +
776 + $key = defined('AUTH_SALT') && AUTH_SALT ? AUTH_SALT : (defined('NONCE_SALT') && NONCE_SALT ? NONCE_SALT : 'kng');
777 + return hash_hmac('sha256', $value, $key);
778 + }
779 +
780 + private function get_request_path(): string
781 + {
782 + $request_uri = isset($_SERVER['REQUEST_URI']) ? (string) wp_unslash($_SERVER['REQUEST_URI']) : '';
783 + $path = wp_parse_url($request_uri, PHP_URL_PATH);
784 + $path = $path ? '/' . ltrim((string) $path, '/') : '/';
785 + return $path;
786 + }
787 +
788 + private function get_masked_request_path(): string
789 + {
790 + return $this->mask_path($this->get_request_path());
791 + }
792 +
793 + private function mask_path(string $path): string
794 + {
795 + $path = $path !== '' ? '/' . ltrim($path, '/') : '/';
796 + $trim = trim($path, '/');
797 + if ($trim === '') {
798 + return '/';
799 + }
800 +
801 + $segments = array_values(array_filter(explode('/', $trim), static fn($s) => $s !== ''));
802 + $maxSegments = 2;
803 + $shown = array_slice($segments, 0, $maxSegments);
804 +
805 + $out = [];
806 + foreach ($shown as $seg) {
807 + $seg = rawurldecode((string) $seg);
808 + $seg = preg_replace('/\s+/', '-', $seg);
809 +
810 + if ($seg === null || $seg === '') {
811 + continue;
812 + }
813 +
814 + if (preg_match('/^[0-9]+$/', $seg)) {
815 + $out[] = '{n}';
816 + continue;
817 + }
818 +
819 + if (preg_match('/^[a-f0-9]{16,}$/i', $seg)) {
820 + $out[] = '{hash}';
821 + continue;
822 + }
823 +
824 + if (preg_match('/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i', $seg)) {
825 + $out[] = '{uuid}';
826 + continue;
827 + }
828 +
829 + $seg = preg_replace('/\d+/', '{n}', $seg);
830 + $seg = preg_replace('/[^a-zA-Z0-9._\-{}]+/', '', (string) $seg);
831 + $seg = substr((string) $seg, 0, 24);
832 + if ($seg === '') {
833 + $seg = '{seg}';
834 + }
835 + $out[] = $seg;
836 + }
837 +
838 + $masked = '/' . implode('/', $out);
839 + if (count($segments) > $maxSegments) {
840 + $masked .= '/…';
841 + }
842 +
843 + return substr($masked, 0, 80);
844 + }
845 +
846 + private function update_24h_unique_ip(string $ip): void
847 + {
848 + $hash = $this->hash_ip($ip);
849 + if ($hash === '') {
850 + return;
851 + }
852 +
853 + $data = $this->get_analytics_24h();
854 + $data['unique'][$hash] = time();
855 + $this->save_analytics_24h($data);
856 + }
857 +
858 + private function hash_ip(string $ip): string
859 + {
860 + $ip = trim($ip);
861 + if ($ip === '') {
862 + return '';
863 + }
864 +
865 + $key = defined('AUTH_SALT') && AUTH_SALT ? AUTH_SALT : (defined('NONCE_SALT') && NONCE_SALT ? NONCE_SALT : 'kng');
866 + return hash_hmac('sha256', $ip, $key);
867 + }
868 +
869 + private function get_analytics_overview(): array
870 + {
871 + $all = $this->get_analytics();
872 + $h24 = $this->get_analytics_24h();
873 +
874 + $bypassAll = $all['bypass_by_reason'] ?? [];
875 + if (!is_array($bypassAll)) {
876 + $bypassAll = [];
877 + }
878 +
879 + $bypass24 = $h24['bypass_by_reason'] ?? [];
880 + if (!is_array($bypass24)) {
881 + $bypass24 = [];
882 + }
883 +
884 + arsort($bypassAll);
885 + arsort($bypass24);
886 +
887 + $topBlocked = $this->get_top_paths_24h($h24, 'blocked');
888 + $topBypass = $this->get_top_paths_24h($h24, 'bypass');
889 +
890 + return [
891 + 'blocked_total' => (int) ($all['blocked_total'] ?? 0),
892 + 'bypass_total' => (int) ($all['bypass_total'] ?? 0),
893 + 'blocked_24h' => (int) ($h24['blocked'] ?? 0),
894 + 'bypass_24h' => (int) ($h24['bypass'] ?? 0),
895 + 'unique_24h' => is_array($h24['unique'] ?? null) ? count($h24['unique']) : 0,
896 + 'bypass_by_reason_total' => $bypassAll,
897 + 'bypass_by_reason_24h' => $bypass24,
898 + 'top_paths_24h_blocked' => $topBlocked,
899 + 'top_paths_24h_bypass' => $topBypass,
900 + ];
901 + }
902 +
903 + private function get_top_paths_24h(array $h24, string $bucket, int $limit = 10): array
904 + {
905 + if (!isset($h24['paths']) || !is_array($h24['paths'])) {
906 + return [];
907 + }
908 + if (!isset($h24['paths'][$bucket]) || !is_array($h24['paths'][$bucket])) {
909 + return [];
910 + }
911 +
912 + $rows = [];
913 + foreach ($h24['paths'][$bucket] as $row) {
914 + if (!is_array($row)) {
915 + continue;
916 + }
917 + $count = (int) ($row['c'] ?? 0);
918 + $mask = isset($row['m']) ? (string) $row['m'] : '';
919 + if ($count <= 0 || $mask === '') {
920 + continue;
921 + }
922 + $rows[] = ['mask' => $mask, 'count' => $count];
923 + }
924 +
925 + usort($rows, static function ($a, $b) {
926 + $ca = (int) ($a['count'] ?? 0);
927 + $cb = (int) ($b['count'] ?? 0);
928 + if ($ca === $cb) {
929 + return strcmp((string) ($a['mask'] ?? ''), (string) ($b['mask'] ?? ''));
930 + }
931 + return $cb <=> $ca;
932 + });
933 +
934 + return array_slice($rows, 0, $limit);
935 + }
936 +
213 937 private function is_user_allowed(): bool
214 938 {
215 939 if (!is_user_logged_in()) {
216 940 return false;
@@ -301,8 +1025,12 @@
301 1025 }
302 1026
303 1027 private function render_maintenance_response(bool $is_preview): void
304 1028 {
1029 + if (!defined('KING_ADDONS_IS_MAINTENANCE_PAGE')) {
1030 + define('KING_ADDONS_IS_MAINTENANCE_PAGE', true);
1031 + }
1032 +
305 1033 $mode = $this->settings['mode'] ?? 'coming_soon';
306 1034 $status = $mode === 'maintenance' ? 503 : 200;
307 1035
308 1036 if ($is_preview) {
@@ -382,11 +1110,100 @@
382 1110 } else {
383 1111 $content = $this->render_builtin_template((string) $this->settings['template_id']);
384 1112 }
385 1113
386 - return '<main class="' . esc_attr(implode(' ', $wrapper_classes)) . '">' . $content . '</main>';
1114 + $private_panel = $this->render_private_access_panel();
1115 +
1116 + return '<main class="' . esc_attr(implode(' ', $wrapper_classes)) . '">' . $content . $private_panel . '</main>';
387 1117 }
388 1118
1119 + private function render_private_access_panel(): string
1120 + {
1121 + if (!$this->is_private_access_enabled()) {
1122 + return '';
1123 + }
1124 +
1125 + if (empty($this->settings['private_password_hash'])) {
1126 + return '';
1127 + }
1128 +
1129 + $error = $this->private_access_error;
1130 +
1131 + ob_start();
1132 + ?>
1133 + <details class="kng-maintenance-private-access" <?php echo $error !== '' ? 'open' : ''; ?>>
1134 + <summary><?php esc_html_e('Private access', 'king-addons'); ?></summary>
1135 + <div class="kng-maintenance-private-access-body">
1136 + <?php if ($error === 'invalid_password') : ?>
1137 + <div class="kng-maintenance-private-access-error"><?php esc_html_e('Incorrect password. Please try again.', 'king-addons'); ?></div>
1138 + <?php elseif ($error === 'invalid_nonce') : ?>
1139 + <div class="kng-maintenance-private-access-error"><?php esc_html_e('Session expired. Please try again.', 'king-addons'); ?></div>
1140 + <?php endif; ?>
1141 +
1142 + <form method="post" class="kng-maintenance-private-access-form">
1143 + <?php wp_nonce_field('kng_maintenance_private_access', '_kng_private_nonce'); ?>
1144 + <input type="password" name="kng_maintenance_private_password" placeholder="<?php echo esc_attr__('Password', 'king-addons'); ?>" autocomplete="current-password">
1145 + <button type="submit" name="<?php echo esc_attr(self::PRIVATE_ACCESS_POST_FLAG); ?>" value="1"><?php esc_html_e('Enter', 'king-addons'); ?></button>
1146 + </form>
1147 + </div>
1148 + </details>
1149 + <?php
1150 + return (string) ob_get_clean();
1151 + }
1152 +
1153 + public function handle_generate_private_token(): void
1154 + {
1155 + if (!current_user_can('manage_options')) {
1156 + wp_die(esc_html__('Unauthorized request.', 'king-addons'));
1157 + }
1158 +
1159 + check_admin_referer('kng_maintenance_private_token');
1160 +
1161 + $settings = $this->get_settings();
1162 + try {
1163 + $settings['private_token'] = bin2hex(random_bytes(16));
1164 + } catch (\Exception $e) {
1165 + $settings['private_token'] = wp_generate_password(32, false, false);
1166 + }
1167 +
1168 + update_option(self::OPTION_NAME, $settings, false);
1169 + $this->redirect_with_message('mode', 'token_generated');
1170 + }
1171 +
1172 + public function handle_revoke_private_token(): void
1173 + {
1174 + if (!current_user_can('manage_options')) {
1175 + wp_die(esc_html__('Unauthorized request.', 'king-addons'));
1176 + }
1177 +
1178 + check_admin_referer('kng_maintenance_private_token');
1179 +
1180 + $settings = $this->get_settings();
1181 + $settings['private_token'] = '';
1182 + update_option(self::OPTION_NAME, $settings, false);
1183 +
1184 + $this->redirect_with_message('mode', 'token_revoked');
1185 + }
1186 +
1187 + public function handle_revoke_private_password(): void
1188 + {
1189 + if (!current_user_can('manage_options')) {
1190 + wp_die(esc_html__('Unauthorized request.', 'king-addons'));
1191 + }
1192 +
1193 + check_admin_referer('kng_maintenance_private_password');
1194 +
1195 + $settings = $this->get_settings();
1196 +
1197 + // update_option() will call the registered sanitize callback.
1198 + // Ensure we provide the same intent flag used by sanitize_settings().
1199 + $settings['private_password_remove'] = 1;
1200 + $settings['private_password'] = '';
1201 + update_option(self::OPTION_NAME, $settings, false);
1202 +
1203 + $this->redirect_with_message('mode', 'password_revoked');
1204 + }
1205 +
389 1206 public function render_shortcode(array $atts): string
390 1207 {
391 1208 $atts = shortcode_atts([
392 1209 'id' => '',
@@ -442,8 +1259,20 @@
442 1259
443 1260 return '<div class="' . esc_attr(implode(' ', $classes)) . '">' . $content . '</div>';
444 1261 }
445 1262
1263 + /**
1264 + * Whether the current user may see this post's body, including status and password.
1265 + */
1266 + private function user_can_view_post_content(\WP_Post $post): bool
1267 + {
1268 + if (!current_user_can('read_post', $post->ID)) {
1269 + return false;
1270 + }
1271 +
1272 + return !post_password_required($post);
1273 + }
1274 +
446 1275 private function get_page_content(int $page_id): string
447 1276 {
448 1277 $page = get_post($page_id);
449 1278 if (!$page) {
@@ -449,8 +1278,16 @@
449 1278 if (!$page) {
450 1279 return $this->render_builtin_template('minimal');
451 1280 }
452 1281
1282 + if (!$this->user_can_view_post_content($page)) {
1283 + if (post_password_required($page) && current_user_can('read_post', $page_id)) {
1284 + return get_the_password_form($page);
1285 + }
1286 +
1287 + return $this->render_builtin_template('minimal');
1288 + }
1289 +
453 1290 $old_post = $GLOBALS['post'] ?? null;
454 1291 $GLOBALS['post'] = $page;
455 1292 setup_postdata($page);
456 1293
@@ -463,8 +1300,17 @@
463 1300 }
464 1301
465 1302 private function get_elementor_content(int $template_id): string
466 1303 {
1304 + $template = get_post($template_id);
1305 + if (!$template || !$this->user_can_view_post_content($template)) {
1306 + if ($template && post_password_required($template) && current_user_can('read_post', $template_id)) {
1307 + return get_the_password_form($template);
1308 + }
1309 +
1310 + return $this->render_builtin_template('minimal');
1311 + }
1312 +
467 1313 if (!class_exists('\\Elementor\\Plugin')) {
468 1314 return $this->render_builtin_template('minimal');
469 1315 }
470 1316
@@ -601,8 +1447,16 @@
601 1447 return [
602 1448 'minimal' => __('Minimal', 'king-addons'),
603 1449 'dark' => __('Dark', 'king-addons'),
604 1450 'gradient' => __('Gradient', 'king-addons'),
1451 + 'aurora' => __('Aurora Glow', 'king-addons'),
1452 + 'neon' => __('Neon Tech', 'king-addons'),
1453 + 'paper' => __('Paper Light', 'king-addons'),
1454 + 'grid' => __('Tech Grid', 'king-addons'),
1455 + 'mono' => __('Mono Minimal', 'king-addons'),
1456 + 'spotlight' => __('Spotlight', 'king-addons'),
1457 + 'poster' => __('Poster', 'king-addons'),
1458 + 'ribbon' => __('Ribbon', 'king-addons'),
605 1459 'countdown' => __('Coming Soon Countdown', 'king-addons'),
606 1460 'progress' => __('Maintenance Progress', 'king-addons'),
607 1461 'subscribe' => __('Simple Subscribe', 'king-addons'),
608 1462 'product-launch' => __('Product Launch', 'king-addons'),
@@ -690,11 +1544,16 @@
690 1544 'allowed_roles' => [],
691 1545 'schedule_enabled' => false,
692 1546 'schedule_start' => '',
693 1547 'schedule_end' => '',
1548 + 'schedule_windows' => [],
1549 + 'recurring_enabled' => false,
1550 + 'recurring_rules' => [],
694 1551 'allow_rest' => true,
695 1552 'allow_admin_ajax' => true,
696 1553 'disable_elementor_editor' => true,
1554 + 'private_password_hash' => '',
1555 + 'private_token' => '',
697 1556 'custom_css' => '',
698 1557 'custom_js' => '',
699 1558 ];
700 1559 }
@@ -717,8 +1576,16 @@
717 1576 if (!is_array($settings['allowed_roles'])) {
718 1577 $settings['allowed_roles'] = $defaults['allowed_roles'];
719 1578 }
720 1579
1580 + if (!is_array($settings['schedule_windows'])) {
1581 + $settings['schedule_windows'] = $defaults['schedule_windows'];
1582 + }
1583 +
1584 + if (!is_array($settings['recurring_rules'])) {
1585 + $settings['recurring_rules'] = $defaults['recurring_rules'];
1586 + }
1587 +
721 1588 return $settings;
722 1589 }
723 1590
724 1591 public function sanitize_settings(array $settings): array
@@ -757,8 +1624,27 @@
757 1624 $whitelist_ips = array_slice($whitelist_ips, 0, 10);
758 1625 $whitelist_paths = array_slice($whitelist_paths, 0, 10);
759 1626 }
760 1627
1628 + $legacyScheduleStart = $this->sanitize_datetime($settings['schedule_start'] ?? '');
1629 + $legacyScheduleEnd = $this->sanitize_datetime($settings['schedule_end'] ?? '');
1630 +
1631 + $scheduleWindows = $this->sanitize_schedule_windows($settings['schedule_windows'] ?? []);
1632 + if ($scheduleWindows === [] && ($legacyScheduleStart !== '' || $legacyScheduleEnd !== '')) {
1633 + $scheduleWindows[] = [
1634 + 'start' => $legacyScheduleStart,
1635 + 'end' => $legacyScheduleEnd,
1636 + 'timezone' => self::DEFAULT_TIMEZONE,
1637 + ];
1638 + }
1639 +
1640 + if ($scheduleWindows !== []) {
1641 + $legacyScheduleStart = (string) ($scheduleWindows[0]['start'] ?? $legacyScheduleStart);
1642 + $legacyScheduleEnd = (string) ($scheduleWindows[0]['end'] ?? $legacyScheduleEnd);
1643 + }
1644 +
1645 + $recurringRules = $this->sanitize_recurring_rules($settings['recurring_rules'] ?? []);
1646 +
761 1647 $clean = [
762 1648 'enabled' => !empty($settings['enabled']),
763 1649 'mode' => $mode,
764 1650 'template_source' => $source,
@@ -773,17 +1659,37 @@
773 1659 'whitelist_paths' => $this->normalize_paths($whitelist_paths),
774 1660 'exclude_admin' => !empty($settings['exclude_admin']),
775 1661 'allowed_roles' => $this->sanitize_list($settings['allowed_roles'] ?? []),
776 1662 'schedule_enabled' => !empty($settings['schedule_enabled']),
777 - 'schedule_start' => $this->sanitize_datetime($settings['schedule_start'] ?? ''),
778 - 'schedule_end' => $this->sanitize_datetime($settings['schedule_end'] ?? ''),
1663 + 'schedule_start' => $legacyScheduleStart,
1664 + 'schedule_end' => $legacyScheduleEnd,
1665 + 'schedule_windows' => $scheduleWindows,
1666 + 'recurring_enabled' => !empty($settings['recurring_enabled']),
1667 + 'recurring_rules' => $recurringRules,
779 1668 'allow_rest' => !empty($settings['allow_rest']),
780 1669 'allow_admin_ajax' => !empty($settings['allow_admin_ajax']),
781 1670 'disable_elementor_editor' => !empty($settings['disable_elementor_editor']),
1671 + 'private_password_hash' => (string) ($existing['private_password_hash'] ?? ''),
1672 + 'private_token' => (string) ($existing['private_token'] ?? ''),
782 1673 'custom_css' => '',
783 1674 'custom_js' => '',
784 1675 ];
785 1676
1677 + if ($this->is_pro()) {
1678 + $remove_password = !empty($settings['private_password_remove']);
1679 + $plain = isset($settings['private_password']) ? (string) $settings['private_password'] : '';
1680 + $plain = trim($plain);
1681 +
1682 + if ($remove_password) {
1683 + $clean['private_password_hash'] = '';
1684 + } elseif ($plain !== '') {
1685 + $clean['private_password_hash'] = function_exists('wp_hash_password') ? wp_hash_password($plain) : $clean['private_password_hash'];
1686 + }
1687 +
1688 + $token = isset($settings['private_token']) ? sanitize_text_field((string) $settings['private_token']) : (string) ($existing['private_token'] ?? '');
1689 + $clean['private_token'] = $token;
1690 + }
1691 +
786 1692 if (!$this->is_pro()) {
787 1693 $clean['allowed_roles'] = [];
788 1694 }
789 1695
@@ -789,8 +1695,111 @@
789 1695
790 1696 return $clean;
791 1697 }
792 1698
1699 + private function sanitize_schedule_windows($windows): array
1700 + {
1701 + if (!is_array($windows)) {
1702 + return [];
1703 + }
1704 +
1705 + $clean = [];
1706 + foreach ($windows as $window) {
1707 + if (!is_array($window)) {
1708 + continue;
1709 + }
1710 +
1711 + $tz = isset($window['timezone']) ? sanitize_text_field((string) $window['timezone']) : self::DEFAULT_TIMEZONE;
1712 + if ($tz === '') {
1713 + $tz = self::DEFAULT_TIMEZONE;
1714 + }
1715 +
1716 + $start = $this->sanitize_datetime_with_timezone((string) ($window['start'] ?? ''), $tz);
1717 + $end = $this->sanitize_datetime_with_timezone((string) ($window['end'] ?? ''), $tz);
1718 +
1719 + if ($start === '' && $end === '') {
1720 + continue;
1721 + }
1722 +
1723 + $clean[] = [
1724 + 'start' => $start,
1725 + 'end' => $end,
1726 + 'timezone' => $tz,
1727 + ];
1728 + }
1729 +
1730 + return $clean;
1731 + }
1732 +
1733 + private function sanitize_recurring_rules($rules): array
1734 + {
1735 + if (!is_array($rules)) {
1736 + return [];
1737 + }
1738 +
1739 + $clean = [];
1740 + foreach ($rules as $rule) {
1741 + if (!is_array($rule)) {
1742 + continue;
1743 + }
1744 +
1745 + $freq = isset($rule['frequency']) ? sanitize_key((string) $rule['frequency']) : '';
1746 + if (!in_array($freq, ['daily', 'weekly', 'monthly'], true)) {
1747 + continue;
1748 + }
1749 +
1750 + $tz = isset($rule['timezone']) ? sanitize_text_field((string) $rule['timezone']) : self::DEFAULT_TIMEZONE;
1751 + if ($tz === '') {
1752 + $tz = self::DEFAULT_TIMEZONE;
1753 + }
1754 +
1755 + $startTime = sanitize_text_field((string) ($rule['start_time'] ?? ''));
1756 + $endTime = sanitize_text_field((string) ($rule['end_time'] ?? ''));
1757 + if ($this->parse_time_minutes($startTime) < 0 || $this->parse_time_minutes($endTime) < 0) {
1758 + continue;
1759 + }
1760 +
1761 + $daysOfWeek = [];
1762 + if ($freq === 'weekly') {
1763 + $raw = $rule['days_of_week'] ?? [];
1764 + if (!is_array($raw)) {
1765 + $raw = preg_split('/\s*,\s*/', (string) $raw);
1766 + }
1767 +
1768 + $daysOfWeek = array_values(array_unique(array_filter(array_map(static function ($v) {
1769 + $n = absint($v);
1770 + return ($n >= 1 && $n <= 7) ? $n : 0;
1771 + }, (array) $raw))));
1772 + }
1773 +
1774 + $daysOfMonth = [];
1775 + if ($freq === 'monthly') {
1776 + $raw = $rule['days_of_month'] ?? [];
1777 + if (!is_array($raw)) {
1778 + $raw = preg_split('/\s*,\s*/', (string) $raw);
1779 + }
1780 +
1781 + $daysOfMonth = array_values(array_unique(array_filter(array_map(static function ($v) {
1782 + $n = absint($v);
1783 + return ($n >= 1 && $n <= 31) ? $n : 0;
1784 + }, (array) $raw))));
1785 + }
1786 +
1787 + $item = [
1788 + 'frequency' => $freq,
1789 + 'timezone' => $tz,
1790 + 'start_time' => $startTime,
1791 + 'end_time' => $endTime,
1792 + 'days_of_week' => $daysOfWeek,
1793 + 'days_of_month' => $daysOfMonth,
1794 + ];
1795 +
1796 + $clean[] = $item;
1797 + }
1798 +
1799 + return $clean;
1800 + }
1801 +
793 1802 public function get_template_content(string $template_id): array
794 1803 {
795 1804 $templates = $this->get_builtin_templates();
796 1805 $template_id = isset($templates[$template_id]) ? $template_id : 'minimal';
@@ -900,8 +1909,16 @@
900 1909 return [
901 1910 'minimal' => $base,
902 1911 'dark' => $base,
903 1912 'gradient' => $base,
1913 + 'aurora' => $base,
1914 + 'neon' => $base,
1915 + 'paper' => $base,
1916 + 'grid' => $base,
1917 + 'mono' => $base,
1918 + 'spotlight' => $base,
1919 + 'poster' => $base,
1920 + 'ribbon' => $base,
904 1921 'construction' => $base,
905 1922 'logo' => $base,
906 1923 'countdown' => $base + [
907 1924 'countdown_days' => 'int',
@@ -984,8 +2001,33 @@
984 2001 return '';
985 2002 }
986 2003
987 2004 return get_gmt_from_date($value, 'Y-m-d H:i:s');
2005 + }
2006 +
2007 + private function sanitize_datetime_with_timezone(string $value, string $timezone): string
2008 + {
2009 + $value = sanitize_text_field($value);
2010 + if ($value === '') {
2011 + return '';
2012 + }
2013 +
2014 + $value = str_replace('T', ' ', $value);
2015 + if (strlen($value) === 16) {
2016 + $value .= ':00';
2017 + }
2018 +
2019 + if (!preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', $value)) {
2020 + return '';
2021 + }
2022 +
2023 + $tz = $this->resolve_timezone($timezone);
2024 + $dt = \DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $value, $tz);
2025 + if (!$dt) {
2026 + return '';
2027 + }
2028 +
2029 + return $dt->setTimezone(new \DateTimeZone('UTC'))->format('Y-m-d H:i:s');
988 2030 }
989 2031
990 2032 private function parse_schedule_time(string $value): int
991 2033 {