PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 2.10.3
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v2.10.3
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.10.3, at includes/class-htaccess-manager.php

791 lines 25.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 * @param string $marker_start Start marker.
241 * @param string $marker_end End marker.
242 * @return bool|WP_Error
243 */
244 public function remove_block( $marker_start, $marker_end ) {
245 if ( ! Vigilante_Settings::can_write_shared_files() ) {
246 return new WP_Error( 'network_not_owner', Vigilante_Settings::get_shared_files_notice() );
247 }
248
249 // Read current content
250 $content = $this->read_file();
251
252 if ( false === $content || empty( $content ) ) {
253 return true; // Nothing to remove
254 }
255
256 // Check if block exists
257 if ( strpos( $content, $marker_start ) === false ) {
258 return true; // Block doesn't exist, nothing to do
259 }
260
261 // Create backup before modification
262 $this->create_backup( $content );
263
264 // Remove the block
265 $new_content = $this->remove_block_from_content( $content, $marker_start, $marker_end );
266
267 // Validate result - WordPress rules should still be there if they were before
268 if ( strpos( $content, '# BEGIN WordPress' ) !== false &&
269 strpos( $new_content, '# BEGIN WordPress' ) === false ) {
270 // WordPress rules were removed - this is wrong, restore backup
271 $this->restore_backup();
272 return new WP_Error( 'wordpress_rules_lost', __( 'Operation would remove WordPress rules, aborted', 'vigilante' ) );
273 }
274
275 // Write file
276 if ( $this->write_file( $new_content ) ) {
277 return true;
278 }
279
280 // Write failed, restore backup
281 $this->restore_backup();
282 return new WP_Error( 'write_failed', __( 'Failed to write .htaccess', 'vigilante' ) );
283 }
284
285 /**
286 * Check if a block exists in .htaccess
287 *
288 * @param string $marker_start Start marker.
289 * @return bool
290 */
291 public function block_exists( $marker_start ) {
292 $content = $this->read_file();
293 if ( false === $content ) {
294 return false;
295 }
296 return strpos( $content, $marker_start ) !== false;
297 }
298
299 /**
300 * Remove a specific block from content string
301 *
302 * @param string $content Content to modify.
303 * @param string $marker_start Start marker.
304 * @param string $marker_end End marker.
305 * @return string Modified content.
306 */
307 private function remove_block_from_content( $content, $marker_start, $marker_end ) {
308 if ( strpos( $content, $marker_start ) === false ) {
309 return $content;
310 }
311
312 // Use line-by-line approach for safety (regex can be unpredictable)
313 $lines = explode( "\n", $content );
314 $new_lines = array();
315 $inside_block = false;
316
317 foreach ( $lines as $line ) {
318 // Check for start marker
319 if ( trim( $line ) === $marker_start ) {
320 $inside_block = true;
321 continue;
322 }
323
324 // Check for end marker
325 if ( trim( $line ) === $marker_end ) {
326 $inside_block = false;
327 continue;
328 }
329
330 // Add line if not inside our block
331 if ( ! $inside_block ) {
332 $new_lines[] = $line;
333 }
334 }
335
336 // Join and clean up multiple empty lines
337 $result = implode( "\n", $new_lines );
338 $result = preg_replace( '/\n{3,}/', "\n\n", $result );
339 $result = trim( $result );
340
341 return $result;
342 }
343
344 /**
345 * Insert a block at the specified position
346 *
347 * @param string $content Current content.
348 * @param string $block Block to insert.
349 * @param string $position Position: 'top' or 'before_wordpress'.
350 * @return string Modified content.
351 */
352 private function insert_block( $content, $block, $position ) {
353 $content = trim( $content );
354
355 if ( empty( $content ) ) {
356 return $block . "\n";
357 }
358
359 if ( 'before_wordpress' === $position ) {
360 $pos = stripos( $content, '# BEGIN WordPress' );
361
362 /*
363 * Spliced by offset, never with preg_replace().
364 *
365 * The block used to be passed as the replacement argument, where
366 * "$1" and "\\1" are backreference syntax and a trailing backslash
367 * escapes whatever follows it. That silently ate the escaping that
368 * generate_whitelist_exceptions() had just applied: a User-Agent
369 * whitelist entry ending in a backslash reached the file as
370 * `"!Bot\\" [NC]`, the backslash escaped the closing quote, and
371 * Apache answered 500 for the whole site. Reproduced from the
372 * settings screen on 25 aug 2026. substr() copies the block
373 * verbatim, which is the only correct thing to do here.
374 */
375 if ( false !== $pos ) {
376 return substr( $content, 0, $pos ) . $block . "\n\n" . substr( $content, $pos );
377 }
378 }
379
380 // Default: insert at top
381 return $block . "\n\n" . $content;
382 }
383
384 /**
385 * Validate .htaccess content
386 *
387 * @param string $content Content to validate.
388 * @return bool
389 */
390 private function validate_content( $content ) {
391 // Empty content is valid (but unusual)
392 if ( empty( trim( $content ) ) ) {
393 return true;
394 }
395
396 // Check for unmatched block markers
397 foreach ( $this->known_blocks as $start => $end ) {
398 $has_start = strpos( $content, $start ) !== false;
399 $has_end = strpos( $content, $end ) !== false;
400
401 // If has start, must have end (and vice versa)
402 if ( $has_start !== $has_end ) {
403 return false;
404 }
405
406 // Start must come before end
407 if ( $has_start && $has_end ) {
408 if ( strpos( $content, $start ) > strpos( $content, $end ) ) {
409 return false;
410 }
411 }
412 }
413
414 // Basic check: if it starts with PHP code, it's wrong
415 if ( preg_match( '/^<\?php/i', trim( $content ) ) ) {
416 return false;
417 }
418
419 /*
420 * Every directive argument has to close the quotes it opens. A stray
421 * backslash right before the closing quote escapes it, the directive
422 * runs on into the rest of the line and Apache answers 500 for the
423 * whole site. That is not hypothetical: until 2.10.0 a User-Agent
424 * whitelist entry ending in a backslash did exactly that, and this
425 * function waved it through because the only syntax check it had was
426 * an unused array of patterns.
427 *
428 * Escaped pairs are removed first, so a legitimate \\" or \\\\ inside an
429 * argument is not miscounted.
430 */
431 foreach ( preg_split( '/\r\n|\r|\n/', $content ) as $line ) {
432 $unescaped = str_replace( array( '\\\\', '\\"' ), '', $line );
433
434 if ( 0 !== ( substr_count( $unescaped, '"' ) % 2 ) ) {
435 return false;
436 }
437 }
438
439 /*
440 * Container tags have to balance. An unclosed <IfModule> swallows every
441 * directive below it, including the ones WordPress itself wrote.
442 */
443 if ( preg_match_all( '/^\s*<IfModule\b/im', $content ) !== preg_match_all( '/^\s*<\/IfModule\s*>/im', $content ) ) {
444 return false;
445 }
446
447 return true;
448 }
449
450 /**
451 * Read .htaccess file
452 *
453 * @return string|false
454 */
455 private function read_file() {
456 if ( ! file_exists( $this->htaccess_path ) ) {
457 return '';
458 }
459
460 if ( ! is_readable( $this->htaccess_path ) ) {
461 return false;
462 }
463
464 $content = file_get_contents( $this->htaccess_path ); // phpcs:ignore
465
466 return ( false !== $content ) ? $content : false;
467 }
468
469 /**
470 * Write .htaccess file
471 *
472 * @param string $content Content to write.
473 * @return bool
474 */
475 private function write_file( $content ) {
476 // Ensure content ends with newline
477 $content = rtrim( $content ) . "\n";
478
479 // Initialize WP_Filesystem
480 global $wp_filesystem;
481 if ( ! function_exists( 'WP_Filesystem' ) ) {
482 require_once ABSPATH . 'wp-admin/includes/file.php';
483 }
484 WP_Filesystem();
485
486 if ( ! $wp_filesystem ) {
487 return false;
488 }
489
490 // Check writability
491 if ( file_exists( $this->htaccess_path ) ) {
492 if ( ! $wp_filesystem->is_writable( $this->htaccess_path ) ) {
493 return false;
494 }
495 } else {
496 if ( ! $wp_filesystem->is_writable( dirname( $this->htaccess_path ) ) ) {
497 return false;
498 }
499 }
500
501 // Write with WP_Filesystem
502 return $wp_filesystem->put_contents( $this->htaccess_path, $content, FS_CHMOD_FILE );
503 }
504
505 /**
506 * Create backup of current .htaccess
507 *
508 * @param string $content Content to backup.
509 * @return bool
510 */
511 private function create_backup( $content ) {
512 $this->push_history( (string) $content );
513
514 // Store the backup in a private database option instead of a file under
515 // the web root, so it can never be served over HTTP.
516 $stored = update_option(
517 'vigilante_htaccess_backup',
518 array(
519 'content' => (string) $content,
520 'time' => time(),
521 ),
522 false
523 );
524
525 // update_option() also returns false when the value is unchanged.
526 return ( false !== $stored ) || ( (string) $content === $this->get_backup_content() );
527 }
528
529 /**
530 * Keep the last few .htaccess versions, newest first.
531 *
532 * The single-slot backup above is the rollback buffer: it is overwritten by
533 * the very next write, which is right for its job and useless for anything
534 * else. Something that only becomes visible days later, such as a header
535 * that quietly stopped being sent, needs more than one step of history.
536 *
537 * Kept in the database with autoload off, never in a file under the web
538 * root. Bounded on both axes so a large .htaccess cannot inflate the
539 * options table: oversized files are not stored at all, rather than stored
540 * truncated, because half an .htaccess is worse than none.
541 *
542 * @since 2.10.0
543 * @param string $content Content being replaced.
544 */
545 private function push_history( $content ) {
546 if ( '' === $content || strlen( $content ) > self::HISTORY_MAX_BYTES ) {
547 return;
548 }
549
550 $history = get_option( self::HISTORY_OPTION );
551 $history = is_array( $history ) ? $history : array();
552
553 // Nothing changed, nothing to record.
554 if ( isset( $history[0]['content'] ) && $history[0]['content'] === $content ) {
555 return;
556 }
557
558 array_unshift(
559 $history,
560 array(
561 'content' => $content,
562 'time' => time(),
563 'version' => VIGILANTE_VERSION,
564 )
565 );
566
567 update_option( self::HISTORY_OPTION, array_slice( $history, 0, self::HISTORY_ENTRIES ), false );
568 }
569
570 /**
571 * Get the stored .htaccess backup content, or '' if none.
572 *
573 * @return string
574 */
575 private function get_backup_content() {
576 $backup = get_option( 'vigilante_htaccess_backup' );
577 return ( is_array( $backup ) && isset( $backup['content'] ) ) ? (string) $backup['content'] : '';
578 }
579
580 /**
581 * Restore .htaccess from backup
582 *
583 * @return bool
584 */
585 public function restore_backup() {
586 $content = $this->get_backup_content();
587
588 if ( '' === $content ) {
589 return false;
590 }
591
592 return $this->write_file( $content );
593 }
594
595 /**
596 * Check if server is Apache/LiteSpeed
597 *
598 * @return bool
599 */
600 public function is_apache() {
601 $detected = null;
602
603 if ( function_exists( 'apache_get_modules' ) ) {
604 $detected = true;
605 } else {
606 $server = isset( $_SERVER['SERVER_SOFTWARE'] )
607 ? sanitize_text_field( wp_unslash( $_SERVER['SERVER_SOFTWARE'] ) )
608 : '';
609
610 if ( '' !== $server ) {
611 $detected = self::looks_like_apache( $server );
612
613 // Remember it, because a WP-CLI run has no request to look at.
614 if ( get_option( self::SERVER_OPTION ) !== $server ) {
615 update_option( self::SERVER_OPTION, $server, false );
616 }
617 }
618 }
619
620 /*
621 * Nothing in this request to go on, which is exactly what happens under
622 * WP-CLI: apache_get_modules() only exists under mod_php and
623 * SERVER_SOFTWARE is not defined on the command line. Until 2.9.9 that
624 * answered "not Apache" and every .htaccess write was refused, so a site
625 * activated with `wp plugin activate` silently got no server layer at
626 * all while the switches showed as on. So fall back to what a web
627 * request taught us earlier.
628 */
629 if ( null === $detected ) {
630 $remembered = (string) get_option( self::SERVER_OPTION, '' );
631
632 if ( '' !== $remembered ) {
633 $detected = self::looks_like_apache( $remembered );
634 }
635 }
636
637 /**
638 * Filter the Apache/LiteSpeed detection.
639 *
640 * The escape hatch for a site deployed entirely from the command line,
641 * where there has never been a web request to learn from.
642 *
643 * @since 2.9.9
644 *
645 * @param bool|null $detected True, false, or null when it could not be told.
646 */
647 $detected = apply_filters( 'vigilante_is_apache', $detected );
648
649 return ( true === $detected );
650 }
651
652 /**
653 * Vigilant blocks sitting in .htaccess files above the WordPress directory
654 *
655 * Apache applies the .htaccess of every directory above the one being
656 * served, and this class only ever writes and reads the one in ABSPATH. So
657 * a WordPress in a subfolder can be receiving rules from the block that the
658 * Vigilant of the parent installation left in the document root: the
659 * settings screen says the header is off, headers_list() does not show it,
660 * and the browser receives it all the same. Costed two rounds of diagnosis
661 * on a real site before it was understood, so it is worth naming the file.
662 *
663 * @since 2.9.9
664 *
665 * @return array<string,string[]> Absolute file path => markers found inside.
666 */
667 public function find_blocks_above() {
668 global $wp_filesystem;
669
670 if ( ! function_exists( 'WP_Filesystem' ) ) {
671 require_once ABSPATH . 'wp-admin/includes/file.php';
672 }
673 WP_Filesystem();
674
675 if ( ! $wp_filesystem ) {
676 return array();
677 }
678
679 $found = array();
680 $markers = array_keys( $this->known_blocks );
681 $dir = dirname( $this->htaccess_path );
682
683 // Bounded walk up to the filesystem root. Eight levels is well past any
684 // real docroot and keeps this cheap on a deep path.
685 for ( $level = 0; $level < 8; $level++ ) {
686 $parent = dirname( $dir );
687
688 if ( $parent === $dir || '' === $parent || '.' === $parent ) {
689 break;
690 }
691
692 $dir = $parent;
693 $file = $dir . '/.htaccess';
694
695 if ( ! $wp_filesystem->exists( $file ) || ! $wp_filesystem->is_readable( $file ) ) {
696 continue;
697 }
698
699 $content = $wp_filesystem->get_contents( $file );
700
701 if ( ! is_string( $content ) || '' === $content ) {
702 continue;
703 }
704
705 $hits = array();
706 foreach ( $markers as $marker ) {
707 // The WordPress block is not ours, only the Vigilant ones count.
708 if ( false === strpos( $marker, 'Vigilante' ) ) {
709 continue;
710 }
711 if ( false !== strpos( $content, $marker ) ) {
712 $hits[] = $marker;
713 }
714 }
715
716 if ( ! empty( $hits ) ) {
717 $found[ $file ] = $hits;
718 }
719 }
720
721 return $found;
722 }
723
724 /**
725 * Whether a SERVER_SOFTWARE string is Apache or LiteSpeed
726 *
727 * @since 2.9.9
728 *
729 * @param string $server Server software string.
730 * @return bool
731 */
732 private static function looks_like_apache( $server ) {
733 return ( false !== stripos( $server, 'apache' ) || false !== stripos( $server, 'litespeed' ) );
734 }
735
736 /**
737 * Whether the server could not be identified in this request
738 *
739 * Tells "we know it is not Apache" apart from "we cannot tell from here",
740 * which is what a WP-CLI run gets. The caller uses it to leave the work
741 * pending for the first web request instead of dropping it.
742 *
743 * @since 2.9.9
744 *
745 * @return bool
746 */
747 public function server_is_unknown() {
748 if ( function_exists( 'apache_get_modules' ) ) {
749 return false;
750 }
751
752 if ( ! empty( $_SERVER['SERVER_SOFTWARE'] ) ) {
753 return false;
754 }
755
756 return ( '' === (string) get_option( self::SERVER_OPTION, '' ) );
757 }
758
759 /**
760 * Check if .htaccess is writable
761 *
762 * @return bool
763 */
764 public function is_writable() {
765 // Initialize WP_Filesystem
766 global $wp_filesystem;
767 if ( ! function_exists( 'WP_Filesystem' ) ) {
768 require_once ABSPATH . 'wp-admin/includes/file.php';
769 }
770 WP_Filesystem();
771
772 if ( ! $wp_filesystem ) {
773 return false;
774 }
775
776 if ( file_exists( $this->htaccess_path ) ) {
777 return $wp_filesystem->is_writable( $this->htaccess_path );
778 }
779 return $wp_filesystem->is_writable( ABSPATH );
780 }
781
782 /**
783 * Get current .htaccess content (for debugging)
784 *
785 * @return string
786 */
787 public function get_content() {
788 $content = $this->read_file();
789 return ( false !== $content ) ? $content : '';
790 }
791 }