matcher = $matcher;
$this->server = $server;
$this->settings = $settings;
$this->files = $files;
}
/**
* Returns the absolute path to the .htaccess file.
*
* @return string Absolute path, or '' when the site root is unknown.
*/
public function get_path(): string {
$home = $this->files->home_path();
return '' === $home ? '' : $home . '.htaccess';
}
/**
* Whether the document root could be located at all.
*
* @return bool True when the site root is known.
*/
public function knows_site_root(): bool {
return '' !== $this->get_path();
}
/**
* Whether this layer can do anything on this server.
*
* @return bool True when the server reads .htaccess.
*/
public function is_available(): bool {
return $this->server->supports_htaccess();
}
/**
* Returns the rule lines, without markers.
*
* @return string[] Rule lines. Empty when there is nothing to block.
*/
public function build_rules(): array {
$tokens = $this->matcher->get_enforceable_tokens();
if ( empty( $tokens ) ) {
return array();
}
$lines = array(
'# Managed by AI Blackout. Changes inside this block will be overwritten.',
'',
);
foreach ( $tokens as $token ) {
$lines[] = sprintf(
' SetEnvIfNoCase User-Agent "%s" aiblackout_block',
preg_quote( $token, '"' )
);
}
$lines[] = '';
$lines[] = ' # The rights declaration stays readable to every crawler, including the';
$lines[] = ' # ones refused above. Neither path carries site content.';
$lines[] = ' SetEnvIf Request_URI "(^|/)robots\.txt$" aiblackout_declaration=1';
$lines[] = ' SetEnvIf Request_URI "(^|/)\.well-known/" aiblackout_declaration=1';
$lines[] = ' SetEnvIf REDIRECT_aiblackout_declaration "1" aiblackout_declaration=1';
$lines[] = ' SetEnvIf aiblackout_declaration "1" !aiblackout_block';
$lines[] = '';
$lines[] = '';
$lines[] = '';
$lines[] = ' AuthMerging And';
$lines[] = ' ';
$lines[] = ' Require all granted';
$lines[] = ' Require not env aiblackout_block';
$lines[] = ' ';
$lines[] = '';
return $lines;
}
/**
* Returns the current file contents.
*
* @return string|null Contents, or null when the file does not exist.
*/
public function read(): ?string {
$path = $this->get_path();
if ( '' === $path || ! file_exists( $path ) ) {
return null;
}
$contents = file_get_contents( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Read-only detection that must not depend on the WP_Filesystem write transport. All writes go through the Filesystem wrapper.
return false === $contents ? null : $contents;
}
/**
* Whether our block is currently in the file.
*
* @return bool True when present.
*/
public function is_applied(): bool {
$contents = $this->read();
return null !== $contents && MarkerBlock::exists( $contents, self::BEGIN_MARKER, self::END_MARKER );
}
/**
* Whether the applied block no longer matches current policy.
*
* @return bool True when a block is present and out of date.
*/
public function is_stale(): bool {
if ( ! $this->is_applied() ) {
return false;
}
return $this->extract_tokens( (string) $this->read() ) !== $this->extract_tokens( $this->build_block() );
}
/**
* Returns the crawler tokens a marker block currently asserts, sorted.
*
* @param string $contents Full file contents, or a bare block.
* @return string[] Quoted token strings, as written (already preg_quote()'d).
*/
private function extract_tokens( string $contents ): array {
$location = MarkerBlock::locate( $contents, self::BEGIN_MARKER, self::END_MARKER );
$block = null === $location ? $contents : substr( $contents, $location['start'], $location['length'] );
$tokens = array();
if ( preg_match_all( '/SetEnvIfNoCase User-Agent "([^"]*)" aiblackout_block/', $block, $matches ) ) {
$tokens = $matches[1];
}
sort( $tokens );
return $tokens;
}
/**
* Returns warnings the user must see before a write.
*
* @return string The block, or an empty string when there is nothing to write.
*/
public function build_block(): string {
$rules = $this->build_rules();
if ( empty( $rules ) ) {
return '';
}
return self::BEGIN_MARKER . "\n" . implode( "\n", $rules ) . "\n" . self::END_MARKER . "\n";
}
/**
* Writes the block into .htaccess.
*
* @return true|\WP_Error True on success.
*/
public function apply() {
if ( ! $this->is_available() ) {
return new \WP_Error( 'aiblackout_htaccess_unsupported', $this->server->describe() );
}
if ( empty( $this->build_rules() ) ) {
return new \WP_Error(
'aiblackout_htaccess_no_rules',
__( 'There are no crawlers set to block, so there is nothing to write.', 'ai-blackout' )
);
}
$path = $this->get_path();
if ( '' === $path ) {
return new \WP_Error(
'aiblackout_home_path_unknown',
$this->files->get_unknown_home_message()
);
}
// Applying is refused unless reverting will also be possible.
if ( ! $this->files->is_available() ) {
return new \WP_Error(
'aiblackout_htaccess_no_filesystem',
$this->files->get_unavailable_message(),
array( 'block' => $this->build_block() )
);
}
if ( file_exists( $path ) && ! $this->files->is_writable( $path ) ) {
return new \WP_Error(
'aiblackout_htaccess_not_writable',
sprintf(
/* translators: %s: Absolute file path. */
__( 'The file %s is not writable, so the .htaccess layer is not active. Your robots.txt and PHP layers are still working, but requests served from a page cache are not being blocked.', 'ai-blackout' ),
$path
),
array( 'block' => $this->build_block() )
);
}
if ( ! file_exists( $path ) && ! $this->files->is_writable( dirname( $path ) ) ) {
return new \WP_Error(
'aiblackout_htaccess_dir_not_writable',
sprintf(
/* translators: %s: Absolute directory path. */
__( 'The directory %s is not writable, so an .htaccess file cannot be created. The .htaccess layer is not active.', 'ai-blackout' ),
dirname( $path )
),
array( 'block' => $this->build_block() )
);
}
$this->backup();
if ( ! function_exists( 'insert_with_markers' ) ) {
require_once ABSPATH . 'wp-admin/includes/misc.php';
}
if ( ! insert_with_markers( $path, self::MARKER, $this->build_rules() ) ) {
return new \WP_Error(
'aiblackout_htaccess_write_failed',
__( 'Writing to .htaccess failed. The .htaccess layer is not active.', 'ai-blackout' )
);
}
return true;
}
/**
* Removes the block from .htaccess.
*
* @return array|\WP_Error Array with 'byte_identical' and 'message', or WP_Error.
*/
public function revert() {
$path = $this->get_path();
if ( '' === $path ) {
return new \WP_Error(
'aiblackout_home_path_unknown',
$this->files->get_unknown_home_message()
);
}
$contents = $this->read();
if ( null === $contents ) {
return array(
'byte_identical' => true,
'message' => __( 'There is no .htaccess file, so there was nothing to remove.', 'ai-blackout' ),
);
}
if ( ! MarkerBlock::exists( $contents, self::BEGIN_MARKER, self::END_MARKER ) ) {
return array(
'byte_identical' => true,
'message' => __( 'The AI Blackout block is not in .htaccess, so there was nothing to remove.', 'ai-blackout' ),
);
}
if ( ! $this->files->is_available() || ! $this->files->is_writable( $path ) ) {
return new \WP_Error(
'aiblackout_htaccess_not_writable',
sprintf(
/* translators: %s: Absolute file path. */
__( 'The file %s could not be written, so the block was not removed. It is still in place and still blocking. Delete the lines between the AI Blackout markers by hand to remove it.', 'ai-blackout' ),
$path
)
);
}
$stripped = MarkerBlock::remove( $contents, self::BEGIN_MARKER, self::END_MARKER );
$backup = $this->settings->get( 'htaccess_backup' );
$original = is_array( $backup ) && isset( $backup['bytes'] ) ? (string) $backup['bytes'] : null;
$existed = ! is_array( $backup ) || ! isset( $backup['existed'] ) || (bool) $backup['existed'];
if ( ! $existed && '' === trim( $stripped ) ) {
if ( ! $this->files->delete( $path ) ) {
return new \WP_Error(
'aiblackout_htaccess_delete_failed',
sprintf(
/* translators: %s: Absolute file path. */
__( 'The AI Blackout rules were removed, but the empty %s file it created could not be deleted. Nothing is being blocked by it. Delete the file by hand to finish clearing up.', 'ai-blackout' ),
$path
)
);
}
$this->settings->set( 'htaccess_backup', null );
return array(
'byte_identical' => true,
'message' => __( 'The AI Blackout block was removed, and the .htaccess file was deleted because your site did not have one before AI Blackout created it.', 'ai-blackout' ),
);
}
// Treat a line-ending-only difference as byte-identical.
$restore = null !== $original
&& $stripped !== $original
&& MarkerBlock::is_whitespace_only_difference( $stripped, $original );
$final = $restore ? $original : $stripped;
if ( ! $this->files->write( $path, $final ) ) {
return new \WP_Error(
'aiblackout_htaccess_write_failed',
__( 'Removing the block from .htaccess failed. It is still in place.', 'ai-blackout' )
);
}
$identical = null === $original || $final === $original;
$this->settings->set( 'htaccess_backup', null );
return array(
'byte_identical' => $identical,
'message' => $identical
? __( 'The AI Blackout block was removed and .htaccess is byte-for-byte what it was before.', 'ai-blackout' )
: __( 'The AI Blackout block was removed. The file does not exactly match the backup taken before the first write, because it was edited outside the markers in the meantime. Those edits have been kept.', 'ai-blackout' ),
);
}
/**
* Stores the exact bytes of .htaccess before the first write.
*
* @return void
*/
private function backup(): void {
if ( null !== $this->settings->get( 'htaccess_backup' ) ) {
return;
}
$contents = $this->read();
$this->settings->set(
'htaccess_backup',
array(
'bytes' => null === $contents ? '' : $contents,
'existed' => null !== $contents,
'sha1' => sha1( (string) $contents ),
'stored_at' => time(),
)
);
}
}