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 { // Этот обработчик исполняется отдельно по SSH, без автозагрузки Config. // Отказ до любых приватных каталогов, recovery, резервов и temp-файлов. if(($p['op']??null)==='write'&&is_string($p['content']??null)){ $candidate=json_decode($p['content']); if($candidate instanceof \stdClass&&($candidate->utm??null) instanceof \stdClass&&property_exists($candidate->utm,'chb'))return ['ok'=>false,'error'=>'Устаревший ключ utm.chb нельзя записывать. Явно удалите его перед сохранением; восстановление такого архива запрещено.']; } $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);} } }