feat: Implement SMS sending functionality with KavehNegar and Rangineh providers
- Add SendSmsMessage class for encapsulating SMS message data. - Create KavehNegarProvider and RanginehProvider classes implementing SmsProviderInterface for sending SMS. - Implement SmsLogRepository and SmsTemplateRepository for managing SMS logs and templates. - Develop SendSmsHandler for handling SMS sending messages. - Create SmsService to manage SMS dispatching and logging. - Add UserProfileController for managing user profiles with CRUD operations. - Implement UserProfile entity and repository for user profile data management. - Update symfony.lock and bootstrap.php for project dependencies and environment setup.
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace App\Shared\Constant;
|
||||
|
||||
class ErrorCodes
|
||||
{
|
||||
// Auth
|
||||
public const ERR_AUTH_001 = 'ERR_AUTH_001';
|
||||
public const ERR_AUTH_002 = 'ERR_AUTH_002';
|
||||
public const ERR_AUTH_003 = 'ERR_AUTH_003';
|
||||
public const ERR_AUTH_004 = 'ERR_AUTH_004';
|
||||
public const ERR_AUTH_005 = 'ERR_AUTH_005';
|
||||
public const ERR_AUTH_006 = 'ERR_AUTH_006';
|
||||
|
||||
// Validation
|
||||
public const ERR_VALIDATION_001 = 'ERR_VALIDATION_001';
|
||||
public const ERR_VALIDATION_002 = 'ERR_VALIDATION_002';
|
||||
|
||||
// Not Found
|
||||
public const ERR_NOT_FOUND_001 = 'ERR_NOT_FOUND_001';
|
||||
|
||||
// Conflict
|
||||
public const ERR_CONFLICT_001 = 'ERR_CONFLICT_001';
|
||||
|
||||
// Forbidden
|
||||
public const ERR_FORBIDDEN_001 = 'ERR_FORBIDDEN_001';
|
||||
|
||||
// Payment
|
||||
public const ERR_PAYMENT_001 = 'ERR_PAYMENT_001';
|
||||
public const ERR_PAYMENT_002 = 'ERR_PAYMENT_002';
|
||||
public const ERR_PAYMENT_003 = 'ERR_PAYMENT_003';
|
||||
|
||||
// Appointment
|
||||
public const ERR_APPOINTMENT_001 = 'ERR_APPOINTMENT_001';
|
||||
public const ERR_APPOINTMENT_002 = 'ERR_APPOINTMENT_002';
|
||||
|
||||
// File
|
||||
public const ERR_FILE_001 = 'ERR_FILE_001';
|
||||
public const ERR_FILE_002 = 'ERR_FILE_002';
|
||||
|
||||
// SMS
|
||||
public const ERR_SMS_001 = 'ERR_SMS_001';
|
||||
public const ERR_SMS_002 = 'ERR_SMS_002';
|
||||
public const ERR_SMS_003 = 'ERR_SMS_003';
|
||||
|
||||
// Secretary
|
||||
public const ERR_SECRETARY_001 = 'ERR_SECRETARY_001';
|
||||
|
||||
// Rate Limit
|
||||
public const ERR_RATE_LIMIT_001 = 'ERR_RATE_LIMIT_001';
|
||||
|
||||
public static function message(string $code): string
|
||||
{
|
||||
return match ($code) {
|
||||
self::ERR_AUTH_001 => 'توکن JWT منقضی شده یا نامعتبر است',
|
||||
self::ERR_AUTH_002 => 'کد OTP نامعتبر است',
|
||||
self::ERR_AUTH_003 => 'کد OTP منقضی شده است',
|
||||
self::ERR_AUTH_004 => 'تعداد تلاشهای OTP به حد مجاز رسیده است',
|
||||
self::ERR_AUTH_005 => 'نام کاربری یا رمز عبور اشتباه است',
|
||||
self::ERR_AUTH_006 => 'این نوع حساب فقط از طریق کد OTP وارد میشود',
|
||||
self::ERR_VALIDATION_001 => 'ورودی نامعتبر است',
|
||||
self::ERR_VALIDATION_002 => 'فیلد الزامی وارد نشده است',
|
||||
self::ERR_NOT_FOUND_001 => 'منبع درخواستی یافت نشد',
|
||||
self::ERR_FORBIDDEN_001 => 'دسترسی به این منبع مجاز نیست',
|
||||
self::ERR_PAYMENT_001 => 'درگاه پرداخت در دسترس نیست',
|
||||
self::ERR_PAYMENT_002 => 'مبلغ پرداخت نامعتبر است',
|
||||
self::ERR_PAYMENT_003 => 'وضعیت نوبت برای پرداخت مناسب نیست',
|
||||
self::ERR_APPOINTMENT_001 => 'اسلات انتخابشده در دسترس نیست',
|
||||
self::ERR_APPOINTMENT_002 => 'نوبت قابل لغو نیست',
|
||||
self::ERR_FILE_001 => 'فرمت فایل مجاز نیست',
|
||||
self::ERR_FILE_002 => 'حجم فایل بیش از حد مجاز است (حداکثر 5MB)',
|
||||
self::ERR_SMS_001 => 'موجودی پیامک کافی نیست',
|
||||
self::ERR_SMS_002 => 'متغیر نامعتبر در تمپلیت',
|
||||
self::ERR_SMS_003 => 'تمپلیت قبلاً ارسال شده است',
|
||||
self::ERR_SECRETARY_001 => 'پلن فعلی اجازه منشی بیشتر را نمیدهد',
|
||||
self::ERR_CONFLICT_001 => 'تداخل: منبع در حال استفاده است یا قبلاً تغییر کرده است',
|
||||
self::ERR_RATE_LIMIT_001 => 'درخواستهای زیاد. لطفاً بعداً تلاش کنید',
|
||||
default => 'خطای ناشناخته',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Shared\Controller;
|
||||
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
|
||||
abstract class BaseController extends AbstractController
|
||||
{
|
||||
protected function success(mixed $data, int $status = 200, array $meta = []): JsonResponse
|
||||
{
|
||||
$response = ['success' => true, 'data' => $data];
|
||||
if (!empty($meta)) {
|
||||
$response['meta'] = $meta;
|
||||
}
|
||||
return new JsonResponse($response, $status);
|
||||
}
|
||||
|
||||
protected function paginated(mixed $data, int $total, int $page, int $limit): JsonResponse
|
||||
{
|
||||
return $this->success($data, 200, [
|
||||
'totalRecords' => $total,
|
||||
'totalPages' => (int) ceil($total / max($limit, 1)),
|
||||
'currentPage' => $page,
|
||||
]);
|
||||
}
|
||||
|
||||
protected function error(string $code, string $message, int $status = 400, ?string $field = null): JsonResponse
|
||||
{
|
||||
$err = ['code' => $code, 'message' => $message];
|
||||
if ($field !== null) {
|
||||
$err['field'] = $field;
|
||||
}
|
||||
return new JsonResponse(['success' => false, 'data' => null, 'errors' => [$err]], $status);
|
||||
}
|
||||
|
||||
protected function validationError(array $violations): JsonResponse
|
||||
{
|
||||
$errors = [];
|
||||
foreach ($violations as $field => $messages) {
|
||||
foreach ((array) $messages as $message) {
|
||||
$errors[] = [
|
||||
'code' => 'ERR_VALIDATION_001',
|
||||
'field' => $field,
|
||||
'message' => $message,
|
||||
];
|
||||
}
|
||||
}
|
||||
return new JsonResponse(['success' => false, 'data' => null, 'errors' => $errors], 422);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Shared\Controller;
|
||||
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Contracts\Cache\CacheInterface;
|
||||
|
||||
class HealthController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly CacheInterface $cache,
|
||||
) {}
|
||||
|
||||
#[Route('/health', methods: ['GET'])]
|
||||
public function __invoke(): JsonResponse
|
||||
{
|
||||
$checks = [];
|
||||
$status = 'ok';
|
||||
|
||||
try {
|
||||
$this->em->getConnection()->executeQuery('SELECT 1');
|
||||
$checks['database'] = 'ok';
|
||||
} catch (\Throwable) {
|
||||
$checks['database'] = 'error';
|
||||
$status = 'degraded';
|
||||
}
|
||||
|
||||
try {
|
||||
$item = $this->cache->getItem('health_check');
|
||||
$checks['redis'] = 'ok';
|
||||
} catch (\Throwable) {
|
||||
$checks['redis'] = 'error';
|
||||
$status = 'degraded';
|
||||
}
|
||||
|
||||
return new JsonResponse([
|
||||
'status' => $status,
|
||||
'checks' => $checks,
|
||||
'timestamp' => time(),
|
||||
], $status === 'ok' ? 200 : 503);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
namespace App\Shared\EventSubscriber;
|
||||
|
||||
use App\Shared\Exception\AppException;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
|
||||
use Symfony\Component\HttpKernel\KernelEvents;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
use Symfony\Component\Security\Core\Exception\AuthenticationException;
|
||||
|
||||
class ExceptionSubscriber implements EventSubscriberInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly LoggerInterface $logger,
|
||||
private readonly TokenStorageInterface $tokenStorage,
|
||||
) {}
|
||||
|
||||
public function onKernelException(ExceptionEvent $event): void
|
||||
{
|
||||
$exception = $event->getThrowable();
|
||||
|
||||
if ($exception instanceof AppException) {
|
||||
$err = ['code' => $exception->getErrorCode(), 'message' => $exception->getMessage()];
|
||||
if ($exception->getField()) {
|
||||
$err['field'] = $exception->getField();
|
||||
}
|
||||
$event->setResponse(new JsonResponse(
|
||||
['success' => false, 'data' => null, 'errors' => [$err]],
|
||||
$exception->getHttpStatus()
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
if ($exception instanceof TooManyRequestsHttpException) {
|
||||
$headers = [];
|
||||
$retryAfter = $exception->getHeaders()['Retry-After'] ?? null;
|
||||
if ($retryAfter !== null) {
|
||||
$headers['Retry-After'] = $retryAfter;
|
||||
}
|
||||
$event->setResponse(new JsonResponse(
|
||||
['success' => false, 'data' => null, 'errors' => [['code' => 'ERR_RATE_LIMIT_001', 'message' => 'درخواستهای زیاد. لطفاً بعداً تلاش کنید']]],
|
||||
429,
|
||||
$headers
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
if ($exception instanceof NotFoundHttpException) {
|
||||
$event->setResponse(new JsonResponse(
|
||||
['success' => false, 'data' => null, 'errors' => [['code' => 'ERR_NOT_FOUND_001', 'message' => 'منبع درخواستی یافت نشد']]],
|
||||
404
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
if ($exception instanceof AccessDeniedHttpException) {
|
||||
$event->setResponse(new JsonResponse(
|
||||
['success' => false, 'data' => null, 'errors' => [['code' => 'ERR_FORBIDDEN_001', 'message' => 'دسترسی به این منبع مجاز نیست']]],
|
||||
403
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
if ($exception instanceof UnauthorizedHttpException) {
|
||||
$event->setResponse(new JsonResponse(
|
||||
['success' => false, 'data' => null, 'errors' => [['code' => 'ERR_AUTH_001', 'message' => 'احراز هویت لازم است']]],
|
||||
401
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
// Security exceptions not yet wrapped into HttpException
|
||||
if ($exception instanceof AccessDeniedException) {
|
||||
$token = $this->tokenStorage->getToken();
|
||||
$isAuthenticated = $token !== null && $token->getUser() !== null;
|
||||
|
||||
if (!$isAuthenticated) {
|
||||
$event->setResponse(new JsonResponse(
|
||||
['success' => false, 'data' => null, 'errors' => [['code' => 'ERR_AUTH_001', 'message' => 'احراز هویت لازم است']]],
|
||||
401
|
||||
));
|
||||
} else {
|
||||
$event->setResponse(new JsonResponse(
|
||||
['success' => false, 'data' => null, 'errors' => [['code' => 'ERR_FORBIDDEN_001', 'message' => 'دسترسی به این منبع مجاز نیست']]],
|
||||
403
|
||||
));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if ($exception instanceof AuthenticationException) {
|
||||
$event->setResponse(new JsonResponse(
|
||||
['success' => false, 'data' => null, 'errors' => [['code' => 'ERR_AUTH_001', 'message' => 'احراز هویت لازم است']]],
|
||||
401
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
// Generic fallback: never leak stack traces or internal details in API responses
|
||||
$this->logger->error('Unhandled exception', [
|
||||
'exception' => $exception,
|
||||
'path' => $event->getRequest()->getPathInfo(),
|
||||
]);
|
||||
|
||||
$event->setResponse(new JsonResponse(
|
||||
['success' => false, 'data' => null, 'errors' => [['code' => 'ERR_INTERNAL_001', 'message' => 'خطای داخلی سرور']]],
|
||||
500
|
||||
));
|
||||
}
|
||||
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
return [KernelEvents::EXCEPTION => ['onKernelException', 10]];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Shared\EventSubscriber;
|
||||
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
use Symfony\Component\HttpKernel\Event\ResponseEvent;
|
||||
use Symfony\Component\HttpKernel\KernelEvents;
|
||||
|
||||
class SecurityHeadersSubscriber implements EventSubscriberInterface
|
||||
{
|
||||
public function onKernelResponse(ResponseEvent $event): void
|
||||
{
|
||||
if (!$event->isMainRequest()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$response = $event->getResponse();
|
||||
$response->headers->set('X-Content-Type-Options', 'nosniff');
|
||||
$response->headers->set('X-Frame-Options', 'DENY');
|
||||
$response->headers->set('X-XSS-Protection', '1; mode=block');
|
||||
$response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||
$response->headers->set('Permissions-Policy', 'geolocation=(), microphone=(), camera=()');
|
||||
|
||||
if ($event->getRequest()->isSecure()) {
|
||||
$response->headers->set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
|
||||
}
|
||||
|
||||
if (str_starts_with($event->getRequest()->getPathInfo(), '/api')) {
|
||||
$response->headers->set('Content-Security-Policy', "default-src 'none'");
|
||||
}
|
||||
}
|
||||
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
return [KernelEvents::RESPONSE => 'onKernelResponse'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Shared\Exception;
|
||||
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
|
||||
class AppException extends \RuntimeException
|
||||
{
|
||||
public function __construct(
|
||||
private readonly string $errorCode,
|
||||
?string $message = null,
|
||||
private readonly int $httpStatus = 400,
|
||||
private readonly ?string $field = null,
|
||||
) {
|
||||
parent::__construct($message ?? ErrorCodes::message($errorCode));
|
||||
}
|
||||
|
||||
public function getErrorCode(): string { return $this->errorCode; }
|
||||
public function getHttpStatus(): int { return $this->httpStatus; }
|
||||
public function getField(): ?string { return $this->field; }
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace App\Shared\Message;
|
||||
|
||||
class SendSmsMessage
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $mobile,
|
||||
public readonly string $message,
|
||||
public readonly ?int $smsLogId = null,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
namespace App\Shared\Service;
|
||||
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Exception\AppException;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
|
||||
class FileValidatorService
|
||||
{
|
||||
private const ALLOWED_SIGNATURES = [
|
||||
'image/jpeg' => ["\xFF\xD8\xFF"],
|
||||
'image/png' => ["\x89\x50\x4E\x47\x0D\x0A\x1A\x0A"],
|
||||
'image/webp' => ["RIFF"],
|
||||
];
|
||||
|
||||
private const ALLOWED_EXTENSIONS = ['jpg', 'jpeg', 'png', 'webp'];
|
||||
|
||||
public function __construct(private readonly int $maxSizeBytes = 5_242_880) {}
|
||||
|
||||
public function validateUploadedFile(UploadedFile $file): string
|
||||
{
|
||||
if ($file->getSize() > $this->maxSizeBytes) {
|
||||
throw new AppException(ErrorCodes::ERR_FILE_002, null, 422);
|
||||
}
|
||||
|
||||
$binaryContent = (string) file_get_contents($file->getPathname());
|
||||
return $this->validate($binaryContent, $file->getClientOriginalName());
|
||||
}
|
||||
|
||||
public function validate(string $binaryContent, string $claimedFilename): string
|
||||
{
|
||||
if (strlen($binaryContent) > $this->maxSizeBytes) {
|
||||
throw new AppException(ErrorCodes::ERR_FILE_002, null, 422);
|
||||
}
|
||||
|
||||
$detected = false;
|
||||
foreach (self::ALLOWED_SIGNATURES as $signatures) {
|
||||
foreach ($signatures as $sig) {
|
||||
if (str_starts_with($binaryContent, $sig)) {
|
||||
$detected = true;
|
||||
break 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!$detected) {
|
||||
throw new AppException(ErrorCodes::ERR_FILE_001, null, 422);
|
||||
}
|
||||
|
||||
return $this->sanitizeFilename($claimedFilename);
|
||||
}
|
||||
|
||||
public function sanitizeFilename(string $filename): string
|
||||
{
|
||||
$safeName = preg_replace('/[^a-zA-Z0-9._-]/', '', basename($filename));
|
||||
if (empty($safeName) || str_contains($safeName, '..')) {
|
||||
throw new AppException(ErrorCodes::ERR_FILE_001, null, 422);
|
||||
}
|
||||
|
||||
$ext = strtolower(pathinfo($safeName, PATHINFO_EXTENSION));
|
||||
if (!in_array($ext, self::ALLOWED_EXTENSIONS, true)) {
|
||||
throw new AppException(ErrorCodes::ERR_FILE_001, null, 422);
|
||||
}
|
||||
|
||||
return $safeName;
|
||||
}
|
||||
|
||||
public function detectMimeType(string $filePath): string
|
||||
{
|
||||
$handle = fopen($filePath, 'rb');
|
||||
$header = fread($handle, 12);
|
||||
fclose($handle);
|
||||
|
||||
foreach (self::ALLOWED_SIGNATURES as $mime => $signatures) {
|
||||
foreach ($signatures as $sig) {
|
||||
if (str_starts_with($header, $sig)) {
|
||||
return $mime;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new AppException(ErrorCodes::ERR_FILE_001, 'نوع فایل پشتیبانی نمیشود', 422);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user