settings = $settings; $this->events = $events; } public function init() { add_action( self::HOOK, array( $this, 'run' ) ); if ( ! wp_next_scheduled( self::HOOK ) ) { wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', self::HOOK ); } } public function run() { global $wpdb; // A site whose tables are not set up yet: nothing to clean. This cron // load sets them up on `init` (Plugin::maybe_upgrade()) unless another // request holds the setup; then the next daily run does it. if ( ! Database::ready() ) { return; } $years = max( 1, (int) $this->settings->get( 'retention_extra_years', 3 ) ); $table = Database::table( 'requests' ); $cutoff = gmdate( 'Y-m-d H:i:s', time() - ( $years * YEAR_IN_SECONDS ) ); // Closed requests age from closure; requests left open must not become an // indefinite store of personal data, so they age from creation instead. // // token_jti carries a UNIQUE index, so each row gets a distinct derived // value rather than an empty string. $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- the plugin's own table; a bulk clean-up, nothing to cache. $wpdb->prepare( "UPDATE %i SET customer_name = '', email_hash = '', ip_hash = '', comment = NULL, token_jti = LEFT( CONCAT( 'anon-', id, '-', MD5( CONCAT( 'careback-anon-', id ) ) ), 32 ) WHERE ( customer_name <> '' OR email_hash <> '' OR ip_hash <> '' OR comment IS NOT NULL ) AND ( ( closed_at IS NOT NULL AND closed_at < %s ) OR ( closed_at IS NULL AND created_at < %s ) )", $table, $cutoff, $cutoff ) ); // The browser a request was sent from goes on the same horizon, once the // table setup that adds its column has run. if ( Database::schema_at_least( Database::USER_AGENT_SCHEMA ) ) { $wpdb->query( $wpdb->prepare( "UPDATE %i SET user_agent = '' WHERE user_agent <> '' AND ( ( closed_at IS NOT NULL AND closed_at < %s ) OR ( closed_at IS NULL AND created_at < %s ) )", $table, $cutoff, $cutoff ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- the plugin's own table; a bulk clean-up, nothing to cache. } // Claims left behind by a request that died between reserving a ticket // and using it. Not personal data, but rows in the options table that // nothing else would ever remove. Ticket::sweep_claims(); // The security-event log is not personal-request data, but it still // grows unbounded, so it is trimmed on its own shorter horizon. Only the // rows the free part writes itself: a row an add-on writes may be how it // remembers a notice it already sent, so the add-on trims its own // (Events_Repository::OWN_TYPES, 04-ARCHITECTURE ยง4). if ( $this->events ) { $days = max( 1, (int) $this->settings->get( 'retention_events_days', 90 ) ); $this->events->prune( gmdate( 'Y-m-d H:i:s', time() - ( $days * DAY_IN_SECONDS ) ), Events_Repository::OWN_TYPES ); } } public static function unschedule() { $timestamp = wp_next_scheduled( self::HOOK ); if ( $timestamp ) { wp_unschedule_event( $timestamp, self::HOOK ); } } }