caterium-app/public/api/support-lib.php
pavlov346346-source 5e72b23161
Correct support mailbox to support@caterium.ru (#42)
Apply the user's explicit address correction to the PHP recipient, Help links, contact form and tests. Refresh the form URL and PWA cache. Exact-recipient PHP tests and support/Help browser tests passed in isolated run 35575721587. No real mail sent, delivery not claimed. Keep standard main QA and production publication gates unchanged; no auth, database or unrelated feature changes.
2026-09-21 11:04:06 +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@caterium.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);}
}