self::MAX_CIPHER_BYTES ) { return self::error(); } $table = Exports::tables()['chunks']; $length = strlen( $encrypted ); $part = 0; for ( $offset = 0; $offset < $length; $offset += self::PART_BYTES, ++$part ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Exact owner-bound ciphertext is authoritative custom storage; the caller owns its job mutex. $saved = $wpdb->query( $wpdb->prepare( 'INSERT INTO %i (job_id,sequence,part,payload) VALUES (%s,%d,%d,%s) ON DUPLICATE KEY UPDATE payload=VALUES(payload)', $table, $job, $sequence, $part, substr( $encrypted, $offset, self::PART_BYTES ) ) ); if ( false === $saved ) { return self::error(); } } // A restarted write can be shorter; remove only obsolete parts in this exact sequence. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Job-mutex protected authoritative ciphertext replacement. return false === $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE job_id=%s AND sequence=%d AND part>=%d', $table, $job, $sequence, $part ) ) ? self::error() : true; } /** Read a bounded ciphertext sequence; null permits the read-only legacy fallback. */ public static function read( string $job, int $sequence ): string|null|\WP_Error { global $wpdb; if ( ! self::identity( $job, $sequence ) ) { return self::error(); } // At most 33 parts can represent one bounded chunk. A 34th is rejected. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Never cache ciphertext across job cancellation or privacy cleanup. $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT part,CASE WHEN OCTET_LENGTH(payload)<=262144 THEN payload ELSE NULL END AS payload FROM %i WHERE job_id=%s AND sequence=%d ORDER BY part LIMIT 34', Exports::tables()['chunks'], $job, $sequence ), ARRAY_A ); if ( '' !== $wpdb->last_error || ! is_array( $rows ) || count( $rows ) > 33 ) { return self::error(); } if ( array() === $rows ) { return null; } $encrypted = ''; $count = count( $rows ); foreach ( $rows as $part => $row ) { if ( (string) $part !== (string) $row['part'] || ! is_string( $row['payload'] ) || '' === $row['payload'] || strlen( $row['payload'] ) > self::PART_BYTES || ( $part + 1 < $count && strlen( $row['payload'] ) !== self::PART_BYTES ) ) { return self::error(); } $encrypted .= $row['payload']; } return strlen( $encrypted ) < 40 || strlen( $encrypted ) > self::MAX_CIPHER_BYTES ? self::error() : $encrypted; } /** Bounded deletion retains job metadata until every ciphertext part is gone. */ public static function remove_chunks( string $job ): bool|\WP_Error { global $wpdb; if ( ! self::identity( $job, 0 ) ) { return self::error(); } $table = Exports::tables()['chunks']; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Caller holds the cleanup job mutex; remove only its bounded ciphertext parts. if ( false === $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE job_id=%s LIMIT 100', $table, $job ) ) ) { return self::error(); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Actual absence is required before recording private-data cleanup completion. $left = $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE job_id=%s', $table, $job ) ); return null === $left || '' !== $wpdb->last_error ? self::error() : 0 === (int) $left; } /** Validate the finite storage identity independently of the caller. */ private static function identity( string $job, int $sequence ): bool { return $sequence >= 0 && $sequence <= 1000000 && 1 === preg_match( '/\A[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\z/D', $job ); } /** Return a fixed public error, without exposing the host's storage. */ private static function error(): \WP_Error { return new \WP_Error( 'storage', 'Protected export storage is unavailable. Download now does not use saved export storage.', array( 'status' => 503 ) ); } /** Resolve the installation's uploads base; legacy storage is read/cleanup only. */ public static function base( bool $legacy = false ): string|\WP_Error { if ( $legacy ) { $base = defined( 'LOGGED_AI_PRIVATE_DIR' ) ? LOGGED_AI_PRIVATE_DIR : WP_CONTENT_DIR . '/.logged-ai-private'; } else { // Export tables are installation-wide. Use the main site's uploads on multisite, // including when a job's original site has since been removed. $main = is_multisite() ? (int) get_main_site_id( get_main_network_id() ) : get_current_blog_id(); $switched = $main !== get_current_blog_id(); $network_switched = is_multisite() && get_current_network_id() !== get_main_network_id(); $original_network = $GLOBALS['current_site'] ?? null; $main_network = $network_switched ? get_network( get_main_network_id() ) : null; if ( $main < 1 || ( $network_switched && ! $main_network instanceof \WP_Network ) ) { return self::error(); } // switch_to_blog alone leaves the originating network in place. Core uploads // uses both contexts, so resolve the installation main site's actual network. if ( $network_switched ) { $GLOBALS['current_site'] = $main_network; } try { if ( $switched ) { switch_to_blog( $main ); } $uploads = wp_upload_dir( null, false, true ); } finally { if ( $network_switched ) { $GLOBALS['current_site'] = $original_network; } try { if ( $network_switched ) { // Core caches uploads by blog/time, without a network key. Restore // its ordinary result in the originating network before returning. wp_upload_dir( null, false, true ); } } finally { if ( $switched ) { restore_current_blog(); } } } if ( ! empty( $uploads['error'] ) || ! is_string( $uploads['basedir'] ?? null ) ) { return self::error(); } $base = rtrim( $uploads['basedir'], '/' ) . '/logged-ai'; } if ( ! is_string( $base ) || '' === $base || '/' !== $base[0] || str_contains( $base, "\0" ) || in_array( '..', explode( '/', $base ), true ) ) { return self::error(); } $path = ''; foreach ( explode( '/', $base ) as $part ) { if ( '' === $part ) { continue; } $path .= '/' . $part; if ( is_link( $path ) ) { return self::error(); } } return rtrim( $base, '/' ); } /** Resolve an owned directory. Missing read/cleanup paths return an empty string. */ public static function directory( ?string $job = null, bool $create = false, bool $legacy = false ): string|\WP_Error { if ( $create ) { return self::error(); } if ( null !== $job && ! preg_match( '/\A[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\z/D', $job ) ) { return self::error(); } $base = self::base( $legacy ); if ( is_wp_error( $base ) ) { return $base; } $paths = array( $base, $base . '/exports' ); if ( null !== $job ) { $paths[] = $base . '/exports/' . $job; } foreach ( $paths as $path ) { if ( is_link( $path ) ) { return self::error(); } if ( ! file_exists( $path ) ) { return ''; } clearstatcache( true, $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- Exclusive atomic ciphertext writes require the local filesystem, not a remote WordPress filesystem transport. if ( ! is_dir( $path ) || ( fileperms( $path ) & 0077 ) || ! is_readable( $path ) || ! is_writable( $path ) ) { return self::error(); } } return $paths[ count( $paths ) - 1 ]; } /** Remove only bounded, recognized ciphertext files; unexpected entries remain. */ public static function remove_job( string $job, int $limit = 100 ): bool|\WP_Error { if ( 'Windows' === PHP_OS_FAMILY ) { return true; } $complete = true; $removed = 0; foreach ( array( false, true ) as $legacy ) { $directory = self::directory( $job, false, $legacy ); if ( is_wp_error( $directory ) ) { return $directory; } if ( '' === $directory ) { continue; } try { $entries = new \DirectoryIterator( $directory ); } catch ( \UnexpectedValueException $error ) { return self::error(); } foreach ( $entries as $file ) { if ( $file->isDot() ) { continue; } if ( ++$removed > $limit ) { $complete = false; break; } if ( $file->isLink() || ! $file->isFile() || ! preg_match( '/\A(?:[0-9]{8}|tmp-[0-9a-f]{32})\.(?:bin|php)\z/D', $file->getFilename() ) ) { return self::error(); } // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink, WordPress.PHP.NoSilencedErrors.Discouraged -- The caller holds the job or uninstall mutation lock; remove only regular ciphertext with finite owned names. Failure preserves metadata for retry. if ( ! @unlink( $file->getPathname() ) ) { return self::error(); } } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir, WordPress.PHP.NoSilencedErrors.Discouraged -- Only this validated UUID child is removed; a non-empty directory keeps cleanup pending. if ( ! @rmdir( $directory ) ) { $complete = false; } } return $complete; } /** Explicit installation-wide uninstall, retaining all unexpected files and ancestors. */ public static function uninstall(): bool|\WP_Error { if ( 'Windows' === PHP_OS_FAMILY ) { return true; } foreach ( array( false, true ) as $legacy ) { $directory = self::directory( null, false, $legacy ); if ( is_wp_error( $directory ) ) { return $directory; } if ( '' === $directory ) { continue; } try { $entries = new \DirectoryIterator( $directory ); } catch ( \UnexpectedValueException $error ) { return self::error(); } foreach ( $entries as $job ) { if ( $job->isDot() ) { continue; } if ( $job->isLink() || ! $job->isDir() ) { return self::error(); } $result = self::remove_job( $job->getFilename(), PHP_INT_MAX ); if ( true !== $result ) { return is_wp_error( $result ) ? $result : self::error(); } } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir, WordPress.PHP.NoSilencedErrors.Discouraged -- Explicit uninstall removes only the empty owned exports child, preserving uploads and configured ancestors. if ( ! @rmdir( $directory ) ) { return self::error(); } } return true; } }