- Implemented ClinicInvitationWebController to manage the invitation process via web. - Added view and respond methods to handle invitation display and responses. - Created result.html.twig and view.html.twig templates for rendering invitation results and views. - Integrated CSRF protection for form submissions. - Established routes for invitation viewing and responding.
17 KiB
صفحه Twig دعوت پزشک + کوتاهکردن URL پیامک
زمینه
پیامک دعوت پزشک به کلینیک یک لینک بلند دارد که برای SMS نامناسب است و باعث خطای 431 Request Header Fields Too Large سمت کاوهنگار هم شده. نمونه پیامک فعلی:
دکتر گرامی، کلینیک {clinic} شما را برای همکاری دعوت کرده است.
برای بررسی: https://clinic-pro.ir/clinic-invitation/fd18ae2ed174db3e79271c27ae03e0317ef81c8989836a9cfeea4933999f4dff56d795ba0420a05f1c04eb6a4602603b
این لینک تا ۷۲ ساعت معتبر است.
مشکل دوم: مسیر https://clinic-pro.ir/clinic-invitation/{token} در clinicpro هیچ route وبی ندارد — فقط نسخهٔ /api/v1/clinic-invitation/{token} (JSON) وجود دارد. پس وقتی پزشک روی لینک پیامک میزند، صفحهای برای رد/تایید نمیبیند. باید یک صفحهٔ HTML با Twig ساخته شود که پزشک بتواند دعوت را «تایید» یا «رد» کند و بعد از تایید پیام «درخواست شما تایید شد و میتوانید وارد پنل ادمین شوید» نمایش داده شود.
نکته: توکن در تسک قبلی از
random_bytes(48)بهrandom_bytes(16)(۳۲ کاراکتر hex) کوتاه شد؛ این تسک آن را کوتاهتر میکند و مصرفکننده (صفحه Twig) را میسازد.
مشکل / هدف
۱. کوتاهکردن توکن و لینک تا پیامک کوتاه و بدون خطای 431 باشد.
۲. ساخت صفحه Twig عمومی روی مسیر بدونِ /api که وضعیت دعوت را نشان میدهد و دو دکمهٔ تایید/رد دارد.
۳. صفحهٔ نتیجه: بعد از تایید → پیام موفقیت + لینک ورود به پنل ادمین؛ بعد از رد → پیام رد؛ برای توکن منقضی/نامعتبر/استفادهشده → پیام مناسب (نه خطای ۵۰۰).
فایلهای مرتبط
| فایل | نقش |
|---|---|
src/ClinicInvitation/Entity/ClinicDoctorInvitation.php |
تولید توکن (خط ۷۷ و ۹۷)؛ ستون token خط ۵۴؛ isUsable()، getStatus()، STATUS_* |
src/ClinicInvitation/Service/ClinicInvitationService.php |
sendSms() خط ۱۱۳ (ساخت لینک)؛ accept()، reject() |
src/ClinicInvitation/Controller/ClinicInvitationController.php |
endpointهای JSON فعلی (/api/v1/clinic-invitation/{token} + accept/reject) — دستنخورده میمانند |
src/ClinicInvitation/Repository/ClinicDoctorInvitationRepository.php |
findByToken() خط ۱۷ |
templates/payment/result.html.twig |
الگوی استایل صفحهٔ نتیجه (Vazirmatn، RTL، noindex، متغیرهای رنگ) — از این کپی کن |
templates/base.html.twig |
لِیاوت پایه |
config/packages/security.yaml |
firewall اصلی فقط `^/(api |
docs/api/clinic-invitation.md |
باید routeهای وب جدید مستند شوند |
وضعیت فعلی
توکن (خط ۷۷ و ۹۷ در Entity):
$this->token = bin2hex(random_bytes(16)); // ۳۲ کاراکتر
ساخت لینک پیامک (ClinicInvitationService.php:113):
$link = rtrim($this->appUrl, '/') . '/clinic-invitation/' . $inv->getToken();
سرویس accept (ClinicInvitationService.php):
public function accept(ClinicDoctorInvitation $inv): void
{
if (!$inv->isUsable()) {
throw new AppException('ERR_NOT_FOUND_001', 'دعوتنامه منقضی یا غیرمعتبر است', 410);
}
$inv->setStatus(ClinicDoctorInvitation::STATUS_ACCEPTED);
$inv->markUsed();
$doctor = $inv->getDoctor();
if ($doctor === null) {
$doctor = $this->doctorRepo->findOneByMobile($inv->getMobile());
if ($doctor !== null) { $inv->setDoctor($doctor); }
}
if ($doctor !== null) {
$clinic = $inv->getClinic();
if (!$clinic->getDoctors()->contains($doctor)) {
$clinic->getDoctors()->add($doctor);
}
}
$this->em->flush();
}
مسیرهای JSON عمومی موجود (در ClinicInvitationController) — حذف نشوند (کلاینت React/اپ از آنها استفاده میکند):
#[Route('/api/v1/clinic-invitation/{token}', methods: ['GET'])] // viewInvitation
#[Route('/api/v1/clinic-invitation/{token}/accept', methods: ['POST'])] // acceptInvitation
#[Route('/api/v1/clinic-invitation/{token}/reject', methods: ['POST'])] // rejectInvitation
الگوی render در پروژه (PaymentController):
return $this->render('payment/redirect.html.twig', ['action' => $action, 'params' => $params]);
وظایف
۱. کوتاهکردن توکن دعوت
در ClinicDoctorInvitation.php خط ۷۷ (constructor) و ۹۷ (refresh()) توکن را کوتاهتر کن. یک توکن تکمصرفِ ۷۲ ساعته نیازی به ۳۲ کاراکتر ندارد — ۱۲ کاراکتر hex (۴۸ بیت آنتروپی) کافی و امن است:
$this->token = bin2hex(random_bytes(6)); // ۱۲ کاراکتر
- ستون
tokenرویstringاست (خط ۵۴، unique indexidx_cdi_token)؛ کوتاهتر شدن مقدار migration لازم ندارد. - چون index یکتاست، احتمال برخورد در ۱۲ کاراکتر عملاً صفر است؛ ولی برای اطمینان، اگر جای دیگری توکن با تضمین یکتایی ساخته میشود همان الگو را نگهدار. (اگر میخواهی strict باشی: در سرویسِ سازندهٔ دعوت، در صورت
UniqueConstraintViolationیک بار دیگر توکن بساز.)
نتیجه: لینک از
.../clinic-invitation/<۳۲>به.../clinic-invitation/<۱۲>میرسد؛ اگر route کوتاه/i/{token}را هم اضافه کنی (وظیفهٔ ۲، اختیاری) لینک بهhttps://clinic-pro.ir/i/<۱۲>(~۳۲ کاراکتر) میرسد.
۲. صفحهٔ Twig دعوت (نمایش + تایید/رد)
یک کنترلر وبِ جدید بساز: src/ClinicInvitation/Controller/ClinicInvitationWebController.php که از BaseController ارث میبرد (پس render() در دسترس است). مسیرها بدون پیشوند /api تا خارج از firewall JWT و عمومی بمانند (مثل صفحهٔ home و صفحات Twig پرداخت):
namespace App\ClinicInvitation\Controller;
use App\ClinicInvitation\Repository\ClinicDoctorInvitationRepository;
use App\ClinicInvitation\Service\ClinicInvitationService;
use App\ClinicInvitation\Entity\ClinicDoctorInvitation;
use App\Shared\Controller\BaseController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class ClinicInvitationWebController extends BaseController
{
public function __construct(
private readonly ClinicDoctorInvitationRepository $invRepo,
private readonly ClinicInvitationService $invitationService,
) {}
// صفحهٔ دعوت — لینک پیامک اینجا باز میشود (GET، بدون auth)
#[Route('/clinic-invitation/{token}', methods: ['GET'], name: 'invitation_web_view')]
public function view(string $token): Response
{
$inv = $this->invRepo->findByToken($token);
if ($inv === null) {
return $this->render('invitation/result.html.twig', ['state' => 'notfound'])
->setStatusCode(404);
}
// اگر قبلاً پاسخ داده شده یا منقضی است، مستقیم صفحهٔ وضعیت را نشان بده
if (!$inv->isUsable()) {
return $this->render('invitation/result.html.twig', [
'state' => $inv->getStatus() === ClinicDoctorInvitation::STATUS_ACCEPTED ? 'accepted'
: ($inv->getStatus() === ClinicDoctorInvitation::STATUS_REJECTED ? 'rejected' : 'expired'),
'admin_url' => $this->adminUrl(),
]);
}
return $this->render('invitation/view.html.twig', [
'token' => $token,
'clinic' => $inv->getClinic()->getName() ?: 'کلینیک',
'doctor' => $inv->getInvitedName(),
'expires_at' => $inv->getExpiresAt(),
]);
}
// تایید/رد — فقط POST تا لینکِ GET (پیشفچ مرورگر/ربات) بهطور ناخواسته accept نکند
#[Route('/clinic-invitation/{token}/respond', methods: ['POST'], name: 'invitation_web_respond')]
public function respond(string $token, Request $request): Response
{
$inv = $this->invRepo->findByToken($token);
if ($inv === null) {
return $this->render('invitation/result.html.twig', ['state' => 'notfound'])->setStatusCode(404);
}
// CSRF: توکن در فرمِ صفحهٔ view رندر میشود
$action = (string) $request->request->get('action', '');
if (!$this->isCsrfTokenValid('invitation_' . $token, (string) $request->request->get('_token'))) {
return $this->render('invitation/result.html.twig', ['state' => 'expired'])->setStatusCode(403);
}
try {
if ($action === 'accept') {
$this->invitationService->accept($inv);
return $this->render('invitation/result.html.twig', [
'state' => 'accepted',
'admin_url' => $this->adminUrl(),
]);
}
if ($action === 'reject') {
$this->invitationService->reject($inv);
return $this->render('invitation/result.html.twig', ['state' => 'rejected']);
}
} catch (\App\Shared\Exception\AppException $e) {
return $this->render('invitation/result.html.twig', ['state' => 'expired']);
}
return $this->render('invitation/result.html.twig', ['state' => 'expired'])->setStatusCode(422);
}
private function adminUrl(): string
{
return rtrim($_ENV['APP_BASE_URL'] ?? '', '/') . '/admin';
}
}
دربارهٔ route کوتاه (اختیاری ولی توصیهشده): برای کوتاهترین لینک ممکن، همین متد
viewرا با یک alias کوتاه هم expose کن:#[Route('/i/{token}', methods: ['GET'])]و درsendSms()از/i/استفاده کن. اگر این کار را کردی، مطمئن شو/i/{token}با route دیگری تداخل ندارد (ddev exec php bin/console debug:router | grep '/i/').
بهروزرسانی لینک پیامک در ClinicInvitationService::sendSms() (خط ۱۱۳) — اگر route کوتاه اضافه کردی:
$link = rtrim($this->appUrl, '/') . '/i/' . $inv->getToken();
اگر route کوتاه اضافه نکردی، این خط بدون تغییر میماند (/clinic-invitation/).
۳. تمپلیتهای Twig
دو فایل بساز. استایل را از templates/payment/result.html.twig کپی کن (فونت Vazirmatn، dir="rtl"، <meta name="robots" content="noindex,nofollow">، متغیرهای رنگ --ok/--err/--primary، کارت وسطچین). فونت را از همان CDN فعلی بگیر.
templates/invitation/view.html.twig — صفحهٔ دعوت با دو دکمه:
- عنوان: «دعوت به همکاری»
- متن: «کلینیک {{ clinic }} شما را برای همکاری دعوت کرده است.» (و اگر
doctorمقدار دارد، «{{ doctor }} عزیز،» بالای آن) - اعتبار: «این دعوت تا {{ expires_at }} معتبر است» (تاریخ را با
dateفیلترِ Twig یا متن ثابت «۷۲ ساعت» نشان بده؛ اگر شمسی خواستی از یک فیلتر موجود استفاده کن، وگرنه متن ثابت کافی است) - دو فرمِ POST جدا (یا یک فرم با دو دکمهٔ
name="action"):
<form method="post" action="{{ path('invitation_web_respond', {token: token}) }}">
<input type="hidden" name="_token" value="{{ csrf_token('invitation_' ~ token) }}">
<button type="submit" name="action" value="accept" class="btn btn-ok">تایید و پذیرش دعوت</button>
<button type="submit" name="action" value="reject" class="btn btn-err">رد دعوت</button>
</form>
templates/invitation/result.html.twig — بر اساس متغیر state:
state |
پیام | جزئیات |
|---|---|---|
accepted |
درخواست شما تایید شد و میتوانید وارد پنل ادمین شوید | دکمهٔ «ورود به پنل ادمین» با href="{{ admin_url }}" |
rejected |
«دعوت رد شد.» | بدون دکمه |
expired |
«این دعوت منقضی شده یا معتبر نیست.» | بدون دکمه |
notfound |
«دعوتنامه یافت نشد.» | بدون دکمه |
مثال بلوک:
{% if state == 'accepted' %}
<div class="icon ok">✓</div>
<h1>درخواست شما تایید شد</h1>
<p>میتوانید وارد پنل ادمین شوید.</p>
<a class="btn btn-primary" href="{{ admin_url }}">ورود به پنل ادمین</a>
{% elseif state == 'rejected' %}
...
{% endif %}
۴. مستندسازی
docs/api/clinic-invitation.md را بهروز کن: بخش جدیدی برای صفحات وب (HTML) اضافه کن:
GET /clinic-invitation/{token}(و در صورت افزودن،GET /i/{token}) — صفحهٔ HTML دعوت، عمومی، بدون JWT.POST /clinic-invitation/{token}/respond— بدنهٔaction=accept|reject+_token(CSRF)؛ خروجی HTML صفحهٔ نتیجه.- ذکر کن endpointهای JSON قبلی (
/api/v1/clinic-invitation/...) دستنخورده باقی ماندهاند و برای کلاینت React/اپاند؛ صفحات وب جدید مخصوص گیرندهٔ پیامک (مرورگر) هستند.
نکات مهم
- عمومی بودن مسیر: firewall اصلی فقط
^/(api|oauth|file/upload)/است؛ چون route جدید زیر/apiنیست، مثل صفحهٔhomeو صفحات Twig پرداخت بهصورت عمومی سِرو میشود. بعد از افزودن، با یکcurlبدون توکن تست کن که ۲۰۰ برمیگردد نه ۴۰۱/�302. اگر به هر دلیل firewall آن را گرفت، الگویpaymentرا درsecurity.yamlدنبال کن. - accept فقط با POST: هرگز روی GET، accept/reject انجام نده — پیشفچ مرورگر یا اسکنر ربات لینک پیامک (GET) نباید دعوت را تغییر دهد. GET فقط نمایش است.
- CSRF: توکن CSRF در صفحهٔ
viewرندر و درrespondاعتبارسنجی شود (csrf_token()/isCsrfTokenValid()). چون فرم پس از باز شدن صفحه ارسال میشود، این کار امکانپذیر است. - edge — پزشک بدون حساب:
accept()پزشک را با موبایل پیدا میکند؛ اگر پزشکی با آن موبایل ثبت نشده باشد، دعوتacceptedمیشود ولی به کلینیک لینک نمیشود و کاربر حسابی برای ورود ندارد. پیام «میتوانید وارد پنل ادمین شوید» برای این حالت گمراهکننده است — میتوانی درresult.html.twigوقتیadmin_urlهست ولی حساب نیست، جمله را نرم کنی (مثلاً «در صورت داشتن حساب میتوانید وارد شوید»). حداقل این edge را در نظر بگیر؛ رفتار پیشفرض همان متن ثابت خواستهٔ کاربر است. - حالتهای توکن: منقضی/استفادهشده/نامعتبر همه باید صفحهٔ HTML مؤدبانه بدهند، نه ۵۰۰ یا JSON خام.
- بدون کتابخانهٔ CSS جدید: استایل inline در تمپلیت مثل
payment/result.html.twig. - تست دستی:
ddev exec php bin/console debug:router | grep clinic-invitation # یک token معتبر از دیتابیس بردار و در مرورگر/curl باز کن curl -sk -o /dev/null -w "%{http_code}\n" https://clinic-pro.ddev.site/clinic-invitation/<token>