diff --git a/.github/workflows/test_and_build.yml b/.github/workflows/test_and_build.yml index f8fce960..4c562a1e 100644 --- a/.github/workflows/test_and_build.yml +++ b/.github/workflows/test_and_build.yml @@ -12,6 +12,8 @@ jobs: test: #---------------------------------------------------------------------- runs-on: ubuntu-latest name: Behat Tests - PHP ${{ matrix.php }} + env: + RCLONE_CONFIG: /etc/rclone/rclone.conf strategy: fail-fast: false matrix: @@ -57,6 +59,14 @@ jobs: sudo curl -L "https://github.com/docker/compose/releases/download/$VERSION/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose sudo chmod +x /usr/local/bin/docker-compose + - name: Set up rclone for backup and restore + run: | + sudo apt-get update -q + sudo apt-get install -y -q rclone + sudo mkdir -p "$(dirname "$RCLONE_CONFIG")" /var/tmp/ee-backups + # EasyEngine's default rclone-path is easyengine:easyengine. + printf '[easyengine]\ntype = alias\nremote = /var/tmp/ee-backups\n' | sudo tee "$RCLONE_CONFIG" >/dev/null + - name: Install dependencies run: | cd "$GITHUB_WORKSPACE/.." @@ -69,6 +79,8 @@ jobs: composer install --prefer-dist --no-progress --no-interaction else sed -i 's/\(easyengine\/.*\):\ \".*\"/\1:\ \"dev-develop\"/' composer.json + # Test this checkout of site-command, not its develop branch. + composer config repositories.site-command '{"type": "path", "url": "'"$GITHUB_WORKSPACE"'", "options": {"symlink": false, "versions": {"easyengine/site-command": "dev-develop"}}}' composer update --prefer-dist --no-progress --no-interaction fi composer dump-autoload --no-dev diff --git a/features/upload-progress.feature b/features/upload-progress.feature new file mode 100644 index 00000000..0ab45c68 --- /dev/null +++ b/features/upload-progress.feature @@ -0,0 +1,170 @@ +@remote @destructive +Feature: Session upload progress stays off + Re-runs the upgrade migrations on the host (destructive) and backs up to the rclone remote from config.yml. + Those steps run only on CI or with EE_BEHAT_DESTRUCTIVE=1. + + # "STDOUT should return exactly" checks a scenario's first command, so each check is its own scenario. + # The scenarios share their sites and run in order. + + Scenario: Sites with a CRLF PATH-only setting, an explicit global On and no custom.ini + When I run 'bin/ee site create bh-site-command-upload-crlf.test --type=php' + And I run 'bash features/upload-progress.sh php-ini-on bh-site-command-upload-crlf.test && bash features/upload-progress.sh crlf-path bh-site-command-upload-crlf.test' + And I run 'bash features/upload-progress.sh probe bh-site-command-upload-crlf.test' + And I run 'bin/ee site reload bh-site-command-upload-crlf.test' + And I run 'bin/ee site create bh-site-command-upload-on.test --type=php' + And I run 'bash features/upload-progress.sh append-on bh-site-command-upload-on.test' + And I run 'bash features/upload-progress.sh probe bh-site-command-upload-on.test' + And I run 'bin/ee site reload bh-site-command-upload-on.test' + And I run 'bash features/upload-progress.sh save bh-site-command-upload-on.test' + And I run 'bin/ee site create bh-site-command-upload-missing.test --type=php' + And I run 'bash features/upload-progress.sh remove bh-site-command-upload-missing.test' + And I run 'bin/ee site reload bh-site-command-upload-missing.test' + Then return value of command 1 should be 0 + And return value of command 2 should be 0 + And return value of command 3 should be 0 + And return value of command 4 should be 0 + And return value of command 5 should be 0 + And return value of command 6 should be 0 + And return value of command 7 should be 0 + And return value of command 8 should be 0 + And return value of command 9 should be 0 + And return value of command 10 should be 0 + And return value of command 11 should be 0 + And return value of command 12 should be 0 + + Scenario: Before the upgrade the PATH-only site runs with upload progress On + When I run 'curl -s http://bh-site-command-upload-crlf.test/upe.php' + Then STDOUT should return exactly + """ + string(1) "1" + """ + + Scenario: Upgrade + When I run 'bash features/upload-progress.sh upgrade' + Then return value should be 0 + And STDERR should return something like + """ + Skipped disabling session.upload_progress for bh-site-command-upload-missing.test: custom.ini is missing or not a regular file. + """ + + Scenario: The PATH-only site runs with upload progress Off + When I run 'sleep 3; curl -s http://bh-site-command-upload-crlf.test/upe.php' + Then STDOUT should return exactly + """ + string(0) "" + """ + + Scenario: The Off line is at the top + When I run 'bash features/upload-progress.sh line2 bh-site-command-upload-crlf.test' + Then STDOUT should return exactly + """ + session.upload_progress.enabled = Off + """ + + Scenario: The explicit On is kept + When I run 'curl -s http://bh-site-command-upload-on.test/upe.php' + Then STDOUT should return exactly + """ + string(1) "1" + """ + + Scenario: The file with the explicit On is unchanged + When I run 'bash features/upload-progress.sh same bh-site-command-upload-on.test' + Then STDOUT should return exactly + """ + same + """ + + Scenario: A missing custom.ini is not created by the upgrade + When I run 'bash features/upload-progress.sh exists bh-site-command-upload-missing.test' + Then STDOUT should return exactly + """ + missing + """ + + Scenario: Run the migration again + When I run 'bash features/upload-progress.sh save bh-site-command-upload-crlf.test && bash features/upload-progress.sh upgrade' + Then return value should be 0 + And STDERR should return something like + """ + Skipped disabling session.upload_progress for bh-site-command-upload-missing.test: custom.ini is missing or not a regular file. + """ + + Scenario: A second run changes nothing + When I run 'bash features/upload-progress.sh same bh-site-command-upload-crlf.test' + Then STDOUT should return exactly + """ + same + """ + + Scenario: Back up a site whose custom.ini lacks the Off line + When I run 'bin/ee site create bh-site-command-upload-restore.test --type=php' + And I run 'bash features/upload-progress.sh php-ini-on bh-site-command-upload-restore.test && bash features/upload-progress.sh strip bh-site-command-upload-restore.test' + And I run 'bash features/upload-progress.sh probe bh-site-command-upload-restore.test' + And I run 'bin/ee site reload bh-site-command-upload-restore.test' + And I run 'bash features/upload-progress.sh backup bh-site-command-upload-restore.test' + Then return value of command 1 should be 0 + And return value of command 2 should be 0 + And return value of command 3 should be 0 + And return value of command 4 should be 0 + And return value of command 5 should be 0 + + Scenario: Without the Off line the site runs with upload progress On + When I run 'curl -s http://bh-site-command-upload-restore.test/upe.php' + Then STDOUT should return exactly + """ + string(1) "1" + """ + + Scenario: Restoring that backup turns upload progress off again + When I run 'bash features/upload-progress.sh restore bh-site-command-upload-restore.test' + Then return value should be 0 + And STDOUT should return something like + """ + The restored custom.ini did not set session.upload_progress.enabled, so it was set to Off again. + """ + + Scenario: The restored file has the Off line at the top + When I run 'bash features/upload-progress.sh line2 bh-site-command-upload-restore.test' + Then STDOUT should return exactly + """ + session.upload_progress.enabled = Off + """ + + Scenario: The restored site runs with upload progress Off + When I run 'sleep 3; curl -s http://bh-site-command-upload-restore.test/upe.php' + Then STDOUT should return exactly + """ + string(0) "" + """ + + Scenario: Back up the site without custom.ini + When I run 'bash features/upload-progress.sh backup bh-site-command-upload-missing.test' + Then return value should be 0 + + Scenario: Restoring a backup without custom.ini warns instead of creating it + When I run 'bash features/upload-progress.sh restore bh-site-command-upload-missing.test' + Then return value should be 0 + And STDERR should return something like + """ + Could not check session.upload_progress in the restored custom.ini: it is missing or not a regular file. + """ + + Scenario: The restore did not create custom.ini + When I run 'bash features/upload-progress.sh exists bh-site-command-upload-missing.test' + Then STDOUT should return exactly + """ + missing + """ + + Scenario: Clean up + When I run 'bin/ee site delete bh-site-command-upload-crlf.test --yes' + And I run 'bin/ee site delete bh-site-command-upload-on.test --yes' + And I run 'bin/ee site delete bh-site-command-upload-missing.test --yes' + And I run 'bin/ee site delete bh-site-command-upload-restore.test --yes' + And I run 'bash features/upload-progress.sh clean' + Then return value of command 1 should be 0 + And return value of command 2 should be 0 + And return value of command 3 should be 0 + And return value of command 4 should be 0 + And return value of command 5 should be 0 diff --git a/features/upload-progress.sh b/features/upload-progress.sh new file mode 100755 index 00000000..ce9f0e4b --- /dev/null +++ b/features/upload-progress.sh @@ -0,0 +1,37 @@ +#!/bin/bash +# upload-progress.sh []: helpers for upload-progress.feature, whose steps can't nest quotes. +set -e +ini=/opt/easyengine/sites/$2/config/php/php/conf.d/custom.ini +guard() { + if [ -z "$CI" ] && [ "$EE_BEHAT_DESTRUCTIVE" != 1 ]; then + echo "$1 re-runs the upgrade migrations or uses the rclone remote: only on CI or with EE_BEHAT_DESTRUCTIVE=1" >&2 + exit 1 + fi +} +case $1 in +# Before v4.13.3 neither php.ini nor custom.ini set it; On is set explicitly so newer templates and images don't hide the gap. +php-ini-on) sed -i '/session\.upload_progress\.enabled/d' "${ini%/conf.d/custom.ini}/php.ini"; printf '\nsession.upload_progress.enabled = On\n' >> "${ini%/conf.d/custom.ini}/php.ini" ;; +crlf-path) printf 'upload_max_filesize = 100M\r\n[PATH=/var/www/htdocs/x]\r\nsession.upload_progress.enabled = Off\r\n' > "$ini" ;; +append-on) printf '\nsession.upload_progress.enabled = On\n' >> "$ini" ;; +strip) sed -i '/session\.upload_progress\.enabled/d; /Session upload progress is off/d' "$ini" ;; +remove) rm "$ini" ;; +exists) if [ -e "$ini" ] || [ -L "$ini" ]; then echo present; else echo missing; fi ;; +probe) printf '%s' ' "/opt/easyengine/sites/$2/app/htdocs/upe.php" ;; +# Runs the upgrade migrations again, the re-check included. +upgrade) + guard upgrade + php <<'PHP' +setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION ); +$db->exec( "DELETE FROM migrations WHERE migration LIKE '%recheck_session_upload_progress%'" ); +$db->exec( "UPDATE options SET value = '4.13.2' WHERE key = 'version'" ); +PHP + bin/ee cli info ;; +backup | restore) guard "$1"; bin/ee site "$1" "$2" ;; +save) cp "$ini" "/tmp/upload-progress-$2.before" ;; +same) cmp "/tmp/upload-progress-$2.before" "$ini" && echo same ;; +line2) tr -s '\r\n' '\n' < "$ini" | sed -n 2p ;; +clean) rm -f /tmp/upload-progress-bh-site-command-upload-*.before ;; +*) echo "unknown action $1" >&2; exit 1 ;; +esac 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..8d18a1b8 --- /dev/null +++ b/migrations/container/20261023233000_site-command_recheck_session_upload_progress.php @@ -0,0 +1,414 @@ + [ '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()}. " . self::manual_fix( $site ) ); + } + } + + 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 ); + } + + /** + * @param Site $site Site. + * + * @return string The manual fix, for warnings. + */ + private static function manual_fix( $site ) { + + return "Add 'session.upload_progress.enabled = Off' to the top of $site->site_fs_path/config/php/php/conf.d/custom.ini and run: ee site reload $site->site_url"; + } + + /** + * 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 = self::manual_fix( $site ); + + // 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..11175edf 100644 --- a/src/helper/site-utils.php +++ b/src/helper/site-utils.php @@ -1623,3 +1623,246 @@ 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" ); + } + + // Non-blocking ('n', O_NONBLOCK), so a FIFO swapped in can't stall the open before it is rejected below. + $handle = @fopen( $file, $mode . 'n' ); + 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 ); + } + stream_set_blocking( $handle, true ); + + 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; +}