diff --git a/migrations/container/20261023193000_site-command_drop_deprecated_session_sid_settings.php b/migrations/container/20261023193000_site-command_drop_deprecated_session_sid_settings.php new file mode 100644 index 00000000..06127805 --- /dev/null +++ b/migrations/container/20261023193000_site-command_drop_deprecated_session_sid_settings.php @@ -0,0 +1,312 @@ + '26', + 'session.sid_bits_per_character' => '5', + ]; + + /** Comments shipped right above each directive, removed together with it. */ + const SHIPPED_COMMENTS = [ + 'session.sid_length' => [ + '; Set session ID character length. This value could be between 22 to 256.', + '; Shorter length than default is supported only for compatibility reason.', + '; Users should use 32 or more chars.', + '; http://php.net/session.sid-length', + '; Default Value: 32', + '; Development Value: 26', + '; Production Value: 26', + ], + 'session.sid_bits_per_character' => [ + '; Define how many bits are stored in each character when converting', + '; the binary hash data to something readable.', + '; Possible values:', + '; 4 (4 bits: 0-9, a-f)', + '; 5 (5 bits: 0-9, a-v)', + '; 6 (6 bits: 0-9, a-z, A-Z, "-", ",")', + '; Default Value: 4', + '; Development Value: 5', + '; Production Value: 5', + '; http://php.net/session.hash-bits-per-character', + ], + ]; + + /** Entry of the quick reference at the top of the shipped php.ini. */ + const SHIPPED_QUICK_REFERENCE = [ + '; session.sid_bits_per_character', + '; Default Value: 4', + '; Development Value: 5', + '; Production Value: 5', + ]; + + private $sites; + + /** @var array Files changed by up(), restored by down(). */ + private $changed = []; + + public function __construct() { + + parent::__construct(); + $this->sites = Site::all(); + if ( $this->is_first_execution || ! $this->sites ) { + $this->skip_this_migration = true; + } + } + + /** + * @throws EE\ExitException + */ + public function up() { + + if ( $this->skip_this_migration ) { + EE::debug( 'Skipping drop-deprecated-session-sid-settings migration as it is not needed.' ); + + return; + } + + foreach ( $this->sites as $site ) { + if ( ! in_array( $site->site_type, [ 'php', 'wp' ], true ) || ! in_array( (string) $site->php_version, self::PHP_VERSIONS, true ) ) { + continue; + } + + // One broken site must not block the upgrade of the others. + try { + $this->migrate_site( $site ); + } catch ( \Throwable $e ) { + EE::warning( sprintf( 'Could not remove the deprecated session ID settings from the php.ini of %s: %s', $site->site_url, $e->getMessage() ) ); + } + } + } + + /** + * Restores the files changed by up(). + * + * @throws EE\ExitException + */ + public function down() { + + foreach ( array_reverse( $this->changed ) as $change ) { + try { + $old = file_get_contents( $change['backup'] ); + if ( false === $old ) { + throw new \Exception( 'Could not read ' . $change['backup'] ); + } + $handle = self::open_ini( $change['ini'] ); + if ( ! $handle ) { + throw new \Exception( $change['ini'] . ' is gone.' ); + } + try { + self::rewrite( $handle, $old, $change['ini'] ); + } finally { + fclose( $handle ); + } + $this->fs->remove( $change['backup'] ); + self::reload_fpm( $change['site'] ); + } catch ( \Throwable $e ) { + EE::warning( sprintf( 'Could not restore the php.ini of %s from %s: %s', $change['site']->site_url, $change['backup'], $e->getMessage() ) ); + } + } + $this->changed = []; + } + + /** + * @param Site $site PHP 8.4 or 8.5 site. + * + * @throws \Exception + */ + private function migrate_site( $site ) { + + $ini = $site->site_fs_path . '/config/php/php/php.ini'; + + // Missing on macOS, where php.ini stays in the container. + $handle = self::open_ini( $ini ); + if ( ! $handle ) { + EE::debug( "Skipping $site->site_url: no php.ini at $ini." ); + + return; + } + + try { + $content = stream_get_contents( $handle ); + if ( false === $content ) { + throw new \Exception( "Could not read $ini." ); + } + + list( $new_content, $removed ) = self::strip_settings( $content ); + if ( $new_content === $content ) { + EE::debug( "$ini has no deprecated session ID settings." ); + + return; + } + + $backup = EE_BACKUP_DIR . '/' . $site->site_url . '/php.ini.with-session-sid-settings'; + $this->fs->dumpFile( $backup, $content ); + + try { + self::rewrite( $handle, $new_content, $ini ); + } catch ( \Exception $e ) { + self::rewrite( $handle, $content, $ini ); + throw $e; + } + } finally { + fclose( $handle ); + } + + $this->changed[] = [ + 'site' => $site, + 'ini' => $ini, + 'backup' => $backup, + ]; + EE::debug( "Removed the deprecated session ID settings from $ini; the previous file is $backup." ); + + foreach ( $removed as $line => $shipped ) { + if ( ! $shipped ) { + EE::warning( sprintf( 'Removed `%s` from the php.ini of %s: it is deprecated as of PHP 8.4, and PHP now uses its default. The previous file is saved as %s.', $line, $site->site_url, $backup ) ); + } + } + + self::reload_fpm( $site ); + } + + /** + * Removes the two directives, wherever they are set, and the shipped comments that document them. + * Every other line is kept byte for byte. + * + * @param string $content php.ini content. + * + * @return array New content, and the removed directive lines => whether the value is the shipped one. + */ + public static function strip_settings( $content ) { + + $lines = preg_split( '/(?<=\n)/', $content, -1, PREG_SPLIT_NO_EMPTY ); + $norm = array_map( + function ( $line ) { + return rtrim( $line, " \t\r\n" ); + }, + $lines + ); + $drop = []; + $removed = []; + + $quick_count = count( self::SHIPPED_QUICK_REFERENCE ); + foreach ( array_keys( $norm ) as $i ) { + if ( array_slice( $norm, $i, $quick_count ) === self::SHIPPED_QUICK_REFERENCE ) { + $drop += array_fill( $i, $quick_count, true ); + if ( isset( $norm[ $i + $quick_count ] ) && '' === $norm[ $i + $quick_count ] ) { + $drop[ $i + $quick_count ] = true; + } + } + } + + foreach ( $norm as $i => $line ) { + if ( ! preg_match( '/^\s*(session\.sid_length|session\.sid_bits_per_character)\s*=(.*)$/', $line, $matches ) ) { + continue; + } + $name = $matches[1]; + $drop[ $i ] = true; + + // Inline comment and quotes don't change the value. + $value = trim( preg_replace( '/;.*$/', '', $matches[2] ), " \t\"'" ); + $removed[ trim( $line ) ] = self::SHIPPED_VALUES[ $name ] === $value; + + $comments = self::SHIPPED_COMMENTS[ $name ]; + $count = count( $comments ); + if ( $i >= $count && array_slice( $norm, $i - $count, $count ) === $comments ) { + $drop += array_fill( $i - $count, $count, true ); + if ( isset( $norm[ $i + 1 ] ) && '' === $norm[ $i + 1 ] ) { + $drop[ $i + 1 ] = true; + } + } + } + + return [ implode( '', array_diff_key( $lines, $drop ) ), $removed ]; + } + + /** + * Opens php.ini for rewriting in place, so no other path is created, followed or re-resolved. + * The directory is mounted into the site's php container, so nothing in it is trusted. + * + * @param string $file php.ini path. + * + * @return resource|null Null when there is no regular file. + * @throws \Exception + */ + private static function open_ini( $file ) { + + if ( is_link( $file ) || ! is_file( $file ) ) { + return null; + } + + $handle = fopen( $file, 'r+' ); + if ( false === $handle ) { + throw new \Exception( "Could not open $file." ); + } + + // Swapped for a symlink after the check, or a hard link to another file. + $opened = fstat( $handle ); + clearstatcache( true, $file ); + $linked = lstat( $file ); + if ( ! $opened || ! $linked || $opened['dev'] !== $linked['dev'] || $opened['ino'] !== $linked['ino'] || 0100000 !== ( $linked['mode'] & 0170000 ) || 1 !== $opened['nlink'] ) { + fclose( $handle ); + throw new \Exception( "$file is not a regular file." ); + } + + return $handle; + } + + /** + * The removal only shortens the file, so writing over it needs no new space; mode and owner stay. + * + * @param resource $handle Handle from open_ini(). + * @param string $content New content. + * @param string $file php.ini path, for the error. + * + * @throws \Exception + */ + private static function rewrite( $handle, $content, $file ) { + + if ( ! rewind( $handle ) || strlen( $content ) !== fwrite( $handle, $content ) || ! fflush( $handle ) || ! ftruncate( $handle, strlen( $content ) ) ) { + throw new \Exception( "Could not write $file." ); + } + } + + /** + * PHP-FPM reads php.ini only when it starts; a graceful reload (as `ee site reload --php` does) applies it. + * Stopped containers read it when they start. + * + * @param Site $site Site. + */ + private static function reload_fpm( $site ) { + + if ( ! $site->site_enabled || ! chdir( $site->site_fs_path ) ) { + return; + } + + $compose = \EE_DOCKER::docker_compose_with_custom(); + $id = trim( EE::launch( "$compose ps -q php" )->stdout ); + if ( '' === $id || 'running' !== \EE_DOCKER::container_status( $id ) ) { + EE::debug( "The php container of $site->site_url is not running." ); + + return; + } + + if ( EE::launch( "$compose exec -T php bash -c 'kill -USR2 1'" )->return_code ) { + EE::warning( sprintf( 'Could not reload PHP-FPM of %1$s. Run `ee site restart %1$s --php` to apply its new php.ini.', $site->site_url ) ); + } + } +} diff --git a/templates/config/php-fpm/php81.zip b/templates/config/php-fpm/php81.zip index 49402070..bd3414fb 100644 Binary files a/templates/config/php-fpm/php81.zip and b/templates/config/php-fpm/php81.zip differ diff --git a/templates/config/php-fpm/php82.zip b/templates/config/php-fpm/php82.zip index 4facee46..c8749f3a 100644 Binary files a/templates/config/php-fpm/php82.zip and b/templates/config/php-fpm/php82.zip differ diff --git a/templates/config/php-fpm/php83.zip b/templates/config/php-fpm/php83.zip index 13aaceb1..00fd4b7b 100644 Binary files a/templates/config/php-fpm/php83.zip and b/templates/config/php-fpm/php83.zip differ diff --git a/templates/config/php-fpm/php84.zip b/templates/config/php-fpm/php84.zip index 9bed38b6..2201c3a8 100644 Binary files a/templates/config/php-fpm/php84.zip and b/templates/config/php-fpm/php84.zip differ diff --git a/templates/config/php-fpm/php85.zip b/templates/config/php-fpm/php85.zip index 31ce07cd..2f1a2996 100644 Binary files a/templates/config/php-fpm/php85.zip and b/templates/config/php-fpm/php85.zip differ diff --git a/templates/config/php-fpm/phplatest.zip b/templates/config/php-fpm/phplatest.zip deleted file mode 100644 index 4facee46..00000000 Binary files a/templates/config/php-fpm/phplatest.zip and /dev/null differ