From 522aaf85540ab15ea62011c2e78f48099718234f Mon Sep 17 00:00:00 2001 From: Panfilov Konstantin Date: Sun, 25 Oct 2026 19:17:51 +0300 Subject: [PATCH] feat(admin): remember login for 90 days and support REG SSH limits --- admin/README.md | 16 +++++ admin/composer.json | 4 +- admin/composer.lock | 5 +- admin/public/assets/admin.css | 1 + admin/public/assets/admin.js | 12 +++- admin/public/index.php | 5 +- admin/src/Auth.php | 61 ++++++++++++++++---- admin/src/Transport.php | 41 ++++++++++--- admin/tests/remember-auth.php | 71 +++++++++++++++++++++++ admin/tests/transport-command-limit.php | 77 +++++++++++++++++++++++++ admin/tests/ui-remember.cjs | 39 +++++++++++++ admin/tests/ui-requisites.cjs | 9 +++ admin/views/dashboard.php | 2 +- 13 files changed, 316 insertions(+), 27 deletions(-) create mode 100644 admin/tests/remember-auth.php create mode 100644 admin/tests/transport-command-limit.php create mode 100644 admin/tests/ui-remember.cjs create mode 100644 admin/tests/ui-requisites.cjs diff --git a/admin/README.md b/admin/README.md index 471b37a..daae964 100644 --- a/admin/README.md +++ b/admin/README.md @@ -5,6 +5,9 @@ проверяется по контракту CPanel и доставляется на выбранные хостинги. Изменение контента не требует Git/push/deploy; `admin/` не входит в публичную поставку прелендингов. +Редактирование реквизитов отключено по поручению владельца: `requisites` видны +в сводке, но не создают полей редактора и не входят в «Выбрать все поля». + Спецификация и план — `docs/superpowers/{specs,plans}/2026-10-25-preland22-*` в репозитории, копии и фактический статус — в базе знаний, основной реестр — preland#22. @@ -48,12 +51,25 @@ PHP-сессии хранятся в `state/sessions` с режимом0700, ф При первом переходе со старого ephemeral backing существующие session-файлы нужно перенести приватно до пересоздания; сам код не мигрирует старый `/tmp`. +Необязательная галочка «Запомнить меня на 90 дней» создаёт persistent cookie +`preland_admin` с HttpOnly/Secure/SameSite=Strict. Срок фиксируется при входе, +чтения его не продлевают. Данные такого входа лежат отдельно в +`state/remember-sessions`0700, файлы0600. Без галочки остаются session-only cookie +и прежний час бездействия. Анонимные обращения не создают долгоживущих файлов. +«Выйти» удаляет серверный сеанс и cookie немедленно; пароль не сохраняется. + UID/GID приложения — 997:987. Приложение получает приватный state и ровно два local mount: `/targets/zoompixel.ru`, `/targets/zoomstage.online`. Весь `/srv/preland`, Docker socket, root SSH key и база основного сайта в приложение не передаются. Права публикации на каждом корне проверяются реальным временным файлом/rename при проверке подключения; нельзя включать запись до успешной проверки. +REG.RU ограничивает размер SSH exec-команды: длинный inline helper разрывал +соединение после успешного входа. Доверенный helper передаётся сжатым zlib; +финальная команда ограничена7000байт, большие payload идут в закрытый SFTP-inbox. +На удалённом PHP требуется `gzinflate`; отсутствие возвращает понятную ошибку. +Содержимое inbox удаляется и при нормальном завершении, и резервно при обрыве. + Два REG.RU-хостинга подключаются SFTP/SSH с их обычными ограниченными учётными записями. Нужны абсолютный корень, host key либо SHA256 fingerprint, пароль или приватный ключ, доступный через stdin CLI `bin/target-import.php`. Секрет хранится 0600 в приватном state, diff --git a/admin/composer.json b/admin/composer.json index 20210a9..5d1a336 100644 --- a/admin/composer.json +++ b/admin/composer.json @@ -2,7 +2,7 @@ "name": "zoomzilla/preland-admin", "description": "Защищённое управление runtime прелендингов", "license": "proprietary", - "require": {"php": "^8.3", "ext-json": "*", "ext-zip": "*", "ext-imagick": "*", "phpseclib/phpseclib": "^3.0"}, + "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"], "test:worker": "php tests/backend-worker.php"} + "scripts": {"test": ["php tests/backend-review.php", "php tests/link-builder.php", "php tests/backend-http.php", "php tests/session-persistence.php", "php tests/remember-auth.php", "php tests/transport-command-limit.php"], "test:worker": "php tests/backend-worker.php"} } diff --git a/admin/composer.lock b/admin/composer.lock index 80ac061..839b942 100644 --- a/admin/composer.lock +++ b/admin/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "5f68a3c22e983ea2df00b8c661b975a4", + "content-hash": "571e6007d448594fcee9719e8947dd65", "packages": [ { "name": "paragonie/constant_time_encoding", @@ -246,7 +246,8 @@ "php": "^8.3", "ext-json": "*", "ext-zip": "*", - "ext-imagick": "*" + "ext-imagick": "*", + "ext-zlib": "*" }, "platform-dev": {}, "plugin-api-version": "2.6.0" diff --git a/admin/public/assets/admin.css b/admin/public/assets/admin.css index 68e4f06..f5fecff 100644 --- a/admin/public/assets/admin.css +++ b/admin/public/assets/admin.css @@ -5,3 +5,4 @@ /* 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} +.login-panel .remember-choice{display:flex;align-items:center;gap:6px;font-weight:400}.login-panel .remember-choice input{flex:0 0 auto;margin:0} diff --git a/admin/public/assets/admin.js b/admin/public/assets/admin.js index 51ca600..7a2913b 100644 --- a/admin/public/assets/admin.js +++ b/admin/public/assets/admin.js @@ -218,7 +218,15 @@ const fields=state.fields.slice(groupFieldsStart);section.prepend(button('Выбрать группу '+marker,()=>{fields.forEach(f=>f.check.checked=true);updateSummary();})); } function renderConfig(){state.fields=[];const container=$('config-fields');container.replaceChildren();const read=readCurrent();if(!read)return;const config=read.config; - for(const [key,value]of Object.entries(config))if(key!=='utm')typed(container,[key],value,key==='host'?'Основной сайт':key==='requisites'?'Реквизиты':key); + for(const [key,value]of Object.entries(config))if(key!=='utm'){ + if(key==='requisites'){ + // По просьбе владельца (#22/comment3770) редактирование реквизитов отключено. + // Ключ config и отображение в сводке сохраняются; в state.fields реквизитов нет. + // typed(container,[key],value,'Реквизиты'); + continue; + } + typed(container,[key],value,key==='host'?'Основной сайт':key); + } if(config.utm&&typeof config.utm==='object'){ for(const marker of markers){const key=Object.hasOwn(config.utm,marker)?marker:aliases[marker]&&Object.hasOwn(config.utm,aliases[marker])?aliases[marker]:marker==='banch'&&Object.hasOwn(config.utm,'chob')?'chob':null;if(key)utmGroup(container,key,config.utm[key],marker);else container.append(el('div',{class:'field-block'},el('h2',{text:marker+'= UTM'}),el('p',{class:'text-muted',text:'Группа отсутствует в этом config.json; автоматически не добавляется.'})));} for(const [key,value]of Object.entries(config.utm))if(!markers.includes(key)&&!Object.values(aliases).includes(key)&&key!=='chob')typed(container,['utm',key],value,key); @@ -252,7 +260,7 @@ 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;}});}); + $('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;}});}); $('logout').addEventListener('click',()=>run(async()=>{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(async()=>{if(!state.source)throw new Error('Выберите домен.');if(state.fields.some(f=>f.check.checked)&&!confirm('Перечитать домен и сбросить несохранённый выбор полей?'))return;await openTarget(state.source,true);})); diff --git a/admin/public/index.php b/admin/public/index.php index ef5cb1f..2b553c2 100644 --- a/admin/public/index.php +++ b/admin/public/index.php @@ -19,7 +19,10 @@ try { if($raw!==''){$object=json_decode($raw,false,32,JSON_THROW_ON_ERROR);if(!$object instanceof stdClass)throw new Problem('Ожидался объект запроса.');$data=(array)$object;} }else $data=$_POST; } - if($path==='/api/login'&&$method==='POST')reply($auth->login((string)($data['username']??''),(string)($data['password']??''),$_SERVER['REMOTE_ADDR']??'unknown')); + if($path==='/api/login'&&$method==='POST'){ + if(array_key_exists('remember',$data)&&!is_bool($data['remember']))throw new Problem('Поле «Запомнить меня» должно быть логическим значением.'); + reply($auth->login((string)($data['username']??''),(string)($data['password']??''),$_SERVER['REMOTE_ADDR']??'unknown',$data['remember']??false)); + } $user=$auth->requireUser(); if($method!=='GET')$auth->csrf($_SERVER['HTTP_X_CSRF_TOKEN']??''); if($path==='/api/logout'&&$method==='POST'){$auth->logout();reply([]);} diff --git a/admin/src/Auth.php b/admin/src/Auth.php index 62f8f10..bbf9e48 100644 --- a/admin/src/Auth.php +++ b/admin/src/Auth.php @@ -3,19 +3,49 @@ declare(strict_types=1); namespace PrelandAdmin; final class Auth { + private const REMEMBER_SECONDS=90*86400; + private static bool $rememberSession=false; public function __construct(private Store $store) {} public static function session(): void { if(session_status()===PHP_SESSION_ACTIVE)return; $directory=self::sessionDirectory(); + $rememberDirectory=self::sessionDirectory('remember-sessions',false); + $incoming=$_COOKIE['preland_admin']??null; + $validId=is_string($incoming)&&preg_match('/^[a-zA-Z0-9,-]{22,256}$/D',$incoming)===1; + if(!$validId)unset($_COOKIE['preland_admin']); + $remember=false; + if($validId&&$rememberDirectory!==null){ + $candidate=$rememberDirectory.'/sess_'.$incoming;clearstatcache(true,$candidate); + if(is_link($candidate))throw new Problem('Символические ссылки файлов сессий запрещены.',503); + $stat=@lstat($candidate);$remember=$stat!==false&&($stat['mode']&0170000)===0100000; + } + self::configure($remember?$rememberDirectory:$directory,$remember); + if(!@session_start())throw new Problem('Не удалось открыть приватное хранилище сессий.',503); + // Между проверкой SID и session_start файл мог быть отозван logout другого запроса. + // Новый анонимный SID не должен оставаться в долгоживущем каталоге. + if($remember&&(session_id()!==$incoming||empty($_SESSION['user'])||!is_int($_SESSION['remember_until']??null))){ + self::destroy(false);self::configure($directory,false); + if(!@session_start())throw new Problem('Не удалось открыть приватное хранилище сессий.',503); + } + } + private static function configure(string $directory,bool $remember): void { if(ini_get('session.save_handler')!=='files'||session_save_path($directory)===false)throw new Problem('Файловое хранилище сессий недоступно.',503); ini_set('session.use_strict_mode','1');ini_set('session.use_only_cookies','1'); - ini_set('session.gc_maxlifetime','3600');ini_set('session.gc_probability','1');ini_set('session.gc_divisor','100'); + ini_set('session.gc_maxlifetime',(string)($remember?self::REMEMBER_SECONDS:3600));ini_set('session.gc_probability','1');ini_set('session.gc_divisor','100'); session_name('preland_admin'); - session_set_cookie_params(['lifetime'=>0,'path'=>'/','secure'=>getenv('ADMIN_COOKIE_SECURE')!=='0','httponly'=>true,'samesite'=>'Strict']); - if(!@session_start())throw new Problem('Не удалось открыть приватное хранилище сессий.',503); + session_set_cookie_params(['lifetime'=>$remember?self::REMEMBER_SECONDS:0]+self::cookieOptions()); + self::$rememberSession=$remember; + } + private static function cookieOptions(): array {return ['path'=>'/','domain'=>(string)ini_get('session.cookie_domain'),'secure'=>getenv('ADMIN_COOKIE_SECURE')!=='0','httponly'=>true,'samesite'=>'Strict'];} + private static function destroy(bool $clearCookie): void { + $_SESSION=[]; + if(session_status()===PHP_SESSION_ACTIVE&&!@session_destroy())throw new Problem('Не удалось отозвать сессию.',503); + unset($_COOKIE['preland_admin']);session_id(''); + if($clearCookie)setcookie('preland_admin','',['expires'=>time()-3600]+self::cookieOptions()); + self::$rememberSession=false; } /** Готовит приватный backing до Store: не создаёт данные в небезопасном state. */ - private static function sessionDirectory(): string { + private static function sessionDirectory(string $bucket='sessions',bool $create=true): ?string { $state=rtrim(getenv('ADMIN_STATE_DIR')?:'/var/lib/preland-admin','/'); if($state===''||!str_starts_with($state,'/')||str_contains($state,'\\')||preg_match('/[\x00-\x1f]/',$state))throw new Problem('Для сессий требуется абсолютный приватный путь состояния.',503); $parts=explode('/',substr($state,1)); @@ -24,17 +54,21 @@ final class Auth foreach(explode(',',getenv('ADMIN_LOCAL_ROOTS')?:'') as $root)$publicRoots[]=trim($root); foreach($publicRoots as $root){$root=rtrim($root,'/');if($root!==''&&($state===$root||str_starts_with($state,$root.'/')))throw new Problem('Сессии нельзя хранить в публичном каталоге.',503);} $current=''; - foreach([...$parts,'sessions'] as $part){ + foreach([...$parts,$bucket] as $part){ $current.='/'.$part;clearstatcache(true,$current); if(is_link($current))throw new Problem('Символические ссылки в пути сессий запрещены.',503); - if(!is_dir($current)&&!@mkdir($current,0700)&&!is_dir($current))throw new Problem('Не удалось подготовить приватное хранилище сессий.',503); + if(!is_dir($current)){ + if(file_exists($current))throw new Problem('Не удалось подготовить приватное хранилище сессий.',503); + if(!$create)return null; + if(!@mkdir($current,0700)&&!is_dir($current))throw new Problem('Не удалось подготовить приватное хранилище сессий.',503); + } clearstatcache(true,$current); if(is_link($current)||realpath($current)!==$current)throw new Problem('Небезопасный путь хранилища сессий.',503); } if(!@chmod($current,0700)||!is_readable($current)||!is_writable($current))throw new Problem('Приватное хранилище сессий недоступно.',503); return $current; } - public function login(string $username,string $password,string $ip): array { + public function login(string $username,string $password,string $ip,bool $remember=false): array { $key=hash('sha256',$ip);$this->store->locked('login',function() use($username,$password,$key){ $attempts=$this->store->read('login-attempts.json');$now=time();$entry=$attempts[$key]??['count'=>0,'since'=>$now];if($entry['since']<$now-900)$entry=['count'=>0,'since'=>$now]; if($entry['count']>=10)throw new Problem('Слишком много попыток. Подождите 15 минут.',429); @@ -44,11 +78,18 @@ final class Auth if(!$valid){$entry['count']++;$attempts[$key]=$entry;$attempts=array_filter($attempts,static fn($e)=>$e['since']>$now-900);$this->store->write('login-attempts.json',$attempts);throw new Problem('Неверный логин или пароль.',401);} unset($attempts[$key]);$this->store->write('login-attempts.json',$attempts); }); - session_regenerate_id(true);$_SESSION=['user'=>$username,'csrf'=>bin2hex(random_bytes(32)),'last'=>time()];return ['user'=>$username,'csrf'=>$_SESSION['csrf']]; + // Каталог проверяется после пароля, но до уничтожения действующего входа. + $directory=self::sessionDirectory($remember?'remember-sessions':'sessions'); + self::destroy(false);self::configure($directory,$remember); + if(!@session_start())throw new Problem('Не удалось создать новую сессию.',503); + $now=time();$_SESSION=['user'=>$username,'csrf'=>bin2hex(random_bytes(32)),'last'=>$now]; + if($remember)$_SESSION['remember_until']=$now+self::REMEMBER_SECONDS; + return ['user'=>$username,'csrf'=>$_SESSION['csrf']]; } public function requireUser(): string { - if(empty($_SESSION['user'])||($_SESSION['last']??0)connect();$code=file_get_contents(__DIR__.'/Remote.php');$code=preg_replace('/^<\?php\s*/','',$code); - // Аргументы проходят shell quoting, обработчик не принимает команды от клиента. - $runner=$code."\necho json_encode(\\PrelandAdmin\\Remote::execute(json_decode(base64_decode(\$argv[1]),true,64,JSON_THROW_ON_ERROR)),JSON_THROW_ON_ERROR);"; - $command='php -d display_errors=0 -r '.escapeshellarg($runner).' '.escapeshellarg(base64_encode(json_encode($payload,JSON_THROW_ON_ERROR))); + // Сжат только доверенный helper из поставки; payload остаётся JSON-данными. + $entry="\necho json_encode(\\PrelandAdmin\\Remote::execute(json_decode(base64_decode(\$argv[1]),true,64,JSON_THROW_ON_ERROR)),JSON_THROW_ON_ERROR);"; + $command=self::inlineCommand($code,$entry,base64_encode(json_encode($payload,JSON_THROW_ON_ERROR))); + $inbox=null;$sftp=null; + try { // Большие наборы доставляются SFTP в приватный inbox: argv/ARG_MAX не используется. - if(strlen($command)>60000) { - $sftp=$this->sftp();$parent=dirname($this->target['root']);$private=$parent.'/.preland-admin-'.substr(hash('sha256',$this->target['root']),0,16); - $this->call(['op'=>'test']);$inbox=$private.'/inbox-'.bin2hex(random_bytes(16)); - if(!$sftp->put($inbox,json_encode($payload,JSON_THROW_ON_ERROR)))throw new Problem('Не удалось доставить подготовленный набор.');$sftp->chmod(0600,$inbox); - $runner=$code."\n\$inbox=\$argv[1];try{echo json_encode(\\PrelandAdmin\\Remote::execute(json_decode(file_get_contents(\$inbox),true,64,JSON_THROW_ON_ERROR)),JSON_THROW_ON_ERROR);}finally{unlink(\$inbox);}"; - $command='php -d display_errors=0 -r '.escapeshellarg($runner).' '.escapeshellarg($inbox); + if(strlen($command)>self::COMMAND_LIMIT) { + if(($payload['op']??null)==='test')throw new Problem('SSH bootstrap превышает безопасный предел команды; сократите helper или путь подключения.'); + $parent=dirname($this->target['root']);$private=$parent.'/.preland-admin-'.substr(hash('sha256',$this->target['root']),0,16); + $candidate=$private.'/inbox-'.bin2hex(random_bytes(16)); + // Проверяем конечный bootstrap до рекурсивного preflight: большой helper + // не должен бесконечно пытаться доставить сам себя через inbox. + $command=self::inboxCommand($code,$candidate); + $this->call(['op'=>'test']);$sftp=$this->sftp();$inbox=$candidate; + if(!$sftp->put($inbox,json_encode($payload,JSON_THROW_ON_ERROR))||!$sftp->chmod(0600,$inbox))throw new Problem('Не удалось доставить подготовленный набор.'); } $timeout=max(1,min(120,(int)(getenv('ADMIN_TRANSPORT_TIMEOUT')?:60)));$ssh->setTimeout($timeout); $output=$ssh->exec($command);$result=is_string($output)?json_decode($output,true):null; if($ssh->isTimeout()||!is_array($result)) { $this->ssh=null;throw new Problem('Неизвестный результат: соединение прервано. Перечитайте фактическое состояние.',504); } + } finally { + // Helper очищает inbox после чтения; резервная очистка также при обрыве + // exec/ошибке загрузки, не подменяя исходную ошибку транспорта. + if($inbox!==null&&$sftp!==null)try{$sftp->delete($inbox);}catch(\Throwable){} + } } if(!($result['ok']??false)) {if(!empty($result['conflict']))throw new Conflict();throw new Problem($result['error']??'Ошибка транспорта.');}return $result; } + private static function inlineCommand(string $code,string $entry,string $argument): string { + if(!function_exists('gzdeflate'))throw new Problem('Для SSH-транспорта требуется расширение zlib.'); + $compressed=gzdeflate($code.$entry,9);if($compressed===false)throw new Problem('Не удалось подготовить SSH helper.'); + $runner='if(!function_exists("gzinflate")){echo json_encode(["ok"=>false,"error"=>"На удалённом PHP отсутствует zlib (gzinflate)."]);exit(1);}eval(gzinflate(base64_decode('.var_export(base64_encode($compressed),true).',true)));'; + return 'php -d display_errors=0 -r '.escapeshellarg($runner).' '.escapeshellarg($argument); + } + private static function inboxCommand(string $code,string $inbox): string { + $entry="\n\$inbox=\$argv[1];try{echo json_encode(\\PrelandAdmin\\Remote::execute(json_decode(file_get_contents(\$inbox),true,64,JSON_THROW_ON_ERROR)),JSON_THROW_ON_ERROR);}finally{unlink(\$inbox);}"; + $command=self::inlineCommand($code,$entry,$inbox); + if(strlen($command)>self::COMMAND_LIMIT)throw new Problem('SSH helper превышает безопасный предел команды; требуется уменьшить bootstrap.'); + return $command; + } private function sftp(): SFTP { $t=$this->target;$sftp=new SFTP($t['host'],$t['port'],20);$key=$sftp->getServerPublicHostKey();$parts=explode(' ',(string)$key);$fp='SHA256:'.rtrim(base64_encode(hash('sha256',base64_decode($parts[1]??'',true)?:'',true)),'=');$expected=$t['host_key']; if(!is_string($key)||!hash_equals(rtrim($expected,'='),rtrim(str_starts_with($expected,'SHA256:')?$fp:$key,'=')))throw new Problem('Ключ SFTP хоста не совпадает.'); diff --git a/admin/tests/remember-auth.php b/admin/tests/remember-auth.php new file mode 100644 index 0000000..d1422a5 --- /dev/null +++ b/admin/tests/remember-auth.php @@ -0,0 +1,71 @@ +['pipe','r'],1=>$log,2=>$log],$pipes,null,$env);if(!is_resource($p))throw new RuntimeException('server failed');fclose($pipes[0]);$rememberServers[$label]=[$p,$log]; + $until=microtime(true)+5;do{$s=@fsockopen('127.0.0.1',$port);if($s){fclose($s);return $port;}usleep(20000);}while(microtime(true)<$until);throw new RuntimeException('server not ready'); +} +function rememberStop(string $label): void {global $rememberServers;if(!isset($rememberServers[$label]))return;[$p,$log]=$rememberServers[$label];proc_terminate($p);proc_close($p);fclose($log);unset($rememberServers[$label]);} +function rememberRequest(int $port,string $path,string $cookie='',?array $body=null,?string $csrf=null): array { + $h=['Content-Type: application/json'];if($cookie!=='')$h[]='Cookie: '.$cookie;if($csrf!==null)$h[]='X-CSRF-Token: '.$csrf; + $ctx=stream_context_create(['http'=>['method'=>$body===null?'GET':'POST','header'=>implode("\r\n",$h),'content'=>$body===null?'':json_encode((object)$body,JSON_THROW_ON_ERROR),'ignore_errors'=>true,'timeout'=>10]]);$text=file_get_contents('http://127.0.0.1:'.$port.$path,false,$ctx);$headers=$http_response_header??[];preg_match('~HTTP/\S+ (\d+)~',$headers[0]??'',$m); + $setCookie=null;foreach($headers as $header)if(str_starts_with(strtolower($header),'set-cookie: preland_admin=')){$setCookie=substr($header,12);$cookie=explode(';',$setCookie)[0];} + clearstatcache();return [(int)($m[1]??0),json_decode((string)$text,true,64,JSON_THROW_ON_ERROR),$cookie,$setCookie]; +} +function rememberLogin(int $port,mixed $remember=false,string $cookie=''): array {return rememberRequest($port,'/api/login',$cookie,['username'=>'remember-fixture','password'=>'remember-fixture-password','remember'=>$remember]);} +function rememberFile(string $cookie,bool $remember=true): string {global $rememberState;return $rememberState.'/'.($remember?'remember-sessions':'sessions').'/sess_'.substr($cookie,strlen('preland_admin='));} +function rememberEdit(string $cookie,string $field,int $value): void {$file=rememberFile($cookie);$text=file_get_contents($file);$new=preg_replace('/'.preg_quote($field,'/').'\|i:\d+;/', $field.'|i:'.$value.';', $text,1,$count);if($count!==1)throw new RuntimeException('Missing fixture session field '.$field);file_put_contents($file,$new);} +function rememberBacking(string $cookie): array { + global $rememberState; + $code='require $argv[1];$_COOKIE["preland_admin"]=$argv[2];PrelandAdmin\\Auth::session();echo json_encode(["bucket"=>basename(session_save_path()),"gc"=>(int)ini_get("session.gc_maxlifetime"),"cookie_lifetime"=>session_get_cookie_params()["lifetime"]]);session_write_close();'; + $env=getenv();$env['ADMIN_STATE_DIR']=$rememberState;$env['ADMIN_COOKIE_SECURE']='0';$p=proc_open([PHP_BINARY,'-r',$code,dirname(__DIR__).'/bootstrap.php',substr($cookie,strlen('preland_admin='))],[0=>['pipe','r'],1=>['pipe','w'],2=>STDERR],$pipes,null,$env);fclose($pipes[0]);$out=stream_get_contents($pipes[1]);fclose($pipes[1]);if(proc_close($p)!==0)throw new RuntimeException('Backing inspection failed');return json_decode($out,true,64,JSON_THROW_ON_ERROR); +} +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'); + 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'); + $backing=rememberBacking($regularCookie);rememberCheck($backing===['bucket'=>'sessions','gc'=>3600,'cookie_lifetime'=>0],'regular backing keeps GC one hour and browser-session lifetime'); + $legacy='preland_admin='.bin2hex(random_bytes(16));$legacyCsrf=bin2hex(random_bytes(32));file_put_contents(rememberFile($legacy,false),'user|'.serialize('remember-fixture').'csrf|'.serialize($legacyCsrf).'last|'.serialize(time())); + [$status,$oldState]=rememberRequest($a,'/api/state',$legacy);rememberCheck($status===200&&$oldState['csrf']===$legacyCsrf&&is_file(rememberFile($legacy,false)),'existing d04-style ordinary session continues without migration or deletion'); + $before=time();[$status,$login,$cookie,$header]=rememberLogin($a,true,$regularCookie); + rememberCheck($status===200&&preg_match('/expires=([^;]+)/i',(string)$header,$match)===1&&abs(strtotime($match[1])-($before+7776000))<3,'checked login issues native session cookie expiring 90 days from login'); + rememberCheck(str_contains(strtolower($header),'httponly')&&str_contains(strtolower($header),'samesite=strict'),'remembered cookie preserves HttpOnly and SameSite Strict'); + rememberCheck($cookie!==$regularCookie&&!is_file($normalFile)&&is_file(rememberFile($cookie)),'regular to remember login destroys old backing and creates fresh native SID'); + $backing=rememberBacking($cookie);rememberCheck($backing===['bucket'=>'remember-sessions','gc'=>7776000,'cookie_lifetime'=>7776000],'only authenticated remember bucket uses ninety-day GC and cookie lifetime'); + [$status]=rememberRequest($a,'/api/state',$regularCookie);rememberCheck($status===401,'old regular cookie cannot be reused after remembered login'); + $text=file_get_contents(rememberFile($cookie));rememberCheck(!str_contains($text,'remember-fixture-password')&&!str_contains($text,'password'),'remembered server session stores no password'); + preg_match('/remember_until\|i:(\d+);/',$text,$untilMatch);$absolute=(int)($untilMatch[1]??0);rememberCheck($absolute>=$before+7776000&&$absolute<=time()+7776000,'server stores absolute remember-until bound from login'); + rememberEdit($cookie,'last',time()-7200);[$status,$state,$same,$refresh]=rememberRequest($a,'/api/state',$cookie); + rememberCheck($status===200&&$state['csrf']===$login['csrf']&&$same===$cookie&&$refresh===null,'remembered session survives old idle timestamp without rotating cookie or CSRF'); + preg_match('/remember_until\|i:(\d+);/',file_get_contents(rememberFile($cookie)),$after);rememberCheck((int)$after[1]===$absolute,'ordinary read does not slide absolute remember deadline'); + rememberStop('a');$b=rememberServer('b');[$status,$body]=rememberRequest($b,'/api/state',$cookie);rememberCheck($status===200&&$body['csrf']===$login['csrf'],'remembered login survives fresh server and separate ephemeral session path'); + $clients=[];$outputs=[];$code='$ctx=stream_context_create(["http"=>["header"=>"Cookie: ".$argv[2],"ignore_errors"=>true,"timeout"=>5]]);$body=file_get_contents($argv[1],false,$ctx);echo json_encode(["body"=>json_decode($body,true),"set_cookie"=>count(array_filter($http_response_header,fn($h)=>str_starts_with(strtolower($h),"set-cookie:")))]);'; + for($i=0;$i<4;$i++){$path=$rememberRoot.'/parallel-'.$i.'.json';$out=fopen($path,'w');$p=proc_open([PHP_BINARY,'-r',$code,'http://127.0.0.1:'.$b.'/api/state',$cookie],[0=>['pipe','r'],1=>$out,2=>STDERR],$pipes);fclose($pipes[0]);$clients[]=[$p,$out];$outputs[]=$path;} + foreach($clients as [$p,$out]){if(proc_close($p)!==0)throw new RuntimeException('Parallel request failed');fclose($out);}foreach($outputs as $path){$parallel=json_decode(file_get_contents($path),true,64,JSON_THROW_ON_ERROR);rememberCheck(($parallel['body']['user']??null)==='remember-fixture'&&($parallel['body']['csrf']??null)===$login['csrf']&&$parallel['set_cookie']===0,'parallel remembered requests preserve authentication, native cookie and CSRF');} + $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'); + [$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'); + [$status,$login,$cookie]=rememberLogin($b,true);$file=rememberFile($cookie);[$status]=rememberRequest($b,'/api/logout',$cookie,[],'invalid');rememberCheck($status===403&&is_file($file),'remembered logout requires existing CSRF'); + [$status,$body,$ignored,$clear]=rememberRequest($b,'/api/logout',$cookie,[],$login['csrf']);rememberCheck($status===200&&!is_file($file)&&str_contains(strtolower((string)$clear),'expires=')&&str_contains(strtolower((string)$clear),'httponly')&&str_contains(strtolower((string)$clear),'samesite=strict'),'logout immediately destroys remembered SID and clears same cookie flags'); + [$status]=rememberRequest($b,'/api/state',$cookie);rememberCheck($status===401,'captured pre-logout remembered cookie cannot be replayed'); + $longCount=count(glob($rememberState.'/remember-sessions/sess_*')?:[]);for($i=0;$i<3;$i++)rememberRequest($b,'/api/state','preland_admin='.bin2hex(random_bytes(16)));rememberCheck(count(glob($rememberState.'/remember-sessions/sess_*')?:[])===$longCount,'arbitrary anonymous SIDs create no 90-day files'); + rememberRequest($b,'/api/login','',['username'=>'remember-fixture','password'=>'wrong','remember'=>true]);rememberCheck(count(glob($rememberState.'/remember-sessions/sess_*')?:[])===$longCount,'failed remembered login creates no long-lived files in existing bucket'); + rememberCheck((fileperms($rememberState.'/remember-sessions')&0777)===0700&&(fileperms($rememberState.'/sessions')&0777)===0700,'both backing directories keep private 0700 modes'); + [$status,$normal,$normalCookie]=rememberLogin($b,false);$normalPath=rememberFile($normalCookie,false);$link=rememberFile($normalCookie,true);symlink($normalPath,$link);[$status]=rememberRequest($b,'/api/state',$normalCookie);rememberCheck($status===503&&is_file($normalPath),'remembered SID symlink is rejected without reading regular backing through it');unlink($link); + [$status,$fresh,$freshCookie]=rememberLogin($b,true,$normalCookie);rememberCheck((fileperms(rememberFile($freshCookie))&0777)===0600,'authenticated remember file has private 0600 mode'); + mkdir($rememberRoot.'/external',0700);mkdir($rememberRoot.'/unsafe',0700);symlink($rememberRoot.'/external',$rememberRoot.'/unsafe/remember-sessions');$unsafe=rememberServer('unsafe',$rememberRoot.'/unsafe');[$status]=rememberRequest($unsafe,'/api/state');rememberCheck($status===503&&glob($rememberRoot.'/external/*')===[],'remember-directory symlink fails closed without external writes'); + echo "Remember auth contract complete\n"; +}finally{foreach(array_keys($rememberServers) as $label)rememberStop($label);} diff --git a/admin/tests/transport-command-limit.php b/admin/tests/transport-command-limit.php new file mode 100644 index 0000000..15b8c44 --- /dev/null +++ b/admin/tests/transport-command-limit.php @@ -0,0 +1,77 @@ +8192)return false; // фактическая граница REG.RU, старая реализация падает + if(self::$dropInbox&&str_contains($command,'/inbox-'))return false; + if(self::$withoutZlib)$command=preg_replace('/^php /','php -d disable_functions=gzinflate ',$command); + $process=proc_open($command,[0=>['pipe','r'],1=>['pipe','w'],2=>['pipe','w']],$pipes); + fclose($pipes[0]);$output=stream_get_contents($pipes[1]);$error=stream_get_contents($pipes[2]);fclose($pipes[1]);fclose($pipes[2]);proc_close($process); + if($error!=='')throw new \RuntimeException($error); + return $output; + } + } + class SFTP extends SSH2 { + public static array $uploads=[]; + public function put(string $path,string $bytes): bool {self::$uploads[]=$path;return file_put_contents($path,$bytes)!==false;} + public function chmod(int $mode,string $path): bool {return chmod($path,$mode);} + public function delete(string $path): bool {return !file_exists($path)||unlink($path);} + } +} +namespace { + require dirname(__DIR__).'/bootstrap.php'; + use PrelandAdmin\{Store,Transport,Problem}; + use phpseclib3\Net\{SSH2,SFTP}; + function ensure(bool $condition,string $message): void {if(!$condition)throw new RuntimeException($message);echo 'PASS: '.$message."\n";} + $fixture=sys_get_temp_dir().'/preland22-command-'.bin2hex(random_bytes(6));mkdir($fixture);mkdir($fixture.'/site'); + $site=$fixture.'/site';file_put_contents($site.'/config.json','{"host":"initial.example","utm":{}}'); + $store=new Store($fixture.'/state'); + $target=['id'=>'limit','type'=>'sftp','host'=>'fixture','port'=>22,'user'=>'fixture','secret'=>'fixture','host_key'=>'ssh-ed25519 '.base64_encode('fixture-key'),'root'=>$site]; + $transport=new Transport($store,$target); + try { + $transport->test();$before=$transport->readConfig(); + ensure($before['config']->host==='initial.example','compressed helper reads under SSH 8KiB limit'); + $transport->writeConfig($before['version'],'{"host":"small.example","utm":{}}'); + ensure($transport->readConfig()['config']->host==='small.example','compressed RPC performs CAS write/read'); + $large=json_encode(['host'=>'large.example','utm'=>(object)[],'padding'=>str_repeat('abcdefgh',3000)],JSON_THROW_ON_ERROR); + $before=$transport->readConfig();$transport->writeConfig($before['version'],$large); + ensure($transport->readConfig()['content']===$large,'large payload uses inbox and preserves bytes'); + ensure(count(SFTP::$uploads)===1,'large payload uploaded through SFTP exactly once'); + foreach(SFTP::$uploads as $path)ensure(!file_exists($path),'SFTP inbox removed after RPC'); + $maximum=max(array_map('strlen',SSH2::$commands)); + ensure($maximum<=7000,'all final shell commands stay within 7000 bytes (maximum '.$maximum.')'); + $commandCount=count(SSH2::$commands);$uploads=count(SFTP::$uploads); + // Inline test может оказаться длиннее inbox runner из-за base64 длинного + // root, даже когда сам inbox помещается: этот preflight нельзя рекурсировать. + $longRootTarget=$target;$longRootTarget['root']='/'.str_repeat('a',1800); + try{(new Transport($store,$longRootTarget))->test();throw new RuntimeException('oversized test recursed');} + catch(Problem $error){ensure(str_contains($error->getMessage(),'SSH bootstrap'),'oversized test RPC rejected before inbox/preflight recursion');} + ensure(count(SSH2::$commands)===$commandCount&&count(SFTP::$uploads)===$uploads,'oversized test performs no exec/upload'); + $bootstrap=new ReflectionMethod(Transport::class,'inboxCommand'); + try{$bootstrap->invoke(null,'/*'.base64_encode(random_bytes(16000)).'*/',$site.'/inbox-test');throw new RuntimeException('oversized bootstrap accepted');} + catch(Problem $error){ensure(str_contains($error->getMessage(),'bootstrap'),'oversized helper rejected before recursive preflight');} + ensure(count(SSH2::$commands)===$commandCount&&count(SFTP::$uploads)===$uploads,'oversized bootstrap performs no remote call/upload'); + $before=$transport->readConfig();SSH2::$dropInbox=true; + try{$transport->writeConfig($before['version'],$large);throw new RuntimeException('dropped exec accepted');} + catch(Problem $error){ensure(str_contains($error->getMessage(),'Неизвестный результат'),'dropped inbox exec remains unknown result');} + SSH2::$dropInbox=false; + foreach(SFTP::$uploads as $path)ensure(!file_exists($path),'inbox cleaned even when exec is interrupted'); + SSH2::$withoutZlib=true; + try{$transport->test();throw new RuntimeException('missing zlib accepted');}catch(Problem $error){ensure(str_contains($error->getMessage(),'zlib'),'missing gzinflate returns clear structured error');} + SSH2::$withoutZlib=false; + } finally { + $iterator=new RecursiveIteratorIterator(new RecursiveDirectoryIterator($fixture,FilesystemIterator::SKIP_DOTS),RecursiveIteratorIterator::CHILD_FIRST); + foreach($iterator as $file)$file->isDir()?rmdir($file->getPathname()):unlink($file->getPathname());rmdir($fixture); + } +} diff --git a/admin/tests/ui-remember.cjs b/admin/tests/ui-remember.cjs new file mode 100644 index 0000000..3031430 --- /dev/null +++ b/admin/tests/ui-remember.cjs @@ -0,0 +1,39 @@ +/* preland#22/comment3770. Real local Docker HTTP + persistent Chromium profile. + * Cookie values/passwords are never written to evidence. */ +const {chromium}=require('playwright-core'); +const assert=require('node:assert/strict'); +const fs=require('node:fs');const os=require('node:os');const path=require('node:path'); +(async()=>{ + const profile=fs.mkdtempSync(path.join(os.tmpdir(),'preland-remember-')); + const origin=process.env.ADMIN_URL||'http://admin',output=process.env.UI_OUTPUT||'/tmp'; + const launch=()=>chromium.launchPersistentContext(profile,{executablePath:process.env.CHROMIUM_PATH||'/usr/bin/chromium',args:['--no-sandbox'],baseURL:origin,viewport:{width:1440,height:1000}}); + let context=await launch();const errors=[];let page=await context.newPage();page.on('pageerror',e=>errors.push(e.message)); + try{ + await page.goto(origin); + const remember=page.getByLabel('Запомнить меня на 90 дней',{exact:true}); + assert.equal(await remember.count(),1,'remember checkbox exists');assert.equal(await remember.isChecked(),false,'unchecked HTML default'); + await page.getByLabel('Логин',{exact:true}).fill(process.env.ADMIN_USER||'admin');await page.getByLabel('Пароль',{exact:true}).fill(process.env.ADMIN_PASSWORD||'test-password');await remember.check(); + const loginRequest=page.waitForRequest(r=>r.url().endsWith('/api/login')&&r.method()==='POST');await page.getByRole('button',{name:'Войти',exact:true}).click();assert.equal((await loginRequest).postDataJSON().remember,true,'explicit boolean true'); + await page.locator('#workspace').waitFor({state:'visible'}); + const cookies=await context.cookies(origin),now=Date.now()/1000; + const durable=cookies.find(c=>process.env.REMEMBER_COOKIE?c.name===process.env.REMEMBER_COOKIE:c.httpOnly&&c.expires>now+86400*60); + assert.ok(durable,'persistent HttpOnly remember cookie');assert.ok(Math.abs((durable.expires-now)-90*86400)<600,'expires approximately 90 days');assert.equal(durable.httpOnly,true);assert.equal(durable.sameSite,'Strict'); + const target=process.env.TARGET_ID||'a'; + const read=async()=>{const r=await page.request.get('/api/config?target='+target).then(r=>r.json());assert.ok(r.ok,r.error);return r;};const before=await read(); + assert.ok(Array.isArray(before.config.requisites)&&before.config.requisites.length,'fixture requisites required'); + await page.locator('#overview-table tr[data-overview-extra=requisites]').getByText(before.config.requisites[0],{exact:false}).first().waitFor(); + await page.locator('#source-target').selectOption(target);await page.getByLabel('Основной сайт',{exact:true}).waitFor(); + assert.equal(await page.locator('#config-fields').getByLabel('Реквизиты',{exact:true}).count(),0,'requisites editor hidden'); + assert.equal(await page.locator('#config-fields input[aria-label^="Применить поле requisites"]').count(),0,'requisites absent from editable fields'); + await page.getByRole('button',{name:'Выбрать все поля формы',exact:true}).click();assert.doesNotMatch(await page.locator('#change-summary').innerText(),/requisites/);await page.getByRole('button',{name:'Снять выбор полей',exact:true}).click(); + const after=await read();assert.equal(after.version,before.version);assert.deepEqual(after.config,before.config,'runtime unchanged'); + await page.screenshot({path:output+'/remember-requisites-editor.png',fullPage:true}); + await context.close();context=await launch();page=await context.newPage();page.on('pageerror',e=>errors.push(e.message));await page.goto(origin);await page.locator('#workspace').waitFor({state:'visible'});assert.equal(await page.locator('#login-panel').isVisible(),false,'reopen authenticates without password'); + await page.getByRole('button',{name:'Выйти',exact:true}).click();await page.locator('#login-panel').waitFor({state:'visible'}); + 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.getByLabel('Запомнить меня на 90 дней',{exact:true}).uncheck(); + const shortRequest=page.waitForRequest(r=>r.url().endsWith('/api/login')&&r.method()==='POST');await page.getByRole('button',{name:'Войти',exact:true}).click();assert.equal((await shortRequest).postDataJSON().remember,false,'explicit boolean false');await page.locator('#workspace').waitFor({state:'visible'}); + const shortCookies=await context.cookies(origin);assert.ok(!shortCookies.some(c=>c.name===durable.name&&c.expires>Date.now()/1000+86400),'unchecked login does not retain 90-day cookie'); + assert.deepEqual(errors,[]);fs.writeFileSync(output+'/remember-evidence.json',JSON.stringify({cookie:{name:durable.name,expires:durable.expires,httpOnly:durable.httpOnly,sameSite:durable.sameSite,secure:durable.secure},configVersion:after.version,pageErrors:errors,reopenAuthenticated:true,uncheckedRememberFalse:true},null,2)); + console.log('PASS actual remember90 checkbox/boolean/cookie/persistent reopen + session login + readonly requisites/overview/config unchanged'); + }finally{await context.close();} +})().catch(error=>{console.error(error);process.exit(1);}); diff --git a/admin/tests/ui-requisites.cjs b/admin/tests/ui-requisites.cjs new file mode 100644 index 0000000..bca5740 --- /dev/null +++ b/admin/tests/ui-requisites.cjs @@ -0,0 +1,9 @@ +/* preland#22/comment3770: requisites are overview-only, never selected for apply. */ +const {chromium}=require('playwright-core');const assert=require('node:assert/strict'); +(async()=>{ + const browser=await chromium.launch({executablePath:process.env.CHROMIUM_PATH||'/usr/bin/chromium',args:['--no-sandbox']}); + try{const page=await browser.newPage({baseURL:process.env.ADMIN_URL||'http://admin'});await page.goto(process.env.ADMIN_URL||'http://admin');await page.getByLabel('Логин',{exact:true}).fill(process.env.ADMIN_USER||'admin');await page.getByLabel('Пароль',{exact:true}).fill(process.env.ADMIN_PASSWORD||'test-password');await page.getByRole('button',{name:'Войти',exact:true}).click();await page.locator('#workspace').waitFor({state:'visible'}); + const target=process.env.TARGET_ID||'a',read=()=>page.request.get('/api/config?target='+target).then(r=>r.json());const before=await read();assert.ok(before.ok,before.error);await page.locator('#source-target').selectOption(target);await page.getByLabel('Основной сайт',{exact:true}).waitFor();assert.equal(await page.locator('#config-fields input[aria-label^="Применить поле requisites"]').count(),0,'requisites not in editable fields'); + await page.getByRole('button',{name:'Выбрать все поля формы',exact:true}).click();assert.doesNotMatch(await page.locator('#change-summary').innerText(),/requisites/);await page.getByRole('button',{name:'Снять выбор полей',exact:true}).click();assert.ok(await page.locator('#overview-table tr[data-overview-extra=requisites]').count());const after=await read();assert.equal(after.version,before.version);assert.deepEqual(after.config,before.config);console.log('PASS requisites overview-only / select all excludes / config unchanged'); + }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 bc54512..b632ba3 100644 --- a/admin/views/dashboard.php +++ b/admin/views/dashboard.php @@ -6,7 +6,7 @@ $adminJsVersion = substr(hash_file('sha256', __DIR__ . '/../public/assets/admin. Прелендинги — UTM и наборы изображений
- +