Created
September 19, 2026 04:49
-
-
Save patrickfreitasdev/cbda6b4e220878437b6e7e0c3fe74eff to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?php | |
| /** | |
| * Plugin Name: Hustle GDPR Cleanup Fix (staging) | |
| * Description: Replaces hustle_general_data_protection_cleanup with a Forever-aware, batched callback. Does not edit plugin files. | |
| * Version: 1.0.0 | |
| * | |
| * Install: copy this file to wp-content/mu-plugins/hustle-gdpr-cleanup-fix.php | |
| * | |
| * What it changes: | |
| * - Skips work when retention is Forever (string/int safe). | |
| * - Never loads every tracking ID into PHP. | |
| * - Anonymizes IPs with batched UPDATE ... LIMIT, not per-row SELECT tracking_id <. | |
| * - Deletes old rows in batches and stops before the PHP time limit. | |
| * | |
| * Keep the logger mu-plugin enabled so you can compare timings in | |
| * wp-content/hustle-gdpr-cleanup-debug.log | |
| */ | |
| if ( ! defined( 'ABSPATH' ) ) { | |
| exit; | |
| } | |
| if ( ! defined( 'HUSTLE_GDPR_FIX_ENABLED' ) ) { | |
| define( 'HUSTLE_GDPR_FIX_ENABLED', true ); | |
| } | |
| if ( ! defined( 'HUSTLE_GDPR_FIX_BATCH_SIZE' ) ) { | |
| define( 'HUSTLE_GDPR_FIX_BATCH_SIZE', 500 ); | |
| } | |
| if ( ! defined( 'HUSTLE_GDPR_FIX_TIME_BUDGET' ) ) { | |
| define( 'HUSTLE_GDPR_FIX_TIME_BUDGET', 20 ); | |
| } | |
| /** | |
| * Log through the logger mu-plugin when present. | |
| * | |
| * @param string $message Message. | |
| * @param mixed $data Optional data. | |
| */ | |
| function hustle_gdpr_fix_log( $message, $data = null ) { | |
| if ( function_exists( 'hustle_gdpr_debug_log' ) ) { | |
| hustle_gdpr_debug_log( '[fix] ' . $message, $data ); | |
| return; | |
| } | |
| $line = 'Hustle GDPR fix: ' . $message; | |
| if ( null !== $data ) { | |
| $line .= ' ' . wp_json_encode( $data ); | |
| } | |
| // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log | |
| error_log( $line ); | |
| } | |
| /** | |
| * Whether a Forever flag should skip cleanup. | |
| * | |
| * @param mixed $value Stored setting. | |
| * @return bool | |
| */ | |
| function hustle_gdpr_fix_is_forever( $value ) { | |
| return '1' === (string) $value; | |
| } | |
| /** | |
| * Whether this job should no-op. | |
| * | |
| * @param mixed $forever Forever flag. | |
| * @param mixed $number Retention number. | |
| * @return bool | |
| */ | |
| function hustle_gdpr_fix_should_skip( $forever, $number ) { | |
| return hustle_gdpr_fix_is_forever( $forever ) || 0 === absint( $number ); | |
| } | |
| /** | |
| * Retain cutoff datetime using the same arithmetic as Hustle. | |
| * | |
| * @param mixed $number Number. | |
| * @param string $unit Unit. | |
| * @return string|false | |
| */ | |
| function hustle_gdpr_fix_retain_time( $number, $unit ) { | |
| $units = array( 'days', 'weeks', 'months', 'years' ); | |
| if ( ! in_array( $unit, $units, true ) ) { | |
| return false; | |
| } | |
| return date_i18n( 'Y-m-d H:i:s', strtotime( '-' . absint( $number ) . ' ' . $unit ) ); | |
| } | |
| /** | |
| * Table names. | |
| * | |
| * @return array<string,string>|false | |
| */ | |
| function hustle_gdpr_fix_tables() { | |
| if ( ! class_exists( 'Hustle_Db' ) ) { | |
| return false; | |
| } | |
| return array( | |
| 'entries' => Hustle_Db::entries_table(), | |
| 'entries_meta' => Hustle_Db::entries_meta_table(), | |
| 'tracking' => Hustle_Db::tracking_table(), | |
| ); | |
| } | |
| /** | |
| * Strip Hustle's original cleanup callback only. | |
| */ | |
| function hustle_gdpr_fix_replace_callback() { | |
| if ( ! HUSTLE_GDPR_FIX_ENABLED || ! class_exists( 'Hustle_General_Data_Protection' ) ) { | |
| return; | |
| } | |
| global $wp_filter; | |
| if ( empty( $wp_filter['hustle_general_data_protection_cleanup'] ) ) { | |
| add_action( 'hustle_general_data_protection_cleanup', 'hustle_gdpr_fix_cleanup', 10 ); | |
| hustle_gdpr_fix_log( 'Original hook missing; registered batched cleanup only' ); | |
| return; | |
| } | |
| $hook = $wp_filter['hustle_general_data_protection_cleanup']; | |
| foreach ( $hook->callbacks as $priority => $callbacks ) { | |
| foreach ( $callbacks as $callback ) { | |
| $function = $callback['function']; | |
| if ( is_array( $function ) && isset( $function[0], $function[1] ) | |
| && $function[0] instanceof Hustle_General_Data_Protection | |
| && 'personal_data_cleanup' === $function[1] | |
| ) { | |
| remove_action( 'hustle_general_data_protection_cleanup', $function, $priority ); | |
| hustle_gdpr_fix_log( 'Removed Hustle_General_Data_Protection->personal_data_cleanup', array( 'priority' => $priority ) ); | |
| } | |
| } | |
| } | |
| add_action( 'hustle_general_data_protection_cleanup', 'hustle_gdpr_fix_cleanup', 10 ); | |
| } | |
| /** | |
| * Batched cleanup replacement. | |
| */ | |
| function hustle_gdpr_fix_cleanup() { | |
| $tables = hustle_gdpr_fix_tables(); | |
| if ( ! $tables || ! class_exists( 'Hustle_Settings_Admin' ) ) { | |
| hustle_gdpr_fix_log( 'Hustle classes/tables unavailable; skipping' ); | |
| return; | |
| } | |
| $settings = Hustle_Settings_Admin::get_privacy_settings(); | |
| $started = microtime( true ); | |
| $deadline = $started + (float) HUSTLE_GDPR_FIX_TIME_BUDGET; | |
| $batch = max( 1, (int) HUSTLE_GDPR_FIX_BATCH_SIZE ); | |
| $plan = array( | |
| 'submissions' => ! hustle_gdpr_fix_should_skip( $settings['retain_submission_forever'], $settings['submissions_retention_number'] ), | |
| 'ip' => ! hustle_gdpr_fix_should_skip( $settings['retain_ip_forever'], $settings['ip_retention_number'] ), | |
| 'tracking' => ! hustle_gdpr_fix_should_skip( $settings['retain_tracking_forever'], $settings['tracking_retention_number'] ), | |
| ); | |
| hustle_gdpr_fix_log( | |
| 'Batched cleanup start', | |
| array( | |
| 'plan' => $plan, | |
| 'batch_size' => $batch, | |
| 'time_budget' => HUSTLE_GDPR_FIX_TIME_BUDGET, | |
| ) | |
| ); | |
| if ( ! $plan['submissions'] && ! $plan['ip'] && ! $plan['tracking'] ) { | |
| hustle_gdpr_fix_log( 'Nothing to do (all jobs Forever or 0). Returning.' ); | |
| return; | |
| } | |
| $stats = array( | |
| 'submissions_deleted' => 0, | |
| 'entry_ips_cleared' => 0, | |
| 'tracking_ips_cleared'=> 0, | |
| 'tracking_deleted' => 0, | |
| 'stopped_early' => false, | |
| ); | |
| if ( $plan['submissions'] ) { | |
| $retain = hustle_gdpr_fix_retain_time( $settings['submissions_retention_number'], $settings['submissions_retention_number_unit'] ); | |
| if ( $retain ) { | |
| $stats['submissions_deleted'] = hustle_gdpr_fix_delete_old_entries( $tables, $retain, $batch, $deadline ); | |
| } | |
| } | |
| if ( $plan['ip'] && microtime( true ) < $deadline ) { | |
| $retain = hustle_gdpr_fix_retain_time( $settings['ip_retention_number'], $settings['ip_retention_number_unit'] ); | |
| if ( $retain ) { | |
| $ip_stats = hustle_gdpr_fix_anonymize_old_ips( $tables, $retain, $batch, $deadline ); | |
| $stats['entry_ips_cleared'] = $ip_stats['entries']; | |
| $stats['tracking_ips_cleared']= $ip_stats['tracking']; | |
| } | |
| } | |
| if ( $plan['tracking'] && microtime( true ) < $deadline ) { | |
| $retain = hustle_gdpr_fix_retain_time( $settings['tracking_retention_number'], $settings['tracking_retention_number_unit'] ); | |
| if ( $retain ) { | |
| $stats['tracking_deleted'] = hustle_gdpr_fix_delete_old_tracking( $tables, $retain, $batch, $deadline ); | |
| } | |
| } | |
| $stats['stopped_early'] = microtime( true ) >= $deadline; | |
| $stats['seconds'] = round( microtime( true ) - $started, 3 ); | |
| hustle_gdpr_fix_log( 'Batched cleanup end', $stats ); | |
| } | |
| /** | |
| * Delete old submissions in ID batches. | |
| * | |
| * @param array $tables Tables. | |
| * @param string $retain Cutoff datetime. | |
| * @param int $batch Batch size. | |
| * @param float $deadline Unix microtime deadline. | |
| * @return int | |
| */ | |
| function hustle_gdpr_fix_delete_old_entries( $tables, $retain, $batch, $deadline ) { | |
| global $wpdb; | |
| $deleted = 0; | |
| $ids = array(); | |
| do { | |
| if ( microtime( true ) >= $deadline ) { | |
| break; | |
| } | |
| $ids = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching | |
| $wpdb->prepare( | |
| "SELECT entry_id FROM `{$tables['entries']}` WHERE date_created < %s LIMIT %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared | |
| $retain, | |
| $batch | |
| ) | |
| ); | |
| if ( empty( $ids ) ) { | |
| break; | |
| } | |
| $ids_sql = implode( ',', array_map( 'absint', $ids ) ); | |
| $wpdb->query( "DELETE FROM `{$tables['entries_meta']}` WHERE entry_id IN ({$ids_sql})" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared | |
| $wpdb->query( "DELETE FROM `{$tables['entries']}` WHERE entry_id IN ({$ids_sql})" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared | |
| $deleted += count( $ids ); | |
| hustle_gdpr_fix_log( 'Submissions batch deleted', array( 'batch' => count( $ids ), 'total' => $deleted ) ); | |
| } while ( ! empty( $ids ) ); | |
| return $deleted; | |
| } | |
| /** | |
| * Anonymize IPs in entries meta and tracking, in batches. | |
| * | |
| * @param array $tables Tables. | |
| * @param string $retain Cutoff datetime. | |
| * @param int $batch Batch size. | |
| * @param float $deadline Unix microtime deadline. | |
| * @return array | |
| */ | |
| function hustle_gdpr_fix_anonymize_old_ips( $tables, $retain, $batch, $deadline ) { | |
| global $wpdb; | |
| $ipv4 = function_exists( 'wp_privacy_anonymize_ip' ) ? wp_privacy_anonymize_ip( '1.2.3.4' ) : '0.0.0.0'; | |
| $ipv6 = function_exists( 'wp_privacy_anonymize_ip' ) ? wp_privacy_anonymize_ip( '2001:db8::1' ) : '::'; | |
| $entry_total = 0; | |
| $tracking_total = 0; | |
| $ids = array(); | |
| $affected = 0; | |
| do { | |
| if ( microtime( true ) >= $deadline ) { | |
| break; | |
| } | |
| $ids = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching | |
| $wpdb->prepare( | |
| "SELECT em.meta_id | |
| FROM `{$tables['entries_meta']}` em | |
| INNER JOIN `{$tables['entries']}` e ON e.entry_id = em.entry_id | |
| WHERE em.meta_key = 'hustle_ip' | |
| AND e.date_created < %s | |
| AND em.meta_value <> '' | |
| AND em.meta_value <> %s | |
| AND em.meta_value <> %s | |
| LIMIT %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared | |
| $retain, | |
| $ipv4, | |
| $ipv6, | |
| $batch | |
| ) | |
| ); | |
| if ( empty( $ids ) ) { | |
| break; | |
| } | |
| $ids_sql = implode( ',', array_map( 'absint', $ids ) ); | |
| $affected = (int) $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching | |
| $wpdb->prepare( | |
| "UPDATE `{$tables['entries_meta']}` | |
| SET meta_value = CASE | |
| WHEN meta_value LIKE %s THEN %s | |
| ELSE %s | |
| END | |
| WHERE meta_id IN ({$ids_sql})", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared | |
| '%:%', | |
| $ipv6, | |
| $ipv4 | |
| ) | |
| ); | |
| $entry_total += max( 0, $affected ); | |
| hustle_gdpr_fix_log( 'Entry IP batch anonymized', array( 'batch' => $affected, 'total' => $entry_total ) ); | |
| } while ( ! empty( $ids ) ); | |
| do { | |
| if ( microtime( true ) >= $deadline ) { | |
| break; | |
| } | |
| $affected = (int) $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching | |
| $wpdb->prepare( | |
| "UPDATE `{$tables['tracking']}` | |
| SET ip = CASE | |
| WHEN ip LIKE %s THEN %s | |
| ELSE %s | |
| END | |
| WHERE date_created < %s | |
| AND ip IS NOT NULL | |
| AND ip <> '' | |
| AND ip <> %s | |
| AND ip <> %s | |
| LIMIT %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared | |
| '%:%', | |
| $ipv6, | |
| $ipv4, | |
| $retain, | |
| $ipv4, | |
| $ipv6, | |
| $batch | |
| ) | |
| ); | |
| $tracking_total += max( 0, $affected ); | |
| if ( $affected > 0 ) { | |
| hustle_gdpr_fix_log( 'Tracking IP batch anonymized', array( 'batch' => $affected, 'total' => $tracking_total ) ); | |
| } | |
| } while ( $affected > 0 ); | |
| return array( | |
| 'entries' => $entry_total, | |
| 'tracking' => $tracking_total, | |
| ); | |
| } | |
| /** | |
| * Delete old tracking rows in batches. | |
| * | |
| * @param array $tables Tables. | |
| * @param string $retain Cutoff datetime. | |
| * @param int $batch Batch size. | |
| * @param float $deadline Unix microtime deadline. | |
| * @return int | |
| */ | |
| function hustle_gdpr_fix_delete_old_tracking( $tables, $retain, $batch, $deadline ) { | |
| global $wpdb; | |
| $deleted = 0; | |
| $affected = 0; | |
| do { | |
| if ( microtime( true ) >= $deadline ) { | |
| break; | |
| } | |
| $affected = (int) $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching | |
| $wpdb->prepare( | |
| "DELETE FROM `{$tables['tracking']}` WHERE date_created < %s LIMIT %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared | |
| $retain, | |
| $batch | |
| ) | |
| ); | |
| $deleted += max( 0, $affected ); | |
| if ( $affected > 0 ) { | |
| hustle_gdpr_fix_log( 'Tracking delete batch', array( 'batch' => $affected, 'total' => $deleted ) ); | |
| } | |
| } while ( $affected > 0 ); | |
| return $deleted; | |
| } | |
| add_action( 'plugins_loaded', 'hustle_gdpr_fix_replace_callback', 1000 ); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment