Files
KantSites/src/Ops/CommandRunner.php
T
kantarciandClaude Fable 5.1 7eb462ac7b Redirect the other www spelling to SITE_ORIGIN, hold roles to the role help and fix the audit findings
SITE_ORIGIN alone decides whether the site is www or not: CanonicalHostSubscriber
sends the other spelling there with 301, cms:site:origin writes the setting, the
system screen shows it. The roles now match the role help (editor: drafts,
publisher: live content, admin: design, settings, users, system). The installer no
longer reopens on an unreachable database, the session cookie follows ADMIN_PATH,
percent-encoded credentials no longer break the container, consent only accepts
same-site decisions, JSON-LD is HTML-safe, own CSS stays in its block, plus the
editor, updater, download and public-output findings from the full review
(ADR 099, ADR 100).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 20:15:20 +02:00

183 lines
7.1 KiB
PHP

<?php
namespace App\Ops;
/**
* Runs an external command, or says plainly that it cannot.
*
* `proc_open` is disabled on a lot of shared hosting, and that is not an edge case to discover
* halfway through an update - it is the first thing the updater has to ask. The command is passed
* as an array, so there is no shell and nothing to quote: an argument can contain a space, a
* quote or a semicolon without becoming a second command.
*/
final class CommandRunner
{
/** @var array<string, list<string>|null> */
private array $resolved = [];
public function available(): bool
{
if (!function_exists('proc_open')) {
return false;
}
$disabled = array_map('trim', explode(',', (string) ini_get('disable_functions')));
return !in_array('proc_open', $disabled, true);
}
/**
* Was ein Kindprozess von der Umgebung sehen darf: Pfade, ein Zuhause fuer Composer und Git,
* aber kein APP_ENV, das einen Konsolenbefehl gegen die falsche Umgebung laufen liesse.
*
* @return array<string, string>
*/
private function environment(): array
{
$environment = ['PATH' => getenv('PATH') ?: '', 'SystemRoot' => getenv('SystemRoot') ?: '', 'TEMP' => sys_get_temp_dir(), 'TMP' => sys_get_temp_dir()];
// Composer verlangt HOME (Linux) oder APPDATA (Windows) - ohne sie bricht `composer
// install` ab, waehrend `composer --version` noch laeuft. Git braucht HOME fuer .gitconfig.
foreach (['HOME', 'USERPROFILE', 'APPDATA', 'LOCALAPPDATA', 'COMPOSER_HOME', 'PATHEXT', 'LANG', 'LC_ALL'] as $name) {
$value = getenv($name);
if (is_string($value) && '' !== $value) {
$environment[$name] = $value;
}
}
if (!isset($environment['HOME']) && !isset($environment['USERPROFILE'])) {
// Unter php-fpm ist HOME oft leer; ein Ordner in var/ tut es fuer Composer und Git.
$environment['HOME'] = sys_get_temp_dir();
}
if (!isset($environment['COMPOSER_HOME'])) {
$environment['COMPOSER_HOME'] = $environment['HOME'] ?? sys_get_temp_dir();
}
return $environment;
}
/**
* Die PHP-Kommandozeile, mit der Konsolenbefehle laufen.
*
* Aus einem Webprozess ist PHP_BINARY der Server selbst (php-fpm) oder leer (mod_php) - und
* `php-fpm bin/console` startet keinen Befehl. Dann wird die Kommandozeile im Pfad gesucht.
*/
public function php(): string
{
if (isset($this->resolved['php'])) {
return $this->resolved['php'][0];
}
$candidates = [];
if ('cli' === \PHP_SAPI && '' !== \PHP_BINARY) {
$candidates[] = \PHP_BINARY;
}
$suffix = '\\' === \DIRECTORY_SEPARATOR ? '.exe' : '';
foreach ([\PHP_BINDIR.'/php'.$suffix, \PHP_BINDIR.'/php'.\PHP_MAJOR_VERSION.'.'.\PHP_MINOR_VERSION.$suffix, 'php'.$suffix, 'php'.\PHP_MAJOR_VERSION.'.'.\PHP_MINOR_VERSION.$suffix, \PHP_BINARY] as $candidate) {
if ('' !== $candidate && !\in_array($candidate, $candidates, true)) {
$candidates[] = $candidate;
}
}
foreach ($candidates as $candidate) {
if ($this->run([$candidate, '-r', 'echo PHP_VERSION;'], sys_get_temp_dir(), 30)['ok']) {
$this->resolved['php'] = [$candidate];
return $candidate;
}
}
return $candidates[0] ?? 'php';
}
/**
* @param list<string> $command
*
* @return array{ok: bool, exit: int, output: string}
*/
public function run(array $command, string $cwd, int $timeout = 300): array
{
if (!$this->available()) {
return ['ok' => false, 'exit' => -1, 'output' => 'proc_open ist auf diesem Server abgeschaltet.'];
}
$pipes = [];
$process = @proc_open(
$command,
[1 => ['pipe', 'w'], 2 => ['pipe', 'w']],
$pipes,
$cwd,
// A minimal, predictable environment: no inherited APP_ENV that would make a console
// command run against the wrong environment.
$this->environment(),
);
if (!is_resource($process)) {
return ['ok' => false, 'exit' => -1, 'output' => sprintf('%s konnte nicht gestartet werden.', $command[0])];
}
foreach ($pipes as $pipe) {
stream_set_blocking($pipe, false);
}
$output = '';
$deadline = microtime(true) + $timeout;
while (true) {
$status = proc_get_status($process);
foreach ($pipes as $pipe) {
$output .= stream_get_contents($pipe) ?: '';
}
if (!$status['running']) {
break;
}
if (microtime(true) > $deadline) {
proc_terminate($process);
foreach ($pipes as $pipe) {
fclose($pipe);
}
proc_close($process);
return ['ok' => false, 'exit' => -1, 'output' => trim($output)."\n".sprintf('Abgebrochen nach %d Sekunden.', $timeout)];
}
usleep(50000);
}
foreach ($pipes as $pipe) {
$output .= stream_get_contents($pipe) ?: '';
fclose($pipe);
}
proc_close($process);
$exit = $status['exitcode'];
return ['ok' => 0 === $exit, 'exit' => $exit, 'output' => trim($output)];
}
/**
* Whether an executable can be found at all, so a missing one is reported, not guessed at.
*
* On Windows several of these are batch files, and proc_open without a shell cannot start a
* .bat - which reported "composer is not available" on a machine that has it. The variants are
* tried in order, and the one that answers is remembered for the next call.
*/
public function has(string $executable, string $cwd): bool
{
return null !== $this->resolve($executable, $cwd);
}
/** The spelling of a command that actually starts here, or null. */
public function resolve(string $executable, string $cwd): ?array
{
if (array_key_exists($executable, $this->resolved)) {
return $this->resolved[$executable];
}
$candidates = [[$executable]];
if ('\\' === DIRECTORY_SEPARATOR) {
// On Windows composer is a .bat, and CreateProcess cannot start a batch file on its
// own - it needs the command interpreter. The argument list stays fixed, nothing from
// a request ever reaches it.
$candidates[] = [getenv('COMSPEC') ?: 'cmd.exe', '/c', $executable];
}
// A phar in the project is the way on hosting where nothing is installed globally.
if (is_file($cwd.'/'.$executable.'.phar')) {
$candidates[] = [$this->php(), $cwd.'/'.$executable.'.phar'];
}
foreach ($candidates as $candidate) {
if ($this->run([...$candidate, '--version'], $cwd, 30)['ok']) {
return $this->resolved[$executable] = $candidate;
}
}
return $this->resolved[$executable] = null;
}
}