613 lines
21 KiB
PHP
613 lines
21 KiB
PHP
<?php
|
||
/**
|
||
* class-rsssl-htaccess-file-manager.php
|
||
*
|
||
* Responsible for reading, writing and versioning .htaccess
|
||
* rules via WordPress’s insert_with_markers API.
|
||
*
|
||
* @package RSSSL\Pro\Security\WordPress\Firewall\Builders\Rules
|
||
*/
|
||
namespace {
|
||
//Multiple requirements to support different WordPress versions and ensure the filesystem API is available.
|
||
if ( ! function_exists( 'insert_with_markers' )) {
|
||
require_once ABSPATH . 'wp-admin/includes/misc.php';
|
||
}
|
||
if ( ! function_exists( 'get_home_path' )) {
|
||
require_once ABSPATH . 'wp-admin/includes/file.php';
|
||
}
|
||
}
|
||
namespace RSSSL\Security {
|
||
/**
|
||
* Handles low-level .htaccess file operations:
|
||
* – locating the file,
|
||
* – reading/writing rules,
|
||
* – recording history,
|
||
* – cooperating with WP Rocket.
|
||
* – will no longer auto-create a missing .htaccess (opt-in via `rsssl_allow_create_htaccess`).
|
||
*/
|
||
class RSSSL_Htaccess_File_Manager {
|
||
|
||
/**
|
||
* Singleton instance.
|
||
*
|
||
* @var self|null
|
||
*/
|
||
private static ?self $instance = null;
|
||
|
||
/**
|
||
* Return the shared instance of this class.
|
||
*
|
||
* @return self
|
||
*/
|
||
public static function get_instance(): self {
|
||
if ( self::$instance === null ) {
|
||
self::$instance = new self();
|
||
}
|
||
return self::$instance;
|
||
}
|
||
|
||
/**
|
||
* Is used for storing the path to the .htaccess file.
|
||
*/
|
||
public string $htaccess_file_path;
|
||
|
||
/**
|
||
* Constructor.
|
||
*
|
||
*/
|
||
public function __construct()
|
||
{
|
||
$this->htaccess_file_path = $this->determineHtaccessFilePath();
|
||
$this->registerRocketHooks();
|
||
}
|
||
|
||
/**
|
||
* Determines the path to the .htaccess file based on various conditions.
|
||
*/
|
||
private function determineHtaccessFilePath(): string
|
||
{
|
||
// Prefer a custom home .htaccess if it exists
|
||
$homePath = apply_filters('rsssl_home_htaccess_path', get_home_path() . '.htaccess');
|
||
if ($this->file_exists($homePath)) {
|
||
return apply_filters('rsssl_htaccess_file_path', $homePath);
|
||
}
|
||
|
||
// Otherwise use the default .htaccess in ABSPATH
|
||
$defaultPath = apply_filters('rsssl_default_htaccess_path', ABSPATH . '.htaccess');
|
||
if ($this->file_exists($defaultPath)) {
|
||
return apply_filters('rsssl_htaccess_file_path', $defaultPath);
|
||
}
|
||
|
||
// Fallback to WP_CONTENT_DIR/.htaccess (path only; file will not be auto-created)
|
||
$contentPath = apply_filters('rsssl_wp_content_htaccess_path', WP_CONTENT_DIR . '/.htaccess');
|
||
return apply_filters('rsssl_htaccess_file_path', $contentPath);
|
||
}
|
||
|
||
/**
|
||
* Registers hooks for WP Rocket activation and deactivation. So we can record the history of changes made by WP Rocket.
|
||
*/
|
||
private function registerRocketHooks(): void
|
||
{
|
||
// Register hooks for WP Rocket activation and deactivation
|
||
add_action('rocket_activation', [ $this, 'record_history_from_rocket' ]);
|
||
add_action('rocket_deactivation', [ $this, 'record_history_from_rocket' ]);
|
||
}
|
||
|
||
/**
|
||
* Sets or updates the path to the .htaccess file to be managed.
|
||
*/
|
||
public function set_htaccess_file_path(string $htaccess_file_path): void {
|
||
$this->htaccess_file_path = $htaccess_file_path;
|
||
}
|
||
|
||
/**
|
||
* Reads the content of the .htaccess file.
|
||
*/
|
||
public function get_htaccess_content():? string
|
||
{
|
||
if ( is_file($this->htaccess_file_path) && is_readable($this->htaccess_file_path)) {
|
||
return file_get_contents($this->htaccess_file_path);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Writes a rule block to the .htaccess file.
|
||
*/
|
||
public function write_rule(array $rule_definition, string $debugTest = 'unknown'): bool
|
||
{
|
||
if (! $this->validateRuleDefinition($rule_definition)) {
|
||
return false;
|
||
}
|
||
|
||
if (! $this->ensure_htaccess_is_writable()) {
|
||
return false;
|
||
}
|
||
|
||
return $this->applyMarkerBlock(
|
||
$this->extract_name_from_marker($rule_definition['marker']),
|
||
$this->prepareLines($rule_definition),
|
||
$debugTest
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Validates the rule definition before writing.
|
||
*
|
||
* @param array $ruleDefinition
|
||
* @return bool True if valid, false otherwise.
|
||
*/
|
||
private function validateRuleDefinition(array $ruleDefinition): bool
|
||
{
|
||
if (empty($ruleDefinition['marker'])) {
|
||
$this->log_error('No marker provided for write_rule.');
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* Prepares the lines to write, inserting a placeholder if needed.
|
||
*
|
||
* @param array $ruleDefinition
|
||
* @return string[] Array of lines to write.
|
||
*/
|
||
private function prepareLines(array $ruleDefinition): array
|
||
{
|
||
$lines = $ruleDefinition['lines'] ?? [];
|
||
$isBeingCleared = ! empty($ruleDefinition['clear_rule']);
|
||
|
||
if (empty($lines) && ! $isBeingCleared) {
|
||
return [
|
||
'',
|
||
'# This feature has not been activated.',
|
||
'',
|
||
];
|
||
}
|
||
|
||
return $lines;
|
||
}
|
||
|
||
/**
|
||
* Applies a marker block to the .htaccess file, supporting configurable top-priority markers.
|
||
*/
|
||
private function applyMarkerBlock(string $markerName, array $lines, string $debugTest = 'unknown'): bool
|
||
{
|
||
$oldContent = $this->get_htaccess_content() ?: '';
|
||
|
||
// Allow certain markers to be forced to the very top of .htaccess (right under any existing top block)
|
||
$top_markers = apply_filters(
|
||
'rsssl_htaccess_top_markers',
|
||
[ 'Really Simple Auto Prepend File', 'Really Simple Security Redirect' ]
|
||
);
|
||
|
||
if ( in_array( $markerName, $top_markers, true ) ) {
|
||
// first remove any existing marker block with the same name
|
||
$result = $this->write_top_marker_block( $markerName, $lines );
|
||
} else {
|
||
// WP core will preserve everything outside of your marker
|
||
$probe = $this->get_htaccess_content();
|
||
if ( $this->is_effectively_empty( $probe ) ) {
|
||
$result = false;
|
||
} else {
|
||
// WP core will preserve everything outside of your marker
|
||
$result = insert_with_markers( $this->htaccess_file_path, $markerName, $lines );
|
||
}
|
||
}
|
||
|
||
if ( $result ) {
|
||
$newContent = $this->get_htaccess_content() ?: '';
|
||
$this->record_history( $oldContent, $newContent, $markerName, $debugTest );
|
||
}
|
||
|
||
return $result;
|
||
}
|
||
|
||
/**
|
||
* Ensures that the .htaccess file exists and is writable.
|
||
*/
|
||
private function ensure_htaccess_is_writable(): bool
|
||
{
|
||
$dir = dirname( $this->htaccess_file_path );
|
||
|
||
// Ensure the directory exists (same as before)
|
||
if ( ! is_dir( $dir ) && ! wp_mkdir_p( $dir ) ) {
|
||
$this->log_error( 'Cannot create directory for .htaccess at: ' . esc_html( $dir ) );
|
||
return false;
|
||
}
|
||
|
||
// Do **not** create a new .htaccess automatically anymore.
|
||
// This previously led to empty files overwriting existing rewrite rules in some environments.
|
||
// If a site really wants us to create the file, they must opt in via the filter below.
|
||
if ( ! is_file( $this->htaccess_file_path ) ) {
|
||
$allow_create = apply_filters( 'rsssl_allow_create_htaccess', false, $this->htaccess_file_path );
|
||
if ( $allow_create ) {
|
||
if ( @file_put_contents( $this->htaccess_file_path, '', LOCK_EX ) === false ) {
|
||
$this->log_error( 'Could not create .htaccess file at: ' . esc_html( $this->htaccess_file_path ) );
|
||
return false;
|
||
} else {
|
||
$this->log_error( 'Created new .htaccess file at: ' . esc_html( $this->htaccess_file_path ) );
|
||
}
|
||
} else {
|
||
$this->log_error( '.htaccess file does not exist and automatic creation is disabled. Path: ' . esc_html( $this->htaccess_file_path ) );
|
||
return false;
|
||
}
|
||
}
|
||
|
||
if ( ! is_writable( $this->htaccess_file_path ) ) {
|
||
$this->log_error( '.htaccess file is not writable at: ' . esc_html( $this->htaccess_file_path ) );
|
||
return false;
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* Writes a marker block that must live at the very top of .htaccess.
|
||
*
|
||
* Used for markers that must run before WordPress rewrite rules – e.g.
|
||
* - "Really Simple Auto Prepend File"
|
||
* - "Really Simple Security Redirect" (HTTP→HTTPS redirect)
|
||
*/
|
||
private function write_top_marker_block( string $markerName, array $linesToWrite ): bool
|
||
{
|
||
// Preserve original content for history
|
||
$originalHtaccess = $this->get_htaccess_content() ?: '';
|
||
// SAFETY: if .htaccess is (effectively) empty or unreadable, do not write our markers
|
||
if ( $this->is_effectively_empty( $originalHtaccess ) ) {
|
||
return false;
|
||
}
|
||
// we remove the redirect marker block if it exists, so we can write a new one
|
||
// this is needed because the redirect marker block is not removed by insert_with_markers
|
||
// We added this function because not on every save we can determine when to remove options when the rule is not present.
|
||
if ( $markerName !== 'Really Simple Security Redirect' && 'htaccess' !== rsssl_get_option('redirect')) {
|
||
$originalHtaccess = $this->remove_marker_block( $originalHtaccess, 'Really Simple Security Redirect' );
|
||
}
|
||
$htaccessWithoutMarker = $this->remove_marker_block( $originalHtaccess, $markerName );
|
||
|
||
if (empty($linesToWrite)) {
|
||
return $this->save_htaccess_if_changed($originalHtaccess, $htaccessWithoutMarker, $markerName);
|
||
}
|
||
|
||
$newMarkerBlock = $this->build_marker_block( $markerName, $linesToWrite );
|
||
$updatedHtaccess = $this->insert_marker_in_correct_position($htaccessWithoutMarker, $markerName, $newMarkerBlock);
|
||
|
||
$updatedHtaccess = $this->cleanupEmptyLines($updatedHtaccess);
|
||
|
||
@file_put_contents( $this->htaccess_file_path, $updatedHtaccess, LOCK_EX );
|
||
$this->record_history( $originalHtaccess, $updatedHtaccess, $markerName );
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* Inserts a marker block in the correct position in the .htaccess file.
|
||
*/
|
||
private function insert_marker_in_correct_position(string $htaccess, string $markerName, string $markerBlock): string
|
||
{
|
||
$autoPrependName = 'Really Simple Auto Prepend File';
|
||
|
||
if (strcasecmp($markerName, $autoPrependName) === 0) {
|
||
return $markerBlock . $htaccess;
|
||
}
|
||
|
||
$escapedAutoPrependName = preg_quote($autoPrependName, '/');
|
||
|
||
$autoPrependPattern = $this->generate_marker_pattern($autoPrependName);
|
||
|
||
if (preg_match($autoPrependPattern, $htaccess, $match, PREG_OFFSET_CAPTURE)) {
|
||
$insertPosition = $match[1][1] + strlen($match[1][0]);
|
||
return substr($htaccess, 0, $insertPosition) . $markerBlock . substr($htaccess, $insertPosition);
|
||
}
|
||
|
||
return $markerBlock . $htaccess;
|
||
}
|
||
|
||
/**
|
||
* Generates a regex pattern to match a marker block in the .htaccess file.
|
||
*
|
||
* This pattern matches both # and ### markers, case-insensitive, and captures
|
||
* the entire block including the BEGIN and END lines.
|
||
*/
|
||
public function generate_marker_pattern(string $markerName): string
|
||
{
|
||
$escaped = preg_quote($markerName, '/');
|
||
//return '/(^#+\s*BEGIN\s+' . $escaped . '[^\n]*\n.*?^#+\s*END\s+' . $escaped . '[^\n]*\n?)/ims';
|
||
return '/(^\s*#+\s*BEGIN\s+' . $escaped . '[^\n]*\n.*?^\s*#+\s*END\s+' . $escaped . '[^\n]*\n?)/ims';
|
||
}
|
||
|
||
/**
|
||
* Removes a marker block from the .htaccess file.
|
||
*/
|
||
private function remove_marker_block(string $htaccess, string $markerName): string
|
||
{
|
||
// Normalize line endings so regex behaves consistently
|
||
// $htaccess = preg_replace("/\r\n? /", "\n", $htaccess);
|
||
$htaccess = preg_replace("/\r\n?/", "\n", $htaccess);
|
||
|
||
// Build a single, tolerant pattern matching any number of leading '#', optional trailing text on BEGIN/END lines,
|
||
// and capturing across multiple lines.
|
||
$pattern = $this->generate_marker_pattern($markerName);
|
||
|
||
// Apply the replacement and capture match count for debugging
|
||
$before = $htaccess;
|
||
$htaccess = preg_replace($pattern, '', $htaccess, -1, $count);
|
||
|
||
return ltrim($htaccess, "\n");
|
||
}
|
||
|
||
/**
|
||
* Saves the .htaccess file if it has changed, and record the history.
|
||
*/
|
||
private function save_htaccess_if_changed(string $original, string $modified, string $markerName): bool
|
||
{
|
||
if ( $modified === $original ) {
|
||
return true;
|
||
}
|
||
|
||
// SAFETY: do not write when the current .htaccess is effectively empty
|
||
if ( $this->is_effectively_empty( $original ) ) {
|
||
return false;
|
||
}
|
||
|
||
$cleaned = $this->cleanupEmptyLines( $modified );
|
||
|
||
// Avoid writing an empty result
|
||
if ( $this->is_effectively_empty( $cleaned ) ) {
|
||
return true;
|
||
}
|
||
|
||
@file_put_contents( $this->htaccess_file_path, $cleaned, LOCK_EX );
|
||
$this->record_history( $original, $cleaned, $markerName );
|
||
|
||
return true;
|
||
}
|
||
|
||
private function build_marker_block(string $markerName, array $lines): string
|
||
{
|
||
return implode(PHP_EOL, array_merge(
|
||
["# BEGIN {$markerName}"],
|
||
$lines,
|
||
["# END {$markerName}"]
|
||
)) . PHP_EOL;
|
||
}
|
||
|
||
|
||
|
||
/**
|
||
* Checks if a specific marker block exists in the .htaccess file.
|
||
*
|
||
* @param array $markers The start and end markers (e.g., ['#BEGIN rule', '#END rule']).
|
||
* @return bool True if the block exists, false otherwise.
|
||
*/
|
||
public function are_markers_present(array $markers): bool
|
||
{
|
||
if (count($markers) !== 2) {
|
||
return false;
|
||
}
|
||
$content = $this->get_htaccess_content();
|
||
if ($content === null) {
|
||
return false;
|
||
}
|
||
$start_marker_escaped = preg_quote($markers[0], '/');
|
||
$end_marker_escaped = preg_quote($markers[1], '/');
|
||
return preg_match('/^\s*' . $start_marker_escaped . '.*?^\s*' . $end_marker_escaped . '/ms', $content) === 1;
|
||
}
|
||
|
||
/**
|
||
* Extracts a usable name from the BEGIN marker for insert_with_markers.
|
||
* E.g., "#BEGIN My Rule" becomes "My Rule".
|
||
*/
|
||
private function extract_name_from_marker(string $begin_marker): string
|
||
{
|
||
// Remove #, BEGIN, Begin, begin and then trim
|
||
// also remove trailing ###
|
||
$name = preg_replace( array( '/^#+\s*(BEGIN|Begin|begin)\s*/i', '/\s*#+$/' ), '', $begin_marker );
|
||
return trim($name);
|
||
}
|
||
|
||
/**
|
||
* Records a change to the .htaccess history.
|
||
*
|
||
* @param string $old_content The previous content.
|
||
* @param string $new_content The new content.
|
||
*/
|
||
private function record_history( string $old_content, string $new_content , string $marker = 'unknown', string $debugTest = 'unknown'): void
|
||
{
|
||
if ( ! $this->is_htaccess_tracking_enabled() ) {
|
||
// we remove the option if the constant is not defined.
|
||
delete_option( 'rsssl_htaccess_history' );
|
||
return;
|
||
}
|
||
if ( $old_content === $new_content ) {
|
||
return;
|
||
}
|
||
$history = get_option( 'rsssl_htaccess_history', [] );
|
||
$history[] = [
|
||
'timestamp' => time(),
|
||
'file_path' => $this->htaccess_file_path,
|
||
'old_content' => $old_content,
|
||
'new_content' => $new_content,
|
||
'user_id' => function_exists( 'get_current_user_id' ) ? get_current_user_id() : 0,
|
||
'marker' => $marker,
|
||
// logging the current hook name for debugging purposes
|
||
'hook' => current_filter() ?: 'unknown',
|
||
// logging the current action for debugging purposes
|
||
'action' => current_action()? : 'unknown',
|
||
'debug_test' => $debugTest,
|
||
];
|
||
if ( count( $history ) > 20 ) {
|
||
$history = array_slice( $history, -20 );
|
||
}
|
||
update_option( 'rsssl_htaccess_history', $history, false );
|
||
}
|
||
|
||
/**
|
||
* Clears a specific marker block from the .htaccess file.
|
||
*
|
||
* @param string|array $marker The marker name (string) or marker array (['#Begin ...', '#End ...']).
|
||
* @return bool True on success, false on failure.
|
||
*/
|
||
public function clear_rule($marker, string $debugTest = 'unknown'): bool
|
||
{
|
||
// Accept either a string (marker name) or an array (markers)
|
||
if (is_array($marker)) {
|
||
$begin_marker = $marker[0] ?? '';
|
||
} else {
|
||
$begin_marker = $marker;
|
||
}
|
||
$rule_definition = [
|
||
'marker' => $begin_marker,
|
||
'lines' => [],
|
||
'clear_rule' => true,
|
||
];
|
||
return $this->write_rule($rule_definition, $debugTest);
|
||
}
|
||
|
||
/**
|
||
* Clears a specific marker block from the .htaccess file without using
|
||
* insert_with_markers. This method directly removes the block using raw
|
||
* regex matching. This is needed for old markings that had capitalized
|
||
* Begin and End markers.
|
||
*/
|
||
public function clear_legacy_rule(string $marker): bool
|
||
{
|
||
$content = $this->get_htaccess_content();
|
||
if ($content === null) {
|
||
return false;
|
||
}
|
||
|
||
// SAFETY: if the file is effectively empty, do not attempt to rewrite it
|
||
if ( $this->is_effectively_empty( $content ) ) {
|
||
return false;
|
||
}
|
||
|
||
// Match and remove the block with the exact marker name
|
||
// Use case-insensitive matching for BEGIN/END to handle both legacy and WordPress standard formats
|
||
$escaped = preg_quote($marker, '/');
|
||
$pattern = '/^#+\s*BEGIN\s+' . $escaped . '.*?^#+\s*END\s+' . $escaped . '.*?$/msi';
|
||
|
||
$new_content = trim(preg_replace($pattern, '', $content));
|
||
|
||
// Regex error
|
||
if ($new_content === null) {
|
||
return false;
|
||
}
|
||
|
||
// Write the updated content back to the .htaccess file
|
||
if ( $new_content !== $content && ! $this->is_effectively_empty( $new_content ) ) {
|
||
return file_put_contents($this->htaccess_file_path, $new_content, LOCK_EX) !== false;
|
||
}
|
||
|
||
return true; // No changes needed
|
||
}
|
||
|
||
/**
|
||
* Records the history of changes made by WP Rocket to the .htaccess file.
|
||
*/
|
||
public function record_history_from_rocket(): void
|
||
{
|
||
// We get the previous content from the history, if it exists.
|
||
$history = get_option( 'rsssl_htaccess_history', [] );
|
||
$old_content = '';
|
||
if ( ! empty( $history ) ) {
|
||
$last_entry = end( $history );
|
||
if ( isset( $last_entry['new_content'] ) ) {
|
||
$old_content = $last_entry['new_content'];
|
||
}
|
||
}
|
||
$new_content = file_get_contents( $this->htaccess_file_path );
|
||
if ( $new_content === false ) {
|
||
return;
|
||
}
|
||
$this->record_history( $old_content, $new_content, 'wp-rocket' );
|
||
}
|
||
|
||
/**
|
||
* Checks if .htaccess history tracking is enabled via constant.
|
||
*
|
||
* @since 5.x.x
|
||
*
|
||
* @return bool True if .htaccess history tracking is enabled, false otherwise.
|
||
*/
|
||
private function is_htaccess_tracking_enabled(): bool
|
||
{
|
||
return defined( 'RSSSL_RECORDS_HISTORY_VERSION' );
|
||
}
|
||
|
||
/**
|
||
* Reads the content between a marker block in the .htaccess file and returns it as a string, including the marker lines.
|
||
*/
|
||
public function get_rule_content(string $markerName):? string
|
||
{
|
||
$content = $this->get_htaccess_content();
|
||
if ($content === null) {
|
||
return null;
|
||
}
|
||
// Match both # and ### marker styles, case-insensitive, including the marker lines
|
||
$escaped = preg_quote($markerName, '/');
|
||
$pattern = '/(#+\s*BEGIN\s+' . $escaped . '[^\n]*\n.*?#+\s*END\s+' . $escaped . '[^\n]*\n?)/is';
|
||
if (preg_match($pattern, $content, $matches)) {
|
||
return trim($matches[1]);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Writes an error message to the error log.
|
||
*/
|
||
public function log_error(string $message): void
|
||
{
|
||
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
|
||
error_log( 'RSSSL_Htaccess_File_Manager: ' . $message );
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Validates the .htaccess file path. If exists, writable and a valid string.
|
||
*/
|
||
public function validate_htaccess_file_path(): bool {
|
||
// Check if the file path is a valid string and not empty
|
||
if (empty( $this->htaccess_file_path ) ) {
|
||
return false;
|
||
}
|
||
|
||
// Check if the file exists and is writable
|
||
if ( ! is_file( $this->htaccess_file_path ) || ! is_writable( $this->htaccess_file_path ) ) {
|
||
return false;
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* Checks if the .htaccess file exists.
|
||
*/
|
||
public function file_exists( string $file_path ): bool {
|
||
return is_file( $file_path );
|
||
}
|
||
|
||
/**
|
||
* Cleans up extra empty lines in .htaccess content.
|
||
*
|
||
* @param string $content The raw .htaccess content.
|
||
* @return string The content with consecutive blank lines reduced.
|
||
*/
|
||
private function cleanupEmptyLines(string $content): string
|
||
{
|
||
// Normalize all line endings to "\n"
|
||
// Collapse three or more consecutive newlines into two
|
||
$content = preg_replace( array( "/\r\n?/", "/\n{3,}/" ), array( "\n", "\n\n" ), $content );
|
||
return $content;
|
||
}
|
||
|
||
/**
|
||
* Checks if the given content is effectively empty (only whitespace).
|
||
*/
|
||
private function is_effectively_empty( $content ): bool {
|
||
if ( $content === null || $content === false ) {
|
||
return true;
|
||
}
|
||
return trim( (string) $content ) === '';
|
||
}
|
||
}
|
||
} |