feat: единая админка runtime-конфигов и indpic (#22)
deploy-preland / deploy zoomstage.online (push) Successful in 1m1s

This commit is contained in:
Panfilov Konstantin committed 2026-10-25 11:33:56 +03:00
1 parent 5b6b2d2053
commit 9515c1c62b
52 files changed
+2459 -34

No files matched your search

+111
View File
@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace PrelandAdmin;
/** Самодостаточный обработчик: один и тот же CAS выполняется локально и под SSH. */
final class Remote
{
private static function cleanStage(string $directory): void {if(!is_dir($directory)||is_link($directory))return;foreach(new \FilesystemIterator($directory,\FilesystemIterator::SKIP_DOTS) as $item){if($item->isFile()||$item->isLink())unlink($item->getPathname());else throw new \RuntimeException('Небезопасный элемент staging.');}rmdir($directory);}
private static function journal(string $path,array $data): void {
$temp=$path.'.tmp-'.bin2hex(random_bytes(8));$fp=fopen($temp,'xb');if(!$fp)throw new \RuntimeException('Не удалось записать журнал переключения.');
try{$json=json_encode($data,JSON_THROW_ON_ERROR);if(fwrite($fp,$json)!==strlen($json)||!fflush($fp)||!fsync($fp))throw new \RuntimeException('Не удалось зафиксировать журнал переключения.');}finally{fclose($fp);}
if(!rename($temp,$path))throw new \RuntimeException('Не удалось опубликовать журнал переключения.');
}
private static function recover(string $root,string $private,bool $local): void {
foreach(glob($private.'/swap-[1-9].json')?:[] as $path){$record=json_decode(file_get_contents($path),true);$number=(int)($record['number']??0);if($number<1||$number>9)throw new \RuntimeException('Повреждён журнал переключения набора.');
$parent=$local?$root.'/.preland-admin-staging':$private;$stage=$record['stage']??'';$backup=$record['backup']??'';
if(dirname($stage)!==$parent||dirname($backup)!==$parent||!preg_match('/^set-[a-f0-9]{16}$/D',basename($stage))||!preg_match('/^backup-'.$number.'-[a-f0-9]{16}$/D',basename($backup)))throw new \RuntimeException('Небезопасные пути журнала переключения.');
$active=$root.'/'.$number;
if(!file_exists($active)&&!is_link($active)){
if(is_dir($backup)&&!is_link($backup)){if(!rename($backup,$active))throw new \RuntimeException('Не удалось восстановить предыдущий набор.');}
elseif(is_dir($stage)&&self::manifest($stage)===($record['desired_manifest']??null)){if(!chmod($stage,0755)||!rename($stage,$active))throw new \RuntimeException('Не удалось завершить переключение набора.');}
else throw new \RuntimeException('Незавершённый набор требует восстановления из резерва.');
}
// При наличии активного каталога третью версию не перезаписываем.
if(is_link($active)||!is_dir($active))throw new \RuntimeException('Небезопасный активный набор при восстановлении.');
self::cleanStage($stage);if($local)self::cleanStage($backup);unlink($path);
}
}
public static function manifest(string $dir): ?string {
if(!is_dir($dir)){if(file_exists($dir)||is_link($dir))throw new \RuntimeException('Путь набора занят небезопасным элементом.');return null;}
if(is_link($dir))throw new \RuntimeException('Символические ссылки набора запрещены.');$hashes=[];$total=0;
foreach(new \FilesystemIterator($dir,\FilesystemIterator::SKIP_DOTS) as $item){if($item->isLink()||!$item->isFile()||$item->getSize()>8388608||count($hashes)>=1000)throw new \RuntimeException('Небезопасный элемент набора.');$total+=$item->getSize();if($total>67108864)throw new \RuntimeException('Превышен лимит набора.');$hashes[$item->getFilename()]=hash_file('sha256',$item->getPathname());}
ksort($hashes);return hash('sha256',json_encode($hashes,JSON_THROW_ON_ERROR));
}
public static function execute(array $p): array {
$root=$p['root'];
if (!is_string($root)||!str_starts_with($root,'/')||str_contains($root,'..')||str_contains($root,'\\')||realpath($root)!==$root||is_link($root)) return ['ok'=>false,'error'=>'Небезопасный корневой путь.'];
$private=$p['local_private']??dirname($root).'/.preland-admin-'.substr(hash('sha256',$root),0,16);
if (is_link($private)) return ['ok'=>false,'error'=>'Небезопасный приватный каталог.'];
if (!is_dir($private) && !mkdir($private,0700)) return ['ok'=>false,'error'=>'Не удалось подготовить приватный каталог вне webroot.'];
chmod($private,0700);
$lock=fopen($private.'/publish.lock','c');if(!$lock) return ['ok'=>false,'error'=>'Не удалось получить удалённую блокировку.'];
$deadline=microtime(true)+20;while(!flock($lock,LOCK_EX|LOCK_NB)){if(microtime(true)>$deadline){fclose($lock);return ['ok'=>false,'error'=>'Истекло время ожидания блокировки.'];}usleep(50000);}
try {
self::recover($root,$private,isset($p['local_private']));
$config=$root.'/config.json';
if (is_link($config)||!is_file($config)||filesize($config)>2097152) throw new \RuntimeException('Настройки отсутствуют либо путь небезопасен.');
$content=file_get_contents($config);$version=hash('sha256',$content);
$op=$p['op'];
if ($op==='read') return ['ok'=>true,'content'=>$content,'version'=>$version];
if ($op==='test') {
if(!function_exists('fsync'))throw new \RuntimeException('Для безопасной публикации требуется PHP CLI не ниже 8.1 с fsync.');
if(!is_writable($root)||!is_writable($config)) throw new \RuntimeException('Нет прав записи.');
if(!json_decode($content) instanceof \stdClass)throw new \RuntimeException('config.json не является корректным объектом.');
$probe=$root.'/.config-probe-'.bin2hex(random_bytes(8));$renamed=$probe.'-renamed';
try{if(file_put_contents($probe,'preland-admin-probe')===false||!rename($probe,$renamed)||file_get_contents($renamed)!=='preland-admin-probe')throw new \RuntimeException('Атомарная запись недоступна.');}finally{if(is_file($probe))unlink($probe);if(is_file($renamed))unlink($renamed);}
return ['ok'=>true,'version'=>$version];
}
if ($op==='set-list') {$numbers=[];foreach(range(1,9) as $n)if(is_dir($root.'/'.$n)&&!is_link($root.'/'.$n))$numbers[]=$n;return ['ok'=>true,'numbers'=>$numbers];}
if ($op==='write') {
if (!hash_equals($version,(string)$p['version'])) return ['ok'=>false,'conflict'=>true,'error'=>'Версия изменена.'];
$next=$p['content'];$decoded=json_decode($next);if(!$decoded instanceof \stdClass || strlen($next)>2097152) throw new \RuntimeException('Некорректные настройки.');
$temp=$root.'/.config-'.bin2hex(random_bytes(8));$backup=$private.'/config-'.gmdate('YmdHis').'-'.bin2hex(random_bytes(6)).'.json';
if(file_put_contents($backup,$content)===false) throw new \RuntimeException('Не удалось сохранить резерв.');chmod($backup,0600);
if(file_put_contents($temp,$next)===false) throw new \RuntimeException('Не удалось записать временный файл.');chmod($temp,0644);
if(hash_file('sha256',$temp)!==hash('sha256',$next)||!rename($temp,$config)){@unlink($temp);throw new \RuntimeException('Не удалось атомарно заменить настройки.');}
$actual=hash_file('sha256',$config);if($actual!==hash('sha256',$next)) throw new \RuntimeException('Повторная проверка настроек не совпала.');
return ['ok'=>true,'version'=>$actual,'previous'=>$content];
}
$number=(int)($p['number']??0);if($number<1||$number>9) throw new \RuntimeException('Номер набора должен быть 1–9.');
$dir=$root.'/'.$number;if(is_link($dir)) throw new \RuntimeException('Символические ссылки набора запрещены.');
if($op==='set-manifest')return ['ok'=>true,'manifest'=>self::manifest($dir)];
if($op==='set-read') {
if(!is_dir($dir)) throw new \RuntimeException('Папка набора отсутствует.');$files=[];$bytes=0;
foreach(new \FilesystemIterator($dir,\FilesystemIterator::SKIP_DOTS) as $item){if(!$item->isFile()||$item->isLink()||$item->getSize()>8388608) throw new \RuntimeException('Недопустимый файл набора (лимит 8 МиБ).');$bytes+=$item->getSize();if($bytes>67108864||count($files)>=90) throw new \RuntimeException('Превышен лимит набора (64 МиБ, 90 файлов).');$files[$item->getFilename()]=base64_encode(file_get_contents($item->getPathname()));}
return ['ok'=>true,'files'=>$files];
}
if($op==='set-write') {
if(!array_key_exists('expected_manifest',$p))throw new \RuntimeException('Отсутствует исходная версия набора.');
if(self::manifest($dir)!==$p['expected_manifest'])return ['ok'=>false,'conflict'=>true,'error'=>'Набор изменился. Перечитайте его состояние перед повторной публикацией.'];
$stageParent=isset($p['local_private'])?$root.'/.preland-admin-staging':$private;
if(is_link($stageParent))throw new \RuntimeException('Небезопасный staging-каталог.');
if(!is_dir($stageParent)&&!mkdir($stageParent,0700))throw new \RuntimeException('Не удалось подготовить staging.');
if(isset($p['local_private']))file_put_contents($stageParent.'/.htaccess',"Require all denied\n");
$stage=$stageParent.'/set-'.bin2hex(random_bytes(8));if(!mkdir($stage,0755))throw new \RuntimeException('Не удалось подготовить набор.');$backup=$stageParent.'/backup-'.$number.'-'.bin2hex(random_bytes(8));$moved=false;
try {
if(count($p['files'])!==90) throw new \RuntimeException('Требуется ровно 90 файлов.');$total=0;
foreach($p['files'] as $name=>$file){if(!preg_match('/^[0-9]{2}_(desk|mob)_[123]-(source|result|thumb)\.(jpg|webp|avif)$/D',$name))throw new \RuntimeException('Недопустимое имя файла.');$data=base64_decode($file['data'],true);$total+=strlen($data===false?'':$data);if($data===false||strlen($data)>8388608||$total>67108864||hash('sha256',$data)!==$file['sha256'])throw new \RuntimeException('Не совпала проверка файла либо превышен лимит.');$path=$stage.'/'.$name;if(file_put_contents($path,$data)===false||hash_file('sha256',$path)!==$file['sha256'])throw new \RuntimeException('Ошибка доставки файла.');chmod($path,0644);}
if(!chmod($stage,0755))throw new \RuntimeException('Не удалось открыть чтение подготовленного набора.');
if(is_dir($dir)){
// Резерв локального bind mount копируется в state до смены активного каталога.
if(isset($p['local_private'])){
$saved=$private.'/backup-'.$number.'-'.bin2hex(random_bytes(8));if(!mkdir($saved,0700))throw new \RuntimeException('Не удалось подготовить резерв.');
foreach(new \FilesystemIterator($dir,\FilesystemIterator::SKIP_DOTS) as $item){if($item->isLink()||!$item->isFile()||!copy($item->getPathname(),$saved.'/'.$item->getFilename()))throw new \RuntimeException('Не удалось сохранить резерв набора.');}
}
self::journal($private.'/swap-'.$number.'.json',['number'=>$number,'stage'=>$stage,'backup'=>$backup,'desired_manifest'=>self::manifest($stage)]);
if(!rename($dir,$backup))throw new \RuntimeException('Не удалось сохранить старый набор.');$moved=true;
}
else self::journal($private.'/swap-'.$number.'.json',['number'=>$number,'stage'=>$stage,'backup'=>$backup,'desired_manifest'=>self::manifest($stage)]);
if(!rename($stage,$dir)) {if($moved)rename($backup,$dir);throw new \RuntimeException('Не удалось активировать набор.');}
foreach($p['files'] as $name=>$file)if(hash_file('sha256',$dir.'/'.$name)!==$file['sha256']) {rename($dir,$stage);if($moved)rename($backup,$dir);throw new \RuntimeException('Ошибка проверки после публикации; выполнен откат.');}
if($moved && isset($p['local_private'])) {
foreach(new \FilesystemIterator($backup,\FilesystemIterator::SKIP_DOTS) as $item)unlink($item->getPathname());rmdir($backup);
}
unlink($private.'/swap-'.$number.'.json');
return ['ok'=>true,'hash'=>hash('sha256',json_encode(array_map(static fn($f)=>$f['sha256'],$p['files'])))];
} finally {if(is_dir($stage)){foreach(new \FilesystemIterator($stage,\FilesystemIterator::SKIP_DOTS) as $item)if($item->isFile())unlink($item->getPathname());rmdir($stage);}}
}
throw new \RuntimeException('Неизвестная операция.');
} catch (\Throwable $e) {return ['ok'=>false,'error'=>$e->getMessage()];} finally {flock($lock,LOCK_UN);fclose($lock);}
}
}