PluginProbe
Yatra – Travel Booking & Tour Operator Software / trunk
Yatra – Travel Booking & Tour Operator Software vtrunk
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 2.0.10 2.0.11 All 82 releases
yatra / app / Controllers / TripDownloadController.php

TripDownloadController.php in Yatra – Travel Booking & Tour Operator Software trunk, at app/Controllers/TripDownloadController.php

643 lines 23.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 declare(strict_types=1);
4
5 namespace Yatra\Controllers;
6
7 use Yatra\Repositories\TripDownloadRepository;
8 use Yatra\Repositories\BookingRepository;
9 use Yatra\Services\TripService;
10 use WP_REST_Request;
11 use WP_REST_Response;
12 use WP_Error;
13
14 /**
15 * Trip Download Controller
16 *
17 * Handles REST API routes for trip downloadable files.
18 * Downloads is a FREE feature in Yatra.
19 *
20 * @package Yatra\Controllers
21 * @since 3.0.0
22 */
23 class TripDownloadController extends BaseController
24 {
25 /**
26 * Trip service instance
27 */
28 protected TripService $tripService;
29
30 /**
31 * Constructor
32 */
33 public function __construct()
34 {
35 $this->tripService = new TripService();
36 }
37
38 /**
39 * Register REST routes
40 */
41 public function register_routes(): void
42 {
43
44 // REST API endpoint for generating download URLs
45 register_rest_route('yatra/v1', '/downloads/(?P<download_id>\d+)', [
46 'methods' => 'GET',
47 'callback' => [$this, 'handleDownloadRequest'],
48 'permission_callback' => '__return_true',
49 'args' => [
50 'download_id' => [
51 'required' => true,
52 'type' => 'integer',
53 ],
54 'booking_id' => [
55 'required' => false,
56 'type' => 'integer',
57 ],
58 'exp' => [
59 'required' => true,
60 'type' => 'integer',
61 ],
62 'sig' => [
63 'required' => true,
64 'type' => 'string',
65 ],
66 'action' => [
67 'required' => false,
68 'type' => 'string',
69 'default' => 'download',
70 ],
71 ],
72 ]);
73
74 // Public endpoint for generating download URLs
75 register_rest_route('yatra/v1', '/downloads/(?P<download_id>\d+)/download-url', [
76 'methods' => 'GET',
77 'callback' => [$this, 'getDownloadUrl'],
78 'permission_callback' => [$this, 'check_download_permission'],
79 'args' => [
80 'download_id' => [
81 'required' => true,
82 'type' => 'integer',
83 ],
84 'booking_id' => [
85 'required' => false,
86 'type' => 'integer',
87 'default' => 0,
88 ],
89 ],
90 ]);
91
92 // Admin endpoints for managing downloads
93 register_rest_route('yatra/v1', '/trips/(?P<trip_id>\d+)/downloads', [
94 [
95 'methods' => 'GET',
96 'callback' => [$this, 'getDownloads'],
97 'permission_callback' => [$this, 'check_admin_permission'],
98 'args' => [
99 'trip_id' => [
100 'required' => true,
101 'type' => 'integer',
102 ],
103 ],
104 ],
105 [
106 'methods' => 'POST',
107 'callback' => [$this, 'saveDownloads'],
108 'permission_callback' => [$this, 'check_admin_permission'],
109 'args' => [
110 'trip_id' => [
111 'required' => true,
112 'type' => 'integer',
113 ],
114 ],
115 ],
116 ]);
117 }
118
119 /**
120 * Handle secure download request
121 */
122 public function handleDownloadRequest(WP_REST_Request $request)
123 {
124 $downloadId = (int) $request->get_param('download_id');
125 $bookingId = (int) $request->get_param('booking_id');
126 $exp = (int) $request->get_param('exp');
127 $sig = (string) $request->get_param('sig');
128 $action = (string) ($request->get_param('action') ?: 'download');
129
130 if ($downloadId <= 0 || $exp <= 0 || $sig === '') {
131 return new WP_Error('yatra_download_invalid_request', __('Invalid download request.', 'yatra'), ['status' => 400]);
132 }
133
134 if (time() > $exp) {
135 return new WP_Error('yatra_download_expired', __('This download link has expired.', 'yatra'), ['status' => 403]);
136 }
137
138 // Validate signature - try both actions for flexibility
139 $expectedSigDownload = $this->signDownloadToken($downloadId, $bookingId, $exp, 'download');
140 $expectedSigPreview = $this->signDownloadToken($downloadId, $bookingId, $exp, 'preview');
141
142 if (!hash_equals($expectedSigDownload, $sig) && !hash_equals($expectedSigPreview, $sig)) {
143 return new WP_Error('yatra_download_invalid_signature', __('Invalid download signature.', 'yatra'), ['status' => 403]);
144 }
145
146 $repo = new TripDownloadRepository();
147 $download = $repo->getById($downloadId);
148
149 if (!$download || empty($download->id)) {
150 return new WP_Error('yatra_download_not_found', __('Download not found.', 'yatra'), ['status' => 404]);
151 }
152
153 if (isset($download->enabled) && !(bool) $download->enabled) {
154 return new WP_Error('yatra_download_disabled', __('This download is not available.', 'yatra'), ['status' => 403]);
155 }
156
157 $visibility = 'booked_only'; // Default to booked_only for production
158 if (isset($download->visibility) && !empty($download->visibility)) {
159 $visibility = (string) $download->visibility;
160 }
161 if ($visibility === 'paid_only') {
162 $visibility = 'booked_only';
163 }
164
165 // Enforce visibility rules
166 if ($visibility === 'public') {
167 // No extra checks needed
168 } elseif ($visibility === 'logged_in') {
169 if (!is_user_logged_in()) {
170 return new WP_Error('yatra_download_login_required', __('Please log in to access this download.', 'yatra'), ['status' => 401]);
171 }
172 } else {
173 // booked_only - require valid booking
174 if ($bookingId <= 0) {
175 return new WP_Error('yatra_download_booking_required', __('Booking is required for this download.', 'yatra'), ['status' => 403]);
176 }
177
178 $bookingRepo = new BookingRepository();
179 $booking = $bookingRepo->findWithTrip($bookingId);
180 if (!$booking) {
181 return new WP_Error('yatra_download_booking_not_found', __('Booking not found.', 'yatra'), ['status' => 404]);
182 }
183
184 $downloadTripId = isset($download->trip_id) ? (int) $download->trip_id : 0;
185 if ($downloadTripId > 0 && (int) $booking->trip_id !== $downloadTripId) {
186 return new WP_Error('yatra_download_trip_mismatch', __('This download does not match your booking.', 'yatra'), ['status' => 403]);
187 }
188
189 // Additional validation: check if booking is confirmed/paid
190 if (isset($booking->status) && !in_array($booking->status, ['confirmed', 'paid', 'completed'])) {
191 return new WP_Error('yatra_download_booking_invalid', __('Booking must be confirmed to access downloads.', 'yatra'), ['status' => 403]);
192 }
193 }
194
195 $filePath = $this->ensureProtectedFile($download, $repo);
196 if (!$filePath || !file_exists($filePath)) {
197 return new WP_Error('yatra_download_file_missing', __('File not found.', 'yatra'), ['status' => 404]);
198 }
199
200 // Get trip title for custom filename
201 $tripTitle = 'Download';
202 // Use TripService to get trip title
203 if (isset($download->trip_id) && $download->trip_id > 0) {
204 $trip = $this->tripService->getById((int) $download->trip_id);
205 if ($trip && !empty($trip->title)) {
206 $tripTitle = sanitize_text_field($trip->title);
207 }
208 }
209
210 // Generate custom filename
211 $originalFilename = basename($filePath);
212 $fileExtension = pathinfo($originalFilename, PATHINFO_EXTENSION);
213
214 if (empty($fileExtension)) {
215 $mime = function_exists('mime_content_type') ? @mime_content_type($filePath) : 'application/octet-stream';
216 switch ($mime) {
217 case 'application/pdf':
218 $fileExtension = 'pdf';
219 break;
220 case 'image/jpeg':
221 $fileExtension = 'jpg';
222 break;
223 case 'image/png':
224 $fileExtension = 'png';
225 break;
226 default:
227 $fileExtension = 'bin';
228 break;
229 }
230 }
231
232 // Get download number for this trip
233 $downloadNumber = 1;
234 if (isset($download->trip_id) && $download->trip_id > 0) {
235 $tripDownloads = $repo->getByTripId((int) $download->trip_id);
236 if (!empty($tripDownloads)) {
237 foreach ($tripDownloads as $index => $tripDownload) {
238 if ($tripDownload->id == $download->id) {
239 $downloadNumber = $index + 1;
240 break;
241 }
242 }
243 }
244 }
245
246 $customFilename = sprintf('%s - Download - %d.%s', $tripTitle, $downloadNumber, $fileExtension);
247
248 $mime = function_exists('mime_content_type') ? (string) @mime_content_type($filePath) : 'application/octet-stream';
249 $disposition = ($action === 'preview') ? 'inline' : 'attachment';
250
251 // Clear all output buffers and serve file directly
252 while (ob_get_level()) {
253 ob_end_clean();
254 }
255
256 // Set headers and serve file
257 header('Content-Type: ' . $mime);
258 header('Content-Length: ' . filesize($filePath));
259 header('Content-Disposition: ' . $disposition . '; filename="' . $customFilename . '"');
260 header('Cache-Control: no-cache, no-store, must-revalidate');
261 header('Pragma: no-cache');
262 header('Expires: 0');
263
264 readfile($filePath);
265 exit;
266 }
267
268 /**
269 * Get download URL for frontend
270 */
271 public function getDownloadUrl(WP_REST_Request $request)
272 {
273 $downloadId = (int) $request->get_param('download_id');
274 $bookingId = (int) $request->get_param('booking_id');
275
276 if ($downloadId <= 0) {
277 return new WP_Error('invalid_download_id', __('Invalid download ID.', 'yatra'), ['status' => 400]);
278 }
279
280 $repo = new TripDownloadRepository();
281 $download = $repo->getById($downloadId);
282
283 if (!$download || empty($download->id)) {
284 return new WP_Error('download_not_found', __('Download not found.', 'yatra'), ['status' => 404]);
285 }
286
287 // Check if download is enabled - default to true if not set
288 $isEnabled = true;
289 if (isset($download->enabled)) {
290 $isEnabled = (bool) $download->enabled;
291 }
292 if (!$isEnabled) {
293 return new WP_Error('download_disabled', __('This download is not available.', 'yatra'), ['status' => 403]);
294 }
295
296 $visibility = 'booked_only'; // Default to booked_only for production
297 if (isset($download->visibility) && !empty($download->visibility)) {
298 $visibility = (string) $download->visibility;
299 }
300 if ($visibility === 'paid_only') {
301 $visibility = 'booked_only';
302 }
303
304 // For public downloads, we don't need booking_id
305 $requiredBookingId = 0;
306 if ($visibility === 'booked_only') {
307 // Require valid booking for booked_only downloads
308 if (!is_user_logged_in()) {
309 return new WP_Error('login_required', __('Please log in to download this file.', 'yatra'), ['status' => 401]);
310 }
311
312 if ($bookingId <= 0) {
313 return new WP_Error('booking_required', __('Valid booking is required to download this file.', 'yatra'), ['status' => 403]);
314 }
315
316 // Validate booking exists and matches trip
317 $bookingRepo = new BookingRepository();
318 $booking = $bookingRepo->findWithTrip($bookingId);
319 if (!$booking) {
320 return new WP_Error('booking_not_found', __('Booking not found.', 'yatra'), ['status' => 404]);
321 }
322
323 $downloadTripId = isset($download->trip_id) ? (int) $download->trip_id : 0;
324 if ($downloadTripId > 0 && (int) $booking->trip_id !== $downloadTripId) {
325 return new WP_Error('booking_mismatch', __('This download does not match your booking.', 'yatra'), ['status' => 403]);
326 }
327
328 // Check booking status
329 if (isset($booking->status) && !in_array($booking->status, ['confirmed', 'paid', 'completed'])) {
330 return new WP_Error('booking_invalid', __('Booking must be confirmed to access downloads.', 'yatra'), ['status' => 403]);
331 }
332
333 // H-2: bind the signed URL to a requester who actually owns this booking.
334 // Without this, any logged-in user could mint a download URL for another
335 // customer's confirmed booking on the same trip. Monitor-first: in monitor
336 // mode this only logs and proceeds (no broken downloads for anyone).
337 if (!$this->requesterOwnsBooking($bookingId, $booking)) {
338 if (\Yatra\Security\Guard::denied('download_url_ownership', [
339 'booking_id' => $bookingId,
340 'download_id' => $downloadId,
341 'user' => get_current_user_id(),
342 ])) {
343 return new WP_Error(
344 'forbidden',
345 __('You do not have permission to download this file.', 'yatra'),
346 ['status' => 403]
347 );
348 }
349 }
350
351 $requiredBookingId = $bookingId;
352 }
353
354 // Generate secure download URL
355 $downloadUrl = self::buildSecureDownloadUrl($downloadId, $requiredBookingId, 'download');
356
357 // Get filename for response
358 $filename = 'download';
359 if (!empty($download->title)) {
360 $filename = sanitize_file_name($download->title);
361 }
362
363 return new WP_REST_Response([
364 'download_url' => $downloadUrl,
365 'filename' => $filename,
366 'visibility' => $visibility,
367 'title' => $download->title ?? '',
368 ], 200);
369 }
370
371 /**
372 * Does the current requester own this booking? (H-2)
373 *
374 * Admins pass; a registered-user booking requires the owning user; a guest
375 * booking (user_id NULL/0) requires the booking-session token bound to it.
376 * Same rule used across the booking-session/payment endpoints.
377 *
378 * @param object|null $booking Booking row, or null when not found.
379 */
380 private function requesterOwnsBooking(int $bookingId, $booking): bool
381 {
382 if (current_user_can('manage_options')) {
383 return true;
384 }
385
386 if (!$booking) {
387 return false;
388 }
389
390 $bookingUserId = (int) ($booking->user_id ?? 0);
391 $currentUserId = (int) get_current_user_id();
392
393 if ($bookingUserId > 0) {
394 return $currentUserId === $bookingUserId;
395 }
396
397 // Guest booking: accept a matching short-lived booking-session token.
398 $token = (isset($_GET['booking_token']) && is_string($_GET['booking_token']))
399 ? sanitize_text_field((string) wp_unslash($_GET['booking_token']))
400 : '';
401 if ($token !== '') {
402 $session = get_transient($token);
403 if (is_array($session) && (int) ($session['booking_id'] ?? 0) === $bookingId) {
404 return true;
405 }
406 }
407
408 return false;
409 }
410
411 /**
412 * Check download permission based on visibility
413 */
414 public function check_download_permission(WP_REST_Request $request): bool
415 {
416 $downloadId = (int) $request->get_param('download_id');
417
418 if ($downloadId <= 0) {
419 return false;
420 }
421
422 $repo = new TripDownloadRepository();
423 $download = $repo->getById($downloadId);
424
425 if (!$download || empty($download->id)) {
426 return false;
427 }
428
429 // Check if download is enabled - default to true if not set
430 $isEnabled = true;
431 if (isset($download->enabled)) {
432 $isEnabled = (bool) $download->enabled;
433 }
434 if (!$isEnabled) {
435 return false;
436 }
437
438 $visibility = 'booked_only'; // Default to booked_only for production
439 if (isset($download->visibility) && !empty($download->visibility)) {
440 $visibility = (string) $download->visibility;
441 }
442 if ($visibility === 'paid_only') {
443 $visibility = 'booked_only';
444 }
445
446 // Allow admins to access all downloads
447 if (current_user_can('manage_options')) {
448 return true;
449 }
450
451 // Public downloads are always allowed - no nonce required for public downloads
452 if ($visibility === 'public') {
453 return true;
454 }
455
456 // For logged_in and booked_only downloads, require authentication
457 if ($visibility === 'logged_in' || $visibility === 'booked_only') {
458 return is_user_logged_in();
459 }
460
461 return false;
462 }
463
464 /**
465 * Get downloads for a trip
466 */
467 public function getDownloads(WP_REST_Request $request): WP_REST_Response
468 {
469 $tripId = (int) $request->get_param('trip_id');
470
471 $repo = new TripDownloadRepository();
472 $downloads = $repo->getByTripId($tripId);
473
474 $items = array_map(function ($download) {
475 $attachmentId = isset($download->attachment_id) ? (int) $download->attachment_id : null;
476 $attachmentUrl = '';
477 $attachmentTitle = '';
478
479 if ($attachmentId) {
480 $attachmentUrl = (string) (wp_get_attachment_url($attachmentId) ?: '');
481 $attachmentTitle = (string) (get_the_title($attachmentId) ?: '');
482 }
483
484 return [
485 'id' => (int) $download->id,
486 'trip_id' => (int) $download->trip_id,
487 'title' => $download->title ?? '',
488 'description' => $download->description ?? '',
489 'attachment_id' => $attachmentId,
490 'attachment_url' => $attachmentUrl,
491 'attachment_title' => $attachmentTitle,
492 'visibility' => $download->visibility ?? 'booked_only',
493 'enabled' => (bool) ($download->enabled ?? true),
494 'sort_order' => (int) ($download->sort_order ?? 0),
495 ];
496 }, $downloads);
497
498 return new WP_REST_Response(['data' => $items], 200);
499 }
500
501 /**
502 * Save downloads for a trip
503 */
504 public function saveDownloads(WP_REST_Request $request): WP_REST_Response
505 {
506 $tripId = (int) $request->get_param('trip_id');
507 $items = $request->get_param('items') ?? [];
508
509 if (!is_array($items)) {
510 $items = [];
511 }
512
513 $repo = new TripDownloadRepository();
514 $repo->replaceForTrip($tripId, $items);
515
516 // Return updated downloads
517 $downloads = $repo->getByTripId($tripId);
518 $responseItems = array_map(function ($download) {
519 $attachmentId = isset($download->attachment_id) ? (int) $download->attachment_id : null;
520 $attachmentUrl = '';
521 $attachmentTitle = '';
522
523 if ($attachmentId) {
524 $attachmentUrl = (string) (wp_get_attachment_url($attachmentId) ?: '');
525 $attachmentTitle = (string) (get_the_title($attachmentId) ?: '');
526 }
527
528 return [
529 'id' => (int) $download->id,
530 'trip_id' => (int) $download->trip_id,
531 'title' => $download->title ?? '',
532 'description' => $download->description ?? '',
533 'attachment_id' => $attachmentId,
534 'attachment_url' => $attachmentUrl,
535 'attachment_title' => $attachmentTitle,
536 'visibility' => $download->visibility ?? 'booked_only',
537 'enabled' => (bool) ($download->enabled ?? true),
538 'sort_order' => (int) ($download->sort_order ?? 0),
539 ];
540 }, $downloads);
541
542 return new WP_REST_Response(['data' => $responseItems], 200);
543 }
544
545 /**
546 * Ensure protected file exists
547 */
548 private function ensureProtectedFile(object $download, TripDownloadRepository $repo): string
549 {
550 $existing = isset($download->protected_path) ? (string) $download->protected_path : '';
551 if ($existing !== '' && file_exists($existing)) {
552 return $existing;
553 }
554
555 // Extract attachment_id from metadata first
556 $attachmentId = 0;
557 if (!empty($download->metadata)) {
558 $metadata = json_decode($download->metadata, true);
559 if (is_array($metadata) && isset($metadata['attachment_id'])) {
560 $attachmentId = (int) $metadata['attachment_id'];
561 }
562 }
563
564 // Fallback to direct attachment_id field
565 if ($attachmentId <= 0 && isset($download->attachment_id)) {
566 $attachmentId = (int) $download->attachment_id;
567 }
568
569 if ($attachmentId <= 0) {
570 return '';
571 }
572
573 $source = get_attached_file($attachmentId);
574 if (!$source || !file_exists($source)) {
575 return '';
576 }
577
578 $uploads = wp_upload_dir();
579 $baseDir = isset($uploads['basedir']) ? (string) $uploads['basedir'] : '';
580 if ($baseDir === '') {
581 return '';
582 }
583
584 $dir = rtrim($baseDir, '/') . '/yatra-protected-downloads';
585 if (!wp_mkdir_p($dir)) {
586 return '';
587 }
588
589 $downloadId = isset($download->id) ? (int) $download->id : 0;
590 $name = basename($source);
591 $safeName = preg_replace('/[^A-Za-z0-9._-]/', '-', $name);
592 $target = $dir . '/download-' . $downloadId . '-' . $safeName;
593
594 if (!file_exists($target)) {
595 @copy($source, $target);
596 }
597
598 // Don't update protected path since column doesn't exist
599 // Just return the target path
600
601 return $target;
602 }
603
604 /**
605 * Sign a download token
606 */
607 private function signDownloadToken(int $downloadId, int $bookingId, int $exp, string $action): string
608 {
609 $payload = $downloadId . '|' . $bookingId . '|' . $exp . '|' . $action;
610 return hash_hmac('sha256', $payload, wp_salt('yatra-downloads'));
611 }
612
613 /**
614 * Build a secure download URL
615 */
616 public static function buildSecureDownloadUrl(int $downloadId, int $bookingId, string $action = 'download'): string
617 {
618 $ts = time() + (24 * 60 * 60); // 24 hours expiration
619 $payload = $downloadId . '|' . $bookingId . '|' . $ts . '|' . $action;
620 $sig = hash_hmac('sha256', $payload, wp_salt('yatra-downloads'));
621
622 // Use REST API endpoint
623 $url = rest_url('yatra/v1/downloads/' . $downloadId);
624
625 $url = add_query_arg([
626 'booking_id' => $bookingId,
627 'action' => $action,
628 'exp' => $ts,
629 'sig' => $sig,
630 ], $url);
631
632 return add_query_arg('_wpnonce', wp_create_nonce('wp_rest'), $url);
633 }
634
635 /**
636 * Check admin permission
637 */
638 public function check_admin_permission(): bool
639 {
640 return current_user_can('manage_options');
641 }
642 }
643