PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.13
Yatra – Travel Booking & Tour Operator Software v3.0.13
3.0.16 3.0.15 3.0.14 3.0.14.1 3.0.14.2 3.0.12 3.0.13 3.0.11 3.0.10 3.0.9 3.0.8 3.0.7 3.0.6 3.0.5 3.0.5.1 3.0.4 3.0.3 3.0.2.9 3.0.2.7 3.0.2.8 3.0.2.6 trunk 1.0.0 2.0.0 2.0.1 All 84 releases
yatra / resources / js / pages / account / utils / downloads.ts

downloads.ts in Yatra – Travel Booking & Tour Operator Software 3.0.13, at resources/js/pages/account/utils/downloads.ts

533 lines 15.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 import { __ } from "../../../lib/i18n";
2
3 /**
4 * Account PDFs use Yatra REST routes with cookie auth + X-WP-Nonce.
5 * Plain-permalink sites use index.php?rest_route=/yatra/v1 — query params must
6 * stay on the URL, not inside rest_route.
7 */
8
9 type RestConfig = { base: string; nonce: string };
10
11 function getAccountRestConfig(): RestConfig {
12 if (typeof window === "undefined") {
13 return { base: "/wp-json/yatra/v1", nonce: "" };
14 }
15 const w = window as unknown as {
16 yatraAccountPage?: { apiUrl?: string; nonce?: string };
17 yatraAdmin?: { apiUrl?: string; nonce?: string };
18 };
19 const raw =
20 w.yatraAccountPage?.apiUrl || w.yatraAdmin?.apiUrl || "/wp-json/yatra/v1";
21 const base = String(raw).replace(/\/$/, "");
22 const nonce = w.yatraAccountPage?.nonce || w.yatraAdmin?.nonce || "";
23 return { base, nonce };
24 }
25
26 function parseYatraRestSubpath(href: string): string {
27 const origin =
28 typeof window !== "undefined" ? window.location.origin : "http://localhost";
29 try {
30 const u = new URL(href, origin);
31 const rr = u.searchParams.get("rest_route");
32 if (rr) {
33 const decoded = decodeURIComponent(rr.replace(/\+/g, " "));
34 const idx = decoded.indexOf("/yatra/v1");
35 if (idx !== -1) {
36 return decoded.slice(idx + "/yatra/v1".length) || decoded;
37 }
38 return decoded;
39 }
40 const path = u.pathname || "";
41 const marker = "/yatra/v1";
42 const pos = path.indexOf(marker);
43 if (pos !== -1) {
44 return path.slice(pos + marker.length);
45 }
46 } catch {
47 /* ignore */
48 }
49 return "";
50 }
51
52 function parsePaymentIdFromHref(href: string): number | null {
53 const sub = parseYatraRestSubpath(href);
54 const m = sub.match(/^\/payment\/(\d+)\/invoice(?:\/?|$)/i);
55 return m ? parseInt(m[1], 10) : null;
56 }
57
58 function parseBookingDocFromHref(
59 href: string,
60 kind: "voucher" | "itinerary",
61 ): number | null {
62 const sub = parseYatraRestSubpath(href);
63 const re = new RegExp(`^/bookings/(\\d+)/${kind}(?:/?|$)`, "i");
64 const m = sub.match(re);
65 return m ? parseInt(m[1], 10) : null;
66 }
67
68 /**
69 * Build GET URL for booking PDF endpoints.
70 */
71 function buildBookingDocumentUrl(
72 baseRaw: string,
73 bookingId: number,
74 kind: "voucher" | "itinerary",
75 mode: "download" | "preview" = "download",
76 ): string {
77 const suffix = `/bookings/${bookingId}/${kind}`;
78 const origin =
79 typeof window !== "undefined" ? window.location.origin : "http://localhost";
80
81 let u: URL;
82 try {
83 u = new URL(String(baseRaw).trim(), origin);
84 } catch {
85 const b = String(baseRaw).replace(/\/$/, "");
86 const q = mode === "download" ? "?download=1" : "?preview=1";
87 return `${b}${suffix}${q}`;
88 }
89
90 if (u.searchParams.has("rest_route")) {
91 const route = (u.searchParams.get("rest_route") || "").replace(/\/$/, "");
92 u.searchParams.set("rest_route", `${route}${suffix}`);
93 if (mode === "download") {
94 u.searchParams.set("download", "1");
95 u.searchParams.delete("preview");
96 } else {
97 u.searchParams.set("preview", "1");
98 u.searchParams.delete("download");
99 }
100 return u.toString();
101 }
102
103 u.pathname = (u.pathname || "").replace(/\/$/, "") + suffix;
104 if (mode === "download") {
105 u.searchParams.set("download", "1");
106 u.searchParams.delete("preview");
107 } else {
108 u.searchParams.set("preview", "1");
109 u.searchParams.delete("download");
110 }
111 return u.toString();
112 }
113
114 function buildPaymentInvoiceUrl(
115 baseRaw: string,
116 paymentId: number,
117 mode: "download" | "preview",
118 ): string {
119 const suffix = `/payment/${paymentId}/invoice`;
120 const origin =
121 typeof window !== "undefined" ? window.location.origin : "http://localhost";
122
123 let u: URL;
124 try {
125 u = new URL(String(baseRaw).trim(), origin);
126 } catch {
127 const b = String(baseRaw).replace(/\/$/, "");
128 const q = mode === "download" ? "?download=1" : "?preview=1";
129 return `${b}${suffix}${q}`;
130 }
131
132 if (u.searchParams.has("rest_route")) {
133 const route = (u.searchParams.get("rest_route") || "").replace(/\/$/, "");
134 u.searchParams.set("rest_route", `${route}${suffix}`);
135 if (mode === "download") {
136 u.searchParams.set("download", "1");
137 u.searchParams.delete("preview");
138 } else {
139 u.searchParams.set("preview", "1");
140 u.searchParams.delete("download");
141 }
142 return u.toString();
143 }
144
145 u.pathname = (u.pathname || "").replace(/\/$/, "") + suffix;
146 if (mode === "download") {
147 u.searchParams.set("download", "1");
148 u.searchParams.delete("preview");
149 } else {
150 u.searchParams.set("preview", "1");
151 u.searchParams.delete("download");
152 }
153 return u.toString();
154 }
155
156 async function readFetchErrorMessage(res: Response): Promise<string> {
157 let msg = res.statusText || __("Request failed", "yatra");
158 try {
159 const j = await res.json();
160 if (j?.message) {
161 msg = typeof j.message === "string" ? j.message : msg;
162 }
163 } catch {
164 const t = await res.text().catch(() => "");
165 if (t && t.length < 200) {
166 msg = t;
167 }
168 }
169 return msg;
170 }
171
172 async function fetchPreviewPdf(url: string): Promise<Blob> {
173 const { nonce } = getAccountRestConfig();
174 if (!nonce) {
175 throw new Error(
176 __(
177 "Missing security token. Reload the account page and try again.",
178 "yatra",
179 ),
180 );
181 }
182 const res = await fetch(url, {
183 method: "GET",
184 credentials: "include",
185 headers: {
186 "X-WP-Nonce": nonce,
187 Accept: "application/json",
188 },
189 });
190 if (!res.ok) {
191 throw new Error(await readFetchErrorMessage(res));
192 }
193 const data = (await res.json()) as {
194 pdf_data?: string;
195 filename?: string;
196 };
197 if (!data?.pdf_data || typeof data.pdf_data !== "string") {
198 throw new Error(__("Invalid preview response from the server.", "yatra"));
199 }
200 const binary = atob(data.pdf_data);
201 const bytes = new Uint8Array(binary.length);
202 for (let i = 0; i < binary.length; i++) {
203 bytes[i] = binary.charCodeAt(i);
204 }
205 return new Blob([bytes], { type: "application/pdf" });
206 }
207
208 /**
209 * Open a blob in a new tab while keeping the browser's user-gesture
210 * heuristic happy.
211 *
212 * Browsers gate `window.open` on the call originating from a *sync*
213 * user gesture. The preview flows here go user-click → `await fetch(…)`
214 * → `window.open(blobUrl)`; the await breaks the gesture chain, so
215 * Chrome/Safari block the popup and the user gets a noisy alert.
216 *
217 * Two-step pattern instead:
218 * 1. caller opens an `about:blank` window SYNCHRONOUSLY inside the
219 * click handler — that pre-opened window IS attributed to the
220 * gesture, so no popup-blocker dance.
221 * 2. once the fetch resolves, we navigate that same window to the
222 * blob URL. If the pre-opened window was already blocked (rare —
223 * means the site is restricted entirely), we silently fall back
224 * to a download anchor so the user still gets the PDF.
225 */
226 function openPdfInPreOpenedWindow(
227 blob: Blob,
228 preOpened: Window | null,
229 filename: string,
230 ): void {
231 const objectUrl = URL.createObjectURL(blob);
232
233 if (preOpened && !preOpened.closed) {
234 try {
235 preOpened.location.href = objectUrl;
236 window.setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000);
237 return;
238 } catch {
239 // Cross-origin / sandboxed navigation refused — close the empty
240 // window and fall through to the anchor fallback below.
241 try {
242 preOpened.close();
243 } catch {
244 /* ignore */
245 }
246 }
247 }
248
249 // Fallback: open in a new tab via an anchor click. Crucially we DO
250 // NOT set `link.download` here — this is the PREVIEW path; using
251 // `download` would force the browser to save the file, which is
252 // exactly the bug we're trying to fix ("Preview button does the
253 // same as Download"). target="_blank" + no download attribute
254 // makes the browser open the PDF in its built-in viewer.
255 const link = document.createElement("a");
256 link.href = objectUrl;
257 link.target = "_blank";
258 link.rel = "noopener noreferrer";
259 document.body.appendChild(link);
260 link.click();
261 document.body.removeChild(link);
262 window.setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000);
263 void filename; // filename only matters for the download path
264 }
265
266 async function downloadBookingBinary(
267 bookingId: number,
268 kind: "voucher" | "itinerary",
269 ): Promise<void> {
270 const { base, nonce } = getAccountRestConfig();
271 if (!nonce) {
272 throw new Error(
273 __(
274 "Missing security token. Reload the account page and try again.",
275 "yatra",
276 ),
277 );
278 }
279 const url = buildBookingDocumentUrl(base, bookingId, kind, "download");
280 const res = await fetch(url, {
281 method: "GET",
282 credentials: "include",
283 headers: {
284 "X-WP-Nonce": nonce,
285 Accept: "application/pdf, application/octet-stream, */*",
286 },
287 });
288 if (!res.ok) {
289 throw new Error(await readFetchErrorMessage(res));
290 }
291 const blob = await res.blob();
292 const dispo = res.headers.get("Content-Disposition");
293 let filename = `${kind}-${bookingId}.pdf`;
294 if (dispo) {
295 const m = /filename\*?=(?:UTF-8'')?["']?([^";\n]+)/i.exec(dispo);
296 if (m?.[1]) {
297 try {
298 filename = decodeURIComponent(m[1].replace(/['"]/g, "").trim());
299 } catch {
300 filename = m[1].replace(/['"]/g, "").trim();
301 }
302 }
303 }
304 const objectUrl = URL.createObjectURL(blob);
305 const link = document.createElement("a");
306 link.href = objectUrl;
307 link.download = filename;
308 document.body.appendChild(link);
309 link.click();
310 document.body.removeChild(link);
311 URL.revokeObjectURL(objectUrl);
312 }
313
314 async function downloadPaymentInvoiceBinary(paymentId: number): Promise<void> {
315 const { base, nonce } = getAccountRestConfig();
316 if (!nonce) {
317 throw new Error(
318 __(
319 "Missing security token. Reload the account page and try again.",
320 "yatra",
321 ),
322 );
323 }
324 const url = buildPaymentInvoiceUrl(base, paymentId, "download");
325 const res = await fetch(url, {
326 method: "GET",
327 credentials: "include",
328 headers: {
329 "X-WP-Nonce": nonce,
330 Accept: "application/pdf, application/octet-stream, */*",
331 },
332 });
333 if (!res.ok) {
334 throw new Error(await readFetchErrorMessage(res));
335 }
336 const blob = await res.blob();
337 const dispo = res.headers.get("Content-Disposition");
338 let filename = `invoice-${paymentId}.pdf`;
339 if (dispo) {
340 const m = /filename\*?=(?:UTF-8'')?["']?([^";\n]+)/i.exec(dispo);
341 if (m?.[1]) {
342 try {
343 filename = decodeURIComponent(m[1].replace(/['"]/g, "").trim());
344 } catch {
345 filename = m[1].replace(/['"]/g, "").trim();
346 }
347 }
348 }
349 const objectUrl = URL.createObjectURL(blob);
350 const link = document.createElement("a");
351 link.href = objectUrl;
352 link.download = filename;
353 document.body.appendChild(link);
354 link.click();
355 document.body.removeChild(link);
356 URL.revokeObjectURL(objectUrl);
357 }
358
359 export interface DocumentDownloadOptions {
360 bookingId?: number;
361 paymentId?: number;
362 documentType: "voucher" | "invoice" | "itinerary" | "all" | "downloads";
363 fallbackUrl?: string;
364 }
365
366 export const downloadDocument = async (
367 options: DocumentDownloadOptions,
368 ): Promise<void> => {
369 if (options.documentType === "downloads" && options.fallbackUrl) {
370 window.open(options.fallbackUrl, "_blank", "noopener,noreferrer");
371 return;
372 }
373
374 if (options.documentType === "invoice") {
375 const pid =
376 options.paymentId ?? parsePaymentIdFromHref(options.fallbackUrl || "");
377 if (pid) {
378 await downloadPaymentInvoiceBinary(pid);
379 return;
380 }
381 }
382
383 if (
384 options.documentType === "voucher" ||
385 options.documentType === "itinerary"
386 ) {
387 const kind = options.documentType === "voucher" ? "voucher" : "itinerary";
388 const bid =
389 options.bookingId ??
390 parseBookingDocFromHref(options.fallbackUrl || "", kind);
391 if (bid) {
392 await downloadBookingBinary(bid, kind);
393 return;
394 }
395 }
396
397 if (options.bookingId && options.documentType === "all") {
398 await downloadBookingBinary(options.bookingId, "voucher");
399 return;
400 }
401
402 console.error(`No handler for document type: ${options.documentType}`);
403 };
404
405 export const downloadVoucher = (bookingId: number) =>
406 downloadBookingBinary(bookingId, "voucher");
407
408 export const downloadInvoice = (paymentId: number) =>
409 downloadPaymentInvoiceBinary(paymentId);
410
411 export const downloadItinerary = (bookingId: number) =>
412 downloadBookingBinary(bookingId, "itinerary");
413
414 /**
415 * Pre-open an `about:blank` window inside the synchronous click
416 * handler so the browser still attributes the eventual navigation to
417 * the user gesture. Returns null when popups are blocked outright —
418 * callers degrade gracefully to a download anchor (see
419 * `openPdfInPreOpenedWindow`).
420 *
421 * IMPORTANT: we do NOT pass "noopener" / "noreferrer" here. With
422 * `noopener` set, `window.open()` is specified to return `null` so
423 * the caller can't navigate the new tab — which is the exact thing
424 * we need to do once the fetch resolves. The previous version of
425 * this function included noopener and silently always returned null,
426 * making EVERY click on Preview fall through to the download anchor.
427 * The blob URL we navigate to is same-origin and untrusted by
428 * design, so opener access from the new tab is harmless here.
429 */
430 function preOpenPreviewWindow(): Window | null {
431 if (typeof window === "undefined") return null;
432 try {
433 return window.open("about:blank", "_blank");
434 } catch {
435 return null;
436 }
437 }
438
439 /** Open payment invoice PDF in a new tab (REST preview JSON → blob). */
440 export const previewPaymentInvoice = async (
441 paymentId: number,
442 ): Promise<void> => {
443 // Open the window BEFORE the async fetch — the click is still the
444 // active user gesture at this point, so the popup blocker doesn't
445 // fire. Subsequent `await` calls are fine; we just navigate the
446 // already-opened window once we have the blob.
447 const preOpened = preOpenPreviewWindow();
448 try {
449 const { base, nonce } = getAccountRestConfig();
450 if (!nonce) {
451 throw new Error(
452 __(
453 "Missing security token. Reload the account page and try again.",
454 "yatra",
455 ),
456 );
457 }
458 const url = buildPaymentInvoiceUrl(base, paymentId, "preview");
459 const blob = await fetchPreviewPdf(url);
460 openPdfInPreOpenedWindow(blob, preOpened, `invoice-${paymentId}.pdf`);
461 } catch (e) {
462 if (preOpened && !preOpened.closed) {
463 try {
464 preOpened.close();
465 } catch {
466 /* ignore */
467 }
468 }
469 throw e;
470 }
471 };
472
473 export async function previewTravelDocument(doc: {
474 category: string;
475 url: string;
476 booking_id?: number;
477 payment_id?: number;
478 }): Promise<void> {
479 const preOpened = preOpenPreviewWindow();
480 try {
481 const { base, nonce } = getAccountRestConfig();
482 if (!nonce) {
483 throw new Error(
484 __(
485 "Missing security token. Reload the account page and try again.",
486 "yatra",
487 ),
488 );
489 }
490
491 if (doc.category === "invoice") {
492 const pid = doc.payment_id ?? parsePaymentIdFromHref(doc.url);
493 if (!pid) {
494 throw new Error(__("Could not resolve invoice link.", "yatra"));
495 }
496 const url = buildPaymentInvoiceUrl(base, pid, "preview");
497 const blob = await fetchPreviewPdf(url);
498 openPdfInPreOpenedWindow(blob, preOpened, `invoice-${pid}.pdf`);
499 return;
500 }
501
502 if (doc.category === "voucher" || doc.category === "itinerary") {
503 const kind = doc.category === "voucher" ? "voucher" : "itinerary";
504 const bid = doc.booking_id ?? parseBookingDocFromHref(doc.url, kind);
505 if (!bid) {
506 throw new Error(__("Could not resolve document link.", "yatra"));
507 }
508 const url = buildBookingDocumentUrl(base, bid, kind, "preview");
509 const blob = await fetchPreviewPdf(url);
510 openPdfInPreOpenedWindow(blob, preOpened, `${kind}-${bid}.pdf`);
511 return;
512 }
513
514 // Unknown category — fall back to navigating the pre-opened
515 // window directly to the original URL, or open it fresh if the
516 // pre-open was blocked.
517 if (preOpened && !preOpened.closed) {
518 preOpened.location.href = doc.url;
519 } else {
520 window.open(doc.url, "_blank", "noopener,noreferrer");
521 }
522 } catch (e) {
523 if (preOpened && !preOpened.closed) {
524 try {
525 preOpened.close();
526 } catch {
527 /* ignore */
528 }
529 }
530 throw e;
531 }
532 }
533