'Вопрос по приложению','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);} }