feat(admin): edit domain settings inline and copy saved configurations
deploy-preland / deploy zoomstage.online (push) Successful in 57s
deploy-preland / deploy zoomstage.online (push) Successful in 57s
This commit is contained in:
1 parent
84f70b1c71
commit
dd6d221dd7
19 files changed
+402
-409
No files matched your search
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
namespace PrelandAdmin;
|
||||
|
||||
/** Копирует только сохранённые host и canonical UTM; клиент не передаёт значения config. */
|
||||
final class Copy
|
||||
{
|
||||
private const GROUPS = ['indpic','theme','degr','reg','banch','tp','cons','focus'];
|
||||
private const ALIASES = ['reg'=>['reg','registration'],'banch'=>['banch','chob','bchoice']];
|
||||
private const MAPS = ['theme'=>['1'=>'dark','2'=>'light'],'reg'=>['1'=>'before','2'=>'after'],'banch'=>['1'=>'b','2'=>'a'],'focus'=>['1'=>'face','2'=>'center']];
|
||||
|
||||
public function __construct(private Store $store) {}
|
||||
|
||||
public function create(array $data): array
|
||||
{
|
||||
if (count($data)!==2 || array_diff(array_keys($data),['source','targets'])) throw new Problem('Передайте только источник и получателей копирования.');
|
||||
$source=$this->selection($data['source']??null);
|
||||
$targets=$data['targets']??null;
|
||||
if (!is_array($targets) || !array_is_list($targets) || !$targets || count($targets)>100) throw new Problem('Выберите от 1 до 100 получателей.');
|
||||
$selected=[];$seen=[$source['id']=>true];
|
||||
foreach ($targets as $entry) {
|
||||
$target=$this->selection($entry);
|
||||
if (isset($seen[$target['id']])) throw new Problem('Источник нельзя выбирать получателем; получатели не должны повторяться.');
|
||||
$seen[$target['id']]=true;$this->available($target['id']);$selected[]=$target;
|
||||
}
|
||||
$target=$this->available($source['id']);
|
||||
$read=(new Transport($this->store,$target))->readConfig();
|
||||
if (!hash_equals($read['version'],$source['version'])) throw new Problem('Настройки источника изменились. Перечитайте источник перед копированием.',409);
|
||||
$changes=self::project($read['config']);
|
||||
if (!$changes) throw new Problem('В источнике нет host или UTM для копирования.');
|
||||
// Jobs сериализует каждое значение отдельно: JSON-объекты и массивы сохраняют типы.
|
||||
return (new Jobs($this->store))->create('copy',['source'=>$source,'targets'=>$selected,'changes'=>$changes]);
|
||||
}
|
||||
|
||||
private function selection(mixed $value): array
|
||||
{
|
||||
if (!$value instanceof \stdClass) throw new Problem('Источник и получатель должны быть объектами с ID и версией.');
|
||||
$value=get_object_vars($value);
|
||||
if (count($value)!==2 || array_diff(array_keys($value),['id','version']) || !is_string($value['id']??null) || !preg_match('/^[a-zA-Z0-9_-]{1,64}$/D',$value['id']) || !is_string($value['version']??null) || !preg_match('/^[a-f0-9]{64}$/D',$value['version'])) throw new Problem('Укажите корректные ID и версию сохранённых настроек.');
|
||||
return $value;
|
||||
}
|
||||
|
||||
private function available(string $id): array
|
||||
{
|
||||
$target=$this->store->target($id);
|
||||
if (empty($target['enabled'])) throw new Problem('Подключение отключено.');
|
||||
if (empty($target['verified'])) throw new Problem('Сначала проверьте подключение.');
|
||||
return $target;
|
||||
}
|
||||
|
||||
/** Проверяет неоднозначность всех alias, включая пару chob/bchoice и значения null. */
|
||||
public static function assertAliases(\stdClass $config): void
|
||||
{
|
||||
if (!property_exists($config,'utm')) return;
|
||||
if (!$config->utm instanceof \stdClass) throw new Problem('UTM должен быть объектом.');
|
||||
foreach (self::ALIASES as $canonical=>$aliases) {
|
||||
$found=0;foreach ($aliases as $alias) if (property_exists($config->utm,$alias)) $found++;
|
||||
if ($found>1) throw new Problem("Конфликт UTM-ключей {$canonical}. Явно устраните неоднозначность.");
|
||||
}
|
||||
}
|
||||
|
||||
private static function project(\stdClass $config): array
|
||||
{
|
||||
self::assertAliases($config);
|
||||
$projection=new \stdClass();$changes=[];
|
||||
if (property_exists($config,'host')) {
|
||||
if (!is_string($config->host)) throw new Problem('Адрес сайта источника должен быть строкой.');
|
||||
$projection->host=$config->host;$changes[]=['path'=>['host'],'value'=>$config->host];
|
||||
}
|
||||
$projection->utm=new \stdClass();
|
||||
foreach (self::GROUPS as $canonical) {
|
||||
$group=null;$present=false;
|
||||
foreach (self::ALIASES[$canonical]??[$canonical] as $alias) {
|
||||
if (isset($config->utm) && property_exists($config->utm,$alias)) {$group=$config->utm->$alias;$present=true;break;}
|
||||
}
|
||||
// Отсутствующая группа источника никогда не удаляет группу получателя.
|
||||
if (!$present) continue;
|
||||
if (!$group instanceof \stdClass || !isset($group->default) || !(($group->weights??null) instanceof \stdClass)) throw new Problem("Некорректная структура UTM {$canonical} источника.");
|
||||
$number=static function(mixed $value) use($canonical): mixed {
|
||||
if (!is_string($value) && !is_int($value)) throw new Problem('Вариант UTM источника должен быть номером.');
|
||||
$digit=array_search((string)$value,self::MAPS[$canonical]??[],true);
|
||||
return $digit===false?$value:(string)$digit;
|
||||
};
|
||||
$weights=new \stdClass();
|
||||
foreach (get_object_vars($group->weights) as $variant=>$weight) {
|
||||
$variant=(string)$number($variant);
|
||||
if (property_exists($weights,$variant)) throw new Problem('Конфликт semantic и numeric вариантов UTM источника.');
|
||||
$weights->$variant=$weight;
|
||||
}
|
||||
$default=$number($group->default);
|
||||
$projection->utm->$canonical=(object)['default'=>$default,'weights'=>$weights];
|
||||
// Меняем листья, чтобы сохранить неизвестные свойства группы получателя.
|
||||
$changes[]=['path'=>['utm',$canonical,'default'],'value'=>$default];
|
||||
$changes[]=['path'=>['utm',$canonical,'weights'],'value'=>$weights];
|
||||
}
|
||||
// Наличие физических наборов и каталог проверяются отдельно на каждом получателе.
|
||||
Config::validate($projection,static fn(int $number): bool=>true);
|
||||
return $changes;
|
||||
}
|
||||
}
|
||||
+4
-3
@@ -5,7 +5,7 @@ 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('Неизвестный тип задания.');
|
||||
if(!in_array($type,['apply','copy','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);
|
||||
@@ -31,14 +31,15 @@ final class Jobs
|
||||
$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)){
|
||||
if(in_array($type,['apply','copy','restore'],true)){
|
||||
$current=$transport->readConfig();$expected=$p['version']??'';
|
||||
if($type==='apply')foreach($p['targets'] as $selection)if($selection['id']===$target['id'])$expected=$selection['version']??'';
|
||||
if(in_array($type,['apply','copy'],true))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);
|
||||
if($type==='copy')Copy::assertAliases($current['config']);
|
||||
$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();
|
||||
|
||||
Reference in new issue
Block a user