feat: единая админка runtime-конфигов и indpic (#22)
deploy-preland / deploy zoomstage.online (push) Successful in 1m1s
deploy-preland / deploy zoomstage.online (push) Successful in 1m1s
This commit is contained in:
1 parent
5b6b2d2053
commit
9515c1c62b
52 files changed
+2459
-34
No files matched your search
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
namespace PrelandAdmin;
|
||||
final class Auth
|
||||
{
|
||||
public function __construct(private Store $store) {}
|
||||
public static function session(): void {
|
||||
if(session_status()===PHP_SESSION_ACTIVE)return;
|
||||
ini_set('session.use_strict_mode','1');ini_set('session.use_only_cookies','1');
|
||||
session_name('preland_admin');
|
||||
session_set_cookie_params(['lifetime'=>0,'path'=>'/','secure'=>getenv('ADMIN_COOKIE_SECURE')!=='0','httponly'=>true,'samesite'=>'Strict']);
|
||||
session_start();
|
||||
}
|
||||
public function login(string $username,string $password,string $ip): array {
|
||||
$key=hash('sha256',$ip);$this->store->locked('login',function() use($username,$password,$key){
|
||||
$attempts=$this->store->read('login-attempts.json');$now=time();$entry=$attempts[$key]??['count'=>0,'since'=>$now];if($entry['since']<$now-900)$entry=['count'=>0,'since'=>$now];
|
||||
if($entry['count']>=10)throw new Problem('Слишком много попыток. Подождите 15 минут.',429);
|
||||
$expected=getenv('ADMIN_USERNAME')?:'';$hash=getenv('ADMIN_PASSWORD_HASH')?:'';
|
||||
if($expected===''||$hash==='')throw new Problem('Администратор ещё не настроен.',503);
|
||||
$valid=password_verify($password,$hash)&&hash_equals($expected,$username);
|
||||
if(!$valid){$entry['count']++;$attempts[$key]=$entry;$attempts=array_filter($attempts,static fn($e)=>$e['since']>$now-900);$this->store->write('login-attempts.json',$attempts);throw new Problem('Неверный логин или пароль.',401);}
|
||||
unset($attempts[$key]);$this->store->write('login-attempts.json',$attempts);
|
||||
});
|
||||
session_regenerate_id(true);$_SESSION=['user'=>$username,'csrf'=>bin2hex(random_bytes(32)),'last'=>time()];return ['user'=>$username,'csrf'=>$_SESSION['csrf']];
|
||||
}
|
||||
public function requireUser(): string {
|
||||
if(empty($_SESSION['user'])||($_SESSION['last']??0)<time()-3600){$_SESSION=[];throw new Problem('Войдите в админку.',401);}$_SESSION['last']=time();return $_SESSION['user'];
|
||||
}
|
||||
public function csrf(string $token): void {if(!$token||!hash_equals($_SESSION['csrf']??'', $token))throw new Problem('Сессия формы истекла. Обновите страницу.',403);}
|
||||
public function logout(): void {$_SESSION=[];session_regenerate_id(true);}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
namespace PrelandAdmin;
|
||||
final class Catalog
|
||||
{
|
||||
public static function read(Store $store,\stdClass $config): ?array {
|
||||
$catalog=$store->read('catalog.json',null);if(!is_array($catalog)||!is_string($catalog['generated_at']??null))return null;
|
||||
$at=strtotime($catalog['generated_at']);if($at===false||$at<time()-180||$at>time()+30)return null;
|
||||
$host=static fn($value)=>strtolower(rtrim((string)parse_url(str_contains((string)$value,'://')?(string)$value:'https://'.$value,PHP_URL_HOST),'.'));
|
||||
if(!$host($config->host??'')||$host($catalog['host']??'')!==$host($config->host??''))return null;
|
||||
foreach(['degr','tp','cons'] as $group){if(!is_array($catalog['groups'][$group]??null))return null;foreach($catalog['groups'][$group] as $entry)if(!is_array($entry)||!is_int($entry['number']??null)||$entry['number']<1||$entry['number']>9||!is_string($entry['name']??null))return null;}
|
||||
return $catalog;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
namespace PrelandAdmin;
|
||||
final class Config
|
||||
{
|
||||
public static function decode(string $json): \stdClass {
|
||||
try { $value = json_decode($json, false, 64, JSON_THROW_ON_ERROR); } catch (\Throwable) { throw new Problem('Настройки содержат некорректный JSON.'); }
|
||||
if (!$value instanceof \stdClass) throw new Problem('Настройки должны быть объектом.');
|
||||
return $value;
|
||||
}
|
||||
public static function encode(\stdClass $value): string { return json_encode($value, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES|JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR) . "\n"; }
|
||||
public static function patch(\stdClass $config, array $changes): \stdClass {
|
||||
if (!$changes || count($changes)>500) throw new Problem('Выберите изменённые поля (не более 500).');
|
||||
$copy = self::decode(self::encode($config));
|
||||
foreach ($changes as $change) {
|
||||
$change=self::adaptChange($copy,$change);
|
||||
$path = $change['path'] ?? [];
|
||||
if (!is_array($path) || !$path || count($path)>16) throw new Problem('Некорректный путь поля.');
|
||||
$ref = &$copy;
|
||||
foreach ($path as $i=>$part) {
|
||||
if (!is_string($part) || $part==='' || strlen($part)>100 || in_array($part,['__proto__','constructor','prototype'],true) || preg_match('/[\x00-\x1f]/',$part)) throw new Problem('Недопустимый ключ поля.');
|
||||
$last = $i===count($path)-1;
|
||||
if ($ref instanceof \stdClass) {
|
||||
if ($last) { if (!empty($change['delete'])) unset($ref->$part); else $ref->$part=$change['value'] ?? null; }
|
||||
else { if (!property_exists($ref,$part)) $ref->$part=new \stdClass(); $ref=&$ref->$part; }
|
||||
} elseif (is_array($ref) && ctype_digit($part)) {
|
||||
$index=(int)$part;
|
||||
if ($last) { if (!empty($change['delete'])) { array_splice($ref,$index,1); } else $ref[$index]=$change['value'] ?? null; }
|
||||
elseif (array_key_exists($index,$ref)) $ref=&$ref[$index]; else throw new Problem('Элемент массива не найден.');
|
||||
} else throw new Problem('Путь проходит через скалярное поле.');
|
||||
}
|
||||
unset($ref);
|
||||
}
|
||||
return $copy;
|
||||
}
|
||||
private static function adaptChange(\stdClass $config,array $change): array {
|
||||
$path=$change['path']??[];if(($path[0]??null)!=='utm'||!isset($path[1]))return $change;
|
||||
$aliases=['reg'=>['reg','registration'],'banch'=>['banch','chob','bchoice']];$maps=['theme'=>['1'=>'dark','2'=>'light'],'reg'=>['1'=>'before','2'=>'after'],'banch'=>['1'=>'b','2'=>'a'],'focus'=>['1'=>'face','2'=>'center']];
|
||||
$key=$path[1];$canonical=['registration'=>'reg','chob'=>'banch','bchoice'=>'banch'][$key]??$key;$settings=$config->utm??new \stdClass();$existing=[];
|
||||
foreach($aliases[$canonical]??[$canonical] as $alias)if(isset($settings->$alias))$existing[]=$alias;
|
||||
if(count($existing)>1)throw new Problem("Конфликт UTM-ключей {$canonical}. Перечитайте и явно устраните неоднозначность.");
|
||||
$actual=$existing[0]??$canonical;$path[1]=$actual;$group=$settings->$actual??null;$map=$maps[$canonical]??[];$semantic=false;
|
||||
if($group instanceof \stdClass){$semantic=in_array($group->default??null,array_values($map),true);foreach(get_object_vars($group->weights??new \stdClass()) as $variant=>$unused)if(in_array((string)$variant,array_values($map),true))$semantic=true;}
|
||||
$convert=static function($value)use($map,$semantic){$original=$value;$digit=array_search((string)$value,$map,true);$digit=$digit===false?(string)$value:(string)$digit;return $semantic?($map[$digit]??$original):(is_int($original)?$original:$digit);};
|
||||
if(($path[2]??null)==='weights'&&isset($path[3]))$path[3]=(string)$convert($path[3]);
|
||||
if(array_key_exists('value',$change)){
|
||||
if(($path[2]??null)==='default')$change['value']=$convert($change['value']);
|
||||
if(count($path)===3&&$path[2]==='weights'&&$change['value'] instanceof \stdClass){$weights=new \stdClass();foreach(get_object_vars($change['value']) as $variant=>$weight){$variant=(string)$convert($variant);if(property_exists($weights,$variant))throw new Problem('Неоднозначные варианты весов.');$weights->$variant=$weight;}$change['value']=$weights;}
|
||||
if(count($path)===2&&$change['value'] instanceof \stdClass){$value=self::decode(self::encode($change['value']));if(isset($value->default))$value->default=$convert($value->default);if(($value->weights??null)instanceof \stdClass){$weights=new \stdClass();foreach(get_object_vars($value->weights) as $variant=>$weight){$variant=(string)$convert($variant);if(property_exists($weights,$variant))throw new Problem('Неоднозначные варианты весов.');$weights->$variant=$weight;}$value->weights=$weights;}$change['value']=$value;}
|
||||
}
|
||||
$change['path']=$path;return $change;
|
||||
}
|
||||
public static function validate(\stdClass $config, callable $setExists,?array $catalog=null): array {
|
||||
if (isset($config->host)) {
|
||||
$host = $config->host;
|
||||
if (!is_string($host) || !preg_match('~^(?:https://)?[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?(?::[0-9]{1,5})?$~iD',$host) || str_contains($host,'..')) throw new Problem('Адрес сайта должен быть HTTPS-хостом без пути.');
|
||||
}
|
||||
if (isset($config->requisites) && (!is_array($config->requisites) || array_filter($config->requisites,static fn($line)=>!is_string($line)))) throw new Problem('Реквизиты должны содержать строки.');
|
||||
$utm=$config->utm ?? new \stdClass();
|
||||
if (!$utm instanceof \stdClass) throw new Problem('UTM должен быть объектом.');
|
||||
foreach (['registration'=>'reg','chob'=>'banch','bchoice'=>'banch'] as $old=>$new) if (isset($utm->$old,$utm->$new)) throw new Problem("Конфликт старого {$old} и нового {$new} UTM-ключа.");
|
||||
$maps=['theme'=>['1'=>'dark','2'=>'light'],'reg'=>['1'=>'before','2'=>'after'],'banch'=>['1'=>'b','2'=>'a'],'focus'=>['1'=>'face','2'=>'center']];
|
||||
$warnings=[];
|
||||
foreach (get_object_vars($utm) as $key=>$group) {
|
||||
$canonical=['registration'=>'reg','chob'=>'banch','bchoice'=>'banch'][$key] ?? $key;
|
||||
if (!in_array($canonical,['indpic','theme','degr','reg','banch','tp','cons','focus'],true)) continue;
|
||||
if (!$group instanceof \stdClass || !isset($group->default) || !(($group->weights ?? null) instanceof \stdClass)) throw new Problem("Некорректная структура UTM {$key}.");
|
||||
$number=static function($value) use($maps,$canonical): string {
|
||||
if (!is_int($value) && !is_string($value)) throw new Problem('Вариант UTM должен быть номером.');
|
||||
$value=(string)$value;
|
||||
if (isset($maps[$canonical])) { $digit=array_search($value,$maps[$canonical],true); if ($digit!==false) return (string)$digit; if (!isset($maps[$canonical][$value])) throw new Problem("Недопустимый вариант {$canonical}."); }
|
||||
if (!preg_match('/^[1-9]$/D',$value)) throw new Problem('Вариант UTM должен быть 1–9.');
|
||||
return $value;
|
||||
};
|
||||
$default=$number($group->default);$seen=[];
|
||||
foreach (get_object_vars($group->weights) as $variant=>$weight) {
|
||||
$digit=$number($variant);
|
||||
if (isset($seen[$digit])) throw new Problem('Конфликт semantic и numeric вариантов UTM.'); $seen[$digit]=true;
|
||||
if ((!is_int($weight) && !is_float($weight)) || !is_finite((float)$weight) || $weight<0) throw new Problem('Вес должен быть конечным неотрицательным числом.');
|
||||
if ($canonical==='indpic' && !$setExists((int)$digit)) throw new Problem("Набор {$digit} отсутствует или не прошёл проверку.");
|
||||
if($catalog!==null&&in_array($canonical,['degr','tp','cons'],true)&&!in_array((int)$digit,array_column($catalog['groups'][$canonical]??[],'number'),true))throw new Problem("Группа {$canonical} {$digit} отсутствует в доступном каталоге основного сайта.");
|
||||
}
|
||||
if (in_array($canonical,['indpic','degr','tp'],true)&&!isset($seen[$default])) throw new Problem("Добавьте вариант {$default} в таблицу весов {$key} (допустим вес 0).");
|
||||
if ($canonical==='indpic' && !$setExists((int)$default)) throw new Problem("Набор default {$default} отсутствует или не прошёл проверку.");
|
||||
if (in_array($canonical,['degr','tp','cons'],true)) {
|
||||
if($catalog===null)$warnings[]="{$canonical}: существование группы на основном сайте не проверено.";
|
||||
elseif(!in_array((int)$default,array_column($catalog['groups'][$canonical]??[],'number'),true))throw new Problem("Группа default {$canonical} {$default} отсутствует в доступном каталоге основного сайта.");
|
||||
}
|
||||
}
|
||||
return $warnings;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<?php
|
||||
namespace PrelandAdmin;
|
||||
final class Conflict extends Problem { public function __construct() { parent::__construct('Файл изменился. Перечитайте настройки и повторите изменение.',409); } }
|
||||
@@ -0,0 +1,384 @@
|
||||
<?php
|
||||
|
||||
namespace PrelandAdmin;
|
||||
|
||||
use FilesystemIterator;
|
||||
use RecursiveDirectoryIterator;
|
||||
use RecursiveIteratorIterator;
|
||||
use RuntimeException;
|
||||
use ZipArchive;
|
||||
|
||||
/**
|
||||
* Файловый контракт Set демонстрационных изображений Index.
|
||||
* Перенесено из API 591a67cc8302 components/indpic/IndpicSetStorage.php (preland#22).
|
||||
*
|
||||
* Компонент не знает о БД: он только проверяет и атомарно заменяет содержимое
|
||||
* каталогов `images/indpic/<номер>`. Это позволяет одинаково применять его к
|
||||
* Set, добавленному через SFTP или ZIP CPanel.
|
||||
*/
|
||||
final class IndpicSetStorage
|
||||
{
|
||||
/** @var array<string, array{width: int, height: int}> */
|
||||
private const DIMENSIONS = [
|
||||
'01_desk_1-source.avif' => ['width' => 1696, 'height' => 920],
|
||||
'01_desk_1-source.webp' => ['width' => 1696, 'height' => 920],
|
||||
'01_desk_1-source.jpg' => ['width' => 1696, 'height' => 920],
|
||||
'01_desk_2-result.avif' => ['width' => 1696, 'height' => 920],
|
||||
'01_desk_2-result.webp' => ['width' => 1696, 'height' => 920],
|
||||
'01_desk_2-result.jpg' => ['width' => 1696, 'height' => 920],
|
||||
'01_desk_3-thumb.avif' => ['width' => 200, 'height' => 200],
|
||||
'01_desk_3-thumb.webp' => ['width' => 200, 'height' => 200],
|
||||
'01_desk_3-thumb.jpg' => ['width' => 200, 'height' => 200],
|
||||
'01_mob_1-source.avif' => ['width' => 400, 'height' => 217],
|
||||
'01_mob_1-source.webp' => ['width' => 400, 'height' => 217],
|
||||
'01_mob_1-source.jpg' => ['width' => 400, 'height' => 217],
|
||||
'01_mob_2-result.avif' => ['width' => 400, 'height' => 217],
|
||||
'01_mob_2-result.webp' => ['width' => 400, 'height' => 217],
|
||||
'01_mob_2-result.jpg' => ['width' => 400, 'height' => 217],
|
||||
'01_mob_3-thumb.avif' => ['width' => 60, 'height' => 60],
|
||||
'01_mob_3-thumb.webp' => ['width' => 60, 'height' => 60],
|
||||
'01_mob_3-thumb.jpg' => ['width' => 60, 'height' => 60],
|
||||
'02_desk_1-source.avif' => ['width' => 1696, 'height' => 920],
|
||||
'02_desk_1-source.webp' => ['width' => 1696, 'height' => 920],
|
||||
'02_desk_1-source.jpg' => ['width' => 1696, 'height' => 920],
|
||||
'02_desk_2-result.avif' => ['width' => 1696, 'height' => 920],
|
||||
'02_desk_2-result.webp' => ['width' => 1696, 'height' => 920],
|
||||
'02_desk_2-result.jpg' => ['width' => 1696, 'height' => 920],
|
||||
'02_desk_3-thumb.avif' => ['width' => 200, 'height' => 200],
|
||||
'02_desk_3-thumb.webp' => ['width' => 200, 'height' => 200],
|
||||
'02_desk_3-thumb.jpg' => ['width' => 200, 'height' => 200],
|
||||
'02_mob_1-source.avif' => ['width' => 400, 'height' => 217],
|
||||
'02_mob_1-source.webp' => ['width' => 400, 'height' => 217],
|
||||
'02_mob_1-source.jpg' => ['width' => 400, 'height' => 217],
|
||||
'02_mob_2-result.avif' => ['width' => 400, 'height' => 217],
|
||||
'02_mob_2-result.webp' => ['width' => 400, 'height' => 217],
|
||||
'02_mob_2-result.jpg' => ['width' => 400, 'height' => 217],
|
||||
'02_mob_3-thumb.avif' => ['width' => 60, 'height' => 60],
|
||||
'02_mob_3-thumb.webp' => ['width' => 60, 'height' => 60],
|
||||
'02_mob_3-thumb.jpg' => ['width' => 60, 'height' => 60],
|
||||
'03_desk_1-source.avif' => ['width' => 1696, 'height' => 920],
|
||||
'03_desk_1-source.webp' => ['width' => 1696, 'height' => 920],
|
||||
'03_desk_1-source.jpg' => ['width' => 1696, 'height' => 920],
|
||||
'03_desk_2-result.avif' => ['width' => 1696, 'height' => 920],
|
||||
'03_desk_2-result.webp' => ['width' => 1696, 'height' => 920],
|
||||
'03_desk_2-result.jpg' => ['width' => 1696, 'height' => 920],
|
||||
'03_desk_3-thumb.avif' => ['width' => 200, 'height' => 200],
|
||||
'03_desk_3-thumb.webp' => ['width' => 200, 'height' => 200],
|
||||
'03_desk_3-thumb.jpg' => ['width' => 200, 'height' => 200],
|
||||
'03_mob_1-source.avif' => ['width' => 400, 'height' => 217],
|
||||
'03_mob_1-source.webp' => ['width' => 400, 'height' => 217],
|
||||
'03_mob_1-source.jpg' => ['width' => 400, 'height' => 217],
|
||||
'03_mob_2-result.avif' => ['width' => 400, 'height' => 217],
|
||||
'03_mob_2-result.webp' => ['width' => 400, 'height' => 217],
|
||||
'03_mob_2-result.jpg' => ['width' => 400, 'height' => 217],
|
||||
'03_mob_3-thumb.avif' => ['width' => 60, 'height' => 60],
|
||||
'03_mob_3-thumb.webp' => ['width' => 60, 'height' => 60],
|
||||
'03_mob_3-thumb.jpg' => ['width' => 60, 'height' => 60],
|
||||
'04_desk_1-source.avif' => ['width' => 1696, 'height' => 920],
|
||||
'04_desk_1-source.webp' => ['width' => 1696, 'height' => 920],
|
||||
'04_desk_1-source.jpg' => ['width' => 1696, 'height' => 920],
|
||||
'04_desk_2-result.avif' => ['width' => 1696, 'height' => 920],
|
||||
'04_desk_2-result.webp' => ['width' => 1696, 'height' => 920],
|
||||
'04_desk_2-result.jpg' => ['width' => 1696, 'height' => 920],
|
||||
'04_desk_3-thumb.avif' => ['width' => 200, 'height' => 200],
|
||||
'04_desk_3-thumb.webp' => ['width' => 200, 'height' => 200],
|
||||
'04_desk_3-thumb.jpg' => ['width' => 200, 'height' => 200],
|
||||
'04_mob_1-source.avif' => ['width' => 400, 'height' => 217],
|
||||
'04_mob_1-source.webp' => ['width' => 400, 'height' => 217],
|
||||
'04_mob_1-source.jpg' => ['width' => 400, 'height' => 217],
|
||||
'04_mob_2-result.avif' => ['width' => 400, 'height' => 217],
|
||||
'04_mob_2-result.webp' => ['width' => 400, 'height' => 217],
|
||||
'04_mob_2-result.jpg' => ['width' => 400, 'height' => 217],
|
||||
'04_mob_3-thumb.avif' => ['width' => 60, 'height' => 60],
|
||||
'04_mob_3-thumb.webp' => ['width' => 60, 'height' => 60],
|
||||
'04_mob_3-thumb.jpg' => ['width' => 60, 'height' => 60],
|
||||
'05_desk_1-source.avif' => ['width' => 1696, 'height' => 920],
|
||||
'05_desk_1-source.webp' => ['width' => 1696, 'height' => 920],
|
||||
'05_desk_1-source.jpg' => ['width' => 1696, 'height' => 920],
|
||||
'05_desk_2-result.avif' => ['width' => 1696, 'height' => 920],
|
||||
'05_desk_2-result.webp' => ['width' => 1696, 'height' => 920],
|
||||
'05_desk_2-result.jpg' => ['width' => 1696, 'height' => 920],
|
||||
'05_desk_3-thumb.avif' => ['width' => 200, 'height' => 200],
|
||||
'05_desk_3-thumb.webp' => ['width' => 200, 'height' => 200],
|
||||
'05_desk_3-thumb.jpg' => ['width' => 200, 'height' => 200],
|
||||
'05_mob_1-source.avif' => ['width' => 400, 'height' => 217],
|
||||
'05_mob_1-source.webp' => ['width' => 400, 'height' => 217],
|
||||
'05_mob_1-source.jpg' => ['width' => 400, 'height' => 217],
|
||||
'05_mob_2-result.avif' => ['width' => 400, 'height' => 217],
|
||||
'05_mob_2-result.webp' => ['width' => 400, 'height' => 217],
|
||||
'05_mob_2-result.jpg' => ['width' => 400, 'height' => 217],
|
||||
'05_mob_3-thumb.avif' => ['width' => 60, 'height' => 60],
|
||||
'05_mob_3-thumb.webp' => ['width' => 60, 'height' => 60],
|
||||
'05_mob_3-thumb.jpg' => ['width' => 60, 'height' => 60],
|
||||
];
|
||||
|
||||
private readonly ?\Closure $imageInspector;
|
||||
|
||||
public function __construct(private readonly string $root, ?callable $imageInspector = null)
|
||||
{
|
||||
$this->imageInspector = $imageInspector === null ? null : \Closure::fromCallable($imageInspector);
|
||||
}
|
||||
|
||||
/** @return array<string, array{width: int, height: int}> */
|
||||
public static function expectedDimensions(): array
|
||||
{
|
||||
return self::DIMENSIONS;
|
||||
}
|
||||
|
||||
public function isComplete(int $set): bool
|
||||
{
|
||||
try {
|
||||
$this->validateDirectory($set);
|
||||
return true;
|
||||
} catch (RuntimeException) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** @throws RuntimeException если каталог не соответствует контракту Set. */
|
||||
public function validateDirectory(int $set): void
|
||||
{
|
||||
$directory = $this->directory($set);
|
||||
if (!is_dir($directory)) {
|
||||
throw new RuntimeException("Папка Set {$set} не найдена.");
|
||||
}
|
||||
|
||||
$actual = [];
|
||||
$errors = [];
|
||||
foreach (new FilesystemIterator($directory, FilesystemIterator::SKIP_DOTS) as $item) {
|
||||
if ($item->isLink() || !$item->isFile()) {
|
||||
$errors[] = "Лишний элемент в Set {$set}: {$item->getFilename()}.";
|
||||
continue;
|
||||
}
|
||||
$actual[$item->getFilename()] = $item->getPathname();
|
||||
}
|
||||
|
||||
$expected = self::DIMENSIONS;
|
||||
foreach (array_keys($expected) as $filename) {
|
||||
if (!isset($actual[$filename])) {
|
||||
$errors[] = "Отсутствует обязательный файл {$filename}.";
|
||||
}
|
||||
}
|
||||
foreach (array_keys($actual) as $filename) {
|
||||
if (!isset($expected[$filename])) {
|
||||
$errors[] = "Лишний файл {$filename}.";
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($expected as $filename => $dimensions) {
|
||||
if (!isset($actual[$filename])) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
$actualDimensions = $this->dimensions($actual[$filename]);
|
||||
if ($actualDimensions !== $dimensions) {
|
||||
$errors[] = sprintf(
|
||||
'Неверный размер %s: %d×%d, ожидалось %d×%d.',
|
||||
$filename,
|
||||
$actualDimensions['width'],
|
||||
$actualDimensions['height'],
|
||||
$dimensions['width'],
|
||||
$dimensions['height']
|
||||
);
|
||||
}
|
||||
} catch (RuntimeException $exception) {
|
||||
$errors[] = $exception->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
if ($errors !== []) {
|
||||
throw new RuntimeException(implode("\n", $errors));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Полностью и атомарно заменяет Set распакованным ZIP после проверки всех файлов.
|
||||
*
|
||||
* @throws RuntimeException
|
||||
*/
|
||||
public function replaceFromZip(int $set, string $zipPath): void
|
||||
{
|
||||
$this->assertSetNumber($set);
|
||||
if (!is_file($zipPath)) {
|
||||
throw new RuntimeException('ZIP-файл не получен.');
|
||||
}
|
||||
if (!class_exists(ZipArchive::class)) {
|
||||
throw new RuntimeException('На сервере не доступна поддержка ZIP.');
|
||||
}
|
||||
|
||||
$stagingParent = $this->root . DIRECTORY_SEPARATOR . '.staging';
|
||||
$staging = $stagingParent . DIRECTORY_SEPARATOR . $set . '-' . bin2hex(random_bytes(8));
|
||||
$stagedSet = $staging . DIRECTORY_SEPARATOR . $set;
|
||||
if (!is_dir($stagingParent) && !mkdir($stagingParent, 0775, true) && !is_dir($stagingParent)) {
|
||||
throw new RuntimeException('Не удалось создать staging-каталог Set.');
|
||||
}
|
||||
if (!mkdir($stagedSet, 0775, true)) {
|
||||
throw new RuntimeException('Не удалось подготовить staging-каталог Set.');
|
||||
}
|
||||
|
||||
try {
|
||||
$this->extractZip($zipPath, $set, $stagedSet);
|
||||
$this->validateDirectoryPath($stagedSet, $set);
|
||||
$this->activateStagedSet($set, $stagedSet);
|
||||
} catch (\Throwable $exception) {
|
||||
if (is_dir($staging)) {
|
||||
$this->removeDirectory($staging);
|
||||
}
|
||||
throw $exception instanceof RuntimeException
|
||||
? $exception
|
||||
: new RuntimeException('Не удалось заменить Set ZIP-архивом.', 0, $exception);
|
||||
}
|
||||
|
||||
if (is_dir($staging)) {
|
||||
$this->removeDirectory($staging);
|
||||
}
|
||||
}
|
||||
|
||||
private function extractZip(string $zipPath, int $set, string $target): void
|
||||
{
|
||||
$zip = new ZipArchive();
|
||||
if ($zip->open($zipPath) !== true) {
|
||||
throw new RuntimeException('Не удалось открыть ZIP-архив.');
|
||||
}
|
||||
|
||||
try {
|
||||
$prefix = $set . '/';
|
||||
$files = [];
|
||||
$directories = [];
|
||||
for ($index = 0; $index < $zip->numFiles; $index++) {
|
||||
$name = (string) $zip->getNameIndex($index);
|
||||
if ($name === '' || str_contains($name, '\\') || str_starts_with($name, '/') || str_contains($name, '../')) {
|
||||
throw new RuntimeException('ZIP содержит недопустимую структуру файлов.');
|
||||
}
|
||||
if ($name === $prefix) {
|
||||
$directories[] = $name;
|
||||
continue;
|
||||
}
|
||||
if (!str_starts_with($name, $prefix) || str_contains(substr($name, strlen($prefix)), '/')) {
|
||||
throw new RuntimeException('ZIP содержит недопустимую структуру файлов.');
|
||||
}
|
||||
|
||||
$filename = substr($name, strlen($prefix));
|
||||
if (!isset(self::DIMENSIONS[$filename]) || isset($files[$filename])) {
|
||||
throw new RuntimeException('ZIP содержит лишний или повторяющийся файл.');
|
||||
}
|
||||
$files[$filename] = $index;
|
||||
}
|
||||
|
||||
// Некоторые ZIP-клиенты (в частности проводник Windows) не добавляют
|
||||
// отдельную запись корневого каталога: достаточно 90 файлов с единым
|
||||
// префиксом "<номер Set>/". Вложенные каталоги всё равно отклоняются выше.
|
||||
if (count($directories) > 1 || count($files) !== count(self::DIMENSIONS)) {
|
||||
throw new RuntimeException("ZIP должен содержать папку {$set} и ровно 90 файлов в ней.");
|
||||
}
|
||||
|
||||
foreach (array_keys(self::DIMENSIONS) as $filename) {
|
||||
if (!isset($files[$filename])) {
|
||||
throw new RuntimeException("В ZIP отсутствует обязательный файл {$filename}.");
|
||||
}
|
||||
$stream = $zip->getStream($prefix . $filename);
|
||||
if ($stream === false) {
|
||||
throw new RuntimeException("Не удалось прочитать файл {$filename} из ZIP.");
|
||||
}
|
||||
$destination = $target . DIRECTORY_SEPARATOR . $filename;
|
||||
$output = fopen($destination, 'wb');
|
||||
if ($output === false) {
|
||||
fclose($stream);
|
||||
throw new RuntimeException("Не удалось подготовить файл {$filename}.");
|
||||
}
|
||||
$copied = stream_copy_to_stream($stream, $output, 8388609);
|
||||
fclose($output);
|
||||
fclose($stream);
|
||||
if ($copied === false || $copied > 8388608) {
|
||||
throw new RuntimeException("Не удалось распаковать файл {$filename}.");
|
||||
}
|
||||
chmod($destination, 0644);
|
||||
}
|
||||
} finally {
|
||||
$zip->close();
|
||||
}
|
||||
}
|
||||
|
||||
private function activateStagedSet(int $set, string $stagedSet): void
|
||||
{
|
||||
$destination = $this->directory($set);
|
||||
$backup = $this->root . DIRECTORY_SEPARATOR . '.backup-' . $set . '-' . bin2hex(random_bytes(8));
|
||||
$hadDestination = is_dir($destination);
|
||||
|
||||
if ($hadDestination && !rename($destination, $backup)) {
|
||||
throw new RuntimeException('Не удалось подготовить замену текущего Set.');
|
||||
}
|
||||
if (!rename($stagedSet, $destination)) {
|
||||
if ($hadDestination) {
|
||||
rename($backup, $destination);
|
||||
}
|
||||
throw new RuntimeException('Не удалось активировать новый Set.');
|
||||
}
|
||||
if ($hadDestination) {
|
||||
$this->removeDirectory($backup);
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array{width: int, height: int} */
|
||||
private function dimensions(string $path): array
|
||||
{
|
||||
if ($this->imageInspector !== null) {
|
||||
$dimensions = ($this->imageInspector)($path);
|
||||
if (!is_array($dimensions) || !isset($dimensions['width'], $dimensions['height'])) {
|
||||
throw new RuntimeException('Проверка изображения вернула некорректный размер.');
|
||||
}
|
||||
return ['width' => (int) $dimensions['width'], 'height' => (int) $dimensions['height']];
|
||||
}
|
||||
if (!class_exists(\Imagick::class)) {
|
||||
throw new RuntimeException('На сервере не доступна проверка изображений Imagick.');
|
||||
}
|
||||
|
||||
try {
|
||||
$image = new \Imagick();
|
||||
$image->readImage($path);
|
||||
$format = strtoupper($image->getImageFormat());
|
||||
$extension = strtolower(pathinfo($path, PATHINFO_EXTENSION));
|
||||
$allowed = ['jpg'=>['JPEG','JPG'], 'webp'=>['WEBP'], 'avif'=>['AVIF','HEIC']];
|
||||
if (!in_array($format, $allowed[$extension] ?? [], true) || $image->getNumberImages() !== 1) {
|
||||
throw new RuntimeException('Формат изображения не совпадает с расширением либо содержит несколько кадров.');
|
||||
}
|
||||
$dimensions = ['width' => $image->getImageWidth(), 'height' => $image->getImageHeight()];
|
||||
$image->clear();
|
||||
$image->destroy();
|
||||
return $dimensions;
|
||||
} catch (\Throwable $exception) {
|
||||
throw new RuntimeException('Файл ' . basename($path) . ' не является корректным изображением.', 0, $exception);
|
||||
}
|
||||
}
|
||||
|
||||
private function validateDirectoryPath(string $directory, int $set): void
|
||||
{
|
||||
$storage = new self(dirname($directory), $this->imageInspector);
|
||||
$storage->validateDirectory($set);
|
||||
}
|
||||
|
||||
private function directory(int $set): string
|
||||
{
|
||||
$this->assertSetNumber($set);
|
||||
return $this->root . DIRECTORY_SEPARATOR . $set;
|
||||
}
|
||||
|
||||
private function assertSetNumber(int $set): void
|
||||
{
|
||||
if ($set < 1 || $set > 9) {
|
||||
throw new RuntimeException('Номер Set должен быть от 1 до 9.');
|
||||
}
|
||||
}
|
||||
|
||||
private function removeDirectory(string $directory): void
|
||||
{
|
||||
$iterator = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($directory, FilesystemIterator::SKIP_DOTS),
|
||||
RecursiveIteratorIterator::CHILD_FIRST
|
||||
);
|
||||
foreach ($iterator as $item) {
|
||||
$item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname());
|
||||
}
|
||||
rmdir($directory);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
namespace PrelandAdmin;
|
||||
final class Jobs
|
||||
{
|
||||
public function __construct(private Store $store) {}
|
||||
public function create(string $type,array $payload): array {
|
||||
if(!in_array($type,['apply','restore','upload','check','register'],true))throw new Problem('Неизвестный тип задания.');
|
||||
$targets=$payload['targets']??(isset($payload['target'])?[$payload['target']]:[]);if(!$targets||count($targets)>100)throw new Problem('Выберите от 1 до 100 подключений.');
|
||||
$results=[];$seen=[];foreach($targets as $t){$id=is_array($t)?($t['id']??''):$t;if(!is_string($id)||!preg_match('/^[a-zA-Z0-9_-]{1,64}$/D',$id))throw new Problem('Некорректная цель задания.');if(isset($seen[$id]))continue;$seen[$id]=true;$results[]=['target'=>$id,'status'=>'queued','message'=>'Ожидает выполнения.'];}
|
||||
if(isset($payload['changes']))foreach($payload['changes'] as &$change){if(array_key_exists('value',$change)){$change['value_json']=json_encode($change['value'],JSON_THROW_ON_ERROR);unset($change['value']);}}unset($change);
|
||||
$id=bin2hex(random_bytes(16));$job=['id'=>$id,'type'=>$type,'status'=>'queued','created_at'=>gmdate(DATE_ATOM),'results'=>$results,'payload'=>$payload];$this->store->write('jobs/'.$id.'.json',$job);return $this->public($job);
|
||||
}
|
||||
private function public(array $job): array {$job['changes']=array_map(static fn($c)=>['path'=>$c['path']],$job['payload']['changes']??[]);unset($job['payload']);foreach($job['results'] as &$result)unset($result['desired_hash'],$result['source_manifest'],$result['desired_manifest']);return $job;}
|
||||
public function get(string $id): array {$job=$this->store->read('jobs/'.$id.'.json',null);if(!$job)throw new Problem('Задание не найдено.',404);return $this->public($job);}
|
||||
public function all(): array {$jobs=[];foreach(glob($this->store->dir.'/jobs/*.json')?:[] as $path)$jobs[]=$this->public($this->store->read('jobs/'.basename($path)));usort($jobs,static fn($a,$b)=>strcmp($b['created_at'],$a['created_at']));return array_slice($jobs,0,100);}
|
||||
public function retry(string $id,array $targets): array {
|
||||
return $this->store->locked('job-'.$id,function() use($id,$targets){$job=$this->store->read('jobs/'.$id.'.json',null);if(!$job)throw new Problem('Задание не найдено.',404);if($job['status']!=='completed')throw new Problem('Задание ещё выполняется.',409);
|
||||
$count=0;foreach($job['results'] as &$result)if(in_array($result['target'],$targets,true)&&in_array($result['status'],['error','unknown'],true)){$result['status']='queued';$result['message']='Повтор ожидает выполнения.';$count++;}unset($result);
|
||||
if(!$count)throw new Problem('Выберите цели с ошибкой либо неизвестным результатом. Конфликт требует перечитать конфиг.');$job['status']='queued';$this->store->write('jobs/'.$id.'.json',$job);return $this->public($job);
|
||||
});
|
||||
}
|
||||
public function run(string $id): void {
|
||||
$this->store->locked('job-'.$id,function() use($id){
|
||||
$job=$this->store->read('jobs/'.$id.'.json',null);if(!$job||$job['status']==='completed')return;
|
||||
$save=function() use(&$job,$id){$this->store->write('jobs/'.$id.'.json',$job);};$job['status']='running';$save();
|
||||
foreach($job['results'] as $index=>$result){
|
||||
if(!in_array($result['status'],['queued','running'],true))continue;
|
||||
$recover=$result['status']==='running';$job['results'][$index]['status']='running';$save();
|
||||
try{
|
||||
$target=$this->store->target($result['target']);if(empty($target['enabled']))throw new Problem('Подключение отключено.');
|
||||
if(empty($target['verified']))throw new Problem('Сначала проверьте подключение.');
|
||||
$transport=new Transport($this->store,$target);$p=$job['payload'];$type=$job['type'];
|
||||
if(in_array($type,['apply','restore'],true)){
|
||||
$current=$transport->readConfig();$expected=$p['version']??'';
|
||||
if($type==='apply')foreach($p['targets'] as $selection)if($selection['id']===$target['id'])$expected=$selection['version']??'';
|
||||
// После обрыва/рестарта сперва сверяем ожидаемый хеш уже подготовленного контента.
|
||||
$desired=$job['results'][$index]['desired_hash']??null;
|
||||
if($desired && hash_equals($desired,$current['version'])) {$job['results'][$index]=['target'=>$target['id'],'status'=>'success','message'=>'Сохранённое состояние подтверждено после повторного чтения.','version'=>$current['version']];$save();continue;}
|
||||
if(!is_string($expected)||!hash_equals($current['version'],$expected))throw new Conflict();
|
||||
if(isset($p['changes']))foreach($p['changes'] as &$change){if(isset($change['value_json']))$change['value']=json_decode($change['value_json'],false,64,JSON_THROW_ON_ERROR);}unset($change);
|
||||
$next=$type==='restore'?Config::decode($this->store->backupContent($target['id'],$p['id'])):Config::patch($current['config'],$p['changes']??[]);
|
||||
$checked=[];$warnings=Config::validate($next,function($number)use($target,&$checked){if(array_key_exists($number,$checked))return $checked[$number];try{(new Sets($this->store))->check($target,$number);return $checked[$number]=true;}catch(\Throwable){return $checked[$number]=false;}},Catalog::read($this->store,$next));
|
||||
$content=Config::encode($next);$job['results'][$index]['desired_hash']=hash('sha256',$content);$save();
|
||||
$written=$transport->writeConfig($expected,$content);$out=['message'=>'Настройки сохранены и проверены.','version'=>$written['version'],'warnings'=>$warnings];
|
||||
}else{
|
||||
$sets=new Sets($this->store);$number=Sets::number($p['number']??0);
|
||||
if($type==='upload') {
|
||||
$actual=$sets->manifest($target,$number);$saved=$job['results'][$index];
|
||||
if(array_key_exists('desired_manifest',$saved)){
|
||||
if($actual===$saved['desired_manifest']){
|
||||
$out=$sets->check($target,$number,true,$p['name']??'');
|
||||
$out['message']='Опубликованный набор подтверждён после повторного чтения.';
|
||||
}elseif($actual!==($saved['source_manifest']??null))throw new Conflict();
|
||||
else $out=$sets->upload($target,$number,$p['prepared'],$p['name']??'',$saved['source_manifest']??null,true);
|
||||
}else{
|
||||
$job['results'][$index]['source_manifest']=$actual;$job['results'][$index]['desired_manifest']=Sets::preparedManifest($p['prepared'],$number);$save();
|
||||
$out=$sets->upload($target,$number,$p['prepared'],$p['name']??'',$actual,true);
|
||||
}
|
||||
}
|
||||
else $out=$sets->check($target,$number,$type==='register',$p['name']??'');
|
||||
}
|
||||
$job['results'][$index]=['target'=>$target['id'],'status'=>'success']+$out;
|
||||
}catch(Conflict $e){$job['results'][$index]=['target'=>$result['target'],'status'=>'conflict','message'=>$e->getMessage()];}
|
||||
catch(\Throwable $e){$old=$job['results'][$index];$status=$e instanceof Problem&&$e->status===504?'unknown':'error';$job['results'][$index]=['target'=>$result['target'],'status'=>$status,'message'=>$e instanceof Problem?$e->getMessage():'Операция не выполнена. Проверьте доступность и повторите.'];foreach(['desired_hash','desired_manifest','source_manifest'] as $field)if(array_key_exists($field,$old))$job['results'][$index][$field]=$old[$field];}
|
||||
$this->store->audit(['job'=>$id,'target'=>$result['target'],'type'=>$job['type'],'paths'=>array_column($job['payload']['changes']??[],'path'),'status'=>$job['results'][$index]['status']]);$save();
|
||||
}
|
||||
$job['status']='completed';$job['finished_at']=gmdate(DATE_ATOM);$save();
|
||||
});
|
||||
}
|
||||
public function tick(): void {foreach(glob($this->store->dir.'/jobs/*.json')?:[] as $path){$job=$this->store->read('jobs/'.basename($path));if(in_array($job['status'],['queued','running'],true))$this->run($job['id']);}}
|
||||
public function pending(): ?string {foreach(glob($this->store->dir.'/jobs/*.json')?:[] as $path){$job=$this->store->read('jobs/'.basename($path));if(in_array($job['status'],['queued','running'],true))return $job['id'];}return null;}
|
||||
public function interrupted(string $id): void {
|
||||
$this->store->locked('job-'.$id,function()use($id){$job=$this->store->read('jobs/'.$id.'.json',null);if(!$job||$job['status']==='completed')return;$queued=false;
|
||||
foreach($job['results'] as &$result){
|
||||
if($result['status']==='running'){
|
||||
$message='Исполнитель прервался. Повтор сначала перечитает фактическое состояние цели.';
|
||||
if($job['type']==='upload'){
|
||||
// Read выполняет journal recovery под транспортной блокировкой без Imagick.
|
||||
// Supervisor обязан попытаться вернуть публичный путь до ожидания retry пользователя.
|
||||
try{(new Transport($this->store,$this->store->target($result['target'])))->readConfig();$result['recovery_checked']=true;}
|
||||
catch(\Throwable){$result['recovery_checked']=false;$message='Исполнитель прервался. Автоматическое восстановление не подтверждено: цель недоступна. Повтор сначала перечитает фактическое состояние.';}
|
||||
}
|
||||
$result['status']='unknown';$result['message']=$message;
|
||||
}
|
||||
if($result['status']==='queued')$queued=true;
|
||||
}unset($result);
|
||||
$job['status']=$queued?'queued':'completed';if(!$queued)$job['finished_at']=gmdate(DATE_ATOM);$this->store->write('jobs/'.$id.'.json',$job);
|
||||
$this->store->audit(['job'=>$id,'status'=>'interrupted']);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<?php
|
||||
namespace PrelandAdmin;
|
||||
class Problem extends \RuntimeException { public function __construct(string $message, public readonly int $status = 422) { parent::__construct($message); } }
|
||||
@@ -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);}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
namespace PrelandAdmin;
|
||||
final class Sets
|
||||
{
|
||||
public function __construct(private Store $store) {}
|
||||
public static function number(mixed $number): int {if(!preg_match('/^[1-9]$/D',(string)$number)) throw new Problem('Номер набора должен быть 1–9.');return (int)$number;}
|
||||
public function list(array $target, ?\stdClass $config=null): array {
|
||||
$names=$this->store->read('sets.json');$checks=$this->store->read('checks.json');$result=[];$available=(new Transport($this->store,$target))->call(['op'=>'set-list'])['numbers'];
|
||||
$config ??= (new Transport($this->store,$target))->readConfig()['config'];$group=$config->utm->indpic ?? new \stdClass();
|
||||
foreach(range(1,9) as $number){$key=$target['id'].':'.$number;$default=(string)($group->default??'')===(string)$number;$weight=(float)($group->weights->{(string)$number}??0);$entry=$names[$key]??null;
|
||||
$result[]=['number'=>$number,'name'=>$entry['name']??'','exists'=>in_array($number,$available,true),'registered'=>$entry!==null,'default'=>$default,'in_use'=>$default||$weight>0,'checked_at'=>$checks[$key]['at']??null];
|
||||
}return $result;
|
||||
}
|
||||
public function prepareZip(string $file,int $number): string {
|
||||
self::number($number);if(!is_file($file)||filesize($file)>67108864)throw new Problem('ZIP превышает лимит 64 МиБ.');
|
||||
$zip=new \ZipArchive();if($zip->open($file)!==true)throw new Problem('Не удалось открыть ZIP.');$total=0;
|
||||
try{if($zip->numFiles>91)throw new Problem('ZIP содержит более 90 файлов.');for($i=0;$i<$zip->numFiles;$i++){$stat=$zip->statIndex($i);$size=(int)$stat['size'];$total+=$size;if($size>8388608||$total>67108864)throw new Problem('Превышен лимит распаковки (8 МиБ на файл, 64 МиБ на набор).');$opsys=0;$attr=0;if($zip->getExternalAttributesIndex($i,$opsys,$attr)&&(($attr>>16)&0170000)===0120000)throw new Problem('Символические ссылки в ZIP запрещены.');}}finally{$zip->close();}
|
||||
$dir=$this->store->dir.'/uploads/'.bin2hex(random_bytes(16));if(!mkdir($dir,0700,true))throw new Problem('Не удалось подготовить проверку ZIP.');
|
||||
(new IndpicSetStorage($dir))->replaceFromZip($number,$file);return $dir;
|
||||
}
|
||||
public function check(array $target,int $number,bool $register=false,string $name=''): array {
|
||||
$number=self::number($number);$transport=new Transport($this->store,$target);$result=$transport->call(['op'=>'set-read','number'=>$number]);
|
||||
$dir=$this->store->dir.'/checks/'.bin2hex(random_bytes(16));mkdir($dir.'/'.$number,0700,true);
|
||||
try {
|
||||
$expected=IndpicSetStorage::expectedDimensions();if(count($result['files'])!==90)throw new Problem('Набор должен содержать ровно 90 файлов.');$hashes=[];
|
||||
foreach($result['files'] as $filename=>$encoded){if(!isset($expected[$filename]))throw new Problem('В наборе найден лишний файл.');$data=base64_decode($encoded,true);if($data===false)throw new Problem('Ошибка чтения изображения.');file_put_contents($dir.'/'.$number.'/'.$filename,$data);$hashes[$filename]=hash('sha256',$data);}
|
||||
(new IndpicSetStorage($dir))->validateDirectory($number);ksort($hashes);$hash=hash('sha256',json_encode($hashes));
|
||||
$this->store->locked('checks',function() use($target,$number,$hash){$checks=$this->store->read('checks.json');$checks[$target['id'].':'.$number]=['at'=>gmdate(DATE_ATOM),'hash'=>$hash];$this->store->write('checks.json',$checks);});
|
||||
if($register)$this->name($target,$number,$name);
|
||||
return ['message'=>'Проверены 90 изображений, размеры и форматы.','hash'=>$hash];
|
||||
}finally{foreach(glob($dir.'/'.$number.'/*')?:[] as $path)unlink($path);@rmdir($dir.'/'.$number);@rmdir($dir);}
|
||||
}
|
||||
public function manifest(array $target,int $number): ?string {return (new Transport($this->store,$target))->call(['op'=>'set-manifest','number'=>self::number($number)])['manifest'];}
|
||||
public static function preparedManifest(string $dir,int $number): string {$hashes=[];foreach(array_keys(IndpicSetStorage::expectedDimensions()) as $filename){$path=$dir.'/'.self::number($number).'/'.$filename;if(!is_file($path)||is_link($path))throw new Problem('Подготовленный набор повреждён.');$hashes[$filename]=hash_file('sha256',$path);}ksort($hashes);return hash('sha256',json_encode($hashes,JSON_THROW_ON_ERROR));}
|
||||
public function upload(array $target,int $number,string $dir,string $name,?string $expectedManifest=null,bool $hasExpected=false): array {
|
||||
(new IndpicSetStorage($dir))->validateDirectory($number);$files=[];
|
||||
foreach(array_keys(IndpicSetStorage::expectedDimensions()) as $filename){$path=$dir.'/'.$number.'/'.$filename;$files[$filename]=['data'=>base64_encode(file_get_contents($path)),'sha256'=>hash_file('sha256',$path)];}
|
||||
if(!$hasExpected)$expectedManifest=$this->manifest($target,$number);
|
||||
$result=(new Transport($this->store,$target))->call(['op'=>'set-write','number'=>$number,'files'=>$files,'expected_manifest'=>$expectedManifest]);
|
||||
$checked=$this->check($target,$number,true,$name);return $checked;
|
||||
}
|
||||
public function name(array $target,int $number,string $name): void {
|
||||
self::number($number);if(preg_match('//u',$name)!==1||preg_match_all('/./us',$name)>200||preg_match('/[\x00-\x1f]/',$name))throw new Problem('Название набора слишком длинное либо содержит управляющие символы.');
|
||||
$this->store->locked('sets',function() use($target,$number,$name){$sets=$this->store->read('sets.json');$sets[$target['id'].':'.$number]=['name'=>$name];$this->store->write('sets.json',$sets);});
|
||||
}
|
||||
public function delete(array $target,int $number): void {
|
||||
$config=(new Transport($this->store,$target))->readConfig()['config'];foreach($this->list($target,$config) as $set)if($set['number']===$number&&$set['in_use'])throw new Problem('Нельзя убрать используемый набор. Сначала измените default и веса.');
|
||||
$this->store->locked('sets',function() use($target,$number){$sets=$this->store->read('sets.json');unset($sets[$target['id'].':'.$number]);$this->store->write('sets.json',$sets);});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
namespace PrelandAdmin;
|
||||
final class Store
|
||||
{
|
||||
public readonly string $dir;
|
||||
public function __construct(?string $dir=null) {
|
||||
$this->dir=$dir ?? (getenv('ADMIN_STATE_DIR') ?: '/var/lib/preland-admin');
|
||||
foreach (['','jobs','backups','uploads','checks'] as $part) { $path=$this->dir . ($part ? '/'.$part : ''); if (!is_dir($path) && !mkdir($path,0700,true)) throw new Problem('Не удалось открыть приватное хранилище.',503); }
|
||||
}
|
||||
public function locked(string $key, callable $callback): mixed {
|
||||
$fp=fopen($this->dir.'/lock-'.hash('sha256',$key),'c'); if (!$fp || !flock($fp,LOCK_EX)) throw new Problem('Не удалось получить блокировку.',503);
|
||||
try {return $callback();} finally {flock($fp,LOCK_UN);fclose($fp);}
|
||||
}
|
||||
public function read(string $name, mixed $default=[]): mixed {
|
||||
$path=$this->path($name); if (!is_file($path)) return $default;
|
||||
try {return json_decode(file_get_contents($path),true,64,JSON_THROW_ON_ERROR);} catch(\Throwable) {throw new Problem('Повреждено приватное состояние.',503);}
|
||||
}
|
||||
public function write(string $name, mixed $value): void {
|
||||
$path=$this->path($name);$temp=$path.'.'.bin2hex(random_bytes(8));
|
||||
if (file_put_contents($temp,json_encode($value,JSON_UNESCAPED_UNICODE|JSON_THROW_ON_ERROR),LOCK_EX)===false || !rename($temp,$path)) throw new Problem('Не удалось сохранить приватное состояние.',503);
|
||||
chmod($path,0600);
|
||||
}
|
||||
public function path(string $name): string { if (!preg_match('~^[a-zA-Z0-9_./-]+$~D',$name) || str_contains($name,'..') || str_starts_with($name,'/')) throw new Problem('Недопустимый идентификатор.'); return $this->dir.'/'.$name; }
|
||||
public function targets(bool $public=true): array {
|
||||
$targets=array_values($this->read('targets.json'));
|
||||
if ($public) foreach($targets as &$target) {$target['has_secret']=!empty($target['secret']);unset($target['secret']);}
|
||||
return $targets;
|
||||
}
|
||||
public function target(string $id): array {foreach($this->targets(false) as $target) if ($target['id']===$id) return $target;throw new Problem('Подключение не найдено.',404);}
|
||||
public function saveTarget(array $input, ?string $secret=null): array {
|
||||
return $this->locked('targets',function() use($input,$secret) {
|
||||
$targets=$this->read('targets.json');$id=$input['id'] ?? bin2hex(random_bytes(8));
|
||||
if (!is_string($id) || !preg_match('/^[a-zA-Z0-9_-]{1,64}$/D',$id)) throw new Problem('Некорректный ID подключения.');
|
||||
$old=$targets[$id] ?? [];$target=[];
|
||||
foreach(['name','domain','environment','type','host','user','root','host_key'] as $key) {$value=$input[$key] ?? $old[$key] ?? '';if(!is_string($value)||strlen($value)>2048||preg_match('/[\x00-\x1f]/',$value)) throw new Problem('Некорректное поле подключения.');$target[$key]=trim($value);}
|
||||
if (!preg_match('/^[a-z0-9.-]+$/iD',$target['domain']) || !$target['domain']) throw new Problem('Некорректный домен.');
|
||||
if (str_ends_with(strtolower($target['domain']),'.online') && strtolower($target['domain'])!=='zoomstage.online') throw new Problem('Отключённые домены .online подключать нельзя.');
|
||||
if (!in_array($target['type'],['local','sftp'],true)) throw new Problem('Допускаются только local и SFTP/SSH.');
|
||||
$root=rtrim($target['root'],'/');if (!$root || !str_starts_with($root,'/') || str_contains($root,'..') || str_contains($root,'\\')) throw new Problem('Корень должен быть абсолютным безопасным путём.');$target['root']=$root;
|
||||
if ($target['type']==='local') {
|
||||
$roots=array_filter(array_map('trim',explode(',',getenv('ADMIN_LOCAL_ROOTS') ?: '')));
|
||||
if (!in_array($root,$roots,true) || !is_dir($root) || realpath($root)!==$root) throw new Problem('Локальный корень не входит в явно разрешённые mount.');
|
||||
} else {
|
||||
if (!preg_match('/^[a-z0-9_.:-]+$/iD',$target['host']) || !preg_match('/^[a-zA-Z0-9_.-]+$/D',$target['user']) || $target['user']==='root') throw new Problem('Укажите ограниченную SSH-учётную запись и хост.');
|
||||
if (!preg_match('~^(SHA256:[A-Za-z0-9+/]{43}=?|(?:ssh-|ecdsa-)[A-Za-z0-9@._+-]+ [A-Za-z0-9+/=]+)$~D',$target['host_key'])) throw new Problem('Требуется закреплённый SSH host key либо SHA256 fingerprint.');
|
||||
}
|
||||
$target['port']=(int)($input['port'] ?? $old['port'] ?? 22);if($target['port']<1||$target['port']>65535) throw new Problem('Некорректный порт.');
|
||||
$target['id']=$id;$target['enabled']=(bool)($input['enabled'] ?? $old['enabled'] ?? false);$target['secret']=$secret ?? $old['secret'] ?? '';
|
||||
// Изменение транспорта сбрасывает проверку: запись включается только после test.
|
||||
$identity=static fn($t)=>array_intersect_key($t,array_flip(['type','host','port','user','root','host_key','secret']));
|
||||
$target['verified']=isset($old['verified']) && $identity($old)===$identity($target) ? $old['verified'] : false;
|
||||
$targets[$id]=$target;$this->write('targets.json',$targets);$safe=$target;unset($safe['secret']);$safe['has_secret']=$target['secret']!=='';return $safe;
|
||||
});
|
||||
}
|
||||
public function verifyTarget(string $id, ?array $expected=null): void {$this->locked('targets',function() use($id,$expected){$targets=$this->read('targets.json');if(!isset($targets[$id])) throw new Problem('Подключение не найдено.',404);if($expected!==null&&$targets[$id]!==$expected)throw new Problem('Подключение изменилось во время проверки. Повторите проверку.',409);$targets[$id]['verified']=true;$this->write('targets.json',$targets);});}
|
||||
public function deleteTarget(string $id): void {$this->locked('targets',function() use($id){$targets=$this->read('targets.json');unset($targets[$id]);$this->write('targets.json',$targets);});}
|
||||
public function backup(string $target,string $content): string {
|
||||
$id=gmdate('YmdHis').'-'.bin2hex(random_bytes(6));$items=$this->read('backups/index.json');$items[]=['id'=>$id,'target'=>$target,'created_at'=>gmdate(DATE_ATOM)];
|
||||
$this->write('backups/'.$id.'.json',['content'=>$content]);$this->locked('backup-index',function() use($items){$current=$this->read('backups/index.json');$current[]=end($items);$this->write('backups/index.json',$current);});return $id;
|
||||
}
|
||||
public function backups(string $target): array {return array_values(array_map(static fn($item)=>array_diff_key($item,['target'=>true]),array_filter($this->read('backups/index.json'),static fn($item)=>$item['target']===$target)));}
|
||||
public function backupContent(string $target,string $id): string {foreach($this->backups($target) as $item) if($item['id']===$id) return $this->read('backups/'.$id.'.json')['content'];throw new Problem('Резервная версия не найдена.',404);}
|
||||
public function audit(array $data): void {$this->locked('audit',function() use($data){file_put_contents($this->dir.'/audit.jsonl',json_encode(['at'=>gmdate(DATE_ATOM)]+$data,JSON_UNESCAPED_UNICODE|JSON_THROW_ON_ERROR)."\n",FILE_APPEND|LOCK_EX);});}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
namespace PrelandAdmin;
|
||||
use phpseclib3\Net\SSH2;
|
||||
use phpseclib3\Net\SFTP;
|
||||
use phpseclib3\Crypt\PublicKeyLoader;
|
||||
final class Transport
|
||||
{
|
||||
private ?SSH2 $ssh=null;
|
||||
public function __construct(private Store $store, public readonly array $target) {}
|
||||
private function connect(): SSH2 {
|
||||
if($this->ssh) return $this->ssh;
|
||||
$t=$this->target;$ssh=new SSH2($t['host'],$t['port'],20);$key=$ssh->getServerPublicHostKey();
|
||||
if(!is_string($key)) throw new Problem('Не удалось получить ключ SSH.');
|
||||
$expected=$t['host_key'];$parts=explode(' ',$key);$fp='SHA256:'.rtrim(base64_encode(hash('sha256',base64_decode($parts[1]??'',true)?:'',true)),'=');
|
||||
if(!hash_equals(rtrim($expected,'='),rtrim(str_starts_with($expected,'SHA256:')?$fp:$key,'='))) throw new Problem('Ключ SSH хоста не совпадает с закреплённым.');
|
||||
$secret=$t['secret'];if(str_contains($secret,'PRIVATE KEY')){try{$secret=PublicKeyLoader::loadPrivateKey($secret);}catch(\Throwable){throw new Problem('Некорректный приватный ключ.');}}
|
||||
if(!$ssh->login($t['user'],$secret)) throw new Problem('Не удалось войти в SSH.');$ssh->setTimeout(60);return $this->ssh=$ssh;
|
||||
}
|
||||
public function call(array $payload): array {
|
||||
$payload['root']=$this->target['root'];
|
||||
if($this->target['type']==='local') {
|
||||
$roots=array_filter(array_map('trim',explode(',',getenv('ADMIN_LOCAL_ROOTS')?:'')));
|
||||
if(!in_array($this->target['root'],$roots,true))throw new Problem('Локальный путь больше не разрешён.');
|
||||
$payload['local_private']=$this->store->dir.'/local-'.hash('sha256',$this->target['root']);$result=Remote::execute($payload);
|
||||
}
|
||||
else {
|
||||
$ssh=$this->connect();$code=file_get_contents(__DIR__.'/Remote.php');$code=preg_replace('/^<\?php\s*/','',$code);
|
||||
// Аргументы проходят shell quoting, обработчик не принимает команды от клиента.
|
||||
$runner=$code."\necho json_encode(\\PrelandAdmin\\Remote::execute(json_decode(base64_decode(\$argv[1]),true,64,JSON_THROW_ON_ERROR)),JSON_THROW_ON_ERROR);";
|
||||
$command='php -d display_errors=0 -r '.escapeshellarg($runner).' '.escapeshellarg(base64_encode(json_encode($payload,JSON_THROW_ON_ERROR)));
|
||||
// Большие наборы доставляются SFTP в приватный inbox: argv/ARG_MAX не используется.
|
||||
if(strlen($command)>60000) {
|
||||
$sftp=$this->sftp();$parent=dirname($this->target['root']);$private=$parent.'/.preland-admin-'.substr(hash('sha256',$this->target['root']),0,16);
|
||||
$this->call(['op'=>'test']);$inbox=$private.'/inbox-'.bin2hex(random_bytes(16));
|
||||
if(!$sftp->put($inbox,json_encode($payload,JSON_THROW_ON_ERROR)))throw new Problem('Не удалось доставить подготовленный набор.');$sftp->chmod(0600,$inbox);
|
||||
$runner=$code."\n\$inbox=\$argv[1];try{echo json_encode(\\PrelandAdmin\\Remote::execute(json_decode(file_get_contents(\$inbox),true,64,JSON_THROW_ON_ERROR)),JSON_THROW_ON_ERROR);}finally{unlink(\$inbox);}";
|
||||
$command='php -d display_errors=0 -r '.escapeshellarg($runner).' '.escapeshellarg($inbox);
|
||||
}
|
||||
$timeout=max(1,min(120,(int)(getenv('ADMIN_TRANSPORT_TIMEOUT')?:60)));$ssh->setTimeout($timeout);
|
||||
$output=$ssh->exec($command);$result=is_string($output)?json_decode($output,true):null;
|
||||
if($ssh->isTimeout()||!is_array($result)) { $this->ssh=null;throw new Problem('Неизвестный результат: соединение прервано. Перечитайте фактическое состояние.',504); }
|
||||
}
|
||||
if(!($result['ok']??false)) {if(!empty($result['conflict']))throw new Conflict();throw new Problem($result['error']??'Ошибка транспорта.');}return $result;
|
||||
}
|
||||
private function sftp(): SFTP {
|
||||
$t=$this->target;$sftp=new SFTP($t['host'],$t['port'],20);$key=$sftp->getServerPublicHostKey();$parts=explode(' ',(string)$key);$fp='SHA256:'.rtrim(base64_encode(hash('sha256',base64_decode($parts[1]??'',true)?:'',true)),'=');$expected=$t['host_key'];
|
||||
if(!is_string($key)||!hash_equals(rtrim($expected,'='),rtrim(str_starts_with($expected,'SHA256:')?$fp:$key,'=')))throw new Problem('Ключ SFTP хоста не совпадает.');
|
||||
$secret=$t['secret'];if(str_contains($secret,'PRIVATE KEY'))$secret=PublicKeyLoader::loadPrivateKey($secret);if(!$sftp->login($t['user'],$secret))throw new Problem('Не удалось войти в SFTP.');return $sftp;
|
||||
}
|
||||
public function test(): array {return $this->call(['op'=>'test']);}
|
||||
public function readConfig(): array {$r=$this->call(['op'=>'read']);return ['config'=>Config::decode($r['content']),'version'=>$r['version'],'content'=>$r['content']];}
|
||||
public function writeConfig(string $version,string $content): array {
|
||||
$before=$this->call(['op'=>'read']);if(!hash_equals($version,$before['version']))throw new Conflict();
|
||||
$this->store->backup($this->target['id'],$before['content']);
|
||||
return $this->call(['op'=>'write','version'=>$version,'content'=>$content]);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user