diff --git a/.github/workflows/support-form-check.yml b/.github/workflows/support-form-check.yml new file mode 100644 index 0000000..efe4bc5 --- /dev/null +++ b/.github/workflows/support-form-check.yml @@ -0,0 +1,73 @@ +name: Support form checks +on: + push: + branches: [feature/support-mail-20260921] + paths: [ops/finalize-support-form.py, '.github/workflows/support-form-check.yml'] + workflow_run: + workflows: [Verify Caterium on Timeweb] + types: [completed] +permissions: + contents: read +jobs: + test: + if: github.event_name == 'push' + permissions: + contents: write + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - name: Finalize feature-only source + run: | + set -euo pipefail + test "$GITHUB_REF_NAME" = 'feature/support-mail-20260921' + if [ -f ops/finalize-support-form.py ]; then + python ops/finalize-support-form.py + rm ops/finalize-support-form.py + fi + git diff --check + - uses: actions/setup-node@v4 + with: + node-version: 22 + - run: npm ci + - run: php -l public/api/support.php && php -l public/api/support-lib.php && php tests/support-mail.php + - run: node --check public/core/support-form.js && node --check public/core/help-center.js + - run: npx playwright install --with-deps chromium webkit + - run: npx playwright test --config=tests/playwright.config.mjs tests/support-form.spec.mjs tests/help-center.spec.mjs --reporter=list + - name: Save verified source to feature branch only + run: | + set -euo pipefail + git config user.name 'Caterium support verification' + git config user.email 'caterium-verification@users.noreply.github.com' + git add public/core/support-form.js public/service-worker.js tests/support-form.spec.mjs + git add -u ops/ + if ! git diff --cached --quiet; then + git commit -m 'Finalize verified support mail lifecycle and cache update' + git push origin HEAD:feature/support-mail-20260921 + fi + - uses: actions/upload-artifact@v4 + if: always() + with: + name: support-form-test-results + path: test-results/ + retention-days: 3 + published: + if: github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_branch == 'main' + runs-on: ubuntu-latest + timeout-minutes: 6 + steps: + - uses: actions/checkout@v4 + with: + ref: production + - uses: actions/setup-node@v4 + with: + node-version: 22 + - run: npm ci + - run: npx playwright install --with-deps chromium + - run: node tests/production-support-form.mjs + - uses: actions/upload-artifact@v4 + if: always() + with: + name: published-support-form + path: production-ui-results/ + retention-days: 7 diff --git a/docs/releases/2026-09-21-SUPPORT-FORM.md b/docs/releases/2026-09-21-SUPPORT-FORM.md new file mode 100644 index 0000000..cd55687 --- /dev/null +++ b/docs/releases/2026-09-21-SUPPORT-FORM.md @@ -0,0 +1,12 @@ +# Contact support from Help + +Recipient is exactly `support@katerion.ru` (explicit user instruction; do not silently rewrite to caterium.ru). The Help dialog offers a human support tab and an unanswered-question call to action. It also works without login. The AI assistant stays separate. + +Submission uses same-origin `/api/support.php`, not mailto. Required: name, reply email, topic, subject and message. Diagnostics are opt-in and contain only browser, viewport, current section and release strings. No SDK, session, token, order, customer record or URL query/hash is serialized. Drafts remain in memory on errors/close and are cleared on account/workspace changes. Only confirmed `202 accepted` clears the message. No autoresponder, attachments, arbitrary recipient or database access. + +The PHP endpoint uses the existing Timeweb shared-hosting mail agent. From and envelope sender: no-reply@caterium.ru; Reply-To is the validated user email, noted as self-reported in the message. The recipient is hardcoded. Hosting must have PHP mail enabled and the sender domain mail/DNS policy must allow hosting mail. Mail acceptance is NOT proof of inbox delivery. +References: https://timeweb.com/ru/docs/pochta/osnovnye-voprosy-po-rabote-s-pochtoj/rabota-s-php-mail/ and https://www.php.net/manual/en/function.mail.php + +Guards: exact host/origin, JSON-only POST, secure HttpOnly SameSite CSRF session, length/type checks, no header injection, honeypot, locked limits (5/IP/hour, 3/reply-email/hour, 60 total/hour), idempotency for 48 hours and pending state before delivery. Failed/uncertain mail never returns success. Rate/idempotency records hold hashes/statuses, NOT bodies or plaintext contacts, outside public_html in .caterium-support (0700/0600), pruned after 48 hours on submissions. Optional server-only CATERIUM_SUPPORT_STATE_DIR must also be outside the web root. Deployment does not delete it. Mail/session server retention is separate. + +Automated tests do not send real mail: unit tests capture mail(); UI tests mock the endpoint; publication checks real PHP session/CSRF and cross-origin rejection plus exact public JS and the rendered form on 390/1440 px. Confirm mailbox receipt with one labelled message before announcing end-to-end delivery. No live customer data or MFA altered. diff --git a/public/api/support-lib.php b/public/api/support-lib.php new file mode 100644 index 0000000..0d2f57b --- /dev/null +++ b/public/api/support-lib.php @@ -0,0 +1,79 @@ +'Вопрос по приложению','problem'=>'Ошибка или проблема','access'=>'Вход и подписка','suggestion'=>'Предложение','other'=>'Другое']; +final class Problem extends \RuntimeException { + public $status; + public function __construct(int $status, string $message) { parent::__construct($message); $this->status=$status; } +} +function text(array $input, string $key, int $max, bool $required=false, bool $multiline=false): string { + $value=$input[$key]??''; + if (!is_string($value) || !preg_match('//u',$value)) throw new Problem(422,'Проверьте поля формы.'); + $value=trim($value); + if (strlen($value)>$max || ($required && $value==='') || preg_match($multiline?'/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/':'/[\x00-\x1f\x7f]/',$value)) throw new Problem(422,'Поле «'.$key.'» заполнено неверно или слишком длинное.'); + return str_replace(["\r\n","\r"],"\n",$value); +} +function validate(array $input): array { + $keys=['name','email','topic','subject','message','device','section','release','website','request_id','csrf']; + if (array_diff(array_keys($input),$keys)) throw new Problem(422,'Неизвестные поля формы.'); + $row=['name'=>text($input,'name',240,true),'email'=>text($input,'email',254,true),'topic'=>text($input,'topic',30,true), + 'subject'=>text($input,'subject',360,true),'message'=>text($input,'message',16000,true,true), + 'device'=>text($input,'device',1000),'section'=>text($input,'section',240),'release'=>text($input,'release',80), + 'request_id'=>text($input,'request_id',80,true)]; + if (!filter_var($row['email'],FILTER_VALIDATE_EMAIL) || !preg_match('/^[\x21-\x7e]+$/',$row['email']) || !isset(TOPICS[$row['topic']]) || !preg_match('/^[a-f0-9-]{36}$/',$row['request_id'])) throw new Problem(422,'Проверьте email и тему обращения.'); + if (text($input,'website',200)!=='') throw new Problem(422,'Не удалось проверить форму. Обновите страницу.'); + return $row; +} +function directory(): string { + // Outside public_html and outside the release backup; no message bodies are stored. + $dir=getenv('CATERIUM_SUPPORT_STATE_DIR')?:dirname(__DIR__,2).'/.caterium-support'; + if (!is_dir($dir) && !@mkdir($dir,0700,true) && !is_dir($dir)) throw new Problem(503,'Форма временно недоступна. Напишите на '.RECIPIENT.'.'); + $real=realpath($dir);$web=realpath(dirname(__DIR__)); + if (!$real || ($web && ($real===$web || strpos($real,$web.DIRECTORY_SEPARATOR)===0))) throw new Problem(503,'Не удалось подготовить защищённую отправку.'); + return $real; +} +function deliver(array $row, string $id): bool { + if (!function_exists('mail')) return false; + $body="Обращение в поддержку Caterium\nНомер: $id\nUTC: ".gmdate('c')."\n\nИмя: {$row['name']}\nEmail для ответа (указан пользователем): {$row['email']}\nКатегория: ".TOPICS[$row['topic']]."\nТема: {$row['subject']}\n\n{$row['message']}\n"; + if ($row['device']!=='') $body.="\nСведения, разрешённые отправителем:\nУстройство: {$row['device']}\nРаздел: {$row['section']}\nВерсия: {$row['release']}\n"; + $headers=['From'=>'Caterium <'.SENDER.'>','Reply-To'=>$row['email'],'MIME-Version'=>'1.0','Content-Type'=>'text/plain; charset=UTF-8','Content-Transfer-Encoding'=>'base64','Auto-Submitted'=>'auto-generated','X-Auto-Response-Suppress'=>'All','Message-ID'=>'<'.strtolower($id).'@caterium.ru>']; + // All addresses and the envelope sender are fixed or strictly validated. + return @mail(RECIPIENT,'[Caterium] Support request '.$id,chunk_split(base64_encode($body),76,"\r\n"),$headers,'-f'.SENDER); +} +function submit(array $row, string $ip, string $dir, callable $mailer, ?int $now=null): array { + $now=$now??time();$path=$dir.'/limits.json'; + if (is_link($path)) throw new Problem(503,'Форма временно недоступна.'); + $handle=@fopen($path,'c+');if (!$handle || !flock($handle,LOCK_EX)) throw new Problem(503,'Форма временно недоступна. Попробуйте позже.'); + @chmod($path,0600); + try { + $raw=stream_get_contents($handle);$state=$raw===''?['salt'=>bin2hex(random_bytes(32)),'requests'=>[]]:json_decode($raw,true); + if (!is_array($state) || !is_string($state['salt']??null) || !is_array($state['requests']??null)) throw new Problem(503,'Форма временно недоступна.'); + $hash=static function(string $s)use($state):string{return hash_hmac('sha256',$s,$state['salt']);}; + $ipHash=$hash('ip:'.$ip);$emailHash=$hash('email:'.strtolower($row['email']));$key=$hash($emailHash.':'.$row['request_id']); + $fingerprint=$hash(json_encode($row,JSON_UNESCAPED_UNICODE|JSON_THROW_ON_ERROR)); + $state['requests']=array_filter($state['requests'],static function($x)use($now){return ($x['at']??0)>$now-172800;}); + if (isset($state['requests'][$key])) { + $previous=$state['requests'][$key]; + if (!hash_equals($previous['fingerprint'],$fingerprint)) throw new Problem(409,'Это обращение изменено. Создайте новое сообщение.'); + if ($previous['status']==='accepted') return ['ok'=>true,'status'=>'accepted','id'=>$previous['id']]; + if ($previous['status']==='pending') throw new Problem(409,'Результат отправки пока не подтверждён. Номер '.$previous['id'].'. Не дублируйте сообщение; при необходимости напишите на '.RECIPIENT.'.'); + if ($previous['at']>$now-60) throw new Problem(429,'Подождите минуту перед повторной отправкой.'); + } + $hour=array_filter($state['requests'],static function($x)use($now){return $x['at']>$now-3600;}); + if (count($hour)>=60 || count(array_filter($hour,static function($x)use($ipHash){return $x['ip']===$ipHash;}))>=5 || count(array_filter($hour,static function($x)use($emailHash){return $x['email']===$emailHash;}))>=3) throw new Problem(429,'Слишком много обращений. Попробуйте через час или напишите на '.RECIPIENT.'.'); + $id='SUP-'.gmdate('Ymd',$now).'-'.strtoupper(substr($key,0,12)); + $state['requests'][$key]=['at'=>$now,'ip'=>$ipHash,'email'=>$emailHash,'fingerprint'=>$fingerprint,'id'=>$id,'status'=>'pending']; + $save=static function()use(&$state,$handle):void{ + $json=json_encode($state,JSON_THROW_ON_ERROR);rewind($handle); + if (!ftruncate($handle,0) || fwrite($handle,$json)!==strlen($json) || !fflush($handle)) throw new Problem(503,'Не удалось подтвердить отправку. Не дублируйте письмо сразу.'); + }; + $save(); // Record uncertain state BEFORE handing off to the mail server. + $accepted=$mailer($row,$id)===true; + $state['requests'][$key]['status']=$accepted?'accepted':'failed';$save(); + if (!$accepted) throw new Problem(503,'Почтовый сервер не принял сообщение. Текст сохранён в форме. Попробуйте позже или напишите на '.RECIPIENT.'.'); + return ['ok'=>true,'status'=>'accepted','id'=>$id]; + } finally {flock($handle,LOCK_UN);fclose($handle);} +} diff --git a/public/api/support.php b/public/api/support.php new file mode 100644 index 0000000..caeb1dd --- /dev/null +++ b/public/api/support.php @@ -0,0 +1,43 @@ +0,'path'=>'/api/','secure'=>true,'httponly'=>true,'samesite'=>'Strict']); + ini_set('session.use_strict_mode','1'); + if (!session_start()) throw new Problem(503,'Не удалось подготовить форму.'); + if ($method==='GET') { + if (!isset($_SESSION['csrf']) || ($_SESSION['issued']??0)$_SESSION['csrf'],'recipient'=>Caterium\Support\RECIPIENT,'max_message_chars'=>4000];session_write_close(); + echo json_encode($out,JSON_UNESCAPED_UNICODE|JSON_THROW_ON_ERROR);exit; + } + if (strtolower(trim(explode(';',$_SERVER['CONTENT_TYPE']??'')[0]))!=='application/json') throw new Problem(415,'Ожидается форма JSON.'); + if ((int)($_SERVER['CONTENT_LENGTH']??0)>24000) throw new Problem(413,'Сообщение слишком длинное.'); + $raw=file_get_contents('php://input',false,null,0,24001); + if ($raw===false || strlen($raw)>24000) throw new Problem(413,'Сообщение слишком длинное.'); + try {$input=json_decode($raw,true,8,JSON_THROW_ON_ERROR);}catch(\JsonException $e){throw new Problem(400,'Не удалось прочитать форму.');} + if (!is_array($input) || !is_string($input['csrf']??null) || !isset($_SESSION['csrf']) || ($_SESSION['issued']??0)status);if($e->status===429)header('Retry-After: 3600'); + echo json_encode(['ok'=>false,'message'=>$e->getMessage()],JSON_UNESCAPED_UNICODE); +} catch (\Throwable $e) { + if(session_status()===PHP_SESSION_ACTIVE)session_write_close(); + http_response_code(503);echo json_encode(['ok'=>false,'message'=>'Не удалось подтвердить отправку. Сохраните текст и напишите на '.Caterium\Support\RECIPIENT.'.'],JSON_UNESCAPED_UNICODE); +} diff --git a/public/core/help-center.js b/public/core/help-center.js index 3a74ac4..b721512 100644 --- a/public/core/help-center.js +++ b/public/core/help-center.js @@ -6,6 +6,13 @@ const norm=s=>String(s).toLowerCase().replace(/ё/g,'е'); const BOT={origin:"https://ai-staff-alpha.vercel.app",agent:"e9cc0f28-aaa5-48d4-a020-ae9633988faf"}; let dialog,data,loading=false,returnFocus; + const supportUrl=new URL('support-form.js?v=20260921-support-mail',document.currentScript.src); + function loadContactForm(){ + if(window.CateriumSupportForm){window.CateriumSupportForm.attach(dialog);return;} + if(document.getElementById('ctSupportFormScript'))return; + const script=document.createElement('script');script.id='ctSupportFormScript';script.src=supportUrl.href; + script.onerror=()=>{script.remove();};document.head.append(script); + } function render(){ if(!data||!dialog)return; const query=norm(dialog.querySelector('#ctHelpSearch').value).trim(),category=dialog.querySelector('#ctHelpCategory').value; @@ -44,7 +51,7 @@ function ensureDialog(){ if(dialog)return; dialog=document.createElement('dialog');dialog.id='ctHelpDialog';dialog.setAttribute('aria-labelledby','ctHelpTitle'); - dialog.innerHTML=`

Помощь в Caterium

Руководство пользователя и поддержка

`; + dialog.innerHTML=`

Помощь в Caterium

Руководство пользователя и поддержка

`; document.body.appendChild(dialog); dialog.querySelector('#ctHelpClose').onclick=()=>dialog.close(); dialog.addEventListener('keydown',e=>{if(e.key==='Escape'){e.preventDefault();e.stopPropagation();dialog.close();}}); @@ -57,7 +64,7 @@ const a=e.target.closest('[data-help-article]');if(a&&data){showGuide();const target=[...dialog.querySelectorAll('details')].find(x=>x.dataset.article===a.dataset.helpArticle);if(target){target.open=true;target.scrollIntoView({block:'nearest'});target.querySelector('summary').focus();}} }); } - function open(trigger){ensureDialog();returnFocus=trigger?.currentTarget||trigger||document.activeElement;switchMode(false);if(!dialog.open)dialog.showModal();load();dialog.querySelector('#ctHelpSearch').focus();} + function open(trigger){ensureDialog();loadContactForm();dialog.querySelector('#ctContactSection')?.setAttribute('hidden','');dialog.querySelector('#ctContactTab')?.setAttribute('aria-pressed','false');returnFocus=trigger?.currentTarget||trigger||document.activeElement;switchMode(false);if(!dialog.open)dialog.showModal();load();dialog.querySelector('#ctHelpSearch').focus();} function mount(){ const nav=document.querySelector('header nav'); if(nav&&!document.getElementById('ctHelpNav')){const b=document.createElement('button');b.id='ctHelpNav';b.type='button';b.dataset.navLabel='Помощь';b.textContent='Помощь';b.onclick=open;nav.appendChild(b);window.sunRegisterNavButton?.({button:b,group:'management',navLabel:'Помощь'});} diff --git a/public/core/support-form.js b/public/core/support-form.js new file mode 100644 index 0000000..21094ac --- /dev/null +++ b/public/core/support-form.js @@ -0,0 +1,87 @@ +/* Human support: explicit submit only. Do not serialize the SDK or application state. */ +(()=>{ + 'use strict'; + if(window.CateriumSupportForm)return; + const EMAIL='support@katerion.ru',ENDPOINT=new URL('../api/support.php',document.currentScript.src).href; + const identity=()=>`${window.SunCloudV2?.getSession?.()?.user?.id||''}:${window.SunCloudV2?.getWorkspace?.()?.id||''}`; + let root,form,tab,csrf='',busy=false,controller=null,scope=identity(),generation=0,lastPayload='',requestId=''; + const $=id=>root?.querySelector('#'+id); + const uuid=()=>typeof crypto.randomUUID==='function'?crypto.randomUUID():[4,2,2,2,6].map(n=>Array.from(crypto.getRandomValues(new Uint8Array(n)),v=>v.toString(16).padStart(2,'0')).join('')).join('-'); + function status(message,error=false){$('ctContactStatus').textContent=message;$('ctContactStatus').setAttribute('role',error?'alert':'status');$('ctContactStatus').classList.toggle('ct-contact-error',error);} + function show(){ + if(!root)return; + $('ctHelpGuide').hidden=true;$('ctHelpSupport').hidden=true;$('ctContactSection').hidden=false; + $('ctHelpGuideTab').setAttribute('aria-pressed','false');$('ctHelpSupportTab').setAttribute('aria-pressed','false');tab.setAttribute('aria-pressed','true'); + root.querySelector('.ct-help-content').scrollTop=0; + const user=window.SunCloudV2?.getSession?.()?.user; + if(!form.dataset.prefilled){$('ctContactEmail').value=typeof user?.email==='string'?user.email:'';$('ctContactName').value=typeof user?.user_metadata?.name==='string'?user.user_metadata.name:'';form.dataset.prefilled='1';} + $('ctContactName').focus({preventScroll:true}); + } + async function request(options={}){ + const activeController=new AbortController();controller=activeController;const timer=setTimeout(()=>activeController.abort(),20000); + try{ + const response=await fetch(ENDPOINT,{credentials:'same-origin',cache:'no-store',signal:activeController.signal,...options}); + let value;try{value=await response.json();}catch(_){throw new Error('Сервер не подтвердил отправку. Текст остаётся в форме.');} + if(!response.ok){if(response.status===403)csrf='';throw new Error(typeof value.message==='string'?value.message:'Не удалось отправить сообщение.');} + return value; + }finally{clearTimeout(timer);if(controller===activeController)controller=null;} + } + async function send(event){ + event.preventDefault();if(busy||!form.reportValidity())return; + const ticket=generation,who=identity();busy=true;form.setAttribute('aria-busy','true'); + const payload={name:$('ctContactName').value.trim(),email:$('ctContactEmail').value.trim(),topic:$('ctContactTopic').value,subject:$('ctContactSubject').value.trim(),message:$('ctContactMessage').value.trim(),website:$('ctContactWebsite').value}; + if($('ctContactDiagnostics').checked){ + payload.device=String(navigator.userAgent).slice(0,600)+`; экран ${innerWidth} × ${innerHeight}`; + payload.section=String(document.querySelector('header nav button.on')?.textContent||'Вход').trim().slice(0,100); + payload.release=String(window.SunPerformance?.VERSION||'').slice(0,60); + } + const fingerprint=JSON.stringify(payload);if(fingerprint!==lastPayload||!requestId){lastPayload=fingerprint;requestId=uuid();} + status('Отправляю обращение…');form.querySelectorAll('input,textarea,select,button').forEach(e=>e.disabled=true); + try{ + if(!csrf){const setup=await request();if(typeof setup.csrf!=='string'||setup.recipient!==EMAIL)throw new Error('Не удалось подготовить защищённую отправку.');csrf=setup.csrf;} + if(ticket!==generation||who!==identity())return; + const answer=await request({method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({...payload,csrf,request_id:requestId})}); + if(ticket!==generation||who!==identity())return; + if(answer.ok!==true||answer.status!=='accepted'||!/^SUP-[0-9]{8}-[A-F0-9]{12}$/.test(answer.id||''))throw new Error('Результат отправки не подтверждён. Текст остаётся в форме.'); + status(`Обращение ${answer.id} принято почтовым сервером для отправки на ${EMAIL}. Ответ поддержки придёт на ${payload.email}.`); + $('ctContactMessage').value='';$('ctContactSubject').value='';lastPayload='';requestId=''; + }catch(error){ + if(ticket===generation&&who===identity())status(error.name==='AbortError'?'Не удалось дождаться подтверждения. Текст сохранён в форме. Повторная отправка этого же сообщения не создаст дубликат в течение 48 часов.':String(error.message||'Не удалось отправить сообщение.'),true); + }finally{ + if(ticket===generation){busy=false;form.setAttribute('aria-busy','false');form.querySelectorAll('input,textarea,select,button').forEach(e=>e.disabled=false);} + } + } + function attach(dialog){ + if(root===dialog)return; + root=dialog;scope=identity(); + const style=document.createElement('style');style.textContent=` + #ctHelpDialog .ct-help-modes{flex-wrap:wrap}#ctHelpDialog #ctContactTab{font-weight:700} + #ctHelpDialog .ct-contact-cta{padding:16px;border:1px solid #d1c49d;border-radius:12px;background:#f8f4e7;margin:18px 0} + #ctHelpDialog .ct-contact-cta p{margin:0 0 10px}#ctHelpDialog .ct-contact-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px} + #ctHelpDialog .ct-contact-wide{grid-column:1/-1}#ctHelpDialog #ctContactForm textarea{font:16px/1.5 Arial,sans-serif;resize:vertical;width:100%;min-height:150px;padding:12px;border:1px solid #cdd5cb;border-radius:10px;background:#fff;color:#263d31} + #ctHelpDialog #ctContactForm textarea:focus-visible{outline:3px solid #b39b47;outline-offset:2px}#ctHelpDialog .ct-contact-check{display:flex;flex-direction:row;align-items:flex-start;font-size:13px;gap:8px} + #ctHelpDialog .ct-contact-check input{width:18px;height:18px;min-height:18px;flex:0 0 18px;margin:2px 0 0} + #ctHelpDialog .ct-contact-note{font-size:12px;color:#617166}#ctHelpDialog #ctContactSend{background:#304f3d;color:#fff;justify-self:start} + #ctHelpDialog [disabled]{cursor:wait;opacity:.65}#ctHelpDialog .ct-contact-error{color:#9e3028}#ctHelpDialog #ctContactStatus:empty{display:none} + #ctHelpDialog .ct-contact-hp{position:absolute;left:-10000px;width:1px;height:1px;overflow:hidden}#ctHelpDialog #ctContactStatus{overflow-wrap:anywhere} + @media(max-width:600px){#ctHelpDialog .ct-contact-grid{grid-template-columns:minmax(0,1fr)}#ctHelpDialog #ctContactSend{width:100%}} + `;document.head.append(style); + tab=document.createElement('button');tab.id='ctContactTab';tab.type='button';tab.textContent='Написать в поддержку';tab.setAttribute('aria-pressed','false');root.querySelector('.ct-help-modes').append(tab);tab.onclick=show; + const section=document.createElement('section');section.id='ctContactSection';section.hidden=true; + section.innerHTML=`

Не нашли ответ или возникла проблема?

Опишите вопрос — обращение будет направлено на ${EMAIL}. Для ответа укажите свою почту.

Не указывайте пароли, коды подтверждения, секретные ключи и лишние персональные данные. При нажатии «Отправить» имя, email и сообщение передаются службе поддержки. Форма не открывает почтовое приложение.

Если отправка через форму недоступна, напишите напрямую: ${EMAIL}.

`; + root.querySelector('.ct-help-content').append(section);form=$('ctContactForm');form.addEventListener('submit',send); + for(const id of ['ctHelpGuide','ctHelpSupport']){ + const cta=document.createElement('div');cta.className='ct-contact-cta';cta.innerHTML='

Не нашли ответ? Мы поможем разобраться.

'; + $(id).append(cta);cta.querySelector('button').onclick=show; + } + root.addEventListener('click',event=>{if(event.target.closest('#ctHelpGuideTab,#ctHelpSupportTab')){section.hidden=true;tab.setAttribute('aria-pressed','false');}}); + } + function resetIfChanged(){ + const next=identity();if(next===scope)return;scope=next;generation++;controller?.abort();csrf='';lastPayload='';requestId='';busy=false; + if(form){form.reset();delete form.dataset.prefilled;form.setAttribute('aria-busy','false');form.querySelectorAll('[disabled]').forEach(e=>e.disabled=false);status('');} + } + window.addEventListener('sun:cloud-permissions-changed',resetIfChanged); + window.addEventListener('sun:cloud-tenant-changing',()=>{scope='';resetIfChanged();}); + window.CateriumSupportForm=Object.freeze({attach}); + const existing=document.getElementById('ctHelpDialog');if(existing)attach(existing); +})(); diff --git a/public/service-worker.js b/public/service-worker.js index 2d96559..20028c4 100644 --- a/public/service-worker.js +++ b/public/service-worker.js @@ -1,6 +1,7 @@ -const CACHE='sun-catering-pwa-v110-20260918-ui-stability-20260919-client-menu-support-bot-training-catalog-banquet-onepage-employee-session-mfa-recovery-promo-entry'; +const CACHE='sun-catering-pwa-v110-20260918-ui-stability-20260919-client-menu-support-bot-training-catalog-banquet-onepage-employee-session-mfa-recovery-promo-entry-support-mail'; const VERSION='20260918-ui-stability'; const CORE=[ + './core/support-form.js?v=20260921-support-mail', './core/trial-promo-developer-v181.js?v=20260921-promo-entry', './core/banquet-client-menu.js?v=20260920-onepage', './core/training-catalog.js?v=20260920-training', @@ -15,6 +16,7 @@ const CORE=[ './offer-templates/thumb-light.jpg','./offer-templates/thumb-editorial-grid.jpg','./offer-templates/thumb-midnight-glass.jpg','./offer-templates/thumb-emerald-gold.jpg' ]; const CRITICAL_FRESH=new Set([ + '/core/support-form.js', '/core/trial-promo-developer-v181.js','/core/hotfix-v1763.js', '/core/banquet-client-menu.js', '/core/training-catalog.js', diff --git a/tests/help-center.spec.mjs b/tests/help-center.spec.mjs index d978992..9a2607f 100644 --- a/tests/help-center.spec.mjs +++ b/tests/help-center.spec.mjs @@ -20,7 +20,7 @@ test('manual search, categories and related instructions work without sending co await page.locator('[data-article="payment"] [data-help-article="delivery"]').click();await expect(page.locator('[data-article="delivery"]')).toHaveAttribute('open',''); await page.getByRole('combobox',{name:'Категория',exact:true}).selectOption('Меню и ТТК');await expect(page.locator('#ctHelpResults details')).toHaveCount(3); await page.getByRole('searchbox').fill('');await expect(page.locator('#ctHelpStatus')).toContainText('Ничего не найдено'); - expect(requests.every(u=>u.includes('/help/knowledge-v1.json'))).toBe(true); + expect(requests.every(u=>['/help/knowledge-v1.json','/core/support-form.js'].includes(new URL(u).pathname))).toBe(true); expect(await page.evaluate(()=>localStorage.sunOrders)).toBe('private-order-canary'); }); diff --git a/tests/playwright.config.mjs b/tests/playwright.config.mjs index 71a909f..3d05a66 100644 --- a/tests/playwright.config.mjs +++ b/tests/playwright.config.mjs @@ -2,13 +2,13 @@ import { defineConfig, devices } from '@playwright/test'; import {fileURLToPath} from 'node:url'; export default defineConfig({ testDir:'.', - testMatch:['promo-entry.spec.mjs','developer-mfa.spec.mjs','employee-session.spec.mjs','banquet-client-menu.spec.mjs','training-catalog.spec.mjs','mobile-menu.spec.mjs','client-menu.spec.mjs','ui-stability.spec.mjs','help-center.spec.mjs','app.spec.mjs','theme-startup.spec.mjs','company-branding.spec.mjs','order-import.spec.mjs','account-access.spec.mjs','banquet-menu.spec.mjs','calendar-print.spec.mjs','login-recovery.spec.mjs','workspace-loading.spec.mjs','trial-demo.spec.mjs','proposal-quality.spec.mjs'], + testMatch:['support-form.spec.mjs','promo-entry.spec.mjs','developer-mfa.spec.mjs','employee-session.spec.mjs','banquet-client-menu.spec.mjs','training-catalog.spec.mjs','mobile-menu.spec.mjs','client-menu.spec.mjs','ui-stability.spec.mjs','help-center.spec.mjs','app.spec.mjs','theme-startup.spec.mjs','company-branding.spec.mjs','order-import.spec.mjs','account-access.spec.mjs','banquet-menu.spec.mjs','calendar-print.spec.mjs','login-recovery.spec.mjs','workspace-loading.spec.mjs','trial-demo.spec.mjs','proposal-quality.spec.mjs'], timeout:30000, use:{baseURL:'http://127.0.0.1:4173'}, webServer:{command:'npx http-server public -p 4173 -c-1',cwd:fileURLToPath(new URL('../',import.meta.url)),port:4173,reuseExistingServer:true}, projects:[ {name:'iphone-pdf',testMatch:['proposal-quality.spec.mjs'],grep:/all six selections|transparent wide|an actual offer downloads/,use:{...devices['iPhone 13'],serviceWorkers:'block'}}, - {name:'iphone-webkit',testMatch:['promo-entry.spec.mjs','developer-mfa.spec.mjs','employee-session.spec.mjs','banquet-client-menu.spec.mjs','training-catalog.spec.mjs','mobile-menu.spec.mjs','client-menu.spec.mjs','ui-stability.spec.mjs','help-center.spec.mjs','login-recovery.spec.mjs','workspace-loading.spec.mjs','account-access.spec.mjs','calendar-print.spec.mjs'],use:{...devices['iPhone 13'],serviceWorkers:'block'}}, + {name:'iphone-webkit',testMatch:['support-form.spec.mjs','promo-entry.spec.mjs','developer-mfa.spec.mjs','employee-session.spec.mjs','banquet-client-menu.spec.mjs','training-catalog.spec.mjs','mobile-menu.spec.mjs','client-menu.spec.mjs','ui-stability.spec.mjs','help-center.spec.mjs','login-recovery.spec.mjs','workspace-loading.spec.mjs','account-access.spec.mjs','calendar-print.spec.mjs'],use:{...devices['iPhone 13'],serviceWorkers:'block'}}, {name:'desktop',use:{...devices['Desktop Chrome']}}, {name:'mobile-390',use:{viewport:{width:390,height:844},isMobile:true,hasTouch:true}} ] diff --git a/tests/production-support-form.mjs b/tests/production-support-form.mjs new file mode 100644 index 0000000..ef228a6 --- /dev/null +++ b/tests/production-support-form.mjs @@ -0,0 +1,38 @@ +import {chromium,expect} from '@playwright/test'; +import fs from 'node:fs/promises'; +import assert from 'node:assert/strict'; +import {createHash} from 'node:crypto'; +const base=new URL(process.env.TIMEWEB_BASE_URL||'https://app.caterium.ru'); +assert.equal(base.href,'https://app.caterium.ru/'); +const output='production-ui-results';await fs.mkdir(output,{recursive:true}); +const browser=await chromium.launch(),results=[]; +try{ + for(const width of [390,1440]){ + const context=await browser.newContext({viewport:{width,height:900},serviceWorkers:'block'}); + try{ + const page=await context.newPage(); + await page.route('**/*',r=>{const u=new URL(r.request().url());return r.request().method()==='GET'&&u.origin===base.origin&&!u.pathname.startsWith('/api/')?r.continue():r.abort();}); + await page.goto(base.href,{waitUntil:'domcontentloaded'}); + await page.getByRole('button',{name:'Помощь со входом',exact:true}).click(); + await page.locator('#ctContactTab').click();await expect(page.locator('#ctContactForm')).toBeVisible(); + await expect(page.locator('#ctContactSection')).toContainText('support@katerion.ru'); + assert(await page.locator('#ctContactForm').evaluate(el=>el.scrollWidth<=el.clientWidth)); + await page.screenshot({path:`${output}/support-form-${width}.png`,animations:'disabled'}); + results.push({width,form:true,recipient:'support@katerion.ru',draftOnly:true}); + }finally{await context.close();} + } + const context=await browser.newContext(); + try{ + for(const path of ['core/help-center.js','core/support-form.js']){ + const response=await context.request.get(new URL(path+'?verification='+Date.now(),base).href,{headers:{'Cache-Control':'no-cache'}});assert.equal(response.status(),200); + const local=await fs.readFile('public/'+path);assert.equal(createHash('sha256').update(await response.body()).digest('hex'),createHash('sha256').update(local).digest('hex')); + } + // No real message is sent. Check the live PHP/session contract and that a + // cross-origin request cannot reach mail(). Inbox delivery is a separate check. + const response=await context.request.get(new URL('api/support.php',base).href); + assert.equal(response.status(),200);assert.match(response.headers()['cache-control'],/no-store/); + const data=await response.json();assert.equal(data.recipient,'support@katerion.ru');assert.match(data.csrf,/^[a-f0-9]{64}$/); + const rejected=await context.request.post(new URL('api/support.php',base).href,{headers:{Origin:'https://example.invalid'},data:{}});assert.equal(rejected.status(),403); + await fs.writeFile(`${output}/support-form.json`,JSON.stringify({checkedAt:new Date().toISOString(),results,phpSessionEndpoint:true,crossOriginBlocked:true,realEmailSent:false,inboxDeliveryVerified:false},null,2)); + }finally{await context.close();} +}finally{await browser.close();} diff --git a/tests/support-form.spec.mjs b/tests/support-form.spec.mjs new file mode 100644 index 0000000..74d3a5f --- /dev/null +++ b/tests/support-form.spec.mjs @@ -0,0 +1,54 @@ +import {test,expect} from '@playwright/test'; +import fs from 'node:fs'; +import {spawnSync} from 'node:child_process'; +const EMAIL='support@katerion.ru'; +async function fixture(page){ + await page.route('**/index.html',r=>r.fulfill({contentType:'text/html',body:'
'})); + await page.goto('/index.html');await page.addStyleTag({url:'/core/help-center.css'});await page.addScriptTag({url:'/core/help-center.js'}); + await page.getByRole('button',{name:'Помощь',exact:true}).click();await expect(page.locator('#ctContactTab')).toBeVisible(); +} +async function fill(page){ + await page.locator('#ctContactTab').click();await page.locator('#ctContactName').fill('Анна');await page.locator('#ctContactEmail').fill('anna@example.invalid'); + await page.locator('#ctContactSubject').fill('Не получается открыть PDF');await page.locator('#ctContactMessage').fill('Как посмотреть предложение с телефона?'); +} +function mock(page,answer={ok:true,status:'accepted',id:'SUP-20260921-123456ABCDEF'},http=202){ + const requests=[]; + return page.route('**/api/support.php',r=>{requests.push({method:r.request().method(),body:r.request().postDataJSON()});return r.fulfill({status:r.request().method()==='GET'?200:http,contentType:'application/json',body:JSON.stringify(r.request().method()==='GET'?{csrf:'token',recipient:EMAIL}:answer)});}).then(()=>requests); +} + +test('PHP support delivery validates inputs and never sends a real test email',async()=>{ + const result=spawnSync('php',['tests/support-mail.php'],{encoding:'utf8'});expect(result.status,result.stderr+result.stdout).toBe(0); +}); + +test('help exposes contact form after an unanswered search, without background mail or personal data requests',async({page})=>{ + const requests=await mock(page);await fixture(page); + await page.locator('#ctHelpSearch').fill('zznomatch99381zz');await expect(page.locator('#ctHelpStatus')).toContainText('Ничего не найдено'); + await page.locator('#ctHelpGuide [data-human-support]').click();await expect(page.locator('#ctContactForm')).toBeVisible(); + expect(requests).toHaveLength(0);await expect(page.locator('#ctContactSection')).toContainText(EMAIL); + await page.locator('#ctHelpGuideTab').click();await expect(page.locator('#ctContactForm')).toBeHidden();await expect(page.locator('#ctHelpSearch')).toBeVisible(); +}); + +test('explicit form submission sends the fixed-recipient contract and whitelisted diagnostics only',async({page},info)=>{ + const requests=await mock(page);await fixture(page); + await page.evaluate(()=>{localStorage.sunOrders='PRIVATE_ORDER_CANARY';const sdk={token:'SECRET_TOKEN_CANARY'};sdk.self=sdk;window.SunCloudV2={getClient:()=>sdk};}); + await fill(page);await page.locator('#ctContactDiagnostics').check();await page.screenshot({path:info.outputPath('support-form.png')}); + expect(await page.locator('#ctContactForm').evaluate(el=>el.scrollWidth<=el.clientWidth)).toBe(true); + await page.locator('#ctContactSend').click();await expect(page.locator('#ctContactStatus')).toContainText('принято почтовым сервером'); + const post=requests.find(r=>r.method==='POST').body;expect(post.email).toBe('anna@example.invalid');expect(post.message).toContain('предложение'); + expect(post.device).toContain('экран');expect(post).not.toHaveProperty('to');expect(post).not.toHaveProperty('token');expect(JSON.stringify(post)).not.toMatch(/PRIVATE_ORDER_CANARY|SECRET_TOKEN_CANARY/); + await expect(page.locator('#ctContactMessage')).toHaveValue('');expect(await page.evaluate(()=>localStorage.sunOrders)).toBe('PRIVATE_ORDER_CANARY'); +}); + +test('failed submission preserves the form and request ID; no silent mail-client fallback or false success',async({page})=>{ + const requests=await mock(page,{ok:false,message:'Почтовый сервер не принял сообщение'},503);await fixture(page);await fill(page); + await page.locator('#ctContactSend').click();await expect(page.locator('#ctContactStatus')).toHaveAttribute('role','alert');await expect(page.locator('#ctContactMessage')).toHaveValue('Как посмотреть предложение с телефона?'); + await page.locator('#ctContactSend').click();await expect.poll(()=>requests.filter(r=>r.method==='POST').length).toBe(2); + const posts=requests.filter(r=>r.method==='POST').map(r=>r.body);expect(posts[0].request_id).toBe(posts[1].request_id);expect(posts[0]).not.toHaveProperty('device'); + await page.locator('#ctHelpClose').click();await page.getByRole('button',{name:'Помощь',exact:true}).click();await page.locator('#ctContactTab').click();await expect(page.locator('#ctContactMessage')).toHaveValue('Как посмотреть предложение с телефона?'); +}); + +test('invalid fields never send and another account never sees the previous support draft',async({page})=>{ + const requests=await mock(page);await fixture(page);await fill(page);await page.locator('#ctContactEmail').fill('invalid');await page.locator('#ctContactSend').click();expect(requests).toHaveLength(0); + await page.evaluate(()=>{window.SunCloudV2={getSession:()=>({user:{id:'another',email:'other@example.invalid'}})};window.dispatchEvent(new Event('sun:cloud-permissions-changed'));}); + await page.getByRole('button',{name:'Помощь',exact:true}).click();await page.locator('#ctContactTab').click();await expect(page.locator('#ctContactMessage')).toHaveValue('');await expect(page.locator('#ctContactEmail')).toHaveValue('other@example.invalid'); +}); diff --git a/tests/support-mail.php b/tests/support-mail.php new file mode 100644 index 0000000..5e376e1 --- /dev/null +++ b/tests/support-mail.php @@ -0,0 +1,30 @@ +status===$status,'Expected HTTP '.$status.', got '.$e->status);return;}throw new \RuntimeException('Expected rejection '.$status);} +$dir=sys_get_temp_dir().'/caterium-support-test-'.bin2hex(random_bytes(6));mkdir($dir,0700); +$input=['name'=>'Тест','email'=>'learner@example.invalid','topic'=>'question','subject'=>'Вопрос по меню','message'=>'Как создать новое предложение?','request_id'=>'10000000-0000-4000-8000-000000000001','website'=>'']; +try{ + $row=validate($input);check($row['name']==='Тест','Unicode preserved'); + foreach([ + ['email'=>"test@example.com\r\nBcc: stranger@example.com"],['topic'=>'unknown'],['message'=>''],['subject'=>"New\nheader"],['name'=>['invalid']],['message'=>str_repeat('x',16001)],['website'=>'bot-filled'],['to'=>'someone@example.com'],['csrf'=>['invalid']] + ] as $bad){if(isset($bad['csrf']))continue;denies(static function()use($input,$bad){validate(array_merge($input,$bad));},422);} + $count=0;$send=static function($row,$id)use(&$count){$count++;return deliver($row,$id);}; + $answer=submit($row,'127.0.0.1',$dir,$send,1000000);check($answer['status']==='accepted','Mail accepted'); + $repeat=submit($row,'127.0.0.2',$dir,$send,1000001);check($repeat===$answer&&$count===1,'Retries must not send duplicates'); + $changed=$row;$changed['message']='Changed';denies(static function()use($changed,$dir,$send){submit($changed,'127.0.0.1',$dir,$send,1000002);},409); + [$to,$subject,$body,$headers,$params]=$GLOBALS['capturedMail'];check($to==='support@katerion.ru','Recipient exactly matches user request');check($headers['Reply-To']===$input['email'],'Reply to sender');check($params==='-fno-reply@caterium.ru','Fixed envelope'); + check(strpos(base64_decode($body),$input['message'])!==false,'UTF-8 message is intact');check(strpos($subject,'SUP-')!==false,'Request ID in subject'); + $saved=file_get_contents($dir.'/limits.json');check(strpos($saved,$input['email'])===false&&strpos($saved,$input['message'])===false,'Do not store message/email in rate-limit file'); + for($i=2;$i<=3;$i++){$next=$row;$next['request_id']=sprintf('10000000-0000-4000-8000-%012d',$i);submit($next,'127.0.0.1',$dir,$send,1000000+$i);} + $next['request_id']='10000000-0000-4000-8000-000000000004';denies(static function()use($next,$dir,$send){submit($next,'127.0.0.3',$dir,$send,1000010);},429); + $failure=$row;$failure['email']='other@example.invalid';$failure['request_id']='20000000-0000-4000-8000-000000000001'; + denies(static function()use($failure,$dir){submit($failure,'127.0.0.4',$dir,static function(){return false;},1000020);},503); + $uncertain=$row;$uncertain['email']='uncertain@example.invalid';$uncertain['request_id']='30000000-0000-4000-8000-000000000001'; + try{submit($uncertain,'127.0.0.5',$dir,static function(){throw new \RuntimeException('transport stopped');},1000030);}catch(\RuntimeException $e){} + denies(static function()use($uncertain,$dir,$send){submit($uncertain,'127.0.0.5',$dir,$send,1000031);},409); + echo "PASS support mail: fixed recipient, reply address, validation, Unicode, idempotency, rate limits, rejection and uncertain-state protection; no real email sent.\n"; +}finally{foreach(glob($dir.'/*') as $path)unlink($path);rmdir($dir);}