From 84f70b1c71c028d8b05f1bc36676a0fa2f9e5841 Mon Sep 17 00:00:00 2001 From: Panfilov Konstantin Date: Sun, 25 Oct 2026 23:16:35 +0300 Subject: [PATCH] fix(admin): align UTM notes and block obsolete chb publication --- admin/README.md | 16 +++++++++------- admin/composer.json | 2 +- admin/public/assets/admin.css | 1 + admin/public/assets/admin.js | 25 ++++++++++++------------- admin/src/Config.php | 7 ++++++- admin/src/Remote.php | 6 ++++++ admin/src/Transport.php | 1 + admin/tests/obsolete-utm.php | 24 ++++++++++++++++++++++++ admin/tests/ui-link-builder.cjs | 2 +- admin/tests/ui-notes.cjs | 5 +++-- admin/tests/ui-remember.cjs | 4 ++-- admin/tests/ui-requisites.cjs | 6 ++++-- bin/ensure-utm-config.php | 6 ++++++ tests/check-numeric-config-patch.php | 17 +++++++++++++++++ 14 files changed, 93 insertions(+), 29 deletions(-) create mode 100644 admin/tests/obsolete-utm.php diff --git a/admin/README.md b/admin/README.md index d9f6cbf..d3d3405 100644 --- a/admin/README.md +++ b/admin/README.md @@ -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 на всю ширину объединённой ячейки доменов; сохранение явное. Черновик сохраняется при обновлении колонок или ошибке запроса. При конфликте сохранения нужно явно загрузить серверный текст; чужой текст молча не заменяется. diff --git a/admin/composer.json b/admin/composer.json index 4975a5a..ee55d4c 100644 --- a/admin/composer.json +++ b/admin/composer.json @@ -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"} } diff --git a/admin/public/assets/admin.css b/admin/public/assets/admin.css index cdd7c38..27c8aae 100644 --- a/admin/public/assets/admin.css +++ b/admin/public/assets/admin.css @@ -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} diff --git a/admin/public/assets/admin.js b/admin/public/assets/admin.js index 29521c6..2186803 100644 --- a/admin/public/assets/admin.js +++ b/admin/public/assets/admin.js @@ -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)}))); diff --git a/admin/src/Config.php b/admin/src/Config.php index ecea982..60008bd 100644 --- a/admin/src/Config.php +++ b/admin/src/Config.php @@ -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-хостом без пути.'); diff --git a/admin/src/Remote.php b/admin/src/Remote.php index 356dde1..63ff5b0 100644 --- a/admin/src/Remote.php +++ b/admin/src/Remote.php @@ -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); diff --git a/admin/src/Transport.php b/admin/src/Transport.php index d7a5e74..86bfe4a 100644 --- a/admin/src/Transport.php +++ b/admin/src/Transport.php @@ -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]); diff --git a/admin/tests/obsolete-utm.php b/admin/tests/obsolete-utm.php new file mode 100644 index 0000000..de1c0c1 --- /dev/null +++ b/admin/tests/obsolete-utm.php @@ -0,0 +1,24 @@ +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"; diff --git a/admin/tests/ui-link-builder.cjs b/admin/tests/ui-link-builder.cjs index 8352482..4bef1a3 100644 --- a/admin/tests/ui-link-builder.cjs +++ b/admin/tests/ui-link-builder.cjs @@ -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'); diff --git a/admin/tests/ui-notes.cjs b/admin/tests/ui-notes.cjs index 3b014fb..07aaa1c 100644 --- a/admin/tests/ui-notes.cjs +++ b/admin/tests/ui-notes.cjs @@ -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;indexObject.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;iNumber(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;indexObject.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;iNumber(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\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'; diff --git a/admin/tests/ui-remember.cjs b/admin/tests/ui-remember.cjs index 3031430..8c59025 100644 --- a/admin/tests/ui-remember.cjs +++ b/admin/tests/ui-remember.cjs @@ -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);}); diff --git a/admin/tests/ui-requisites.cjs b/admin/tests/ui-requisites.cjs index bca5740..cbec5c7 100644 --- a/admin/tests/ui-requisites.cjs +++ b/admin/tests/ui-requisites.cjs @@ -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);}); diff --git a/bin/ensure-utm-config.php b/bin/ensure-utm-config.php index 617c701..5b00119 100644 --- a/bin/ensure-utm-config.php +++ b/bin/ensure-utm-config.php @@ -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=[]; diff --git a/tests/check-numeric-config-patch.php b/tests/check-numeric-config-patch.php index 2282f30..351a93d 100644 --- a/tests/check-numeric-config-patch.php +++ b/tests/check-numeric-config-patch.php @@ -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);}