0 && ( ! get_post( $post_id ) || get_option( 'accessibility_audio_tts_privacy_deleting_' . (int) $post_id, false ) ) ) ) { throw new \RuntimeException( 'This post is being permanently deleted. Audio publication stopped.' ); } } /** Remember every generated version so older private files share the post's lifecycle. */ public static function remember_audio( $post_id, $relative ) { if ( $post_id <= 0 || ! Audio_Access::valid_relative_path( $relative ) ) { return; } Atomic_Options::update( 'accessibility_audio_tts_privacy_audio_index_' . (int) $post_id, static function ( $files ) use ( $relative ) { $files = is_array( $files ) ? $files : array(); $files[] = $relative; return array_values( array_unique( $files ) ); } ); if ( ! get_post( $post_id ) && Privacy_Settings::get( 'delete_audio_on_post_delete' ) ) { Atomic_Options::update( self::QUEUE, static function ( $queue ) use ( $post_id, $relative ) { $queue = is_array( $queue ) ? $queue : array(); if ( ! isset( $queue[ $post_id ] ) ) { $queue[ $post_id ] = array( 'paths' => array(), 'root' => 'private', 'stack' => array( array( 'directory' => '', 'offset' => 0, 'last' => null ) ), 'attempts' => 0 ); } $queue[ $post_id ]['paths'][] = $relative; $queue[ $post_id ]['paths'] = array_values( array_unique( $queue[ $post_id ]['paths'] ) ); return $queue; } ); self::schedule(); } } public static function before_delete_post( $post_id, $post = null ) { if ( ! Privacy_Settings::get( 'delete_audio_on_post_delete' ) || wp_is_post_revision( $post_id ) ) { return; } $paths = get_option( 'accessibility_audio_tts_privacy_audio_index_' . (int) $post_id, array() ); $paths = is_array( $paths ) ? $paths : array(); $private = get_post_meta( $post_id, Audio_Access::PRIVATE_META, true ); if ( is_array( $private ) && isset( $private['relative_path'] ) ) { $paths[] = $private['relative_path']; } $legacy = get_post_meta( $post_id, Private_Audio_Migration::FILES_META, true ); if ( is_array( $legacy ) ) { $paths = array_merge( $paths, $legacy ); } $url_path = Audio_Access::relative_path_for_url( get_post_meta( $post_id, 'accessibility_audio_tts_url', true ) ); if ( $url_path ) { $paths[] = $url_path; } $paths = array_values( array_unique( array_filter( $paths, array( Audio_Access::class, 'valid_relative_path' ) ) ) ); // Persist the intent before WordPress removes the only canonical file binding. Atomic_Options::update( self::QUEUE, static function ( $queue ) use ( $post_id, $paths ) { $queue = is_array( $queue ) ? $queue : array(); $queue[ (int) $post_id ] = array( 'paths' => $paths, 'root' => 'private', 'stack' => array( array( 'directory' => '', 'offset' => 0, 'last' => null ) ), 'attempts' => 0 ); return $queue; } ); update_option( 'accessibility_audio_tts_privacy_deleting_' . (int) $post_id, true, false ); self::schedule(); } public static function deleted_post( $post_id, $post = null ) { if ( isset( get_option( self::QUEUE, array() )[ (int) $post_id ] ) ) { self::cleanup_post( (int) $post_id, 250 ); } } /** A binding on a different live post must survive deletion of a shared alias. */ private static function has_other_owner( $post_id, $relative ) { global $wpdb; $ids = $wpdb->get_col( $wpdb->prepare( "SELECT DISTINCT post_id FROM {$wpdb->postmeta} WHERE meta_key IN (%s,%s) AND meta_value LIKE %s", Audio_Access::PRIVATE_META, 'accessibility_audio_tts_url', '%' . $wpdb->esc_like( $relative ) . '%' ) ); if ( $wpdb->last_error || ! is_array( $ids ) ) { throw new \RuntimeException( 'Cannot verify shared audio ownership. Cleanup will retry.' ); } foreach ( $ids as $id ) { if ( (int) $id === $post_id || ! get_post( $id ) ) { continue; } $private = get_post_meta( $id, Audio_Access::PRIVATE_META, true ); if ( is_array( $private ) && ( $private['relative_path'] ?? '' ) === $relative ) { return true; } if ( Audio_Access::relative_path_for_url( get_post_meta( $id, 'accessibility_audio_tts_url', true ) ) === $relative ) { return true; } } return false; } private static function public_root() { $root = wp_upload_dir()['basedir'] . '/accessibility-audio-tts'; if ( is_link( $root ) ) { throw new \RuntimeException( 'Unsafe legacy audio directory. Cleanup stopped.' ); } return is_dir( $root ) ? rtrim( wp_normalize_path( realpath( $root ) ), '/' ) : null; } private static function remove_audio( $post_id, $root, $relative ) { if ( ! Audio_Access::valid_relative_path( $relative ) || self::has_other_owner( $post_id, $relative ) ) { return; } $path = $root . '/' . $relative; if ( is_link( $path ) ) { throw new \RuntimeException( 'Unsafe managed recording. Cleanup stopped.' ); } if ( ! file_exists( $path ) ) { return; } $real = realpath( $path ); if ( ! is_file( $path ) || false === $real || 0 !== strpos( wp_normalize_path( $real ), $root . '/' ) ) { throw new \RuntimeException( 'Recording escaped its managed directory. Cleanup stopped.' ); } wp_delete_file( $path ); clearstatcache( true, $path ); if ( file_exists( $path ) ) { throw new \RuntimeException( 'A recording could not be removed. Check permissions; cleanup will retry.' ); } } private static function filename_belongs_to_post( $name, $post_id ) { return (bool) preg_match( '/^(?:(?:accessibility_audio_tts_' . (int) $post_id . '_|post_' . (int) $post_id . '_chunk_|post[-_]' . (int) $post_id . '[-_]audio)[a-zA-Z0-9_.-]*|audio_' . (int) $post_id . ')\.(?:mp3|wav|ogg)$/iD', $name ); } /** One bounded directory pass also finds old versions created before the index existed. */ private static function scan_deleted_post( $post_id, &$state, $root, $limit, $deadline ) { $count = 0; $walkers = array(); while ( $state['stack'] && $count < $limit && microtime( true ) < $deadline ) { $key = count( $state['stack'] ) - 1; $frame = $state['stack'][ $key ]; $directory = $root . ( '' === $frame['directory'] ? '' : '/' . $frame['directory'] ); $real = realpath( $directory ); if ( is_link( $directory ) || false === $real || 0 !== strpos( wp_normalize_path( $real ) . '/', $root . '/' ) ) { array_pop( $state['stack'] ); continue; } $offset = (int) $frame['offset']; if ( ! isset( $walkers[ $directory ] ) ) { $iterator = new \DirectoryIterator( $directory ); try { if ( $offset > 0 ) { $iterator->seek( $offset - 1 ); if ( ! $iterator->valid() || $iterator->getFilename() !== $frame['last'] ) { $offset = 0; } } $iterator->seek( $offset ); } catch ( \OutOfBoundsException $error ) { array_pop( $state['stack'] ); continue; } $walkers[ $directory ] = $iterator; } $iterator = $walkers[ $directory ]; if ( ! $iterator->valid() ) { array_pop( $state['stack'] ); continue; } $name = $iterator->getFilename(); $entry = $iterator->getFileInfo(); $iterator->next(); $relative = ltrim( $frame['directory'] . '/' . $name, '/' ); $state['stack'][ $key ]['offset'] = $offset + 1; $state['stack'][ $key ]['last'] = $name; ++$count; if ( '.' === $name || '..' === $name || $entry->isLink() ) { continue; } if ( $entry->isDir() ) { $state['stack'][] = array( 'directory' => $relative, 'offset' => 0, 'last' => null ); continue; } if ( $entry->isFile() && self::filename_belongs_to_post( $name, $post_id ) ) { self::remove_audio( $post_id, $root, $relative ); } } } public static function cleanup_post( $post_id, $limit = 250 ) { $queue = get_option( self::QUEUE, array() ); if ( ! is_array( $queue ) || ! isset( $queue[ $post_id ] ) || get_post( $post_id ) ) { return false; } $lease = Generation_Lease::acquire( (int) $post_id ); if ( ! $lease ) { self::schedule(); return false; } try { $queue = get_option( self::QUEUE, array() ); if ( ! isset( $queue[ $post_id ] ) || get_post( $post_id ) ) { return false; } $state = $queue[ $post_id ]; $deadline = microtime( true ) + 3; $private = Audio_Access::private_root(); $public = self::public_root(); $index = get_option( 'accessibility_audio_tts_privacy_audio_index_' . $post_id, array() ); $state['finished_paths'] = $state['finished_paths'] ?? array(); $state['paths'] = array_values( array_diff( array_unique( array_merge( $state['paths'], is_array( $index ) ? $index : array() ) ), $state['finished_paths'] ) ); $processed = 0; while ( $state['paths'] && ++$processed <= min( 50, max( 1, (int) $limit ) ) && microtime( true ) < $deadline ) { $relative = $state['paths'][0]; if ( ! $lease->renew() ) { throw new \RuntimeException( 'Recording cleanup lost its lease.' ); } self::remove_audio( $post_id, $private, $relative ); if ( $public ) { self::remove_audio( $post_id, $public, $relative ); } $state['finished_paths'][] = $relative; array_shift( $state['paths'] ); } $root = 'private' === $state['root'] ? $private : $public; if ( ! $state['paths'] ) { if ( $root ) { self::scan_deleted_post( $post_id, $state, $root, max( 1, min( 1000, (int) $limit ) ), $deadline ); } else { $state['stack'] = array(); } } if ( ! $state['stack'] && 'private' === $state['root'] ) { $state['root'] = 'public'; $state['stack'] = array( array( 'directory' => '', 'offset' => 0, 'last' => null ) ); } $complete = ! $state['stack'] && ! $state['paths']; if ( $complete ) { // A worker interrupted during binding may have recreated orphaned postmeta. global $wpdb; $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->postmeta} WHERE post_id=%d AND (meta_key LIKE %s OR meta_key LIKE %s)", $post_id, $wpdb->esc_like( 'accessibility_audio_tts_' ) . '%', $wpdb->esc_like( '_accessibility_audio_tts_' ) . '%' ) ); if ( $wpdb->last_error ) { throw new \RuntimeException( 'Deleted-post metadata could not be cleaned. Cleanup will retry.' ); } } $saved = Atomic_Options::update( self::QUEUE, static function ( $current ) use ( $post_id, $state, $complete ) { $current = is_array( $current ) ? $current : array(); $state['paths'] = array_values( array_diff( array_unique( array_merge( $state['paths'], $current[ $post_id ]['paths'] ?? array() ) ), $state['finished_paths'] ) ); if ( $complete && ! $state['paths'] ) { unset( $current[ $post_id ] ); } else { $current[ $post_id ] = $state; } return $current; } ); $complete = ! isset( $saved[ $post_id ] ); if ( $complete ) { delete_option( 'accessibility_audio_tts_privacy_deleting_' . $post_id ); delete_option( 'accessibility_audio_tts_privacy_audio_index_' . $post_id ); } return $complete; } catch ( \Throwable $error ) { update_option( self::ERROR, $error->getMessage(), false ); self::schedule(); return false; } finally { $lease->release(); } } /** Only the exact hash-named files produced by the legacy backup migration are managed. */ public static function remember_backup( $path ) { $root = Audio_Access::private_root() . '/legacy-backups'; $name = basename( $path ); if ( ! preg_match( '/^[a-f0-9]{64}\.zip$/D', $name ) || is_link( $root ) || is_link( $path ) || ! is_file( $path ) || realpath( dirname( $path ) ) !== realpath( $root ) || hash_file( 'sha256', $path ) . '.zip' !== $name ) { throw new \RuntimeException( 'Invalid managed backup.' ); } Atomic_Options::update( self::BACKUPS, static function ( $files ) use ( $name, $path ) { $files = is_array( $files ) ? $files : array(); if ( ! isset( $files[ $name ] ) ) { $files[ $name ] = (int) filemtime( $path ); } return $files; } ); } public static function cleanup_backups() { $directory = Audio_Access::private_root() . '/legacy-backups'; if ( ! is_dir( $directory ) ) { return; } if ( is_link( $directory ) ) { throw new \RuntimeException( 'Unsafe private backup directory.' ); } // Upgrade from older guarded builds: verify each old file before adopting it. $iterator = new \DirectoryIterator( $directory ); $seen = 0; $scan = get_option( 'accessibility_audio_tts_privacy_backup_scan', array( 'offset' => 0, 'last' => null ) ); $offset = (int) ( $scan['offset'] ?? 0 ); try { if ( $offset > 0 ) { $iterator->seek( $offset - 1 ); if ( ! $iterator->valid() || $iterator->getFilename() !== ( $scan['last'] ?? null ) ) { $offset = 0; } } $iterator->seek( $offset ); } catch ( \OutOfBoundsException $error ) { $offset = 0; $iterator->rewind(); } while ( $iterator->valid() && ++$seen <= 250 ) { $entry = $iterator->getFileInfo(); $scan = array( 'offset' => ++$offset, 'last' => $entry->getFilename() ); $iterator->next(); if ( $entry->isFile() && ! $entry->isLink() && preg_match( '/^[a-f0-9]{64}\.zip$/D', $entry->getFilename() ) && hash_file( 'sha256', $entry->getPathname() ) . '.zip' === $entry->getFilename() ) { self::remember_backup( $entry->getPathname() ); } } update_option( 'accessibility_audio_tts_privacy_backup_scan', $iterator->valid() ? $scan : array( 'offset' => 0, 'last' => null ), false ); $files = get_option( self::BACKUPS, array() ); $removed = array(); $checked = 0; foreach ( is_array( $files ) ? $files : array() as $name => $created ) { if ( ! preg_match( '/^[a-f0-9]{64}\.zip$/D', $name ) || (int) $created > time() - Privacy_Settings::get( 'backup_retention_days' ) * DAY_IN_SECONDS ) { continue; } if ( ++$checked > 250 ) { break; } $path = $directory . '/' . $name; if ( is_link( $path ) || ( is_file( $path ) && hash_file( 'sha256', $path ) . '.zip' !== $name ) ) { throw new \RuntimeException( 'A managed backup changed unexpectedly. Cleanup stopped.' ); } if ( is_file( $path ) ) { wp_delete_file( $path ); clearstatcache( true, $path ); } if ( file_exists( $path ) ) { throw new \RuntimeException( 'An expired backup could not be removed.' ); } $removed[] = $name; } Atomic_Options::update( self::BACKUPS, static function ( $current ) use ( $removed ) { foreach ( $removed as $name ) { unset( $current[ $name ] ); } return $current; } ); } /** Diagnostics never use a public uploads log, even when private storage fails. */ public static function log_directory() { $directory = Audio_Access::private_root() . '/logs'; if ( is_link( $directory ) || ( ! is_dir( $directory ) && ! @mkdir( $directory, 0700 ) && ! is_dir( $directory ) ) || realpath( dirname( $directory ) ) !== realpath( Audio_Access::private_root() ) ) { throw new \RuntimeException( 'Cannot create private diagnostics storage.' ); } @chmod( $directory, 0700 ); return $directory; } /** Resolve only current managed daily logs, never arbitrary server paths. */ public static function latest_log_file() { try { $directory = self::log_directory(); for ( $day = 0; $day < self::LOG_RETENTION_DAYS; ++$day ) { foreach ( array( '', '.old' ) as $suffix ) { $path = $directory . '/debug-' . gmdate( 'Y-m-d', time() - $day * DAY_IN_SECONDS ) . '.log' . $suffix; if ( ! is_link( $path ) && is_file( $path ) && is_readable( $path ) && filesize( $path ) > 0 && realpath( dirname( $path ) ) === realpath( $directory ) ) { return $path; } } } } catch ( \Throwable $error ) { /* The admin receives an unavailable state. */ } return ''; } /** At most 64 KiB and 200 lines; diagnostic downloads do not load a whole log. */ public static function read_log_tail( $path, $lines = 50 ) { try { $directory = self::log_directory(); if ( ! is_string( $path ) || '' === $path || is_link( $path ) || ! is_file( $path ) || ! is_readable( $path ) || realpath( dirname( $path ) ) !== realpath( $directory ) || ! preg_match( '/^debug-[0-9]{4}-[0-9]{2}-[0-9]{2}\.log(?:\.old)?$/D', basename( $path ) ) ) { return ''; } $handle = @fopen( $path, 'rb' ); if ( false === $handle ) { return ''; } try { $size = (int) filesize( $path ); $offset = max( 0, $size - 65536 ); if ( 0 !== fseek( $handle, $offset ) ) { return ''; } $content = (string) fread( $handle, 65536 ); if ( $offset > 0 && false !== strpos( $content, "\n" ) ) { $content = substr( $content, strpos( $content, "\n" ) + 1 ); } return implode( "\n", array_slice( explode( "\n", $content ), -max( 10, min( 200, (int) $lines ) ) ) ); } finally { fclose( $handle ); } } catch ( \Throwable $error ) { return ''; } } public static function cleanup_logs() { $directory = self::log_directory(); $public = self::public_root(); $legacy = $public ? $public . '/debug.log' : null; foreach ( $legacy ? array( $legacy, $legacy . '.old' ) : array() as $source ) { if ( ! file_exists( $source ) ) { continue; } if ( is_link( $source ) || ! is_file( $source ) ) { throw new \RuntimeException( 'Unsafe old diagnostic log.' ); } $hash = hash_file( 'sha256', $source ); if ( false === $hash ) { throw new \RuntimeException( 'Cannot verify the old diagnostic log.' ); } $destination = $directory . '/legacy-debug-' . $hash . '.log'; if ( is_link( $destination ) ) { throw new \RuntimeException( 'Unsafe private diagnostic log.' ); } if ( ! is_file( $destination ) && ! copy( $source, $destination ) ) { throw new \RuntimeException( 'Cannot preserve an old diagnostic log privately.' ); } if ( hash_file( 'sha256', $destination ) !== $hash ) { throw new \RuntimeException( 'Cannot verify the private diagnostic log.' ); } @chmod( $destination, 0600 ); @touch( $destination, (int) filemtime( $source ) ); wp_delete_file( $source ); clearstatcache( true, $source ); if ( file_exists( $source ) ) { throw new \RuntimeException( 'Cannot remove the old public diagnostic log.' ); } } foreach ( new \DirectoryIterator( $directory ) as $entry ) { if ( ! $entry->isFile() || $entry->isLink() || ! preg_match( '/^(?:debug(?:-[0-9]{4}-[0-9]{2}-[0-9]{2})?\.log(?:\.old)?|legacy-debug-[a-f0-9]{64}\.log)$/D', $entry->getFilename() ) ) { continue; } if ( $entry->getMTime() < time() - self::LOG_RETENTION_DAYS * DAY_IN_SECONDS ) { wp_delete_file( $entry->getPathname() ); clearstatcache( true, $entry->getPathname() ); if ( file_exists( $entry->getPathname() ) ) { throw new \RuntimeException( 'An expired diagnostic log could not be removed.' ); } } } } public static function secure_legacy_logs() { if ( get_option( 'accessibility_audio_tts_privacy_legacy_logs_secured', false ) ) { return; } try { self::cleanup_logs(); update_option( 'accessibility_audio_tts_privacy_legacy_logs_secured', true, false ); } catch ( \Throwable $error ) { update_option( self::ERROR, $error->getMessage(), false ); self::schedule(); } } public static function cleanup() { try { $queue = get_option( self::QUEUE, array() ); $processed = 0; $visited = array(); foreach ( array_keys( is_array( $queue ) ? $queue : array() ) as $post_id ) { if ( ++$processed > 3 ) { break; } $visited[] = $post_id; self::cleanup_post( (int) $post_id ); } // Busy or failing posts yield their place; one damaged file cannot starve others. Atomic_Options::update( self::QUEUE, static function ( $current ) use ( $visited ) { $current = is_array( $current ) ? $current : array(); foreach ( $visited as $id ) { if ( isset( $current[ $id ] ) ) { $entry = $current[ $id ]; unset( $current[ $id ] ); $current[ $id ] = $entry; } } return $current; } ); self::cleanup_backups(); self::cleanup_logs(); if ( ! get_option( self::QUEUE, array() ) ) { delete_option( self::ERROR ); } } catch ( \Throwable $error ) { update_option( self::ERROR, $error->getMessage(), false ); } if ( ! wp_next_scheduled( self::HOOK ) ) { wp_schedule_single_event( time() + ( get_option( self::QUEUE, array() ) ? 60 : HOUR_IN_SECONDS ), self::HOOK ); } } public static function status() { $queue = get_option( self::QUEUE, array() ); return array( 'pending_posts' => is_array( $queue ) ? count( $queue ) : 0, 'error' => get_option( self::ERROR, '' ), 'log_retention_days' => self::LOG_RETENTION_DAYS ); } /** Explicit uninstall purge only; unrelated files and symlinks are never traversed. */ public static function purge_managed_data() { global $wpdb; update_option( 'accessibility_audio_tts_privacy_purging', true, false ); $lease = null; $leases = array(); try { wp_cache_delete( 'accessibility_audio_tts_privacy_purging', 'options' ); if ( ! get_option( 'accessibility_audio_tts_privacy_purging', false ) ) { throw new \RuntimeException( 'Cannot stop audio publication before uninstall.' ); } $lease = Generation_Lease::acquire( 0 ); if ( ! $lease ) { throw new \RuntimeException( 'Stop audio generation and migration before purging plugin files.' ); } $lock_keys = $wpdb->get_col( $wpdb->prepare( "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s", $wpdb->esc_like( 'accessibility_audio_tts_generation_lock_' ) . '%' ) ); if ( $wpdb->last_error ) { throw new \RuntimeException( 'Cannot verify generation state before uninstall.' ); } foreach ( $lock_keys as $key ) { $id = (int) substr( $key, strlen( 'accessibility_audio_tts_generation_lock_' ) ); if ( $id > 0 && Generation_Lease::is_busy( $id ) ) { throw new \RuntimeException( 'Stop audio generation before purging plugin files.' ); } } $known = array(); $rows = $wpdb->get_results( $wpdb->prepare( "SELECT post_id,meta_value FROM {$wpdb->postmeta} WHERE meta_key=%s", Audio_Access::PRIVATE_META ), ARRAY_A ); if ( $wpdb->last_error ) { throw new \RuntimeException( 'Cannot verify recording ownership before uninstall.' ); } foreach ( $rows as $row ) { $data = maybe_unserialize( $row['meta_value'] ); if ( is_array( $data ) && Audio_Access::valid_relative_path( $data['relative_path'] ?? null ) ) { $known[ $data['relative_path'] ] = (int) $row['post_id']; } } $indexes = $wpdb->get_results( $wpdb->prepare( "SELECT option_name,option_value FROM {$wpdb->options} WHERE option_name LIKE %s", $wpdb->esc_like( 'accessibility_audio_tts_privacy_audio_index_' ) . '%' ), ARRAY_A ); if ( $wpdb->last_error ) { throw new \RuntimeException( 'Cannot verify recording history before uninstall.' ); } foreach ( $indexes as $row ) { $id = (int) substr( $row['option_name'], strlen( 'accessibility_audio_tts_privacy_audio_index_' ) ); $paths = maybe_unserialize( $row['option_value'] ); foreach ( is_array( $paths ) ? $paths : array() as $relative ) { if ( Audio_Access::valid_relative_path( $relative ) ) { $known[ $relative ] = $id; } } } $candidates = array(); $ids = array(); $visited = 0; foreach ( array_filter( array( Audio_Access::private_root(), self::public_root() ) ) as $root ) { $stack = array( $root ); while ( $stack ) { $directory = array_pop( $stack ); foreach ( new \DirectoryIterator( $directory ) as $entry ) { if ( ++$visited > 10000 ) { throw new \RuntimeException( 'The managed file tree is too large for synchronous uninstall. Remove managed files through maintenance before uninstalling.' ); } if ( $entry->isDot() || $entry->isLink() ) { continue; } $path = $entry->getPathname(); if ( $entry->isDir() ) { $stack[] = $path; continue; } if ( ! $entry->isFile() ) { continue; } $relative = substr( wp_normalize_path( $path ), strlen( $root ) + 1 ); $name = $entry->getFilename(); $id = $known[ $relative ] ?? 0; $managed = isset( $known[ $relative ] ); if ( Audio_Access::valid_relative_path( $relative ) && preg_match( '/^(?:accessibility_audio_tts_(\d+)_|post_(\d+)_chunk_|post[-_](\d+)[-_]audio|audio_(\d+)\.)/', $name, $matches ) ) { $managed = true; $id = max( array_map( 'intval', array_slice( $matches, 1 ) ) ); } if ( 'legacy-backups' === dirname( $relative ) && preg_match( '/^[a-f0-9]{64}\.zip$/D', $name ) && hash_file( 'sha256', $path ) . '.zip' === $name ) { $managed = true; } if ( 'logs' === dirname( $relative ) && preg_match( '/^(?:debug(?:-[0-9]{4}-[0-9]{2}-[0-9]{2})?\.log(?:\.old)?|legacy-debug-[a-f0-9]{64}\.log)$/D', $name ) ) { $managed = true; } if ( $managed ) { $candidates[] = $path; if ( $id > 0 ) { $ids[ $id ] = $id; } } } } } sort( $ids ); foreach ( $ids as $id ) { $post_lease = Generation_Lease::acquire( $id ); if ( ! $post_lease ) { throw new \RuntimeException( 'Audio generation became active. Retry uninstall after stopping it.' ); } $leases[] = $post_lease; } foreach ( $candidates as $path ) { if ( ! $lease->renew() ) { throw new \RuntimeException( 'File purge lost its lease.' ); } wp_delete_file( $path ); clearstatcache( true, $path ); if ( file_exists( $path ) ) { throw new \RuntimeException( 'A managed plugin file could not be removed. Check permissions and retry uninstall.' ); } } return count( $candidates ); } finally { foreach ( $leases as $post_lease ) { $post_lease->release(); } if ( $lease ) { $lease->release(); } delete_option( 'accessibility_audio_tts_privacy_purging' ); } } public static function notice() { if ( current_user_can( 'manage_options' ) && get_option( self::ERROR, '' ) ) { echo '
' . esc_html__( 'Privacy cleanup needs attention. Check the Privacy and cookies section and directory permissions; pending recordings and backups remain private.', 'accessibility-audio-tts-text-to-speech-for-articles' ) . '
' . esc_html( get_option( self::ERROR, '' ) ) . '