diff --git a/migrations/container/20261023233000_site-command_recheck_session_upload_progress.php b/migrations/container/20261023233000_site-command_recheck_session_upload_progress.php new file mode 100644 index 00000000..b0874e5e --- /dev/null +++ b/migrations/container/20261023233000_site-command_recheck_session_upload_progress.php @@ -0,0 +1,404 @@ + [ 'site' => Site, 'file' => string, 'dir' => string, 'backup' => string, 'written' => bool ] for every custom.ini changed by up(). */ + private $changed = []; + + /** @var array site_url => php container ID seen by up(), for running sites. */ + private $containers = []; + + /** @var string|null */ + private $backup_root; + + public function __construct() { + + parent::__construct(); + if ( $this->is_first_execution ) { + $this->skip_this_migration = true; + + return; + } + + $this->sites = array_filter( + Site::all() ?: [], + function ( $site ) { + return in_array( $site->site_type, self::SITE_TYPES, true ); + } + ); + if ( empty( $this->sites ) ) { + $this->skip_this_migration = true; + } + } + + /** + * Add the setting to every custom.ini that doesn't set it globally. + */ + public function up() { + + if ( $this->skip_this_migration ) { + EE::debug( 'Skipping recheck-session-upload-progress migration as it is not needed.' ); + + return; + } + + $this->backup_root = EE_BACKUP_DIR . '/session-upload-progress-recheck-' . date( 'Ymd-His' ); + if ( file_exists( $this->backup_root ) ) { + $this->backup_root .= '-' . getmypid(); + } + + foreach ( $this->sites as $site ) { + // One broken site must not block the upgrade of the others. + try { + $this->update_site( $site ); + } catch ( \Throwable $e ) { + if ( isset( $this->changed[ $site->site_url ] ) ) { + $this->restore_file( $site->site_url ); + } + unset( $this->containers[ $site->site_url ] ); + EE::warning( "Could not check session.upload_progress in the custom.ini of $site->site_url: {$e->getMessage()}" ); + } + } + + if ( empty( $this->changed ) ) { + $this->remove_empty_backup_root(); + + return; + } + + $written = array_filter( array_column( $this->changed, 'written' ) ); + if ( ! $written ) { + return; + } + EE::log( 'Disabled session.upload_progress in custom.ini of ' . count( $written ) . ' more site(s) where it was not set globally. The original files are in ' . $this->backup_root ); + + // The image migration may recreate php containers, which then read the new file anyway; reload only the others, once it is done. + if ( method_exists( '\EE\Migration\CustomContainerMigrations', 'keep_executed_migrations' ) ) { + EE::add_hook( 'after_docker_image_migration', [ $this, 'reload_unchanged_containers' ] ); + } else { + $this->reload_unchanged_containers(); + } + } + + /** + * Restore the original custom.ini files and reload the running sites. + */ + public function down() { + + if ( empty( $this->changed ) ) { + return; + } + + $sites = array_column( $this->changed, 'site' ); + $this->restore_files(); + + // A rolled-back image migration may have restarted them on the new file. + foreach ( $sites as $site ) { + if ( $site->site_enabled ) { + $id = $this->get_php_container( $site ); + if ( null !== $id && '' !== $id ) { + $this->reload_php( $site->site_url, $id ); + } + } + } + } + + /** + * Gracefully reload php-fpm of the sites whose php container is still the one up() saw. + */ + public function reload_unchanged_containers() { + + foreach ( $this->containers as $site_url => $id ) { + if ( ! isset( $this->changed[ $site_url ] ) ) { + continue; + } + $now = $this->get_php_container( $this->changed[ $site_url ]['site'] ); + if ( $now !== $id ) { + if ( null !== $now ) { + EE::debug( "$site_url: php container was recreated or stopped, no reload needed." ); + } + continue; + } + $this->reload_php( $site_url, $id ); + } + $this->containers = []; + } + + /** + * Whether PHP reads an ini value as true, like zend_ini_parse_bool(), which falls back to atoi(). + * + * @param string $value Value as the ini scanner gives it. + * + * @return bool + */ + public static function is_on( string $value ) { + + return in_array( strtolower( $value ), [ 'true', 'yes', 'on' ], true ) || 1 === preg_match( '/^\s*[+-]?0*[1-9]/', $value ); + } + + /** + * Add the setting to one site's custom.ini. Problems with one site are warnings, so they don't block the upgrade. + * + * @param Site $site Site to check. + * + * @throws \Exception + */ + private function update_site( $site ) { + + $file = $site->site_fs_path . '/config/php/php/conf.d/custom.ini'; + $manual = "Add 'session.upload_progress.enabled = Off' to the top of $file and run: ee site reload $site->site_url"; + + // Not created when missing: PHP can't create a file safely in a directory the site's container can swap. The v4.13.3 migration created it wherever conf.d existed. + clearstatcache( true ); + if ( is_link( $file ) || ! is_file( $file ) ) { + EE::warning( "Skipped disabling session.upload_progress for $site->site_url: custom.ini is missing or not a regular file. $manual" ); + + return; + } + + // Read-only first: a file that needs no change may be read-only or hard-linked. + $dir = site_php_conf_dir( $site->site_fs_path ); + $handle = open_container_file( $file, 'r', $dir ); + try { + $content = read_container_file( $handle, self::MAX_SIZE ); + } finally { + fclose( $handle ); + } + + $new_content = add_session_upload_progress_off( $content ); + if ( null === $new_content ) { + $value = ini_global_value( preg_replace( '/^\xEF\xBB\xBF/', '', $content ), self::DIRECTIVE ); + if ( null !== $value && self::is_on( $value ) ) { + EE::log( "Kept session.upload_progress On for $site->site_url: its custom.ini turns it on. Set it to Off there unless your app needs it." ); + } else { + EE::debug( "$site->site_url: custom.ini already turns off session.upload_progress." ); + } + + return; + } + + if ( ! ini_sets_globally( preg_replace( '/^\xEF\xBB\xBF/', '', $new_content ), self::DIRECTIVE ) ) { + EE::warning( "Skipped disabling session.upload_progress for $site->site_url: could not add it to custom.ini safely. $manual" ); + + return; + } + + $handle = open_container_file( $file, 'r+', $dir ); + try { + if ( read_container_file( $handle, self::MAX_SIZE ) !== $content ) { + throw new \Exception( 'custom.ini changed while it was checked' ); + } + $written = $this->write_ini( $site, $handle, $file, $dir, $content, $new_content, $manual ); + } finally { + fclose( $handle ); + } + + if ( ! $written ) { + return; + } + + $id = $site->site_enabled ? $this->get_php_container( $site ) : ''; + if ( null !== $id && '' !== $id ) { + $this->containers[ $site->site_url ] = $id; + } + EE::debug( "$site->site_url: disabled session.upload_progress in custom.ini." ); + } + + /** + * Back up and rewrite custom.ini through the handle from open_container_file(). + * + * @param Site $site Site. + * @param resource $handle custom.ini handle. + * @param string $file custom.ini path. + * @param string $dir Real path of conf.d. + * @param string $content Current content. + * @param string $new_content New content. + * @param string $manual Manual fix, for warnings. + * + * @return bool Whether the file now holds the new content. + */ + private function write_ini( $site, $handle, string $file, string $dir, string $content, string $new_content, string $manual ) { + + $stat = fstat( $handle ); + $backup = $this->backup_root . '/' . $site->site_url . '/custom.ini'; + try { + $this->fs->mkdir( dirname( $backup ), 0700 ); + $this->fs->dumpFile( $backup, $content ); + $this->fs->chmod( $backup, $stat['mode'] & 0777 ); + $this->fs->touch( $backup, $stat['mtime'] ); + } catch ( \Throwable $e ) { + EE::warning( "Skipped disabling session.upload_progress for $site->site_url: could not back up custom.ini ({$e->getMessage()}). $manual" ); + + return false; + } + + $entry = [ + 'site' => $site, + 'file' => $file, + 'dir' => $dir, + 'backup' => $backup, + 'written' => true, + ]; + + try { + rewrite_container_file( $handle, $new_content ); + } catch ( \Exception $e ) { + EE::warning( "Could not disable session.upload_progress for $site->site_url: could not write custom.ini. $manual" ); + try { + rewrite_container_file( $handle, $content ); + } catch ( \Exception $e2 ) { + // Left for down() to retry from the backup. + $entry['written'] = false; + $this->changed[ $site->site_url ] = $entry; + EE::warning( "Could not restore $file; its original is $backup" ); + + return false; + } + $this->remove_backup( $backup ); + + return false; + } + + $this->changed[ $site->site_url ] = $entry; + + return true; + } + + /** + * Restore every file changed by up(). + */ + private function restore_files() { + + foreach ( array_keys( $this->changed ) as $site_url ) { + $this->restore_file( $site_url ); + } + $this->containers = []; + $this->remove_empty_backup_root(); + } + + /** + * Restore one file changed by up(); its backup is removed once restored. Never throws. + * + * @param string $site_url Site. + */ + private function restore_file( string $site_url ) { + + $entry = $this->changed[ $site_url ]; + unset( $this->changed[ $site_url ] ); + + try { + $content = @file_get_contents( $entry['backup'] ); + if ( false === $content ) { + throw new \Exception( 'could not read the backup' ); + } + $handle = open_container_file( $entry['file'], 'r+', $entry['dir'] ); + try { + // Skip the write when it never happened; read no more than needed to tell. + if ( stream_get_contents( $handle, strlen( $content ) + 1 ) !== $content ) { + rewrite_container_file( $handle, $content ); + } + } finally { + fclose( $handle ); + } + } catch ( \Throwable $e ) { + EE::warning( "Could not restore {$entry['file']} ({$e->getMessage()}). Its original is {$entry['backup']}" ); + + return; + } + $this->remove_backup( $entry['backup'] ); + EE::debug( "$site_url: restored custom.ini." ); + } + + /** + * @param string|null $backup Backup file; its directory is removed. + */ + private function remove_backup( $backup ) { + + if ( null === $backup ) { + return; + } + try { + $this->fs->remove( dirname( $backup ) ); + } catch ( \Throwable $e ) { + EE::debug( "Could not remove $backup: {$e->getMessage()}" ); + } + } + + private function remove_empty_backup_root() { + + try { + if ( ! empty( $this->backup_root ) && is_dir( $this->backup_root ) && ! ( new \FilesystemIterator( $this->backup_root ) )->valid() ) { + $this->fs->remove( $this->backup_root ); + } + } catch ( \Throwable $e ) { + EE::debug( "Could not remove the empty $this->backup_root: {$e->getMessage()}" ); + } + } + + /** + * @param Site $site Site. + * + * @return string|null ID of the site's running php container, '' when none runs, null when the lookup failed (already warned). + */ + private function get_php_container( $site ) { + + $cwd = getcwd(); + $result = @chdir( $site->site_fs_path ) ? EE::launch( \EE_DOCKER::docker_compose_with_custom() . ' ps -q php' ) : null; + if ( false !== $cwd ) { + chdir( $cwd ); + } + + if ( null === $result || 0 !== $result->return_code ) { + EE::warning( "Could not check the php container of $site->site_url, so php-fpm may still run with the old session.upload_progress setting. Fix the error, then run: ee site reload $site->site_url" ); + + return null; + } + + return trim( $result->stdout ); + } + + /** + * Same graceful reload as `ee site reload`: php-fpm re-reads its ini files. + * + * @param string $site_url Site. + * @param string $id php container ID. + */ + private function reload_php( string $site_url, string $id ) { + + $result = EE::launch( 'docker exec --user root ' . escapeshellarg( $id ) . " sh -c 'kill -USR2 1'" ); + if ( 0 === $result->return_code ) { + EE::debug( "$site_url: reloaded php-fpm." ); + } else { + EE::warning( "Could not reload php-fpm of $site_url. Run: ee site reload $site_url" ); + } + } +} diff --git a/src/helper/Site_Backup_Restore.php b/src/helper/Site_Backup_Restore.php index d2620cc4..639e7ba0 100644 --- a/src/helper/Site_Backup_Restore.php +++ b/src/helper/Site_Backup_Restore.php @@ -1693,6 +1693,63 @@ private function restore_php_conf( $backup_dir ) { $restore_command = sprintf( 'rsync -a %s/php/php/conf.d/custom.ini %s/config/php/php/conf.d/custom.ini', $backup_dir, EE_ROOT_DIR . '/sites/' . $this->site_data['site_url'] ); EE::exec( $restore_command ); } + + $this->keep_session_upload_progress_off(); + } + + /** + * A backup taken before v4.13.3 brings back a custom.ini without `session.upload_progress.enabled = Off`, so add it + * again unless the restored file sets the directive itself. The site reload at the end of the restore applies it. + */ + private function keep_session_upload_progress_off() { + $site_url = $this->site_data['site_url']; + $file = EE_ROOT_DIR . '/sites/' . $site_url . '/config/php/php/conf.d/custom.ini'; + $manual = "Add 'session.upload_progress.enabled = Off' to $file and run: ee site reload $site_url"; + + // Not created when missing: PHP can't create a file safely in a directory the site's container can swap. + if ( is_link( $file ) || ! is_file( $file ) ) { + EE::warning( "Could not check session.upload_progress in the restored custom.ini: it is missing or not a regular file. $manual" ); + + return; + } + + // The directory is in the site's config volume, which its php container can change: every read and write goes through a checked handle. + try { + $dir = \EE\Site\Utils\site_php_conf_dir( EE_ROOT_DIR . '/sites/' . $site_url ); + $handle = \EE\Site\Utils\open_container_file( $file, 'r', $dir ); + try { + $content = \EE\Site\Utils\read_container_file( $handle ); + } finally { + fclose( $handle ); + } + + $new_content = \EE\Site\Utils\add_session_upload_progress_off( $content ); + if ( null === $new_content ) { + return; + } + + $handle = \EE\Site\Utils\open_container_file( $file, 'r+', $dir ); + try { + if ( \EE\Site\Utils\read_container_file( $handle ) !== $content ) { + throw new \Exception( 'it changed while it was checked' ); + } + try { + \EE\Site\Utils\rewrite_container_file( $handle, $new_content ); + } catch ( \Exception $e ) { + // A short write leaves part of the file; put the restored one back. + \EE\Site\Utils\rewrite_container_file( $handle, $content ); + throw $e; + } + } finally { + fclose( $handle ); + } + } catch ( \Exception $e ) { + EE::warning( "Could not turn off session.upload_progress in the restored custom.ini ({$e->getMessage()}). $manual" ); + + return; + } + + EE::log( 'The restored custom.ini did not set session.upload_progress.enabled, so it was set to Off again. Set it to On there only if your app needs it.' ); } /** diff --git a/src/helper/site-utils.php b/src/helper/site-utils.php index a4766b19..69abeb45 100644 --- a/src/helper/site-utils.php +++ b/src/helper/site-utils.php @@ -1623,3 +1623,244 @@ function get_wp_core_download_command( string $path, array $args = [] ) { // docker_compose_exec() wraps the command in bash -c "...". return addcslashes( $script, '"$`\\' ); } + +/** + * Add `session.upload_progress.enabled = Off` to a site's custom.ini content, unless the content already sets it globally. + * + * Same rule as the v4.13.3 upload-progress migrations: an effective global line, On or Off, is the user's choice and is kept. + * + * @param string $content custom.ini content. + * + * @return string|null The new content, or null when it already sets the directive. + */ +function add_session_upload_progress_off( string $content ) { + + $bom = ''; + if ( 0 === strpos( $content, "\xEF\xBB\xBF" ) ) { + $bom = "\xEF\xBB\xBF"; + $content = substr( $content, 3 ); + } + + if ( ini_sets_globally( $content, 'session.upload_progress.enabled' ) ) { + return null; + } + + $eol = false !== strpos( $content, "\r\n" ) ? "\r\n" : ( false === strpos( $content, "\n" ) && false !== strpos( $content, "\r" ) ? "\r" : "\n" ); + $lines = '; Session upload progress is off for security; set it to On only if your app needs it.' . $eol . 'session.upload_progress.enabled = Off' . $eol; + + if ( '' === trim( $content ) ) { + return $bom . $lines; + } + + // At the very top, so neither a [PATH=]/[HOST=] section nor a multi-line quoted value can capture it. + return $bom . $lines . $eol . $content; +} + +/** + * Whether ini content sets a directive outside any [PATH=]/[HOST=] section, read the way PHP reads php.ini. + * + * @param string $content ini content without BOM. + * @param string $key Directive name (case-sensitive, like PHP). + * + * @return bool + */ +function ini_sets_globally( string $content, string $key ) { + + return null !== ini_global_value( $content, $key ); +} + +/** + * The value a directive gets outside any [PATH=]/[HOST=] section, read the way PHP reads php.ini. + * + * @param string $content ini content without BOM. + * @param string $key Directive name (case-sensitive, like PHP). + * + * @return string|null The value as PHP's ini scanner gives it (On is "1", Off is ""), or null when it isn't set there. + */ +function ini_global_value( string $content, string $key ) { + + $global = ini_global_part( $content ); + // Without sections, entries of ordinary sections are global and `foo[key] = x` stays an array, so it can't match. + $ini = @parse_ini_string( $global, false, INI_SCANNER_NORMAL ); + // PHP applies the lines above a syntax error, so only that part counts. + for ( $lines = preg_split( '/(?<=\n)|(?<=\r)(?!\n)/', $global ), $n = count( $lines ) - 1; false === $ini && $n > 0; $n-- ) { + $ini = @parse_ini_string( implode( '', array_slice( $lines, 0, $n ) ), false, INI_SCANNER_NORMAL ); + } + + return false !== $ini && array_key_exists( $key, $ini ) && ! is_array( $ini[ $key ] ) ? (string) $ini[ $key ] : null; +} + +/** + * The part of ini content before the first section named PATH or HOST plus at least one more character. + * + * PHP scopes every entry after such a header to it, even under later ordinary headers. The parser decides where each + * header ends and what it is called, so any line ending, quoted names and several headers on one line are handled. + * + * @param string $content ini content without BOM. + * + * @return string + */ +function ini_global_part( string $content ) { + + $probe = '__ee_ini_probe_' . md5( $content ); + for ( $open = strpos( $content, '[' ); false !== $open; $open = strpos( $content, '[', $open + 1 ) ) { + // Only where the parser expects a new entry can '[' start a header: not in a quoted value, a comment or a value. + $probed = @parse_ini_string( substr( $content, 0, $open ) . $probe . ' = 1', true, INI_SCANNER_NORMAL ); + if ( false === $probed || ! array_key_exists( $probe, $probed ) && ! array_filter( $probed, function ( $entry ) use ( $probe ) { + return is_array( $entry ) && array_key_exists( $probe, $entry ); + } ) ) { + continue; + } + // A quoted name may contain ']', so try each one until the text up to it parses. + for ( $close = strpos( $content, ']', $open ); false !== $close; $close = strpos( $content, ']', $close + 1 ) ) { + if ( false === @parse_ini_string( substr( $content, 0, $close + 1 ), true, INI_SCANNER_NORMAL ) ) { + continue; + } + $header = @parse_ini_string( substr( $content, $open, $close - $open + 1 ), true, INI_SCANNER_NORMAL ); + // PHP scopes only a name with something after PATH or HOST; entries under a bare [PATH] stay global. + if ( is_array( $header ) && 1 === count( $header ) && preg_match( '/^(PATH|HOST)./is', (string) key( $header ) ) ) { + return substr( $content, 0, $open ); + } + break; + } + } + + return $content; +} + +/** + * Real path of a site's conf.d. config/php is EasyEngine's own link to the site's config volume; everything below it belongs to the site's php container. + * + * @param string $site_fs_path Site root. + * + * @return string + * @throws \Exception + */ +function site_php_conf_dir( string $site_fs_path ) { + + $base = realpath( $site_fs_path . '/config/php' ); + if ( false === $base ) { + throw new \Exception( 'could not resolve config/php' ); + } + + return $base . '/php/conf.d'; +} + +/** + * Open a file in a directory the site's php container can change, as a handle for every later read and write, so nothing swapped in at that path is followed afterwards. + * + * Host root must not write through a symlink, a hard link or a swapped directory, so the handle has to be the regular file in $dir. On Linux the kernel's own path of the open file decides; elsewhere the path is checked before and after opening. + * + * @param string $file Path. + * @param string $mode 'r' or 'r+'. Nothing is created: PHP expands every path it creates or links to an absolute one, so it can't be confined to a directory the container can swap. + * @param string $dir Real path of the directory the file must be in. + * + * @return resource + * @throws \Exception + */ +function open_container_file( string $file, string $mode, string $dir ) { + + if ( 'r' !== $mode && 'r+' !== $mode ) { + throw new \InvalidArgumentException( "Unsupported mode $mode." ); + } + + $name = basename( $file ); + clearstatcache( true ); + if ( realpath( dirname( $file ) ) !== $dir ) { + throw new \Exception( "a directory above $name is a symlink" ); + } + + $handle = @fopen( $file, $mode ); + if ( false === $handle ) { + throw new \Exception( "could not open $name" ); + } + + $opened = fstat( $handle ); + clearstatcache( true ); + $linked = @lstat( $file ); + $real = opened_file_path( $handle ); + $error = null; + if ( ! $opened || ! $linked || $opened['dev'] !== $linked['dev'] || $opened['ino'] !== $linked['ino'] || 0100000 !== ( $linked['mode'] & 0170000 ) ) { + $error = "$name is not a regular file"; + } elseif ( realpath( dirname( $file ) ) !== $dir || ( null !== $real && "$dir/$name" !== $real ) ) { + $error = "a directory above $name is a symlink"; + } elseif ( 'r' !== $mode && 1 !== $opened['nlink'] ) { + $error = "$name has other hard links"; + } + if ( null !== $error ) { + fclose( $handle ); + throw new \Exception( $error ); + } + + return $handle; +} + +/** + * The kernel's path of an open file, from /proc/self/fd, which no later swap of a directory changes. + * + * @param resource $handle Open file. + * + * @return string|null Null without /proc (macOS); '' when the file isn't found there. + */ +function opened_file_path( $handle ) { + + $want = fstat( $handle ); + $fds = $want ? @scandir( '/proc/self/fd' ) : false; + if ( ! $fds ) { + return null; + } + + clearstatcache( true ); + foreach ( $fds as $fd ) { + if ( ! preg_match( '/^\d+$/', $fd ) ) { + continue; + } + $stat = @stat( "/proc/self/fd/$fd" ); + if ( $stat && $stat['dev'] === $want['dev'] && $stat['ino'] === $want['ino'] ) { + $path = @readlink( "/proc/self/fd/$fd" ); + + return false === $path ? '' : $path; + } + } + + return ''; +} + +/** + * Overwrite an open file through its handle: owner, mode and inode stay (macOS bind-mounts single config files). + * + * @param resource $handle Handle from open_container_file(). + * @param string $content New content. + * + * @throws \Exception When the file doesn't hold exactly $content afterwards. + */ +function rewrite_container_file( $handle, string $content ) { + + // The read-back stops one byte past the content, so data the container appends can't make it unbounded. + if ( ! rewind( $handle ) || strlen( $content ) !== fwrite( $handle, $content ) || ! fflush( $handle ) || ! ftruncate( $handle, strlen( $content ) ) || ! rewind( $handle ) || stream_get_contents( $handle, strlen( $content ) + 1 ) !== $content ) { + throw new \Exception( 'could not write ' . basename( stream_get_meta_data( $handle )['uri'] ) ); + } +} + +/** + * Read an open file, at most $max bytes, so a file the container keeps growing can't exhaust memory. + * + * @param resource $handle Handle from open_container_file(). + * @param int $max Largest size accepted. + * + * @return string + * @throws \Exception When it can't be read or is larger. + */ +function read_container_file( $handle, int $max = 65536 ) { + + $name = basename( stream_get_meta_data( $handle )['uri'] ); + $content = stream_get_contents( $handle, $max + 1 ); + if ( false === $content ) { + throw new \Exception( "could not read $name" ); + } + if ( strlen( $content ) > $max ) { + throw new \Exception( "$name is larger than $max bytes" ); + } + + return $content; +}