fix(admin): align UTM notes and block obsolete chb publication
deploy-preland / deploy zoomstage.online (push) Successful in 54s
deploy-preland / deploy zoomstage.online (push) Successful in 54s
This commit is contained in:
1 parent
f2097d4800
commit
84f70b1c71
14 files changed
+93
-29
No files matched your search
+9
-7
@@ -5,15 +5,14 @@
|
||||
проверяется по контракту CPanel и доставляется на выбранные хостинги. Изменение контента
|
||||
не требует Git/push/deploy; `admin/` не входит в публичную поставку прелендингов.
|
||||
|
||||
Редактирование реквизитов отключено по поручению владельца: `requisites` видны
|
||||
в сводке, но не создают полей редактора и не входят в «Выбрать все поля».
|
||||
Редактирование реквизитов отключено по поручению владельца: `requisites` скрыты в сводке и редакторе, не входят в «Выбрать все поля». Данные config сохраняются.
|
||||
|
||||
Спецификация и план — `docs/superpowers/{specs,plans}/2026-10-25-preland22-*` в репозитории,
|
||||
копии и фактический статус — в базе знаний, основной реестр — preland#22.
|
||||
|
||||
## Сводка и конструктор ссылки
|
||||
|
||||
Первый экран читает сохранённые конфиги всех подключений: основной сайт, реквизиты,
|
||||
Первый экран читает сохранённые конфиги всех подключений: основной сайт,
|
||||
дополнительные поля, default и все веса UTM. У каждой колонки своё время чтения,
|
||||
версия, обновление и ошибка; при отказе прежний снимок явно помечается. Обновление
|
||||
сводки не сбрасывает черновик редактора и не меняет его версию для CAS.
|
||||
@@ -32,15 +31,18 @@
|
||||
Неизвестная в текущем runtime/catalog группа сопровождается предупреждением;
|
||||
конструктор не исправляет конфигурацию автоматически.
|
||||
|
||||
## Компактная сводка и заметки — preland#23
|
||||
## Компактная сводка и заметки — preland#23 / #24
|
||||
|
||||
Ячейка UTM содержит только номер и долю заданного веса: `1 (100%)`,
|
||||
`2 (0%) - default`. Положительные доли выделены жирным. Проценты округляются
|
||||
`2 (0%) - default`. Положительные номер и доля выделены жирным; суффикс ` - default` всегда обычный. Проценты округляются
|
||||
до двух знаков без лишних нулей; при всех нулевых весах у всех вариантов `0%`.
|
||||
Таблица не меняет default/веса и не подставляет другому варианту `100%`.
|
||||
|
||||
Под каждой из восьми UTM расположена общая для доменов заметка. Редактор —
|
||||
растягиваемая по высоте textarea на всю ширину таблицы; сохранение явное.
|
||||
В блоке каждой UTM верхняя строка содержит название один раз и общую заметку
|
||||
в объединённой ячейке доменов. Ниже — пустая первая ячейка и значения доменов.
|
||||
Устаревший `utm.chb` скрыт в сводке и редакторе; остальные неизвестные поля видны.
|
||||
Редактор заметки —
|
||||
растягиваемая по высоте textarea на всю ширину объединённой ячейки доменов; сохранение явное.
|
||||
Черновик сохраняется при обновлении колонок или ошибке запроса. При конфликте
|
||||
сохранения нужно явно загрузить серверный текст; чужой текст молча не заменяется.
|
||||
|
||||
|
||||
+1
-1
@@ -4,5 +4,5 @@
|
||||
"license": "proprietary",
|
||||
"require": {"php": "^8.3", "ext-json": "*", "ext-zip": "*", "ext-imagick": "*", "ext-zlib": "*", "phpseclib/phpseclib": "^3.0"},
|
||||
"config": {"sort-packages": true},
|
||||
"scripts": {"test": ["php tests/backend-review.php", "php tests/link-builder.php", "php tests/backend-http.php", "php tests/session-persistence.php", "php tests/remember-auth.php", "php tests/transport-command-limit.php", "php tests/utm-notes.php"], "test:worker": "php tests/backend-worker.php"}
|
||||
"scripts": {"test": ["php tests/backend-review.php", "php tests/link-builder.php", "php tests/backend-http.php", "php tests/session-persistence.php", "php tests/remember-auth.php", "php tests/transport-command-limit.php", "php tests/utm-notes.php", "php tests/obsolete-utm.php"], "test:worker": "php tests/backend-worker.php"}
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
/* Overview and link builder retain the existing CPanel / Bootstrap surface. */
|
||||
.overview-wrapper{margin-top:15px}.overview-table{min-width:760px;table-layout:fixed}.overview-table th:first-child{width:110px}.overview-table th:not(:first-child){min-width:190px}.overview-table th,.overview-table td{vertical-align:top;overflow-wrap:anywhere}.overview-table th:first-child,.overview-table td:first-child{position:sticky;left:0;background:#f5f5f5;z-index:1}.overview-table caption{text-align:left;padding:8px 0}.overview-domain{font-size:15px;margin-bottom:6px}.overview-status{font-size:12px;font-weight:400;margin:8px 0}.overview-weights{list-style:none;padding:0;margin:7px 0 0}.overview-weights li{margin:3px 0}.overview-warning{color:#8a6d3b;margin-top:8px;font-size:12px}.overview-error{color:#a94442}.overview-table .toolbar{gap:6px;margin-bottom:0}.builder-domain{display:block;max-width:450px;margin:15px 0}.builder-field{display:block}.builder-field select{margin-top:5px}#builder-fields{margin:15px 0}#builder-result{margin-top:18px}#builder-url{font-family:monospace}#builder-state{margin-top:10px}#builder-warnings .alert{padding:10px}#builder-save{margin-top:5px}@media(max-width:600px){.overview-table{min-width:720px}.overview-table th:first-child{width:90px}.overview-table .btn{padding:4px 8px}.builder-domain{max-width:100%}}
|
||||
.overview-plain{white-space:pre-wrap}
|
||||
.overview-default{font-weight:400}.overview-table tr[data-utm-note] th{font-size:16px}.overview-table tr[data-overview-utm] th{background:#fafafa}
|
||||
.login-panel .remember-choice{display:flex;align-items:center;gap:6px;font-weight:400}.login-panel .remember-choice input{flex:0 0 auto;margin:0}
|
||||
/* #23: compact numeric percentages; note rows span the whole matrix without sticky cells. */
|
||||
.overview-percent{display:block;margin:6px 0;line-height:1.35}.overview-percent:first-child{margin-top:0}.overview-percent:last-child{margin-bottom:0}.overview-raw{font-size:12px;overflow-wrap:anywhere}#overview-table td.utm-note-cell{position:static;left:auto;width:auto;min-width:0;z-index:auto;background:#fafafa;white-space:normal}.utm-note-title{display:block;font-size:12px;color:#707070;margin-bottom:6px}.utm-note-text{white-space:pre-wrap;overflow-wrap:anywhere;margin:0 0 8px}.utm-note-textarea{width:100%;max-width:100%;min-width:0;resize:vertical;min-height:90px}.utm-note-status{margin:6px 0 0;white-space:pre-wrap;overflow-wrap:anywhere}.utm-note-status:empty{display:none}.utm-note-edit .toolbar{margin-bottom:0}.utm-note-cell .btn{white-space:normal}.utm-note-read:has(.utm-note-text[hidden]){display:flex;align-items:center}
|
||||
@@ -35,17 +35,17 @@
|
||||
if(defaultDigit&&!seen.has(defaultDigit))variants.push({digit:defaultDigit,weight:0});
|
||||
variants.sort((a,b)=>Number(a.digit)-Number(b.digit));
|
||||
const maximum=Math.max(0,...variants.map(v=>v.weight)),sum=maximum>0?variants.reduce((total,v)=>total+v.weight/maximum,0):0;
|
||||
for(const variant of variants){const percent=sum>0?Math.round((variant.weight/maximum)/sum*10000)/100:0;const text=variant.digit+' ('+percent+'%)'+(variant.digit===defaultDigit?' - default':'');cell.append(el(percent>0?'strong':'span',{class:'overview-percent',text}));}
|
||||
for(const variant of variants){const percent=sum>0?Math.round((variant.weight/maximum)/sum*10000)/100:0;const line=el('div',{class:'overview-percent'},el(percent>0?'strong':'span',{class:'overview-value',text:variant.digit+' ('+percent+'%)'}));if(variant.digit===defaultDigit)line.append(el('span',{class:'overview-default',text:' - default'}));cell.append(line);}
|
||||
if(!variants.length)cell.append(el('span',{class:'text-muted',text:'Вариантов нет'}));
|
||||
return cell;
|
||||
}
|
||||
const utmNotes={entries:new Map(),loading:false,error:'',serial:0};
|
||||
function noteEntry(marker){
|
||||
if(utmNotes.entries.has(marker)){const entry=utmNotes.entries.get(marker);entry.cell.colSpan=state.targets.length+1;return entry;}
|
||||
if(utmNotes.entries.has(marker)){const entry=utmNotes.entries.get(marker);entry.cell.colSpan=Math.max(1,state.targets.length);return entry;}
|
||||
const entry={marker,epoch:0,note:null,draft:'',baseVersion:'',editing:false,saving:false,loading:false,error:'',conflict:false};
|
||||
const makeButton=(text,action)=>el('button',{type:'button',class:'btn btn-default btn-xs',text,onclick:()=>run(action)});
|
||||
entry.row=el('tr',{'data-utm-note':marker});entry.cell=el('td',{class:'utm-note-cell'});entry.row.append(entry.cell);
|
||||
entry.title=el('span',{class:'utm-note-title',text:'Заметка '+marker});entry.text=el('div',{class:'utm-note-text'});
|
||||
entry.row=el('tr',{'data-utm-note':marker});entry.cell=el('td',{class:'utm-note-cell'});entry.row.append(el('th',{scope:'row',text:marker}),entry.cell);
|
||||
entry.text=el('div',{class:'utm-note-text'});
|
||||
entry.edit=makeButton('Добавить заметку',()=>{if(!entry.note)return;entry.epoch++;entry.draft=entry.note.text;entry.baseVersion=entry.note.version;entry.editing=true;entry.error='';entry.conflict=false;renderNote(entry);entry.textarea.focus();});
|
||||
entry.edit.setAttribute('aria-label','Добавить заметку '+marker);
|
||||
entry.readView=el('div',{class:'utm-note-read'},entry.text,entry.edit);
|
||||
@@ -57,10 +57,10 @@
|
||||
entry.editView=el('div',{class:'utm-note-edit'},entry.textarea,el('div',{class:'toolbar'},entry.save,entry.cancel,entry.reload));
|
||||
entry.retry=makeButton('Повторить загрузку заметок',loadNotes);entry.retry.setAttribute('aria-label','Повторить загрузку заметок '+marker);
|
||||
entry.status=el('p',{class:'utm-note-status text-muted',role:'status','aria-live':'polite'});
|
||||
entry.cell.append(entry.title,entry.readView,entry.editView,entry.status,entry.retry);utmNotes.entries.set(marker,entry);renderNote(entry);return entry;
|
||||
entry.cell.append(entry.readView,entry.editView,entry.status,entry.retry);utmNotes.entries.set(marker,entry);renderNote(entry);return entry;
|
||||
}
|
||||
function renderNote(entry){
|
||||
entry.cell.colSpan=state.targets.length+1;entry.text.textContent=entry.note?.text||'';entry.text.hidden=!entry.note?.text;
|
||||
entry.cell.colSpan=Math.max(1,state.targets.length);entry.text.textContent=entry.note?.text||'';entry.text.hidden=!entry.note?.text;
|
||||
entry.readView.hidden=entry.editing;entry.editView.hidden=!entry.editing;
|
||||
entry.edit.textContent=entry.note?.text?'Редактировать':'Добавить заметку';entry.edit.setAttribute('aria-label',(entry.note?.text?'Редактировать':'Добавить')+' заметку '+entry.marker);entry.edit.disabled=!entry.note||entry.loading;
|
||||
if(entry.textarea.value!==entry.draft)entry.textarea.value=entry.draft;
|
||||
@@ -93,14 +93,13 @@
|
||||
function overviewExtra(config){
|
||||
const values=new Map(),secretKeys=new Set(['secret','password','private_key','privatekey','credential','credentials','api_key','apikey','access_token','accesstoken','refresh_token','refreshtoken','authorization']);
|
||||
const visit=(path,value)=>{const key=path.join('.');if(path.some(part=>secretKeys.has(String(part).toLowerCase().replace(/-/g,'_')))){values.set(key,{label:key,text:'Скрытое поле'});return;}
|
||||
if(path.length===1&&path[0]==='requisites'&&Array.isArray(value)&&value.every(v=>typeof v==='string')){values.set(key,{label:'Реквизиты',text:value.length?value.join('\n'):'Пустой массив'});return;}
|
||||
if(value!==null&&typeof value==='object'){const entries=Object.entries(value);if(!entries.length){values.set(key,{label:key,text:Array.isArray(value)?'Пустой массив':'Пустой объект'});return;}for(const [child,next]of entries)visit([...path,child],next);return;}
|
||||
const text=value===null?'null':typeof value==='boolean'?(value?'Да':'Нет')+' (boolean)':typeof value==='number'?String(value)+' (число)':String(value);
|
||||
values.set(key,{label:key==='host'?'Основной сайт':key,text});
|
||||
};
|
||||
for(const [key,value]of Object.entries(config||{}))if(key!=='utm')visit([key],value);
|
||||
for(const [key,value]of Object.entries(config||{}))if(!['utm','requisites'].includes(key))visit([key],value);
|
||||
const utm=config?.utm;
|
||||
if(utm&&typeof utm==='object'&&!Array.isArray(utm)){for(const [key,value]of Object.entries(utm)){
|
||||
if(utm&&typeof utm==='object'&&!Array.isArray(utm)){for(const [key,value]of Object.entries(utm)){if(key==='chb')continue;
|
||||
const marker=key==='registration'?'reg':['chob','bchoice'].includes(key)?'banch':key;
|
||||
if(!markers.includes(marker))visit(['utm',key],value);
|
||||
else if(groupFor(config,marker).conflict.length)visit(['utm',key],value);
|
||||
@@ -121,9 +120,9 @@
|
||||
table.querySelector('thead').replaceChildren(head);const body=table.querySelector('tbody');body.replaceChildren();
|
||||
const extraByTarget=new Map(),extraKeys=new Map();
|
||||
for(const target of state.targets){const extra=overviewExtra(overview.entries.get(target.id)?.data?.config);extraByTarget.set(target.id,extra);for(const [key,value]of extra)extraKeys.set(key,value.label);}
|
||||
const orderedKeys=[...extraKeys.keys()].sort((a,b)=>{const rank=key=>key==='host'?0:key==='requisites'?1:2;return rank(a)-rank(b)||a.localeCompare(b,'ru');});
|
||||
const orderedKeys=[...extraKeys.keys()].sort((a,b)=>{const rank=key=>key==='host'?0:1;return rank(a)-rank(b)||a.localeCompare(b,'ru');});
|
||||
for(const key of orderedKeys){const row=el('tr',{'data-overview-extra':key},el('th',{scope:'row',text:extraKeys.get(key)}));for(const target of state.targets){const entry=overview.entries.get(target.id),value=extraByTarget.get(target.id)?.get(key);row.append(el('td',{class:'overview-plain',text:value?value.text:entry?.data?'Поле отсутствует':'Нет прочитанного конфига'}));}body.append(row);}
|
||||
for(const marker of markers){const row=el('tr',{'data-overview-utm':marker},el('th',{scope:'row',text:marker}));for(const target of state.targets){const entry=overview.entries.get(target.id);row.append(entry?.data?overviewCell(entry.data.config,marker):el('td',{class:'text-muted',text:entry?.status==='loading'?'Загрузка…':'Нет прочитанного конфига'}));}body.append(row);body.append(noteEntry(marker).row);}
|
||||
for(const marker of markers){const row=el('tr',{'data-overview-utm':marker},el('th',{'aria-hidden':'true',text:''}));for(const target of state.targets){const entry=overview.entries.get(target.id);row.append(entry?.data?overviewCell(entry.data.config,marker):el('td',{class:'text-muted',text:entry?.status==='loading'?'Загрузка…':'Нет прочитанного конфига'}));}body.append(noteEntry(marker).row,row);}
|
||||
if(noteFocus?.node.isConnected&&!noteFocus.node.disabled){noteFocus.node.focus({preventScroll:true});noteFocus.node.setSelectionRange(noteFocus.start,noteFocus.end,noteFocus.direction);noteFocus.node.scrollTop=noteFocus.scroll;}
|
||||
if(builder.target&&builder.entries.get(builder.target)?.ready)updateBuilderState();
|
||||
}
|
||||
@@ -281,7 +280,7 @@
|
||||
for(const [key,value]of Object.entries(config))if(key!=='utm'){
|
||||
if(key==='requisites'){
|
||||
// По просьбе владельца (#22/comment3770) редактирование реквизитов отключено.
|
||||
// Ключ config и отображение в сводке сохраняются; в state.fields реквизитов нет.
|
||||
// Данные config сохраняются; реквизитов нет в сводке и state.fields (#24).
|
||||
// typed(container,[key],value,'Реквизиты');
|
||||
continue;
|
||||
}
|
||||
@@ -289,7 +288,7 @@
|
||||
}
|
||||
if(config.utm&&typeof config.utm==='object'){
|
||||
for(const marker of markers){const key=Object.hasOwn(config.utm,marker)?marker:aliases[marker]&&Object.hasOwn(config.utm,aliases[marker])?aliases[marker]:marker==='banch'&&Object.hasOwn(config.utm,'chob')?'chob':null;if(key)utmGroup(container,key,config.utm[key],marker);else container.append(el('div',{class:'field-block'},el('h2',{text:marker+'= UTM'}),el('p',{class:'text-muted',text:'Группа отсутствует в этом config.json; автоматически не добавляется.'})));}
|
||||
for(const [key,value]of Object.entries(config.utm))if(!markers.includes(key)&&!Object.values(aliases).includes(key)&&key!=='chob')typed(container,['utm',key],value,key);
|
||||
for(const [key,value]of Object.entries(config.utm))if(!markers.includes(key)&&!Object.values(aliases).includes(key)&&key!=='chob'&&key!=='chb')typed(container,['utm',key],value,key);
|
||||
}
|
||||
$('read-state').textContent='Прочитано '+formatDate(new Date())+' · версия '+read.version.slice(0,12);
|
||||
$('warnings').replaceChildren(...(read.warnings||[]).map(w=>el('p',{class:'alert alert-warning',text:typeof w==='string'?w:JSON.stringify(w)})));
|
||||
|
||||
@@ -9,6 +9,10 @@ final class Config
|
||||
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"; }
|
||||
/** Старый raw читается без миграции, но повторная публикация chb запрещена. */
|
||||
public static function assertWritable(\stdClass $config): void {
|
||||
if(($config->utm??null) instanceof \stdClass&&property_exists($config->utm,'chb'))throw new Problem('Устаревший ключ utm.chb нельзя записывать. Явно удалите его перед сохранением; восстановление такого архива запрещено.');
|
||||
}
|
||||
public static function patch(\stdClass $config, array $changes): \stdClass {
|
||||
if (!$changes || count($changes)>500) throw new Problem('Выберите изменённые поля (не более 500).');
|
||||
$copy = self::decode(self::encode($config));
|
||||
@@ -31,7 +35,7 @@ final class Config
|
||||
}
|
||||
unset($ref);
|
||||
}
|
||||
return $copy;
|
||||
self::assertWritable($copy);return $copy;
|
||||
}
|
||||
private static function adaptChange(\stdClass $config,array $change): array {
|
||||
$path=$change['path']??[];if(($path[0]??null)!=='utm'||!isset($path[1]))return $change;
|
||||
@@ -51,6 +55,7 @@ final class Config
|
||||
$change['path']=$path;return $change;
|
||||
}
|
||||
public static function validate(\stdClass $config, callable $setExists,?array $catalog=null): array {
|
||||
self::assertWritable($config);
|
||||
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-хостом без пути.');
|
||||
|
||||
@@ -32,6 +32,12 @@ final class Remote
|
||||
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);
|
||||
|
||||
@@ -78,6 +78,7 @@ final class Transport
|
||||
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 {
|
||||
Config::assertWritable(Config::decode($content));
|
||||
$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]);
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require dirname(__DIR__).'/bootstrap.php';
|
||||
use PrelandAdmin\{Config,Store,Transport,Remote,Jobs,Problem};
|
||||
function obsoleteCheck(bool $ok,string $message): void {if(!$ok)throw new RuntimeException($message);echo "PASS {$message}\n";}
|
||||
function obsoleteReject(callable $call,string $message): void {try{$call();}catch(Problem $e){obsoleteCheck($e->status===422&&str_contains($e->getMessage(),'utm.chb'),$message);return;}throw new RuntimeException('Accepted obsolete key: '.$message);}
|
||||
$legacy='{"host":"zoomzilla.ru","utm":{"chb":null,"unknown":{"raw":true},"cons":{"default":"2","weights":{"1":0,"2":7}}},"custom":{"chb":true}}';
|
||||
$raw=Config::decode($legacy);obsoleteCheck(property_exists($raw->utm,'chb')&&Config::decode(Config::encode($raw))->custom->chb===true,'legacy raw remains readable and encodable without migration');
|
||||
obsoleteReject(fn()=>Config::validate($raw,static fn()=>true),'validation rejects exact deprecated key even null');
|
||||
foreach([
|
||||
[['path'=>['host'],'value'=>'other.example']],
|
||||
[['path'=>['utm','chb'],'value'=>(object)['default'=>'1','weights'=>(object)['1'=>1]]]],
|
||||
[['path'=>['utm','chb','default'],'value'=>'1']],
|
||||
[['path'=>['utm'],'value'=>(object)['chb'=>null]]],
|
||||
] as $changes)obsoleteReject(fn()=>Config::patch($changes[0]['path']===['utm','chb','default']?Config::decode('{"utm":{"chb":{}}}'):$raw,$changes),'patch refuses resulting chb at any depth or parent replacement');
|
||||
$clean=Config::patch($raw,[['path'=>['utm','chb'],'delete'=>true]]);obsoleteCheck(!property_exists($clean->utm,'chb')&&Config::encode($clean->utm->cons)===Config::encode($raw->utm->cons)&&Config::encode($clean->utm->unknown)===Config::encode($raw->utm->unknown)&&$clean->custom->chb===true,'explicit delete removes only utm.chb and preserves unknown keys and cons');
|
||||
obsoleteCheck(Config::validate($clean,static fn()=>true)!==null,'cleaned config passes write validation');
|
||||
$root=sys_get_temp_dir().'/obsolete-utm-'.bin2hex(random_bytes(6));mkdir($root,0700);mkdir($root.'/site',0700);$store=new Store($root.'/state');putenv('ADMIN_LOCAL_ROOTS='.$root.'/site');$store->saveTarget(['id'=>'a','name'=>'A','domain'=>'a.invalid','type'=>'local','root'=>$root.'/site','enabled'=>true]);$store->verifyTarget('a');$target=$store->target('a');$transport=new Transport($store,$target);file_put_contents($root.'/site/config.json',$legacy);$read=$transport->readConfig();obsoleteCheck($read['content']===$legacy&&property_exists($read['config']->utm,'chb'),'actual transport reads obsolete raw without rewriting');
|
||||
obsoleteReject(fn()=>$transport->writeConfig($read['version'],$legacy),'direct transport rejects obsolete write');obsoleteCheck(file_get_contents($root.'/site/config.json')===$legacy&&$store->backups('a')===[],'rejected direct transport creates no backup and changes no runtime bytes');
|
||||
$remotePrivate=$root.'/remote-private';$remote=Remote::execute(['op'=>'write','root'=>$root.'/site','local_private'=>$remotePrivate,'version'=>$read['version'],'content'=>$legacy]);obsoleteCheck(!$remote['ok']&&str_contains($remote['error'],'utm.chb'),'standalone remote rejects obsolete write');obsoleteCheck(!file_exists($remotePrivate)&&file_get_contents($root.'/site/config.json')===$legacy,'remote rejection happens before private directory, backup or temp creation');
|
||||
$jobs=new Jobs($store);$job=$jobs->create('apply',['targets'=>[['id'=>'a','version'=>$read['version']]],'changes'=>[['path'=>['host'],'value'=>'other.example']]]);$jobs->run($job['id']);$result=$jobs->get($job['id'])['results'][0];obsoleteCheck($result['status']==='error'&&str_contains($result['message'],'utm.chb')&&file_get_contents($root.'/site/config.json')===$legacy&&$store->backups('a')===[],'actual apply job rejects retained chb with clear error and no publication/backup');
|
||||
$job=$jobs->create('apply',['targets'=>[['id'=>'a','version'=>$read['version']]],'changes'=>[['path'=>['utm','chb'],'delete'=>true]]]);$jobs->run($job['id']);obsoleteCheck($jobs->get($job['id'])['results'][0]['status']==='success','actual explicit delete job succeeds');$after=$transport->readConfig();obsoleteCheck(!property_exists($after['config']->utm,'chb')&&Config::encode($after['config']->utm->cons)===Config::encode($raw->utm->cons),'delete publication preserves cons exactly');
|
||||
$backups=$store->backups('a');$job=$jobs->create('restore',['target'=>'a','id'=>$backups[0]['id'],'version'=>$after['version']]);$jobs->run($job['id']);$result=$jobs->get($job['id'])['results'][0];obsoleteCheck($result['status']==='error'&&str_contains($result['message'],'utm.chb'),'legacy restore is explicitly rejected, never silently sanitized');obsoleteCheck($transport->readConfig()['content']===$after['content']&&$store->backups('a')===$backups&&$store->backupContent('a',$backups[0]['id'])===$legacy,'rejected restore keeps runtime, backups and historical legacy content exact');
|
||||
echo "Obsolete UTM write guard contract complete\n";
|
||||
@@ -16,7 +16,7 @@ const fs=require('node:fs');
|
||||
await page.getByLabel('Логин',{exact:true}).fill(process.env.ADMIN_USER||'admin');await page.getByLabel('Пароль',{exact:true}).fill(process.env.ADMIN_PASSWORD||'test-password');await page.getByRole('button',{name:'Войти',exact:true}).click();
|
||||
await page.locator('#workspace').waitFor({state:'visible'});
|
||||
assert.equal(await page.locator('#workspace > section').first().getAttribute('id'),'overview','saved runtime overview is first section');
|
||||
await page.locator('#overview-table tbody tr').first().waitFor();assert.equal(await page.locator('#overview-table tbody tr[data-overview-utm]').count(),8);await page.locator('#overview-table tr[data-overview-extra=host]').waitFor();await page.locator('#overview-table tr[data-overview-extra=requisites]').waitFor();
|
||||
await page.locator('#overview-table tbody tr').first().waitFor();assert.equal(await page.locator('#overview-table tbody tr[data-overview-utm]').count(),8);await page.locator('#overview-table tr[data-overview-extra=host]').waitFor();assert.equal(await page.locator('#overview-table tr[data-overview-extra=requisites]').count(),0);
|
||||
const read=async id=>{const r=await page.request.get('/api/config?target='+id).then(r=>r.json());assert.ok(r.ok,r.error);return r;};
|
||||
const before=await Promise.all(['a','b'].map(read));
|
||||
await page.locator('#builder-target').selectOption('a');
|
||||
|
||||
@@ -24,11 +24,12 @@ const {chromium}=require('playwright-core');const assert=require('node:assert/st
|
||||
try{
|
||||
await page.goto(process.env.ADMIN_URL||'http://admin');await page.getByLabel('Логин',{exact:true}).fill(process.env.ADMIN_USER||'admin');await page.getByLabel('Пароль',{exact:true}).fill(process.env.ADMIN_PASSWORD||'test-password');await page.getByRole('button',{name:'Войти',exact:true}).click();await page.locator('#workspace').waitFor({state:'visible'});
|
||||
await page.locator('#overview-table tr[data-overview-utm=reg] td .overview-percent').first().waitFor({timeout:6000});
|
||||
for(const marker of ['indpic','theme','degr','reg','banch','tp','cons','focus']){const header=page.locator('tr[data-utm-note='+marker+']'),data=page.locator('tr[data-overview-utm='+marker+']');assert.equal(await header.locator('th').innerText(),marker);assert.equal(await data.locator('th').innerText(),'');assert.equal(await data.evaluate(n=>n.previousElementSibling.dataset.utmNote),marker);}
|
||||
const state=await request('state');csrf=state.csrf;const snapshots=await Promise.all(state.targets.map(async t=>{const r=await page.request.get('/api/config?target='+t.id).then(r=>r.json());return {id:t.id,...r};}));const jobsBefore=(await request('jobs')).jobs.map(j=>j.id).sort();
|
||||
const enumMap={theme:{dark:'1',light:'2'},reg:{before:'1',after:'2'},banch:{b:'1',a:'2'},focus:{face:'1',center:'2'}};
|
||||
const alias={reg:['registration'],banch:['chob','bchoice']};
|
||||
for(let index=0;index<snapshots.length;index++){const snapshot=snapshots[index];if(!snapshot.ok)continue;for(const marker of ['indpic','theme','degr','reg','banch','tp','cons','focus']){const config=snapshot.config.utm||{},key=[marker,...(alias[marker]||[])].find(k=>Object.hasOwn(config,k));if(!key)continue;const group=config[key],items=Object.entries(group.weights||{}).map(([v,w])=>[enumMap[marker]?.[v]||v,w]);const sum=items.reduce((n,[,w])=>n+w,0),def=enumMap[marker]?.[group.default]||String(group.default);if(!items.some(([v])=>v===def))items.push([def,0]);items.sort((a,b)=>Number(a[0])-Number(b[0]));const cell=page.locator('#overview-table tr[data-overview-utm='+marker+'] td').nth(index);const expected=items.map(([v,w])=>v+' ('+(sum>0?Number((w/sum*100).toFixed(2)):0)+'%)'+(v===def?' - default':''));assert.deepEqual(await cell.locator('.overview-percent').allTextContents(),expected);assert.doesNotMatch(await cell.innerText(),/Default:|вес|тёмная|светлая|имейл|pop-up|центр/);for(let i=0;i<expected.length;i++){const weight=await cell.locator('.overview-percent').nth(i).evaluate(n=>Number(getComputedStyle(n).fontWeight));if(/\(0%\)/.test(expected[i]))assert.ok(weight<=400,'zero percentages use regular weight');else assert.ok(weight>=600,'positive percentages use bold weight');}}}
|
||||
await page.locator('#overview-table tr[data-overview-utm=indpic] td').first().screenshot({path:output+'/owner-format-cell.png'});const notes=await request('utm-notes');initialNote=notes.notes.reg;await row().waitFor();assert.equal(Number(await row().locator('td').getAttribute('colspan')),state.targets.length+1);assert.equal(await row().locator('td').evaluate(n=>getComputedStyle(n).position),'static');await page.locator('#overview').screenshot({path:output+'/overview-desktop.png'});
|
||||
for(let index=0;index<snapshots.length;index++){const snapshot=snapshots[index];if(!snapshot.ok)continue;for(const marker of ['indpic','theme','degr','reg','banch','tp','cons','focus']){const config=snapshot.config.utm||{},key=[marker,...(alias[marker]||[])].find(k=>Object.hasOwn(config,k));if(!key)continue;const group=config[key],items=Object.entries(group.weights||{}).map(([v,w])=>[enumMap[marker]?.[v]||v,w]);const sum=items.reduce((n,[,w])=>n+w,0),def=enumMap[marker]?.[group.default]||String(group.default);if(!items.some(([v])=>v===def))items.push([def,0]);items.sort((a,b)=>Number(a[0])-Number(b[0]));const cell=page.locator('#overview-table tr[data-overview-utm='+marker+'] td').nth(index);const expected=items.map(([v,w])=>v+' ('+(sum>0?Number((w/sum*100).toFixed(2)):0)+'%)'+(v===def?' - default':''));assert.deepEqual(await cell.locator('.overview-percent').allTextContents(),expected);assert.doesNotMatch(await cell.innerText(),/Default:|вес|тёмная|светлая|имейл|pop-up|центр/);for(let i=0;i<expected.length;i++){const weight=await cell.locator('.overview-value').nth(i).evaluate(n=>Number(getComputedStyle(n).fontWeight));if(/\(0%\)/.test(expected[i]))assert.ok(weight<=400,'zero percentages use regular weight');else assert.ok(weight>=600,'positive percentages use bold weight');}for(const suffix of await cell.locator('.overview-default').all()){assert.equal(await suffix.innerText(),' - default');assert.ok(await suffix.evaluate(n=>Number(getComputedStyle(n).fontWeight))<=400,'default suffix is regular');}}}
|
||||
await page.locator('#overview-table tr[data-overview-utm=indpic] td').first().screenshot({path:output+'/owner-format-cell.png'});const notes=await request('utm-notes');initialNote=notes.notes.reg;await row().waitFor();assert.equal(Number(await row().locator('td').getAttribute('colspan')),state.targets.length);assert.equal(await row().locator('td').evaluate(n=>getComputedStyle(n).position),'static');await page.locator('#overview').screenshot({path:output+'/overview-desktop.png'});
|
||||
await enterEdit();const draft='Заметка UI — '+Date.now()+'\n<img src=x onerror=alert(1)>\nТолько обычный текст';await textarea().fill(draft);const handle=await textarea().elementHandle();await textarea().evaluate(n=>{document.querySelector('#overview-refresh').click();n.focus();n.setSelectionRange(7,11);});await page.waitForFunction(()=>document.querySelector('#overview-refresh').disabled===false);assert.equal(await textarea().inputValue(),draft);assert.deepEqual(await handle.evaluate(n=>({connected:n.isConnected,focused:document.activeElement===n,start:n.selectionStart,end:n.selectionEnd})),{connected:true,focused:true,start:7,end:11});
|
||||
await save();assert.equal((await request('utm-notes')).notes.reg.text,draft);assert.equal(await row().locator('img').count(),0);await page.reload();await page.locator('#workspace').waitFor({state:'visible'});await row().getByText(draft,{exact:true}).waitFor();
|
||||
const newer=draft+'\nСохранено после начала старого GET';
|
||||
|
||||
@@ -21,7 +21,7 @@ const fs=require('node:fs');const os=require('node:os');const path=require('node
|
||||
const target=process.env.TARGET_ID||'a';
|
||||
const read=async()=>{const r=await page.request.get('/api/config?target='+target).then(r=>r.json());assert.ok(r.ok,r.error);return r;};const before=await read();
|
||||
assert.ok(Array.isArray(before.config.requisites)&&before.config.requisites.length,'fixture requisites required');
|
||||
await page.locator('#overview-table tr[data-overview-extra=requisites]').getByText(before.config.requisites[0],{exact:false}).first().waitFor();
|
||||
await page.locator('#overview-table tr[data-overview-extra=host]').waitFor();assert.equal(await page.locator('#overview-table tr[data-overview-extra=requisites]').count(),0,'requisites overview hidden');
|
||||
await page.locator('#source-target').selectOption(target);await page.getByLabel('Основной сайт',{exact:true}).waitFor();
|
||||
assert.equal(await page.locator('#config-fields').getByLabel('Реквизиты',{exact:true}).count(),0,'requisites editor hidden');
|
||||
assert.equal(await page.locator('#config-fields input[aria-label^="Применить поле requisites"]').count(),0,'requisites absent from editable fields');
|
||||
@@ -34,6 +34,6 @@ const fs=require('node:fs');const os=require('node:os');const path=require('node
|
||||
const shortRequest=page.waitForRequest(r=>r.url().endsWith('/api/login')&&r.method()==='POST');await page.getByRole('button',{name:'Войти',exact:true}).click();assert.equal((await shortRequest).postDataJSON().remember,false,'explicit boolean false');await page.locator('#workspace').waitFor({state:'visible'});
|
||||
const shortCookies=await context.cookies(origin);assert.ok(!shortCookies.some(c=>c.name===durable.name&&c.expires>Date.now()/1000+86400),'unchecked login does not retain 90-day cookie');
|
||||
assert.deepEqual(errors,[]);fs.writeFileSync(output+'/remember-evidence.json',JSON.stringify({cookie:{name:durable.name,expires:durable.expires,httpOnly:durable.httpOnly,sameSite:durable.sameSite,secure:durable.secure},configVersion:after.version,pageErrors:errors,reopenAuthenticated:true,uncheckedRememberFalse:true},null,2));
|
||||
console.log('PASS actual remember90 checkbox/boolean/cookie/persistent reopen + session login + readonly requisites/overview/config unchanged');
|
||||
console.log('PASS actual remember90 checkbox/boolean/cookie/persistent reopen + session login + hidden requisites/config unchanged');
|
||||
}finally{await context.close();}
|
||||
})().catch(error=>{console.error(error);process.exit(1);});
|
||||
@@ -1,9 +1,11 @@
|
||||
/* preland#22/comment3770: requisites are overview-only, never selected for apply. */
|
||||
/* preland#24: requisites/chb hidden; ordinary unknown fields retained; no writes. */
|
||||
const {chromium}=require('playwright-core');const assert=require('node:assert/strict');
|
||||
(async()=>{
|
||||
const browser=await chromium.launch({executablePath:process.env.CHROMIUM_PATH||'/usr/bin/chromium',args:['--no-sandbox']});
|
||||
try{const page=await browser.newPage({baseURL:process.env.ADMIN_URL||'http://admin'});await page.goto(process.env.ADMIN_URL||'http://admin');await page.getByLabel('Логин',{exact:true}).fill(process.env.ADMIN_USER||'admin');await page.getByLabel('Пароль',{exact:true}).fill(process.env.ADMIN_PASSWORD||'test-password');await page.getByRole('button',{name:'Войти',exact:true}).click();await page.locator('#workspace').waitFor({state:'visible'});
|
||||
const target=process.env.TARGET_ID||'a',read=()=>page.request.get('/api/config?target='+target).then(r=>r.json());const before=await read();assert.ok(before.ok,before.error);await page.locator('#source-target').selectOption(target);await page.getByLabel('Основной сайт',{exact:true}).waitFor();assert.equal(await page.locator('#config-fields input[aria-label^="Применить поле requisites"]').count(),0,'requisites not in editable fields');
|
||||
await page.getByRole('button',{name:'Выбрать все поля формы',exact:true}).click();assert.doesNotMatch(await page.locator('#change-summary').innerText(),/requisites/);await page.getByRole('button',{name:'Снять выбор полей',exact:true}).click();assert.ok(await page.locator('#overview-table tr[data-overview-extra=requisites]').count());const after=await read();assert.equal(after.version,before.version);assert.deepEqual(after.config,before.config);console.log('PASS requisites overview-only / select all excludes / config unchanged');
|
||||
const observed={requisites:await page.locator('#overview-table tr[data-overview-extra=requisites]').count(),chb:await page.locator('#overview-table tr[data-overview-extra^="utm.chb"]').count(),chbEditor:await page.locator('#config-fields input[aria-label^="Применить поле utm.chb"]').count()};console.log('Observed hidden-field surface',observed);assert.deepEqual(observed,{requisites:0,chb:0,chbEditor:0});
|
||||
if(before.config.utm.custom_visible){assert.equal(await page.locator('#overview-table tr[data-overview-extra="utm.custom_visible"]').count(),1);assert.ok(await page.locator('#config-fields input[aria-label^="Применить поле utm.custom_visible"]').count());}
|
||||
await page.getByRole('button',{name:'Выбрать все поля формы',exact:true}).click();assert.doesNotMatch(await page.locator('#change-summary').innerText(),/requisites|utm\.chb/);await page.getByRole('button',{name:'Снять выбор полей',exact:true}).click();const after=await read();assert.equal(after.version,before.version);assert.deepEqual(after.config,before.config);console.log('PASS requisites/chb hidden / unknown visible / select all excludes / config unchanged');
|
||||
}finally{await browser.close();}
|
||||
})().catch(error=>{console.error(error);process.exit(1);});
|
||||
@@ -56,6 +56,12 @@ try {
|
||||
// Валидация всей грамматики без последующей сериализации decoded данных.
|
||||
json_decode($raw,false,512,JSON_THROW_ON_ERROR|JSON_BIGINT_AS_STRING);
|
||||
$offset=0; $root=utmConfigNode($raw,$offset);
|
||||
// Проверяем source keys, а не decoded object: escaped/повторные имена
|
||||
// тоже не должны скрыть chb перед публикацией enum replacement.
|
||||
foreach($root['properties']??[] as $property){
|
||||
if($property['key']['value']!=='utm'||($property['value']['type']??null)!=='object')continue;
|
||||
foreach($property['value']['properties'] as $setting)if($setting['key']['value']==='chb')throw new RuntimeException('Устаревший ключ utm.chb нельзя публиковать. Явно удалите его перед миграцией.');
|
||||
}
|
||||
$utm=utmConfigProperty($root,'utm')['value'] ?? null;
|
||||
if (($root['type'] ?? null)!=='object' || ($utm['type'] ?? null)!=='object') throw new RuntimeException('Отсутствует объект utm');
|
||||
$replacements=[];
|
||||
|
||||
@@ -42,5 +42,22 @@ try{
|
||||
exec(PHP_BINARY.' '.escapeshellarg($script).' '.escapeshellarg($file).' 2>/dev/null',$out,$code);
|
||||
if($code!==2||file_get_contents($file)!==$conflict)throw new RuntimeException('api#327: конфликт aliases должен блокировать patch без записи');
|
||||
}
|
||||
// preland#24: публикация старого chb запрещена даже при enum migration.
|
||||
foreach([
|
||||
'{"utm":{"theme":{"weights":{"dark":1},"default":"dark"},"chb":{"default":"1"}}}',
|
||||
'{"utm":{"theme":{"weights":{"1":1},"default":"1"},"chb":null}}',
|
||||
'{"utm":{"c\u0068b":null}}',
|
||||
'{"utm":{"chb":{},"chb":null}}',
|
||||
'{"utm":{"chb":null},"utm":{"theme":{"weights":{"dark":1},"default":"dark"}}}',
|
||||
] as $obsolete){
|
||||
file_put_contents($file,$obsolete);$stderr=$file.'.stderr';
|
||||
exec(PHP_BINARY.' '.escapeshellarg($script).' '.escapeshellarg($file).' 2>'.escapeshellarg($stderr),$out,$code);
|
||||
$message=file_get_contents($stderr);unlink($stderr);
|
||||
if($code!==2||file_get_contents($file)!==$obsolete||!str_contains($message,'utm.chb'))throw new RuntimeException('preland#24: chb должен явно блокировать публикацию без изменения байтов');
|
||||
}
|
||||
$nested='{ "chb":null,"utm":{"custom":{"chb":null},"theme":{"weights":{"dark":1.0},"default":"dark"}} }';
|
||||
file_put_contents($file,$nested);exec(PHP_BINARY.' '.escapeshellarg($script).' '.escapeshellarg($file),$out,$code);
|
||||
$expectedNested=str_replace(['"dark":','"default":"dark"'],['"1":','"default":"1"'],$nested);
|
||||
if($code!==10||file_get_contents($file)!==$expectedNested||property_exists(json_decode(file_get_contents($file))->utm,'cons'))throw new RuntimeException('preland#24: соседние chb/unknown сохраняются, отсутствующий cons не добавляется');
|
||||
echo "OK: numeric config migration preserves weights/default/foreign JSON types\n";
|
||||
}finally{unlink($file);}
|
||||
Reference in new issue
Block a user