Log::TYPE_REPORT, 'status' => Log::STATUS_WARNING, /* translators: %d: number of days. */ 'title' => sprintf( _n( 'Scheduled client report held: a site check is failing. It will be sent when the checks pass, or in %d day at the latest.', 'Scheduled client report held: a site check is failing. It will be sent when the checks pass, or in %d days at the latest.', $max_days, 'maintained-log' ), $max_days ), 'source' => Log::SOURCE_SYSTEM, 'user_id' => 0, ) ); Alerts::send_report_held( $check['results'], $max_days ); return false; } if ( time() - $held_since < $max_days * DAY_IN_SECONDS ) { return false; } } $report = self::build( $period['from'], $period['to'], new \DateTimeImmutable( 'now', wp_timezone() ) ); if ( ! Settings::get( 'report_send_if_empty' ) && $report['is_empty'] ) { update_option( self::LAST_PERIOD_OPTION, $period['to']->format( 'Y-m-d H:i:s' ), false ); delete_option( self::HELD_SINCE_OPTION ); return false; } $sent = self::send( $report ); // A blocked environment or missing recipients should not retry every 12 hours forever. if ( true === $sent || ( is_wp_error( $sent ) && in_array( $sent->get_error_code(), array( 'blocked', 'no_recipients' ), true ) ) ) { update_option( self::LAST_PERIOD_OPTION, $period['to']->format( 'Y-m-d H:i:s' ), false ); delete_option( self::HELD_SINCE_OPTION ); } return $sent; } /** * When the next automatic report will be sent, or null if they are manual. * * @return \DateTimeImmutable|null Now if one is already due. */ public static function next_send_date() { $frequency = Settings::get( 'report_frequency' ); $tz = wp_timezone(); $now = new \DateTimeImmutable( 'now', $tz ); $months = Settings::report_months( $frequency ); if ( 'weekly' !== $frequency && ! $months ) { return null; } if ( self::due_period() ) { return $now; } if ( $months ) { return self::period_start( $now, $months )->modify( "+{$months} months" ); } $start_of_week = (int) get_option( 'start_of_week', 1 ); $days_ahead = ( $start_of_week - (int) $now->format( 'w' ) + 7 ) % 7; return $now->setTime( 0, 0 )->modify( '+' . ( $days_ahead ? $days_ahead : 7 ) . ' days' ); } /** * Start of the calendar period of several months that contains a date: * periods are aligned to January (e.g. quarters start in January, April, * July and October). * * @param \DateTimeImmutable $date Date in the site timezone. * @param int $months Months per period. * @return \DateTimeImmutable */ private static function period_start( \DateTimeImmutable $date, $months ) { $month = (int) $date->format( 'n' ) - 1; return $date->setDate( (int) $date->format( 'Y' ), $month - $month % $months + 1, 1 )->setTime( 0, 0 ); } /** * The last complete period not yet reported, or null. * * @param \DateTimeImmutable|null $now Override for "now" (site timezone). * @return array{from:\DateTimeImmutable,to:\DateTimeImmutable}|null */ public static function due_period( $now = null ) { $frequency = Settings::get( 'report_frequency' ); $months = Settings::report_months( $frequency ); if ( 'weekly' !== $frequency && ! $months ) { return null; } $tz = wp_timezone(); $now = $now ? $now : new \DateTimeImmutable( 'now', $tz ); if ( $months ) { $to = self::period_start( $now, $months ); $from = $to->modify( "-{$months} months" ); } else { $start_of_week = (int) get_option( 'start_of_week', 1 ); $days_back = ( (int) $now->format( 'w' ) - $start_of_week + 7 ) % 7; $to = $now->setTime( 0, 0 )->modify( "-{$days_back} days" ); $from = $to->modify( '-7 days' ); } $last = get_option( self::LAST_PERIOD_OPTION ); if ( $last ) { $last_end = new \DateTimeImmutable( $last, $tz ); if ( $last_end >= $to ) { return null; } // A manual report sent mid-period already covered the start of it. if ( $last_end > $from ) { $from = $last_end; } } else { // First run: do not send a report for a period before the plugin existed. $first = Log::query( array( 'per_page' => 1, 'order' => 'ASC', ) ); if ( ! $first || get_date_from_gmt( $first[0]->created_at ) >= $to->format( 'Y-m-d H:i:s' ) ) { return null; } } return array( 'from' => $from, 'to' => $to, ); } /** * Default period for a manual send: since the last report, or the last 30 days. * * @return array{from:\DateTimeImmutable,to:\DateTimeImmutable} */ public static function manual_period() { $tz = wp_timezone(); $to = new \DateTimeImmutable( 'now', $tz ); $last = get_option( self::LAST_PERIOD_OPTION ); $from = $last ? new \DateTimeImmutable( $last, $tz ) : $to->modify( '-30 days' )->setTime( 0, 0 ); return array( 'from' => $from, 'to' => $to, ); } /** * Collects everything that goes in a report. * * @param \DateTimeImmutable $from Start, inclusive (site timezone). * @param \DateTimeImmutable $to End, exclusive (site timezone). * @param \DateTimeImmutable|null $checks_until Use the latest site check up to this moment, even after * the period (a scheduled report verifies the site right * before sending). Defaults to the period end. * @return array */ public static function build( \DateTimeImmutable $from, \DateTimeImmutable $to, $checks_until = null ) { return self::in_site_locale( static function () use ( $from, $to, $checks_until ) { return self::collect( $from, $to, $checks_until ); } ); } /** * Runs a callback in the site language, so the client report never mixes the * language of the user who sends or previews it with the site's. * * @param callable $callback Callback. * @return mixed What the callback returns. */ private static function in_site_locale( callable $callback ) { $switched = switch_to_locale( get_locale() ); try { return $callback(); } finally { if ( $switched ) { restore_previous_locale(); } } } /** * Collects the report data; see build(). * * @param \DateTimeImmutable $from Start. * @param \DateTimeImmutable $to End. * @param \DateTimeImmutable|null $checks_until Latest site check to use. * @return array */ private static function collect( \DateTimeImmutable $from, \DateTimeImmutable $to, $checks_until = null ) { $range = array( 'from' => get_gmt_from_date( $from->format( 'Y-m-d H:i:s' ) ), 'to' => get_gmt_from_date( $to->format( 'Y-m-d H:i:s' ) ), 'per_page' => 0, 'order' => 'ASC', ); $updates = array_values( array_filter( Log::query( array_merge( $range, array( 'type' => Log::TYPE_UPDATE ) ) ), static function ( $entry ) { return isset( $entry->details['kind'] ) && in_array( $entry->details['kind'], self::CLIENT_UPDATE_KINDS, true ); } ) ); $checks = self::site_checks( Log::query( array_merge( $range, array( 'type' => Log::TYPE_CHECK ) ) ) ); $tasks = self::latest_versions( Log::query( array_merge( $range, array( 'type' => Log::TYPE_MANUAL ) ) ) ); // One-click backup confirmations have their own report section. $tasks = array_values( array_filter( $tasks, static function ( $entry ) { return empty( $entry->details['backup'] ); } ) ); // Clients only see the outcome of the latest check; failures along the way go to the administrator. $last_check = $checks ? end( $checks ) : null; if ( $checks_until ) { $later = self::site_checks( Log::query( array( 'type' => Log::TYPE_CHECK, // Inclusive: the pre-report check is usually logged in the same second. 'to' => get_gmt_from_date( $checks_until->modify( '+1 second' )->format( 'Y-m-d H:i:s' ) ), 'per_page' => 10, ) ) ); $last_check = $later ? $later[0] : $last_check; } $minutes = 0; foreach ( $tasks as $task ) { $minutes += $task->minutes; } $snapshot = Inventory::get_stored(); $settings = Settings::all(); $report = array( 'from' => $from, 'to' => $to, 'site_name' => get_bloginfo( 'name' ), 'site_url' => home_url( '/' ), 'settings' => $settings, 'updates' => $updates, 'checks' => $checks, 'last_check' => $last_check, 'last_check_ok' => $last_check && Log::STATUS_ERROR !== $last_check->status, 'tasks' => $tasks, 'minutes' => $minutes, 'versions' => $snapshot ? $snapshot['core'] : array(), 'performance' => Settings::get( 'perf_enabled' ) ? Performance::summary( $range['from'], $range['to'] ) : array(), 'security' => Settings::get( 'vuln_enabled' ) ? self::security_updates( $updates ) : array(), 'tech' => Tech_Status::get()['items'], 'tech_notes' => Tech_Status::notes(), 'public_url' => Public_Report::url( $from, $to ), 'backups' => array( 'policy' => Backups::policy_sentence(), 'last_check' => Backups::last_confirmation( Backups::KIND_CHECKED, $range['to'] ), 'last_restore' => Backups::last_confirmation( Backups::KIND_RESTORE_TEST, $range['to'] ), ), 'is_empty' => ! $updates && ! $checks && ! $tasks, ); /** * Filters the report data before it is rendered. * * @param array $report Report data. */ return apply_filters( 'maintained_log_report_data', $report ); } /** * Placeholder values for subject, intro and outro. * * @param array $report Report from build(). * @return array */ public static function placeholders( array $report ) { $date_format = get_option( 'date_format' ); // The period end is exclusive; show the last day included. $last_day = $report['to']->format( 'H:i:s' ) === '00:00:00' ? $report['to']->modify( '-1 day' ) : $report['to']; return array( '{site_name}' => $report['site_name'], '{site_url}' => $report['site_url'], '{agency_name}' => $report['settings']['agency_name'], '{client_name}' => isset( $report['settings']['client_name'] ) ? $report['settings']['client_name'] : '', '{period_start}' => wp_date( $date_format, $report['from']->getTimestamp() ), '{period_end}' => wp_date( $date_format, $last_day->getTimestamp() ), '{updates_count}' => (string) count( $report['updates'] ), '{checks_count}' => (string) count( $report['checks'] ), '{tasks_count}' => (string) count( $report['tasks'] ), '{hours}' => self::format_minutes( $report['minutes'] ), ); } /** * Replaces placeholders in a template string. * * @param string $text Template. * @param array $report Report from build(). * @return string */ public static function fill( $text, array $report ) { $text = (string) $text; // Without a client name, "Hello {client_name}," must read "Hello,", not "Hello ,". if ( empty( $report['settings']['client_name'] ) ) { $text = str_replace( array( ' {client_name}', '{client_name} ', '{client_name}' ), '', $text ); } return strtr( $text, self::placeholders( $report ) ); } /** * Introduction of the report, greeting the client by name. * * Texts saved before {client_name} existed start with a bare greeting * ("Hola:", "Hello,"): the name is added after it, without changing the * saved text. * * @param array $report Report from build(). * @return string */ public static function intro( array $report ) { $text = (string) $report['settings']['report_intro']; $name = isset( $report['settings']['client_name'] ) ? trim( $report['settings']['client_name'] ) : ''; if ( '' !== $name && false === strpos( $text, '{client_name}' ) ) { /** * Filters the greeting words after which the client name is inserted. * * @param string[] $greetings Greetings, matched case-insensitively at the start of the first line. */ $greetings = apply_filters( 'maintained_log_greetings', array( 'Hola', 'Buenos días', 'Buenas tardes', 'Buenas', 'Estimado', 'Estimada', 'Hello', 'Hi', 'Dear', 'Bonjour', 'Olá', 'Ciao', 'Hallo' ) ); // Only spaces and tabs around the punctuation: line breaks must be kept. $pattern = '/^([ \t]*(?:' . implode( '|', array_map( 'preg_quote', $greetings ) ) . '))[ \t]*([,:!.]?)[ \t]*(\r?\n|$)/iu'; $text = preg_replace( $pattern, '$1 {client_name}$2$3', $text, 1 ); } return self::fill( $text, $report ); } /** * Renders the HTML email. * * @param array $report Report from build(). * @param string $context email, web (public page) or preview (admin). * @return string */ public static function render( array $report, $context = 'email' ) { return self::in_site_locale( static function () use ( $report, $context ) { return self::render_template( $report, $context ); } ); } /** * Renders the template; see render(). * * @param array $report Report from build(). * @param string $context Context. * @return string */ private static function render_template( array $report, $context ) { $template = locate_template( 'maintained-log/email-report.php' ); if ( ! $template ) { $template = MAINTAINED_LOG_DIR . 'templates/email-report.php'; } ob_start(); include $template; return (string) ob_get_clean(); } /** * Sends the report to the client recipients and logs it. * * @param array $report Report from build(). * @param string|null $recipients Override recipients (e.g. a test send). * @return true|\WP_Error */ public static function send( array $report, $recipients = null ) { return self::in_site_locale( static function () use ( $report, $recipients ) { return self::deliver( $report, $recipients ); } ); } /** * Sends the report; see send(). * * @param array $report Report from build(). * @param string|null $recipients Override recipients. * @return true|\WP_Error */ private static function deliver( array $report, $recipients = null ) { $is_test = null !== $recipients; if ( ! $is_test && ! Environment::can_email_clients() ) { $reason = Environment::blocking_reason(); Log::add( array( 'type' => Log::TYPE_REPORT, 'status' => Log::STATUS_WARNING, /* translators: %s: reason. */ 'title' => sprintf( __( 'Client report not sent: %s', 'maintained-log' ), $reason ), 'source' => Log::SOURCE_SYSTEM, 'user_id' => 0, ) ); return new \WP_Error( 'blocked', $reason ); } $to = Settings::parse_emails( $is_test ? $recipients : $report['settings']['report_recipients'] ); if ( ! $to ) { return new \WP_Error( 'no_recipients', __( 'No client recipients configured.', 'maintained-log' ) ); } // Addressed by name ("Ana García ") when the report goes to a single person. $mail_to = $to; $name = isset( $report['settings']['client_name'] ) ? str_replace( array( '<', '>', '"', ',' ), '', $report['settings']['client_name'] ) : ''; if ( ! $is_test && 1 === count( $to ) && '' !== $name ) { $mail_to = array( sprintf( '%s <%s>', $name, $to[0] ) ); } $subject = self::fill( $report['settings']['report_subject'], $report ); if ( $is_test ) { /* translators: %s: email subject. */ $subject = sprintf( __( '[Test] %s', 'maintained-log' ), $subject ); } $headers = array( 'Content-Type: text/html; charset=UTF-8' ); $from = $report['settings']['report_from_email']; if ( $from && is_email( $from ) ) { $name = $report['settings']['report_from_name'] ? $report['settings']['report_from_name'] : $report['settings']['agency_name']; $headers[] = sprintf( 'From: %s <%s>', str_replace( array( '<', '>', '"' ), '', $name ), $from ); } $reply_to = Settings::parse_emails( $report['settings']['admin_recipients'] ); if ( $reply_to ) { $headers[] = 'Reply-To: ' . $reply_to[0]; } $sent = wp_mail( $mail_to, $subject, self::render( $report ), $headers ); if ( ! $sent ) { return new \WP_Error( 'mail_failed', __( 'WordPress could not send the email. Check the mail configuration (an SMTP plugin is recommended).', 'maintained-log' ) ); } if ( ! $is_test ) { $placeholders = self::placeholders( $report ); Log::add( array( 'type' => Log::TYPE_REPORT, 'status' => Log::STATUS_SUCCESS, /* translators: 1: period start, 2: period end, 3: recipients. */ 'title' => sprintf( __( 'Client report for %1$s – %2$s sent to %3$s', 'maintained-log' ), $placeholders['{period_start}'], $placeholders['{period_end}'], implode( ', ', $to ) ), 'details' => array( 'from' => $report['from']->format( 'c' ), 'to' => $report['to']->format( 'c' ), 'recipients' => $to, 'updates' => count( $report['updates'] ), 'checks' => count( $report['checks'] ), 'tasks' => count( $report['tasks'] ), 'minutes' => $report['minutes'], ), 'source' => Log::SOURCE_SYSTEM, ) ); } return true; } /** * Formats minutes as "2 h 15 min". * * @param int $minutes Minutes. * @return string */ public static function format_minutes( $minutes ) { $minutes = (int) $minutes; $hours = intdiv( $minutes, 60 ); $rest = $minutes % 60; if ( $hours && $rest ) { /* translators: 1: hours, 2: minutes. */ return sprintf( __( '%1$d h %2$d min', 'maintained-log' ), $hours, $rest ); } if ( $hours ) { /* translators: %d: hours. */ return sprintf( __( '%d h', 'maintained-log' ), $hours ); } /* translators: %d: minutes. */ return sprintf( __( '%d min', 'maintained-log' ), $rest ); } /** * Updates that fixed known vulnerabilities, with plain-language explanations. * * @param object[] $updates Update entries of the period. * @return array[] */ private static function security_updates( array $updates ) { $rows = array(); $seen = array(); foreach ( $updates as $entry ) { // Deactivated plugins and themes are not mentioned to the client. if ( ! Vulnerabilities::is_enabled_component( $entry->details['component'], isset( $entry->details['key'] ) ? $entry->details['key'] : '' ) ) { continue; } // The same jump can appear more than once (e.g. updated, rolled back and updated again): count it once. $fixed = array_values( array_filter( Vulnerabilities::fixed_by_entry( $entry ), static function ( $vuln ) use ( $seen ) { return ! isset( $seen[ $vuln['id'] ] ); } ) ); if ( ! $fixed ) { continue; } foreach ( $fixed as $vuln ) { $seen[ $vuln['id'] ] = true; } $explanations = array(); foreach ( $fixed as $vuln ) { $text = Vulnerabilities::explain( $vuln ); if ( ! isset( $explanations[ $text ] ) ) { $explanations[ $text ] = Vulnerabilities::access_context( $vuln ); } } $rows[] = array( 'name' => $entry->details['name'], 'from' => $entry->details['from'], 'to' => $entry->details['to'], 'date' => $entry->created_at, 'count' => count( $fixed ), 'severity' => Vulnerabilities::severity( $fixed[0] ), 'explanations' => array_slice( $explanations, 0, 3, true ), ); } return $rows; } /** * Page checks only, without technical status entries. * * @param object[] $entries Check entries. * @return object[] */ private static function site_checks( array $entries ) { return array_values( array_filter( $entries, static function ( $entry ) { return 'technical' !== $entry->category; } ) ); } /** * Drops entries that were later corrected, keeping the correction. * * @param object[] $entries Entries. * @return object[] */ private static function latest_versions( array $entries ) { $amended = array(); foreach ( $entries as $entry ) { if ( $entry->amends_id ) { $amended[ $entry->amends_id ] = true; } } return array_values( array_filter( $entries, static function ( $entry ) use ( $amended ) { return ! isset( $amended[ $entry->id ] ); } ) ); } }