Skip to content

Instantly share code, notes, and snippets.

@patrickfreitasdev
Created September 19, 2026 04:49
Show Gist options
  • Select an option

  • Save patrickfreitasdev/cbda6b4e220878437b6e7e0c3fe74eff to your computer and use it in GitHub Desktop.

Select an option

Save patrickfreitasdev/cbda6b4e220878437b6e7e0c3fe74eff to your computer and use it in GitHub Desktop.
<?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