PluginProbe
Darkify – Dark Mode & Night Mode for Website & Admin (Dark Theme Included) / 2.1.3
Darkify – Dark Mode & Night Mode for Website & Admin (Dark Theme Included) v2.1.3
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.1.3, at src/Admin/Rest/PreviewRest.php

414 lines 19.5 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 /* The denylist is grouped in :is() so the shared exemptions below
287 apply to every entry at once — Darkify's own switch, and the
288 theme's semantic site header / banner landmark, which must never
289 be filtered out: it is page chrome the admin is previewing, not
290 third-party overlay UI, and some themes name their header bar with
291 words that appear in this list ("sticky-bar", "top bar", …). */
292 :is(
293 [id*="cookie" i], [class*="cookie" i],
294 [id*="consent" i], [class*="consent" i],
295 [id*="gdpr" i], [class*="gdpr" i],
296 [id*="cookielaw" i], [class*="cookielaw" i],
297 [id*="cky-consent" i], [class*="cky-" i],
298 [id*="chat-widget" i], [class*="chat-widget" i],
299 [id*="livechat" i], [class*="livechat" i],
300 [id*="tawk" i], [class*="tawk" i],
301 [id*="crisp-client" i], [id*="intercom" i], [class*="intercom-" i],
302 [id*="drift-frame" i], [id*="fc_frame" i], [id*="fc_widget" i],
303 [id*="hubspot-messages" i], [id*="zsiq" i], [class*="zsiq" i],
304 [id*="fb-customer-chat" i], [id*="messenger-chat" i],
305 [id*="whatsapp" i], [class*="whatsapp" i],
306 [id*="popup" i], [class*="popup" i],
307 [id*="modal" i], [class*="modal-overlay" i],
308 [class*="pum-" i], [id*="pum-" i],
309 [class*="elementor-popup-modal" i],
310 [class*="announcement-bar" i], [id*="hello-bar" i], [class*="hello-bar" i],
311 [class*="sticky-bar" i], [class*="notification-bar" i]
312 ):not([class*="darkify" i]):not([id*="darkify" i]):not(header):not([role="banner"]):not(header *):not([role="banner"] *)
313 {
314 display: none !important;
315 visibility: hidden !important;
316 pointer-events: none !important;
317 }
318 </style>
319 <script id="darkify-preview-overlay-script">
320 (function () {
321 var KEEP_RE = /darkify|wpadminbar/i;
322 /* Class/id hints a theme uses for its own site header / primary nav. */
323 var HEADER_RE = /(^|[\s_-])(site[_-]?header|main[_-]?header|page[_-]?header|header[_-]?wrap|masthead|navbar|nav[_-]?bar|main[_-]?nav|primary[_-]?nav|site[_-]?nav|top[_-]?bar|topbar)([\s_-]|$)|(^|[\s])(ta[_-]header|header)([\s]|$)/i;
324 function classOf(el) {
325 var c = el.className;
326 return (typeof c === "string") ? c : (el.getAttribute ? (el.getAttribute("class") || "") : "");
327 }
328 function isExempt(el) {
329 while (el && el.nodeType === 1) {
330 if (KEEP_RE.test(el.id || "") || KEEP_RE.test(classOf(el))) return true;
331 el = el.parentElement;
332 }
333 return false;
334 }
335 /**
336 * Is this the theme's own site header rather than third-party overlay UI?
337 *
338 * A theme header is very often `position: fixed` and is NOT a slim bar
339 * flush to the top: it may sit at an offset (`top: 2rem` floating navs)
340 * and may stack a promo/announcement strip above the nav, pushing it well
341 * past 140px tall. Judging it by the slim-top-bar shape alone hid real
342 * headers from the preview, which is the one part of the page an admin
343 * most wants to see dark-moded.
344 *
345 * Identified structurally (a `<header>` element, `role="banner"`, or a
346 * header/nav class name) plus anchored near the top of the page and not
347 * covering the viewport — so a full-screen mobile menu overlay or a modal
348 * that happens to contain a `<nav>` is still filtered out.
349 */
350 function isSiteHeader(el, rect, vw, vh) {
351 var tag = (el.tagName || "").toLowerCase();
352 var looksHeader =
353 tag === "header" ||
354 (el.getAttribute && el.getAttribute("role") === "banner") ||
355 HEADER_RE.test(el.id || "") ||
356 HEADER_RE.test(classOf(el)) ||
357 (tag === "nav" && rect.width >= vw * 0.5);
358 if (!looksHeader) return false;
359 /* Anchored to the top strip of the viewport, spanning most of it,
360 and not tall enough to be a full-screen takeover. */
361 return rect.top <= 160 && rect.width >= vw * 0.5 && rect.height <= vh * 0.5;
362 }
363 function maybeHide(el) {
364 if (!el || el.nodeType !== 1 || isExempt(el)) return;
365 var style;
366 try { style = getComputedStyle(el); } catch (e) { return; }
367 if (style.position !== "fixed" && style.position !== "sticky") return;
368 var rect = el.getBoundingClientRect();
369 if (rect.width === 0 && rect.height === 0) return;
370 var vw = window.innerWidth || document.documentElement.clientWidth;
371 var vh = window.innerHeight || document.documentElement.clientHeight;
372 if (isSiteHeader(el, rect, vw, vh)) return;
373 var coversViewport = rect.width >= vw * 0.6 && rect.height >= vh * 0.6;
374 var slimTopBar = rect.top <= 4 && rect.height <= 140;
375 if (coversViewport || !slimTopBar) {
376 el.style.setProperty("display", "none", "important");
377 }
378 }
379 function scan(root) {
380 if (root && root.querySelectorAll) {
381 root.querySelectorAll("*").forEach(maybeHide);
382 }
383 }
384 function safeScan() {
385 if (document.body) scan(document.body);
386 }
387 safeScan();
388 [300, 800, 1500, 3000].forEach(function (ms) {
389 setTimeout(safeScan, ms);
390 });
391 new MutationObserver(function (mutations) {
392 mutations.forEach(function (m) {
393 if (m.type === "childList") {
394 m.addedNodes.forEach(function (node) {
395 if (node.nodeType !== 1) return;
396 maybeHide(node);
397 if (node.querySelectorAll) node.querySelectorAll("*").forEach(maybeHide);
398 });
399 } else if (m.type === "attributes") {
400 maybeHide(m.target);
401 }
402 });
403 }).observe(document.documentElement, {
404 childList: true,
405 subtree: true,
406 attributes: true,
407 attributeFilter: ["style", "class"],
408 });
409 })();
410 </script>
411 <?php
412 }
413 }
414