PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.6
Yatra – Travel Booking & Tour Operator Software v3.0.6
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 3.0.6, at app/Controllers/TripDownloadController.php

585 lines 20.9 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 $requiredBookingId = $bookingId;
334 }
335
336 // Generate secure download URL
337 $downloadUrl = self::buildSecureDownloadUrl($downloadId, $requiredBookingId, 'download');
338
339 // Get filename for response
340 $filename = 'download';
341 if (!empty($download->title)) {
342 $filename = sanitize_file_name($download->title);
343 }
344
345 return new WP_REST_Response([
346 'download_url' => $downloadUrl,
347 'filename' => $filename,
348 'visibility' => $visibility,
349 'title' => $download->title ?? '',
350 ], 200);
351 }
352
353 /**
354 * Check download permission based on visibility
355 */
356 public function check_download_permission(WP_REST_Request $request): bool
357 {
358 $downloadId = (int) $request->get_param('download_id');
359
360 if ($downloadId <= 0) {
361 return false;
362 }
363
364 $repo = new TripDownloadRepository();
365 $download = $repo->getById($downloadId);
366
367 if (!$download || empty($download->id)) {
368 return false;
369 }
370
371 // Check if download is enabled - default to true if not set
372 $isEnabled = true;
373 if (isset($download->enabled)) {
374 $isEnabled = (bool) $download->enabled;
375 }
376 if (!$isEnabled) {
377 return false;
378 }
379
380 $visibility = 'booked_only'; // Default to booked_only for production
381 if (isset($download->visibility) && !empty($download->visibility)) {
382 $visibility = (string) $download->visibility;
383 }
384 if ($visibility === 'paid_only') {
385 $visibility = 'booked_only';
386 }
387
388 // Allow admins to access all downloads
389 if (current_user_can('manage_options')) {
390 return true;
391 }
392
393 // Public downloads are always allowed - no nonce required for public downloads
394 if ($visibility === 'public') {
395 return true;
396 }
397
398 // For logged_in and booked_only downloads, require authentication
399 if ($visibility === 'logged_in' || $visibility === 'booked_only') {
400 return is_user_logged_in();
401 }
402
403 return false;
404 }
405
406 /**
407 * Get downloads for a trip
408 */
409 public function getDownloads(WP_REST_Request $request): WP_REST_Response
410 {
411 $tripId = (int) $request->get_param('trip_id');
412
413 $repo = new TripDownloadRepository();
414 $downloads = $repo->getByTripId($tripId);
415
416 $items = array_map(function ($download) {
417 $attachmentId = isset($download->attachment_id) ? (int) $download->attachment_id : null;
418 $attachmentUrl = '';
419 $attachmentTitle = '';
420
421 if ($attachmentId) {
422 $attachmentUrl = (string) (wp_get_attachment_url($attachmentId) ?: '');
423 $attachmentTitle = (string) (get_the_title($attachmentId) ?: '');
424 }
425
426 return [
427 'id' => (int) $download->id,
428 'trip_id' => (int) $download->trip_id,
429 'title' => $download->title ?? '',
430 'description' => $download->description ?? '',
431 'attachment_id' => $attachmentId,
432 'attachment_url' => $attachmentUrl,
433 'attachment_title' => $attachmentTitle,
434 'visibility' => $download->visibility ?? 'booked_only',
435 'enabled' => (bool) ($download->enabled ?? true),
436 'sort_order' => (int) ($download->sort_order ?? 0),
437 ];
438 }, $downloads);
439
440 return new WP_REST_Response(['data' => $items], 200);
441 }
442
443 /**
444 * Save downloads for a trip
445 */
446 public function saveDownloads(WP_REST_Request $request): WP_REST_Response
447 {
448 $tripId = (int) $request->get_param('trip_id');
449 $items = $request->get_param('items') ?? [];
450
451 if (!is_array($items)) {
452 $items = [];
453 }
454
455 $repo = new TripDownloadRepository();
456 $repo->replaceForTrip($tripId, $items);
457
458 // Return updated downloads
459 $downloads = $repo->getByTripId($tripId);
460 $responseItems = array_map(function ($download) {
461 $attachmentId = isset($download->attachment_id) ? (int) $download->attachment_id : null;
462 $attachmentUrl = '';
463 $attachmentTitle = '';
464
465 if ($attachmentId) {
466 $attachmentUrl = (string) (wp_get_attachment_url($attachmentId) ?: '');
467 $attachmentTitle = (string) (get_the_title($attachmentId) ?: '');
468 }
469
470 return [
471 'id' => (int) $download->id,
472 'trip_id' => (int) $download->trip_id,
473 'title' => $download->title ?? '',
474 'description' => $download->description ?? '',
475 'attachment_id' => $attachmentId,
476 'attachment_url' => $attachmentUrl,
477 'attachment_title' => $attachmentTitle,
478 'visibility' => $download->visibility ?? 'booked_only',
479 'enabled' => (bool) ($download->enabled ?? true),
480 'sort_order' => (int) ($download->sort_order ?? 0),
481 ];
482 }, $downloads);
483
484 return new WP_REST_Response(['data' => $responseItems], 200);
485 }
486
487 /**
488 * Ensure protected file exists
489 */
490 private function ensureProtectedFile(object $download, TripDownloadRepository $repo): string
491 {
492 $existing = isset($download->protected_path) ? (string) $download->protected_path : '';
493 if ($existing !== '' && file_exists($existing)) {
494 return $existing;
495 }
496
497 // Extract attachment_id from metadata first
498 $attachmentId = 0;
499 if (!empty($download->metadata)) {
500 $metadata = json_decode($download->metadata, true);
501 if (is_array($metadata) && isset($metadata['attachment_id'])) {
502 $attachmentId = (int) $metadata['attachment_id'];
503 }
504 }
505
506 // Fallback to direct attachment_id field
507 if ($attachmentId <= 0 && isset($download->attachment_id)) {
508 $attachmentId = (int) $download->attachment_id;
509 }
510
511 if ($attachmentId <= 0) {
512 return '';
513 }
514
515 $source = get_attached_file($attachmentId);
516 if (!$source || !file_exists($source)) {
517 return '';
518 }
519
520 $uploads = wp_upload_dir();
521 $baseDir = isset($uploads['basedir']) ? (string) $uploads['basedir'] : '';
522 if ($baseDir === '') {
523 return '';
524 }
525
526 $dir = rtrim($baseDir, '/') . '/yatra-protected-downloads';
527 if (!wp_mkdir_p($dir)) {
528 return '';
529 }
530
531 $downloadId = isset($download->id) ? (int) $download->id : 0;
532 $name = basename($source);
533 $safeName = preg_replace('/[^A-Za-z0-9._-]/', '-', $name);
534 $target = $dir . '/download-' . $downloadId . '-' . $safeName;
535
536 if (!file_exists($target)) {
537 @copy($source, $target);
538 }
539
540 // Don't update protected path since column doesn't exist
541 // Just return the target path
542
543 return $target;
544 }
545
546 /**
547 * Sign a download token
548 */
549 private function signDownloadToken(int $downloadId, int $bookingId, int $exp, string $action): string
550 {
551 $payload = $downloadId . '|' . $bookingId . '|' . $exp . '|' . $action;
552 return hash_hmac('sha256', $payload, wp_salt('yatra-downloads'));
553 }
554
555 /**
556 * Build a secure download URL
557 */
558 public static function buildSecureDownloadUrl(int $downloadId, int $bookingId, string $action = 'download'): string
559 {
560 $ts = time() + (24 * 60 * 60); // 24 hours expiration
561 $payload = $downloadId . '|' . $bookingId . '|' . $ts . '|' . $action;
562 $sig = hash_hmac('sha256', $payload, wp_salt('yatra-downloads'));
563
564 // Use REST API endpoint
565 $url = rest_url('yatra/v1/downloads/' . $downloadId);
566
567 $url = add_query_arg([
568 'booking_id' => $bookingId,
569 'action' => $action,
570 'exp' => $ts,
571 'sig' => $sig,
572 ], $url);
573
574 return add_query_arg('_wpnonce', wp_create_nonce('wp_rest'), $url);
575 }
576
577 /**
578 * Check admin permission
579 */
580 public function check_admin_permission(): bool
581 {
582 return current_user_can('manage_options');
583 }
584 }
585