diff --git a/admin/README.md b/admin/README.md
index 695546a..afe48c5 100644
--- a/admin/README.md
+++ b/admin/README.md
@@ -8,6 +8,27 @@
Спецификация и план — `docs/superpowers/{specs,plans}/2026-10-25-preland22-*` в репозитории,
копии и фактический статус — в базе знаний, основной реестр — preland#22.
+## Сводка и конструктор ссылки
+
+Первый экран читает сохранённые конфиги всех подключений: основной сайт, реквизиты,
+дополнительные поля, default и все веса UTM. У каждой колонки своё время чтения,
+версия, обновление и ошибка; при отказе прежний снимок явно помечается. Обновление
+сводки не сбрасывает черновик редактора и не меняет его версию для CAS.
+
+Конструктор работает отдельно: значения или «Рандом» в восьми списках, затем
+«Сформировать и сохранить». Последний выбор и точный URL сохраняются на сервере
+отдельно по ID домена, восстанавливаются после перезагрузки. Изменение списка скрывает
+старую ссылку до явного сохранения. Значащая цифра стоит на пятой позиции метки,
+`0` означает выбор по весам. Конструктор не меняет config, default, веса или наборы,
+не создаёт задания; сохранённый выбор доступен и при недоступном хостинге.
+
+`GET /api/link-builder?target=ID` читает выбор, `POST /api/link-builder` сохраняет
+`{target,selection}` с ровно восемью каноническими UTM-ключами. Действуют общий вход
+и CSRF. Состояние находится в приватном `state/link-builder-ID.json` с режимом0600.
+При переименовании домена меняется origin ссылки, выбор и сохранённые метки остаются.
+Неизвестная в текущем runtime/catalog группа сопровождается предупреждением;
+конструктор не исправляет конфигурацию автоматически.
+
## Состав и доступы
`compose.yaml` запускает web и отдельный worker из одного неизменяемого образа. Нужны
diff --git a/admin/composer.json b/admin/composer.json
index c570ece..d5377f8 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": "*", "phpseclib/phpseclib": "^3.0"},
"config": {"sort-packages": true},
- "scripts": {"test": ["php tests/backend-review.php", "php tests/backend-http.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"], "test:worker": "php tests/backend-worker.php"}
}
diff --git a/admin/public/assets/admin.css b/admin/public/assets/admin.css
index 1776a5f..68e4f06 100644
--- a/admin/public/assets/admin.css
+++ b/admin/public/assets/admin.css
@@ -2,3 +2,6 @@
* api/modules/cpanel/views/settings/index.php at 591a67cc8302 (preland#22). */
*{box-sizing:border-box}body{margin:0;color:#333;background:#f7f7f7;font:14px/1.42857143 "Helvetica Neue",Helvetica,Arial,sans-serif}.container{width:min(1170px,100%);padding:0 15px;margin:0 auto}.navbar{background:#222;color:#ddd;min-height:52px}.nav-inner{display:flex;gap:24px;align-items:center;min-height:52px}.nav-inner strong{font-size:18px}.nav-inner button{margin-left:auto}h1{font-size:30px;font-weight:500;margin:24px 0 20px}h2{font-size:24px;font-weight:500;margin:0 0 18px}h3{font-size:18px;font-weight:500}p{margin:0 0 12px}.panel{padding:20px;background:white;border:1px solid #ddd;border-radius:4px;margin:20px 0}.login-panel{max-width:420px;margin:60px auto}.login-panel label{display:block;margin:15px 0}.form-control{display:block;width:100%;min-height:34px;padding:6px 12px;font:inherit;line-height:1.42857143;color:#555;background:#fff;border:1px solid #ccc;border-radius:4px;box-shadow:inset 0 1px 1px #0001}input[type=checkbox]{margin:0 5px 0 0;accent-color:#337ab7}input:focus,select:focus,button:focus-visible,a:focus-visible{outline:2px solid #337ab7;outline-offset:2px}button,input,select{font:inherit}.btn{display:inline-block;padding:6px 12px;border:1px solid transparent;border-radius:4px;font-weight:400;text-align:center;cursor:pointer;background:white;color:#333}.btn-default{border-color:#ccc}.btn-default:hover{background:#e6e6e6}.btn-primary{background:#337ab7;border-color:#2e6da4;color:white}.btn-danger{background:#d9534f;color:white}.btn-xs{padding:1px 5px;font-size:12px}.btn:disabled{opacity:.5;cursor:not-allowed}.toolbar,.target-options,.utm-inline-options,.utm-inline-option{display:flex;align-items:center;flex-wrap:wrap;gap:12px}.toolbar{margin:12px 0}.grow{flex:1;min-width:200px}.target-options label{padding:8px 10px;border:1px solid #ddd;border-radius:4px}.text-muted{color:#707070}.alert{border:1px solid #ddd;border-radius:4px;padding:15px;margin:12px 0;white-space:pre-wrap;overflow-wrap:anywhere}.alert-danger{background:#f2dede;color:#a94442;border-color:#ebccd1}.alert-success{background:#dff0d8;color:#3c763d}.alert-warning{background:#fcf8e3;color:#8a6d3b}.alert-info{background:#d9edf7;color:#31708f}.tabs{display:flex;flex-wrap:wrap;gap:24px;border-bottom:1px solid #ddd;padding-bottom:12px}a,.utm-default-action,.utm-group-save{color:#337ab7}.utm-default-action,.utm-group-save{border:0;background:none;cursor:pointer;padding:0;text-decoration:underline}.utm-default-value{font-weight:bold;color:#3c763d}.table-responsive{overflow-x:auto;max-width:100%}.table{width:100%;border-collapse:collapse;margin-bottom:12px}.table th{text-align:left;background:#f5f5f5}.table td,.table th{padding:8px;vertical-align:middle;border:1px solid #ddd}.table-hover tbody tr:hover{background:#f5f5f5}.indpic-sets-table{table-layout:fixed;min-width:900px}.indpic-sets-table th:first-child{width:170px}.indpic-default-column{width:105px}.indpic-check-column{width:110px}.indpic-actions-column{width:100px}.indpic-file-control{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.indpic-file-control input{max-width:230px;min-width:0}.indpic-add-form{display:flex;align-items:end;flex-wrap:wrap;gap:10px;margin-top:14px}.indpic-number-input{width:96px}.indpic-add-form .checkbox{margin:0 4px 7px}.indpic-add-form input[name=name]{width:170px}.indpic-add-form input[type=file]{max-width:300px}.utm-group-number{font-size:18px;font-weight:600}.utm-group-name{margin:6px 0}.utm-inline-options{gap:24px;margin-bottom:12px}.utm-inline-option{gap:6px}.form-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:15px}.field{margin:12px 0}.field-head{display:flex;align-items:center;gap:6px;margin-bottom:6px}.field-head label{font-weight:600}.nested{border-left:3px solid #eee;padding-left:15px;min-width:0}.field-block{margin:18px 0;padding-top:12px;border-top:1px solid #eee}.weight-table{max-width:560px}.weight-table input,.weight-table select{width:100%;min-width:75px}.mixed{font-size:12px;color:#8a6d3b;margin-left:8px}.job{margin:16px 0;border-top:1px solid #ddd;padding-top:12px}.job .table{min-width:480px}.status-success{color:#3c763d}.status-error,.status-conflict{color:#a94442}.status-unknown{color:#8a6d3b}.connection{padding:10px 0;border-bottom:1px solid #eee;display:flex;align-items:center;gap:10px;flex-wrap:wrap}.connection span{flex:1;min-width:160px}.break{overflow-wrap:anywhere}dialog{border:1px solid #999;border-radius:6px;box-shadow:0 5px 15px #0008;padding:0;width:960px;max-width:calc(100% - 40px)}dialog::backdrop{background:#0007}.modal-header{position:relative;text-align:center;padding:15px;border-bottom:1px solid #e5e5e5}.modal-header h2{font-size:18px;margin:0 34px}.close{position:absolute;right:15px;top:10px;background:none;border:0;font-size:25px;color:#777;cursor:pointer}.modal-body{padding:15px;max-height:calc(100vh - 180px);overflow-y:auto;white-space:pre-wrap;overflow-wrap:anywhere}.modal-footer{padding:15px;text-align:right;border-top:1px solid #e5e5e5}[hidden]{display:none!important}@media(max-width:600px){.panel{padding:14px}.form-grid{grid-template-columns:1fr}h1{font-size:25px}.nav-inner{gap:12px}.nav-inner>span{display:none}.target-options{align-items:stretch;flex-direction:column}.toolbar .btn{max-width:100%}.tabs{gap:15px}.indpic-add-form{align-items:stretch;flex-direction:column}.indpic-add-form input[name=name],.indpic-add-form input[type=file]{width:100%;max-width:100%}.weight-table{min-width:290px}.container{padding:0 10px}.nested{padding-left:8px}}
#notice{position:sticky;top:8px;z-index:100;box-shadow:0 2px 6px #0002}
+/* 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}
diff --git a/admin/public/assets/admin.js b/admin/public/assets/admin.js
index 941d820..51ca600 100644
--- a/admin/public/assets/admin.js
+++ b/admin/public/assets/admin.js
@@ -9,6 +9,128 @@
const enums={theme:{'1':'dark','2':'light'},reg:{'1':'before','2':'after'},banch:{'1':'b','2':'a'},focus:{'1':'face','2':'center'}};
const descriptions={theme:{'1':'тёмная','2':'светлая'},reg:{'1':'ввод имейл → подтверждение PIN → оплата','2':'ввод имейл → оплата → подтверждение PIN'},banch:{'1':'нет pop-up','2':'есть pop-up'},focus:{'1':'лицо, ближайшее к центру','2':'геометрический центр'}};
const formatDate=value=>{const date=new Date(value);return Number.isNaN(date.getTime())?String(value):date.toLocaleString('ru-RU',{timeZone:'Europe/Moscow'})+' МСК';};
+ // Read-only runtime snapshots and independent constructor drafts never enter state.reads/fields.
+ const overview={entries:new Map(),pending:new Map(),serial:0};
+ const builder={target:'',entries:new Map(),serial:0};
+ function groupFor(config,marker){
+ const keys=[marker,...(marker==='reg'?['registration']:marker==='banch'?['chob','bchoice']:[])].filter(key=>Object.hasOwn(config?.utm||{},key));
+ return {key:keys[0],value:keys.length?config.utm[keys[0]]:null,conflict:keys.length>1?keys:[]};
+ }
+ function digitFor(marker,value){const raw=String(value??'');const semantic=Object.entries(enums[marker]||{}).find(([,name])=>name===raw)?.[0];const digit=semantic||raw;return /^[1-9]$/.test(digit)&&(!enums[marker]||Object.hasOwn(enums[marker],digit))?digit:null;}
+ function overviewCell(config,marker){
+ const cell=el('td'),group=groupFor(config,marker);
+ if(group.conflict.length){cell.append(el('p',{class:'overview-error',text:'Конфликт ключей: '+group.conflict.join(', ')}));return cell;}
+ if(!group.value||typeof group.value!=='object'||Array.isArray(group.value)){cell.append(el('span',{class:'text-muted',text:group.key?'Некорректная группа':'Группа отсутствует'}));return cell;}
+ const value=group.value,digit=digitFor(marker,value.default);
+ cell.append(el('strong',{text:'Default: '+(digit?optionLabel(marker,digit):Object.hasOwn(value,'default')?'некорректное значение '+String(value.default):'не задан')}));
+ const weights=value.weights;
+ if(weights&&typeof weights==='object'&&!Array.isArray(weights)){
+ const list=el('ul',{class:'overview-weights'}),seen=new Set();let collision=false;
+ for(const [variant,weight]of Object.entries(weights)){const normalized=digitFor(marker,variant);if(normalized&&seen.has(normalized))collision=true;seen.add(normalized);list.append(el('li',{text:(normalized?optionLabel(marker,normalized):variant+' (неизвестный вариант)')+' — вес '+String(weight)}));}
+ if(!Object.keys(weights).length)list.append(el('li',{class:'text-muted',text:'Вариантов нет'}));cell.append(list);
+ if(collision)cell.append(el('p',{class:'overview-warning',text:'Конфликт semantic и numeric вариантов; исходные записи показаны без объединения.'}));
+ }else cell.append(el('p',{class:'overview-warning',text:'Веса отсутствуют или имеют неверный тип.'}));
+ return cell;
+ }
+ 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);
+ const utm=config?.utm;
+ if(utm&&typeof utm==='object'&&!Array.isArray(utm)){for(const [key,value]of Object.entries(utm)){
+ 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);
+ else if(value&&typeof value==='object'&&!Array.isArray(value))for(const [extra,next]of Object.entries(value))if(!['default','weights'].includes(extra))visit(['utm',key,extra],next);
+ }}else if(utm!==undefined)visit(['utm'],utm);
+ return values;
+ }
+ function renderOverview(){
+ const table=$('overview-table'),head=el('tr',{},el('th',{scope:'col',text:'Параметр'}));
+ for(const target of state.targets){const entry=overview.entries.get(target.id),th=el('th',{scope:'col','data-overview-target':target.id},el('div',{class:'overview-domain',text:target.domain}),el('div',{class:'text-muted',text:target.environment+(target.enabled?'':' · запись отключена')+(target.verified?'':' · соединение не проверено')}));
+ let text=entry?.status==='loading'?'Чтение…':entry?.status==='error'?'Ошибка чтения: '+entry.error:entry?.data?'Прочитано':'Не прочитано';
+ if(entry?.data){text+=(entry.status!=='success'?' Показан прежний снимок. ':' ')+formatDate(entry.readAt)+' · версия '+entry.data.version.slice(0,12);}
+ th.append(el('p',{class:'overview-status'+(entry?.status==='error'?' overview-error':''),text}));
+ const refresh=button('Обновить',()=>refreshOverviewTarget(target.id));refresh.setAttribute('aria-label','Обновить сводку '+target.domain);refresh.disabled=entry?.status==='loading';
+ th.append(el('div',{class:'toolbar'},refresh,button('Редактировать',async()=>{await openTarget(target.id);if(state.source===target.id){$('source-target').value=target.id;$('settings').scrollIntoView({behavior:'smooth',block:'start'});}})));head.append(th);
+ }
+ 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');});
+ 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);}
+ if(builder.target&&builder.entries.get(builder.target)?.ready)updateBuilderState();
+ }
+ function refreshOverviewTarget(id){
+ const request=++overview.serial,previous=overview.entries.get(id);overview.entries.set(id,{...previous,status:'loading',request});renderOverview();
+ const pending=(async()=>{try{const data=await api('config?target='+encodeURIComponent(id));if(overview.entries.get(id)?.request!==request||!state.csrf)return;overview.entries.set(id,{status:'success',request,data,readAt:new Date().toISOString()});}catch(error){if(overview.entries.get(id)?.request!==request||!state.csrf)return;overview.entries.set(id,{...previous,status:'error',request,error:error.message});}finally{if(overview.entries.get(id)?.request===request){overview.pending.delete(id);renderOverview();}}})();overview.pending.set(id,pending);return pending;
+ }
+ async function refreshOverview(){const control=$('overview-refresh');control.disabled=true;try{await Promise.allSettled(state.targets.map(target=>refreshOverviewTarget(target.id)));}finally{control.disabled=false;}}
+ function syncOverview(){
+ const ids=new Set(state.targets.map(target=>target.id));for(const id of overview.entries.keys())if(!ids.has(id)){overview.entries.delete(id);overview.pending.delete(id);builder.entries.delete(id);}
+ if(builder.target&&!ids.has(builder.target))builder.target='';
+ const select=$('builder-target');select.replaceChildren(el('option',{value:'',text:'Выберите домен'}),...state.targets.map(t=>el('option',{value:t.id,text:targetName(t.id)})));select.value=builder.target;renderOverview();if(builder.target&&builder.entries.get(builder.target)?.domain!==state.targets.find(t=>t.id===builder.target)?.domain)run(()=>openBuilder(builder.target,true));else renderBuilder();run(refreshOverview);
+ }
+ function selectionsEqual(a,b){return !!a&&!!b&&markers.every(marker=>String(a[marker])===String(b[marker]));}
+ function constructorValid(selection){return markers.every(marker=>selection[marker]==='rnd'||!!digitFor(marker,selection[marker])&&String(digitFor(marker,selection[marker]))===selection[marker]);}
+ function builderWarnings(entry){
+ const result=[],snapshot=overview.entries.get(builder.target),config=snapshot?.data?.config;
+ if(!config){result.push('Текущий runtime-конфиг не прочитан; фактическое применение выбранных значений не проверено.');return result;}
+ if(snapshot.status!=='success')result.push('Проверка выбора использует прежний снимок runtime-конфига; обновите сводку.');
+ const catalog=snapshot.data.catalog,generatedAt=Date.parse(catalog?.generated_at||''),now=Date.now();
+ const host=value=>{try{return new URL(String(value).includes('://')?String(value):'https://'+value).hostname.toLowerCase().replace(/[.]$/,'');}catch(_){return '';}};
+ const catalogFresh=snapshot.status==='success'&&Number.isFinite(generatedAt)&&generatedAt>=now-180000&&generatedAt<=now+30000&&host(catalog?.host)&&host(catalog.host)===host(config.host);
+ if(!catalogFresh)result.push('degr/tp/cons: актуальный каталог основного сайта недоступен; существование групп не проверено.');
+ for(const marker of markers){const choice=entry.draft[marker],group=groupFor(config,marker);if(group.conflict.length){result.push(marker+': конфликт ключей '+group.conflict.join(', ')+'. Публичный resolver может выбрать другое значение.');continue;}
+ if(choice!=='rnd'&&!digitFor(marker,choice)){result.push(marker+': выберите допустимое значение. Сохранённый выбор не заменён автоматически.');continue;}
+ const weights=group.value?.weights||{},variants=Object.keys(weights).map(v=>digitFor(marker,v));
+ if(['indpic','degr','tp'].includes(marker)&&choice!=='rnd'&&!variants.includes(choice))result.push(marker+'='+choice+': варианта нет в текущих весах; при открытии ссылки resolver использует default (или штатный fallback). Конфигурация не меняется.');
+ if(catalogFresh&&['degr','tp','cons'].includes(marker)&&Array.isArray(catalog.groups?.[marker])&&choice!=='rnd'&&digitFor(marker,choice)&&!catalog.groups[marker].some(item=>String(item.number)===choice))result.push(marker+'='+choice+': группы нет в актуальном каталоге основного сайта. '+(marker==='cons'?'Прелендинг передаст выбранное значение; основной сайт может не принять эту группу.':'Основной сайт может не принять эту группу; наличие варианта в весах не подтверждает её существование.'));
+ if(!group.key)result.push(marker+': группа отсутствует в runtime; при открытии применяется штатный fallback.');
+ }
+ return result;
+ }
+ function renderBuilder(){
+ const entry=builder.entries.get(builder.target);$('builder-fields').replaceChildren();$('builder-result').hidden=true;$('builder-warnings').replaceChildren();$('builder-save').disabled=true;
+ if(!builder.target){$('builder-state').textContent='Выберите домен.';return;}
+ if(!entry?.ready){$('builder-state').textContent=entry?.error?'Ошибка чтения выбора: '+entry.error:'Чтение последнего выбора…';if(entry?.error)$('builder-fields').append(button('Повторить чтение выбора',()=>openBuilder(builder.target,true)));return;}
+ for(const marker of markers){const values=[...(enums[marker]?Object.keys(enums[marker]):Array.from({length:9},(_,i)=>String(i+1))),'rnd'];const select=el('select',{class:'form-control','data-marker':marker,'aria-label':'Ссылка: '+marker},el('option',{value:'',text:'Выберите значение'}),...values.map(value=>el('option',{value,text:value==='rnd'?'Рандом':optionLabel(marker,value)})));
+ if(entry.draft[marker]&&!values.includes(entry.draft[marker]))select.append(el('option',{value:entry.draft[marker],text:'Недопустимый сохранённый выбор: '+entry.draft[marker]}));select.value=entry.draft[marker]||'';select.disabled=entry.saving;
+ select.addEventListener('change',()=>{entry.draft[marker]=select.value;entry.error='';updateBuilderState();});$('builder-fields').append(labelControl(marker,select));
+ }
+ updateBuilderState();
+ }
+ function updateBuilderState(){
+ const entry=builder.entries.get(builder.target);if(!entry?.ready)return;const dirty=!selectionsEqual(entry.draft,entry.saved),valid=constructorValid(entry.draft);
+ $('builder-state').textContent=entry.saving?'Формирование и сохранение…':entry.error?'Ошибка: '+entry.error:dirty?'Есть несохранённый выбор. Нажмите «Сформировать и сохранить».':'Показан последний сохранённый выбор этого домена.';
+ $('builder-save').disabled=!!entry.saving||!valid;for(const select of $('builder-fields').querySelectorAll('select'))select.disabled=!!entry.saving;
+ $('builder-warnings').replaceChildren(...builderWarnings(entry).map(text=>el('p',{class:'alert alert-warning',text})));
+ const show=!!entry.url&&!dirty;let safeUrl=null;if(show){try{const parsed=new URL(entry.url),target=state.targets.find(t=>t.id===builder.target);if(parsed.protocol==='https:'&&parsed.host===target?.domain)safeUrl=parsed.href;}catch(_){}}
+ $('builder-result').hidden=!safeUrl;if(safeUrl){$('builder-url').value=safeUrl;$('builder-open').href=safeUrl;$('builder-saved-at').textContent=entry.savedAt?'Выбор сохранён '+formatDate(entry.savedAt):'';}else if(show)$('builder-state').textContent='Сервер вернул недопустимую ссылку. Сформируйте её повторно.';
+ }
+ async function openBuilder(id,force=false){
+ builder.target=id;$('builder-target').value=id;if(!id){renderBuilder();return;}const domain=state.targets.find(t=>t.id===id)?.domain;if(builder.entries.get(id)?.ready&&builder.entries.get(id).domain===domain&&!force){renderBuilder();return;}
+ const request=++builder.serial;builder.entries.set(id,{ready:false,request,domain});renderBuilder();
+ try{const stored=await api('link-builder?target='+encodeURIComponent(id));if(!stored.selection&&!overview.entries.get(id)?.data)await(overview.pending.get(id)||refreshOverviewTarget(id));if(builder.entries.get(id)?.request!==request||!state.csrf)return;
+ const saved=stored.selection?{...stored.selection}:null;const draft=saved?{...saved}:{};if(!saved){const config=overview.entries.get(id)?.data?.config;for(const marker of markers){const group=groupFor(config,marker);draft[marker]=group.conflict.length?'':digitFor(marker,group.value?.default)||'';}}
+ builder.entries.set(id,{ready:true,request,domain,draft,saved,url:stored.url,savedAt:stored.saved_at,error:''});
+ }catch(error){if(builder.entries.get(id)?.request===request&&state.csrf)builder.entries.set(id,{ready:false,request,domain,error:error.message});}finally{if(builder.target===id)renderBuilder();}
+ }
+ async function saveBuilder(){
+ const id=builder.target,entry=builder.entries.get(id);if(!entry?.ready||!constructorValid(entry.draft)||entry.saving)return;const request=entry.request;entry.saving=true;entry.error='';updateBuilderState();
+ try{const result=await api('link-builder',{target:id,selection:{...entry.draft}});if(builder.entries.get(id)?.request!==request||!state.csrf)return;entry.saved={...result.selection};entry.draft={...result.selection};entry.url=result.url;entry.savedAt=result.saved_at;}catch(error){entry.error=error.message;}finally{entry.saving=false;if(builder.target===id)updateBuilderState();}
+ }
+ $('overview-refresh').addEventListener('click',()=>run(refreshOverview));
+ $('builder-target').addEventListener('change',event=>run(()=>openBuilder(event.target.value)));
+ $('builder-save').addEventListener('click',()=>run(saveBuilder));
+ $('builder-copy').addEventListener('click',()=>run(async()=>{const input=$('builder-url');if($('builder-result').hidden)return;try{if(!navigator.clipboard)throw new Error();await navigator.clipboard.writeText(input.value);notice('Ссылка скопирована.','success');}catch(_){input.focus();input.select();notice('Ссылка выделена. Скопируйте её через меню браузера или Ctrl+C.','info');}}));
+
const statuses={queued:'Ожидание',running:'Выполняется',success:'Сохранено и проверено',error:'Ошибка',conflict:'Конфликт версии — перечитайте домен',unknown:'Неизвестный результат — проверьте состояние'};
function el(tag,attrs={},...children){const n=document.createElement(tag);for(const [k,v] of Object.entries(attrs)){if(k==='class')n.className=v;else if(k==='text')n.textContent=v;else if(k.startsWith('on'))n.addEventListener(k.slice(2),v);else if(k in n && k!=='list')n[k]=v;else n.setAttribute(k,v);}for(const c of children.flat())if(c!==undefined&&c!==null)n.append(c instanceof Node?c:document.createTextNode(String(c)));return n;}
const button=(label,action,kind='default')=>{const control=el('button',{type:'button',class:'btn btn-'+kind,text:label});control.addEventListener('click',()=>run(async()=>{control.disabled=true;try{await action();}finally{control.disabled=false;}}));return control;};
@@ -21,7 +143,7 @@
if(!response.ok||!body.ok){if(response.status===401)showLogin();const error=new Error(body.error||'Не удалось выполнить операцию (HTTP '+response.status+').');error.status=response.status;throw error;}return body;
}
async function run(action){try{await action();}catch(error){notice(error.message);}}
- function showLogin(){for(const timer of pollTimers.values())clearTimeout(timer);pollTimers.clear();state.csrf='';state.source='';state.fields=[];state.reads.clear();$('workspace').hidden=true;$('logout').hidden=true;$('login-panel').hidden=false;}
+ function showLogin(){overview.entries.clear();overview.pending.clear();builder.entries.clear();builder.target='';for(const timer of pollTimers.values())clearTimeout(timer);pollTimers.clear();state.csrf='';state.source='';state.fields=[];state.reads.clear();$('workspace').hidden=true;$('logout').hidden=true;$('login-panel').hidden=false;}
function selected(){return [...$('targets').querySelectorAll('input:checked')].map(n=>n.value);}
function targetName(id){const t=state.targets.find(t=>t.id===id);return t? t.domain+' ('+t.environment+')':id;}
function valueAt(object,path){return path.reduce((value,key)=>value&&typeof value==='object'?value[key]:undefined,object);}
@@ -108,7 +230,7 @@
async function fetchRead(id){const data=await api('config?target='+encodeURIComponent(id));state.reads.set(id,data);return data;}
async function openTarget(id,force=false){if(!force&&state.fields.some(f=>f.check.checked)&&!confirm('При смене домена несохранённый выбор полей будет сброшен. Продолжить?')){$('source-target').value=state.source;return;}state.source=id;state.fields=[];$('config-fields').replaceChildren();$('sets-body').replaceChildren();$('backups').replaceChildren();$('warnings').replaceChildren();state.reads.clear();renderTargetOptions();updateSummary();if(!id){$('read-state').textContent='Выберите домен.';return;}state.loading++;$('read-state').textContent='Чтение текущей конфигурации…';updateSummary();try{await fetchRead(id);renderConfig();await Promise.all([loadSets(),loadBackups()]);notice('','info');}finally{state.loading--;updateSummary();}}
function renderTargetOptions(){const box=$('targets');box.replaceChildren();for(const target of state.targets){const input=el('input',{type:'checkbox',value:target.id,checked:target.id===state.source&&target.enabled,disabled:!target.enabled});const status=el('span',{class:'text-muted'});const label=el('label',{},input,targetName(target.id),status);box.append(label);input.addEventListener('change',()=>run(async()=>{if(input.checked&&!state.reads.has(target.id)){status.textContent=' · чтение версии…';state.loading++;updateSummary();try{await fetchRead(target.id);status.textContent=' · прочитано';}catch(e){input.checked=false;status.textContent=' · недоступен';throw e;}finally{state.loading--;}}updateSummary();}));}}
- async function loadState(){const data=await api('state');state.csrf=data.csrf;state.targets=data.targets||[];$('login-panel').hidden=true;$('workspace').hidden=false;$('logout').hidden=false;const source=$('source-target');source.replaceChildren(el('option',{value:'',text:'Выберите домен'}),...state.targets.map(t=>el('option',{value:t.id,text:targetName(t.id)})));source.value=state.source;renderTargetOptions();renderConnections();await loadJobs();}
+ async function loadState(){const data=await api('state');state.csrf=data.csrf;state.targets=data.targets||[];$('login-panel').hidden=true;$('workspace').hidden=false;$('logout').hidden=false;const source=$('source-target');source.replaceChildren(el('option',{value:'',text:'Выберите домен'}),...state.targets.map(t=>el('option',{value:t.id,text:targetName(t.id)})));source.value=state.source;renderTargetOptions();renderConnections();syncOverview();await loadJobs();}
function safeTargets(){const ids=selected();if(!ids.length)throw new Error('Выберите хотя бы один домен.');if(state.loading||ids.some(id=>!state.reads.has(id)))throw new Error('Сначала перечитайте выбранные домены и дождитесь загрузки.');return ids;}
async function applyChanges(changes=chosenChanges(),ids=safeTargets()){
if(!changes.length)throw new Error('Выберите поля для применения.');
@@ -127,7 +249,7 @@
async function loadJobs(){const r=await api('jobs');state.jobs=(r.jobs||[]).slice().sort((a,b)=>String(b.created_at||'').localeCompare(String(a.created_at||'')));renderJobs();for(const job of state.jobs)if(job.status!=='completed'&&!pollTimers.has(job.id))pollTimers.set(job.id,setTimeout(()=>run(()=>pollJob(job.id,false)),1400));}
const pollTimers=new Map();
async function showJob(job,dialog=false){const id=typeof job==='string'?job:job.id;if(typeof job==='object'){state.jobs=state.jobs.filter(j=>j.id!==id);state.jobs.unshift(job);renderJobs();}if(dialog)modal('Проверка / публикация indpic','Задание '+id+' принято. Ожидание проверки…');await pollJob(id,dialog);}
- async function pollJob(id,dialog){pollTimers.delete(id);const r=await api('jobs?id='+encodeURIComponent(id));const job=r.job;state.jobs=state.jobs.filter(j=>j.id!==id);state.jobs.unshift(job);renderJobs();if(dialog&&$('indpic-result-modal').open){modal('Результат проверки indpic',(job.results||[]).map(v=>targetName(v.target)+'\n'+(statuses[v.status]||v.status)+'\n'+(v.message||'')).join('\n\n')||'Ожидание…');}if(job.status!=='completed'){clearTimeout(pollTimers.get(id));pollTimers.set(id,setTimeout(()=>run(()=>pollJob(id,dialog)),1400));}else{clearTimeout(pollTimers.get(id));pollTimers.delete(id);const configChanged=['apply','restore'].includes(job.type);if(configChanged)for(const result of job.results||[])if(result.status==='success')state.reads.delete(result.target);if((job.results||[]).some(r=>r.status==='success'))notice('Задание завершено. Результаты показаны отдельно для каждого домена.'+(configChanged?' Перед следующим изменением перечитайте домен.':''),'info');if(state.source){await loadSets();await loadBackups();}updateSummary();}}
+ async function pollJob(id,dialog){pollTimers.delete(id);const r=await api('jobs?id='+encodeURIComponent(id));const job=r.job;state.jobs=state.jobs.filter(j=>j.id!==id);state.jobs.unshift(job);renderJobs();if(dialog&&$('indpic-result-modal').open){modal('Результат проверки indpic',(job.results||[]).map(v=>targetName(v.target)+'\n'+(statuses[v.status]||v.status)+'\n'+(v.message||'')).join('\n\n')||'Ожидание…');}if(job.status!=='completed'){clearTimeout(pollTimers.get(id));pollTimers.set(id,setTimeout(()=>run(()=>pollJob(id,dialog)),1400));}else{clearTimeout(pollTimers.get(id));pollTimers.delete(id);const configChanged=['apply','restore'].includes(job.type);if(configChanged)for(const result of job.results||[])if(result.status==='success'){state.reads.delete(result.target);run(()=>refreshOverviewTarget(result.target));}if((job.results||[]).some(r=>r.status==='success'))notice('Задание завершено. Результаты показаны отдельно для каждого домена.'+(configChanged?' Перед следующим изменением перечитайте домен.':''),'info');if(state.source){await loadSets();await loadBackups();}updateSummary();}}
async function loadBackups(){const box=$('backups');box.replaceChildren();if(!state.source)return;const targetId=state.source;const r=await api('backups?target='+encodeURIComponent(targetId));if(targetId!==state.source)return;for(const backup of r.backups||[]){box.append(el('div',{class:'toolbar'},el('span',{class:'break',text:(backup.created_at?formatDate(backup.created_at):'')+' · '+backup.id}),button('Восстановить',async()=>{if(!confirm('Восстановить резерв '+backup.id+' на '+targetName(state.source)+'?'))return;const read=readCurrent();if(!read)throw new Error('Перед восстановлением перечитайте текущий домен.');const result=await api('restore',{target:state.source,id:backup.id,version:read.version});await showJob(result.job);})));}if(!(r.backups||[]).length)box.append(el('p',{class:'text-muted',text:'Резервных копий пока нет.'}));}
function renderConnections(){const list=$('connection-list');list.replaceChildren();for(const t of state.targets){list.append(el('div',{class:'connection'},el('span',{class:'break',text:t.name+' · '+targetName(t.id)+' · '+t.type+' · '+(t.enabled?'включено':'отключено')+(t.verified?' · соединение проверено':' · требуется проверка')+(t.has_secret?' · секрет сохранён':'')}),button('Изменить',()=>{const form=$('connection-form');form.reset();for(const key of ['id','name','domain','environment','type','host','port','user','root','host_key'])form.elements[key].value=t[key]??'';form.elements.enabled.checked=!!t.enabled;form.elements.id.readOnly=true;$('connection-title').textContent='Изменить подключение';form.elements.name.focus();}),button('Проверить соединение',async()=>{const result=await api('targets/test',{id:t.id});modal('Проверка соединения',result.message||result.result?.message||'Соединение и пути проверены.');}),button('Удалить',async()=>{if(confirm('Удалить подключение '+t.domain+'? Сайт и файлы останутся.')){await api('targets',{id:t.id},'DELETE');if(state.source===t.id){state.source='';state.fields=[];$('config-fields').replaceChildren();}await loadState();}},'danger')));}}
$('login-form').addEventListener('submit',event=>{event.preventDefault();run(async()=>{const form=event.currentTarget;const submit=form.querySelector('button');submit.disabled=true;try{const data=await api('login',{username:form.username.value,password:form.password.value});state.csrf=data.csrf||'';form.password.value='';await loadState();notice('','info');}finally{submit.disabled=false;}});});
diff --git a/admin/public/index.php b/admin/public/index.php
index 89e6f94..ef5cb1f 100644
--- a/admin/public/index.php
+++ b/admin/public/index.php
@@ -1,7 +1,7 @@
login((string)($data['username']??''),(string)($data['password']??''),$_SERVER['REMOTE_ADDR']??'unknown'));
$user=$auth->requireUser();
if($method!=='GET')$auth->csrf($_SERVER['HTTP_X_CSRF_TOKEN']??'');
- if($path==='/api/state'&&$method==='GET')reply(['csrf'=>$_SESSION['csrf'],'user'=>$user,'targets'=>$store->targets()]);
if($path==='/api/logout'&&$method==='POST'){$auth->logout();reply([]);}
+ // Авторизация, idle timestamp и CSRF уже проверены; SSH/файловые операции
+ // не должны удерживать PHP session lock и блокировать соседние запросы.
+ session_write_close();
+ if($path==='/api/state'&&$method==='GET')reply(['csrf'=>$_SESSION['csrf'],'user'=>$user,'targets'=>$store->targets()]);
+ if($path==='/api/link-builder'){
+ $builder=new LinkBuilder($store);
+ if($method==='GET'){
+ if(array_diff(array_keys($_GET),['target'])||!is_string($_GET['target']??null))throw new Problem('Укажите ID подключения конструктора ссылки.');
+ reply($builder->get($_GET['target']));
+ }
+ if($method==='POST'){
+ if(count($data)!==2||array_diff(array_keys($data),['target','selection'])||!is_string($data['target']??null)||!(($data['selection']??null) instanceof stdClass))throw new Problem('Передайте ID подключения и объект выбора конструктора ссылки.');
+ reply($builder->save($data['target'],get_object_vars($data['selection'])));
+ }
+ throw new Problem('Метод конструктора ссылки не поддерживается.',405);
+ }
if($path==='/api/targets'&&$method==='POST')reply(['target'=>$store->saveTarget((array)($data['target']??[]),isset($data['secret'])?(string)$data['secret']:null)]);
if($path==='/api/targets'&&$method==='DELETE'){$store->deleteTarget((string)($data['id']??''));reply([]);}
if($path==='/api/targets/test'&&$method==='POST'){$id=(string)($data['id']??'');$t=$store->target($id);(new Transport($store,$t))->test();$store->verifyTarget($id,$t);reply(['message'=>'Пути, блокировка, чтение и права записи проверены.']);}
diff --git a/admin/src/LinkBuilder.php b/admin/src/LinkBuilder.php
new file mode 100644
index 0000000..39ac9c1
--- /dev/null
+++ b/admin/src/LinkBuilder.php
@@ -0,0 +1,104 @@
+assertId($targetId);
+ return $this->store->locked('link-builder-' . $targetId, function () use ($targetId): array {
+ $origin = $this->origin($this->store->target($targetId));
+ $name = $this->filename($targetId);
+ $saved = $this->store->read($name, null);
+ if ($saved === null) return ['selection'=>null, 'url'=>null, 'saved_at'=>null];
+ if (!is_array($saved) || !is_array($saved['selection'] ?? null)
+ || !is_string($saved['url'] ?? null) || !is_string($saved['saved_at'] ?? null)) {
+ throw new Problem('Повреждено сохранённое состояние конструктора ссылки.', 503);
+ }
+ $selection = $this->selection($saved['selection']);
+ $query = parse_url($saved['url'], PHP_URL_QUERY);
+ if (!is_string($query)) throw new Problem('Повреждена сохранённая ссылка.', 503);
+ parse_str($query, $parameters);
+ if (array_keys($parameters) !== self::KEYS) throw new Problem('Повреждена сохранённая ссылка.', 503);
+ foreach ($selection as $key=>$value) {
+ $digit = $value === 'rnd' ? '0' : $value;
+ if (!is_string($parameters[$key]) || !preg_match('/^[a-f0-9]{4}' . $digit . '[a-f0-9]{12}$/D', $parameters[$key])) {
+ throw new Problem('Повреждена сохранённая ссылка.', 503);
+ }
+ }
+ // Переименование цели меняет только origin, сохранённый шум не генерируется заново.
+ $url = $origin . '/?' . http_build_query($parameters, '', '&', PHP_QUERY_RFC3986);
+ if ($saved['url'] !== $url) {
+ $saved['url'] = $url;
+ $this->store->write($name, $saved);
+ }
+ return ['selection'=>$selection, 'url'=>$url, 'saved_at'=>$saved['saved_at']];
+ });
+ }
+
+ public function save(string $targetId, array $selection): array
+ {
+ $this->assertId($targetId);
+ $selection = $this->selection($selection);
+ return $this->store->locked('link-builder-' . $targetId, function () use ($targetId, $selection): array {
+ $origin = $this->origin($this->store->target($targetId));
+ $parameters = [];
+ foreach ($selection as $key=>$value) {
+ $parameters[$key] = bin2hex(random_bytes(2)) . ($value === 'rnd' ? '0' : $value) . bin2hex(random_bytes(6));
+ }
+ $saved = [
+ 'selection'=>$selection,
+ 'url'=>$origin . '/?' . http_build_query($parameters, '', '&', PHP_QUERY_RFC3986),
+ 'saved_at'=>gmdate(DATE_ATOM),
+ ];
+ $this->store->write($this->filename($targetId), $saved);
+ return $saved;
+ });
+ }
+
+ private function selection(array $input): array
+ {
+ if (count($input) !== count(self::KEYS) || array_diff(array_keys($input), self::KEYS)) {
+ throw new Problem('Передайте ровно восемь параметров конструктора ссылки.');
+ }
+ $selection = [];
+ foreach (self::KEYS as $key) {
+ $value = $input[$key] ?? null;
+ if (!is_string($value) || ($value !== 'rnd' && !preg_match('/^[1-9]$/D', $value))
+ || (in_array($key, self::ENUMS, true) && !in_array($value, ['1', '2', 'rnd'], true))) {
+ throw new Problem("Недопустимое значение параметра {$key} конструктора ссылки.");
+ }
+ $selection[$key] = $value;
+ }
+ return $selection;
+ }
+
+ private function origin(array $target): string
+ {
+ $domain = $target['domain'] ?? null;
+ if (!is_string($domain) || strlen($domain)>253 || !preg_match('/^[a-z0-9.-]+$/iD', $domain)
+ || str_starts_with($domain, '.') || str_ends_with($domain, '.')
+ || filter_var($domain, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME) === false) {
+ throw new Problem('В подключении указан небезопасный домен для HTTPS-ссылки.');
+ }
+ return 'https://' . strtolower($domain);
+ }
+
+ private function assertId(string $targetId): void
+ {
+ if (!preg_match('/^[a-zA-Z0-9_-]{1,64}$/D', $targetId)) throw new Problem('Некорректный ID подключения.');
+ }
+
+ private function filename(string $targetId): string
+ {
+ return 'link-builder-' . $targetId . '.json';
+ }
+}
diff --git a/admin/tests/backend-http.php b/admin/tests/backend-http.php
index 1df0044..92fe698 100644
--- a/admin/tests/backend-http.php
+++ b/admin/tests/backend-http.php
@@ -2,6 +2,7 @@
declare(strict_types=1);
require __DIR__.'/backend.php';
$port=random_int(19000,29000);$env=getenv();$env['ADMIN_COOKIE_SECURE']='0';$env['ADMIN_USERNAME']='test-admin';$env['ADMIN_PASSWORD_HASH']=password_hash('fixture-admin-password',PASSWORD_DEFAULT);$env['ADMIN_STATE_DIR']=$root.'/state';$env['ADMIN_LOCAL_ROOTS']=$root.'/site';
+$env['PHP_CLI_SERVER_WORKERS']='4';
$log=fopen($root.'/http.log','a');$server=proc_open([PHP_BINARY,'-S','127.0.0.1:'.$port,'-t',dirname(__DIR__).'/public',dirname(__DIR__).'/public/index.php'],[0=>['pipe','r'],1=>$log,2=>$log],$pipes,null,$env);if(!is_resource($server))throw new RuntimeException('server failed');
$cookie='';
function request(string $path,string $method='GET',?array $body=null,?string $csrf=null): array {
@@ -15,6 +16,7 @@ function request(string $path,string $method='GET',?array $body=null,?string $cs
try{
$deadline=microtime(true)+5;do{$socket=@fsockopen('127.0.0.1',$port);if($socket){fclose($socket);break;}usleep(50000);}while(microtime(true)<$deadline);
[$status,$body]=request('/api/state');check($status===401&&!$body['ok'],'HTTP unauthenticated state blocked');
+ [$status,$body]=request('/api/link-builder?target=test');check($status===401,'HTTP link builder requires authentication');
[$status,$body,$headers]=request('/api/login','POST',['username'=>'test-admin','password'=>'fixture-admin-password']);check($status===200&&isset($body['csrf']),'HTTP login creates authenticated CSRF session');$csrf=$body['csrf'];
check((bool)array_filter($headers,static fn($h)=>str_contains(strtolower($h),'httponly')&&str_contains(strtolower($h),'samesite=strict')),'session cookie HttpOnly and SameSite Strict');
[$status,$body]=request('/api/state');check($status===200&&isset($body['targets'][0])&&!array_key_exists('secret',$body['targets'][0]),'HTTP state redacts transport credentials');
@@ -23,6 +25,33 @@ try{
[$status,$body]=request('/api/apply','POST',['targets'=>[['id'=>'test','version'=>$version]],'changes'=>[['path'=>['host'],'value'=>'http.example']]],$csrf);check($status===202&&isset($body['job']['id']),'HTTP apply queues persistent job');$id=$body['job']['id'];
(new PrelandAdmin\Jobs($store))->run($id);
[$status,$body]=request('/api/jobs?id='.$id);check($body['job']['results'][0]['status']==='success'&&$body['job']['changes'][0]['path']===['host'],'HTTP persisted result and changed paths visible');
+ [$status,$body]=request('/api/link-builder?target=test');check($status===200&&$body['selection']===null&&$body['url']===null&&$body['saved_at']===null,'HTTP new link builder state is explicitly empty');
+ $linkSelection=array_fill_keys(['indpic','theme','degr','reg','banch','tp','cons','focus'],'rnd');$runtimeHash=hash_file('sha256',$root.'/site/config.json');$jobCount=count(glob($store->dir.'/jobs/*.json'));
+ [$status,$body]=request('/api/link-builder','POST',['target'=>'test','selection'=>$linkSelection]);check($status===403,'HTTP link generation requires CSRF');
+ [$status,$body]=request('/api/link-builder','POST',['target'=>'test','selection'=>$linkSelection],$csrf);check($status===200&&$body['selection']===$linkSelection&&str_starts_with($body['url'],'https://test.invalid/?'),'HTTP explicit link generation returns saved independent choice');$savedLink=$body;
+ [$status,$body]=request('/api/link-builder?target=test');check($status===200&&$body===$savedLink,'HTTP reload returns exact stored URL without fresh noise');
+ $linkSelection['focus']='2';[$status,$body]=request('/api/link-builder','POST',['target'=>'test','selection'=>$linkSelection],$csrf);check($status===200&&array_diff_assoc($body['selection'],$savedLink['selection'])===['focus'=>'2'],'HTTP changing one link selector preserves other choices');
+ [$status,$body]=request('/api/link-builder','POST',['target'=>'test','selection'=>$linkSelection+['evil'=>'1']],$csrf);check($status===422,'HTTP unexpected selection key rejected');
+ [$status,$body]=request('/api/link-builder','POST',['target'=>['test'],'selection'=>$linkSelection],$csrf);check($status===422,'HTTP non-string target rejected with safe validation status');
+ [$status,$body]=request('/api/link-builder','POST',['target'=>'test','selection'=>[]],$csrf);check($status===422,'HTTP selection must be complete object');
+ [$status,$body]=request('/api/link-builder','POST',['target'=>'test','selection'=>$linkSelection,'url'=>'javascript:alert(1)'],$csrf);check($status===422,'HTTP caller cannot inject URL or extra top-level fields');
+ [$status,$body]=request('/api/targets','POST',['target'=>['id'=>'test','domain'=>'renamed.invalid']],$csrf);check($status===200,'HTTP target domain rename accepted');
+ [$status,$body]=request('/api/link-builder?target=test');check($status===200&&str_starts_with($body['url'],'https://renamed.invalid/?')&&$body['selection']===$linkSelection,'HTTP saved selection survives domain rename with current URL base');
+ // Настоящий медленный SSH read: отдельный TCP fixture принимает соединение без SSH banner.
+ $slowPort=random_int(30000,39000);$accepted=$root.'/slow-accepted';$ready=$root.'/slow-ready';
+ $tcpCode='$server=stream_socket_server("tcp://127.0.0.1:".$argv[1]);if(!$server)exit(2);file_put_contents($argv[2],"ready");$client=stream_socket_accept($server,10);if($client){file_put_contents($argv[3],"accepted");sleep(3);fclose($client);}fclose($server);';
+ $tcp=proc_open([PHP_BINARY,'-r',$tcpCode,(string)$slowPort,$ready,$accepted],[0=>['pipe','r'],1=>$log,2=>$log],$tcpPipes);fclose($tcpPipes[0]);
+ $slowRead=null;
+ try{
+ $until=microtime(true)+5;while(!is_file($ready)&µtime(true)<$until)usleep(10000);if(!is_file($ready))throw new RuntimeException('Slow TCP fixture unavailable');
+ $targets=$store->read('targets.json');$targets['slow']=['id'=>'slow','domain'=>'slow.invalid','type'=>'sftp','host'=>'127.0.0.1','port'=>$slowPort,'user'=>'fixture','host_key'=>'SHA256:'.str_repeat('A',43),'secret'=>'fixture','root'=>'/not-mounted','enabled'=>true];$store->write('targets.json',$targets);
+ $readCode='$ctx=stream_context_create(["http"=>["header"=>"Cookie: ".$argv[2],"ignore_errors"=>true,"timeout"=>10]]);@file_get_contents($argv[1],false,$ctx);';
+ $slowRead=proc_open([PHP_BINARY,'-r',$readCode,'http://127.0.0.1:'.$port.'/api/config?target=slow',$cookie],[0=>['pipe','r'],1=>$log,2=>$log],$readPipes);fclose($readPipes[0]);
+ $until=microtime(true)+5;while(!is_file($accepted)&µtime(true)<$until)usleep(10000);if(!is_file($accepted))throw new RuntimeException('Slow authenticated config read did not reach transport');
+ $started=microtime(true);[$status,$body]=request('/api/link-builder?target=test');$elapsed=microtime(true)-$started;
+ check($status===200&&$elapsed<1.5,'slow remote config read does not block link builder in same authenticated session');
+ }finally{if(is_resource($slowRead))proc_close($slowRead);proc_close($tcp);}
+ check(hash_file('sha256',$root.'/site/config.json')===$runtimeHash&&count(glob($store->dir.'/jobs/*.json'))===$jobCount,'HTTP link generation does not change runtime or enqueue jobs');
[$status,$body]=request('/api/logout','POST',null,$csrf);check($status===200,'HTTP logout succeeds');
[$status,$body]=request('/api/state');check($status===401,'HTTP logout invalidates session');
echo "HTTP integration complete\n";
diff --git a/admin/tests/link-builder.php b/admin/tests/link-builder.php
new file mode 100644
index 0000000..e08fe03
--- /dev/null
+++ b/admin/tests/link-builder.php
@@ -0,0 +1,67 @@
+write('targets.json', [
+ 'first' => ['id'=>'first','domain'=>'first.example','type'=>'sftp','host'=>'unreachable.invalid','root'=>'/not-mounted','secret'=>'fixture-only-secret'],
+ 'second' => ['id'=>'second','domain'=>'second.example','type'=>'local','root'=>'/not-mounted'],
+]);
+$runtimeFile = $testRoot . '/runtime-fixture.json';
+file_put_contents($runtimeFile, '{"host":"unchanged.example","utm":{"indpic":{"default":"1"}}}');
+$runtimeBefore = hash_file('sha256', $runtimeFile);
+$builder = new LinkBuilder($linkStore);
+$empty = ['selection'=>null, 'url'=>null, 'saved_at'=>null];
+linkCheck($builder->get('first') === $empty, 'first opening has no implicit saved choice or generated URL');
+$selection = ['indpic'=>'9','theme'=>'2','degr'=>'3','reg'=>'1','banch'=>'rnd','tp'=>'4','cons'=>'5','focus'=>'rnd'];
+$saved = $builder->save('first', $selection);
+linkCheck($saved['selection'] === $selection && is_string($saved['saved_at']), 'explicit save returns complete independent selection and timestamp');
+linkCheck(str_starts_with($saved['url'], 'https://first.example/?'), 'URL uses saved target HTTPS origin');
+parse_str(parse_url($saved['url'], PHP_URL_QUERY), $parameters);
+linkCheck(array_keys($parameters) === array_keys($selection), 'URL contains all eight canonical UTM keys');
+foreach ($selection as $key=>$value) {
+ $digit = $value === 'rnd' ? '0' : $value;
+ linkCheck((bool)preg_match('/^[a-zA-Z0-9]{4}' . $digit . '[a-zA-Z0-9]+$/D', $parameters[$key]), "{$key} has four ASCII prefix characters, selected digit and noise suffix");
+}
+linkCheck((new LinkBuilder(new Store($testRoot)))->get('first') === $saved, 'reload preserves exact saved URL and selection without regenerating noise');
+$regenerated = $builder->save('first', $selection);
+linkCheck($regenerated['selection']===$saved['selection']&&$regenerated['url']!==$saved['url'],'only explicit generation refreshes random noise for unchanged selection');
+$saved = $regenerated;
+linkCheck($builder->get('second') === $empty, 'other target does not inherit saved selection');
+$modified = $saved['selection']; $modified['theme'] = '1';
+$updated = $builder->save('first', $modified);
+linkCheck($updated['selection']['theme']==='1' && array_diff_assoc($updated['selection'], $selection)===['theme'=>'1'], 'changing one selector preserves all other saved choices');
+$builder->save('second', array_fill_keys(array_keys($selection), 'rnd'));
+linkCheck($builder->get('first') === $updated, 'saving another target does not overwrite first target state');
+$targets = $linkStore->read('targets.json');$targets['first']['domain']='renamed.example';$linkStore->write('targets.json',$targets);
+$renamed = $builder->get('first');
+linkCheck(str_starts_with($renamed['url'],'https://renamed.example/?') && !str_contains($renamed['url'],'first.example'), 'domain rename rebases saved URL to current origin');
+linkCheck($renamed['selection']===$modified && $renamed['saved_at']===$updated['saved_at'] && parse_url($renamed['url'],PHP_URL_QUERY)===parse_url($updated['url'],PHP_URL_QUERY), 'domain rename preserves selection, timestamp and existing query noise');
+foreach ([[], array_diff_key($selection,['focus'=>true]), $selection+['unexpected'=>'1'], array_replace($selection,['theme'=>'9']), array_replace($selection,['indpic'=>'0']), array_replace($selection,['cons'=>1]), array_replace($selection,['tp'=>'rnd&host=evil']), array_replace($selection,['reg'=>['1']])] as $invalid) {
+ linkReject(fn()=>$builder->save('first',$invalid),'strict selection rejects missing, unknown, wrong-type or injected value');
+}
+linkReject(fn()=>$builder->get('../targets'),'target traversal rejected');
+linkReject(fn()=>$builder->get('missing'),'unknown target rejected');
+foreach (['evil.example/path','user@evil.example','https://evil.example','evil.example:443','bad..example','-bad.example',"evil.example\nX: bad"] as $domain) {
+ $targets['first']['domain']=$domain;$linkStore->write('targets.json',$targets);
+ linkReject(fn()=>$builder->get('first'),'unsafe registry domain rejected instead of composing executable URL');
+}
+linkCheck(hash_file('sha256',$runtimeFile)===$runtimeBefore && glob($testRoot.'/jobs/*.json')===[], 'link builder leaves runtime bytes unchanged and creates no jobs');
+linkCheck((fileperms($testRoot.'/link-builder-first.json') & 0777)===0600,'saved selection and URL remain in private 0600 state');
+echo "Link builder contract complete\n";
diff --git a/admin/tests/ui-link-builder.cjs b/admin/tests/ui-link-builder.cjs
new file mode 100644
index 0000000..8352482
--- /dev/null
+++ b/admin/tests/ui-link-builder.cjs
@@ -0,0 +1,46 @@
+/* Approved overview + independent link builder, preland#22/issue3767.
+ * Automated Docker QA; production CUA verification is owned by the lead.
+ * No mocked routes; constructor must never write config or create jobs. */
+const {chromium}=require('playwright-core');
+const assert=require('node:assert/strict');
+const fs=require('node:fs');
+(async()=>{
+ const browser=await chromium.launch({executablePath:process.env.CHROMIUM_PATH||'/usr/bin/chromium',args:['--no-sandbox']});
+ const page=await browser.newPage({baseURL:process.env.ADMIN_URL||'http://admin',viewport:{width:1440,height:1000}});
+ const errors=[],mutations=[];page.on('pageerror',e=>errors.push(e.message));page.on('dialog',d=>d.accept());
+ page.on('request',r=>{if(r.method()!=='GET'&&/\/api\/(apply|restore|sets\/|jobs\/retry)/.test(r.url()))mutations.push(r.url());});
+ try{
+ await page.goto(process.env.ADMIN_URL||'http://admin');
+ assert.match(await page.locator('script[src*="admin.js"]').getAttribute('src'),/^\/assets\/admin[.]js[?]v=[a-f0-9]{12}$/);
+ assert.match(await page.locator('link[href*="admin.css"]').getAttribute('href'),/^\/assets\/admin[.]css[?]v=[a-f0-9]{12}$/);
+ 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();
+ 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');
+ await page.locator('#builder-fields select[data-marker=theme]').waitFor();
+ const generate=async()=>{const response=page.waitForResponse(r=>r.url().endsWith('/api/link-builder')&&r.request().method()==='POST');await page.getByRole('button',{name:'Сформировать и сохранить',exact:true}).click();const result=await(await response).json();assert.ok(result.ok,result.error);await page.waitForFunction(url=>document.querySelector('#builder-result').hidden===false&&document.querySelector('#builder-url').value===url,result.url);return result.url;};
+ const expectedTheme='rnd';await page.locator('#builder-fields select[data-marker=theme]').selectOption(expectedTheme);
+ const first=await generate();assert.equal(new URL(first).searchParams.get('theme')[4],'0');
+ const selectionA=await page.request.get('/api/link-builder?target=a').then(r=>r.json());assert.equal(selectionA.selection.theme,'rnd');assert.equal(selectionA.url,first);
+ const nextReg=selectionA.selection.reg==='rnd'?'1':'rnd';await page.locator('#builder-fields select[data-marker=reg]').selectOption(nextReg);
+ assert.equal(await page.locator('#builder-result').isVisible(),false,'old saved URL hidden for unsaved constructor changes');
+ const updated=await generate();const saved=await page.request.get('/api/link-builder?target=a').then(r=>r.json());assert.equal(saved.selection.reg,nextReg);for(const marker of Object.keys(selectionA.selection))if(marker!=='reg')assert.equal(saved.selection[marker],selectionA.selection[marker]);assert.notEqual(updated,first);
+ await page.locator('#source-target').selectOption('a');await page.getByLabel('Основной сайт',{exact:true}).waitFor();await page.getByLabel('Основной сайт',{exact:true}).fill('draft-only.example.org');
+ const version=await page.locator('#read-state').innerText();
+ await page.getByRole('button',{name:'Обновить сводку',exact:true}).click();
+ await page.waitForFunction(()=>document.querySelector('#overview-refresh').disabled===false);
+ assert.equal(await page.getByLabel('Основной сайт',{exact:true}).inputValue(),'draft-only.example.org');assert.equal(await page.locator('#read-state').innerText(),version);assert.match(await page.locator('#change-summary').innerText(),/host/);
+ assert.equal((await read('a')).config.host,before[0].config.host,'overview/constructor did not save editor draft');
+ await page.screenshot({path:(process.env.UI_OUTPUT||'/tmp')+'/overview-builder-desktop.png',fullPage:true});
+ await page.reload();await page.locator('#workspace').waitFor({state:'visible'});await page.locator('#builder-target').selectOption('a');await page.locator('#builder-url').waitFor({state:'visible'});assert.equal(await page.locator('#builder-url').inputValue(),updated);assert.equal(await page.locator('#builder-fields select[data-marker=theme]').inputValue(),'rnd');assert.equal(await page.locator('#builder-fields select[data-marker=reg]').inputValue(),nextReg);
+ await page.locator('#builder-target').selectOption('b');await page.locator('#builder-fields select[data-marker=theme]').waitFor();const persistedB=await page.request.get('/api/link-builder?target=b').then(r=>r.json());if(persistedB.selection)assert.equal(await page.locator('#builder-fields select[data-marker=theme]').inputValue(),persistedB.selection.theme);
+ const after=await Promise.all(['a','b'].map(read));for(let i=0;i<2;i++){assert.equal(after[i].version,before[i].version);assert.deepEqual(after[i].config,before[i].config);}
+ assert.deepEqual(mutations,[],'no config/job/sets mutations from overview or constructor');assert.deepEqual(errors,[]);
+ await page.setViewportSize({width:390,height:844});await page.screenshot({path:(process.env.UI_OUTPUT||'/tmp')+'/overview-builder-mobile.png',fullPage:true});assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),true);
+ fs.writeFileSync((process.env.UI_OUTPUT||'/tmp')+'/overview-builder-evidence.json',JSON.stringify({configVersions:after.map(r=>r.version),selection:saved.selection,url:saved.url,mutations,pageErrors:errors},null,2));
+ console.log('PASS real overview / independent constructor / saved selection URL reload / no config writes / editor isolation / desktop mobile');
+ }finally{await browser.close();}
+})().catch(error=>{console.error(error);process.exit(1);});
diff --git a/admin/views/dashboard.php b/admin/views/dashboard.php
index 0ae0187..bc54512 100644
--- a/admin/views/dashboard.php
+++ b/admin/views/dashboard.php
@@ -1,9 +1,15 @@
+
-
Прелендинги — UTM и наборы изображений
+Прелендинги — UTM и наборы изображений
-Настройка прелендингов
+
Настройка прелендингов
+
Сводка текущих конфигураций
Сохранённые runtime-конфиги каждого домена на момент чтения. Несохранённые изменения редактора сюда не попадают.
Default и все варианты с весами. Таблицу можно прокручивать по горизонтали.
+
Конструктор ссылки
Выберите значения UTM или «Рандом». Последний сформированный выбор хранится отдельно для каждого домена. Конфигурация, веса и default не меняются.
Выберите домен.
Настройки домена
Выберите домен для чтения текущего config.json.
Применить изменения
Отмечены только введённые или явно выбранные поля. Остальные значения каждого домена сохраняются.
Поля не выбраны.
indpic= UTM
Таблица открытого домена. ZIP и проверка SFTP используют выбранные выше цели. Загрузка не меняет default и веса.
| Номер / название | Default | Проверка | ZIP | Действия |
|---|
Добавить Set