1<?php
2/**
3 * Used to render the header of PMA's pages
4 */
5
6declare(strict_types=1);
7
8namespace PhpMyAdmin;
9
10use PhpMyAdmin\Html\Generator;
11use PhpMyAdmin\Navigation\Navigation;
12use function defined;
13use function gmdate;
14use function header;
15use function htmlspecialchars;
16use function implode;
17use function ini_get;
18use function is_bool;
19use function strlen;
20use function strtolower;
21use function urlencode;
22
23/**
24 * Class used to output the HTTP and HTML headers
25 */
26class Header
27{
28    /**
29     * Scripts instance
30     *
31     * @access private
32     * @var Scripts
33     */
34    private $scripts;
35    /**
36     * PhpMyAdmin\Console instance
37     *
38     * @access private
39     * @var Console
40     */
41    private $console;
42    /**
43     * Menu instance
44     *
45     * @access private
46     * @var Menu
47     */
48    private $menu;
49    /**
50     * Whether to offer the option of importing user settings
51     *
52     * @access private
53     * @var bool
54     */
55    private $userprefsOfferImport;
56    /**
57     * The page title
58     *
59     * @access private
60     * @var string
61     */
62    private $title;
63    /**
64     * The value for the id attribute for the body tag
65     *
66     * @access private
67     * @var string
68     */
69    private $bodyId;
70    /**
71     * Whether to show the top menu
72     *
73     * @access private
74     * @var bool
75     */
76    private $menuEnabled;
77    /**
78     * Whether to show the warnings
79     *
80     * @access private
81     * @var bool
82     */
83    private $warningsEnabled;
84    /**
85     * Whether the page is in 'print view' mode
86     *
87     * @access private
88     * @var bool
89     */
90    private $isPrintView;
91    /**
92     * Whether we are servicing an ajax request.
93     *
94     * @access private
95     * @var bool
96     */
97    private $isAjax;
98    /**
99     * Whether to display anything
100     *
101     * @access private
102     * @var bool
103     */
104    private $isEnabled;
105    /**
106     * Whether the HTTP headers (and possibly some HTML)
107     * have already been sent to the browser
108     *
109     * @access private
110     * @var bool
111     */
112    private $headerIsSent;
113
114    /** @var UserPreferences */
115    private $userPreferences;
116
117    /** @var Template */
118    private $template;
119
120    /**
121     * Creates a new class instance
122     */
123    public function __construct()
124    {
125        global $db, $table;
126
127        $this->template = new Template();
128
129        $this->isEnabled = true;
130        $this->isAjax = false;
131        $this->bodyId = '';
132        $this->title = '';
133        $this->console = new Console();
134        $this->menu = new Menu(
135            $db ?? '',
136            $table ?? ''
137        );
138        $this->menuEnabled = true;
139        $this->warningsEnabled = true;
140        $this->isPrintView = false;
141        $this->scripts = new Scripts();
142        $this->addDefaultScripts();
143        $this->headerIsSent = false;
144        // if database storage for user preferences is transient,
145        // offer to load exported settings from localStorage
146        // (detection will be done in JavaScript)
147        $this->userprefsOfferImport = false;
148        if ($GLOBALS['PMA_Config']->get('user_preferences') === 'session'
149            && ! isset($_SESSION['userprefs_autoload'])
150        ) {
151            $this->userprefsOfferImport = true;
152        }
153
154        $this->userPreferences = new UserPreferences();
155    }
156
157    /**
158     * Loads common scripts
159     */
160    private function addDefaultScripts(): void
161    {
162        // Localised strings
163        $this->scripts->addFile('vendor/jquery/jquery.min.js');
164        $this->scripts->addFile('vendor/jquery/jquery-migrate.js');
165        $this->scripts->addFile('vendor/sprintf.js');
166        $this->scripts->addFile('ajax.js');
167        $this->scripts->addFile('keyhandler.js');
168        $this->scripts->addFile('vendor/bootstrap/bootstrap.bundle.min.js');
169        $this->scripts->addFile('vendor/jquery/jquery-ui.min.js');
170        $this->scripts->addFile('vendor/js.cookie.js');
171        $this->scripts->addFile('vendor/jquery/jquery.mousewheel.js');
172        $this->scripts->addFile('vendor/jquery/jquery.validate.js');
173        $this->scripts->addFile('vendor/jquery/jquery-ui-timepicker-addon.js');
174        $this->scripts->addFile('vendor/jquery/jquery.ba-hashchange-2.0.js');
175        $this->scripts->addFile('vendor/jquery/jquery.debounce-1.0.6.js');
176        $this->scripts->addFile('menu_resizer.js');
177
178        // Cross-framing protection
179        if ($GLOBALS['cfg']['AllowThirdPartyFraming'] === false) {
180            $this->scripts->addFile('cross_framing_protection.js');
181        }
182
183        $this->scripts->addFile('rte.js');
184        if ($GLOBALS['cfg']['SendErrorReports'] !== 'never') {
185            $this->scripts->addFile('vendor/tracekit.js');
186            $this->scripts->addFile('error_report.js');
187        }
188
189        // Here would not be a good place to add CodeMirror because
190        // the user preferences have not been merged at this point
191
192        $this->scripts->addFile('messages.php', ['l' => $GLOBALS['lang']]);
193        $this->scripts->addCode($this->getVariablesForJavaScript());
194        $this->scripts->addFile('config.js');
195        $this->scripts->addFile('doclinks.js');
196        $this->scripts->addFile('functions.js');
197        $this->scripts->addFile('navigation.js');
198        $this->scripts->addFile('indexes.js');
199        $this->scripts->addFile('common.js');
200        $this->scripts->addFile('page_settings.js');
201        if ($GLOBALS['cfg']['enable_drag_drop_import'] === true) {
202            $this->scripts->addFile('drag_drop_import.js');
203        }
204        if (! $GLOBALS['PMA_Config']->get('DisableShortcutKeys')) {
205            $this->scripts->addFile('shortcuts_handler.js');
206        }
207        $this->scripts->addCode($this->getJsParamsCode());
208    }
209
210    /**
211     * Returns, as an array, a list of parameters
212     * used on the client side
213     *
214     * @return array
215     */
216    public function getJsParams(): array
217    {
218        global $db, $table, $dbi;
219
220        $pftext = $_SESSION['tmpval']['pftext'] ?? '';
221
222        $params = [
223            // Do not add any separator, JS code will decide
224            'common_query' => Url::getCommonRaw([], ''),
225            'opendb_url' => Util::getScriptNameForOption(
226                $GLOBALS['cfg']['DefaultTabDatabase'],
227                'database'
228            ),
229            'lang' => $GLOBALS['lang'],
230            'server' => $GLOBALS['server'],
231            'table' => $table ?? '',
232            'db' => $db ?? '',
233            'token' => $_SESSION[' PMA_token '],
234            'text_dir' => $GLOBALS['text_dir'],
235            'show_databases_navigation_as_tree' => $GLOBALS['cfg']['ShowDatabasesNavigationAsTree'],
236            'pma_text_default_tab' => Util::getTitleForTarget(
237                $GLOBALS['cfg']['DefaultTabTable']
238            ),
239            'pma_text_left_default_tab' => Util::getTitleForTarget(
240                $GLOBALS['cfg']['NavigationTreeDefaultTabTable']
241            ),
242            'pma_text_left_default_tab2' => Util::getTitleForTarget(
243                $GLOBALS['cfg']['NavigationTreeDefaultTabTable2']
244            ),
245            'LimitChars' => $GLOBALS['cfg']['LimitChars'],
246            'pftext' => $pftext,
247            'confirm' => $GLOBALS['cfg']['Confirm'],
248            'LoginCookieValidity' => $GLOBALS['cfg']['LoginCookieValidity'],
249            'session_gc_maxlifetime' => (int) ini_get('session.gc_maxlifetime'),
250            'logged_in' => isset($dbi) ? $dbi->isConnected() : false,
251            'is_https' => $GLOBALS['PMA_Config']->isHttps(),
252            'rootPath' => $GLOBALS['PMA_Config']->getRootPath(),
253            'arg_separator' => Url::getArgSeparator(),
254            'PMA_VERSION' => PMA_VERSION,
255        ];
256        if (isset($GLOBALS['cfg']['Server'], $GLOBALS['cfg']['Server']['auth_type'])) {
257            $params['auth_type'] = $GLOBALS['cfg']['Server']['auth_type'];
258            if (isset($GLOBALS['cfg']['Server']['user'])) {
259                $params['user'] = $GLOBALS['cfg']['Server']['user'];
260            }
261        }
262
263        return $params;
264    }
265
266    /**
267     * Returns, as a string, a list of parameters
268     * used on the client side
269     */
270    public function getJsParamsCode(): string
271    {
272        $params = $this->getJsParams();
273        foreach ($params as $key => $value) {
274            if (is_bool($value)) {
275                $params[$key] = $key . ':' . ($value ? 'true' : 'false') . '';
276            } else {
277                $params[$key] = $key . ':"' . Sanitize::escapeJsString($value) . '"';
278            }
279        }
280
281        return 'CommonParams.setAll({' . implode(',', $params) . '});';
282    }
283
284    /**
285     * Disables the rendering of the header
286     */
287    public function disable(): void
288    {
289        $this->isEnabled = false;
290    }
291
292    /**
293     * Set the ajax flag to indicate whether
294     * we are servicing an ajax request
295     *
296     * @param bool $isAjax Whether we are servicing an ajax request
297     */
298    public function setAjax(bool $isAjax): void
299    {
300        $this->isAjax = $isAjax;
301        $this->console->setAjax($isAjax);
302    }
303
304    /**
305     * Returns the Scripts object
306     *
307     * @return Scripts object
308     */
309    public function getScripts(): Scripts
310    {
311        return $this->scripts;
312    }
313
314    /**
315     * Returns the Menu object
316     *
317     * @return Menu object
318     */
319    public function getMenu(): Menu
320    {
321        return $this->menu;
322    }
323
324    /**
325     * Setter for the ID attribute in the BODY tag
326     *
327     * @param string $id Value for the ID attribute
328     */
329    public function setBodyId(string $id): void
330    {
331        $this->bodyId = htmlspecialchars($id);
332    }
333
334    /**
335     * Setter for the title of the page
336     *
337     * @param string $title New title
338     */
339    public function setTitle(string $title): void
340    {
341        $this->title = htmlspecialchars($title);
342    }
343
344    /**
345     * Disables the display of the top menu
346     */
347    public function disableMenuAndConsole(): void
348    {
349        $this->menuEnabled = false;
350        $this->console->disable();
351    }
352
353    /**
354     * Disables the display of the top menu
355     */
356    public function disableWarnings(): void
357    {
358        $this->warningsEnabled = false;
359    }
360
361    /**
362     * Turns on 'print view' mode
363     */
364    public function enablePrintView(): void
365    {
366        $this->disableMenuAndConsole();
367        $this->setTitle(__('Print view') . ' - phpMyAdmin ' . PMA_VERSION);
368        $this->isPrintView = true;
369    }
370
371    /**
372     * Generates the header
373     *
374     * @return string The header
375     */
376    public function getDisplay(): string
377    {
378        global $db, $table, $PMA_Theme, $dbi;
379
380        if ($this->headerIsSent || ! $this->isEnabled) {
381            return '';
382        }
383
384        $recentTable = '';
385        if (empty($_REQUEST['recent_table'])) {
386            $recentTable = $this->addRecentTable($db, $table);
387        }
388
389        if ($this->isAjax) {
390            return $recentTable;
391        }
392
393        $this->sendHttpHeaders();
394
395        $baseDir = defined('PMA_PATH_TO_BASEDIR') ? PMA_PATH_TO_BASEDIR : '';
396        $uniqueValue = $GLOBALS['PMA_Config']->getThemeUniqueValue();
397        $themePath = $PMA_Theme !== null ? $PMA_Theme->getPath() : '';
398        $version = self::getVersionParameter();
399
400        // The user preferences have been merged at this point
401        // so we can conditionally add CodeMirror
402        if ($GLOBALS['cfg']['CodemirrorEnable']) {
403            $this->scripts->addFile('vendor/codemirror/lib/codemirror.js');
404            $this->scripts->addFile('vendor/codemirror/mode/sql/sql.js');
405            $this->scripts->addFile('vendor/codemirror/addon/runmode/runmode.js');
406            $this->scripts->addFile('vendor/codemirror/addon/hint/show-hint.js');
407            $this->scripts->addFile('vendor/codemirror/addon/hint/sql-hint.js');
408            if ($GLOBALS['cfg']['LintEnable']) {
409                $this->scripts->addFile('vendor/codemirror/addon/lint/lint.js');
410                $this->scripts->addFile(
411                    'codemirror/addon/lint/sql-lint.js'
412                );
413            }
414        }
415
416        $this->scripts->addCode(
417            'ConsoleEnterExecutes='
418            . ($GLOBALS['cfg']['ConsoleEnterExecutes'] ? 'true' : 'false')
419        );
420        $this->scripts->addFiles($this->console->getScripts());
421
422        if ($this->userprefsOfferImport) {
423            $this->scripts->addFile('config.js');
424        }
425
426        if ($this->menuEnabled && $GLOBALS['server'] > 0) {
427            $nav = new Navigation(
428                $this->template,
429                new Relation($dbi),
430                $dbi
431            );
432            $navigation = $nav->getDisplay();
433        }
434
435        $customHeader = Config::renderHeader();
436
437        // offer to load user preferences from localStorage
438        if ($this->userprefsOfferImport) {
439            $loadUserPreferences = $this->userPreferences->autoloadGetHeader();
440        }
441
442        if ($this->menuEnabled && $GLOBALS['server'] > 0) {
443            $menu = $this->menu->getDisplay();
444        }
445
446        $console = $this->console->getDisplay();
447        $messages = $this->getMessage();
448
449        return $this->template->render('header', [
450            'lang' => $GLOBALS['lang'],
451            'allow_third_party_framing' => $GLOBALS['cfg']['AllowThirdPartyFraming'],
452            'is_print_view' => $this->isPrintView,
453            'base_dir' => $baseDir,
454            'unique_value' => $uniqueValue,
455            'theme_path' => $themePath,
456            'version' => $version,
457            'text_dir' => $GLOBALS['text_dir'],
458            'server' => $GLOBALS['server'] ?? null,
459            'title' => $this->getPageTitle(),
460            'scripts' => $this->scripts->getDisplay(),
461            'body_id' => $this->bodyId,
462            'navigation' => $navigation ?? '',
463            'custom_header' => $customHeader,
464            'load_user_preferences' => $loadUserPreferences ?? '',
465            'show_hint' => $GLOBALS['cfg']['ShowHint'],
466            'is_warnings_enabled' => $this->warningsEnabled,
467            'is_menu_enabled' => $this->menuEnabled,
468            'menu' => $menu ?? '',
469            'console' => $console,
470            'messages' => $messages,
471            'recent_table' => $recentTable,
472        ]);
473    }
474
475    /**
476     * Returns the message to be displayed at the top of
477     * the page, including the executed SQL query, if any.
478     */
479    public function getMessage(): string
480    {
481        $retval = '';
482        $message = '';
483        if (! empty($GLOBALS['message'])) {
484            $message = $GLOBALS['message'];
485            unset($GLOBALS['message']);
486        } elseif (! empty($_REQUEST['message'])) {
487            $message = $_REQUEST['message'];
488        }
489        if (! empty($message)) {
490            if (isset($GLOBALS['buffer_message'])) {
491                $buffer_message = $GLOBALS['buffer_message'];
492            }
493            $retval .= Generator::getMessage($message);
494            if (isset($buffer_message)) {
495                $GLOBALS['buffer_message'] = $buffer_message;
496            }
497        }
498
499        return $retval;
500    }
501
502    /**
503     * Sends out the HTTP headers
504     */
505    public function sendHttpHeaders(): void
506    {
507        if (defined('TESTSUITE')) {
508            return;
509        }
510
511        /**
512         * Sends http headers
513         */
514        $GLOBALS['now'] = gmdate('D, d M Y H:i:s') . ' GMT';
515
516        /* Prevent against ClickJacking by disabling framing */
517        if (strtolower((string) $GLOBALS['cfg']['AllowThirdPartyFraming']) === 'sameorigin') {
518            header(
519                'X-Frame-Options: SAMEORIGIN'
520            );
521        } elseif ($GLOBALS['cfg']['AllowThirdPartyFraming'] !== true) {
522            header(
523                'X-Frame-Options: DENY'
524            );
525        }
526        header(
527            'Referrer-Policy: no-referrer'
528        );
529
530        $cspHeaders = $this->getCspHeaders();
531        foreach ($cspHeaders as $cspHeader) {
532            header($cspHeader);
533        }
534
535        // Re-enable possible disabled XSS filters
536        // see https://www.owasp.org/index.php/List_of_useful_HTTP_headers
537        header(
538            'X-XSS-Protection: 1; mode=block'
539        );
540        // "nosniff", prevents Internet Explorer and Google Chrome from MIME-sniffing
541        // a response away from the declared content-type
542        // see https://www.owasp.org/index.php/List_of_useful_HTTP_headers
543        header(
544            'X-Content-Type-Options: nosniff'
545        );
546        // Adobe cross-domain-policies
547        // see https://www.adobe.com/devnet/articles/crossdomain_policy_file_spec.html
548        header(
549            'X-Permitted-Cross-Domain-Policies: none'
550        );
551        // Robots meta tag
552        // see https://developers.google.com/webmasters/control-crawl-index/docs/robots_meta_tag
553        header(
554            'X-Robots-Tag: noindex, nofollow'
555        );
556        Core::noCacheHeader();
557        if (! defined('IS_TRANSFORMATION_WRAPPER')) {
558            // Define the charset to be used
559            header('Content-Type: text/html; charset=utf-8');
560        }
561        $this->headerIsSent = true;
562    }
563
564    /**
565     * If the page is missing the title, this function
566     * will set it to something reasonable
567     */
568    public function getPageTitle(): string
569    {
570        if (strlen($this->title) == 0) {
571            if ($GLOBALS['server'] > 0) {
572                if (strlen($GLOBALS['table'])) {
573                    $temp_title = $GLOBALS['cfg']['TitleTable'];
574                } elseif (strlen($GLOBALS['db'])) {
575                    $temp_title = $GLOBALS['cfg']['TitleDatabase'];
576                } elseif (strlen($GLOBALS['cfg']['Server']['host'])) {
577                    $temp_title = $GLOBALS['cfg']['TitleServer'];
578                } else {
579                    $temp_title = $GLOBALS['cfg']['TitleDefault'];
580                }
581                $this->title = htmlspecialchars(
582                    Util::expandUserString($temp_title)
583                );
584            } else {
585                $this->title = 'phpMyAdmin';
586            }
587        }
588
589        return $this->title;
590    }
591
592    /**
593     * Get all the CSP allow policy headers
594     *
595     * @return string[]
596     */
597    private function getCspHeaders(): array
598    {
599        global $cfg;
600
601        $mapTileUrls = ' *.tile.openstreetmap.org';
602        $captchaUrl = '';
603        $cspAllow = $cfg['CSPAllow'];
604
605        if (! empty($cfg['CaptchaApi'])
606            && ! empty($cfg['CaptchaRequestParam'])
607            && ! empty($cfg['CaptchaResponseParam'])
608            && ! empty($cfg['CaptchaLoginPrivateKey'])
609            && ! empty($cfg['CaptchaLoginPublicKey'])
610        ) {
611            $captchaUrl = ' ' . $cfg['CaptchaCsp'] . ' ';
612        }
613
614        return [
615
616            "Content-Security-Policy: default-src 'self' "
617                . $captchaUrl
618                . $cspAllow . ';'
619                . "script-src 'self' 'unsafe-inline' 'unsafe-eval' "
620                . $captchaUrl
621                . $cspAllow . ';'
622                . "style-src 'self' 'unsafe-inline' "
623                . $captchaUrl
624                . $cspAllow
625                . ';'
626                . "img-src 'self' data: "
627                . $cspAllow
628                . $mapTileUrls
629                . $captchaUrl
630                . ';'
631                . "object-src 'none';",
632
633            "X-Content-Security-Policy: default-src 'self' "
634                . $captchaUrl
635                . $cspAllow . ';'
636                . 'options inline-script eval-script;'
637                . 'referrer no-referrer;'
638                . "img-src 'self' data: "
639                . $cspAllow
640                . $mapTileUrls
641                . $captchaUrl
642                . ';'
643                . "object-src 'none';",
644
645            "X-WebKit-CSP: default-src 'self' "
646                . $captchaUrl
647                . $cspAllow . ';'
648                . "script-src 'self' "
649                . $captchaUrl
650                . $cspAllow
651                . " 'unsafe-inline' 'unsafe-eval';"
652                . 'referrer no-referrer;'
653                . "style-src 'self' 'unsafe-inline' "
654                . $captchaUrl
655                . ';'
656                . "img-src 'self' data: "
657                . $cspAllow
658                . $mapTileUrls
659                . $captchaUrl
660                . ';'
661                . "object-src 'none';",
662        ];
663    }
664
665    /**
666     * Add recently used table and reload the navigation.
667     *
668     * @param string $db    Database name where the table is located.
669     * @param string $table The table name
670     */
671    private function addRecentTable(string $db, string $table): string
672    {
673        $retval = '';
674        if ($this->menuEnabled
675            && strlen($table) > 0
676            && $GLOBALS['cfg']['NumRecentTables'] > 0
677        ) {
678            $tmp_result = RecentFavoriteTable::getInstance('recent')->add(
679                $db,
680                $table
681            );
682            if ($tmp_result === true) {
683                $retval = RecentFavoriteTable::getHtmlUpdateRecentTables();
684            } else {
685                $error  = $tmp_result;
686                $retval = $error->getDisplay();
687            }
688        }
689
690        return $retval;
691    }
692
693    /**
694     * Returns the phpMyAdmin version to be appended to the url to avoid caching
695     * between versions
696     *
697     * @return string urlencoded pma version as a parameter
698     */
699    public static function getVersionParameter(): string
700    {
701        return 'v=' . urlencode(PMA_VERSION);
702    }
703
704    private function getVariablesForJavaScript(): string
705    {
706        global $cfg, $PMA_Theme;
707
708        $maxInputVars = ini_get('max_input_vars');
709        $maxInputVarsValue = $maxInputVars === false || $maxInputVars === '' ? 'false' : (int) $maxInputVars;
710
711        return $this->template->render('javascript/variables', [
712            'first_day_of_calendar' => $cfg['FirstDayOfCalendar'] ?? 0,
713            'theme_image_path' => $PMA_Theme !== null ? $PMA_Theme->getImgPath() : '',
714            'max_input_vars' => $maxInputVarsValue,
715        ]);
716    }
717}
718