Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
85.33% covered (warning)
85.33%
64 / 75
36.36% covered (danger)
36.36%
4 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
CloudflareFeaturesManager
85.33% covered (warning)
85.33%
64 / 75
36.36% covered (danger)
36.36%
4 / 11
42.56
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 on_site_capabilities_change
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 get_cookie_value
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
6
 print_cookie_script
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
3.00
 maybe_remove_legacy_htaccess_fragment
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 strip_legacy_block_from_state
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
10.02
 remove_marker_block
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 get_htaccess_state
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 update_htaccess_state
50.00% covered (danger)
50.00%
2 / 4
0.00% covered (danger)
0.00%
0 / 1
2.50
 is_cleanup_done
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 set_cleanup_done
50.00% covered (danger)
50.00%
2 / 4
0.00% covered (danger)
0.00%
0 / 1
2.50
1<?php
2/**
3 * CloudflareFeaturesManager
4 *
5 * Tracks Cloudflare-related optimization toggles (Polish, Mirage, Fonts) and
6 * advertises the active set to Cloudflare via a front-end cookie.
7 *
8 * The cookie is written client-side (JavaScript) rather than through an
9 * `.htaccess` `Set-Cookie` response header. A `Set-Cookie` on a front-end
10 * response makes that response uncacheable to nginx+ and Cloudflare, and the
11 * old rule emitted it on every cookieless request — so only visitors that
12 * already held the cookie could ever populate the shared cache. Setting the
13 * cookie from JS keeps every HTML response cacheable while still presenting the
14 * cookie on subsequent requests, which is all the Cloudflare edge rules key on.
15 *
16 * @package NewfoldLabs\WP\Module\Performance\Cloudflare
17 * @since 1.0.0
18 */
19
20namespace NewfoldLabs\WP\Module\Performance\Cloudflare;
21
22use NewfoldLabs\WP\Module\Performance\Fonts\FontSettings;
23use NewfoldLabs\WP\Module\Performance\Images\ImageSettings;
24use NewfoldLabs\WP\Module\Htaccess\Api as HtaccessApi;
25
26/**
27 * Handles detection and tracking of Cloudflare Polish, Mirage, and Font Optimization.
28 *
29 * @since 1.0.0
30 */
31class CloudflareFeaturesManager {
32
33    /**
34     * Cookie name the Cloudflare edge rules look for.
35     *
36     * @var string
37     */
38    private const COOKIE_NAME = 'nfd-enable-cf-opt';
39
40    /**
41     * Cookie lifetime in seconds (24 hours).
42     *
43     * @var int
44     */
45    private const COOKIE_TTL = 86400;
46
47    /**
48     * Identifier the legacy Cloudflare optimization block is persisted under.
49     *
50     * Used only to locate and remove that one block from the htaccess module's saved
51     * state. No fragment is ever registered under this ID again.
52     *
53     * @var string
54     */
55    private const FRAGMENT_ID = 'nfd.cloudflare.optimization.header';
56
57    /**
58     * Marker label printed in the BEGIN/END comments of the legacy `.htaccess` block.
59     *
60     * On installs migrated to the htaccess "managed marker block" format the block is
61     * baked into the persisted body and is identifiable only by this label (not by the
62     * fragment ID), so the cleanup matches on it directly.
63     *
64     * @var string
65     */
66    private const MARKER = 'Newfold CF Optimization Header';
67
68    /**
69     * Option name where the htaccess module persists its composed state.
70     *
71     * Read/written directly (not through the htaccess code) so the cleanup can strip
72     * the legacy block from the persisted body without any change to the htaccess
73     * module. This is the documented option name from that module's Options map.
74     *
75     * @var string
76     */
77    private const HTACCESS_STATE_OPTION = 'nfd_module_htaccess_saved_state';
78
79    /**
80     * Option flag recording that the legacy `.htaccess` block has been removed.
81     *
82     * @var string
83     */
84    private const CLEANUP_FLAG = 'nfd_cf_opt_cookie_htaccess_cleaned';
85
86    /**
87     * Constructor to register hooks.
88     *
89     * @since 1.0.0
90     *
91     * @param mixed $container Optional DI container retained for backwards compatibility.
92     */
93    public function __construct( $container = null ) {
94        // Set the Cloudflare optimization cookie from the front end, client-side,
95        // so HTML responses stay cacheable.
96        add_action( 'wp_head', array( $this, 'print_cookie_script' ), 0 );
97
98        // Keep image/font settings in sync when site capabilities change.
99        add_action( 'set_transient_nfd_site_capabilities', array( $this, 'on_site_capabilities_change' ), 10, 2 );
100
101        // One-time removal of the obsolete Set-Cookie `.htaccess` block.
102        add_action( 'init', array( $this, 'maybe_remove_legacy_htaccess_fragment' ) );
103    }
104
105    /**
106     * Callback for when the `nfd_site_capabilities` transient is set.
107     *
108     * Triggers a refresh of image and font optimization settings based on updated
109     * site capabilities.
110     *
111     * @since 1.0.0
112     *
113     * @param mixed $value The value being set in the transient.
114     * @return void
115     */
116    public function on_site_capabilities_change( $value ): void {
117        if ( is_array( $value ) ) {
118            ImageSettings::maybe_refresh_with_capabilities( $value );
119            FontSettings::maybe_refresh_with_capabilities( $value );
120        }
121    }
122
123    /**
124     * Compute the deterministic cookie value for the currently-enabled CF features.
125     *
126     * Mirrors the historical encoding exactly — concatenated 8-char sha1 prefixes
127     * for mirage, polish, and fonts in that order — so the value the Cloudflare
128     * edge rules already key on is unchanged. Returns an empty string when no
129     * features are enabled.
130     *
131     * @since 1.0.0
132     *
133     * @return string
134     */
135    private function get_cookie_value(): string {
136        $image_settings = get_option( 'nfd_image_optimization', array() );
137        $fonts_settings = get_option( 'nfd_fonts_optimization', array() );
138
139        $images_cloudflare = isset( $image_settings['cloudflare'] ) ? (array) $image_settings['cloudflare'] : array();
140        $fonts_cloudflare  = isset( $fonts_settings['cloudflare'] ) ? (array) $fonts_settings['cloudflare'] : array();
141
142        $mirage_enabled = ! empty( $images_cloudflare['mirage']['value'] );
143        $polish_enabled = ! empty( $images_cloudflare['polish']['value'] );
144        $fonts_enabled  = ! empty( $fonts_cloudflare['fonts']['value'] );
145
146        $mirage_hash = $mirage_enabled ? substr( sha1( 'mirage' ), 0, 8 ) : '';
147        $polish_hash = $polish_enabled ? substr( sha1( 'polish' ), 0, 8 ) : '';
148        $fonts_hash  = $fonts_enabled ? substr( sha1( 'fonts' ), 0, 8 ) : '';
149
150        return "{$mirage_hash}{$polish_hash}{$fonts_hash}";
151    }
152
153    /**
154     * Print a tiny inline script that sets the CF optimization cookie client-side.
155     *
156     * Hooked early on `wp_head` (front end only) so the cookie is set before the
157     * parser reaches most sub-resource requests. When no CF features are enabled
158     * nothing is printed and any existing cookie simply expires.
159     *
160     * @since 1.0.0
161     *
162     * @return void
163     */
164    public function print_cookie_script(): void {
165        $value = $this->get_cookie_value();
166
167        if ( '' === $value ) {
168            return;
169        }
170
171        $script = sprintf(
172            "(function(){var n=%s,v=%s;if(document.cookie.indexOf(n+'='+v)===-1){document.cookie=n+'='+v+'; path=/; max-age=%d; SameSite=Lax';}})();",
173            wp_json_encode( self::COOKIE_NAME ),
174            wp_json_encode( $value ),
175            self::COOKIE_TTL
176        );
177
178        if ( function_exists( 'wp_print_inline_script_tag' ) ) {
179            wp_print_inline_script_tag( $script );
180            return;
181        }
182
183        echo '<script>' . $script . '</script>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- value is a sha1 hex string encoded via wp_json_encode.
184    }
185
186    /**
187     * Remove the obsolete Set-Cookie `.htaccess` block from sites that still have it.
188     *
189     * Runs EXACTLY ONCE per site (guarded by an option flag set up front, so it can
190     * never run on more than one request — no per-request retry loop). This touches
191     * ONLY the Cloudflare optimization block: it removes the single state block keyed
192     * by {@see self::FRAGMENT_ID} and the one `# BEGIN/END {@see self::MARKER}` region
193     * from the persisted body, leaving the rest of the state byte-for-byte intact. It
194     * deliberately does NOT call the htaccess unregister API, which would recompose the
195     * whole body from block entries; everything else any module wrote is preserved.
196     *
197     * The persisted state is read directly from its option, which is always available
198     * on `init` regardless of whether the htaccess Manager has booted — so a single
199     * pass is authoritative and there is no boot-timing race that would justify
200     * retrying.
201     *
202     * @since 1.0.0
203     *
204     * @return void
205     */
206    public function maybe_remove_legacy_htaccess_fragment(): void {
207        if ( $this->is_cleanup_done() ) {
208            return;
209        }
210
211        // Record completion immediately so this runs once and only once per site, even
212        // if the state is a no-op (clean site) or in an unexpected shape. The state is
213        // read directly from its option below, so one pass is authoritative.
214        $this->set_cleanup_done();
215
216        // Surgically remove ONLY the CF optimization block from the persisted state.
217        // apply reuses the persisted body verbatim, so every other block is kept.
218        if ( $this->strip_legacy_block_from_state() && class_exists( HtaccessApi::class ) ) {
219            HtaccessApi::queue_apply( 'nfd-cf-opt-legacy-cleanup' );
220        }
221    }
222
223    /**
224     * Strip the legacy CF optimization block from the htaccess module's saved state.
225     *
226     * Removes both a fragment entry keyed by {@see self::FRAGMENT_ID} and the block
227     * text from the composed body, then persists the change. The body is what the
228     * htaccess module writes to disk on its next apply, and clearing it also stops the
229     * block being re-detected as a legacy marker label on subsequent applies.
230     *
231     * @since 1.0.0
232     *
233     * @return bool True if the state was modified.
234     */
235    private function strip_legacy_block_from_state(): bool {
236        $state = $this->get_htaccess_state();
237
238        if ( ! is_array( $state ) ) {
239            return false;
240        }
241
242        $changed = false;
243
244        if ( isset( $state['blocks'] ) && is_array( $state['blocks'] ) && array_key_exists( self::FRAGMENT_ID, $state['blocks'] ) ) {
245            unset( $state['blocks'][ self::FRAGMENT_ID ] );
246            $changed = true;
247        }
248
249        if ( isset( $state['body'] ) && is_string( $state['body'] ) && false !== strpos( $state['body'], '# BEGIN ' . self::MARKER ) ) {
250            $clean = $this->remove_marker_block( $state['body'], self::MARKER );
251            if ( $clean !== $state['body'] ) {
252                $state['body']     = $clean;
253                $state['checksum'] = hash( 'sha256', $clean );
254                $changed           = true;
255            }
256        }
257
258        if ( ! $changed ) {
259            return false;
260        }
261
262        $this->update_htaccess_state( $state );
263
264        return true;
265    }
266
267    /**
268     * Remove a `# BEGIN <marker> ... # END <marker>` block from htaccess body text.
269     *
270     * Tolerant of leading whitespace and CR/LF variants; collapses the blank lines
271     * left behind so the remaining body stays well formed.
272     *
273     * @since 1.0.0
274     *
275     * @param string $body   The htaccess body text.
276     * @param string $marker The marker label to remove.
277     * @return string
278     */
279    private function remove_marker_block( string $body, string $marker ): string {
280        $quoted  = preg_quote( $marker, '/' );
281        $pattern = '/(?:\r\n|\r|\n)*[ \t]*#[ \t]*BEGIN[ \t]+' . $quoted . '\b.*?#[ \t]*END[ \t]+' . $quoted . '[^\r\n]*/s';
282
283        $count   = 0;
284        $cleaned = preg_replace( $pattern, '', $body, -1, $count );
285
286        // Only treat the body as changed when a complete BEGIN..END block was actually
287        // removed. A partial/malformed marker leaves the body untouched so the caller
288        // does not write or trigger a rewrite for a no-op.
289        if ( null === $cleaned || 0 === $count ) {
290            return $body;
291        }
292
293        $cleaned = preg_replace( '/(?:\r\n|\r|\n){3,}/', "\n\n", $cleaned );
294
295        return trim( (string) $cleaned, "\r\n" );
296    }
297
298    /**
299     * Read the htaccess module's persisted state from its option.
300     *
301     * @since 1.0.0
302     *
303     * @return array
304     */
305    private function get_htaccess_state(): array {
306        $state = is_multisite()
307            ? get_site_option( self::HTACCESS_STATE_OPTION, array() )
308            : get_option( self::HTACCESS_STATE_OPTION, array() );
309
310        return is_array( $state ) ? $state : array();
311    }
312
313    /**
314     * Persist the htaccess module's state back to its option.
315     *
316     * @since 1.0.0
317     *
318     * @param array $state State to persist.
319     * @return void
320     */
321    private function update_htaccess_state( array $state ): void {
322        if ( is_multisite() ) {
323            update_site_option( self::HTACCESS_STATE_OPTION, $state );
324            return;
325        }
326
327        update_option( self::HTACCESS_STATE_OPTION, $state );
328    }
329
330    /**
331     * Whether the one-time cleanup has already completed.
332     *
333     * @since 1.0.0
334     *
335     * @return bool
336     */
337    private function is_cleanup_done(): bool {
338        return (bool) ( is_multisite()
339            ? get_site_option( self::CLEANUP_FLAG )
340            : get_option( self::CLEANUP_FLAG ) );
341    }
342
343    /**
344     * Record that the one-time cleanup has completed.
345     *
346     * Stored autoloaded so the guard read on every `init` costs no extra query once
347     * the flag is set.
348     *
349     * @since 1.0.0
350     *
351     * @return void
352     */
353    private function set_cleanup_done(): void {
354        if ( is_multisite() ) {
355            update_site_option( self::CLEANUP_FLAG, true );
356            return;
357        }
358
359        update_option( self::CLEANUP_FLAG, true, true );
360    }
361}