From e850f6dfbdac9ae6a70f18980c5bcf5563daed96 Mon Sep 17 00:00:00 2001 From: AliReza Date: Mon, 5 Oct 2026 04:52:25 +0330 Subject: [PATCH] Payment return decided by the server only; encoded path ids; no sign-out on a two-tab refresh race - ?orderId must be numeric; every id in an API path is encodeURIComponent'd. - The payment sheet shows success only from GET /payment/orders/{id}; a failed lookup says the status is unknown instead of "paid". - A 401 from /auth/refresh is not a sign-out when another tab already stored a rotated token. Co-Authored-By: Claude Opus 5.5 --- src/api/http.ts | 8 +++++++- src/api/index.ts | 31 +++++++++++++++++-------------- src/app/MainApp.tsx | 30 ++++++++++++++++++++++++------ src/app/launchParams.ts | 3 ++- 4 files changed, 50 insertions(+), 22 deletions(-) diff --git a/src/api/http.ts b/src/api/http.ts index 2c4212a..3ecab50 100644 --- a/src/api/http.ts +++ b/src/api/http.ts @@ -202,7 +202,13 @@ export function createHttpClient(options: HttpClientOptions) { } catch { return new ApiError(0, 'ارتباط با سرور برقرار نشد'); } - if (response.status === 401) return 'invalid'; + if (response.status === 401) { + // تبِ دیگری همین توکن را زودتر تمدید کرده (سرور توکنِ قبلی را باطل می‌کند) و توکنِ تازه + // را در همان localStorageِ مشترک گذاشته؛ آن نشست سالم است و نباید هر دو تب خارج شوند. + const latest = storage.get(); + if (latest?.refreshToken && latest.refreshToken !== current.refreshToken) return latest; + return 'invalid'; + } if (!response.ok) return errorFrom(response, await parseBody(response)); const data = (await response.json()) as Tokens; const next = { token: data.token, refreshToken: data.refreshToken || current.refreshToken }; diff --git a/src/api/index.ts b/src/api/index.ts index d414b26..462b2e9 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -22,6 +22,9 @@ function saveTokens(data: T.AuthResponse) { return data; } +/** یک بخشِ مسیر؛ شناسه‌ای که از آدرسِ صفحه یا سرور می‌آید نباید با «/» یا «..» مسیرِ دیگری از API را صدا بزند. */ +const seg = (value: string | number) => encodeURIComponent(String(value)); + export const api = { auth: { isSignedIn: () => Boolean(http.storage.get()?.token), @@ -85,37 +88,37 @@ export const api = { /** بدونِ grade، پایه‌ی ذخیره‌شده‌ی خودِ کاربر ملاک است. */ home: () => http.get('/home'), - nodeLessons: (nodeId: string) => http.get(`/nodes/${nodeId}/lessons`), + nodeLessons: (nodeId: string) => http.get(`/nodes/${seg(nodeId)}/lessons`), /** * آزمونِ جزیره: از هر درسِ جزیره حداکثر یک سوال (پیش‌فرض ۴). ترتیب برای هر * کاربر ثابت است، پس رفرش همان آزمون را می‌دهد. */ - nodeQuiz: (nodeId: string) => http.get(`/nodes/${nodeId}/quiz`), + nodeQuiz: (nodeId: string) => http.get(`/nodes/${seg(nodeId)}/quiz`), /** پایانِ آزمون، جزیره را با هر نمره‌ای تمام می‌کند و جزیره‌ی بعدی را باز. */ nodeQuizComplete: (nodeId: string, result: { correctCount: number; totalQuestions: number }, idempotencyKey?: string) => - http.create(`/nodes/${nodeId}/quiz/complete`, result, { idempotencyKey }), + http.create(`/nodes/${seg(nodeId)}/quiz/complete`, result, { idempotencyKey }), lesson: { - intro: (lessonId: number) => http.get(`/lessons/${lessonId}/intro`), + intro: (lessonId: number) => http.get(`/lessons/${seg(lessonId)}/intro`), questions: (lessonId: number) => - http.get<{ questions: T.Question[] }>(`/lessons/${lessonId}/questions`).then((r) => r.questions), + http.get<{ questions: T.Question[] }>(`/lessons/${seg(lessonId)}/questions`).then((r) => r.questions), /** * کلیدِ یکتا را یک‌بار برای هر جواب بسازید و در تکرار همان را بفرستید تا * امتیاز و قلب دوبار حساب نشود. */ answer: (questionId: string, selectedOptionId: string, idempotencyKey = newIdempotencyKey()) => http.post( - `/questions/${questionId}/answer`, + `/questions/${seg(questionId)}/answer`, { selectedOptionId: Number(selectedOptionId) }, { idempotencyKey, retry: true }, ), /** مجموعِ XP و سکه‌ی کاربر اینجا به‌روز می‌شود، نه سرِ هر جواب. */ complete: (lessonId: number, result: { correctCount: number; totalQuestions: number }, idempotencyKey?: string) => - http.create(`/lessons/${lessonId}/complete`, result, { idempotencyKey }), + http.create(`/lessons/${seg(lessonId)}/complete`, result, { idempotencyKey }), report: (questionId: string, reason: T.ReportReason, note?: string) => http.post<{ success: boolean; reportId: number; alreadyReported: boolean }>( - `/questions/${questionId}/report`, + `/questions/${seg(questionId)}/report`, { reason, ...(note ? { note } : {}) }, ), }, @@ -157,19 +160,19 @@ export const api = { return done; }, feedback: (messageId: string, isHelpful: boolean, reason?: string) => - http.post<{ success: boolean }>(`/ai/messages/${messageId}/feedback`, { + http.post<{ success: boolean }>(`/ai/messages/${seg(messageId)}/feedback`, { isHelpful, ...(reason ? { reason } : {}), }), history: (conversationId: string) => http.get<{ conversationId: string; lessonId: string | null; messages: T.ConversationMessage[] }>( - `/ai/conversations/${conversationId}/messages`, + `/ai/conversations/${seg(conversationId)}/messages`, ), }, notifications: { list: () => http.get<{ items: T.NotificationItem[]; unreadCount: number }>('/notifications'), - markRead: (id: number) => http.patch(`/notifications/${id}/read`), + markRead: (id: number) => http.patch(`/notifications/${seg(id)}/read`), markAllRead: () => http.patch('/notifications/read-all'), }, @@ -190,7 +193,7 @@ export const api = { /** کدِ تخفیف باید پیش از رفتن به درگاه ثبت شود؛ هر سفارش فقط یک کد. */ redeem: (code: string, orderId: string) => http.post('/discount/redeem', { code, orderId }), - order: (orderId: string) => http.get(`/payment/orders/${orderId}`), + order: (orderId: string) => http.get(`/payment/orders/${seg(orderId)}`), /** پرداخت‌ها و فاکتورهای همین کاربر، تازه‌ترین اول. */ invoices: () => http.get<{ invoices: T.Invoice[] }>('/payment/invoices').then((r) => r.invoices), }, @@ -200,9 +203,9 @@ export const api = { http.create<{ ticketId: string; status: string }>('/support/tickets', { subject, category }), /** تیکت‌های همین کاربر، تازه‌ترین اول (۲۰ تای آخر). */ tickets: () => http.get<{ tickets: T.TicketSummary[] }>('/support/tickets').then((r) => r.tickets), - ticket: (ticketId: string) => http.get(`/support/tickets/${ticketId}`), + ticket: (ticketId: string) => http.get(`/support/tickets/${seg(ticketId)}`), reply: (ticketId: string, text: string) => - http.create<{ messageId: string; success: boolean }>(`/support/tickets/${ticketId}/messages`, { text }), + http.create<{ messageId: string; success: boolean }>(`/support/tickets/${seg(ticketId)}/messages`, { text }), bugReport: (title: string, description: string) => http.create<{ bugReportId: string; status: string }>('/support/bug-reports', { title, description }), }, diff --git a/src/app/MainApp.tsx b/src/app/MainApp.tsx index 3ded5d2..60ce497 100644 --- a/src/app/MainApp.tsx +++ b/src/app/MainApp.tsx @@ -144,29 +144,47 @@ function RegisterView({ onRegistered }: Pick) { ); } -/** نتیجه‌ی برگشت از درگاه؛ وضعیتِ سفارش از خودِ سرور هم دوباره خوانده می‌شود. */ +/** + * نتیجه‌ی برگشت از درگاه. فقط پاسخِ سرور تعیین می‌کند پرداخت موفق بوده یا نه؛ `status` در آدرس را هر + * لینکِ ساختگی می‌تواند «paid» بگذارد. + */ function PaymentResult({ payment }: { payment: PaymentReturn }) { const { refreshSummary } = useAppData(); const [open, setOpen] = useState(true); - const [paid, setPaid] = useState(payment.status === 'paid'); + const [state, setState] = useState<'checking' | 'paid' | 'failed' | 'unknown'>('checking'); useEffect(() => { api.subscription .order(payment.orderId) - .then((order) => setPaid(order.status === 'paid')) - .catch(() => {}); + .then((order) => setState(order.status === 'paid' ? 'paid' : 'failed')) + .catch((err: unknown) => { + console.warn('payment result lookup failed', err); + setState('unknown'); + }); void refreshSummary(); }, [payment.orderId, refreshSummary]); + if (state === 'checking') { + return ( + setOpen(false)}> + + + ); + } + const paid = state === 'paid'; return ( setOpen(false)}>
{paid ? : } -

{paid ? 'پرداخت موفق بود! 🎉' : 'پرداخت انجام نشد'}

+

+ {paid ? 'پرداخت موفق بود! 🎉' : state === 'unknown' ? 'وضعیتِ پرداخت معلوم نشد' : 'پرداخت انجام نشد'} +

{paid ? 'اشتراکت فعال شد؛ از الان قلب‌هات نامحدوده.' - : 'اگه مبلغی از حسابت کم شده، نگران نباش؛ طیِ چند ساعت خودکار برمی‌گرده یا اشتراکت فعال می‌شه.'} + : state === 'unknown' + ? 'چند دقیقه‌ی دیگه از «پرداخت‌ها و فاکتورها» در پروفایل نگاه کن. اگه مبلغی کم شده، یا خریدت فعال می‌شه یا پول خودکار برمی‌گرده.' + : 'اگه مبلغی از حسابت کم شده، نگران نباش؛ طیِ چند ساعت خودکار برمی‌گرده یا اشتراکت فعال می‌شه.'}

setOpen(false)}>باشه
diff --git a/src/app/launchParams.ts b/src/app/launchParams.ts index e98a11c..63f2c10 100644 --- a/src/app/launchParams.ts +++ b/src/app/launchParams.ts @@ -22,7 +22,8 @@ function readLaunchParams(): PaymentReturn | null { if (ref || orderId) { window.history.replaceState(null, '', window.location.pathname); } - return orderId ? { orderId, status: status ?? '' } : null; + // فقط شماره‌ی سفارش: هر چیزِ دیگری (مثلاً «../../user/delete») از یک لینکِ ساختگی آمده است. + return orderId && /^\d{1,12}$/.test(orderId) ? { orderId, status: status ?? '' } : null; } /** یک‌بار، هنگامِ بار شدنِ اپ. */