caterium-app/public/api/support-lib.php
pavlov346346-source c3cb44419b
Add human support form with fixed email recipient (#40)
Add Help contact form and PHP mail endpoint for support@katerion.ru, with validated Reply-To, explicit diagnostics consent, CSRF/origin checks, hashed rate limits and duplicate protection. Preserve drafts on error and avoid serializing customer data or SDK internals. Full PR QA passed in 35559317001; isolated PHP and 30 browser cases passed in 35559179372. Standard production gates unchanged. Publication checks do not send real mail; inbox receipt remains unverified. No training photo assets or unfinished training lifecycle changes.
2026-09-21 07:12:26 +03:00

80 lines
7.7 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
/** Fixed-recipient contact form. No customer database access and no mail relay. */
declare(strict_types=1);
namespace Caterium\Support;
const RECIPIENT = 'support@katerion.ru';
const SENDER = 'no-reply@caterium.ru';
const TOPICS = ['question'=>'Вопрос по приложению','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);}
}