Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 76 additions & 11 deletions core/src/Console/SiteUpdateCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@
use Illuminate\Console\Command;

/**
* Check and apply a core archive using the site's configured Manager and Composer environment.
*
* Preflight runs before the overlay; dependency repair, migrations and cache cleanup
* then bring the installed files and application state onto the requested version.
*
* @see: https://github.com/laravel-zero/foundation/blob/9.x/src/Illuminate/Foundation/Console/ClearCompiledCommand.php
*/
class SiteUpdateCommand extends Command
Expand Down Expand Up @@ -95,6 +100,11 @@ class SiteUpdateCommand extends Command
with 1 when an error left the site unchanged (unknown branch, GitHub unreachable
or rate limited, custom packages that do not fit).

Manager files follow EVO_MANAGER_PATH, including a renamed manager directory.
Existing Manager configuration and local access-control files are preserved.
Composer keeps the operator's authentication environment; core/composer/auth.json
remains supported, with a site-local home fallback for headless environments.

HELP;

/**
Expand Down Expand Up @@ -258,7 +268,7 @@ public function startUpdate()
return false;
}

putenv('COMPOSER_HOME=' . EVO_CORE_PATH . 'composer');
$this->configureComposerHome(EVO_CORE_PATH . 'composer');
$customPackageUpdate = $this->preflightComposer(
$temp_dir . '/' . $dir . '/core',
$customPackageNames,
Expand All @@ -270,7 +280,7 @@ public function startUpdate()
return false;
}

self::moveFiles($temp_dir . '/' . $dir, EVO_BASE_PATH);
self::moveFiles($temp_dir . '/' . $dir, EVO_BASE_PATH, EVO_MANAGER_PATH);
self::rmdirs($temp_dir);

// Only a release carries a version number of its own. Any other ref (a
Expand Down Expand Up @@ -1491,28 +1501,83 @@ protected function isCommitHash(string $ref): bool
}

/**
* Recursively move files from the extracted update archive into the site root.
* Keep Composer's normal authentication lookup unless a legacy site auth file is needed.
*
* Destination directories are created on demand. Existing writable files are replaced.
* An explicit home or inline authentication belongs to the operator. Without
* an explicit home, an existing site-local auth.json remains supported unless
* inline authentication was supplied. Otherwise Composer resolves its own home,
* including XDG_CONFIG_HOME, instead of losing global auth. A worker without HOME
* (APPDATA on Windows) still needs the old site-local fallback, even with inline
* authentication. No credentials are read, copied or printed by the updater.
*
* @since 3.6.1
* @param string $legacyHome Site-local Composer home used by earlier updaters.
* @return void
*/
protected function configureComposerHome(string $legacyHome): void
{
if (getenv('COMPOSER_HOME') !== false) {
return;
}

$authFile = rtrim($legacyHome, '/\\') . '/auth.json';
$globalHome = getenv(windows_os() ? 'APPDATA' : 'HOME');
if ((getenv('COMPOSER_AUTH') === false && is_file($authFile) && is_readable($authFile))
|| $globalHome === false || $globalHome === '') {
putenv('COMPOSER_HOME=' . $legacyHome);
}
}

/**
* Overlay an update archive, routing only its top-level manager tree to the active Manager.
*
* The optional Manager path preserves the two-argument file-copy contract. Site
* updates pass EVO_MANAGER_PATH so renamed or externally located Managers receive
* the new forms and assets without creating a second default login directory.
* Existing Manager configuration, .htaccess and .user.ini stay operator-owned;
* unrelated destination files are never deleted. Invalid roots fail before writes.
*
* @param string $src Absolute path to the extracted source directory.
* @param string $dest Absolute path to the destination root.
* @param string|null $managerPath Existing configured Manager directory, or null for an unchanged archive layout.
* @return void
*/
static public function moveFiles($src, $dest)
static public function moveFiles($src, $dest, ?string $managerPath = null)
{
$path = realpath($src);
$dest = realpath($dest);
$objects = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($path), \RecursiveIteratorIterator::SELF_FIRST);
$manager = $managerPath === null ? null : realpath($managerPath);
if ($path === false || !is_dir($path) || $dest === false || !is_dir($dest)
|| ($managerPath !== null && ($manager === false || !is_dir($manager)))) {
throw new \RuntimeException('The update source, site root and configured Manager must be existing directories.');
}

$objects = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::SELF_FIRST
);
foreach ($objects as $name => $object) {
$startsAt = substr(dirname($name), strlen($path));
self::mmkDir($dest . $startsAt);
$relative = str_replace('\\', '/', substr($name, strlen($path) + 1));
$target = $dest . '/' . $relative;
if ($manager !== null && ($relative === 'manager' || str_starts_with($relative, 'manager/'))) {
$managerRelative = substr($relative, strlen('manager/'));
$target = $manager . ($managerRelative === '' ? '' : '/' . $managerRelative);
if (in_array($managerRelative, ['includes/config.inc.php', '.htaccess', '.user.ini'], true)
&& (file_exists($target) || is_link($target))) {
continue;
}
}

if ($object->isDir()) {
self::mmkDir($dest . substr($name, strlen($path)));
self::mmkDir($target);
continue;
}

if (is_writable($dest . $startsAt) && $object->isFile()) {
rename((string)$name, $dest . $startsAt . '/' . basename($name));
if ($object->isFile()) {
self::mmkDir(dirname($target));
if (!is_writable(dirname($target)) || !rename((string) $name, $target)) {
throw new \RuntimeException('Could not replace an update file: ' . $relative);
}
}
}
}
Expand Down
186 changes: 186 additions & 0 deletions core/tests/Unit/Console/SiteUpdateLayoutTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
<?php

use EvolutionCMS\Console\SiteUpdateCommand;
use Illuminate\Filesystem\Filesystem;
use Illuminate\View\Compilers\BladeCompiler;

beforeEach(function () {
$this->workspace = sys_get_temp_dir() . '/evo-update-layout-' . bin2hex(random_bytes(8));
$this->archive = $this->workspace . '/unpacked';
$this->site = $this->workspace . '/site';
mkdir($this->archive . '/manager/views/page', 0777, true);
mkdir($this->archive . '/manager/includes', 0777, true);
mkdir($this->archive . '/assets/manager', 0777, true);
mkdir($this->site . '/core', 0777, true);
file_put_contents($this->archive . '/index.php', 'new front controller');
file_put_contents($this->archive . '/manager/index.php', 'new manager controller');
file_put_contents($this->archive . '/manager/views/page/system_settings.blade.php',
file_get_contents(dirname(__DIR__, 4) . '/manager/views/page/system_settings.blade.php'));
file_put_contents($this->archive . '/assets/manager/example.js', 'ordinary asset');

$this->composerEnvironment = [];
foreach (['COMPOSER_HOME', 'COMPOSER_AUTH'] as $name) {
$this->composerEnvironment[$name] = getenv($name);
putenv($name);
}
});

afterEach(function () {
foreach ($this->composerEnvironment as $name => $value) {
$value === false ? putenv($name) : putenv($name . '=' . $value);
}
SiteUpdateCommand::rmdirs($this->workspace);
});

test('update overlays the configured Manager and delivers the current CSRF form', function (string $directory) {
$manager = $this->site . '/' . $directory;
mkdir($manager . '/views/page', 0777, true);
file_put_contents($manager . '/views/page/system_settings.blade.php', '<form name="settings"></form>');
file_put_contents($manager . '/local-plugin.php', 'keep local extension');

SiteUpdateCommand::moveFiles($this->archive, $this->site, $manager . '/');

$template = file_get_contents($manager . '/views/page/system_settings.blade.php');
$compiler = new BladeCompiler(new Filesystem(), $this->workspace . '/compiled');
expect($template)->toContain('@csrf')
->and($compiler->compileString($template))->toContain('csrf_field()')
->and(file_get_contents($manager . '/index.php'))->toBe('new manager controller')
->and(file_get_contents($manager . '/local-plugin.php'))->toBe('keep local extension')
->and(file_get_contents($this->site . '/index.php'))->toBe('new front controller')
->and(file_get_contents($this->site . '/assets/manager/example.js'))->toBe('ordinary asset');
if ($directory !== 'manager') {
expect(is_dir($this->site . '/manager'))->toBeFalse();
}
})->with(['manager', 'points', 'private/control-panel']);

test('renamed Manager updates do not write to a leftover default Manager', function () {
mkdir($this->site . '/points', 0777, true);
mkdir($this->site . '/manager', 0777, true);
file_put_contents($this->site . '/manager/index.php', 'leftover default manager');

SiteUpdateCommand::moveFiles($this->archive, $this->site, $this->site . '/points');

expect(file_get_contents($this->site . '/points/index.php'))->toBe('new manager controller')
->and(file_get_contents($this->site . '/manager/index.php'))->toBe('leftover default manager')
->and(is_dir($this->site . '/manager/views'))->toBeFalse();
});

test('Manager paths outside the public site root follow EVO_MANAGER_PATH too', function () {
$manager = $this->workspace . '/private-manager';
mkdir($manager, 0777, true);

SiteUpdateCommand::moveFiles($this->archive, $this->site, $manager);

expect(file_get_contents($manager . '/index.php'))->toBe('new manager controller')
->and(is_dir($this->site . '/manager'))->toBeFalse();
});

test('Manager overlay preserves existing configuration and access-control files', function (string $directory) {
$manager = $this->site . '/' . $directory;
mkdir($manager . '/includes', 0777, true);
foreach (['includes/config.inc.php', '.htaccess', '.user.ini'] as $file) {
file_put_contents($this->archive . '/manager/' . $file, 'archive default');
file_put_contents($manager . '/' . $file, 'local configuration');
}

SiteUpdateCommand::moveFiles($this->archive, $this->site, $manager);

foreach (['includes/config.inc.php', '.htaccess', '.user.ini'] as $file) {
expect(file_get_contents($manager . '/' . $file))->toBe('local configuration');
}
})->with(['manager', 'points']);

test('Manager overlay installs shipped access-control files when they are absent', function () {
$manager = $this->site . '/points';
mkdir($manager, 0777, true);
file_put_contents($this->archive . '/manager/.htaccess', 'archive access control');

SiteUpdateCommand::moveFiles($this->archive, $this->site, $manager);

expect(file_get_contents($manager . '/.htaccess'))->toBe('archive access control');
});

test('repeated Manager overlays update the same configured directory', function () {
$manager = $this->site . '/points';
mkdir($manager, 0777, true);
SiteUpdateCommand::moveFiles($this->archive, $this->site, $manager);
file_put_contents($this->archive . '/manager/index.php', 'next manager controller');

SiteUpdateCommand::moveFiles($this->archive, $this->site, $manager);

expect(file_get_contents($manager . '/index.php'))->toBe('next manager controller')
->and(is_dir($this->site . '/manager'))->toBeFalse();
});

test('a missing configured Manager fails before any site file is overwritten', function () {
file_put_contents($this->site . '/index.php', 'old front controller');

expect(fn () => SiteUpdateCommand::moveFiles($this->archive, $this->site, $this->site . '/missing'))
->toThrow(RuntimeException::class)
->and(file_get_contents($this->site . '/index.php'))->toBe('old front controller');
});

test('two-argument moveFiles keeps the ordinary archive layout', function () {
SiteUpdateCommand::moveFiles($this->archive, $this->site);

expect(file_get_contents($this->site . '/manager/index.php'))->toBe('new manager controller');
});

test('updater passes the configured Manager path into the archive overlay', function () {
$source = file_get_contents(dirname(__DIR__, 3) . '/src/Console/SiteUpdateCommand.php');

expect($source)->toContain("self::moveFiles(\$temp_dir . '/' . \$dir, EVO_BASE_PATH, EVO_MANAGER_PATH);");
});

test('Composer keeps global and explicit authentication while retaining legacy site auth', function (
bool $legacyAuth,
string|false $home,
string|false $auth,
string|false $expectedHome
) {
$legacy = $this->workspace . '/legacy-composer';
mkdir($legacy, 0777, true);
if ($legacyAuth) {
file_put_contents($legacy . '/auth.json', '{}');
}
if ($home !== false) {
putenv('COMPOSER_HOME=' . $home);
}
if ($auth !== false) {
putenv('COMPOSER_AUTH=' . $auth);
}
$method = new ReflectionMethod(SiteUpdateCommand::class, 'configureComposerHome');

$method->invoke(new SiteUpdateCommand(), $legacy);

expect(getenv('COMPOSER_HOME'))->toBe($expectedHome === 'legacy' ? $legacy : $expectedHome)
->and(getenv('COMPOSER_AUTH'))->toBe($auth)
->and(is_file($legacy . '/auth.json'))->toBe($legacyAuth);
})->with([
'normal global/XDG lookup' => [false, false, false, false],
'legacy site auth' => [true, false, false, 'legacy'],
'explicit home without legacy auth' => [false, '/operator/composer', false, '/operator/composer'],
'explicit home wins over legacy auth' => [true, '/operator/composer', false, '/operator/composer'],
'inline auth without legacy auth' => [false, false, '{}', false],
'inline auth wins over legacy home' => [true, false, '{}', false],
]);

test('a headless worker still has a Composer home without hiding inline auth', function (bool $inlineAuth) {
$name = windows_os() ? 'APPDATA' : 'HOME';
$home = getenv($name);
$legacy = $this->workspace . '/worker-composer';
putenv($name);
if ($inlineAuth) {
putenv('COMPOSER_AUTH={}');
}

try {
(new ReflectionMethod(SiteUpdateCommand::class, 'configureComposerHome'))
->invoke(new SiteUpdateCommand(), $legacy);

expect(getenv('COMPOSER_HOME'))->toBe($legacy)
->and(getenv('COMPOSER_AUTH'))->toBe($inlineAuth ? '{}' : false);
} finally {
$home === false ? putenv($name) : putenv($name . '=' . $home);
}
})->with([false, true]);
Loading