setTime( $hour, 0, 0 ); if ( $target <= $now ) { $target->modify( '+1 day' ); } return $target->getTimestamp(); } if ( 'monthly' === $settings['frequency'] ) { $day_of_month = (int) $settings['day_of_month']; $target = clone $now; $target->setDate( (int) $target->format( 'Y' ), (int) $target->format( 'n' ), $day_of_month ); $target->setTime( $hour, 0, 0 ); if ( $target <= $now ) { $target->modify( 'first day of next month' ); $target->setDate( (int) $target->format( 'Y' ), (int) $target->format( 'n' ), $day_of_month ); $target->setTime( $hour, 0, 0 ); } return $target->getTimestamp(); } // 'weekly' (Standard/Fallback). $iso_day = (int) $settings['day']; $target = clone $now; $target->setTime( $hour, 0, 0 ); $current_iso_day = (int) $now->format( 'N' ); $diff = $iso_day - $current_iso_day; if ( $diff < 0 || ( 0 === $diff && $target <= $now ) ) { $diff += 7; } if ( $diff > 0 ) { $target->modify( "+{$diff} days" ); } return $target->getTimestamp(); } /** * Bestellt den naechsten Termin VOR dem eigentlichen Versand nach -- * ein Fataler in send_digest() (Speicher-/Zeitlimit, ein Fehler aus einer * fremden 'wp_mail'/'http_api'-Hook-Funktion) darf die Kette nicht * abreissen lassen. WordPress hat das aktuelle Einzel-Event bereits * entfernt, bevor dieser Callback laeuft -- ohne Neubestellung zuerst * gaebe es sonst dauerhaft keinen naechsten Termin mehr. */ public static function run_scheduled_digest() { self::reschedule( Wp360_Security_Digest_Settings::get() ); self::send_digest(); } /** * Sammelt und verschickt den Digest. $force=true (z. B. beim manuellen * "Jetzt senden") verschickt auch dann eine E-Mail, wenn nichts ansteht, * damit ein Testversand immer sichtbar etwas bewirkt. */ public static function send_digest( $force = false ) { $settings = Wp360_Security_Digest_Settings::get(); return self::with_digest_locale( $settings, function () use ( $force, $settings ) { $data = self::gather_updates(); $lines = self::compose_lines( $data ); if ( empty( $lines ) && ! $force ) { self::record_history( __( 'No updates due — nothing sent.', '360-orbit-security-digest' ) ); return array( 'sent' => false, 'summary' => __( 'No updates due — nothing sent.', '360-orbit-security-digest' ), ); } $recipients = Wp360_Security_Digest_Settings::recipients( $settings ); $subject = sprintf( /* translators: %s: site name */ __( '[%s] Security digest', '360-orbit-security-digest' ), get_bloginfo( 'name' ) ); $body = self::render_body( $lines ); list( $sent, $mail_error ) = self::send_mail_with_diagnostics( $recipients, $subject, $body ); $summary = $lines ? implode( "\n", $lines ) : __( 'No updates due.', '360-orbit-security-digest' ); if ( $mail_error ) { $summary = sprintf( /* translators: %s: error message reported by wp_mail() */ __( '⚠ Delivery failed: %s', '360-orbit-security-digest' ), $mail_error ) . "\n\n" . $summary; } self::record_history( $summary ); return array( 'sent' => $sent, 'summary' => $summary, 'mail_error' => $mail_error, ); } ); } /** * Verschickt ueber wp_mail() und faengt dabei per 'wp_mail_failed' einen * evtl. Fehler ab (#14) -- WordPress liefert sonst keine * Zustellbestaetigung, nur den Versandversuch. Der Hook wird nur waehrend * dieses einen Aufrufs gehangen, um keine fremden Mails dieses Requests * mitzuerfassen. */ private static function send_mail_with_diagnostics( $recipients, $subject, $body ) { $mail_error = null; $capture = function ( $wp_error ) use ( &$mail_error ) { $mail_error = $wp_error->get_error_message(); }; add_action( 'wp_mail_failed', $capture ); $sent = wp_mail( $recipients, $subject, $body ); remove_action( 'wp_mail_failed', $capture ); return array( $sent, $mail_error ); } /** * Baut den naechsten Digest zusammen, ohne ihn zu verschicken oder in die * Historie einzutragen (#5) -- inklusive der Plugin-Liste fuer die * Stummschalten-Buttons im Backend. */ public static function preview() { $settings = Wp360_Security_Digest_Settings::get(); return self::with_digest_locale( $settings, function () { $data = self::gather_updates(); $lines = self::compose_lines( $data ); return array( 'body' => self::render_body( $lines ), 'plugins' => self::muteable_plugins( $data['plugins'] ), ); } ); } private static function muteable_plugins( $plugin_updates ) { $out = array(); foreach ( $plugin_updates as $file => $plugin ) { $out[] = array( 'file' => $file, 'name' => $plugin->Name ?? $file, 'current' => $plugin->Version ?? '?', 'new' => $plugin->update->new_version ?? '?', ); } return $out; } /** * Fuehrt $callback in der per 'digest_language' erzwungenen Sprache aus * (#13) -- unabhaengig von der WordPress-Backend-Sprache des gerade * eingeloggten Admins bzw. des Cron-Requests. Leerer Wert = keine * Umschaltung, folgt der aktuellen Sprache wie bisher. */ private static function with_digest_locale( array $settings, callable $callback ) { $locale = $settings['digest_language'] ?? ''; if ( '' === $locale ) { return $callback(); } unload_textdomain( '360-orbit-security-digest' ); // Zwei moegliche Fundorte: Im Paket von 360wporbit.com liegen die // Uebersetzungen im Plugin selbst, aus dem WordPress.org-Verzeichnis // kommen sie als Sprachpaket unter WP_LANG_DIR/plugins. Wer nur den // ersten Ort kennt, dessen Spracheinstellung bleibt in der // Verzeichnis-Fassung wirkungslos. $candidates = array( WP360_SD_DIR . 'languages/360-orbit-security-digest-' . $locale . '.mo', WP_LANG_DIR . '/plugins/360-orbit-security-digest-' . $locale . '.mo', ); foreach ( $candidates as $mofile ) { if ( file_exists( $mofile ) ) { load_textdomain( '360-orbit-security-digest', $mofile ); break; } } // Ohne passende .mo-Datei (z. B. 'en_US') bleiben die englischen // Quelltexte einfach unuebersetzt stehen -- das ist bereits Englisch. try { return $callback(); } finally { // Zurueck zur normalen Sprache: Seit WordPress 4.6 laedt der Kern // die Uebersetzung beim naechsten Aufruf von selbst nach. Die // Textdomain hier noch einmal von Hand zu laden waere nicht nur // ueberfluessig, es wird im Verzeichnis auch beanstandet. unload_textdomain( '360-orbit-security-digest' ); } } public static function gather_updates() { if ( ! function_exists( 'get_plugin_updates' ) ) { require_once ABSPATH . 'wp-admin/includes/update.php'; } if ( ! function_exists( 'get_plugins' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } if ( ! function_exists( 'wp_get_themes' ) ) { require_once ABSPATH . 'wp-admin/includes/theme.php'; } // Aktualisiert die WordPress-eigenen Transienten, statt sie nur // passiv zu lesen -- sonst meldet der Digest faelschlich "alles // aktuell", nur weil der naechste automatische Check noch nicht // gelaufen ist. wp_version_check(); wp_update_plugins(); wp_update_themes(); // Stummgeschaltete Plugins (#6) aus dem Sammel-Digest herausfiltern -- // bewusst nur hier, nicht beim Sicherheits-Alarm (siehe class-mutes.php). $plugin_updates = get_plugin_updates(); foreach ( array_keys( Wp360_Security_Digest_Mutes::all() ) as $muted_file ) { unset( $plugin_updates[ $muted_file ] ); } $data = array( 'core' => self::pending_core_update(), 'plugins' => $plugin_updates, 'themes' => get_theme_updates(), ); return $data; } private static function pending_core_update() { $transient = get_site_transient( 'update_core' ); if ( empty( $transient->updates ) ) { return null; } foreach ( $transient->updates as $update ) { if ( isset( $update->response ) && 'upgrade' === $update->response && ! empty( $update->current ) ) { return $update->current; } } return null; } private static function compose_lines( array $data ) { $lines = array(); if ( $data['core'] ) { $lines[] = sprintf( /* translators: %s: new WordPress version */ __( 'WordPress core: update available to %s', '360-orbit-security-digest' ), $data['core'] ); } foreach ( $data['plugins'] as $plugin ) { $current = $plugin->Version ?? '?'; $new = $plugin->update->new_version ?? '?'; $lines[] = sprintf( '%s: %s → %s', $plugin->Name ?? '?', $current, $new ); } foreach ( $data['themes'] as $theme ) { $name = method_exists( $theme, 'get' ) ? $theme->get( 'Name' ) : ( $theme->Name ?? '?' ); $current = method_exists( $theme, 'get' ) ? $theme->get( 'Version' ) : ( $theme->Version ?? '?' ); $new = $theme->update['new_version'] ?? '?'; $lines[] = sprintf( '%s: %s → %s', $name, $current, $new ); } return $lines; } private static function render_body( array $lines ) { $body = __( 'Here is what could use an update on your website:', '360-orbit-security-digest' ) . "\n\n"; if ( $lines ) { foreach ( $lines as $line ) { $body .= '- ' . $line . "\n"; } } else { $body .= __( 'Nothing to report — everything is up to date.', '360-orbit-security-digest' ) . "\n"; } $body .= "\n" . admin_url( 'update-core.php' ) . "\n"; return $body; } private static function record_history( $summary ) { $history = get_option( self::HISTORY_OPTION, array() ); $history[] = array( 'sent_at' => current_time( 'mysql' ), 'summary' => $summary, ); $history = array_slice( $history, -self::HISTORY_MAX ); update_option( self::HISTORY_OPTION, $history, false ); } public static function history() { return array_reverse( get_option( self::HISTORY_OPTION, array() ) ); } public static function next_scheduled() { return wp_next_scheduled( self::CRON_HOOK ); } }