Linux webm002.cluster126.gra.hosting.ovh.net 5.15.206-ovh-vps-grsec-zfs-classid #1 SMP Fri May 15 02:41:25 UTC 2026 x86_64
/
home
/
a
/
r
/
i
/
ariannadhf
/
www
/
wp-content
/
plugins
/
simple-history
/
inc
/
channels
/
/home/a/r/i/ariannadhf/www/wp-content/plugins/simple-history/inc/channels/class-file-channel.php
<?php namespace Simple_History\Channels; use Simple_History\Helpers; /** * File Channel for Simple History. * * This channel automatically writes all log events to files, * providing a backup mechanism and demonstrating the channel system. * This is included in the free version of Simple History. * * @since 4.4.0 */ class File_Channel extends Channel { /** * Delay before running cleanup after a log write (in seconds). * Daily is sufficient since files only rotate daily/weekly/monthly. */ private const CLEANUP_DELAY_SECONDS = DAY_IN_SECONDS; /** * The unique slug for this channel. * * @var ?string */ protected ?string $slug = 'file'; /** * Called when the channel is loaded and ready. * * Registers hooks for async cleanup processing. */ public function loaded() { add_action( 'simple_history_cleanup_log_files', [ $this, 'handle_async_cleanup' ] ); } /** * Get the selected formatter slug with fallback. * * Returns the saved formatter slug if it exists in available formatters, * otherwise falls back to 'human_readable'. This handles the case where * a user had the premium add-on with additional formatters, selected one, * and then disabled the premium add-on. * * @return string The formatter slug. */ private function get_selected_formatter_slug(): string { $formatter_slug = $this->get_setting( 'formatter', 'human_readable' ); $formatters = $this->get_available_formatters(); // Check if saved formatter exists in available formatters. if ( isset( $formatters[ $formatter_slug ] ) ) { return $formatter_slug; } // Fall back to human readable. return 'human_readable'; } /** * Get the formatter instance for this channel. * * @return Formatter_Interface The formatter instance. */ private function get_formatter(): Formatter_Interface { $formatter_slug = $this->get_selected_formatter_slug(); $formatters = $this->get_available_formatters(); // Return the formatter instance. if ( isset( $formatters[ $formatter_slug ] ) && $formatters[ $formatter_slug ] instanceof Formatter_Interface ) { return $formatters[ $formatter_slug ]; } // Fall back to human readable (should not happen but safety first). return new Human_Readable_Formatter(); } /** * Get available formatters. * * Returns an array of formatter instances keyed by slug. * Each formatter provides its own name and description via get_name() and get_description(). * * @return array<string, Formatter_Interface> */ private function get_available_formatters(): array { $formatters = [ 'human_readable' => new Human_Readable_Formatter(), ]; /** * Filter available formatters for the file channel. * * Allows adding custom formatters. Each formatter must implement Formatter_Interface * and provide get_name() and get_description() methods. * * Example: * * add_filter( 'simple_history/file_channel/formatters', function( $formatters ) { * $formatters['my_format'] = new My_Custom_Formatter(); * return $formatters; * } ); * * @since 5.7.0 * * @param array<string, Formatter_Interface> $formatters Array of formatter instances keyed by slug. */ return apply_filters( 'simple_history/file_channel/formatters', $formatters ); } /** * Get the display name for this channel. * * @return string The channel display name. */ public function get_name() { return __( 'Local Files', 'simple-history' ); } /** * Get the description for this channel. * * @return string The channel description. */ public function get_description() { return __( 'Write events to log files on this server for backup or import into analysis tools.', 'simple-history' ); } /** * Output HTML after the description in the intro section. */ public function settings_output_intro() { ?> <p> <?php esc_html_e( 'Log files are stored independently from the database, unaffected by "Clear log" or database retention settings.', 'simple-history' ); ?> </p> <?php } /** * Send an event to this channel. * * @param array $event_data The event data to send. * @param string $formatted_message The formatted message. * @return bool True on success, false on failure. */ public function send_event( $event_data, $formatted_message ) { // Don't write anything if channel is disabled. if ( ! $this->is_enabled() ) { return true; } // Get the log file path. $log_file = $this->get_log_file_path(); if ( ! $log_file ) { return false; } // Ensure directory exists. $log_dir = dirname( $log_file ); if ( ! $this->ensure_directory_exists( $log_dir ) ) { return false; } // Format the log entry using configured formatter. $formatter = $this->get_formatter(); $log_entry = $formatter->format( $event_data, $formatted_message ); // Write to file. // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.file_ops_file_put_contents -- Direct file operations required for log file channel. $result = file_put_contents( $log_file, $log_entry, FILE_APPEND | LOCK_EX ); if ( $result === false ) { return false; } // Schedule cleanup if needed. $this->schedule_cleanup_if_needed(); return true; } /** * Add settings fields for this channel using WordPress Settings API. * * @param string $settings_page_slug The settings page slug. * @param string $settings_section_id The settings section ID. */ public function add_settings_fields( $settings_page_slug, $settings_section_id ) { // Add parent's enable checkbox first. parent::add_settings_fields( $settings_page_slug, $settings_section_id ); $option_name = $this->get_settings_option_name(); // Output format field. add_settings_field( $option_name . '_formatter', Helpers::get_settings_field_title_output( __( 'Output format', 'simple-history' ) ), [ $this, 'settings_field_formatter' ], $settings_page_slug, $settings_section_id ); // File management field (rotation + retention combined). add_settings_field( $option_name . '_file_management', Helpers::get_settings_field_title_output( __( 'File management', 'simple-history' ) ), [ $this, 'settings_field_file_management' ], $settings_page_slug, $settings_section_id ); // File path info field. add_settings_field( $option_name . '_file_path', Helpers::get_settings_field_title_output( __( 'Log folder', 'simple-history' ) ), [ $this, 'settings_field_file_path' ], $settings_page_slug, $settings_section_id ); } /** * Render the "Enabled" settings field with a descriptive label. * * Overrides parent to provide a more descriptive checkbox label. */ public function settings_field_enabled() { $enabled = $this->is_enabled(); $option_name = $this->get_settings_option_name(); ?> <label> <input type="checkbox" name="<?php echo esc_attr( $option_name ); ?>[enabled]" value="1" <?php checked( $enabled ); ?> /> <?php esc_html_e( 'Enable file logging', 'simple-history' ); ?> </label> <?php } /** * Render the combined file management settings field. * Combines rotation frequency and file retention into a natural sentence. */ public function settings_field_file_management() { $option_name = $this->get_settings_option_name(); $rotation_value = $this->get_setting( 'rotation_frequency', 'daily' ); $keep_files_value = $this->get_setting( 'keep_files', 30 ); $rotation_options = [ 'daily' => __( 'daily', 'simple-history' ), 'weekly' => __( 'weekly', 'simple-history' ), 'monthly' => __( 'monthly', 'simple-history' ), ]; ?> <label class="sh-FileChannel-fileManagement"> <?php esc_html_e( 'Create a new file', 'simple-history' ); ?> <select name="<?php echo esc_attr( $option_name ); ?>[rotation_frequency]"> <?php foreach ( $rotation_options as $value => $label ) { ?> <option value="<?php echo esc_attr( $value ); ?>" <?php selected( $rotation_value, $value ); ?>> <?php echo esc_html( $label ); ?> </option> <?php } ?> </select> <?php esc_html_e( 'and keep the last', 'simple-history' ); ?> <input type="number" name="<?php echo esc_attr( $option_name ); ?>[keep_files]" value="<?php echo esc_attr( $keep_files_value ); ?>" min="1" max="365" class="small-text" /> <?php esc_html_e( 'files', 'simple-history' ); ?> </label> <?php } /** * Render the output format settings field. */ public function settings_field_formatter() { $option_name = $this->get_settings_option_name(); $selected_formatted_slug = $this->get_selected_formatter_slug(); $formatters = $this->get_available_formatters(); $is_premium_active = Helpers::is_premium_add_on_active(); ?> <fieldset class="sh-RadioOptions"> <?php foreach ( $formatters as $formatter_slug => $formatter ) { ?> <label class="sh-RadioOption"> <input type="radio" name="<?php echo esc_attr( $option_name ); ?>[formatter]" value="<?php echo esc_attr( $formatter_slug ); ?>" <?php checked( $selected_formatted_slug, $formatter_slug ); ?> /> <?php echo esc_html( $formatter->get_name() ); ?> <span class="sh-RadioOptionDescription description"> <?php echo esc_html( $formatter->get_description() ); ?> </span> </label> <?php } // Show disabled premium formatters when premium is not active. if ( ! $is_premium_active ) { $this->render_premium_formatter_teasers(); } ?> </fieldset> <?php // Show premium promo box if premium add-on is not active. if ( $is_premium_active ) { return; } echo wp_kses_post( Helpers::get_premium_feature_teaser( __( 'Unlock All Log Formats', 'simple-history' ), [ __( 'JSON Lines, Logfmt, and Syslog formats', 'simple-history' ), __( 'Compatible with Graylog, Splunk, Grafana Loki, and more', 'simple-history' ), __( 'Machine-readable for easy parsing and analysis', 'simple-history' ), ], 'file_channel_formatters', __( 'Unlock All Formats', 'simple-history' ) ) ); } /** * Render disabled radio buttons for premium formatters. * * Shows what premium formatters look like without being selectable, * creating FOMO and demonstrating value of the premium add-on. */ private function render_premium_formatter_teasers() { // Define premium formatters with their names and descriptions. // These match the actual premium formatters for consistency. $premium_formatters = [ 'json_lines' => [ 'name' => __( 'JSON Lines (GELF)', 'simple-history' ), 'description' => __( 'One JSON object per line. Best for Graylog, ELK, Splunk, and log aggregation tools.', 'simple-history' ), ], 'logfmt' => [ 'name' => __( 'Logfmt', 'simple-history' ), 'description' => __( 'Key=value pairs. Best for Grafana Loki, Prometheus, and cloud-native log systems.', 'simple-history' ), ], 'rfc5424' => [ 'name' => __( 'RFC 5424 Syslog', 'simple-history' ), 'description' => __( 'Standard syslog format with structured data. Best for syslog servers and SIEM tools.', 'simple-history' ), ], ]; foreach ( $premium_formatters as $formatter ) { ?> <label class="sh-RadioOption sh-RadioOption--disabled"> <input type="radio" disabled /> <?php echo esc_html( $formatter['name'] ); ?> <span class="sh-RadioOptionDescription description"> <?php echo esc_html( $formatter['description'] ); ?> </span> </label> <?php } } /** * Test folder writability and attempt to create if needed. * * Returns an array with status information about the folder: * - is_writable: bool - Whether the folder exists and is writable * - creation_failed: bool - Whether creation was attempted but failed * * @param string $directory The directory path to test. * @return array{is_writable: bool, creation_failed: bool} */ private function test_folder_writability( $directory ) { $existed_before = is_dir( $directory ); $is_writable = $this->ensure_directory_exists( $directory ); return [ 'is_writable' => $is_writable, 'creation_failed' => ! $existed_before && ! $is_writable, ]; } /** * Render the file path info field. */ public function settings_field_file_path() { $log_directory = $this->get_log_directory_path(); $test_url = $this->get_log_directory_url(); // Test folder writability and attempt creation if needed. $folder_status = $this->test_folder_writability( $log_directory ); $creation_failed = $folder_status['creation_failed']; $is_writable = $folder_status['is_writable']; // Get stats for display. $stats = $is_writable ? $this->get_log_files_stats() : null; ?> <div class="sh-FileChannel-folderInfo"> <?php // Folder path. ?> <code class="sh-FileChannel-folderPath"><?php echo esc_html( $log_directory ); ?></code> <?php // Status line with icon. ?> <p class="sh-FileChannel-folderStatus"> <?php if ( $creation_failed ) { ?> <span class="sh-FileChannel-folderStatus--error"> <span class="dashicons dashicons-warning"></span> <?php esc_html_e( 'Folder could not be created. Check that the parent directory is writable.', 'simple-history' ); ?> </span> <?php } elseif ( ! $is_writable ) { ?> <span class="sh-FileChannel-folderStatus--error"> <span class="dashicons dashicons-warning"></span> <?php esc_html_e( 'Folder exists but is not writable. Check folder permissions.', 'simple-history' ); ?> </span> <?php } else { ?> <span class="sh-FileChannel-folderStatus--success"> <span class="dashicons dashicons-yes-alt"></span> <?php esc_html_e( 'Writable', 'simple-history' ); ?> </span> <?php if ( $stats && $stats['count'] > 0 ) { ?> <span class="sh-FileChannel-folderStats"> <?php echo esc_html( sprintf( /* translators: 1: number of files, 2: total size */ _n( '%1$s file', '%1$s files', $stats['count'], 'simple-history' ), number_format_i18n( $stats['count'] ) ) ); ?> · <?php echo esc_html( size_format( $stats['total_size'] ) ); ?> <?php if ( $stats['oldest'] && $stats['newest'] ) { ?> · <?php echo esc_html( sprintf( /* translators: 1: start date, 2: end date */ __( '%1$s – %2$s', 'simple-history' ), wp_date( 'M j', $stats['oldest'] ), wp_date( 'M j, Y', $stats['newest'] ) ) ); ?> <?php } ?> </span> <?php } ?> <?php } ?> </p> <?php // Security note (inline, discrete). ?> <p class="sh-FileChannel-securityNote"> <?php if ( $test_url ) { esc_html_e( 'Folder is public.', 'simple-history' ); ?> <a href="<?php echo esc_url( $test_url ); ?>" target="_blank" class="sh-ExternalLink"> <?php esc_html_e( 'Verify access is blocked', 'simple-history' ); ?> </a> <?php } else { esc_html_e( 'Folder is outside the public web directory.', 'simple-history' ); } ?> </p> </div> <?php } /** * Get the URL to the log directory for access testing. * * @return string|false The URL to the log directory, or false if not determinable. */ private function get_log_directory_url() { $log_directory = $this->get_log_directory_path(); // Check if the log directory is inside ABSPATH (WordPress root). // If so, it's potentially publicly accessible. if ( strpos( $log_directory, ABSPATH ) !== 0 ) { return false; } // Get the relative path from ABSPATH. $relative_path = substr( $log_directory, strlen( ABSPATH ) ); // Build the URL. return site_url( $relative_path ); } /** * Sanitize settings for this channel. * * @param array $input Raw input data from form submission. * @return array Sanitized settings. */ public function sanitize_settings( $input ) { // Get parent sanitization first. $sanitized = parent::sanitize_settings( $input ); // Sanitize rotation frequency. $valid_frequencies = [ 'daily', 'weekly', 'monthly' ]; $sanitized['rotation_frequency'] = in_array( $input['rotation_frequency'] ?? '', $valid_frequencies, true ) ? $input['rotation_frequency'] : 'daily'; // Sanitize formatter. $valid_formatters = array_keys( $this->get_available_formatters() ); $sanitized['formatter'] = in_array( $input['formatter'] ?? '', $valid_formatters, true ) ? $input['formatter'] : 'human_readable'; // Sanitize keep files (integer between 1 and 365). $keep_files = isset( $input['keep_files'] ) ? absint( $input['keep_files'] ) : 30; $sanitized['keep_files'] = min( 365, max( 1, $keep_files ) ); return $sanitized; } /** * Get the default settings for this channel. * * @return array Array of default settings. */ protected function get_default_settings() { return array_merge( parent::get_default_settings(), [ 'rotation_frequency' => 'daily', 'formatter' => 'human_readable', 'keep_files' => 30, ] ); } /** * Get the log file path based on current settings. * * @return string|false Log file path or false on error. */ private function get_log_file_path() { $log_dir = $this->get_log_directory_path(); $rotation = $this->get_setting( 'rotation_frequency', 'daily' ); $filename = $this->get_log_filename( $rotation ); if ( ! $filename ) { return false; } return $log_dir . $filename; } /** * Get the log directory path. * * Uses a hard-to-guess directory within the uploads folder for security * and VIP compatibility. Returns path with trailing slash. * * @return string Log directory path with trailing slash. */ public function get_log_directory_path() { // Use a random token for directory name (stored in settings for stability). $folder_token = $this->get_folder_token(); // Use uploads directory for VIP compatibility. $upload_dir = wp_get_upload_dir(); $default_directory = trailingslashit( $upload_dir['basedir'] ) . 'simple-history-logs-' . $folder_token; /** * Filter the log directory path. * * Allows customization of where log files are stored. * For security, consider placing logs outside the public web directory. * * Example: Move logs outside the public web directory: * * add_filter( 'simple_history/file_channel/log_directory', function( $directory ) { * return '/var/log/wordpress/simple-history'; * } ); * * @since 5.6.0 * * @param string $directory The log directory path. */ $log_directory = apply_filters( 'simple_history/file_channel/log_directory', $default_directory ); // Ensure the directory ends with a slash. $log_directory = trailingslashit( $log_directory ); return $log_directory; } /** * Get or create the folder token for log directory naming. * * Uses a stored random token instead of deriving from site config, * ensuring the folder path remains stable even if auth keys change. * * Note: Stored in a separate option (not channel settings) to avoid * conflicts with the WordPress Settings API sanitization flow. * * @return string 16-character alphanumeric token. */ private function get_folder_token() { $option_name = 'simple_history_file_channel_folder_token'; $token = get_option( $option_name ); if ( ! $token ) { $token = wp_generate_password( 16, false, false ); update_option( $option_name, $token, false ); } /** * Filter the folder token used for log directory naming. * * Allows customization of the folder token for multisite or custom deployments. * * @since 5.6.0 * * @param string $token The 16-character folder token. */ $token = apply_filters( 'simple_history/file_channel/folder_token', $token ); // Sanitize for safe folder naming in case wp_generate_password() is // overridden or filter returns unexpected characters. $token = sanitize_file_name( $token ); return $token; } /** * Get the log filename based on rotation frequency. * * Example formats: * - Daily: events-2023-10-01.log * - Weekly: events-2023-W40.log * - Monthly: events-2023-10.log * * @param string $rotation Rotation frequency. * @return string|false Log filename or false on error. */ private function get_log_filename( $rotation ) { $base_name = 'events'; $extension = '.log'; switch ( $rotation ) { case 'daily': return $base_name . '-' . current_time( 'Y-m-d' ) . $extension; case 'weekly': return $base_name . '-' . current_time( 'Y' ) . '-W' . current_time( 'W' ) . $extension; case 'monthly': return $base_name . '-' . current_time( 'Y-m' ) . $extension; default: return false; } } /** * Ensure a directory exists and is writable. * * Creates security files (.htaccess and index.php) when creating a new directory. * * @param string $directory Directory path. * @return bool True if directory exists and is writable. */ private function ensure_directory_exists( $directory ) { if ( is_dir( $directory ) ) { // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.file_ops_is_writable return is_writable( $directory ); } // Try to create the directory. if ( ! wp_mkdir_p( $directory ) ) { return false; } // Set appropriate permissions. // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.chmod_chmod chmod( $directory, 0755 ); // Create security files. $this->create_htaccess_file( $directory ); $this->create_index_file( $directory ); return true; } /** * Create .htaccess file to protect log directory. * * @param string $directory Directory path. */ private function create_htaccess_file( $directory ) { $htaccess_path = trailingslashit( $directory ) . '.htaccess'; // Only create if it doesn't exist. if ( file_exists( $htaccess_path ) ) { return; } $htaccess_content = "# Simple History log directory protection\n\n"; $htaccess_content .= "# Apache 2.4+\n"; $htaccess_content .= "<IfModule mod_authz_core.c>\n"; $htaccess_content .= " Require all denied\n"; $htaccess_content .= "</IfModule>\n\n"; $htaccess_content .= "# Apache 2.2\n"; $htaccess_content .= "<IfModule !mod_authz_core.c>\n"; $htaccess_content .= " Order deny,allow\n"; $htaccess_content .= " Deny from all\n"; $htaccess_content .= "</IfModule>\n"; // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.file_ops_file_put_contents -- Direct file operations required for log directory protection. file_put_contents( $htaccess_path, $htaccess_content ); } /** * Create index.php file to prevent directory listing. * * @param string $directory Directory path. */ private function create_index_file( $directory ) { $index_path = trailingslashit( $directory ) . 'index.php'; // Only create if it doesn't exist. if ( file_exists( $index_path ) ) { return; } // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.file_ops_file_put_contents -- Direct file operations required for log directory protection. file_put_contents( $index_path, "<?php\n// Silence is golden.\n" ); } /** * Schedule cleanup if not already scheduled. */ private function schedule_cleanup_if_needed() { $hook = 'simple_history_cleanup_log_files'; $args = [ $this->get_slug() ]; if ( wp_next_scheduled( $hook, $args ) ) { return; } wp_schedule_single_event( time() + self::CLEANUP_DELAY_SECONDS, $hook, $args ); } /** * Clean up old log files based on current rotation frequency and keep settings. * * Only removes files that match the current rotation pattern. */ private function cleanup_old_files() { $keep_files = $this->get_setting( 'keep_files', 30 ); $rotation = $this->get_setting( 'rotation_frequency', 'daily' ); $log_dir = $this->get_log_directory_path(); if ( ! is_dir( $log_dir ) ) { return; } // Get files that match the current rotation pattern only. $pattern = $this->get_cleanup_pattern( $rotation ); $log_files = glob( $log_dir . $pattern ); // glob() returns false on error, not an empty array. if ( ! is_array( $log_files ) || count( $log_files ) <= $keep_files ) { return; } // Sort by filename (which contains date) for consistent ordering. sort( $log_files ); // Delete oldest files, keeping the most recent ones. $files_to_delete = array_slice( $log_files, 0, count( $log_files ) - $keep_files ); foreach ( $files_to_delete as $file ) { // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.file_ops_unlink -- Direct file operations required for log file rotation cleanup. unlink( $file ); } } /** * Get the glob pattern for cleanup based on rotation frequency. * * @param string $rotation The rotation frequency. * @return string The glob pattern to match log files. */ private function get_cleanup_pattern( $rotation ) { switch ( $rotation ) { case 'daily': // Matches files like events-2025-01-23.log. return 'events-[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9].log*'; case 'weekly': // Matches files like events-2025-W04.log. return 'events-[0-9][0-9][0-9][0-9]-W[0-9][0-9].log*'; case 'monthly': // Matches files like events-2025-01.log. return 'events-[0-9][0-9][0-9][0-9]-[0-9][0-9].log*'; default: // Fallback for any unexpected rotation values. return 'events*.log*'; } } /** * Handle async cleanup triggered by WordPress cron. * * @param string $channel_slug The channel slug to clean up for. */ public function handle_async_cleanup( $channel_slug ) { // Only process if this is our channel. if ( $channel_slug !== $this->get_slug() ) { return; } $this->cleanup_old_files(); } /** * Get statistics about log files in the log directory. * * @return array{count: int, oldest: int|null, newest: int|null, total_size: int} File statistics. */ private function get_log_files_stats(): array { $stats = [ 'count' => 0, 'oldest' => null, 'newest' => null, 'total_size' => 0, ]; $log_dir = $this->get_log_directory_path(); if ( ! is_dir( $log_dir ) ) { return $stats; } // Get all log files (any rotation pattern). $log_files = glob( $log_dir . 'events-*.log*' ); if ( empty( $log_files ) ) { return $stats; } $stats['count'] = count( $log_files ); // Get modification times for all files. $file_times = []; foreach ( $log_files as $file ) { $mtime = filemtime( $file ); $file_times[ $file ] = $mtime; $stats['total_size'] += filesize( $file ); } // Find oldest and newest by modification time. $stats['oldest'] = min( $file_times ); $stats['newest'] = max( $file_times ); return $stats; } }