PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.6
Yatra – Travel Booking & Tour Operator Software v3.0.6
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 2.0.10 All 83 releases
yatra / app / Services / PdfService.php

PdfService.php in Yatra – Travel Booking & Tour Operator Software 3.0.6, at app/Services/PdfService.php

507 lines 21.6 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\Services;
6
7 class PdfService
8 {
9 public function isAvailable(): bool
10 {
11 return class_exists('Dompdf\\Dompdf') || class_exists('Mpdf\\Mpdf');
12 }
13
14 public function renderHtmlToPdf(string $html, array $options = []): string
15 {
16 $paper = (string) ($options['paper'] ?? 'A4');
17 $orientation = (string) ($options['orientation'] ?? 'portrait');
18
19 // Resolve the default font with awareness of the site's locale,
20 // so a Nepali / Hindi / Arabic / CJK install gets a font that
21 // can actually render the script — Dompdf's bundled DejaVu Sans
22 // only covers Latin/Greek/Cyrillic, so non-Latin glyphs come
23 // out as blank rectangles otherwise. See `resolveDefaultFont()`.
24 $defaultFont = (string) ($options['default_font'] ?? $this->resolveDefaultFont());
25
26 if (class_exists('Dompdf\\Dompdf')) {
27 $optionsClass = 'Dompdf\\Options';
28 $dompdfClass = 'Dompdf\\Dompdf';
29
30 $dompdfOptions = class_exists($optionsClass) ? new $optionsClass() : null;
31 if ($dompdfOptions) {
32 // Remote loading is required so PDFs can render the site logo / trip images.
33 // Filter exists so site owners can lock it down to the local filesystem if they
34 // never use remote images and want to fully eliminate SSRF risk.
35 $remoteEnabled = (bool) apply_filters('yatra_pdf_remote_enabled', true);
36 $dompdfOptions->set('isRemoteEnabled', $remoteEnabled);
37 $dompdfOptions->set('isHtml5ParserEnabled', true);
38 $dompdfOptions->set('defaultFont', $defaultFont);
39
40 // Enable `<script type="text/php">` blocks in PDF
41 // templates. Required so the itinerary template can
42 // draw a per-page header that's skipped on page 1
43 // (Dompdf's CSS `position: fixed` runs unconditionally
44 // on every page; a PHP canvas script is the only way
45 // to gate by `$PAGE_NUM`). The PHP runs in Dompdf's
46 // sandbox with access to the $pdf, $fontMetrics,
47 // $PAGE_NUM and $PAGE_COUNT variables only — the
48 // template files we ship are plugin-controlled so
49 // this isn't an attack surface like user-supplied
50 // HTML would be.
51 $dompdfOptions->set('isPhpEnabled', true);
52
53 // Move the font cache out of the plugin's vendor/ tree
54 // (which is normally read-only on managed hosts and gets
55 // wiped by composer install). Using
56 // wp-content/uploads/yatra-pdf-fonts/cache/ keeps the
57 // generated .ufm metric files persistent across deploys
58 // AND ensures Dompdf can actually write — without write
59 // access registerFont() silently no-ops, the Devanagari
60 // font never gets installed, and Nepali glyphs render
61 // as missing-glyph rectangles.
62 $cacheDir = $this->ensureWritableFontCacheDir();
63 if ($cacheDir) {
64 $dompdfOptions->set('fontDir', $cacheDir);
65 $dompdfOptions->set('fontCache', $cacheDir);
66 }
67
68 // Restrict file:// reads to within ABSPATH so a crafted template can't read /etc/passwd
69 // or other files outside the WordPress install.
70 if (defined('ABSPATH')) {
71 $dompdfOptions->set('chroot', [ABSPATH]);
72 }
73
74 // Block file:// and php:// protocols from remote requests
75 // (only http/https for network fetches). The empty-string
76 // key whitelists LOCAL file PATHS (no URL scheme) —
77 // Dompdf's Helpers::explode_url() returns "" as the
78 // protocol for a bare absolute path. Without that key,
79 // FontMetrics::registerFont() silently rejects every
80 // local TTF we pass to it, which is exactly why the
81 // bundled Noto Sans Devanagari fonts never installed and
82 // Nepali text fell back to DejaVu missing-glyph
83 // rectangles. file paths are NOT the same as `file://`
84 // URLs and don't expose any SSRF surface.
85 $dompdfOptions->set('allowedProtocols', [
86 '' => ['rules' => []],
87 'http://' => ['rules' => []],
88 'https://' => ['rules' => []],
89 ]);
90
91 // SSRF hardening for remote fetches: short timeout + identifying User-Agent so admins
92 // can spot dompdf traffic in logs. Does not stop SSRF on its own — sites that do not
93 // need remote images should disable via the `yatra_pdf_remote_enabled` filter above.
94 if ($remoteEnabled) {
95 $httpContext = stream_context_create([
96 'http' => [
97 'timeout' => 5,
98 'follow_location' => 0,
99 'user_agent' => 'YatraPDF/' . (defined('YATRA_VERSION') ? YATRA_VERSION : '1.0'),
100 ],
101 'ssl' => [
102 'verify_peer' => true,
103 'verify_peer_name' => true,
104 ],
105 ]);
106 $dompdfOptions->setHttpContext($httpContext);
107 }
108 }
109
110 $dompdf = $dompdfOptions ? new $dompdfClass($dompdfOptions) : new $dompdfClass();
111
112 // Register any user-supplied Unicode fonts BEFORE loadHtml so
113 // CSS `font-family` references resolve correctly.
114 $this->registerExtraFonts($dompdf);
115
116 $dompdf->loadHtml($html, 'UTF-8');
117 $dompdf->setPaper($paper, $orientation);
118 $dompdf->render();
119 return (string) $dompdf->output();
120 }
121
122 if (class_exists('Mpdf\\Mpdf')) {
123 $mpdfClass = 'Mpdf\\Mpdf';
124 $mpdf = new $mpdfClass(['format' => $paper]);
125 $mpdf->WriteHTML($html);
126 return (string) $mpdf->Output('', 'S');
127 }
128
129 throw new \RuntimeException('PDF engine is not available');
130 }
131
132 /**
133 * Decide which font family to hand to Dompdf as `defaultFont` based
134 * on the current WordPress locale. Dompdf's only bundled Unicode
135 * font is DejaVu Sans, which covers Latin/Greek/Cyrillic; for other
136 * scripts the site has to ship its own TTF (see
137 * `registerExtraFonts()`).
138 *
139 * The mapping intentionally aims at FAMILIES the typical
140 * `yatra_pdf_extra_fonts` install would expose (Noto Sans <script>
141 * is the canonical pick), so when an admin drops the matching font
142 * file into the fonts dir the chain "just works". The CSS in the
143 * PDF templates ALSO carries these names as fallbacks, so
144 * Dompdf's per-glyph font picker can pick whichever family is
145 * actually registered.
146 *
147 * Filter `yatra_pdf_default_font` lets a site override the
148 * resolved family entirely.
149 */
150 private function resolveDefaultFont(): string
151 {
152 $locale = function_exists('determine_locale') ? determine_locale() : (function_exists('get_locale') ? get_locale() : 'en_US');
153 $lang = strtolower(substr((string) $locale, 0, 2));
154
155 $devanagari = ['ne', 'hi', 'mr', 'sa']; // Nepali, Hindi, Marathi, Sanskrit
156 $arabic = ['ar', 'fa', 'ur'];
157 $cjk = ['zh', 'ja', 'ko'];
158
159 if (in_array($lang, $devanagari, true)) {
160 $font = 'Noto Sans Devanagari';
161 } elseif (in_array($lang, $arabic, true)) {
162 $font = 'Noto Sans Arabic';
163 } elseif (in_array($lang, $cjk, true)) {
164 $font = 'Noto Sans CJK';
165 } else {
166 $font = 'DejaVu Sans';
167 }
168
169 return (string) apply_filters('yatra_pdf_default_font', $font, $locale);
170 }
171
172 /**
173 * Parse a TTF/OTF basename into (cssFamily, style).
174 *
175 * Examples:
176 * "NotoSansDevanagari-Regular" → ("Noto Sans Devanagari", "normal")
177 * "NotoSansDevanagari-Bold" → ("Noto Sans Devanagari", "bold")
178 * "NotoSansArabic-Italic" → ("Noto Sans Arabic", "italic")
179 * "NotoSansCJK-BoldItalic" → ("Noto Sans CJK", "bold_italic")
180 * "Roboto" → ("Roboto", "normal")
181 * "Noto Sans Devanagari-Bold" → ("Noto Sans Devanagari", "bold") (file already has spaces)
182 *
183 * Returns `["", ""]` when the basename can't yield a sensible family.
184 *
185 * The CSS family is derived by:
186 * 1. Stripping any recognised weight/style suffix (Regular, Bold,
187 * Italic, BoldItalic, Oblique, BoldOblique).
188 * 2. Replacing underscores with spaces.
189 * 3. If the result is PascalCase with no separators, inserting a
190 * space before each interior capital letter — so the bundled
191 * `NotoSansDevanagari` file matches the CSS `"Noto Sans
192 * Devanagari"` declaration in the templates.
193 *
194 * @return array{0:string,1:string}
195 */
196 private function parseFontFilename(string $base): array
197 {
198 $style = 'normal';
199 $family = $base;
200
201 if (preg_match('/^(.+?)[-_](Regular|Bold(?:Italic|Oblique)?|Italic|Oblique)$/i', $base, $m)) {
202 $family = $m[1];
203 $variant = strtolower($m[2]);
204 if ($variant === 'bolditalic' || $variant === 'boldoblique') {
205 $style = 'bold_italic';
206 } elseif ($variant === 'bold') {
207 $style = 'bold';
208 } elseif ($variant === 'italic' || $variant === 'oblique') {
209 $style = 'italic';
210 } else {
211 // Regular / unknown → normal weight.
212 $style = 'normal';
213 }
214 }
215
216 // Normalise underscores → spaces first so a file already named
217 // "Noto_Sans_Devanagari-Bold.ttf" lands as "Noto Sans Devanagari".
218 $cssFamily = str_replace('_', ' ', $family);
219
220 // PascalCase → spaced (insert a space between a lowercase and the
221 // next uppercase, and between two uppercases followed by a
222 // lowercase — preserves "CJK" inside "Noto Sans CJK").
223 if (strpos($cssFamily, ' ') === false) {
224 $cssFamily = preg_replace('/([a-z])([A-Z])/', '$1 $2', $cssFamily);
225 $cssFamily = preg_replace('/([A-Z]+)([A-Z][a-z])/', '$1 $2', (string) $cssFamily);
226 }
227
228 $cssFamily = trim((string) $cssFamily);
229
230 return [$cssFamily, $style];
231 }
232
233 /**
234 * Resolve a writable font cache directory under wp-content/uploads/
235 * — required so Dompdf's `registerFont()` can write the generated
236 * `.ufm` metric files and the copied TTF. Returns the absolute path
237 * on success, or an empty string when uploads aren't writable (in
238 * which case the caller falls back to Dompdf's vendor cache).
239 */
240 private function ensureWritableFontCacheDir(): string
241 {
242 $uploads = function_exists('wp_get_upload_dir') ? wp_get_upload_dir() : null;
243 if (!$uploads || empty($uploads['basedir'])) {
244 return '';
245 }
246 $dir = rtrim((string) $uploads['basedir'], '/\\') . '/yatra-pdf-fonts/cache';
247 if (!is_dir($dir)) {
248 if (function_exists('wp_mkdir_p')) {
249 wp_mkdir_p($dir);
250 } else {
251 @mkdir($dir, 0755, true);
252 }
253 }
254 if (!is_dir($dir) || !is_writable($dir)) {
255 return '';
256 }
257
258 // One-time cache invalidation. Tied to the plugin version so it
259 // re-runs whenever the bundled fonts or the registerFont logic
260 // change — for example the `allowedProtocols` fix that finally
261 // lets registerFont() write font cache for local TTFs:
262 // installations that rendered a PDF BEFORE that fix have a
263 // partial `installed-fonts.json` recording the failed bold
264 // entry, and Dompdf's runtime cache trusts that file even when
265 // the matching .ufm is missing. Wiping the dir once per
266 // version forces a fresh build with the corrected logic.
267 $version = defined('YATRA_VERSION') ? (string) constant('YATRA_VERSION') : '1.0.0';
268 $stampFile = $dir . '/.yatra-font-cache-version';
269 $currentStamp = is_file($stampFile) ? (string) @file_get_contents($stampFile) : '';
270 if ($currentStamp !== $version) {
271 foreach ((array) glob($dir . '/*') as $file) {
272 if (is_file($file)) {
273 @unlink($file);
274 }
275 }
276 @file_put_contents($stampFile, $version);
277 }
278
279 return $dir;
280 }
281
282 /**
283 * Register any TTF/OTF the site has dropped into the Yatra PDF
284 * fonts dir with Dompdf's `FontMetrics`. Without this Dompdf has
285 * no idea those files exist — even with a matching CSS
286 * `font-family`, glyphs from the file would never reach the PDF.
287 *
288 * Default font dir is `wp-content/uploads/yatra-pdf-fonts/`
289 * (filter `yatra_pdf_fonts_dir`), so admins can add fonts without
290 * touching plugin code. Any TTF/OTF whose filename matches
291 * `<Family>[-<Style>].ttf` is registered as that family/style —
292 * style suffix is one of `Regular`, `Bold`, `Italic`,
293 * `BoldItalic`. Unknown suffixes default to `normal`.
294 *
295 * The filter `yatra_pdf_extra_fonts` lets code register fonts
296 * imperatively too, returning an array of
297 * [ family => [ 'normal' => '/abs/path.ttf', 'bold' => ... ] ]
298 */
299 private function registerExtraFonts($dompdf): void
300 {
301 $metrics = method_exists($dompdf, 'getFontMetrics') ? $dompdf->getFontMetrics() : null;
302 if (!$metrics) {
303 return;
304 }
305
306 // Scan order: plugin-bundled fonts (always present) first, then
307 // the user-overridable uploads dir. Later registrations win so
308 // a site can drop in a higher-quality Devanagari font in
309 // uploads/yatra-pdf-fonts/ to override the bundled Noto Sans.
310 $dirs = [];
311 if (defined('YATRA_PLUGIN_PATH')) {
312 $bundled = rtrim((string) YATRA_PLUGIN_PATH, '/\\') . '/assets/pdf-fonts';
313 if (is_dir($bundled)) {
314 $dirs[] = $bundled;
315 }
316 }
317 $uploads = function_exists('wp_get_upload_dir') ? wp_get_upload_dir() : null;
318 if ($uploads && !empty($uploads['basedir'])) {
319 $userDir = rtrim((string) $uploads['basedir'], '/\\') . '/yatra-pdf-fonts';
320 $userDir = (string) apply_filters('yatra_pdf_fonts_dir', $userDir);
321 if ($userDir && is_dir($userDir)) {
322 $dirs[] = $userDir;
323 }
324 }
325
326 $fonts = [];
327
328 foreach ($dirs as $dir) {
329 foreach ((array) glob($dir . '/*.{ttf,TTF,otf,OTF}', GLOB_BRACE) as $file) {
330 $base = pathinfo((string) $file, PATHINFO_FILENAME);
331 [$cssFamily, $style] = $this->parseFontFilename($base);
332 if ($cssFamily === '') {
333 continue;
334 }
335 if (!isset($fonts[$cssFamily])) {
336 $fonts[$cssFamily] = [];
337 }
338 $fonts[$cssFamily][$style] = $file;
339 }
340 }
341
342 $fonts = (array) apply_filters('yatra_pdf_extra_fonts', $fonts);
343
344 // Map our internal style keys to Dompdf's
345 // ['weight' => …, 'style' => …] tuple, since registerFont()
346 // uses CSS-shaped style arrays (weight = normal|bold,
347 // style = normal|italic) and not the four-key family entries.
348 $styleMatrix = [
349 'normal' => ['weight' => 'normal', 'style' => 'normal'],
350 'bold' => ['weight' => 'bold', 'style' => 'normal'],
351 'italic' => ['weight' => 'normal', 'style' => 'italic'],
352 'bold_italic' => ['weight' => 'bold', 'style' => 'italic'],
353 ];
354
355 foreach ($fonts as $family => $styles) {
356 if (!is_array($styles)) {
357 continue;
358 }
359 // Fill missing styles with the regular variant so Dompdf
360 // doesn't fall back to its own default on bold/italic.
361 $normal = $styles['normal'] ?? reset($styles);
362 if (!$normal) {
363 continue;
364 }
365 foreach (array_keys($styleMatrix) as $style) {
366 if (empty($styles[$style])) {
367 $styles[$style] = $normal;
368 }
369 }
370
371 foreach ($styleMatrix as $key => $css) {
372 $file = $styles[$key] ?? null;
373 if (!$file || !is_file($file)) {
374 continue;
375 }
376 try {
377 // registerFont downloads (or here, copies from a
378 // local path) the TTF into Dompdf's font dir, then
379 // generates the .ufm metrics file and wires the
380 // family/weight/style triple into the FontMetrics
381 // lookup table — all in one call. Passing a local
382 // path works because Helpers::getFileContent() just
383 // file_get_contents()s it.
384 $metrics->registerFont(
385 [
386 'family' => (string) $family,
387 'weight' => $css['weight'],
388 'style' => $css['style'],
389 ],
390 $file
391 );
392 } catch (\Throwable $e) {
393 // Don't take the whole PDF render down for one bad font.
394 error_log('[Yatra] Failed to register PDF font ' . $family . ' (' . $key . '): ' . $e->getMessage());
395 }
396 }
397 }
398 }
399
400 public function renderHtmlToPdfSafely(string $html, array $options = []): string
401 {
402 $originalErrorReporting = error_reporting();
403 $originalDisplayErrors = ini_get('display_errors');
404 $originalHtmlErrors = ini_get('html_errors');
405 $startObLevel = ob_get_level();
406
407 ini_set('display_errors', '0');
408 ini_set('html_errors', '0');
409 error_reporting($originalErrorReporting & ~E_DEPRECATED & ~E_USER_DEPRECATED);
410 ob_start();
411
412 $previousErrorHandler = set_error_handler(static function (int $errno) {
413 if ($errno === E_DEPRECATED || $errno === E_USER_DEPRECATED) {
414 return true;
415 }
416 return false;
417 });
418
419 try {
420 return $this->renderHtmlToPdf($html, $options);
421 } finally {
422 if ($previousErrorHandler !== null) {
423 restore_error_handler();
424 }
425
426 while (ob_get_level() > $startObLevel) {
427 ob_end_clean();
428 }
429
430 error_reporting($originalErrorReporting);
431 if ($originalDisplayErrors !== false) {
432 ini_set('display_errors', (string) $originalDisplayErrors);
433 }
434 if ($originalHtmlErrors !== false) {
435 ini_set('html_errors', (string) $originalHtmlErrors);
436 }
437 }
438 }
439
440 public function renderTemplate(string $templatePath, array $data = []): string
441 {
442 $templateFile = YATRA_PLUGIN_PATH . 'templates/' . $templatePath;
443
444 if (!file_exists($templateFile)) {
445 throw new \InvalidArgumentException("Template file not found: {$templateFile}");
446 }
447
448 // Defence in depth: only extract string-keyed entries with valid PHP-identifier names,
449 // and skip keys that would clobber locals already in scope (EXTR_SKIP). Stops a malicious
450 // $data array from injecting arbitrary local variables into the template scope.
451 $safeData = [];
452 foreach ($data as $key => $value) {
453 if (is_string($key) && preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $key)) {
454 $safeData[$key] = $value;
455 }
456 }
457 extract($safeData, EXTR_SKIP);
458
459 // Capture output
460 ob_start();
461 try {
462 include $templateFile;
463 return ob_get_clean();
464 } catch (\Throwable $e) {
465 ob_end_clean();
466 throw $e;
467 }
468 }
469
470 public function renderTemplateToPdf(string $templatePath, array $data = [], array $options = []): string
471 {
472 $html = $this->renderTemplate($templatePath, $data);
473 return $this->renderHtmlToPdf($html, $options);
474 }
475
476 public function renderTemplateToPdfSafely(string $templatePath, array $data = [], array $options = []): string
477 {
478 $html = $this->renderTemplate($templatePath, $data);
479 return $this->renderHtmlToPdfSafely($html, $options);
480 }
481
482 public function outputPdfDownload(string $pdfBinary, string $filename, bool $inline = false): void
483 {
484 // Strip CR/LF (header injection) and any quotes/backslashes from the ASCII fallback name.
485 // Keep an RFC 5987 UTF-8 form so non-ASCII filenames still display correctly in modern browsers.
486 $cleanFilename = preg_replace('/[\r\n"\\\\]/', '', $filename) ?? '';
487 if ($cleanFilename === '') {
488 $cleanFilename = 'document.pdf';
489 }
490 $asciiFilename = preg_replace('/[^\x20-\x7E]/', '_', $cleanFilename) ?? 'document.pdf';
491 $encodedFilename = rawurlencode($cleanFilename);
492
493 header('Content-Type: application/pdf');
494 header(
495 'Content-Disposition: ' . ($inline ? 'inline' : 'attachment')
496 . '; filename="' . $asciiFilename . '"'
497 . "; filename*=UTF-8''" . $encodedFilename
498 );
499 header('Cache-Control: no-cache, no-store, must-revalidate');
500 header('Pragma: no-cache');
501 header('Expires: 0');
502
503 echo $pdfBinary;
504 exit;
505 }
506 }
507