[name, reader key]. */ private const PLUGINS = array( 'updraftplus/updraftplus.php' => array( 'UpdraftPlus', 'updraft' ), 'backwpup/backwpup.php' => array( 'BackWPup', 'backwpup' ), 'backwpup-pro/backwpup.php' => array( 'BackWPup Pro', 'backwpup' ), 'wpvivid-backuprestore/wpvivid-backuprestore.php' => array( 'WPvivid', 'wpvivid' ), 'all-in-one-wp-migration/all-in-one-wp-migration.php' => array( 'All-in-One WP Migration', 'ai1wm' ), 'duplicator/duplicator.php' => array( 'Duplicator', 'duplicator' ), 'duplicator-pro/duplicator-pro.php' => array( 'Duplicator Pro', 'duplicator' ), 'vaultpress/vaultpress.php' => array( 'VaultPress', 'external' ), 'jetpack-backup/jetpack-backup.php' => array( 'Jetpack VaultPress Backup', 'external' ), 'blogvault-real-time-backup/blogvault.php' => array( 'BlogVault', 'external' ), ); /** * {@inheritDoc} */ public function group(): string { return 'backups'; } /** * {@inheritDoc} */ public function run(): array { $found = array(); foreach ( self::PLUGINS as $file => list( $name, $reader ) ) { if ( ! self::is_active( $file ) ) { continue; } $found[] = array( 'name' => $name, 'kind' => 'external' === $reader ? 'external' : 'local', 'last' => 'external' === $reader ? null : $this->read( $reader ), ); } return self::evaluate( $found, time() ); } /** * Pure evaluation. "last": Unix time of the newest completed backup; 0 = none found; null = unknown. * * @param array $found Active backup plugins. * @param int $now Current time. * @return Result[] */ public static function evaluate( array $found, int $now ): array { $url = admin_url( 'plugin-install.php?s=backup&tab=search&type=term' ); if ( array() === $found ) { return array( new Result( 'backups.status', Result::RECOMMENDED, __( 'No backup plugin detected', 'keepwatch' ), __( 'Keepwatch could not find an active backup plugin. If your host takes backups, you can ignore this; otherwise install a backup plugin such as UpdraftPlus and schedule regular backups stored off the server.', 'keepwatch' ), $url, __( 'Find a backup plugin', 'keepwatch' ) ), ); } // The newest known backup across all plugins counts. $best = null; foreach ( $found as $plugin ) { if ( is_int( $plugin['last'] ) && $plugin['last'] > 0 && ( null === $best || $plugin['last'] > $best['last'] ) ) { $best = $plugin; } } if ( null !== $best ) { $days = (int) floor( max( 0, $now - (int) $best['last'] ) / DAY_IN_SECONDS ); $age = 0 === $days ? __( 'today', 'keepwatch' ) /* translators: %d: number of days */ : sprintf( _n( '%d day ago', '%d days ago', $days, 'keepwatch' ), $days ); /* translators: 1: "today" or "N days ago", 2: backup plugin name */ $label = sprintf( __( 'Last backup %1$s (%2$s)', 'keepwatch' ), $age, $best['name'] ); $status = $days > self::CRITICAL_DAYS ? Result::CRITICAL : ( $days > self::WARN_DAYS ? Result::RECOMMENDED : Result::GOOD ); return array( new Result( 'backups.status', $status, $label, Result::GOOD === $status ? '' : __( 'Backups are getting old. Check the schedule and the latest log of your backup plugin, and make sure backups are stored off the server.', 'keepwatch' ) ), ); } $external = array_values( array_filter( $found, static function ( array $plugin ): bool { return 'external' === $plugin['kind']; } ) ); if ( array() !== $external ) { return array( new Result( 'backups.status', Result::GOOD, /* translators: %s: backup service name */ sprintf( __( 'Backups handled by %s', 'keepwatch' ), $external[0]['name'] ), __( 'This service backs up the site from outside, so Keepwatch cannot see the time of the last backup. Check it in the service dashboard.', 'keepwatch' ) ), ); } $never = array_values( array_filter( $found, static function ( array $plugin ): bool { return 0 === $plugin['last']; } ) ); if ( array() !== $never ) { return array( new Result( 'backups.status', Result::CRITICAL, /* translators: %s: backup plugin name */ sprintf( __( '%s is installed but no completed backup was found', 'keepwatch' ), $never[0]['name'] ), __( 'Run a backup now and set up a schedule. A backup plugin without backups does not protect the site.', 'keepwatch' ) ), ); } return array( new Result( 'backups.status', Result::RECOMMENDED, /* translators: %s: backup plugin name */ sprintf( __( '%s is active, last backup time unknown', 'keepwatch' ), $found[0]['name'] ), __( 'Keepwatch could not read when the last backup finished. Check the backup plugin dashboard.', 'keepwatch' ) ), ); } /** * Plugin active on this site (or network-wide). * * @param string $file Plugin basename. */ private static function is_active( string $file ): bool { $active = get_option( 'active_plugins', array() ); if ( is_array( $active ) && in_array( $file, $active, true ) ) { return true; } if ( is_multisite() ) { $network = get_site_option( 'active_sitewide_plugins', array() ); return is_array( $network ) && isset( $network[ $file ] ); } return false; } /** * Newest completed backup for a plugin: Unix time, 0 if none, null if it cannot be read. * * @param string $reader Reader key. */ private function read( string $reader ): ?int { switch ( $reader ) { case 'updraft': return self::from_updraft( get_option( 'updraft_last_backup' ), get_option( 'updraft_backup_history' ) ); case 'backwpup': return self::from_backwpup( get_site_option( 'backwpup_jobs' ), (float) get_option( 'gmt_offset', 0 ) ); case 'wpvivid': return self::from_wpvivid( get_option( 'wpvivid_backup_list' ) ); case 'ai1wm': return self::from_ai1wm(); case 'duplicator': return self::from_duplicator(); } return null; } /** * UpdraftPlus. * * @param mixed $last updraft_last_backup. * @param mixed $history updraft_backup_history (timestamp => sets). */ public static function from_updraft( $last, $history ): int { if ( is_array( $last ) && ! empty( $last['success'] ) && isset( $last['backup_time'] ) && (int) $last['backup_time'] > 0 ) { return (int) $last['backup_time']; } $newest = 0; if ( is_array( $history ) ) { foreach ( array_keys( $history ) as $time ) { $newest = max( $newest, (int) $time ); } } return $newest; } /** * BackWPup: lastrun is site-local time; only runs that stored a file count. * * @param mixed $jobs backwpup_jobs. * @param float $gmt_offset Site GMT offset in hours. */ public static function from_backwpup( $jobs, float $gmt_offset ): int { $newest = 0; if ( is_array( $jobs ) ) { foreach ( $jobs as $job ) { if ( is_array( $job ) && ! empty( $job['lastrun'] ) && ! empty( $job['lastbackupdownloadurl'] ) ) { $newest = max( $newest, (int) $job['lastrun'] - (int) round( $gmt_offset * HOUR_IN_SECONDS ) ); } } } return $newest; } /** * WPvivid. * * @param mixed $backups wpvivid_backup_list. */ public static function from_wpvivid( $backups ): int { $newest = 0; if ( is_array( $backups ) ) { foreach ( $backups as $backup ) { if ( is_array( $backup ) && isset( $backup['create_time'] ) ) { $newest = max( $newest, (int) $backup['create_time'] ); } } } return $newest; } /** * All-in-One WP Migration: newest .wpress archive. */ private static function from_ai1wm(): int { $files = glob( trailingslashit( WP_CONTENT_DIR ) . 'ai1wm-backups/*.wpress' ); $newest = 0; foreach ( is_array( $files ) ? $files : array() as $file ) { $time = filemtime( $file ); $newest = max( $newest, false === $time ? 0 : $time ); } return $newest; } /** * Duplicator: completed packages (status 100) when the table exists; otherwise unknown. */ private static function from_duplicator(): ?int { global $wpdb; foreach ( array( 'duplicator_packages', 'duplicator_pro_packages' ) as $name ) { $table = $wpdb->prefix . $name; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- read-only, runs in the hourly check. if ( $table !== $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $table ) ) ) ) { continue; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- see above. $created = $wpdb->get_var( $wpdb->prepare( 'SELECT MAX(created) FROM %i WHERE status >= %d', $table, 100 ) ); if ( null === $created || '' === $created ) { return 0; } $time = strtotime( (string) $created ); return false === $time ? null : $time; } return null; } }