policy = $policy; $this->settings = $settings; $this->files = $files; $this->signals = $signals; } /** * Registers the filter. * * @return void */ public function register(): void { add_filter( 'robots_txt', array( $this, 'filter_robots_txt' ), 10, 2 ); } /** * Appends the managed block to WordPress's virtual robots.txt. * * @param string $output The robots.txt output so far. * @param bool $is_public Whether the site is considered public. * @return string Filtered output. */ public function filter_robots_txt( $output, $is_public ): string { unset( $is_public ); $output = (string) $output; if ( aiblackout_is_disabled() ) { return $output; } $block = $this->build_block(); if ( '' === $block ) { return $output; } return rtrim( $output, "\n" ) . "\n\n" . $block; } /** * Builds the managed robots.txt block. * * @return string The block, or an empty string when there is nothing to declare. */ public function build_block(): string { $agents = $this->policy->agents_to_declare(); $signals = $this->signals->build_block(); if ( empty( $agents ) && '' === $signals ) { return ''; } $lines = array( self::BEGIN_MARKER, '# Managed by AI Blackout. Changes inside this block will be overwritten.', ); if ( '' !== $signals ) { $lines[] = ''; $lines[] = $signals; } foreach ( $agents as $agent ) { $lines[] = ''; $lines[] = 'User-agent: ' . $agent['ua']; $lines[] = 'Disallow: /'; } $lines[] = ''; $lines[] = self::END_MARKER; return implode( "\n", $lines ) . "\n"; } /** * Returns the absolute path where a physical robots.txt would live. * * @return string Absolute path, or '' when the site root is unknown. */ public function get_physical_path(): string { $home = $this->files->home_path(); return '' === $home ? '' : $home . 'robots.txt'; } /** * Whether a physical robots.txt exists and is therefore overriding * WordPress. * * @return bool True when a physical file exists. */ public function has_physical_file(): bool { $path = $this->get_physical_path(); return '' !== $path && file_exists( $path ); } /** * 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_physical_path(); } /** * Returns the current contents of the physical robots.txt. * * @return string|null File contents, or null when there is no file. */ public function read_physical_file(): ?string { if ( ! $this->has_physical_file() ) { return null; } $contents = file_get_contents( $this->get_physical_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 the physical file already contains our managed block. * * @return bool True when the block is present. */ public function physical_file_has_block(): bool { $contents = $this->read_physical_file(); return null !== $contents && false !== strpos( $contents, self::BEGIN_MARKER ); } /** * Produces the merged contents of the physical file, without writing. * * @return string Proposed file contents. */ private function build_merged_file(): string { $existing = $this->read_physical_file(); $block = $this->build_block(); if ( null === $existing ) { return $block; } return MarkerBlock::replace( $existing, self::BEGIN_MARKER, self::END_MARKER, $block ); } /** * Writes the merged block into the physical robots.txt. * * @return true|\WP_Error True on success. */ public function merge_into_physical_file() { $path = $this->get_physical_path(); if ( '' === $path ) { return new \WP_Error( 'aiblackout_home_path_unknown', $this->files->get_unknown_home_message() ); } if ( ! $this->has_physical_file() ) { return new \WP_Error( 'aiblackout_no_physical_robots', __( 'There is no robots.txt file to merge into. WordPress is serving a virtual one, which this plugin already controls.', 'ai-blackout' ) ); } if ( ! $this->files->is_available() || ! $this->files->is_writable( $path ) ) { return new \WP_Error( 'aiblackout_robots_not_writable', sprintf( /* translators: %s: Absolute file path. */ __( 'The file %s could not be written, so the rules were not added. Your robots.txt rules are not live. Fix the file permissions, or paste the block in yourself.', 'ai-blackout' ), $path ), array( 'block' => $this->build_block() ) ); } $this->backup_physical_file(); if ( ! $this->files->write( $path, $this->build_merged_file() ) ) { return new \WP_Error( 'aiblackout_robots_write_failed', __( 'Writing to robots.txt failed. Your robots.txt rules are not live.', 'ai-blackout' ) ); } return true; } /** * Removes our block from the physical robots.txt. * * @return array|\WP_Error Array with 'byte_identical' and 'message', or WP_Error. */ public function revert_physical_file() { $path = $this->get_physical_path(); if ( '' === $path ) { return new \WP_Error( 'aiblackout_home_path_unknown', $this->files->get_unknown_home_message() ); } if ( ! $this->has_physical_file() ) { return array( 'byte_identical' => true, 'message' => __( 'There is no robots.txt file, so there was nothing to remove.', 'ai-blackout' ), ); } if ( ! $this->files->is_available() || ! $this->files->is_writable( $path ) ) { return new \WP_Error( 'aiblackout_robots_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. Delete the lines between the AI Blackout markers by hand to remove it.', 'ai-blackout' ), $path ) ); } $contents = $this->read_physical_file(); if ( null === $contents || ! MarkerBlock::exists( $contents, self::BEGIN_MARKER, self::END_MARKER ) ) { return array( 'byte_identical' => true, 'message' => __( 'The AI Blackout block is not in robots.txt, so there was nothing to remove.', 'ai-blackout' ), ); } $stripped = MarkerBlock::remove( $contents, self::BEGIN_MARKER, self::END_MARKER ); $backup = $this->settings->get( 'robots_backup' ); $original = is_array( $backup ) && isset( $backup['bytes'] ) ? (string) $backup['bytes'] : null; // Treat a whitespace-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_robots_write_failed', __( 'Removing the block from robots.txt failed. It is still in place.', 'ai-blackout' ) ); } $identical = null === $original || $final === $original; // Dropped for the same reason Htaccess::revert() drops its own. $this->settings->set( 'robots_backup', null ); return array( 'byte_identical' => $identical, 'message' => $identical ? __( 'The AI Blackout block was removed and robots.txt 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 the physical file before the first write. * * @return void */ private function backup_physical_file(): void { if ( null !== $this->settings->get( 'robots_backup' ) ) { return; } $contents = $this->read_physical_file(); if ( null === $contents ) { return; } $this->settings->set( 'robots_backup', array( 'bytes' => $contents, 'sha1' => sha1( $contents ), 'stored_at' => time(), ) ); } }