PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 2.11.0
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v2.11.0
3.0.0 2.11.12 2.11.11 2.11.10 2.11.9 2.11.7 2.11.8 2.11.6 2.11.5 2.11.4 2.11.3 2.11.1 2.11.2 2.11.0 2.10.5 2.10.4 2.10.3 2.10.2 2.10.1 2.10.0 2.9.9 2.9.8 2.9.6 2.9.7 2.9.5 All 88 releases
vigilante / includes / class-htaccess-manager.php

class-htaccess-manager.php in Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… 2.11.0, at includes/class-htaccess-manager.php

810 lines 26.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * HTAccess Manager Class
4 *
5 * Centralized, safe management of .htaccess modifications
6 * Used by both Firewall and Security Headers modules
7 *
8 * @package Vigilante
9 */
10
11 // Prevent direct access
12 if ( ! defined( 'ABSPATH' ) ) {
13 exit;
14 }
15
16 /**
17 * Class Vigilante_Htaccess_Manager
18 *
19 * Provides atomic, safe operations on .htaccess file
20 */
21 class Vigilante_Htaccess_Manager {
22
23 /**
24 * Singleton instance
25 *
26 * @var Vigilante_Htaccess_Manager
27 */
28 private static $instance = null;
29
30 /**
31 * Option where the server software seen in a web request is remembered
32 *
33 * WP-CLI has no request to look at, so the detection made from the web is
34 * kept here and used as the fallback. See is_apache().
35 *
36 * @since 2.9.9
37 *
38 * @var string
39 */
40 const SERVER_OPTION = 'vigilante_server_software';
41
42 /**
43 * Path to .htaccess file
44 *
45 * @var string
46 */
47 private $htaccess_path;
48
49 /**
50 * Known block markers (start => end)
51 *
52 * @var array
53 */
54 /**
55 * Option holding the write lock, and how long a held lock is believed.
56 *
57 * @since 2.10.0
58 */
59 const LOCK_OPTION = 'vigilante_htaccess_write_lock';
60 const LOCK_TIMEOUT = 30;
61
62 /**
63 * Rolling history of replaced .htaccess versions, and the one-off snapshot
64 * taken from a site the 2.9.8 migration had already wiped.
65 *
66 * @since 2.10.0
67 */
68 const HISTORY_OPTION = 'vigilante_htaccess_history';
69 const HISTORY_ENTRIES = 5;
70 const HISTORY_MAX_BYTES = 262144;
71
72 private $known_blocks = array(
73 '# BEGIN Vigilante Protection' => '# END Vigilante Protection',
74 '# BEGIN Vigilante Security Headers' => '# END Vigilante Security Headers',
75 '# BEGIN WordPress' => '# END WordPress',
76 );
77
78 /**
79 * Get singleton instance
80 *
81 * @return Vigilante_Htaccess_Manager
82 */
83 public static function get_instance() {
84 if ( null === self::$instance ) {
85 self::$instance = new self();
86 }
87 return self::$instance;
88 }
89
90 /**
91 * Constructor
92 */
93 private function __construct() {
94 $this->htaccess_path = ABSPATH . '.htaccess';
95 }
96
97 /**
98 * Add or update a block in .htaccess
99 *
100 * @param string $marker_start Start marker (e.g. "# BEGIN Vigilante Protection").
101 * @param string $marker_end End marker (e.g. "# END Vigilante Protection").
102 * @param string $rules Rules content (without markers).
103 * @param string $position Where to add: 'top' or 'before_wordpress'.
104 * @return bool|WP_Error
105 */
106 public function add_block( $marker_start, $marker_end, $rules, $position = 'top', $automatic = false ) {
107 /*
108 * On a network the root .htaccess is shared by every site, so only the
109 * main site writes it.
110 *
111 * Which question to ask depends on who is asking. A write a person
112 * started from a settings screen has to clear the capability too, so one
113 * site's administrator cannot overwrite what the network decided. A write
114 * Vigilant performs by itself only has to come from the right site: it
115 * makes no decision, and demanding a capability of it means demanding one
116 * of whichever visitor happened to trigger the request, which nobody has.
117 *
118 * Getting that distinction wrong is what shipped in 2.10.0, and it is why
119 * no network ever had its .htaccess refreshed after an update.
120 */
121 $allowed = $automatic
122 ? Vigilante_Settings::owns_shared_files()
123 : Vigilante_Settings::can_write_shared_files();
124
125 if ( ! $allowed ) {
126 return new WP_Error( 'network_not_owner', Vigilante_Settings::get_shared_files_notice() );
127 }
128
129 /*
130 * One writer at a time. maybe_sync_server_files() runs on init, on every
131 * request, so the first visitors after an update can enter this
132 * read-modify-write at the same moment. Two concurrent writers of
133 * *different* blocks is the case that bites: the second one read the file
134 * before the first one wrote, so its write drops the block the first one
135 * had just added.
136 */
137 if ( ! $this->acquire_lock() ) {
138 return new WP_Error( 'locked', __( 'Another process is writing .htaccess right now', 'vigilante' ) );
139 }
140
141 try {
142 // Read current content
143 $original = $this->read_file();
144 if ( false === $original ) {
145 $original = '';
146 }
147
148 // Create backup before modification
149 if ( ! empty( $original ) ) {
150 $this->create_backup( $original );
151 }
152
153 // Remove existing block if present
154 $content = $this->remove_block_from_content( $original, $marker_start, $marker_end );
155
156 // Build new block
157 $block = $marker_start . "\n" . $rules . "\n" . $marker_end;
158
159 // Insert at correct position
160 $new_content = $this->insert_block( $content, $block, $position );
161
162 // Validate result
163 if ( ! $this->validate_content( $new_content ) ) {
164 return new WP_Error( 'invalid_result', __( 'Resulting .htaccess would be invalid', 'vigilante' ) );
165 }
166
167 // Write file
168 if ( ! $this->write_file( $new_content ) ) {
169 return new WP_Error( 'write_failed', __( 'Failed to write .htaccess', 'vigilante' ) );
170 }
171
172 /*
173 * Read back what actually landed. Writing is not the same as having
174 * written: a truncated write, a full disk or a filesystem layer that
175 * quietly mangles the content would otherwise leave the site serving a
176 * broken .htaccess with nobody the wiser, which on this file means a
177 * 500 on every page. If the file on disk does not validate, or does
178 * not contain the block that was just added, put back exactly what was
179 * there before and report the failure instead of walking away.
180 */
181 $written = $this->read_file();
182
183 if ( false === $written
184 || ! $this->validate_content( $written )
185 || false === strpos( $written, $marker_start )
186 ) {
187 if ( '' !== $original ) {
188 $this->write_file( $original );
189 }
190
191 return new WP_Error( 'verify_failed', __( 'The .htaccess was written but did not read back as expected, so the previous content was restored', 'vigilante' ) );
192 }
193
194 return true;
195 } finally {
196 $this->release_lock();
197 }
198 }
199
200 /**
201 * Take the write lock, or fail if another process holds it.
202 *
203 * add_option() is the atomic part: option_name carries a unique index, so
204 * exactly one caller can create the row. A lock older than the timeout is
205 * treated as abandoned (a fatal between acquire and release) and taken over,
206 * otherwise a single crash would freeze every future write.
207 *
208 * @since 2.10.0
209 * @return bool
210 */
211 private function acquire_lock() {
212 $now = time();
213 $held = get_option( self::LOCK_OPTION );
214
215 if ( false !== $held && is_numeric( $held ) && ( $now - (int) $held ) < self::LOCK_TIMEOUT ) {
216 return false;
217 }
218
219 if ( false !== $held ) {
220 // Abandoned lock: take it over.
221 update_option( self::LOCK_OPTION, $now, false );
222 return true;
223 }
224
225 return (bool) add_option( self::LOCK_OPTION, $now, '', false );
226 }
227
228 /**
229 * Release the write lock.
230 *
231 * @since 2.10.0
232 */
233 private function release_lock() {
234 delete_option( self::LOCK_OPTION );
235 }
236
237 /**
238 * Remove a block from .htaccess
239 *
240 * Takes the same write lock as add_block(): until 2.11.0 this
241 * read-modify-write ran unlocked, so a removal racing an addition of a
242 * different block could drop the block that had just been written (S5).
243 *
244 * @param string $marker_start Start marker.
245 * @param string $marker_end End marker.
246 * @param bool $automatic True when Vigilant removes the block by itself
247 * (a mode expiring on cron), false when a person
248 * asked for it. Same distinction as add_block().
249 * @return bool|WP_Error
250 */
251 public function remove_block( $marker_start, $marker_end, $automatic = false ) {
252 $allowed = $automatic
253 ? Vigilante_Settings::owns_shared_files()
254 : Vigilante_Settings::can_write_shared_files();
255
256 if ( ! $allowed ) {
257 return new WP_Error( 'network_not_owner', Vigilante_Settings::get_shared_files_notice() );
258 }
259
260 if ( ! $this->acquire_lock() ) {
261 return new WP_Error( 'locked', __( 'Another process is writing .htaccess right now', 'vigilante' ) );
262 }
263
264 try {
265 // Read current content
266 $content = $this->read_file();
267
268 if ( false === $content || empty( $content ) ) {
269 return true; // Nothing to remove
270 }
271
272 // Check if block exists
273 if ( strpos( $content, $marker_start ) === false ) {
274 return true; // Block doesn't exist, nothing to do
275 }
276
277 // Create backup before modification
278 $this->create_backup( $content );
279
280 // Remove the block
281 $new_content = $this->remove_block_from_content( $content, $marker_start, $marker_end );
282
283 // Validate result - WordPress rules should still be there if they were before
284 if ( strpos( $content, '# BEGIN WordPress' ) !== false &&
285 strpos( $new_content, '# BEGIN WordPress' ) === false ) {
286 // WordPress rules were removed - this is wrong, restore backup
287 $this->restore_backup();
288 return new WP_Error( 'wordpress_rules_lost', __( 'Operation would remove WordPress rules, aborted', 'vigilante' ) );
289 }
290
291 // Write file
292 if ( $this->write_file( $new_content ) ) {
293 return true;
294 }
295
296 // Write failed, restore backup
297 $this->restore_backup();
298 return new WP_Error( 'write_failed', __( 'Failed to write .htaccess', 'vigilante' ) );
299 } finally {
300 $this->release_lock();
301 }
302 }
303
304 /**
305 * Check if a block exists in .htaccess
306 *
307 * @param string $marker_start Start marker.
308 * @return bool
309 */
310 public function block_exists( $marker_start ) {
311 $content = $this->read_file();
312 if ( false === $content ) {
313 return false;
314 }
315 return strpos( $content, $marker_start ) !== false;
316 }
317
318 /**
319 * Remove a specific block from content string
320 *
321 * @param string $content Content to modify.
322 * @param string $marker_start Start marker.
323 * @param string $marker_end End marker.
324 * @return string Modified content.
325 */
326 private function remove_block_from_content( $content, $marker_start, $marker_end ) {
327 if ( strpos( $content, $marker_start ) === false ) {
328 return $content;
329 }
330
331 // Use line-by-line approach for safety (regex can be unpredictable)
332 $lines = explode( "\n", $content );
333 $new_lines = array();
334 $inside_block = false;
335
336 foreach ( $lines as $line ) {
337 // Check for start marker
338 if ( trim( $line ) === $marker_start ) {
339 $inside_block = true;
340 continue;
341 }
342
343 // Check for end marker
344 if ( trim( $line ) === $marker_end ) {
345 $inside_block = false;
346 continue;
347 }
348
349 // Add line if not inside our block
350 if ( ! $inside_block ) {
351 $new_lines[] = $line;
352 }
353 }
354
355 // Join and clean up multiple empty lines
356 $result = implode( "\n", $new_lines );
357 $result = preg_replace( '/\n{3,}/', "\n\n", $result );
358 $result = trim( $result );
359
360 return $result;
361 }
362
363 /**
364 * Insert a block at the specified position
365 *
366 * @param string $content Current content.
367 * @param string $block Block to insert.
368 * @param string $position Position: 'top' or 'before_wordpress'.
369 * @return string Modified content.
370 */
371 private function insert_block( $content, $block, $position ) {
372 $content = trim( $content );
373
374 if ( empty( $content ) ) {
375 return $block . "\n";
376 }
377
378 if ( 'before_wordpress' === $position ) {
379 $pos = stripos( $content, '# BEGIN WordPress' );
380
381 /*
382 * Spliced by offset, never with preg_replace().
383 *
384 * The block used to be passed as the replacement argument, where
385 * "$1" and "\\1" are backreference syntax and a trailing backslash
386 * escapes whatever follows it. That silently ate the escaping that
387 * generate_whitelist_exceptions() had just applied: a User-Agent
388 * whitelist entry ending in a backslash reached the file as
389 * `"!Bot\\" [NC]`, the backslash escaped the closing quote, and
390 * Apache answered 500 for the whole site. Reproduced from the
391 * settings screen on 25 aug 2026. substr() copies the block
392 * verbatim, which is the only correct thing to do here.
393 */
394 if ( false !== $pos ) {
395 return substr( $content, 0, $pos ) . $block . "\n\n" . substr( $content, $pos );
396 }
397 }
398
399 // Default: insert at top
400 return $block . "\n\n" . $content;
401 }
402
403 /**
404 * Validate .htaccess content
405 *
406 * @param string $content Content to validate.
407 * @return bool
408 */
409 private function validate_content( $content ) {
410 // Empty content is valid (but unusual)
411 if ( empty( trim( $content ) ) ) {
412 return true;
413 }
414
415 // Check for unmatched block markers
416 foreach ( $this->known_blocks as $start => $end ) {
417 $has_start = strpos( $content, $start ) !== false;
418 $has_end = strpos( $content, $end ) !== false;
419
420 // If has start, must have end (and vice versa)
421 if ( $has_start !== $has_end ) {
422 return false;
423 }
424
425 // Start must come before end
426 if ( $has_start && $has_end ) {
427 if ( strpos( $content, $start ) > strpos( $content, $end ) ) {
428 return false;
429 }
430 }
431 }
432
433 // Basic check: if it starts with PHP code, it's wrong
434 if ( preg_match( '/^<\?php/i', trim( $content ) ) ) {
435 return false;
436 }
437
438 /*
439 * Every directive argument has to close the quotes it opens. A stray
440 * backslash right before the closing quote escapes it, the directive
441 * runs on into the rest of the line and Apache answers 500 for the
442 * whole site. That is not hypothetical: until 2.10.0 a User-Agent
443 * whitelist entry ending in a backslash did exactly that, and this
444 * function waved it through because the only syntax check it had was
445 * an unused array of patterns.
446 *
447 * Escaped pairs are removed first, so a legitimate \\" or \\\\ inside an
448 * argument is not miscounted.
449 */
450 foreach ( preg_split( '/\r\n|\r|\n/', $content ) as $line ) {
451 $unescaped = str_replace( array( '\\\\', '\\"' ), '', $line );
452
453 if ( 0 !== ( substr_count( $unescaped, '"' ) % 2 ) ) {
454 return false;
455 }
456 }
457
458 /*
459 * Container tags have to balance. An unclosed <IfModule> swallows every
460 * directive below it, including the ones WordPress itself wrote.
461 */
462 if ( preg_match_all( '/^\s*<IfModule\b/im', $content ) !== preg_match_all( '/^\s*<\/IfModule\s*>/im', $content ) ) {
463 return false;
464 }
465
466 return true;
467 }
468
469 /**
470 * Read .htaccess file
471 *
472 * @return string|false
473 */
474 private function read_file() {
475 if ( ! file_exists( $this->htaccess_path ) ) {
476 return '';
477 }
478
479 if ( ! is_readable( $this->htaccess_path ) ) {
480 return false;
481 }
482
483 $content = file_get_contents( $this->htaccess_path ); // phpcs:ignore
484
485 return ( false !== $content ) ? $content : false;
486 }
487
488 /**
489 * Write .htaccess file
490 *
491 * @param string $content Content to write.
492 * @return bool
493 */
494 private function write_file( $content ) {
495 // Ensure content ends with newline
496 $content = rtrim( $content ) . "\n";
497
498 // Initialize WP_Filesystem
499 global $wp_filesystem;
500 if ( ! function_exists( 'WP_Filesystem' ) ) {
501 require_once ABSPATH . 'wp-admin/includes/file.php';
502 }
503 WP_Filesystem();
504
505 if ( ! $wp_filesystem ) {
506 return false;
507 }
508
509 // Check writability
510 if ( file_exists( $this->htaccess_path ) ) {
511 if ( ! $wp_filesystem->is_writable( $this->htaccess_path ) ) {
512 return false;
513 }
514 } else {
515 if ( ! $wp_filesystem->is_writable( dirname( $this->htaccess_path ) ) ) {
516 return false;
517 }
518 }
519
520 // Write with WP_Filesystem
521 return $wp_filesystem->put_contents( $this->htaccess_path, $content, FS_CHMOD_FILE );
522 }
523
524 /**
525 * Create backup of current .htaccess
526 *
527 * @param string $content Content to backup.
528 * @return bool
529 */
530 private function create_backup( $content ) {
531 $this->push_history( (string) $content );
532
533 // Store the backup in a private database option instead of a file under
534 // the web root, so it can never be served over HTTP.
535 $stored = update_option(
536 'vigilante_htaccess_backup',
537 array(
538 'content' => (string) $content,
539 'time' => time(),
540 ),
541 false
542 );
543
544 // update_option() also returns false when the value is unchanged.
545 return ( false !== $stored ) || ( (string) $content === $this->get_backup_content() );
546 }
547
548 /**
549 * Keep the last few .htaccess versions, newest first.
550 *
551 * The single-slot backup above is the rollback buffer: it is overwritten by
552 * the very next write, which is right for its job and useless for anything
553 * else. Something that only becomes visible days later, such as a header
554 * that quietly stopped being sent, needs more than one step of history.
555 *
556 * Kept in the database with autoload off, never in a file under the web
557 * root. Bounded on both axes so a large .htaccess cannot inflate the
558 * options table: oversized files are not stored at all, rather than stored
559 * truncated, because half an .htaccess is worse than none.
560 *
561 * @since 2.10.0
562 * @param string $content Content being replaced.
563 */
564 private function push_history( $content ) {
565 if ( '' === $content || strlen( $content ) > self::HISTORY_MAX_BYTES ) {
566 return;
567 }
568
569 $history = get_option( self::HISTORY_OPTION );
570 $history = is_array( $history ) ? $history : array();
571
572 // Nothing changed, nothing to record.
573 if ( isset( $history[0]['content'] ) && $history[0]['content'] === $content ) {
574 return;
575 }
576
577 array_unshift(
578 $history,
579 array(
580 'content' => $content,
581 'time' => time(),
582 'version' => VIGILANTE_VERSION,
583 )
584 );
585
586 update_option( self::HISTORY_OPTION, array_slice( $history, 0, self::HISTORY_ENTRIES ), false );
587 }
588
589 /**
590 * Get the stored .htaccess backup content, or '' if none.
591 *
592 * @return string
593 */
594 private function get_backup_content() {
595 $backup = get_option( 'vigilante_htaccess_backup' );
596 return ( is_array( $backup ) && isset( $backup['content'] ) ) ? (string) $backup['content'] : '';
597 }
598
599 /**
600 * Restore .htaccess from backup
601 *
602 * @return bool
603 */
604 public function restore_backup() {
605 $content = $this->get_backup_content();
606
607 if ( '' === $content ) {
608 return false;
609 }
610
611 return $this->write_file( $content );
612 }
613
614 /**
615 * Check if server is Apache/LiteSpeed
616 *
617 * @return bool
618 */
619 public function is_apache() {
620 $detected = null;
621
622 if ( function_exists( 'apache_get_modules' ) ) {
623 $detected = true;
624 } else {
625 $server = isset( $_SERVER['SERVER_SOFTWARE'] )
626 ? sanitize_text_field( wp_unslash( $_SERVER['SERVER_SOFTWARE'] ) )
627 : '';
628
629 if ( '' !== $server ) {
630 $detected = self::looks_like_apache( $server );
631
632 // Remember it, because a WP-CLI run has no request to look at.
633 if ( get_option( self::SERVER_OPTION ) !== $server ) {
634 update_option( self::SERVER_OPTION, $server, false );
635 }
636 }
637 }
638
639 /*
640 * Nothing in this request to go on, which is exactly what happens under
641 * WP-CLI: apache_get_modules() only exists under mod_php and
642 * SERVER_SOFTWARE is not defined on the command line. Until 2.9.9 that
643 * answered "not Apache" and every .htaccess write was refused, so a site
644 * activated with `wp plugin activate` silently got no server layer at
645 * all while the switches showed as on. So fall back to what a web
646 * request taught us earlier.
647 */
648 if ( null === $detected ) {
649 $remembered = (string) get_option( self::SERVER_OPTION, '' );
650
651 if ( '' !== $remembered ) {
652 $detected = self::looks_like_apache( $remembered );
653 }
654 }
655
656 /**
657 * Filter the Apache/LiteSpeed detection.
658 *
659 * The escape hatch for a site deployed entirely from the command line,
660 * where there has never been a web request to learn from.
661 *
662 * @since 2.9.9
663 *
664 * @param bool|null $detected True, false, or null when it could not be told.
665 */
666 $detected = apply_filters( 'vigilante_is_apache', $detected );
667
668 return ( true === $detected );
669 }
670
671 /**
672 * Vigilant blocks sitting in .htaccess files above the WordPress directory
673 *
674 * Apache applies the .htaccess of every directory above the one being
675 * served, and this class only ever writes and reads the one in ABSPATH. So
676 * a WordPress in a subfolder can be receiving rules from the block that the
677 * Vigilant of the parent installation left in the document root: the
678 * settings screen says the header is off, headers_list() does not show it,
679 * and the browser receives it all the same. Costed two rounds of diagnosis
680 * on a real site before it was understood, so it is worth naming the file.
681 *
682 * @since 2.9.9
683 *
684 * @return array<string,string[]> Absolute file path => markers found inside.
685 */
686 public function find_blocks_above() {
687 global $wp_filesystem;
688
689 if ( ! function_exists( 'WP_Filesystem' ) ) {
690 require_once ABSPATH . 'wp-admin/includes/file.php';
691 }
692 WP_Filesystem();
693
694 if ( ! $wp_filesystem ) {
695 return array();
696 }
697
698 $found = array();
699 $markers = array_keys( $this->known_blocks );
700 $dir = dirname( $this->htaccess_path );
701
702 // Bounded walk up to the filesystem root. Eight levels is well past any
703 // real docroot and keeps this cheap on a deep path.
704 for ( $level = 0; $level < 8; $level++ ) {
705 $parent = dirname( $dir );
706
707 if ( $parent === $dir || '' === $parent || '.' === $parent ) {
708 break;
709 }
710
711 $dir = $parent;
712 $file = $dir . '/.htaccess';
713
714 if ( ! $wp_filesystem->exists( $file ) || ! $wp_filesystem->is_readable( $file ) ) {
715 continue;
716 }
717
718 $content = $wp_filesystem->get_contents( $file );
719
720 if ( ! is_string( $content ) || '' === $content ) {
721 continue;
722 }
723
724 $hits = array();
725 foreach ( $markers as $marker ) {
726 // The WordPress block is not ours, only the Vigilant ones count.
727 if ( false === strpos( $marker, 'Vigilante' ) ) {
728 continue;
729 }
730 if ( false !== strpos( $content, $marker ) ) {
731 $hits[] = $marker;
732 }
733 }
734
735 if ( ! empty( $hits ) ) {
736 $found[ $file ] = $hits;
737 }
738 }
739
740 return $found;
741 }
742
743 /**
744 * Whether a SERVER_SOFTWARE string is Apache or LiteSpeed
745 *
746 * @since 2.9.9
747 *
748 * @param string $server Server software string.
749 * @return bool
750 */
751 private static function looks_like_apache( $server ) {
752 return ( false !== stripos( $server, 'apache' ) || false !== stripos( $server, 'litespeed' ) );
753 }
754
755 /**
756 * Whether the server could not be identified in this request
757 *
758 * Tells "we know it is not Apache" apart from "we cannot tell from here",
759 * which is what a WP-CLI run gets. The caller uses it to leave the work
760 * pending for the first web request instead of dropping it.
761 *
762 * @since 2.9.9
763 *
764 * @return bool
765 */
766 public function server_is_unknown() {
767 if ( function_exists( 'apache_get_modules' ) ) {
768 return false;
769 }
770
771 if ( ! empty( $_SERVER['SERVER_SOFTWARE'] ) ) {
772 return false;
773 }
774
775 return ( '' === (string) get_option( self::SERVER_OPTION, '' ) );
776 }
777
778 /**
779 * Check if .htaccess is writable
780 *
781 * @return bool
782 */
783 public function is_writable() {
784 // Initialize WP_Filesystem
785 global $wp_filesystem;
786 if ( ! function_exists( 'WP_Filesystem' ) ) {
787 require_once ABSPATH . 'wp-admin/includes/file.php';
788 }
789 WP_Filesystem();
790
791 if ( ! $wp_filesystem ) {
792 return false;
793 }
794
795 if ( file_exists( $this->htaccess_path ) ) {
796 return $wp_filesystem->is_writable( $this->htaccess_path );
797 }
798 return $wp_filesystem->is_writable( ABSPATH );
799 }
800
801 /**
802 * Get current .htaccess content (for debugging)
803 *
804 * @return string
805 */
806 public function get_content() {
807 $content = $this->read_file();
808 return ( false !== $content ) ? $content : '';
809 }
810 }