includes
2 months ago
server
2 months ago
tests
2 months ago
wordpress
2 months ago
class-rsssl-htaccess-file-manager.php
2 months ago
cron.php
2 months ago
deactivate-integration.php
2 months ago
firewall-manager.php
2 months ago
functions.php
2 months ago
index.php
2 months ago
integrations.php
2 months ago
notices.php
2 months ago
security.php
2 months ago
sync-settings.php
2 months ago
tests.php
2 months ago
class-rsssl-htaccess-file-manager.php
613 lines
| 1 | <?php |
| 2 | /** |
| 3 | * class-rsssl-htaccess-file-manager.php |
| 4 | * |
| 5 | * Responsible for reading, writing and versioning .htaccess |
| 6 | * rules via WordPress’s insert_with_markers API. |
| 7 | * |
| 8 | * @package RSSSL\Pro\Security\WordPress\Firewall\Builders\Rules |
| 9 | */ |
| 10 | namespace { |
| 11 | //Multiple requirements to support different WordPress versions and ensure the filesystem API is available. |
| 12 | if ( ! function_exists( 'insert_with_markers' )) { |
| 13 | require_once ABSPATH . 'wp-admin/includes/misc.php'; |
| 14 | } |
| 15 | if ( ! function_exists( 'get_home_path' )) { |
| 16 | require_once ABSPATH . 'wp-admin/includes/file.php'; |
| 17 | } |
| 18 | } |
| 19 | namespace RSSSL\Security { |
| 20 | /** |
| 21 | * Handles low-level .htaccess file operations: |
| 22 | * – locating the file, |
| 23 | * – reading/writing rules, |
| 24 | * – recording history, |
| 25 | * – cooperating with WP Rocket. |
| 26 | * – will no longer auto-create a missing .htaccess (opt-in via `rsssl_allow_create_htaccess`). |
| 27 | */ |
| 28 | class RSSSL_Htaccess_File_Manager { |
| 29 | |
| 30 | /** |
| 31 | * Singleton instance. |
| 32 | * |
| 33 | * @var self|null |
| 34 | */ |
| 35 | private static ?self $instance = null; |
| 36 | |
| 37 | /** |
| 38 | * Return the shared instance of this class. |
| 39 | * |
| 40 | * @return self |
| 41 | */ |
| 42 | public static function get_instance(): self { |
| 43 | if ( self::$instance === null ) { |
| 44 | self::$instance = new self(); |
| 45 | } |
| 46 | return self::$instance; |
| 47 | } |
| 48 | |
| 49 | /** |
| 50 | * Is used for storing the path to the .htaccess file. |
| 51 | */ |
| 52 | public string $htaccess_file_path; |
| 53 | |
| 54 | /** |
| 55 | * Constructor. |
| 56 | * |
| 57 | */ |
| 58 | public function __construct() |
| 59 | { |
| 60 | $this->htaccess_file_path = $this->determineHtaccessFilePath(); |
| 61 | $this->registerRocketHooks(); |
| 62 | } |
| 63 | |
| 64 | /** |
| 65 | * Determines the path to the .htaccess file based on various conditions. |
| 66 | */ |
| 67 | private function determineHtaccessFilePath(): string |
| 68 | { |
| 69 | // Prefer a custom home .htaccess if it exists |
| 70 | $homePath = apply_filters('rsssl_home_htaccess_path', get_home_path() . '.htaccess'); |
| 71 | if ($this->file_exists($homePath)) { |
| 72 | return apply_filters('rsssl_htaccess_file_path', $homePath); |
| 73 | } |
| 74 | |
| 75 | // Otherwise use the default .htaccess in ABSPATH |
| 76 | $defaultPath = apply_filters('rsssl_default_htaccess_path', ABSPATH . '.htaccess'); |
| 77 | if ($this->file_exists($defaultPath)) { |
| 78 | return apply_filters('rsssl_htaccess_file_path', $defaultPath); |
| 79 | } |
| 80 | |
| 81 | // Fallback to WP_CONTENT_DIR/.htaccess (path only; file will not be auto-created) |
| 82 | $contentPath = apply_filters('rsssl_wp_content_htaccess_path', WP_CONTENT_DIR . '/.htaccess'); |
| 83 | return apply_filters('rsssl_htaccess_file_path', $contentPath); |
| 84 | } |
| 85 | |
| 86 | /** |
| 87 | * Registers hooks for WP Rocket activation and deactivation. So we can record the history of changes made by WP Rocket. |
| 88 | */ |
| 89 | private function registerRocketHooks(): void |
| 90 | { |
| 91 | // Register hooks for WP Rocket activation and deactivation |
| 92 | add_action('rocket_activation', [ $this, 'record_history_from_rocket' ]); |
| 93 | add_action('rocket_deactivation', [ $this, 'record_history_from_rocket' ]); |
| 94 | } |
| 95 | |
| 96 | /** |
| 97 | * Sets or updates the path to the .htaccess file to be managed. |
| 98 | */ |
| 99 | public function set_htaccess_file_path(string $htaccess_file_path): void { |
| 100 | $this->htaccess_file_path = $htaccess_file_path; |
| 101 | } |
| 102 | |
| 103 | /** |
| 104 | * Reads the content of the .htaccess file. |
| 105 | */ |
| 106 | public function get_htaccess_content():? string |
| 107 | { |
| 108 | if ( is_file($this->htaccess_file_path) && is_readable($this->htaccess_file_path)) { |
| 109 | return file_get_contents($this->htaccess_file_path); |
| 110 | } |
| 111 | return null; |
| 112 | } |
| 113 | |
| 114 | /** |
| 115 | * Writes a rule block to the .htaccess file. |
| 116 | */ |
| 117 | public function write_rule(array $rule_definition, string $debugTest = 'unknown'): bool |
| 118 | { |
| 119 | if (! $this->validateRuleDefinition($rule_definition)) { |
| 120 | return false; |
| 121 | } |
| 122 | |
| 123 | if (! $this->ensure_htaccess_is_writable()) { |
| 124 | return false; |
| 125 | } |
| 126 | |
| 127 | return $this->applyMarkerBlock( |
| 128 | $this->extract_name_from_marker($rule_definition['marker']), |
| 129 | $this->prepareLines($rule_definition), |
| 130 | $debugTest |
| 131 | ); |
| 132 | } |
| 133 | |
| 134 | /** |
| 135 | * Validates the rule definition before writing. |
| 136 | * |
| 137 | * @param array $ruleDefinition |
| 138 | * @return bool True if valid, false otherwise. |
| 139 | */ |
| 140 | private function validateRuleDefinition(array $ruleDefinition): bool |
| 141 | { |
| 142 | if (empty($ruleDefinition['marker'])) { |
| 143 | $this->log_error('No marker provided for write_rule.'); |
| 144 | return false; |
| 145 | } |
| 146 | return true; |
| 147 | } |
| 148 | |
| 149 | /** |
| 150 | * Prepares the lines to write, inserting a placeholder if needed. |
| 151 | * |
| 152 | * @param array $ruleDefinition |
| 153 | * @return string[] Array of lines to write. |
| 154 | */ |
| 155 | private function prepareLines(array $ruleDefinition): array |
| 156 | { |
| 157 | $lines = $ruleDefinition['lines'] ?? []; |
| 158 | $isBeingCleared = ! empty($ruleDefinition['clear_rule']); |
| 159 | |
| 160 | if (empty($lines) && ! $isBeingCleared) { |
| 161 | return [ |
| 162 | '', |
| 163 | '# This feature has not been activated.', |
| 164 | '', |
| 165 | ]; |
| 166 | } |
| 167 | |
| 168 | return $lines; |
| 169 | } |
| 170 | |
| 171 | /** |
| 172 | * Applies a marker block to the .htaccess file, supporting configurable top-priority markers. |
| 173 | */ |
| 174 | private function applyMarkerBlock(string $markerName, array $lines, string $debugTest = 'unknown'): bool |
| 175 | { |
| 176 | $oldContent = $this->get_htaccess_content() ?: ''; |
| 177 | |
| 178 | // Allow certain markers to be forced to the very top of .htaccess (right under any existing top block) |
| 179 | $top_markers = apply_filters( |
| 180 | 'rsssl_htaccess_top_markers', |
| 181 | [ 'Really Simple Auto Prepend File', 'Really Simple Security Redirect' ] |
| 182 | ); |
| 183 | |
| 184 | if ( in_array( $markerName, $top_markers, true ) ) { |
| 185 | // first remove any existing marker block with the same name |
| 186 | $result = $this->write_top_marker_block( $markerName, $lines ); |
| 187 | } else { |
| 188 | // WP core will preserve everything outside of your marker |
| 189 | $probe = $this->get_htaccess_content(); |
| 190 | if ( $this->is_effectively_empty( $probe ) ) { |
| 191 | $result = false; |
| 192 | } else { |
| 193 | // WP core will preserve everything outside of your marker |
| 194 | $result = insert_with_markers( $this->htaccess_file_path, $markerName, $lines ); |
| 195 | } |
| 196 | } |
| 197 | |
| 198 | if ( $result ) { |
| 199 | $newContent = $this->get_htaccess_content() ?: ''; |
| 200 | $this->record_history( $oldContent, $newContent, $markerName, $debugTest ); |
| 201 | } |
| 202 | |
| 203 | return $result; |
| 204 | } |
| 205 | |
| 206 | /** |
| 207 | * Ensures that the .htaccess file exists and is writable. |
| 208 | */ |
| 209 | private function ensure_htaccess_is_writable(): bool |
| 210 | { |
| 211 | $dir = dirname( $this->htaccess_file_path ); |
| 212 | |
| 213 | // Ensure the directory exists (same as before) |
| 214 | if ( ! is_dir( $dir ) && ! wp_mkdir_p( $dir ) ) { |
| 215 | $this->log_error( 'Cannot create directory for .htaccess at: ' . esc_html( $dir ) ); |
| 216 | return false; |
| 217 | } |
| 218 | |
| 219 | // Do **not** create a new .htaccess automatically anymore. |
| 220 | // This previously led to empty files overwriting existing rewrite rules in some environments. |
| 221 | // If a site really wants us to create the file, they must opt in via the filter below. |
| 222 | if ( ! is_file( $this->htaccess_file_path ) ) { |
| 223 | $allow_create = apply_filters( 'rsssl_allow_create_htaccess', false, $this->htaccess_file_path ); |
| 224 | if ( $allow_create ) { |
| 225 | if ( @file_put_contents( $this->htaccess_file_path, '', LOCK_EX ) === false ) { |
| 226 | $this->log_error( 'Could not create .htaccess file at: ' . esc_html( $this->htaccess_file_path ) ); |
| 227 | return false; |
| 228 | } else { |
| 229 | $this->log_error( 'Created new .htaccess file at: ' . esc_html( $this->htaccess_file_path ) ); |
| 230 | } |
| 231 | } else { |
| 232 | $this->log_error( '.htaccess file does not exist and automatic creation is disabled. Path: ' . esc_html( $this->htaccess_file_path ) ); |
| 233 | return false; |
| 234 | } |
| 235 | } |
| 236 | |
| 237 | if ( ! is_writable( $this->htaccess_file_path ) ) { |
| 238 | $this->log_error( '.htaccess file is not writable at: ' . esc_html( $this->htaccess_file_path ) ); |
| 239 | return false; |
| 240 | } |
| 241 | |
| 242 | return true; |
| 243 | } |
| 244 | |
| 245 | /** |
| 246 | * Writes a marker block that must live at the very top of .htaccess. |
| 247 | * |
| 248 | * Used for markers that must run before WordPress rewrite rules – e.g. |
| 249 | * - "Really Simple Auto Prepend File" |
| 250 | * - "Really Simple Security Redirect" (HTTP→HTTPS redirect) |
| 251 | */ |
| 252 | private function write_top_marker_block( string $markerName, array $linesToWrite ): bool |
| 253 | { |
| 254 | // Preserve original content for history |
| 255 | $originalHtaccess = $this->get_htaccess_content() ?: ''; |
| 256 | // SAFETY: if .htaccess is (effectively) empty or unreadable, do not write our markers |
| 257 | if ( $this->is_effectively_empty( $originalHtaccess ) ) { |
| 258 | return false; |
| 259 | } |
| 260 | // we remove the redirect marker block if it exists, so we can write a new one |
| 261 | // this is needed because the redirect marker block is not removed by insert_with_markers |
| 262 | // We added this function because not on every save we can determine when to remove options when the rule is not present. |
| 263 | if ( $markerName !== 'Really Simple Security Redirect' && 'htaccess' !== rsssl_get_option('redirect')) { |
| 264 | $originalHtaccess = $this->remove_marker_block( $originalHtaccess, 'Really Simple Security Redirect' ); |
| 265 | } |
| 266 | $htaccessWithoutMarker = $this->remove_marker_block( $originalHtaccess, $markerName ); |
| 267 | |
| 268 | if (empty($linesToWrite)) { |
| 269 | return $this->save_htaccess_if_changed($originalHtaccess, $htaccessWithoutMarker, $markerName); |
| 270 | } |
| 271 | |
| 272 | $newMarkerBlock = $this->build_marker_block( $markerName, $linesToWrite ); |
| 273 | $updatedHtaccess = $this->insert_marker_in_correct_position($htaccessWithoutMarker, $markerName, $newMarkerBlock); |
| 274 | |
| 275 | $updatedHtaccess = $this->cleanupEmptyLines($updatedHtaccess); |
| 276 | |
| 277 | @file_put_contents( $this->htaccess_file_path, $updatedHtaccess, LOCK_EX ); |
| 278 | $this->record_history( $originalHtaccess, $updatedHtaccess, $markerName ); |
| 279 | return true; |
| 280 | } |
| 281 | |
| 282 | /** |
| 283 | * Inserts a marker block in the correct position in the .htaccess file. |
| 284 | */ |
| 285 | private function insert_marker_in_correct_position(string $htaccess, string $markerName, string $markerBlock): string |
| 286 | { |
| 287 | $autoPrependName = 'Really Simple Auto Prepend File'; |
| 288 | |
| 289 | if (strcasecmp($markerName, $autoPrependName) === 0) { |
| 290 | return $markerBlock . $htaccess; |
| 291 | } |
| 292 | |
| 293 | $escapedAutoPrependName = preg_quote($autoPrependName, '/'); |
| 294 | |
| 295 | $autoPrependPattern = $this->generate_marker_pattern($autoPrependName); |
| 296 | |
| 297 | if (preg_match($autoPrependPattern, $htaccess, $match, PREG_OFFSET_CAPTURE)) { |
| 298 | $insertPosition = $match[1][1] + strlen($match[1][0]); |
| 299 | return substr($htaccess, 0, $insertPosition) . $markerBlock . substr($htaccess, $insertPosition); |
| 300 | } |
| 301 | |
| 302 | return $markerBlock . $htaccess; |
| 303 | } |
| 304 | |
| 305 | /** |
| 306 | * Generates a regex pattern to match a marker block in the .htaccess file. |
| 307 | * |
| 308 | * This pattern matches both # and ### markers, case-insensitive, and captures |
| 309 | * the entire block including the BEGIN and END lines. |
| 310 | */ |
| 311 | public function generate_marker_pattern(string $markerName): string |
| 312 | { |
| 313 | $escaped = preg_quote($markerName, '/'); |
| 314 | //return '/(^#+\s*BEGIN\s+' . $escaped . '[^\n]*\n.*?^#+\s*END\s+' . $escaped . '[^\n]*\n?)/ims'; |
| 315 | return '/(^\s*#+\s*BEGIN\s+' . $escaped . '[^\n]*\n.*?^\s*#+\s*END\s+' . $escaped . '[^\n]*\n?)/ims'; |
| 316 | } |
| 317 | |
| 318 | /** |
| 319 | * Removes a marker block from the .htaccess file. |
| 320 | */ |
| 321 | private function remove_marker_block(string $htaccess, string $markerName): string |
| 322 | { |
| 323 | // Normalize line endings so regex behaves consistently |
| 324 | // $htaccess = preg_replace("/\r\n? /", "\n", $htaccess); |
| 325 | $htaccess = preg_replace("/\r\n?/", "\n", $htaccess); |
| 326 | |
| 327 | // Build a single, tolerant pattern matching any number of leading '#', optional trailing text on BEGIN/END lines, |
| 328 | // and capturing across multiple lines. |
| 329 | $pattern = $this->generate_marker_pattern($markerName); |
| 330 | |
| 331 | // Apply the replacement and capture match count for debugging |
| 332 | $before = $htaccess; |
| 333 | $htaccess = preg_replace($pattern, '', $htaccess, -1, $count); |
| 334 | |
| 335 | return ltrim($htaccess, "\n"); |
| 336 | } |
| 337 | |
| 338 | /** |
| 339 | * Saves the .htaccess file if it has changed, and record the history. |
| 340 | */ |
| 341 | private function save_htaccess_if_changed(string $original, string $modified, string $markerName): bool |
| 342 | { |
| 343 | if ( $modified === $original ) { |
| 344 | return true; |
| 345 | } |
| 346 | |
| 347 | // SAFETY: do not write when the current .htaccess is effectively empty |
| 348 | if ( $this->is_effectively_empty( $original ) ) { |
| 349 | return false; |
| 350 | } |
| 351 | |
| 352 | $cleaned = $this->cleanupEmptyLines( $modified ); |
| 353 | |
| 354 | // Avoid writing an empty result |
| 355 | if ( $this->is_effectively_empty( $cleaned ) ) { |
| 356 | return true; |
| 357 | } |
| 358 | |
| 359 | @file_put_contents( $this->htaccess_file_path, $cleaned, LOCK_EX ); |
| 360 | $this->record_history( $original, $cleaned, $markerName ); |
| 361 | |
| 362 | return true; |
| 363 | } |
| 364 | |
| 365 | private function build_marker_block(string $markerName, array $lines): string |
| 366 | { |
| 367 | return implode(PHP_EOL, array_merge( |
| 368 | ["# BEGIN {$markerName}"], |
| 369 | $lines, |
| 370 | ["# END {$markerName}"] |
| 371 | )) . PHP_EOL; |
| 372 | } |
| 373 | |
| 374 | |
| 375 | |
| 376 | /** |
| 377 | * Checks if a specific marker block exists in the .htaccess file. |
| 378 | * |
| 379 | * @param array $markers The start and end markers (e.g., ['#BEGIN rule', '#END rule']). |
| 380 | * @return bool True if the block exists, false otherwise. |
| 381 | */ |
| 382 | public function are_markers_present(array $markers): bool |
| 383 | { |
| 384 | if (count($markers) !== 2) { |
| 385 | return false; |
| 386 | } |
| 387 | $content = $this->get_htaccess_content(); |
| 388 | if ($content === null) { |
| 389 | return false; |
| 390 | } |
| 391 | $start_marker_escaped = preg_quote($markers[0], '/'); |
| 392 | $end_marker_escaped = preg_quote($markers[1], '/'); |
| 393 | return preg_match('/^\s*' . $start_marker_escaped . '.*?^\s*' . $end_marker_escaped . '/ms', $content) === 1; |
| 394 | } |
| 395 | |
| 396 | /** |
| 397 | * Extracts a usable name from the BEGIN marker for insert_with_markers. |
| 398 | * E.g., "#BEGIN My Rule" becomes "My Rule". |
| 399 | */ |
| 400 | private function extract_name_from_marker(string $begin_marker): string |
| 401 | { |
| 402 | // Remove #, BEGIN, Begin, begin and then trim |
| 403 | // also remove trailing ### |
| 404 | $name = preg_replace( array( '/^#+\s*(BEGIN|Begin|begin)\s*/i', '/\s*#+$/' ), '', $begin_marker ); |
| 405 | return trim($name); |
| 406 | } |
| 407 | |
| 408 | /** |
| 409 | * Records a change to the .htaccess history. |
| 410 | * |
| 411 | * @param string $old_content The previous content. |
| 412 | * @param string $new_content The new content. |
| 413 | */ |
| 414 | private function record_history( string $old_content, string $new_content , string $marker = 'unknown', string $debugTest = 'unknown'): void |
| 415 | { |
| 416 | if ( ! $this->is_htaccess_tracking_enabled() ) { |
| 417 | // we remove the option if the constant is not defined. |
| 418 | delete_option( 'rsssl_htaccess_history' ); |
| 419 | return; |
| 420 | } |
| 421 | if ( $old_content === $new_content ) { |
| 422 | return; |
| 423 | } |
| 424 | $history = get_option( 'rsssl_htaccess_history', [] ); |
| 425 | $history[] = [ |
| 426 | 'timestamp' => time(), |
| 427 | 'file_path' => $this->htaccess_file_path, |
| 428 | 'old_content' => $old_content, |
| 429 | 'new_content' => $new_content, |
| 430 | 'user_id' => function_exists( 'get_current_user_id' ) ? get_current_user_id() : 0, |
| 431 | 'marker' => $marker, |
| 432 | // logging the current hook name for debugging purposes |
| 433 | 'hook' => current_filter() ?: 'unknown', |
| 434 | // logging the current action for debugging purposes |
| 435 | 'action' => current_action()? : 'unknown', |
| 436 | 'debug_test' => $debugTest, |
| 437 | ]; |
| 438 | if ( count( $history ) > 20 ) { |
| 439 | $history = array_slice( $history, -20 ); |
| 440 | } |
| 441 | update_option( 'rsssl_htaccess_history', $history, false ); |
| 442 | } |
| 443 | |
| 444 | /** |
| 445 | * Clears a specific marker block from the .htaccess file. |
| 446 | * |
| 447 | * @param string|array $marker The marker name (string) or marker array (['#Begin ...', '#End ...']). |
| 448 | * @return bool True on success, false on failure. |
| 449 | */ |
| 450 | public function clear_rule($marker, string $debugTest = 'unknown'): bool |
| 451 | { |
| 452 | // Accept either a string (marker name) or an array (markers) |
| 453 | if (is_array($marker)) { |
| 454 | $begin_marker = $marker[0] ?? ''; |
| 455 | } else { |
| 456 | $begin_marker = $marker; |
| 457 | } |
| 458 | $rule_definition = [ |
| 459 | 'marker' => $begin_marker, |
| 460 | 'lines' => [], |
| 461 | 'clear_rule' => true, |
| 462 | ]; |
| 463 | return $this->write_rule($rule_definition, $debugTest); |
| 464 | } |
| 465 | |
| 466 | /** |
| 467 | * Clears a specific marker block from the .htaccess file without using |
| 468 | * insert_with_markers. This method directly removes the block using raw |
| 469 | * regex matching. This is needed for old markings that had capitalized |
| 470 | * Begin and End markers. |
| 471 | */ |
| 472 | public function clear_legacy_rule(string $marker): bool |
| 473 | { |
| 474 | $content = $this->get_htaccess_content(); |
| 475 | if ($content === null) { |
| 476 | return false; |
| 477 | } |
| 478 | |
| 479 | // SAFETY: if the file is effectively empty, do not attempt to rewrite it |
| 480 | if ( $this->is_effectively_empty( $content ) ) { |
| 481 | return false; |
| 482 | } |
| 483 | |
| 484 | // Match and remove the block with the exact marker name |
| 485 | // Use case-insensitive matching for BEGIN/END to handle both legacy and WordPress standard formats |
| 486 | $escaped = preg_quote($marker, '/'); |
| 487 | $pattern = '/^#+\s*BEGIN\s+' . $escaped . '.*?^#+\s*END\s+' . $escaped . '.*?$/msi'; |
| 488 | |
| 489 | $new_content = trim(preg_replace($pattern, '', $content)); |
| 490 | |
| 491 | // Regex error |
| 492 | if ($new_content === null) { |
| 493 | return false; |
| 494 | } |
| 495 | |
| 496 | // Write the updated content back to the .htaccess file |
| 497 | if ( $new_content !== $content && ! $this->is_effectively_empty( $new_content ) ) { |
| 498 | return file_put_contents($this->htaccess_file_path, $new_content, LOCK_EX) !== false; |
| 499 | } |
| 500 | |
| 501 | return true; // No changes needed |
| 502 | } |
| 503 | |
| 504 | /** |
| 505 | * Records the history of changes made by WP Rocket to the .htaccess file. |
| 506 | */ |
| 507 | public function record_history_from_rocket(): void |
| 508 | { |
| 509 | // We get the previous content from the history, if it exists. |
| 510 | $history = get_option( 'rsssl_htaccess_history', [] ); |
| 511 | $old_content = ''; |
| 512 | if ( ! empty( $history ) ) { |
| 513 | $last_entry = end( $history ); |
| 514 | if ( isset( $last_entry['new_content'] ) ) { |
| 515 | $old_content = $last_entry['new_content']; |
| 516 | } |
| 517 | } |
| 518 | $new_content = file_get_contents( $this->htaccess_file_path ); |
| 519 | if ( $new_content === false ) { |
| 520 | return; |
| 521 | } |
| 522 | $this->record_history( $old_content, $new_content, 'wp-rocket' ); |
| 523 | } |
| 524 | |
| 525 | /** |
| 526 | * Checks if .htaccess history tracking is enabled via constant. |
| 527 | * |
| 528 | * @since 5.x.x |
| 529 | * |
| 530 | * @return bool True if .htaccess history tracking is enabled, false otherwise. |
| 531 | */ |
| 532 | private function is_htaccess_tracking_enabled(): bool |
| 533 | { |
| 534 | return defined( 'RSSSL_RECORDS_HISTORY_VERSION' ); |
| 535 | } |
| 536 | |
| 537 | /** |
| 538 | * Reads the content between a marker block in the .htaccess file and returns it as a string, including the marker lines. |
| 539 | */ |
| 540 | public function get_rule_content(string $markerName):? string |
| 541 | { |
| 542 | $content = $this->get_htaccess_content(); |
| 543 | if ($content === null) { |
| 544 | return null; |
| 545 | } |
| 546 | // Match both # and ### marker styles, case-insensitive, including the marker lines |
| 547 | $escaped = preg_quote($markerName, '/'); |
| 548 | $pattern = '/(#+\s*BEGIN\s+' . $escaped . '[^\n]*\n.*?#+\s*END\s+' . $escaped . '[^\n]*\n?)/is'; |
| 549 | if (preg_match($pattern, $content, $matches)) { |
| 550 | return trim($matches[1]); |
| 551 | } |
| 552 | return null; |
| 553 | } |
| 554 | |
| 555 | /** |
| 556 | * Writes an error message to the error log. |
| 557 | */ |
| 558 | public function log_error(string $message): void |
| 559 | { |
| 560 | if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { |
| 561 | error_log( 'RSSSL_Htaccess_File_Manager: ' . $message ); |
| 562 | } |
| 563 | } |
| 564 | |
| 565 | /** |
| 566 | * Validates the .htaccess file path. If exists, writable and a valid string. |
| 567 | */ |
| 568 | public function validate_htaccess_file_path(): bool { |
| 569 | // Check if the file path is a valid string and not empty |
| 570 | if (empty( $this->htaccess_file_path ) ) { |
| 571 | return false; |
| 572 | } |
| 573 | |
| 574 | // Check if the file exists and is writable |
| 575 | if ( ! is_file( $this->htaccess_file_path ) || ! is_writable( $this->htaccess_file_path ) ) { |
| 576 | return false; |
| 577 | } |
| 578 | |
| 579 | return true; |
| 580 | } |
| 581 | |
| 582 | /** |
| 583 | * Checks if the .htaccess file exists. |
| 584 | */ |
| 585 | public function file_exists( string $file_path ): bool { |
| 586 | return is_file( $file_path ); |
| 587 | } |
| 588 | |
| 589 | /** |
| 590 | * Cleans up extra empty lines in .htaccess content. |
| 591 | * |
| 592 | * @param string $content The raw .htaccess content. |
| 593 | * @return string The content with consecutive blank lines reduced. |
| 594 | */ |
| 595 | private function cleanupEmptyLines(string $content): string |
| 596 | { |
| 597 | // Normalize all line endings to "\n" |
| 598 | // Collapse three or more consecutive newlines into two |
| 599 | $content = preg_replace( array( "/\r\n?/", "/\n{3,}/" ), array( "\n", "\n\n" ), $content ); |
| 600 | return $content; |
| 601 | } |
| 602 | |
| 603 | /** |
| 604 | * Checks if the given content is effectively empty (only whitespace). |
| 605 | */ |
| 606 | private function is_effectively_empty( $content ): bool { |
| 607 | if ( $content === null || $content === false ) { |
| 608 | return true; |
| 609 | } |
| 610 | return trim( (string) $content ) === ''; |
| 611 | } |
| 612 | } |
| 613 | } |