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.
80 lines
7.7 KiB
PHP
80 lines
7.7 KiB
PHP
<?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);}
|
||
}
|