PluginProbe
Darkify – Dark Mode & Night Mode for Website & Admin (Dark Theme Included) / 2.0.2
Darkify – Dark Mode & Night Mode for Website & Admin (Dark Theme Included) v2.0.2
2.1.3 2.1.2 2.1.1 2.1.0 2.0.4 2.0.3 2.0.2 2.0.1 2.0.0 1.5.5 1.5.4 1.5.3 1.5.2 1.5.1 1.5.0 trunk 1.0.1 1.1.0 1.2.0 1.2.1 1.2.2 1.2.3 1.2.4 1.2.5 1.3.0 All 57 releases
darkify / src / Admin / Rest / PreviewRest.php

PreviewRest.php in Darkify – Dark Mode & Night Mode for Website & Admin (Dark Theme Included) 2.0.2, at src/Admin/Rest/PreviewRest.php

376 lines 16.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * Admin live-preview REST controller.
5 *
6 * Renders the admin's live preview by loading the site's REAL frontend homepage
7 * inside an iframe with the *unsaved* settings applied — the same approach Chat
8 * Help Pro uses to preview the real frontend widget, adapted to Darkify, whose
9 * "frontend output" is the entire dark-moded page rather than a single widget.
10 *
11 * Flow:
12 * 1. The admin SPA POSTs the current (unsaved) form values here. They are
13 * sanitized, merged over the real saved `darkify` option, and stored in a
14 * short-lived per-user transient — NEVER written to the real option.
15 * 2. The iframe requests the homepage with `?darkify_preview=<nonce>`. On that
16 * one request, `maybe_enable_preview()` filters `option_darkify` to return
17 * the transient's merged values, so Darkify's normal frontend code renders
18 * the page exactly as it would once the settings were saved.
19 *
20 * Because the override is (a) read-only, (b) scoped to a single nonce-verified
21 * request from a `manage_options` user, and (c) applied only to that user's own
22 * transient, it changes nothing persistent and is invisible to every other
23 * visitor and request — full backward compatibility.
24 *
25 * @package darkify
26 * @subpackage darkify/src/Admin/Rest
27 * @author ThemeAtelier<themeatelierbd@gmail.com>
28 */
29
30 namespace ThemeAtelier\Darkify\Admin\Rest;
31
32 use WP_REST_Server;
33 use WP_REST_Request;
34 use WP_REST_Response;
35
36 if (! defined('ABSPATH')) {
37 die;
38 }
39
40 class PreviewRest extends AbstractRestController
41 {
42 /** The option key the preview drives (Darkify stores everything here). */
43 const OPTION_KEY = 'darkify';
44
45 /** Query var the iframe carries to request a preview render. */
46 const PREVIEW_QUERY_VAR = 'darkify_preview';
47
48 /** Nonce action guarding the preview render. */
49 const PREVIEW_NONCE_ACTION = 'darkify_preview';
50
51 /** Transient lifetime — long enough for an editing session, short enough to
52 * self-clean. Refreshed on every preview POST. */
53 const PREVIEW_TTL = HOUR_IN_SECONDS;
54
55 public function __construct()
56 {
57 parent::__construct();
58 // Runs on EVERY request (this controller is constructed in the plugin
59 // boot, not only in wp-admin), so it can catch the frontend iframe
60 // request and swap in the preview values before Darkify's frontend
61 // reads the option.
62 \add_action('init', [$this, 'maybe_enable_preview']);
63 }
64
65 public function register_routes(): void
66 {
67 \register_rest_route(self::NS, '/preview', [
68 'methods' => WP_REST_Server::CREATABLE,
69 'callback' => [$this, 'store_preview'],
70 'permission_callback' => [$this, 'can_manage'],
71 ]);
72 }
73
74 /** Per-user transient key so one admin's preview never leaks into another's. */
75 private function transient_key(int $user_id): string
76 {
77 return 'darkify_admin_preview_' . $user_id;
78 }
79
80 /**
81 * POST /preview — sanitize + merge the unsaved values over the saved option
82 * and stash them in the current user's transient. Returns the URL the iframe
83 * should load. Never persists to the real option.
84 */
85 public function store_preview(WP_REST_Request $request): WP_REST_Response
86 {
87 $user_id = \get_current_user_id();
88 if (! $user_id) {
89 return new WP_REST_Response(['message' => \__('Not allowed.', 'darkify')], 403);
90 }
91
92 $incoming = $request->get_param('values');
93 $incoming = \is_array($incoming) ? $incoming : [];
94
95 $sections = $this->get_registered_sections(self::OPTION_KEY);
96 $type_map = $this->collect_field_types($sections);
97 $sanitized = $this->sanitize_values($incoming, $type_map);
98 // Free plugin: the preview renders with Pro-locked values stripped too,
99 // so it always shows what the free frontend would actually do.
100 $sanitized = $this->strip_pro_keys($sanitized, $sections);
101
102 $existing = \get_option(self::OPTION_KEY, []);
103 $existing = \is_array($existing) ? $existing : [];
104 $merged = \array_merge($existing, $sanitized);
105
106 \set_transient($this->transient_key($user_id), $merged, self::PREVIEW_TTL);
107
108 return \rest_ensure_response([
109 'ok' => true,
110 'url' => $this->preview_url(),
111 ]);
112 }
113
114 /** The homepage URL carrying the preview nonce. */
115 public function preview_url(): string
116 {
117 return \add_query_arg(
118 self::PREVIEW_QUERY_VAR,
119 \wp_create_nonce(self::PREVIEW_NONCE_ACTION),
120 \home_url('/')
121 );
122 }
123
124 /**
125 * On a valid preview request, make Darkify's frontend read the unsaved
126 * preview values instead of the saved option — for this request only.
127 */
128 public function maybe_enable_preview(): void
129 {
130 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- nonce verified on the next line.
131 $token = isset($_GET[self::PREVIEW_QUERY_VAR]) ? \sanitize_text_field(\wp_unslash($_GET[self::PREVIEW_QUERY_VAR])) : '';
132 if ($token === '' || ! \wp_verify_nonce($token, self::PREVIEW_NONCE_ACTION)) {
133 return;
134 }
135 if (! \is_user_logged_in() || ! \current_user_can('manage_options')) {
136 return;
137 }
138
139 $preview = \get_transient($this->transient_key(\get_current_user_id()));
140 if (! \is_array($preview)) {
141 return;
142 }
143
144 // The preview renders the frontend with the admin's *unsaved* settings
145 // and NO forced mode: it uses the exact same light/dark logic a real
146 // visitor would (this browser's stored toggle state, then the unsaved
147 // `enable_default_dark_mode` / OS-aware / time-based settings). So it
148 // starts in whatever mode the site actually would, and the admin can
149 // flip the in-preview switch just like on the front end — the switch
150 // icon then stays in sync because dark mode is never force-applied
151 // out-of-band.
152 \add_filter('option_' . self::OPTION_KEY, function () use ($preview) {
153 return $preview;
154 }, 99);
155
156 // Tell the frontend engine this exact page load IS the admin's own Live
157 // Preview iframe — not a real third-party iframe embed on someone's page.
158 // This makes it behave like the TOP-LEVEL front end: the "Frontend Iframe
159 // Dark Mode" setting (which governs iframes NESTED WITHIN a page) can't
160 // suppress the engine on the previewed page itself. It does NOT force any
161 // mode — the light/dark state is still whatever the front end would show.
162 // See Frontend::darkify_early_iframe_guard() and the
163 // `darkify_is_admin_live_preview` JS flag in client_main.js's
164 // `_dkf_iframe_disabled` guard.
165 \add_filter('darkify_is_admin_live_preview', '__return_true');
166
167 // Keep the preview context when browsing INSIDE the iframe. Without
168 // this, clicking any link inside the preview navigates to a plain URL
169 // with no token, so this whole method no-ops for that page: it silently
170 // reverts to the SAVED settings, and — because a tokenless page in an
171 // iframe fails client_main.js's `_dkf_iframe_disabled` guard whenever
172 // "Frontend Iframe Dark Mode" is off — the engine switches itself off
173 // entirely and the page renders light. Carrying the token forward keeps
174 // the previewed page a preview.
175 \add_action('wp_head', [$this, 'print_preview_link_persistence'], 1);
176
177 // A cleaner canvas: no admin bar overlapping the previewed page.
178 \add_filter('show_admin_bar', '__return_false');
179
180 // Strip other plugins' floating/overlay UI (chat widgets, cookie and
181 // consent banners, popups, announcement bars, …) from the preview only
182 // — see print_preview_overlay_filter() for why this is safe to do
183 // unconditionally for "any installed plugin" without touching the real
184 // frontend or the page's own layout/content.
185 \add_action('wp_head', [$this, 'print_preview_overlay_filter'], 1);
186
187 // Mark the response so it is never cached by page caches / CDNs.
188 if (! \headers_sent()) {
189 \nocache_headers();
190 }
191 }
192
193 /**
194 * Carry the preview token across link clicks inside the preview iframe.
195 *
196 * Only ever registered from maybe_enable_preview(), so it prints on preview
197 * requests alone — a real visitor's page never sees it.
198 *
199 * A delegated listener rather than rewriting every `href`: it costs one
200 * handler regardless of page size and it also covers links a theme or
201 * plugin injects later. It defers to the page in every case where the click
202 * wasn't a plain same-tab navigation to another page of this site —
203 * modified clicks (new tab), `target`ed links, external hosts, in-page
204 * anchors, and anything a script already handled (`defaultPrevented`) are
205 * left completely alone.
206 */
207 public function print_preview_link_persistence(): void
208 {
209 $var = \wp_json_encode(self::PREVIEW_QUERY_VAR);
210 $token = \wp_json_encode(\wp_create_nonce(self::PREVIEW_NONCE_ACTION));
211 ?>
212 <script type="text/javascript" class="darkify_preview_js">
213 (function () {
214 var VAR = <?php echo $var; // phpcs:ignore WordPress.Security.EscapeOutput -- wp_json_encode output. ?>;
215 var TOKEN = <?php echo $token; // phpcs:ignore WordPress.Security.EscapeOutput -- wp_json_encode output. ?>;
216 document.addEventListener('click', function (e) {
217 if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) {
218 return;
219 }
220 var a = (e.target && e.target.closest) ? e.target.closest('a') : null;
221 if (!a || !a.getAttribute('href')) {
222 return;
223 }
224 if (a.hasAttribute('download') || (a.target && a.target !== '_self')) {
225 return;
226 }
227 var u;
228 try {
229 u = new URL(a.href, window.location.href);
230 } catch (err) {
231 return;
232 }
233 if (u.origin !== window.location.origin) {
234 return;
235 }
236 if (u.searchParams.get(VAR)) {
237 return;
238 }
239 /* Same page + hash only: let the browser jump the anchor. */
240 if (u.pathname === window.location.pathname && u.search === window.location.search && u.hash) {
241 return;
242 }
243 u.searchParams.set(VAR, TOKEN);
244 e.preventDefault();
245 window.location.href = u.toString();
246 });
247 }());
248 </script>
249 <?php
250 }
251
252 /**
253 * Hide third-party plugin overlay UI inside the preview iframe.
254 *
255 * Only ever registered from maybe_enable_preview(), i.e. only for this one
256 * nonce-verified preview request — never on a real visitor's page load — so
257 * this cannot affect the actual frontend.
258 *
259 * Two layers, because no single technique reliably catches "any installed
260 * plugin, not just named ones":
261 *
262 * 1. A CSS denylist of the id/class substrings the most common overlay
263 * categories (cookie/consent banners, chat widgets, popups/modals,
264 * sticky announcement bars) overwhelmingly use in the wild. Cheap,
265 * instant, and — being plain CSS — it also matches elements a plugin
266 * injects into the page *after* this prints (e.g. via its own
267 * wp_footer script), since the browser re-evaluates selectors as the
268 * DOM changes.
269 * 2. A small script that hides anything else `position: fixed` or
270 * `sticky` UNLESS it reads as an ordinary slim top bar (the shape a
271 * theme's own sticky header/nav takes) — the general fallback for
272 * overlay UI the CSS list didn't anticipate. It re-scans on a few
273 * delays and watches the DOM (MutationObserver, attached to
274 * `<html>` so it works even though this prints in `<head>` before
275 * `<body>` exists) to catch widgets that inject themselves late.
276 *
277 * Both explicitly exempt Darkify's own floating switch (`.darkify_switch`,
278 * `#darkify_switch_*` — see Darkify::switcher_wrapper()) and the admin
279 * bar, and neither touches in-flow page content, so the theme's real
280 * layout renders untouched.
281 */
282 public function print_preview_overlay_filter(): void
283 {
284 ?>
285 <style id="darkify-preview-overlay-filter">
286 [id*="cookie" i]:not([class*="darkify" i]):not([id*="darkify" i]),
287 [class*="cookie" i]:not([class*="darkify" i]),
288 [id*="consent" i]:not([class*="darkify" i]),
289 [class*="consent" i]:not([class*="darkify" i]),
290 [id*="gdpr" i], [class*="gdpr" i],
291 [id*="cookielaw" i], [class*="cookielaw" i],
292 [id*="cky-consent" i], [class*="cky-" i],
293 [id*="chat-widget" i], [class*="chat-widget" i],
294 [id*="livechat" i], [class*="livechat" i],
295 [id*="tawk" i], [class*="tawk" i],
296 [id*="crisp-client" i], [id*="intercom" i], [class*="intercom-" i],
297 [id*="drift-frame" i], [id*="fc_frame" i], [id*="fc_widget" i],
298 [id*="hubspot-messages" i], [id*="zsiq" i], [class*="zsiq" i],
299 [id*="fb-customer-chat" i], [id*="messenger-chat" i],
300 [id*="whatsapp" i]:not([class*="darkify" i]):not([id*="darkify" i]),
301 [class*="whatsapp" i]:not([class*="darkify" i]),
302 [id*="popup" i]:not([class*="darkify" i]):not([id*="darkify" i]),
303 [class*="popup" i]:not([class*="darkify" i]),
304 [id*="modal" i]:not([class*="darkify" i]):not([id*="darkify" i]),
305 [class*="modal-overlay" i],
306 [class*="pum-" i], [id*="pum-" i],
307 [class*="elementor-popup-modal" i],
308 [class*="announcement-bar" i], [id*="hello-bar" i], [class*="hello-bar" i],
309 [class*="sticky-bar" i], [class*="notification-bar" i]
310 {
311 display: none !important;
312 visibility: hidden !important;
313 pointer-events: none !important;
314 }
315 </style>
316 <script id="darkify-preview-overlay-script">
317 (function () {
318 var KEEP_RE = /darkify|wpadminbar/i;
319 function isExempt(el) {
320 while (el && el.nodeType === 1) {
321 if (KEEP_RE.test(el.id || "") || KEEP_RE.test(el.className || "")) return true;
322 el = el.parentElement;
323 }
324 return false;
325 }
326 function maybeHide(el) {
327 if (!el || el.nodeType !== 1 || isExempt(el)) return;
328 var style;
329 try { style = getComputedStyle(el); } catch (e) { return; }
330 if (style.position !== "fixed" && style.position !== "sticky") return;
331 var rect = el.getBoundingClientRect();
332 if (rect.width === 0 && rect.height === 0) return;
333 var vw = window.innerWidth || document.documentElement.clientWidth;
334 var vh = window.innerHeight || document.documentElement.clientHeight;
335 var coversViewport = rect.width >= vw * 0.6 && rect.height >= vh * 0.6;
336 var slimTopBar = rect.top <= 4 && rect.height <= 140;
337 if (coversViewport || !slimTopBar) {
338 el.style.setProperty("display", "none", "important");
339 }
340 }
341 function scan(root) {
342 if (root && root.querySelectorAll) {
343 root.querySelectorAll("*").forEach(maybeHide);
344 }
345 }
346 function safeScan() {
347 if (document.body) scan(document.body);
348 }
349 safeScan();
350 [300, 800, 1500, 3000].forEach(function (ms) {
351 setTimeout(safeScan, ms);
352 });
353 new MutationObserver(function (mutations) {
354 mutations.forEach(function (m) {
355 if (m.type === "childList") {
356 m.addedNodes.forEach(function (node) {
357 if (node.nodeType !== 1) return;
358 maybeHide(node);
359 if (node.querySelectorAll) node.querySelectorAll("*").forEach(maybeHide);
360 });
361 } else if (m.type === "attributes") {
362 maybeHide(m.target);
363 }
364 });
365 }).observe(document.documentElement, {
366 childList: true,
367 subtree: true,
368 attributes: true,
369 attributeFilter: ["style", "class"],
370 });
371 })();
372 </script>
373 <?php
374 }
375 }
376