From 00fbef9ede6d654c5e406336564edce2899ffbdc Mon Sep 17 00:00:00 2001 From: Panfilov Konstantin Date: Mon, 26 Oct 2026 04:11:44 +0300 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=D1=81=D0=BA=D1=80=D1=8B=D1=82?= =?UTF-8?q?=D0=B8=D0=B5=20=D0=B4=D0=BE=D0=BC=D0=B5=D0=BD=D0=BE=D0=B2=20?= =?UTF-8?q?=D0=B8=20=D1=81=D0=BE=D1=85=D1=80=D0=B0=D0=BD=D1=8F=D0=B5=D0=BC?= =?UTF-8?q?=D1=8B=D0=B9=20=D0=BF=D0=BE=D1=80=D1=8F=D0=B4=D0=BE=D0=BA=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=BB=D0=BE=D0=BD=D0=BE=D0=BA=20(#26)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin/README.md | 28 +++++++++++ admin/composer.json | 2 +- admin/public/assets/admin.css | 2 + admin/public/assets/admin.js | 70 +++++++++++++++++++++------- admin/public/index.php | 31 +++++++++--- admin/src/Store.php | 35 +++++++++++++- admin/tests/backend-http.php | 24 +++++++++- admin/tests/remember-auth.php | 6 ++- admin/tests/targets.php | 51 ++++++++++++++++++++ admin/tests/ui-target-login-race.cjs | 22 +++++++++ admin/tests/ui-target-visibility.cjs | 10 ++++ admin/views/dashboard.php | 4 +- 12 files changed, 252 insertions(+), 33 deletions(-) create mode 100644 admin/tests/targets.php create mode 100644 admin/tests/ui-target-login-race.cjs create mode 100644 admin/tests/ui-target-visibility.cjs diff --git a/admin/README.md b/admin/README.md index 897d3c2..e797383 100644 --- a/admin/README.md +++ b/admin/README.md @@ -189,3 +189,31 @@ partial failure/source changed, aliases/unknown/requisites, независимы через выход и повторный вход: старое продолжение не сбрасывает новый черновик, не подменяет результаты/notice и не снимает busy новой операции. Серверные задания при выходе продолжаются; отменяются только устаревшие UI-продолжения. + +## Скрытие, удаление и порядок доменов (preland#26) + +Значок перечёркнутого глаза справа от домена временно убирает его из таблицы и +рабочих списков. Подключение, секрет, проверка соединения, сайт, config.json и наборы +сохраняются. В «Скрытых доменах» доступны возврат и удаление подключения. Сервер +запрещает удалять видимый домен и домен с незавершённым заданием; сайт и файлы +удаление подключения не затрагивает. История сохраняется, результаты скрытых целей +не выводятся в рабочих списках. + +Маркер слева от названия позволяет перетащить колонку мышью; стрелки на маркере +позволяют изменить порядок с клавиатуры. Порядок автоматически сохраняется на сервере +и применяется к таблице и спискам выбора для всех входов. Скрытый домен сохраняет +свою позицию, новый добавляется в конец. Черновики настроек и заметок при перестановке +сохраняются; перед скрытием изменённого домена нужно сохранить или отменить его правки. + +API с обычными авторизацией и CSRF: `POST /api/targets/visibility` принимает +`{id,hidden}` (hidden — boolean), `POST /api/targets/order` принимает `{ids}` — +ровно все видимые ID в нужном порядке; оба возвращают актуальные `targets`. +Неверный/устаревший набор ID отвергается, скрытые позиции не перезаписываются. +`GET /api/state` содержит все подключения, включая hidden; интерфейс отдельно +фильтрует рабочие списки. Скрытие не отменяет уже запущенное задание. + +Ответы API401 не отправляют Set-Cookie: задержанный ответ старого запроса не может +перезаписать cookie нового успешного входа. Истёкший сеанс отзывается на сервере, +а явный выход (200) удаляет cookie. UI также отбрасывает ответы прежнего сеанса. +Проверка `ui-target-login-race.cjs` включает следующий авторизованный запрос и +сохранение порядка после задержанного401, а не только видимость рабочей области. diff --git a/admin/composer.json b/admin/composer.json index 498de88..1e0027b 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", "php tests/obsolete-utm.php", "php tests/copy.php"], "test:worker": "php tests/backend-worker.php"} + "scripts": {"test": ["php tests/backend-review.php", "php tests/link-builder.php", "php tests/targets.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", "php tests/copy.php"], "test:worker": "php tests/backend-worker.php"} } diff --git a/admin/public/assets/admin.css b/admin/public/assets/admin.css index a641b2f..8a8824e 100644 --- a/admin/public/assets/admin.css +++ b/admin/public/assets/admin.css @@ -13,3 +13,5 @@ .container{width:min(1440px,100%)}.overview-table{min-width:1280px}.overview-table th:first-child{width:110px}.overview-table th:not(:first-child){width:auto}.overview-table td{position:static!important}.overview-table tr[data-overview-utm] th{background:#fafafa}.overview-table .matrix-variant{display:grid;grid-template-columns:44px 58px 48px 1fr 20px;align-items:center;gap:6px;margin-top:7px}.matrix-variant .form-control{height:30px;min-height:30px;min-width:0;padding:3px 5px;font-size:13px}.matrix-labels{display:grid;grid-template-columns:44px 58px 48px 1fr;gap:6px;color:#78818e;font-size:11px}.matrix-default{display:flex;gap:4px;align-items:center;font-size:12px;font-weight:400;white-space:nowrap}.matrix-default input{margin:0}.matrix-percent{font-size:12px;white-space:nowrap}.matrix-percent.positive{font-weight:600}.matrix-remove{padding:0;border:0;background:none;color:#7a8594;font-size:19px;cursor:pointer;line-height:24px}.matrix-remove:hover{color:#a94442}.matrix-variants+.btn{font-size:12px;border:0;background:none;padding:6px 0 0;color:#337ab7}.matrix-footer td{background:#f8f9fb}.matrix-footer .toolbar{margin:0;gap:7px}.matrix-status{font-size:12px;font-weight:400;margin:7px 0}.matrix-dirty{color:#9c6413}.matrix-environment{font-size:12px;font-weight:400}.copy-grid{display:grid;grid-template-columns:320px minmax(0,1fr);gap:32px}.copy-grid>label{font-weight:600}.copy-grid select{margin-top:8px}.copy-hint{display:block;margin-top:10px;font-size:12px;font-weight:400}.copy-grid .target-options{margin-top:8px}.copy-bottom{display:flex;justify-content:space-between;align-items:center;gap:20px;border-top:1px solid #e1e5eb;margin-top:20px;padding-top:16px}.copy-bottom p{margin:0;font-size:13px}.copy-bottom .btn{flex-shrink:0}#copy-reason{margin:12px 0 0}.utm-note-read{display:flex;align-items:center;justify-content:space-between;gap:14px}.utm-note-text{margin:0}.utm-note-cell .btn{flex-shrink:0}.overview-wrapper{overscroll-behavior-x:contain}.overview-table caption{font-size:12px}@media(max-width:600px){.overview-table{min-width:1240px}.copy-grid{grid-template-columns:1fr;gap:20px}.copy-bottom{align-items:stretch;flex-direction:column}.copy-bottom .btn{align-self:flex-start}.overview-table th:first-child{width:90px}.matrix-footer .toolbar{flex-wrap:nowrap}.matrix-footer .btn{font-size:12px}.utm-note-read{align-items:flex-start}.utm-note-cell .btn{max-width:150px}.overview-table .form-control{font-size:13px}} #notice{position:static;box-shadow:none} + +.overview-toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:14px;flex-wrap:wrap}.hidden-link{border:0;background:none;color:#52677c;text-decoration:underline;cursor:pointer;padding:6px 0}.domain-heading{display:flex;align-items:center;gap:7px}.domain-heading .overview-domain{margin:0;min-width:0;overflow-wrap:anywhere}.domain-grip,.domain-hide{display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;border:0;background:transparent;color:#89949c;padding:3px;width:24px;height:24px;border-radius:4px}.domain-grip{cursor:grab;font-size:22px;touch-action:none}.domain-hide{cursor:pointer}.domain-grip:hover,.domain-hide:hover{background:#edf1f4;color:#40566b}.domain-hide:focus-visible,.domain-grip:focus-visible{outline:2px solid #337ab7}.domain-grip:disabled,.domain-hide:disabled{opacity:.4;cursor:wait}.hidden-domain-row{display:flex;align-items:center;gap:12px;padding:14px 0;border-bottom:1px solid #e2e7ec;flex-wrap:wrap}.domain-dragging{user-select:none;cursor:grabbing}.domain-drag-preview{position:fixed;z-index:1001;pointer-events:none;background:#fff;border:1px solid #9bb7cf;border-radius:6px;padding:12px;box-shadow:0 8px 24px #23394d30;opacity:.92}.domain-drag-preview>p,.domain-drag-preview>button{display:none}.domain-insertion{position:fixed;z-index:1000;pointer-events:none;width:3px;background:#337ab7}.hidden-domain-row .grow{min-width:180px} diff --git a/admin/public/assets/admin.js b/admin/public/assets/admin.js index 398809b..587ce19 100644 --- a/admin/public/assets/admin.js +++ b/admin/public/assets/admin.js @@ -3,7 +3,7 @@ 'use strict'; (() => { const $ = id => document.getElementById(id); - const state = {sessionGeneration:0,csrf:'',targets:[],source:'',reads:new Map(),jobs:[],loading:0}; + const state = {sessionGeneration:0,csrf:'',allTargets:[],targets:[],targetsEpoch:0,targetsBusy:false,source:'',reads:new Map(),jobs:[],loading:0}; const markers=['indpic','theme','degr','reg','banch','tp','cons','focus']; 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':'геометрический центр'}}; @@ -106,14 +106,14 @@ function matrixChanges(entry){const changes=[];if(entry.hostTouched&&entry.host&&entry.host.value!==entry.data.config.host){if(!/^(?:https:\/\/)?[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?(?::[0-9]{1,5})?$/i.test(entry.host.value)||entry.host.value.includes('..'))throw Error('Адрес сайта должен быть HTTPS-хостом без пути.');changes.push({path:['host'],value:entry.host.value});}for(const [marker,group]of entry.groups){if(!group.touched&&!group.added)continue;if(!group.rows.length)throw Error(marker+': добавьте хотя бы один вариант.');const weights={};for(const row of group.rows){const key=row.variant.value,w=Number(row.weight.value);if(!digitFor(marker,key)||Object.hasOwn(weights,key))throw Error(marker+': варианты должны быть уникальны.');if(row.weight.value===''||!Number.isFinite(w)||w<0)throw Error(marker+': вес должен быть неотрицательным числом.');weights[key]=w;}if(!group.defaultRow)throw Error(marker+': выберите default.');const digit=digitFor(marker,group.defaultRow.variant.value),original=group.original;const rawDefault=original&&digitFor(marker,original.default)===digit?original.default:typeof original?.default==='number'?Number(digit):Object.values(enums[marker]||{}).includes(String(original?.default))?enums[marker][digit]:digit;if(group.added){changes.push({path:['utm',marker],value:{default:rawDefault,weights}});}else{if(!equivalent(weights,original.weights))changes.push({path:['utm',group.key,'weights'],value:weights});if(!equivalent(rawDefault,original.default))changes.push({path:['utm',group.key,'default'],value:rawDefault});}}return changes;} function populateMatrix(entry){entry.groups.clear();entry.hostTouched=false;entry.validation='';for(const [key,cell]of entry.cells){cell.replaceChildren();if(key==='host'){entry.host=el('input',{class:'form-control',value:entry.data.config.host??'','data-host':'','aria-label':'Основной сайт '+entry.id});entry.host.addEventListener('input',()=>{entry.hostTouched=true;matrixChanged(entry);});cell.append(entry.host);}else matrixGroup(entry,key,cell);}updateMatrixStatus(entry);} function renderOverview(){ - const focused=document.activeElement,selection=focused&&'selectionStart'in focused?{start:focused.selectionStart,end:focused.selectionEnd}:null;const signature=state.targets.map(t=>t.id).join('|'),table=$('overview-table');table.style.minWidth=Math.max(760,110+state.targets.length*292)+'px';if(matrix.structure!==signature||!table.querySelector('thead tr')){matrix.structure=signature;const head=el('tr',{},el('th',{scope:'col',text:'Параметр'})),body=table.querySelector('tbody');body.replaceChildren();table.querySelector('thead').replaceChildren(head);const hostrow=el('tr',{'data-overview-extra':'host'},el('th',{scope:'row',text:'Основной сайт'}));body.append(hostrow);for(const target of state.targets){const entry=matrixEntry(target.id);entry.statusNode=el('p',{class:'matrix-status',role:'status'});entry.reload=button('Обновить',()=>{if(entry.dirty&&!confirm('Загрузить сохранённые настройки и сбросить черновик '+target.domain+'?'))return;return refreshOverviewTarget(target.id,true);});entry.reload.setAttribute('aria-label','Обновить настройки '+target.id);head.append(el('th',{'data-overview-target':target.id},el('div',{class:'overview-domain',text:target.domain}),el('div',{class:'text-muted matrix-environment',text:target.environment+(target.enabled?'':' · отключено')+(target.verified?'':' · соединение не проверено')}),entry.statusNode,entry.reload));const cell=entry.cells.get('host')||el('td',{'data-matrix-target':target.id});entry.cells.set('host',cell);hostrow.append(cell);} + const focused=document.activeElement,selection=focused&&'selectionStart'in focused?{start:focused.selectionStart,end:focused.selectionEnd}:null;const signature=state.targets.map(t=>t.id).join('|'),table=$('overview-table');table.style.minWidth=Math.max(760,110+state.targets.length*292)+'px';if(matrix.structure!==signature&&matrix.structure.split('|').slice().sort().join('|')===signature.split('|').slice().sort().join('|')&&table.querySelector('thead tr')){const ids=state.targets.map(t=>t.id);for(const row of table.querySelectorAll('tr')){if(row.hasAttribute('data-utm-note'))continue;const children=[...row.children];if(children.length===ids.length+1)for(const id of ids){const oldIndex=matrix.structure.split('|').indexOf(id);row.append(children[oldIndex+1]);}}matrix.structure=signature;if(focused?.isConnected){focused.focus({preventScroll:true});if(selection&&selection.start!==null)focused.setSelectionRange(selection.start,selection.end);}}if(matrix.structure!==signature||!table.querySelector('thead tr')){matrix.structure=signature;const head=el('tr',{},el('th',{scope:'col',text:'Параметр'})),body=table.querySelector('tbody');body.replaceChildren();table.querySelector('thead').replaceChildren(head);const hostrow=el('tr',{'data-overview-extra':'host'},el('th',{scope:'row',text:'Основной сайт'}));body.append(hostrow);for(const target of state.targets){const entry=matrixEntry(target.id);entry.statusNode=el('p',{class:'matrix-status',role:'status'});entry.reload=button('Обновить',()=>{if(entry.dirty&&!confirm('Загрузить сохранённые настройки и сбросить черновик '+target.domain+'?'))return;return refreshOverviewTarget(target.id,true);});entry.reload.setAttribute('aria-label','Обновить настройки '+target.id);head.append(el('th',{'data-overview-target':target.id},domainHeading(target),el('div',{class:'text-muted matrix-environment',text:target.environment+(target.enabled?'':' · отключено')+(target.verified?'':' · соединение не проверено')}),entry.statusNode,entry.reload));const cell=entry.cells.get('host')||el('td',{'data-matrix-target':target.id});entry.cells.set('host',cell);hostrow.append(cell);} for(const marker of markers){const row=el('tr',{'data-overview-utm':marker},el('th',{'aria-hidden':'true'}));for(const target of state.targets){const cell=matrixEntry(target.id).cells.get(marker)||el('td',{'data-matrix-target':target.id,'data-marker':marker});matrixEntry(target.id).cells.set(marker,cell);row.append(cell);}body.append(noteEntry(marker).row,row);} const footer=el('tr',{class:'matrix-footer'},el('th',{scope:'row',text:'Сохранение'}));for(const target of state.targets){const entry=matrixEntry(target.id);entry.save=button('Сохранить',()=>saveMatrix(target.id),'primary');entry.save.setAttribute('aria-label','Сохранить настройки '+target.id);entry.cancel=button('Отмена',()=>{if(entry.conflict)return refreshOverviewTarget(target.id,true);entry.epoch++;entry.dirty=false;entry.error='';entry.validation='';populateMatrix(entry);renderCopy();});entry.cancel.setAttribute('aria-label','Отменить настройки '+target.id);entry.footerError=el('p',{class:'overview-error matrix-footer-error',role:'alert','aria-live':'polite',hidden:true});footer.append(el('td',{},el('div',{class:'toolbar'},entry.save,entry.cancel),entry.footerError));if(entry.data&&!entry.host)populateMatrix(entry);else updateMatrixStatus(entry);}body.append(footer);if(focused?.isConnected){focused.focus({preventScroll:true});if(selection&&selection.start!==null)focused.setSelectionRange(selection.start,selection.end);} } - for(const target of state.targets){const entry=matrixEntry(target.id),snapshot=overview.entries.get(target.id);const title=entry.statusNode?.parentElement;if(title){title.querySelector('.overview-domain').textContent=target.domain;title.querySelector('.matrix-environment').textContent=target.environment+(target.enabled?'':' · отключено')+(target.verified?'':' · соединение не проверено');}if(snapshot?.data&&entry.data!==snapshot.data&&!entry.dirty&&!entry.saving){entry.data=snapshot.data;populateMatrix(entry);}if(snapshot?.status==='error'&&!entry.error){entry.error='Ошибка чтения: '+snapshot.error;}updateMatrixStatus(entry);}renderExtras();renderCopy();if(builder.target&&builder.entries.get(builder.target)?.ready)updateBuilderState(); + for(const target of state.targets){const entry=matrixEntry(target.id),snapshot=overview.entries.get(target.id);const title=entry.statusNode?.parentElement;if(title){title.querySelector('.overview-domain').textContent=target.domain;title.querySelector('.matrix-environment').textContent=target.environment+(target.enabled?'':' · отключено')+(target.verified?'':' · соединение не проверено');}if(snapshot?.data&&entry.data!==snapshot.data&&!entry.dirty&&!entry.saving){entry.data=snapshot.data;populateMatrix(entry);}if(snapshot?.status==='error'&&!entry.error){entry.error='Ошибка чтения: '+snapshot.error;}updateMatrixStatus(entry);}renderTargetControls();renderExtras();renderCopy();if(builder.target&&builder.entries.get(builder.target)?.ready)updateBuilderState(); } function renderExtras(){const body=$('overview-table').querySelector('tbody'),values=new Map(),keys=new Map();for(const t of state.targets){const v=overviewExtra(matrixEntry(t.id).data?.config);v.delete('host');values.set(t.id,v);for(const [key,item]of v)keys.set(key,item.label);}body.querySelectorAll('[data-matrix-extra]').forEach(n=>n.remove());for(const [key,label]of keys){const row=el('tr',{'data-matrix-extra':key},el('th',{text:label}));for(const t of state.targets)row.append(el('td',{class:'overview-plain',text:values.get(t.id).get(key)?.text||'Поле отсутствует'}));body.append(row);}} - function refreshOverviewTarget(id,force=false){const entry=matrixEntry(id),epoch=++entry.epoch,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(entry.epoch!==epoch||matrix.entries.get(id)!==entry||!state.csrf)return;overview.entries.set(id,{status:'success',request,data});if(entry.dirty&&!force){if(entry.data?.version!==data.version){entry.conflict=true;entry.error='Настройки изменены на сервере. Черновик сохранён. Загрузите сохранённое или отмените изменения.';}}else{entry.data=data;entry.dirty=false;entry.conflict=false;entry.error='';populateMatrix(entry);}}catch(error){if(entry.epoch!==epoch||matrix.entries.get(id)!==entry||!state.csrf)return;overview.entries.set(id,{...previous,status:'error',request,error:error.message});entry.error=error.message;}finally{if(entry.epoch===epoch&&matrix.entries.get(id)===entry){overview.pending.delete(id);renderOverview();}}})();overview.pending.set(id,pending);return pending;} + function refreshOverviewTarget(id,force=false){if(!state.targets.some(t=>t.id===id))return Promise.resolve();const entry=matrixEntry(id),epoch=++entry.epoch,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(entry.epoch!==epoch||matrix.entries.get(id)!==entry||!state.csrf)return;overview.entries.set(id,{status:'success',request,data});if(entry.dirty&&!force){if(entry.data?.version!==data.version){entry.conflict=true;entry.error='Настройки изменены на сервере. Черновик сохранён. Загрузите сохранённое или отмените изменения.';}}else{entry.data=data;entry.dirty=false;entry.conflict=false;entry.error='';populateMatrix(entry);}}catch(error){if(entry.epoch!==epoch||matrix.entries.get(id)!==entry||!state.csrf)return;overview.entries.set(id,{...previous,status:'error',request,error:error.message});entry.error=error.message;}finally{if(entry.epoch===epoch&&matrix.entries.get(id)===entry){overview.pending.delete(id);renderOverview();}}})();overview.pending.set(id,pending);return pending;} async function refreshOverview(){const control=$('overview-refresh');control.disabled=true;run(loadNotes);try{await Promise.allSettled(state.targets.map(t=>refreshOverviewTarget(t.id)));}finally{control.disabled=false;}} async function waitMatrixJob(job,isCurrent){ let current=typeof job==='object'?job:null,id=typeof job==='string'?job:job.id; @@ -140,7 +140,7 @@ } function renderCopy(){ $('copy-all').disabled=matrix.copyBusy;$('copy-none').disabled=matrix.copyBusy; - const resultBox=$('copy-results');resultBox.replaceChildren();if(matrix.copyError)resultBox.append(el('p',{class:'overview-error',text:matrix.copyError}));for(const result of matrix.copyResults){const id=typeof result.target==='object'?result.target.id:result.target;resultBox.append(el('p',{class:'status-'+result.status,text:targetName(id)+' — '+(statuses[result.status]||result.status)+(result.message?' · '+result.message:'')}));} + const resultBox=$('copy-results');resultBox.replaceChildren();if(matrix.copyError)resultBox.append(el('p',{class:'overview-error',text:matrix.copyError}));for(const result of orderedResults(matrix.copyResults)){const id=typeof result.target==='object'?result.target.id:result.target;resultBox.append(el('p',{class:'status-'+result.status,text:targetName(id)+' — '+(statuses[result.status]||result.status)+(result.message?' · '+result.message:'')}));} const source=$('copy-source'),ids=state.targets.map(t=>t.id);if([...source.options].slice(1).map(o=>o.value).join('|')!==ids.join('|'))source.replaceChildren(el('option',{value:'',text:'Выберите домен'}),...state.targets.map(t=>el('option',{value:t.id,text:t.domain})));for(const option of [...source.options]){const target=state.targets.find(t=>t.id===option.value);if(target)option.textContent=target.domain;}source.value=matrix.copySource;source.disabled=matrix.copyBusy;const box=$('copy-targets');box.replaceChildren();for(const target of state.targets){if(target.id===matrix.copySource){matrix.copyTargets.delete(target.id);continue;}const input=el('input',{type:'checkbox',checked:matrix.copyTargets.has(target.id),disabled:matrix.copyBusy||!target.enabled||!target.verified,'aria-label':'Копировать в '+target.domain});input.addEventListener('change',()=>{if(input.checked)matrix.copyTargets.add(target.id);else matrix.copyTargets.delete(target.id);renderCopy();});box.append(el('label',{},input,target.domain));}const from=matrix.entries.get(matrix.copySource),targets=[...matrix.copyTargets].map(id=>matrixEntry(id));let reason=matrix.copyBusy?'Копирование…':!from?'Выберите исходный домен.':!state.targets.find(t=>t.id===from.id)?.enabled||!state.targets.find(t=>t.id===from.id)?.verified?'Источник отключён или соединение не проверено.':!from.data?'Дождитесь чтения исходного домена.':overview.entries.get(from.id)?.status!=='success'?'Обновите настройки источника после ошибки чтения.':from.dirty||from.saving?'Сначала сохраните или отмените изменения источника.':from.conflict?'Обновите настройки источника.':!targets.length?'Выберите домены получателей.':targets.some(e=>!state.targets.find(t=>t.id===e.id)?.enabled||!state.targets.find(t=>t.id===e.id)?.verified)?'Получатель отключён или соединение не проверено.':targets.some(e=>e.dirty||e.saving)?'Сначала сохраните или отмените изменения получателей.':targets.some(e=>!e.data||e.conflict||overview.entries.get(e.id)?.status!=='success')?'Обновите настройки получателей.':'';$('copy-reason').textContent=reason;$('copy-submit').disabled=!!reason;$('copy-submit').textContent='Копировать в '+targets.length+' '+(targets.length%10===1&&targets.length%100!==11?'домен':targets.length%10>=2&&targets.length%10<=4&&(targets.length%100<12||targets.length%100>14)?'домена':'доменов');} async function copySettings(){ if($('copy-submit').disabled)return; @@ -163,10 +163,10 @@ }catch(error){if(isCurrent()){matrix.copyError=error.message;notice(error.message);if(error.status===409){from.conflict=true;from.error=error.message;updateMatrixStatus(from);}}} finally{if(sameOperation()){matrix.copyBusy=false;for(const [id,entry]of entries)if(matrix.entries.get(id)===entry){entry.saving=false;updateMatrixStatus(entry);}renderCopy();}} } - function syncOverview(){ + function syncOverview(refresh=true){ const ids=new Set(state.targets.map(target=>target.id));for(const id of matrix.entries.keys())if(!ids.has(id)){matrix.entries.get(id).epoch++;matrix.entries.delete(id);matrix.copyTargets.delete(id);matrix.operationTargets.delete(id);}if(!ids.has(matrix.copySource))matrix.copySource='';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); + 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();if(refresh)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]);} @@ -231,10 +231,10 @@ if(data!==undefined){options.headers['X-CSRF-Token']=state.csrf;if(data instanceof FormData)options.body=data;else{options.headers['Content-Type']='application/json';options.body=JSON.stringify(data);}} let response;try{response=await fetch('/api/'+path,options);}catch(_){throw new Error('Связь с админкой прервана. Откройте журнал: операция могла сохраниться на сервере.');} let body;try{body=await response.json();}catch(_){throw new Error('Сервер вернул непонятный ответ. Проверьте журнал перед повтором.');} - if(!response.ok||!body.ok){if(response.status===401&&generation===state.sessionGeneration)showLogin();const error=new Error(body.error||'Не удалось выполнить операцию (HTTP '+response.status+').');error.status=response.status;throw error;}return body; + if(generation!==state.sessionGeneration){const error=new Error('Ответ относится к завершённому сеансу.');error.staleSession=true;throw error;}if(!response.ok||!body.ok){if(response.status===401&&generation===state.sessionGeneration)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(){state.sessionGeneration++;matrix.copyEpoch++;matrix.copyBusy=false;for(const entry of matrix.entries.values())entry.epoch++;matrix.entries.clear();matrix.structure='';matrix.copySource='';matrix.copyError='';matrix.copyResults=[];matrix.copyTargets.clear();matrix.operationTargets.clear();utmNotes.serial++;utmNotes.entries.clear();utmNotes.loading=false;utmNotes.error='';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.reads.clear();$('workspace').hidden=true;$('logout').hidden=true;$('login-panel').hidden=false;} + async function run(action){try{await action();}catch(error){if(!error.staleSession)notice(error.message);}} + function showLogin(){state.sessionGeneration++;matrix.copyEpoch++;matrix.copyBusy=false;for(const entry of matrix.entries.values())entry.epoch++;matrix.entries.clear();matrix.structure='';matrix.copySource='';matrix.copyError='';matrix.copyResults=[];matrix.copyTargets.clear();matrix.operationTargets.clear();utmNotes.serial++;utmNotes.entries.clear();utmNotes.loading=false;utmNotes.error='';overview.entries.clear();overview.pending.clear();builder.entries.clear();builder.target='';for(const timer of pollTimers.values())clearTimeout(timer);pollTimers.clear();state.csrf='';state.targetsEpoch++;state.targetsBusy=false;state.targets=[];state.allTargets=[];state.source='';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 equivalent(a,b){return JSON.stringify(a)===JSON.stringify(b);} @@ -243,9 +243,42 @@ function labelControl(text,input){const label=el('label',{text});label.append(input);return label;} function optionLabel(marker,value){const digit=Object.entries(enums[marker]||{}).find(([,v])=>v===value)?.[0]||value;return digit+(descriptions[marker]?.[digit]?' — '+descriptions[marker][digit]:'');} - async function fetchRead(id){const data=await api('config?target='+encodeURIComponent(id));state.reads.set(id,data);return data;} - async function openTarget(id){if(id!==state.source)matrix.operationTargets=new Set(id?[id]:[]);state.source=id;$('sets-body').replaceChildren();$('backups').replaceChildren();renderTargetOptions();if(!id){$('read-state').textContent='Выберите домен.';return;}state.loading++;$('read-state').textContent='Чтение конфигурации для наборов и резерва…';try{await fetchRead(id);await Promise.all([loadSets(),loadBackups()]);$('read-state').textContent='Домен прочитан.';}finally{state.loading--;}} function renderTargetOptions(){const box=$('targets');box.replaceChildren();for(const target of state.targets){const input=el('input',{type:'checkbox',value:target.id,checked:matrix.operationTargets.has(target.id)&&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)matrix.operationTargets.add(target.id);else matrix.operationTargets.delete(target.id);if(input.checked&&!state.reads.has(target.id)){status.textContent=' · чтение версии…';state.loading++;try{await fetchRead(target.id);status.textContent=' · прочитано';}catch(e){input.checked=false;status.textContent=' · недоступен';throw e;}finally{state.loading--;}}}));}} - 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();} + async function fetchRead(id){const generation=state.sessionGeneration;const data=await api('config?target='+encodeURIComponent(id));if(generation===state.sessionGeneration&&state.targets.some(t=>t.id===id))state.reads.set(id,data);return data;} + async function openTarget(id){if(id&&!state.targets.some(t=>t.id===id))return;if(id!==state.source)matrix.operationTargets=new Set(id?[id]:[]);state.source=id;$('sets-body').replaceChildren();$('backups').replaceChildren();renderTargetOptions();if(!id){$('read-state').textContent='Выберите домен.';return;}state.loading++;$('read-state').textContent='Чтение конфигурации для наборов и резерва…';try{await fetchRead(id);await Promise.all([loadSets(),loadBackups()]);if(state.source===id)$('read-state').textContent='Домен прочитан.';}finally{state.loading--;}} function renderTargetOptions(){const box=$('targets');box.replaceChildren();for(const target of state.targets){const input=el('input',{type:'checkbox',value:target.id,checked:matrix.operationTargets.has(target.id)&&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)matrix.operationTargets.add(target.id);else matrix.operationTargets.delete(target.id);if(input.checked&&!state.reads.has(target.id)){status.textContent=' · чтение версии…';state.loading++;try{await fetchRead(target.id);status.textContent=' · прочитано';}catch(e){input.checked=false;status.textContent=' · недоступен';throw e;}finally{state.loading--;}}}));}} + async function loadState(){if(state.targetsBusy)return;const epoch=state.targetsEpoch,generation=state.sessionGeneration;const data=await api('state');if(generation!==state.sessionGeneration||epoch!==state.targetsEpoch||state.targetsBusy)return;state.csrf=data.csrf;$('login-panel').hidden=true;$('workspace').hidden=false;$('logout').hidden=false;acceptTargets(data.targets||[]);await loadJobs();} + function orderedResults(results){return (results||[]).filter(visibleResult).slice().sort((a,b)=>{const id=r=>typeof r.target==='object'?r.target.id:r.target;return state.targets.findIndex(t=>t.id===id(a))-state.targets.findIndex(t=>t.id===id(b));});} + function visibleResult(result){const id=typeof result.target==='object'?result.target.id:result.target;return state.targets.some(t=>t.id===id);} + function acceptTargets(targets,refresh=true){ + state.allTargets=targets;state.targets=targets.filter(t=>!t.hidden);const ids=new Set(state.targets.map(t=>t.id)); + for(const id of state.reads.keys())if(!ids.has(id))state.reads.delete(id); + if(state.source&&!ids.has(state.source)){state.source='';$('sets-body').replaceChildren();$('backups').replaceChildren();$('read-state').textContent='Выберите домен.';if($('indpic-result-modal').open)$('indpic-result-modal').close();} + const form=$('connection-form');if(form.elements.id.readOnly&&!ids.has(form.elements.id.value)){$('connection-new').click();} + 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(refresh);renderJobs();renderHidden(); + } + async function mutateTargets(path,data,method='POST'){ + if(state.targetsBusy)return;const generation=state.sessionGeneration;state.targetsBusy=true;state.targetsEpoch++;renderTargetControls(); + try{const result=await api(path,data,method);if(generation!==state.sessionGeneration||!state.csrf)return;if(result.targets)acceptTargets(result.targets,path!=='targets/order');else{const snapshot=await api('state');if(generation===state.sessionGeneration&&state.csrf)acceptTargets(snapshot.targets||[]);}if(generation===state.sessionGeneration&&state.csrf){const message=path==='targets/order'?'Порядок доменов сохранён.':path==='targets/visibility'?(data.hidden?'Домен скрыт.':'Домен возвращён.'):method==='DELETE'?'Подключение удалено.':'';if(message)notice(message,'success');}} + finally{if(generation===state.sessionGeneration){state.targetsBusy=false;renderTargetControls();}} + } + function renderTargetControls(){document.querySelectorAll('.domain-hide,.domain-grip,#hidden-domains button').forEach(n=>n.disabled=state.targetsBusy);$('connection-form').querySelector('button[type=submit],button:not([type])')?.toggleAttribute('disabled',state.targetsBusy);} + function hideTarget(id){const entry=matrix.entries.get(id);if(entry?.dirty||entry?.saving||matrix.copyBusy||state.loading){notice('Сначала сохраните или отмените изменения домена и дождитесь завершения текущей операции.');return;}return mutateTargets('targets/visibility',{id,hidden:true});} + function renderHidden(){const hidden=state.allTargets.filter(t=>t.hidden);$('hidden-toggle').textContent='Скрытые домены ('+hidden.length+')';const box=$('hidden-list');box.replaceChildren();if(!hidden.length)box.append(el('p',{class:'text-muted',text:'Скрытых доменов пока нет.'}));for(const target of hidden)box.append(el('div',{class:'hidden-domain-row','data-hidden-target':target.id},el('span',{class:'grow break'},el('strong',{text:target.domain}),el('span',{class:'text-muted',text:' · '+target.environment})),Object.assign(button('Вернуть',()=>mutateTargets('targets/visibility',{id:target.id,hidden:false})),{ariaLabel:'Вернуть домен '+target.domain}),Object.assign(button('Удалить',()=>{if(confirm('Удалить подключение '+target.domain+' из админки? Сайт, файлы, DNS и регистрация домена останутся без изменений.'))return mutateTargets('targets',{id:target.id},'DELETE');},'danger'),{ariaLabel:'Удалить домен '+target.domain})));renderTargetControls();} + function reorderTarget(id,before){if(state.targetsBusy)return;const ids=state.targets.map(t=>t.id),next=ids.filter(v=>v!==id),index=before?next.indexOf(before):next.length;next.splice(index<0?next.length:index,0,id);if(next.join('|')===ids.join('|'))return;return mutateTargets('targets/order',{ids:next});} + function domainHeading(target){ + const grip=el('button',{type:'button',class:'domain-grip',text:'⠿',title:'Перетащить домен; стрелки влево/вправо меняют порядок','aria-label':'Переместить домен '+target.domain}); + grip.addEventListener('keydown',event=>{if(!['ArrowLeft','ArrowRight'].includes(event.key))return;event.preventDefault();const ids=state.targets.map(t=>t.id),i=ids.indexOf(target.id),next=i+(event.key==='ArrowRight'?1:-1);if(next<0||next>=ids.length)return;run(()=>reorderTarget(target.id,event.key==='ArrowRight'?ids[next+1]:ids[next]));}); + grip.addEventListener('pointerdown',event=>startDomainDrag(event,target.id)); + const hide=el('button',{type:'button',class:'domain-hide',title:'Скрыть домен','aria-label':'Скрыть домен '+target.domain,onclick:()=>run(()=>hideTarget(target.id))}); + hide.innerHTML=''; + return el('div',{class:'domain-heading'},grip,el('span',{class:'overview-domain',text:target.domain}),hide); + } + function startDomainDrag(event,id){ + if(event.button!==0||state.targetsBusy)return;event.preventDefault();const grip=event.currentTarget,wrapper=$('overview-table').closest('.overview-wrapper'),header=grip.closest('th'),preview=header.cloneNode(true),marker=el('div',{class:'domain-insertion'});preview.className='domain-drag-preview';preview.style.width=header.offsetWidth+'px';document.body.append(preview,marker);let before=id,x=event.clientX,y=event.clientY,frame; + const move=()=>{preview.style.left=x+12+'px';preview.style.top=y+12+'px';const rect=wrapper.getBoundingClientRect();if(xrect.right-45)wrapper.scrollLeft+=15;const heads=[...$('overview-table').querySelectorAll('[data-overview-target]')].filter(n=>n.dataset.overviewTarget!==id);const next=heads.find(n=>x{x=e.clientX;y=e.clientY;};const finish=e=>{cancelAnimationFrame(frame);document.removeEventListener('pointermove',pointerMove);document.removeEventListener('pointerup',finish);document.removeEventListener('pointercancel',cancel);preview.remove();marker.remove();document.body.classList.remove('domain-dragging');if(e.type==='pointerup')run(()=>reorderTarget(id,before));};const cancel=e=>finish(e);document.body.classList.add('domain-dragging');document.addEventListener('pointermove',pointerMove);document.addEventListener('pointerup',finish);document.addEventListener('pointercancel',cancel);move(); + } + 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,ids=safeTargets()){ if(!changes.length)throw new Error('Выберите поля для применения.'); @@ -260,14 +293,15 @@ } async function uploadSet(file,number,name,inUse=false){if(!file)throw new Error('Выберите ZIP-файл.');const ids=safeTargets();const existing=ids.filter(id=>(state.reads.get(id)?.sets||[]).some(s=>String(s.number)===String(number)&&(s.exists||s.in_use)));if((inUse||existing.length)&&!confirm('Заменить существующий набор '+number+' на выбранных доменах? Он может использоваться как default или с ненулевым весом. Default и веса не изменятся.'))return;const form=new FormData();form.set('file',file);form.set('number',number);form.set('name',name);form.set('targets',JSON.stringify(safeTargets()));const r=await api('sets/upload',form);await showJob(r.job,true);} function modal(title,text){$('result-title').textContent=title;$('result-body').textContent=text;if(!$('indpic-result-modal').open)$('indpic-result-modal').showModal();} - function renderJobs(){const box=$('jobs');box.replaceChildren();if(!state.jobs.length)box.append(el('p',{class:'text-muted',text:'Заданий пока нет.'}));for(const job of state.jobs){const block=el('div',{class:'job'},el('strong',{class:'break',text:'Задание '+job.id+' · '+(job.status==='completed'?'Завершено':statuses[job.status]||job.status)}));if(job.created_at)block.append(el('p',{class:'text-muted',text:formatDate(job.created_at)}));if(job.changes)block.append(el('p',{class:'text-muted break',text:'Поля: '+job.changes.map(c=>Array.isArray(c.path)?c.path.join('.'):String(c.path)).join(', ')}));const table=el('table',{class:'table table-bordered'},el('thead',{},el('tr',{},el('th',{text:'Домен'}),el('th',{text:'Результат'}),el('th',{text:'Повтор'}))));const body=el('tbody');const retry=[];for(const result of job.results||[]){const id=typeof result.target==='object'?result.target.id:result.target;const tr=el('tr');const outcome=el('td',{class:'status-'+result.status},el('strong',{text:statuses[result.status]||result.status}),el('div',{class:'break',text:[result.message,...(result.warnings||[])].filter(Boolean).join(' · ')}));const action=el('td');if(['error','conflict','unknown'].includes(result.status)){const check=el('input',{type:'checkbox',value:id,'aria-label':'Повтор '+targetName(id),checked:false});retry.push(check);action.append(check);}tr.append(el('td',{text:targetName(id)}),outcome,action);body.append(tr);}table.append(body);block.append(el('div',{class:'table-responsive'},table));if(retry.length)block.append(button('Повторить выбранные неуспешные цели',async()=>{const ids=retry.filter(c=>c.checked).map(c=>c.value);if(!ids.length)throw new Error('Отметьте неуспешные цели для повтора.');if((job.results||[]).some(r=>ids.includes(r.target)&&r.status==='conflict')){throw new Error('Конфликт нельзя повторить со старой версией. Перечитайте домен, проверьте значения и примените поля заново.');}const r=await api('jobs/retry',{id:job.id,targets:ids});await showJob(r.job);}));box.append(block);}} + function renderJobs(){const box=$('jobs');box.replaceChildren();const visibleJobs=state.jobs.filter(job=>orderedResults(job.results).length);if(!visibleJobs.length)box.append(el('p',{class:'text-muted',text:'Заданий для видимых доменов пока нет.'}));for(const job of visibleJobs){const block=el('div',{class:'job'},el('strong',{class:'break',text:'Задание '+job.id+' · '+(job.status==='completed'?'Завершено':statuses[job.status]||job.status)}));if(job.created_at)block.append(el('p',{class:'text-muted',text:formatDate(job.created_at)}));if(job.changes)block.append(el('p',{class:'text-muted break',text:'Поля: '+job.changes.map(c=>Array.isArray(c.path)?c.path.join('.'):String(c.path)).join(', ')}));const table=el('table',{class:'table table-bordered'},el('thead',{},el('tr',{},el('th',{text:'Домен'}),el('th',{text:'Результат'}),el('th',{text:'Повтор'}))));const body=el('tbody');const retry=[];for(const result of orderedResults(job.results)){const id=typeof result.target==='object'?result.target.id:result.target;const tr=el('tr');const outcome=el('td',{class:'status-'+result.status},el('strong',{text:statuses[result.status]||result.status}),el('div',{class:'break',text:[result.message,...(result.warnings||[])].filter(Boolean).join(' · ')}));const action=el('td');if(['error','conflict','unknown'].includes(result.status)){const check=el('input',{type:'checkbox',value:id,'aria-label':'Повтор '+targetName(id),checked:false});retry.push(check);action.append(check);}tr.append(el('td',{text:targetName(id)}),outcome,action);body.append(tr);}table.append(body);block.append(el('div',{class:'table-responsive'},table));if(retry.length)block.append(button('Повторить выбранные неуспешные цели',async()=>{const ids=retry.filter(c=>c.checked).map(c=>c.value);if(!ids.length)throw new Error('Отметьте неуспешные цели для повтора.');if((job.results||[]).some(r=>ids.includes(r.target)&&r.status==='conflict')){throw new Error('Конфликт нельзя повторить со старой версией. Перечитайте домен, проверьте значения и примените поля заново.');}const r=await api('jobs/retry',{id:job.id,targets:ids});await showJob(r.job);}));box.append(block);}} 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','copy'].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();}}} + 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',orderedResults(job.results).map(v=>targetName(typeof v.target==='object'?v.target.id: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','copy'].includes(job.type);if(configChanged)for(const result of job.results||[])if(result.status==='success'&&visibleResult(result)){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();}}} 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});if(state.targets.includes(t)){t.verified=true;renderConnections();renderOverview();}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='';}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,remember:form.remember.checked});state.csrf=data.csrf||'';form.password.value='';await loadState();notice('','info');}finally{submit.disabled=false;}});}); + 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});if(state.targets.includes(t)){t.verified=true;renderConnections();renderOverview();}modal('Проверка соединения',result.message||result.result?.message||'Соединение и пути проверены.');})));}} + $('hidden-toggle').addEventListener('click',()=>{$('hidden-domains').hidden=false;$('hidden-domains').scrollIntoView({block:'start'});});$('hidden-close').addEventListener('click',()=>{$('hidden-domains').hidden=true;$('overview').scrollIntoView({block:'start'});}); + $('login-form').addEventListener('submit',event=>{event.preventDefault();run(async()=>{const form=event.currentTarget;const submit=form.querySelector('button');submit.disabled=true;state.sessionGeneration++;state.targetsEpoch++;try{const data=await api('login',{username:form.username.value,password:form.password.value,remember:form.remember.checked});state.csrf=data.csrf||'';form.password.value='';await loadState();notice('','info');}finally{submit.disabled=false;}});}); $('logout').addEventListener('click',()=>run(async()=>{if(matrixDirty()&&!confirm('Есть несохранённые изменения настроек или заметок. Выйти?'))return;await api('logout',{});for(const timer of pollTimers.values())clearTimeout(timer);pollTimers.clear();showLogin();notice('Сеанс завершён.','info');})); $('source-target').addEventListener('change',event=>run(()=>openTarget(event.target.value))); $('reload').addEventListener('click',()=>run(()=>openTarget(state.source))); @@ -277,7 +311,7 @@ $('copy-submit').addEventListener('click',()=>run(copySettings)); $('set-add').elements.sftp.addEventListener('change',event=>{const form=$('set-add');form.querySelector('.zip-field').hidden=event.target.checked;form.elements.file.required=!event.target.checked;form.querySelector('button').textContent=event.target.checked?'Проверить и добавить':'Загрузить и добавить';}); $('set-add').addEventListener('submit',event=>{event.preventDefault();run(async()=>{const form=event.currentTarget;const number=Number(form.elements.number.value);if(form.elements.sftp.checked){const r=await api('sets/register',{targets:safeTargets(),number,name:form.elements.name.value});await showJob(r.job,true);}else await uploadSet(form.elements.file.files[0],number,form.elements.name.value,false);});}); $('connection-new').addEventListener('click',()=>{$('connection-form').reset();$('connection-form').elements.id.readOnly=false;$('connection-title').textContent='Добавить подключение';}); - $('connection-form').addEventListener('submit',event=>{event.preventDefault();run(async()=>{const form=event.currentTarget;const target={};for(const key of ['id','name','domain','environment','type','host','user','root','host_key'])target[key]=form.elements[key].value;target.port=Number(form.elements.port.value);target.enabled=form.elements.enabled.checked;const data={target};if(form.elements.secret.value)data.secret=form.elements.secret.value;await api('targets',data);form.elements.secret.value='';await loadState();notice('Подключение сохранено. Проверьте соединение перед записью.','success');});}); + $('connection-form').addEventListener('submit',event=>{event.preventDefault();run(async()=>{const form=event.currentTarget;const target={};for(const key of ['id','name','domain','environment','type','host','user','root','host_key'])target[key]=form.elements[key].value;target.port=Number(form.elements.port.value);target.enabled=form.elements.enabled.checked;const data={target};if(form.elements.secret.value)data.secret=form.elements.secret.value;await mutateTargets('targets',data);form.elements.secret.value='';notice('Подключение сохранено. Проверьте соединение перед записью.','success');});}); $('refresh-history').addEventListener('click',()=>run(async()=>{await Promise.all([loadJobs(),loadBackups()]);notice('Журнал обновлён.','info');})); for(const id of ['result-close','result-done'])$(id).addEventListener('click',()=>$('indpic-result-modal').close()); window.addEventListener('beforeunload',event=>{if(matrixDirty()){event.preventDefault();event.returnValue='';}}); diff --git a/admin/public/index.php b/admin/public/index.php index 82e03fc..3bf003e 100644 --- a/admin/public/index.php +++ b/admin/public/index.php @@ -29,6 +29,9 @@ try { // Авторизация, idle timestamp и CSRF уже проверены; SSH/файловые операции // не должны удерживать PHP session lock и блокировать соседние запросы. session_write_close(); + // Удаление и постановка заданий используют одну блокировку: транспорт нельзя + // удалить между проверкой существования подключения и записью задания. + $enqueue=static function(callable $create,array $selections)use($store){return $store->locked('targets',function()use($create,$selections,$store){foreach($selections as $selection){$id=is_array($selection)?($selection['id']??null):$selection;if(!is_string($id))throw new Problem('Некорректная цель задания.');$store->target($id);}return $create();});}; if($path==='/api/state'&&$method==='GET')reply(['csrf'=>$_SESSION['csrf'],'user'=>$user,'targets'=>$store->targets()]); if($path==='/api/utm-notes'){ $notes=new Notes($store); @@ -52,6 +55,14 @@ try { 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/visibility'&&$method==='POST'){ + if(count($data)!==2||array_diff(array_keys($data),['id','hidden'])||!is_string($data['id']??null)||!is_bool($data['hidden']??null))throw new Problem('Передайте строковый ID и логическое значение hidden.'); + reply(['targets'=>$store->setTargetVisibility($data['id'],$data['hidden'])]); + } + if($path==='/api/targets/order'&&$method==='POST'){ + if(count($data)!==1||!is_array($data['ids']??null)||!array_is_list($data['ids']))throw new Problem('Передайте список строковых ID в поле ids.'); + reply(['targets'=>$store->orderTargets($data['ids'])]); + } 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'=>'Пути, блокировка, чтение и права записи проверены.']);} if($path==='/api/config'&&$method==='GET'){ @@ -61,23 +72,29 @@ try { } if($path==='/api/copy'){ if($method!=='POST')throw new Problem('Метод копирования не поддерживается.',405); - reply(['job'=>(new Copy($store))->create($data)],202); + reply(['job'=>$enqueue(fn()=>(new Copy($store))->create($data),[])],202); } - if($path==='/api/apply'&&$method==='POST'){$data['targets']=array_map(static fn($t)=>(array)$t,(array)($data['targets']??[]));$data['changes']=array_map(static fn($c)=>(array)$c,(array)($data['changes']??[]));reply(['job'=>$jobs->create('apply',$data)],202);} + if($path==='/api/apply'&&$method==='POST'){$data['targets']=array_map(static fn($t)=>(array)$t,(array)($data['targets']??[]));$data['changes']=array_map(static fn($c)=>(array)$c,(array)($data['changes']??[]));reply(['job'=>$enqueue(fn()=>$jobs->create('apply',$data),$data['targets'])],202);} if($path==='/api/jobs'&&$method==='GET')reply(isset($_GET['id'])?['job'=>$jobs->get((string)$_GET['id'])]:['jobs'=>$jobs->all()]); - if($path==='/api/jobs/retry'&&$method==='POST')reply(['job'=>$jobs->retry((string)($data['id']??''),(array)($data['targets']??[]))],202); + if($path==='/api/jobs/retry'&&$method==='POST')reply(['job'=>$enqueue(fn()=>$jobs->retry((string)($data['id']??''),(array)($data['targets']??[])),(array)($data['targets']??[]))],202); if($path==='/api/backups'&&$method==='GET'){$id=(string)($_GET['target']??'');$store->target($id);reply(['backups'=>$store->backups($id)]);} - if($path==='/api/restore'&&$method==='POST')reply(['job'=>$jobs->create('restore',$data)],202); + if($path==='/api/restore'&&$method==='POST')reply(['job'=>$enqueue(fn()=>$jobs->create('restore',$data),[$data['target']??null])],202); if($path==='/api/sets'&&$method==='GET'){$target=$store->target((string)($_GET['target']??''));$read=(new Transport($store,$target))->readConfig();reply(['sets'=>$sets->list($target,$read['config']),'version'=>$read['version']]);} if($path==='/api/sets/upload'&&$method==='POST'){ if(($_FILES['file']['error']??UPLOAD_ERR_NO_FILE)!==UPLOAD_ERR_OK||!is_uploaded_file($_FILES['file']['tmp_name']))throw new Problem('ZIP не получен либо превышает лимит 64 МиБ.'); $number=Sets::number($data['number']??0);$prepared=$sets->prepareZip($_FILES['file']['tmp_name'],$number); $targets=json_decode((string)($data['targets']??'[]'),true,16,JSON_THROW_ON_ERROR); - reply(['job'=>$jobs->create('upload',['targets'=>$targets,'number'=>$number,'name'=>(string)($data['name']??''),'prepared'=>$prepared])],202); + reply(['job'=>$enqueue(fn()=>$jobs->create('upload',['targets'=>$targets,'number'=>$number,'name'=>(string)($data['name']??''),'prepared'=>$prepared]),$targets)],202); } - if(in_array($path,['/api/sets/check','/api/sets/register'],true)&&$method==='POST'){$data['number']=Sets::number($data['number']??0);reply(['job'=>$jobs->create($path==='/api/sets/check'?'check':'register',$data)],202);} + if(in_array($path,['/api/sets/check','/api/sets/register'],true)&&$method==='POST'){$data['number']=Sets::number($data['number']??0);reply(['job'=>$enqueue(fn()=>$jobs->create($path==='/api/sets/check'?'check':'register',$data),(array)($data['targets']??(isset($data['target'])?[$data['target']]:[])))],202);} if(in_array($path,['/api/sets/name','/api/sets/delete'],true)&&$method==='POST'){$target=$store->target((string)($data['target']??''));$number=Sets::number($data['number']??0);if($path==='/api/sets/name')$sets->name($target,$number,(string)($data['name']??''));else $sets->delete($target,$number);reply([]);} throw new Problem('Маршрут не найден.',404); -}catch(Problem $e){http_response_code($e->status);echo json_encode(['ok'=>false,'error'=>$e->getMessage()],JSON_UNESCAPED_UNICODE|JSON_THROW_ON_ERROR);} +}catch(Problem $e){ + // Запоздалый 401 (анонимный запрос, истечение сессии, ошибка входа) не должен + // подменять или удалять cookie более нового успешного входа. Истёкший backing + // Auth уже отзывает на сервере; явный logout возвращает 200 и очищает cookie. + if($e->status===401)header_remove('Set-Cookie'); + http_response_code($e->status);echo json_encode(['ok'=>false,'error'=>$e->getMessage()],JSON_UNESCAPED_UNICODE|JSON_THROW_ON_ERROR); +} catch(JsonException){http_response_code(400);echo json_encode(['ok'=>false,'error'=>'Некорректный JSON запроса.'],JSON_UNESCAPED_UNICODE);} catch(Throwable){http_response_code(500);echo json_encode(['ok'=>false,'error'=>'Операция не выполнена. Проверьте доступность и повторите.'],JSON_UNESCAPED_UNICODE);} diff --git a/admin/src/Store.php b/admin/src/Store.php index 7bf94ad..6ac6d79 100644 --- a/admin/src/Store.php +++ b/admin/src/Store.php @@ -24,7 +24,7 @@ final class Store public function path(string $name): string { if (!preg_match('~^[a-zA-Z0-9_./-]+$~D',$name) || str_contains($name,'..') || str_starts_with($name,'/')) throw new Problem('Недопустимый идентификатор.'); return $this->dir.'/'.$name; } public function targets(bool $public=true): array { $targets=array_values($this->read('targets.json')); - if ($public) foreach($targets as &$target) {$target['has_secret']=!empty($target['secret']);unset($target['secret']);} + if ($public) foreach($targets as &$target) {$target['hidden']=(bool)($target['hidden']??false);$target['has_secret']=!empty($target['secret']);unset($target['secret']);} return $targets; } public function target(string $id): array {foreach($this->targets(false) as $target) if ($target['id']===$id) return $target;throw new Problem('Подключение не найдено.',404);} @@ -47,6 +47,7 @@ final class Store } $target['port']=(int)($input['port'] ?? $old['port'] ?? 22);if($target['port']<1||$target['port']>65535) throw new Problem('Некорректный порт.'); $target['id']=$id;$target['enabled']=(bool)($input['enabled'] ?? $old['enabled'] ?? false);$target['secret']=$secret ?? $old['secret'] ?? ''; + $target['hidden']=(bool)($old['hidden']??false); // Изменение транспорта сбрасывает проверку: запись включается только после test. $identity=static fn($t)=>array_intersect_key($t,array_flip(['type','host','port','user','root','host_key','secret'])); $target['verified']=isset($old['verified']) && $identity($old)===$identity($target) ? $old['verified'] : false; @@ -54,7 +55,37 @@ final class Store }); } public function verifyTarget(string $id, ?array $expected=null): void {$this->locked('targets',function() use($id,$expected){$targets=$this->read('targets.json');if(!isset($targets[$id])) throw new Problem('Подключение не найдено.',404);if($expected!==null&&$targets[$id]!==$expected)throw new Problem('Подключение изменилось во время проверки. Повторите проверку.',409);$targets[$id]['verified']=true;$this->write('targets.json',$targets);});} - public function deleteTarget(string $id): void {$this->locked('targets',function() use($id){$targets=$this->read('targets.json');unset($targets[$id]);$this->write('targets.json',$targets);});} + public function setTargetVisibility(string $id,bool $hidden): array { + return $this->locked('targets',function() use($id,$hidden){ + $targets=$this->read('targets.json');if(!isset($targets[$id]))throw new Problem('Подключение не найдено.',404); + $targets[$id]['hidden']=$hidden;$this->write('targets.json',$targets);return $this->targets(); + }); + } + public function orderTargets(array $ids): array { + if(!array_is_list($ids))throw new Problem('Порядок должен быть списком ID.'); + foreach($ids as $id)if(!is_string($id))throw new Problem('Порядок должен содержать строковые ID.'); + return $this->locked('targets',function() use($ids){ + $targets=$this->read('targets.json');$visible=[]; + foreach($targets as $id=>$target)if(empty($target['hidden']))$visible[]=(string)$id; + if(count($ids)!==count($visible)||count(array_unique($ids,SORT_STRING))!==count($ids)||array_diff($ids,$visible))throw new Problem('Список видимых подключений изменился. Обновите страницу и повторите.',409); + $ordered=[];$position=0; + foreach($targets as $id=>$target){if(empty($target['hidden'])){$id=$ids[$position++];$target=$targets[$id];}$ordered[$id]=$target;} + $this->write('targets.json',$ordered);return $this->targets(); + }); + } + public function deleteTarget(string $id): void { + $this->locked('targets',function() use($id){ + $targets=$this->read('targets.json');if(!isset($targets[$id]))throw new Problem('Подключение не найдено.',404); + if(empty($targets[$id]['hidden']))throw new Problem('Перед удалением скройте подключение.',409); + foreach(glob($this->dir.'/jobs/*.json')?:[] as $path){ + $job=$this->read('jobs/'.basename($path));if(!in_array($job['status']??null,['queued','running'],true))continue; + $references=array_column($job['results']??[],'target'); + if(isset($job['payload']['source']['id']))$references[]=$job['payload']['source']['id']; + if(in_array($id,$references,true))throw new Problem('Подключение используется незавершённым заданием. Дождитесь его завершения.',409); + } + unset($targets[$id]);$this->write('targets.json',$targets); + }); + } public function backup(string $target,string $content): string { $id=gmdate('YmdHis').'-'.bin2hex(random_bytes(6));$items=$this->read('backups/index.json');$items[]=['id'=>$id,'target'=>$target,'created_at'=>gmdate(DATE_ATOM)]; $this->write('backups/'.$id.'.json',['content'=>$content]);$this->locked('backup-index',function() use($items){$current=$this->read('backups/index.json');$current[]=end($items);$this->write('backups/index.json',$current);});return $id; diff --git a/admin/tests/backend-http.php b/admin/tests/backend-http.php index 92fe698..1ce6e37 100644 --- a/admin/tests/backend-http.php +++ b/admin/tests/backend-http.php @@ -15,11 +15,33 @@ 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,$anonymousHeaders]=request('/api/state');check($status===401&&!$body['ok'],'HTTP unauthenticated state blocked'); + check(!array_filter($anonymousHeaders,static fn($header)=>str_starts_with(strtolower($header),'set-cookie: preland_admin=')),'HTTP anonymous state cannot overwrite a later authenticated cookie'); [$status,$body]=request('/api/link-builder?target=test');check($status===401,'HTTP link builder requires authentication'); + foreach(['/api/targets/visibility','/api/targets/order'] as $route){[$status]=request($route,'POST',['id'=>'test']);check($status===401,'HTTP target organization 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'); + [$status]=request('/api/targets/visibility','POST',['id'=>'test','hidden'=>true]);check($status===403,'HTTP visibility requires CSRF'); + [$status]=request('/api/targets/order','POST',['ids'=>['test']]);check($status===403,'HTTP order requires CSRF'); + [$status,$body]=request('/api/targets/visibility','POST',['id'=>'test','hidden'=>true],$csrf);check($status===200&&$body['targets'][0]['hidden']===true&&!isset($body['targets'][0]['secret']),'HTTP visibility returns redacted updated targets'); + foreach([['id'=>'test','hidden'=>'true'],['id'=>'test','hidden'=>1],['id'=>['test'],'hidden'=>true],['id'=>'test'],['id'=>'test','hidden'=>false,'enabled'=>true]] as $invalid){[$status]=request('/api/targets/visibility','POST',$invalid,$csrf);check($status===422,'HTTP visibility rejects wrong shapes and extra fields');} + [$status]=request('/api/targets/visibility','POST',['id'=>'absent','hidden'=>true],$csrf);check($status===404,'HTTP visibility missing target'); + foreach([['ids'=>(object)['0'=>'test']],['ids'=>'test'],['ids'=>[1]],['ids'=>[],'extra'=>true]] as $invalid){[$status]=request('/api/targets/order','POST',$invalid,$csrf);check($status===422,'HTTP order requires only a JSON string array');} + [$status,$body]=request('/api/targets/order','POST',['ids'=>[]],$csrf);check($status===200&&$body['targets'][0]['id']==='test','HTTP all-hidden empty order accepted'); + [$status]=request('/api/targets/order','POST',['ids'=>['test']],$csrf);check($status===409,'HTTP stale visible order conflicts after hide'); + [$status]=request('/api/targets/visibility','POST',['id'=>'test','hidden'=>false],$csrf);check($status===200,'HTTP restore visible target'); + foreach([[],['test','test'],['absent']] as $invalid){[$status]=request('/api/targets/order','POST',['ids'=>$invalid],$csrf);check($status===409,'HTTP incomplete duplicate unknown order conflicts');} + [$status]=request('/api/targets','DELETE',['id'=>'test'],$csrf);check($status===409,'HTTP visible deletion rejected'); + [$status]=request('/api/targets','DELETE',['id'=>'absent'],$csrf);check($status===404,'HTTP missing deletion rejected'); + [$status]=request('/api/targets','POST',['target'=>['id'=>'delete-fixture','domain'=>'delete.invalid','type'=>'local','root'=>$root.'/site','enabled'=>true]],$csrf);check($status===200,'HTTP creates disposable deletion fixture'); + [$status]=request('/api/targets/visibility','POST',['id'=>'delete-fixture','hidden'=>true],$csrf);check($status===200,'HTTP hides disposable deletion fixture'); + [$status,$body]=request('/api/sets/check','POST',['targets'=>['delete-fixture'],'number'=>1],$csrf);check($status===202,'HTTP hidden target remains available to explicit jobs');$deleteJob=$body['job']['id']; + [$status]=request('/api/targets','DELETE',['id'=>'delete-fixture'],$csrf);check($status===409,'HTTP queued job prevents hidden deletion'); + $pending=$store->read('jobs/'.$deleteJob.'.json');$pending['status']='completed';$store->write('jobs/'.$deleteJob.'.json',$pending); + [$status]=request('/api/targets','DELETE',['id'=>'delete-fixture'],$csrf);check($status===200&&is_file($root.'/site/config.json'),'HTTP idle hidden deletion keeps site config'); + $countBefore=count(glob($store->dir.'/jobs/*.json')); + [$status]=request('/api/sets/check','POST',['targets'=>['delete-fixture'],'number'=>1],$csrf);check($status===404&&count(glob($store->dir.'/jobs/*.json'))===$countBefore,'HTTP stale enqueue cannot create job after connection deletion'); [$status,$body]=request('/api/apply','POST',['targets'=>[],'changes'=>[]]);check($status===403,'HTTP mutation rejects missing CSRF'); [$status,$body]=request('/api/config?target=test');check($status===200&&isset($body['version'],$body['config']),'HTTP config reads actual target version');$version=$body['version']; [$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']; diff --git a/admin/tests/remember-auth.php b/admin/tests/remember-auth.php index d1422a5..8efa9c5 100644 --- a/admin/tests/remember-auth.php +++ b/admin/tests/remember-auth.php @@ -29,7 +29,8 @@ function rememberBacking(string $cookie): array { try{ $a=rememberServer('a'); [$status]=rememberRequest($a,'/api/state');rememberCheck($status===401&&!is_dir($rememberState.'/remember-sessions'),'anonymous request does not create long-lived backing'); - [$status]=rememberRequest($a,'/api/login','',['username'=>'remember-fixture','password'=>'wrong','remember'=>true]);rememberCheck($status===401&&!is_dir($rememberState.'/remember-sessions'),'failed remembered login does not create long-lived backing'); + [$status,$ignored,$ignoredCookie,$failedCookie]=rememberRequest($a,'/api/login','',['username'=>'remember-fixture','password'=>'wrong','remember'=>true]);rememberCheck($status===401&&!is_dir($rememberState.'/remember-sessions'),'failed remembered login does not create long-lived backing'); + rememberCheck($failedCookie===null,'failed login cannot replace a newer authenticated cookie'); foreach(['true',1,null,[],(object)['value'=>true]] as $bad){[$status]=rememberLogin($a,$bad);rememberCheck($status===422,'remember input requires strict boolean');} [$status,$regular,$regularCookie,$header]=rememberLogin($a,false);rememberCheck($status===200&&!str_contains(strtolower((string)$header),'expires=')&&!str_contains(strtolower((string)$header),'max-age='),'unchecked login issues browser-session cookie'); $normalFile=rememberFile($regularCookie,false);rememberCheck(is_file($normalFile)&&!is_dir($rememberState.'/remember-sessions'),'regular login remains in short-lived backing'); @@ -54,7 +55,8 @@ try{ $other=rememberServer('other',$rememberRoot.'/other-state');[$status]=rememberRequest($other,'/api/state',$cookie);rememberCheck($status===401,'remember cookie cannot authenticate against unrelated state'); // Абсолютный срок важнее свежего last; пограничный срок ещё до 90 дней допустим. rememberEdit($cookie,'remember_until',time()+5);rememberEdit($cookie,'last',time()-7200);[$status]=rememberRequest($b,'/api/state',$cookie);rememberCheck($status===200,'remembered session before absolute deadline ignores regular idle expiry'); - rememberEdit($cookie,'remember_until',time()-1);rememberEdit($cookie,'last',time());[$status,$body,$ignored,$expiredHeader]=rememberRequest($b,'/api/state',$cookie);rememberCheck($status===401&&!is_file(rememberFile($cookie))&&str_contains(strtolower((string)$expiredHeader),'expires='),'absolute expiry destroys remembered backing and clears cookie despite fresh last'); + rememberEdit($cookie,'remember_until',time()-1);rememberEdit($cookie,'last',time());[$status,$body,$ignored,$expiredHeader]=rememberRequest($b,'/api/state',$cookie);rememberCheck($status===401&&!is_file(rememberFile($cookie))&&$expiredHeader===null,'absolute expiry revokes backing without sending a cookie that can erase a newer login'); + [$status,$ignored,$ignoredCookie,$replayedHeader]=rememberRequest($b,'/api/state',$cookie);rememberCheck($status===401&&$replayedHeader===null&&!is_file(rememberFile($cookie)),'expired cookie remains unauthenticated on replay after server revocation'); [$status,$login,$cookie]=rememberLogin($b,true);$longFile=rememberFile($cookie);[$status,$newLogin,$newCookie,$header]=rememberLogin($b,false,$cookie);rememberCheck($status===200&&$newCookie!==$cookie&&!is_file($longFile)&&is_file(rememberFile($newCookie,false))&&!str_contains(strtolower((string)$header),'expires='),'remember to regular login destroys long-lived SID and restores session-only cookie'); [$status]=rememberRequest($b,'/api/state',$cookie);rememberCheck($status===401,'old remembered cookie cannot be reused after regular login'); $normal=rememberFile($newCookie,false);$raw=file_get_contents($normal);file_put_contents($normal,preg_replace('/last\|i:\d+;/','last|i:'.(time()-3601).';',$raw));[$status]=rememberRequest($b,'/api/state',$newCookie);rememberCheck($status===401,'unchecked login still enforces one-hour idle expiry'); diff --git a/admin/tests/targets.php b/admin/tests/targets.php new file mode 100644 index 0000000..bd31a92 --- /dev/null +++ b/admin/tests/targets.php @@ -0,0 +1,51 @@ +status===$status,$message);} +} +$legacy=$store->read('targets.json'); +unset($legacy['test']['hidden']);$store->write('targets.json',$legacy); +check($store->targets()[0]['hidden']===false,'legacy target is visible by default'); +check($store->target('test')===$legacy['test'],'legacy raw target identity remains unchanged'); +foreach(['b','c','d'] as $id)$store->saveTarget(['id'=>$id,'domain'=>$id.'.invalid','type'=>'local','root'=>$root.'/site']); +$store->setTargetVisibility('b',true); +$before=$store->target('b'); +check($before['hidden']===true,'visibility persists independently of transport'); +$unchanged=$legacy['test'];$store->setTargetVisibility('test',true);$afterVisibility=$store->target('test');unset($afterVisibility['hidden']); +check($afterVisibility===$unchanged,'visibility changes no credentials enabled verified or transport fields'); +$store->setTargetVisibility('test',false); +$store->saveTarget(['id'=>'b','hidden'=>false]); +check($store->target('b')['hidden']===true,'editing connection cannot override hidden state'); +$store->saveTarget(['id'=>'new','domain'=>'new.invalid','type'=>'local','root'=>$root.'/site','hidden'=>true]); +check($store->target('new')['hidden']===false,'new connection is visible and appended'); +$store->orderTargets(['d','test','new','c']); +check(array_column((new PrelandAdmin\Store($root.'/state'))->targets(),'id')===['d','b','test','new','c'],'reorder persists while preserving hidden slot'); +$store->setTargetVisibility('b',false); +check(array_column($store->targets(),'id')===['d','b','test','new','c'],'restore returns target to preserved slot'); +$unchanged=$store->read('targets.json'); +foreach([['d','b','test','new'],['d','b','test','new','new'],['d','b','test','new','missing']] as $ids)targetProblem(fn()=>$store->orderTargets($ids),409,'stale incomplete duplicate or unknown order is rejected'); +check($store->read('targets.json')===$unchanged,'rejected reorder does not alter persisted state'); +targetProblem(fn()=>$store->orderTargets(['d',1,'test','new','c']),422,'order requires string IDs'); +targetProblem(fn()=>$store->setTargetVisibility('missing',true),404,'visibility missing target returns 404'); +targetProblem(fn()=>$store->deleteTarget('missing'),404,'delete missing target returns 404'); +targetProblem(fn()=>$store->deleteTarget('b'),409,'visible target cannot be deleted'); +$store->setTargetVisibility('b',true); +$job=$jobs->create('check',['targets'=>['b'],'number'=>1]); +targetProblem(fn()=>$store->deleteTarget('b'),409,'queued target cannot be deleted'); +$rawJob=$store->read('jobs/'.$job['id'].'.json');$rawJob['status']='running';$store->write('jobs/'.$job['id'].'.json',$rawJob); +targetProblem(fn()=>$store->deleteTarget('b'),409,'running target cannot be deleted'); +check($store->target('b')['root']===$before['root'],'hiding and rejected deletion retain usable target transport'); +$rawJob['status']='completed';$store->write('jobs/'.$job['id'].'.json',$rawJob); +$job=$jobs->create('copy',['source'=>['id'=>'b'],'targets'=>[['id'=>'test']]]); +targetProblem(fn()=>$store->deleteTarget('b'),409,'queued copy source cannot be deleted'); +$rawJob=$store->read('jobs/'.$job['id'].'.json');$rawJob['status']='completed';$store->write('jobs/'.$job['id'].'.json',$rawJob); +$store->deleteTarget('b'); +check(array_column($store->targets(),'id')===['d','test','new','c'],'hidden idle target deletes only its connection'); +check(is_file($root.'/site/config.json'),'connection deletion keeps site files'); +foreach(['d','test','new','c'] as $id)$store->setTargetVisibility($id,true); +$store->orderTargets([]); +check(array_column($store->targets(),'id')===['d','test','new','c'],'empty visible order preserves all hidden connections'); +echo "Targets Store contract complete\n"; diff --git a/admin/tests/ui-target-login-race.cjs b/admin/tests/ui-target-login-race.cjs new file mode 100644 index 0000000..9421caa --- /dev/null +++ b/admin/tests/ui-target-login-race.cjs @@ -0,0 +1,22 @@ +/* preland#26: late anonymous401 must preserve UI, notice and actual authenticated session. */ +const {chromium}=require('playwright-core'),assert=require('node:assert/strict'); +(async()=>{ + const browser=await chromium.launch({executablePath:process.env.CHROMIUM_PATH||'/usr/bin/chromium',args:['--no-sandbox']}); + const options={baseURL:process.env.ADMIN_URL||'http://admin'};const page=await browser.newPage(options);let original=[]; + const login=async p=>{await p.goto('/');await p.getByLabel('Логин',{exact:true}).fill('admin');await p.getByLabel('Пароль',{exact:true}).fill('test-password');await p.getByRole('button',{name:'Войти',exact:true}).click();await p.locator('#workspace').waitFor({state:'visible'});}; + try{ + let release,ready,held=false;const gate=new Promise(r=>release=r),captured=new Promise(r=>ready=r); + await page.route('**/api/state',async route=>{if(held)return route.continue();held=true;const response=await route.fetch();assert.equal(response.status(),401);ready();await gate;await route.fulfill({response});}); + const entered=login(page);await captured;await entered; + original=await page.locator('[data-overview-target]').evaluateAll(ns=>ns.map(n=>n.dataset.overviewTarget));const id=original[0]; + await page.locator('[data-overview-target="'+id+'"] .domain-grip').press('ArrowRight');await page.waitForFunction(()=>document.querySelector('#notice').textContent==='Порядок доменов сохранён.'); + release();await page.waitForTimeout(500); + assert.equal(await page.locator('#workspace').isVisible(),true,'late anonymous401 must not destroy successful login'); + assert.equal(await page.locator('#notice').innerText(),'Порядок доменов сохранён.','late response must not replace successful mutation notice'); + const current=await page.request.get('/api/state');assert.equal(current.status(),200,'late anonymous401 must not overwrite authenticated session cookie'); + console.log('PASS late anonymous401 preserves workspace/success notice/authenticated session'); + }finally{ + if(original.length){const cleanup=await browser.newPage(options);await login(cleanup);const state=await cleanup.request.get('/api/state').then(r=>r.json());const response=await cleanup.request.post('/api/targets/order',{data:{ids:original},headers:{'X-CSRF-Token':state.csrf}});assert.ok(response.ok(),'cleanup restores order');} + await browser.close(); + } +})().catch(e=>{console.error(e);process.exit(1)}); diff --git a/admin/tests/ui-target-visibility.cjs b/admin/tests/ui-target-visibility.cjs new file mode 100644 index 0000000..2987bd8 --- /dev/null +++ b/admin/tests/ui-target-visibility.cjs @@ -0,0 +1,10 @@ +const {chromium}=require('playwright-core');const assert=require('node:assert/strict'); +(async()=>{const browser=await chromium.launch({executablePath:process.env.CHROMIUM_PATH,args:['--no-sandbox']});const context=await browser.newContext({baseURL:process.env.ADMIN_URL,viewport:{width:1440,height:1000}});const page=await context.newPage();page.on('dialog',d=>d.accept());const errors=[];page.on('pageerror',e=>errors.push(e.message));try{await page.goto('/');await page.getByLabel('Логин',{exact:true}).fill('admin');await page.getByLabel('Пароль',{exact:true}).fill('test-password');await page.getByRole('button',{name:'Войти',exact:true}).click();await page.locator('#workspace').waitFor();const before=await page.locator('[data-overview-target]').evaluateAll(ns=>ns.map(n=>n.dataset.overviewTarget));assert.equal(await page.locator('.domain-hide').count(),before.length,'each domain has hide control');const first=before[0];await page.locator('[data-overview-target="'+first+'"] .domain-grip').press('ArrowRight');await page.waitForFunction(id=>document.querySelectorAll('[data-overview-target]')[1]?.dataset.overviewTarget===id,first);await page.reload();await page.locator('[data-overview-target]').first().waitFor();assert.equal(await page.locator('[data-overview-target]').nth(1).getAttribute('data-overview-target'),first);await page.locator('[data-overview-target="'+first+'"] .domain-hide').click();await page.locator('[data-overview-target="'+first+'"]').waitFor({state:'detached'});assert.equal(await page.locator('#notice').innerText(),'Домен скрыт.');for(const select of ['#source-target','#copy-source','#builder-target'])assert.equal(await page.locator(select+' option[value="'+first+'"]').count(),0);await page.getByRole('button',{name:/Скрытые домены/}).click();await page.locator('[data-hidden-target="'+first+'"] button').filter({hasText:'Вернуть'}).click();await page.locator('[data-overview-target="'+first+'"]').waitFor();await page.locator('[data-overview-target="'+first+'"] .domain-grip').press('ArrowLeft');await page.waitForFunction(id=>document.querySelector('[data-overview-target]')?.dataset.overviewTarget===id,first); +// Native pointer path, preserving unrelated drafts, selection and shared note nodes. +await page.locator('[data-host]').first().waitFor();const host=page.locator('[data-host]').first(),original=await host.inputValue();await host.fill('draft.example');const draftNode=await host.elementHandle();const noteRow=page.locator('tr[data-utm-note=reg]');await noteRow.getByRole('button',{name:/(Добавить|Редактировать) заметку reg/}).click();const note=noteRow.getByLabel('Заметка reg',{exact:true});await note.fill('Несохранённая заметка при переносе');const noteNode=await note.elementHandle(); +const second=before[1],grip=page.locator('[data-overview-target="'+second+'"] .domain-grip');await grip.scrollIntoViewIfNeeded();const box=await grip.boundingBox(),destination=await page.locator('[data-overview-target="'+first+'"]').boundingBox();await page.mouse.move(box.x+10,box.y+10);await page.mouse.down();await page.mouse.move(destination.x+15,destination.y+20,{steps:12});assert.equal(await page.locator('.domain-drag-preview').count(),1);await page.screenshot({path:(process.env.UI_OUTPUT||'/tmp')+'/targets-drag.png'});await page.mouse.up();await page.waitForFunction(id=>document.querySelector('[data-overview-target]').dataset.overviewTarget===id,second);assert.equal(await draftNode.inputValue(),'draft.example');assert.equal(await noteNode.inputValue(),'Несохранённая заметка при переносе');assert.ok(await draftNode.evaluate(n=>n.isConnected));assert.ok(await noteNode.evaluate(n=>n.isConnected)); +await page.locator('[data-overview-target="'+first+'"] .domain-hide').click();assert.equal(await page.locator('[data-overview-target="'+first+'"]').count(),1);assert.match(await page.locator('#notice').innerText(),/Сначала сохраните/);await page.getByLabel('Отменить настройки '+first,{exact:true}).click();await noteRow.getByRole('button',{name:'Отменить заметку reg',exact:true}).click();await page.locator('[data-overview-target="'+first+'"] .domain-grip').press('ArrowLeft');await page.waitForFunction(id=>document.querySelector('[data-overview-target]').dataset.overviewTarget===id,first); +// Server persistence for an independent authenticated device/context. +const another=await browser.newContext({baseURL:process.env.ADMIN_URL});const remote=await another.newPage();await remote.goto('/');await remote.getByLabel('Логин',{exact:true}).fill('admin');await remote.getByLabel('Пароль',{exact:true}).fill('test-password');await remote.getByRole('button',{name:'Войти',exact:true}).click();await remote.locator('[data-overview-target]').first().waitFor();assert.deepEqual(await remote.locator('[data-overview-target]').evaluateAll(ns=>ns.map(n=>n.dataset.overviewTarget)),before);await another.close(); +for(const id of before){await page.locator('[data-overview-target="'+id+'"] .domain-hide').click();await page.locator('[data-overview-target="'+id+'"]').waitFor({state:'detached'});}assert.equal(await page.locator('#connection-new').isVisible(),true);assert.equal(await page.locator('#jobs table').count(),0);assert.match(await page.locator('#jobs').innerText(),/Заданий для видимых доменов пока нет/);await page.locator('#hidden-toggle').click();assert.equal(await page.locator('[data-hidden-target]').count(),before.length);await page.screenshot({path:(process.env.UI_OUTPUT||'/tmp')+'/targets-hidden.png',fullPage:true});for(const id of before){await page.locator('[data-hidden-target="'+id+'"] button').filter({hasText:'Вернуть'}).click();await page.locator('[data-overview-target="'+id+'"]').waitFor();}await page.locator('#hidden-close').click();await page.setViewportSize({width:390,height:844});assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),true);await page.screenshot({path:(process.env.UI_OUTPUT||'/tmp')+'/targets-mobile.png'});assert.deepEqual(errors,[]);assert.equal(await page.locator('#notice').innerText(),'Домен возвращён.');assert.equal(await page.locator('#notice').getAttribute('class'),'alert alert-success');console.log('PASS visibility/order/native drag/drafts/notes/new context/all hidden/responsive UI'); +}finally{await browser.close();}})().catch(e=>{console.error(e);process.exit(1)}); diff --git a/admin/views/dashboard.php b/admin/views/dashboard.php index 6c82c7a..5669251 100644 --- a/admin/views/dashboard.php +++ b/admin/views/dashboard.php @@ -8,8 +8,8 @@ $adminJsVersion = substr(hash_file('sha256', __DIR__ . '/../public/assets/admin.