true, 'show_in_rest' => false, 'auth_callback' => array( __CLASS__, 'can_manage_private_metadata' ) ) ); } add_action( 'admin_post_' . self::ACTION, array( __CLASS__, 'serve' ) ); add_action( 'admin_post_nopriv_' . self::ACTION, array( __CLASS__, 'serve' ) ); add_action( 'admin_post_accessibility_audio_tts_migration_batch', array( __CLASS__, 'admin_migration_batch' ) ); // Classify custom post types after their usual init registration callbacks. add_action( 'init', array( __CLASS__, 'migrate_restricted_audio' ), 99 ); add_action( Private_Audio_Migration::HOOK, array( __CLASS__, 'migrate_restricted_audio' ) ); add_filter( 'wp_insert_post_data', array( __CLASS__, 'before_post_update' ), 10, 2 ); add_action( 'post_updated', array( __CLASS__, 'post_updated' ), 10, 1 ); add_action( 'added_post_meta', array( __CLASS__, 'metadata_updated' ), 10, 4 ); add_action( 'updated_post_meta', array( __CLASS__, 'metadata_updated' ), 10, 4 ); add_action( 'admin_notices', array( __CLASS__, 'notice' ) ); add_action( 'template_redirect', array( __CLASS__, 'protect_page_cache' ), 0 ); add_action( 'accessibility_audio_tts_delete_sample', array( __CLASS__, 'delete_sample' ) ); } private static function private_metadata_keys() { return array( self::PRIVATE_META, Private_Audio_Migration::FILES_META, Private_Audio_Migration::RETRY_META, 'accessibility_audio_tts_file_data', 'accessibility_audio_tts_path', 'accessibility_audio_tts_filename', 'accessibility_audio_tts_size', 'accessibility_audio_tts_url' ); } public static function protected_metadata( $protected, $key, $type ) { return 'post' === $type && in_array( $key, self::private_metadata_keys(), true ) ? true : $protected; } public static function can_manage_private_metadata() { return current_user_can( 'manage_options' ); } public static function is_restricted( $post ) { return ! $post || ! empty( $post->post_password ) || ! is_post_publicly_viewable( $post ); } public static function can_read( $post ) { $post = get_post( $post ); if ( ! $post ) { return false; } if ( ! self::can_read_post( $post ) ) { return false; } $file = get_post_meta( $post->ID, self::PRIVATE_META, true ); if ( is_array( $file ) && isset( $file['relative_path'] ) && self::valid_relative_path( $file['relative_path'] ) ) { // A shared recording inherits every owning post's restrictions, never the least restrictive alias. global $wpdb; $owners = $wpdb->get_col( $wpdb->prepare( "SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = %s AND meta_value LIKE %s", self::PRIVATE_META, '%' . $wpdb->esc_like( $file['relative_path'] ) . '%' ) ); foreach ( $owners as $id ) { $other = get_post_meta( $id, self::PRIVATE_META, true ); $owner = get_post( $id ); if ( $owner && is_array( $other ) && ( $other['relative_path'] ?? '' ) === $file['relative_path'] && ! self::can_read_post( $owner ) ) { return false; } } } return true; } private static function can_read_post( $post ) { if ( current_user_can( 'edit_post', $post->ID ) ) { return true; } if ( is_post_publicly_viewable( $post ) ) { return ! post_password_required( $post ); } return is_user_logged_in() && current_user_can( 'read_post', $post->ID ) && ! post_password_required( $post ); } public static function url( $post_id, $preview = false ) { $args = array( 'action' => self::ACTION, 'post_id' => absint( $post_id ) ); if ( $preview ) { $args['aat_audio_preview'] = '1'; } return add_query_arg( $args, admin_url( 'admin-post.php' ) ); } /** A durable directory, never uploads or an OS temporary directory. */ public static function private_root() { $document_root = ! empty( $_SERVER['DOCUMENT_ROOT'] ) ? realpath( $_SERVER['DOCUMENT_ROOT'] ) : false; $wordpress_root = realpath( ABSPATH ); if ( false === $wordpress_root ) { throw new \RuntimeException( 'Cannot resolve the WordPress directory.' ); } // CLI cannot infer a parent web root safely. Use one explicit root for web and CLI. if ( ! defined( 'ACCESSIBILITY_AUDIO_TTS_PRIVATE_DIR' ) && false === $document_root ) { throw new \RuntimeException( 'Configure ACCESSIBILITY_AUDIO_TTS_PRIVATE_DIR outside the web root before accessing audio from the command line.' ); } $directory = defined( 'ACCESSIBILITY_AUDIO_TTS_PRIVATE_DIR' ) ? rtrim( ACCESSIBILITY_AUDIO_TTS_PRIVATE_DIR, '/\\' ) . ( is_multisite() ? '/site-' . get_current_blog_id() : '' ) : dirname( $wordpress_root ) . '/.accessibility-audio-tts-private/' . substr( hash( 'sha256', $wordpress_root . '|' . get_current_blog_id() ), 0, 20 ); return self::prepare_private_directory( $directory, false ); } /** Temporary chunks are private even while an originally public post is edited. */ public static function working_directory( $post, $fallback = null ) { $directory = sys_get_temp_dir() . '/aat-work-' . substr( hash( 'sha256', ABSPATH . '|' . get_current_blog_id() ), 0, 20 ); return self::prepare_private_directory( $directory, true ); } /** Fail before billable work when either destination cannot actually store bytes. */ public static function assert_storage_ready( $relative_directory = null ) { if ( get_option( 'accessibility_audio_tts_privacy_purging', false ) ) { throw new \RuntimeException( 'Plugin files are being removed. Audio generation is unavailable.' ); } $relative_directory = null === $relative_directory ? gmdate( 'Y/m' ) : $relative_directory; if ( ! is_string( $relative_directory ) || ! self::valid_relative_path( $relative_directory . '/aat-storage-probe.mp3' ) ) { throw new \RuntimeException( 'Invalid audio destination directory.' ); } $destination = dirname( self::import_destination( 0, $relative_directory . '/aat-storage-probe.mp3' ) ); foreach ( array_unique( array( $destination, self::working_directory( null ) ) ) as $directory ) { $path = $directory . '/.aat-probe-' . bin2hex( random_bytes( 12 ) ); $handle = @fopen( $path, 'x+b' ); if ( false === $handle ) { throw new \RuntimeException( __( 'Audio storage is not writable. Configure the private audio directory before generating speech.', 'accessibility-audio-tts-text-to-speech-for-articles' ) ); } try { @chmod( $path, 0600 ); $bytes = random_bytes( 64 ); if ( fwrite( $handle, $bytes ) !== strlen( $bytes ) || ! fflush( $handle ) || ! rewind( $handle ) || fread( $handle, strlen( $bytes ) ) !== $bytes ) { throw new \RuntimeException( __( 'Audio storage failed its write/read check. Check available disk space and directory permissions.', 'accessibility-audio-tts-text-to-speech-for-articles' ) ); } } finally { fclose( $handle ); @unlink( $path ); } clearstatcache( true, $path ); if ( file_exists( $path ) ) { throw new \RuntimeException( __( 'Audio storage failed its cleanup check. Check directory permissions before generating speech.', 'accessibility-audio-tts-text-to-speech-for-articles' ) ); } } } /** Admin readiness is a real filesystem check; it never calls a speech provider. */ public static function storage_status() { try { self::assert_storage_ready(); return array( 'ready' => true, 'message' => __( 'Audio storage is ready.', 'accessibility-audio-tts-text-to-speech-for-articles' ) ); } catch ( \Throwable $error ) { return array( 'ready' => false, 'message' => $error->getMessage() ); } } private static function prepare_private_directory( $directory, $temporary ) { if ( ! is_string( $directory ) || '' === $directory || false !== strpos( $directory, "\0" ) || false !== strpos( $directory, '://' ) ) { throw new \RuntimeException( 'Invalid private audio directory.' ); } $directory = wp_normalize_path( $directory ); if ( '/' !== substr( $directory, 0, 1 ) && ! preg_match( '/^[A-Za-z]:\//', $directory ) ) { throw new \RuntimeException( 'Private audio directory must be absolute.' ); } if ( in_array( '..', explode( '/', $directory ), true ) ) { throw new \RuntimeException( 'Private audio directory cannot contain parent segments.' ); } // Resolve existing ancestors before creating anything; reject symlinked escapes. $ancestor = $directory; $suffix = array(); while ( ! file_exists( $ancestor ) ) { $suffix[] = basename( $ancestor ); $parent = dirname( $ancestor ); if ( $parent === $ancestor ) { break; } $ancestor = $parent; } $real = realpath( $ancestor ); if ( false === $real ) { throw new \RuntimeException( 'Cannot resolve private storage.' ); } $resolved = wp_normalize_path( $real . ( $suffix ? '/' . implode( '/', array_reverse( $suffix ) ) : '' ) ); $upload = wp_upload_dir(); $roots = array( ABSPATH, WP_CONTENT_DIR, $upload['basedir'] ); foreach ( array( 'WP_PLUGIN_DIR', 'WPMU_PLUGIN_DIR' ) as $constant ) { if ( defined( $constant ) ) { $roots[] = constant( $constant ); } } if ( ! empty( $_SERVER['DOCUMENT_ROOT'] ) ) { $roots[] = $_SERVER['DOCUMENT_ROOT']; } foreach ( $roots as $root ) { $root = realpath( $root ); if ( false !== $root && 0 === strpos( $resolved . '/', rtrim( wp_normalize_path( $root ), '/' ) . '/' ) ) { throw new \RuntimeException( 'Private audio storage must be outside every public directory. Configure ACCESSIBILITY_AUDIO_TTS_PRIVATE_DIR in wp-config.php.' ); } } if ( ! $temporary ) { $temp = realpath( sys_get_temp_dir() ); if ( $temp && 0 === strpos( $resolved . '/', rtrim( wp_normalize_path( $temp ), '/' ) . '/' ) ) { throw new \RuntimeException( 'Durable audio storage cannot use the system temporary directory.' ); } } // A second request may create it concurrently; any other mkdir failure remains an explicit error. if ( is_link( $directory ) || ( ! is_dir( $directory ) && ! @mkdir( $directory, 0700, true ) && ! is_dir( $directory ) ) || ! wp_is_writable( $directory ) ) { throw new \RuntimeException( 'Cannot create durable private audio storage. Configure a writable ACCESSIBILITY_AUDIO_TTS_PRIVATE_DIR outside the web root.' ); } @chmod( $directory, 0700 ); return rtrim( wp_normalize_path( realpath( $directory ) ), '/' ); } public static function valid_relative_path( $relative ) { if ( ! is_string( $relative ) || ! preg_match( '~^[a-zA-Z0-9_./-]+$~D', $relative ) ) { return false; } foreach ( explode( '/', $relative ) as $part ) { if ( '' === $part || '.' === $part || '..' === $part ) { return false; } } return in_array( strtolower( pathinfo( $relative, PATHINFO_EXTENSION ) ), array( 'mp3', 'wav', 'ogg' ), true ); } public static function relative_path_for_url( $url ) { $uploads = wp_upload_dir(); $base = rtrim( $uploads['baseurl'], '/' ) . '/accessibility-audio-tts/'; if ( ! is_string( $url ) || 0 !== strpos( $url, $base ) ) { return null; } $relative = rawurldecode( substr( $url, strlen( $base ) ) ); return self::valid_relative_path( $relative ) ? $relative : null; } /** All imported files are private before they are associated with a post. */ public static function import_destination( $post_id, $relative ) { if ( ! self::valid_relative_path( $relative ) ) { throw new \RuntimeException( 'Invalid audio path.' ); } $root = self::private_root(); $directory = $root; $parts = explode( '/', dirname( $relative ) ); foreach ( $parts as $part ) { if ( '.' === $part ) { continue; } $directory .= '/' . $part; if ( is_link( $directory ) || ( ! is_dir( $directory ) && ! @mkdir( $directory, 0700, true ) && ! is_dir( $directory ) ) || 0 !== strpos( wp_normalize_path( realpath( $directory ) ) . '/', $root . '/' ) ) { throw new \RuntimeException( 'Unsafe private audio destination.' ); } @chmod( $directory, 0700 ); } $path = $root . '/' . $relative; if ( is_link( $path ) || is_dir( $path ) ) { throw new \RuntimeException( 'Unsafe audio file destination.' ); } return $path; } public static function register_imported_audio( $post_id, $relative, array $recording = array() ) { $path = self::import_destination( $post_id, $relative ); if ( ! is_file( $path ) ) { throw new \RuntimeException( 'Imported audio is missing.' ); } self::record_private_file( $post_id, $relative, $path, $recording ); return self::url( $post_id ); } /** Publish a complete private stage. No plaintext final file is briefly public. */ public static function publish_file( $post_id, $stage, $relative, $sample = false, array $recording = array() ) { if ( ! $sample ) { Privacy_Lifecycle::remember_audio( $post_id, $relative ); Privacy_Lifecycle::assert_post_available( $post_id ); } else { Privacy_Lifecycle::assert_post_available( 0 ); } $destination = self::import_destination( $post_id, $relative ); if ( $recording ) { $recording = Recording_Metadata::for_publication( $recording, $relative, $stage ); } Generation_Lease::checkpoint( (int) $post_id ); self::verified_move( $stage, $destination ); Generation_Lease::checkpoint( (int) $post_id ); if ( $sample ) { $token = bin2hex( random_bytes( 16 ) ); set_transient( 'accessibility_audio_tts_sample_' . $token, $relative, HOUR_IN_SECONDS ); wp_schedule_single_event( time() + HOUR_IN_SECONDS, 'accessibility_audio_tts_delete_sample', array( $relative ) ); $url = add_query_arg( array( 'action' => self::ACTION, 'sample' => $token ), admin_url( 'admin-post.php' ) ); } else { self::record_private_file( $post_id, $relative, $destination, $recording ); $url = self::url( $post_id ); } return array( 'success' => true, 'url' => $url, 'path' => $destination, 'size' => filesize( $destination ), 'filename' => basename( $destination ), 'recording' => $recording ); } public static function delete_sample( $relative ) { if ( self::valid_relative_path( $relative ) && 0 === strpos( basename( $relative ), 'accessibility_audio_tts_99999_' ) ) { try { $path = self::import_destination( 0, $relative ); if ( is_file( $path ) ) { wp_delete_file( $path ); } } catch ( \Throwable $error ) { accessibility_audio_tts_debug_log( 'Private sample cleanup: ' . $error->getMessage() ); } } } private static function record_private_file( $post_id, $relative, $path, ?array $recording = null ) { $post_id = absint( $post_id ); Privacy_Lifecycle::remember_audio( $post_id, $relative ); Privacy_Lifecycle::assert_post_available( $post_id ); $was_busy = isset( self::$busy[ $post_id ] ); self::$busy[ $post_id ] = true; try { $explicit_recording = null !== $recording; $previous = get_post_meta( $post_id, self::PRIVATE_META, true ); // A protection/migration refresh retains provenance only for the same // active bytes. Explicit imports/publications never inherit old facts. if ( null === $recording ) { $recording = is_array( $previous ) && ( $previous['relative_path'] ?? '' ) === $relative ? ( $previous['recording'] ?? array() ) : array(); } if ( $recording && ! Recording_Metadata::valid( $recording, true ) ) { throw new \RuntimeException( 'Invalid recording provenance.' ); } if ( $recording && 'recorded' === $recording['provenance'] && isset( $recording['audio_sha256'] ) && ( ! is_array( $previous ) || ( $previous['recording'] ?? null ) !== $recording || ( $previous['relative_path'] ?? '' ) !== $relative ) ) { $recording = Recording_Metadata::for_publication( $recording, $relative, $path ); } $file = array( 'relative_path' => $relative, 'path' => $path ); if ( $recording ) { $file['recording'] = $recording; } self::save_verified_meta( $post_id, self::PRIVATE_META, $file ); $data = get_post_meta( $post_id, 'accessibility_audio_tts_file_data', true ); $data = is_array( $data ) ? $data : array(); $data = array_merge( $data, array( 'url' => self::url( $post_id ), 'path' => $path, 'size' => filesize( $path ), 'filename' => basename( $path ), 'format' => pathinfo( $path, PATHINFO_EXTENSION ) ) ); self::save_verified_meta( $post_id, 'accessibility_audio_tts_file_data', $data ); foreach ( array( 'url', 'path', 'size', 'filename' ) as $key ) { self::save_verified_meta( $post_id, 'accessibility_audio_tts_' . $key, $data[ $key ] ); } if ( $recording ) { Recording_Metadata::synchronize_compatibility( $post_id ); } elseif ( $explicit_recording && class_exists( Recording_Metadata::class ) ) { Recording_Metadata::clear_compatibility( $post_id ); } Privacy_Lifecycle::assert_post_available( $post_id ); } finally { if ( ! $was_busy ) { unset( self::$busy[ $post_id ] ); } } } /** Re-read the database, not a pre-update object cache, before claiming a binding saved. */ private static function save_verified_meta( $post_id, $key, $value ) { global $wpdb; update_post_meta( $post_id, $key, $value ); if ( $wpdb->last_error ) { throw new \RuntimeException( 'The audio file metadata could not be saved. Check the database and retry migration.' ); } wp_cache_delete( $post_id, 'post_meta' ); $actual = get_post_meta( $post_id, $key, true ); if ( $wpdb->last_error || ( is_array( $value ) ? $actual !== $value : (string) $actual !== (string) $value ) ) { throw new \RuntimeException( 'The audio file metadata could not be saved. Check the database and retry migration.' ); } } private static function hold_migration_owner( $owner, &$leases ) { if ( Generation_Lease::current( $owner ) ) { Generation_Lease::checkpoint( $owner ); return; } $lock = Generation_Lease::acquire( $owner ); if ( ! $lock ) { throw new \RuntimeException( 'A shared recording is currently being generated. Retry migration after that task finishes.' ); } $leases[] = array( $lock, $lock->activate() ); } public static function resolve_audio_file( $post_id ) { $post_id = absint( $post_id ); $post = get_post( $post_id ); if ( ! $post ) { return null; } if ( self::is_restricted( $post ) && empty( self::$busy[ $post_id ] ) ) { self::protect_post( $post_id ); } $private = get_post_meta( $post_id, self::PRIVATE_META, true ); if ( is_array( $private ) && isset( $private['relative_path'] ) ) { $path = self::import_destination( $post_id, $private['relative_path'] ); if ( is_file( $path ) && is_readable( $path ) ) { return array( 'path' => $path, 'relative_path' => $private['relative_path'], 'private' => true, 'url' => self::url( $post_id ) ); } return null; } $url = get_post_meta( $post_id, 'accessibility_audio_tts_url', true ); $relative = self::relative_path_for_url( $url ); if ( null === $relative || self::is_restricted( $post ) ) { return null; } $uploads = wp_upload_dir(); $root = realpath( $uploads['basedir'] . '/accessibility-audio-tts' ); $path = realpath( $uploads['basedir'] . '/accessibility-audio-tts/' . $relative ); if ( ! $root || ! $path || ! is_file( $path ) || 0 !== strpos( wp_normalize_path( $path ), wp_normalize_path( $root ) . '/' ) ) { return null; } return array( 'path' => $path, 'relative_path' => $relative, 'private' => false, 'url' => $url ); } private static function verified_move( $source, $destination ) { if ( ! is_file( $source ) || is_link( $source ) ) { throw new \RuntimeException( 'Cannot read the existing audio file.' ); } $hash = hash_file( 'sha256', $source ); if ( ! is_file( $destination ) ) { $stage = $destination . '.' . bin2hex( random_bytes( 8 ) ) . '.part'; try { if ( ! copy( $source, $stage ) || hash_file( 'sha256', $stage ) !== $hash || ! rename( $stage, $destination ) ) { throw new \RuntimeException( 'Cannot preserve audio in private storage.' ); } @chmod( $destination, 0600 ); } finally { if ( is_file( $stage ) ) { wp_delete_file( $stage ); } } } if ( hash_file( 'sha256', $destination ) !== $hash ) { throw new \RuntimeException( 'A different private file already uses this audio name.' ); } if ( wp_normalize_path( $source ) !== wp_normalize_path( $destination ) ) { wp_delete_file( $source ); clearstatcache( true, $source ); if ( file_exists( $source ) ) { throw new \RuntimeException( 'The public audio copy could not be removed; content protection was not changed.' ); } } } /** Preserve every known old copy before password/status protection takes effect. */ public static function protect_post( $post_id, $force = false, $indexed_batch = false ) { $post_id = absint( $post_id ); if ( isset( self::$busy[ $post_id ] ) ) { return; } $post = get_post( $post_id ); if ( ! $post || ( ! $force && ! self::is_restricted( $post ) ) ) { return; } // The cheap no-audio path does not lock or inspect the uploads tree. if ( ! get_post_meta( $post_id, 'accessibility_audio_tts_url', true ) && ! get_post_meta( $post_id, 'accessibility_audio_tts_file_data', true ) && ! get_post_meta( $post_id, Private_Audio_Migration::FILES_META, true ) && ! get_post_meta( $post_id, 'accessibility_audio_tts_generated', true ) ) { return; } self::$busy[ $post_id ] = true; $alias_leases = array(); try { self::hold_migration_owner( $post_id, $alias_leases ); wp_cache_delete( $post_id, 'post_meta' ); $url = get_post_meta( $post_id, 'accessibility_audio_tts_url', true ); $metadata = get_post_meta( $post_id, 'accessibility_audio_tts_file_data', true ); $private = get_post_meta( $post_id, self::PRIVATE_META, true ); $indexed_files = get_post_meta( $post_id, Private_Audio_Migration::FILES_META, true ); $indexed_files = is_array( $indexed_files ) ? $indexed_files : array(); if ( ! $url && is_array( $metadata ) && isset( $metadata['url'] ) && is_string( $metadata['url'] ) ) { $url = $metadata['url']; } $current = self::relative_path_for_url( $url ); if ( null !== $current && ! $indexed_batch && ! Private_Audio_Migration::index_complete() ) { throw new \RuntimeException( __( 'Complete the existing audio migration before changing this recording\'s protection.', 'accessibility-audio-tts-text-to-speech-for-articles' ) ); } if ( $url && null === $current && ! is_array( $private ) ) { throw new \RuntimeException( 'Existing protected audio is not a local plugin file and cannot be secured automatically.' ); } $uploads = wp_upload_dir(); $public_directory = $uploads['basedir'] . '/accessibility-audio-tts'; $public_root = realpath( $public_directory ); $public_url = rtrim( $uploads['baseurl'], '/' ) . '/accessibility-audio-tts/'; if ( null !== $current ) { $indexed_files[] = $current; } $files = array_values( array_unique( $indexed_files ) ); for ( $file_index = 0; $file_index < count( $files ); ++$file_index ) { $relative = $files[ $file_index ]; if ( ! self::valid_relative_path( $relative ) ) { throw new \RuntimeException( 'Cannot migrate an unsupported audio name.' ); } $legacy_url = $relative === $current ? $url : $public_url . $relative; global $wpdb; $aliases = $wpdb->get_col( $wpdb->prepare( "SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = 'accessibility_audio_tts_url' AND meta_value = %s", $legacy_url ) ); if ( $wpdb->last_error ) { throw new \RuntimeException( 'The shared audio owners could not be read. Check the database and retry migration.' ); } $aliases = array_unique( array_map( 'intval', $aliases ) ); sort( $aliases ); foreach ( $aliases as $alias ) { self::hold_migration_owner( $alias, $alias_leases ); } // Discovery precedes acquisition. A worker may publish in that interval: // reload under its lease and retain only aliases still owning these bytes. $owners = array(); foreach ( $aliases as $alias ) { wp_cache_delete( $alias, 'post_meta' ); $alias_url = get_post_meta( $alias, 'accessibility_audio_tts_url', true ); $alias_private = get_post_meta( $alias, self::PRIVATE_META, true ); if ( get_post( $alias ) && $alias_url === $legacy_url && ( ! is_array( $alias_private ) || ( $alias_private['relative_path'] ?? '' ) === $relative ) ) { $owners[] = $alias; } $alias_files = get_post_meta( $alias, Private_Audio_Migration::FILES_META, true ); if ( is_array( $alias_files ) ) { foreach ( $alias_files as $extra ) { if ( ! in_array( $extra, $files, true ) ) { $files[] = $extra; } } } } // A legacy worker can finish during upgrade. Inspect only known owner // filename patterns beside the current recording, never the whole tree. if ( ! $indexed_batch && $relative === $current ) { foreach ( array_unique( array_merge( $aliases, array( $post_id ) ) ) as $alias ) { foreach ( array( 'accessibility_audio_tts_' . $alias . '_*', 'post_' . $alias . '_chunk_*', 'post-' . $alias . '-audio*', 'post_' . $alias . '_audio*', 'audio_' . $alias . '.*' ) as $pattern ) { foreach ( (array) glob( dirname( $public_directory . '/' . $relative ) . '/' . $pattern ) as $extra ) { if ( is_file( $extra ) && ! is_link( $extra ) && in_array( strtolower( pathinfo( $extra, PATHINFO_EXTENSION ) ), array( 'mp3', 'wav', 'ogg' ), true ) ) { $extra = substr( wp_normalize_path( $extra ), strlen( wp_normalize_path( $public_directory ) ) + 1 ); if ( ! in_array( $extra, $files, true ) ) { $files[] = $extra; } } } } } } // Persist the exact path before removal. It survives an unlink crash or // a partial metadata update and lets retries recover remaining aliases. $known = get_post_meta( $post_id, Private_Audio_Migration::FILES_META, true ); $known = array_values( array_unique( array_merge( is_array( $known ) ? $known : array(), array( $relative ) ) ) ); self::save_verified_meta( $post_id, Private_Audio_Migration::FILES_META, $known ); $source = $public_directory . '/' . $relative; $destination = self::import_destination( $post_id, $relative ); if ( is_file( $source ) ) { $real = realpath( $source ); if ( ! $public_root || ! $real || 0 !== strpos( wp_normalize_path( $real ), wp_normalize_path( $public_root ) . '/' ) || is_link( $source ) ) { throw new \RuntimeException( 'Cannot migrate an unsafe audio path.' ); } Generation_Lease::checkpoint( $post_id ); self::verified_move( $real, $destination ); } if ( $owners && ! is_file( $destination ) ) { throw new \RuntimeException( 'The existing audio file is missing from both public and private storage. Restore it before retrying migration.' ); } Generation_Lease::checkpoint( $post_id ); foreach ( $alias_leases as $held ) { if ( ! $held[0]->renew() ) { throw new \RuntimeException( 'A shared recording migration changed owner. Retry migration.' ); } } foreach ( $owners as $alias ) { self::record_private_file( $alias, $relative, $destination ); } } // A preceding metadata failure may already have stored the authoritative // private pointer while leaving older URL/compatibility fields behind. if ( is_array( $private ) && isset( $private['relative_path'] ) ) { $path = self::import_destination( $post_id, $private['relative_path'] ); if ( ! is_file( $path ) ) { throw new \RuntimeException( 'The existing private audio file is missing. Restore it before retrying migration.' ); } self::record_private_file( $post_id, $private['relative_path'], $path ); } $errors = get_option( 'accessibility_audio_tts_private_audio_errors', array() ); if ( isset( $errors[ $post_id ] ) ) { unset( $errors[ $post_id ] ); update_option( 'accessibility_audio_tts_private_audio_errors', $errors, false ); } } finally { foreach ( array_reverse( $alias_leases ) as $held ) { $held[0]->deactivate( $held[1] ); $held[0]->release(); } unset( self::$busy[ $post_id ] ); } } public static function before_post_update( $data, $postarr ) { $id = absint( $postarr['ID'] ?? 0 ); if ( ! $id ) { return $data; } $future = get_post( $id ); if ( ! $future ) { return $data; } $future = clone $future; $future->post_status = $data['post_status']; $future->post_type = $data['post_type']; $future->post_password = wp_unslash( $data['post_password'] ); if ( self::is_restricted( $future ) ) { try { self::protect_post( $id, true ); } catch ( \Throwable $error ) { self::remember_error( $id, $error ); wp_die( esc_html( $error->getMessage() ), esc_html__( 'Audio protection failed', 'accessibility-audio-tts-text-to-speech-for-articles' ), array( 'response' => 500 ) ); } } return $data; } public static function post_updated( $post_id ) { try { self::protect_post( $post_id ); } catch ( \Throwable $error ) { self::remember_error( $post_id, $error ); } } public static function metadata_updated( $meta_id, $post_id, $key, $value ) { if ( in_array( $key, array( 'accessibility_audio_tts_url', 'accessibility_audio_tts_file_data' ), true ) ) { self::post_updated( $post_id ); } } public static function migrate_restricted_audio( $force = false ) { return Private_Audio_Migration::run_batch( (bool) $force ); } public static function migration_status() { return Private_Audio_Migration::status(); } public static function remember_migration_error( $post_id, $error ) { self::remember_error( $post_id, $error ); } public static function admin_migration_batch() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You cannot manage audio migration.', 'accessibility-audio-tts-text-to-speech-for-articles' ), '', array( 'response' => 403 ) ); } check_admin_referer( 'accessibility_audio_tts_migration_batch' ); Private_Audio_Migration::run_batch( true ); wp_safe_redirect( admin_url( 'admin.php?page=accessibility-audio-tts' ) ); exit; } private static function remember_error( $post_id, $error ) { $errors = get_option( 'accessibility_audio_tts_private_audio_errors', array() ); $errors[ absint( $post_id ) ] = $error->getMessage(); update_option( 'accessibility_audio_tts_private_audio_errors', $errors, false ); } public static function notice() { if ( ! current_user_can( 'manage_options' ) ) { return; } $status = self::migration_status(); if ( ! $status['complete'] ) { $url = wp_nonce_url( admin_url( 'admin-post.php?action=accessibility_audio_tts_migration_batch' ), 'accessibility_audio_tts_migration_batch' ); echo '
' . esc_html( $status['message'] ) . '
' . esc_html( $status['error'] ) . '
' . esc_html__( 'Some existing recordings could not be moved to private storage. Their old public URLs may still be accessible. Configure ACCESSIBILITY_AUDIO_TTS_PRIVATE_DIR outside the web root and retry before publishing protected content.', 'accessibility-audio-tts-text-to-speech-for-articles' ) . '