Passed
Pull Request — master (#7011)
by
unknown
14:48 queued 05:40
created

Diagnoser::e()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 1 Features 0
Metric Value
cc 1
eloc 1
c 1
b 1
f 0
nc 1
nop 1
dl 0
loc 3
rs 10
1
<?php
2
3
declare(strict_types=1);
4
5
/* For licensing terms, see /license.txt */
6
7
use Chamilo\CoreBundle\Enums\ActionIcon;
8
use Chamilo\CoreBundle\Enums\ObjectIcon;
9
use Chamilo\CoreBundle\Enums\StateIcon;
10
11
/**
12
 * Generates diagnostic information about the system.
13
 *
14
 * Notes:
15
 * - Adjusted to current path constants provided by the platform.
16
 * - Database section is DBAL-3 friendly (no getHost()).
17
 * - Courses space section prefers DB sum (resource_file) with safe fallbacks.
18
 */
19
class Diagnoser
20
{
21
    public const STATUS_OK = 1;
22
    public const STATUS_WARNING = 2;
23
    public const STATUS_ERROR = 3;
24
    public const STATUS_INFORMATION = 4;
25
26
    public function __construct() {}
27
28
    /**
29
     * Render diagnostics UI with Tailwind (no Bootstrap).
30
     * Drop-in replacement for show_html().
31
     */
32
    public function show_html(): void
33
    {
34
        // Section registry (label + short info)
35
        $sections = [
36
            'chamilo' => [
37
                'label' => 'Chamilo',
38
                'info' => 'State of Chamilo requirements',
39
                'icon' => 'mdi-cog-outline',
40
            ],
41
            'php' => [
42
                'label' => 'PHP',
43
                'info' => 'State of PHP settings on the server',
44
                'icon' => 'mdi-language-php',
45
            ],
46
            'database' => [
47
                'label' => 'Database',
48
                'info' => 'Database server configuration and metadata',
49
                'icon' => 'mdi-database',
50
            ],
51
            'webserver' => [
52
                'label' => get_lang('Web server'),
53
                'info' => 'Information about your webserver configuration',
54
                'icon' => 'mdi-server',
55
            ],
56
            'paths' => [
57
                'label' => 'Paths',
58
                'info' => 'api_get_path() constants resolved on this portal',
59
                'icon' => 'mdi-folder-outline',
60
            ],
61
            'courses_space' => [
62
                'label' => 'Courses space',
63
                'info' => 'Disk usage per course vs disk quota',
64
                'icon' => 'mdi-folder-cog-outline',
65
            ],
66
        ];
67
68
        $current = isset($_GET['section']) ? trim((string) $_GET['section']) : '';
69
        if (!array_key_exists($current, $sections)) {
70
            $current = 'chamilo';
71
        }
72
73
        // Header
74
        echo $this->tw_header(
75
            title: 'System status',
76
            subtitle: $sections[$current]['info']
77
        );
78
79
        // Icon cards navigation
80
        echo $this->tw_nav_cards($sections, $current);
81
82
        // Section notice
83
        echo $this->tw_notice($sections[$current]['info']);
84
85
        // Fetch data
86
        $method = 'get_'.$current.'_data';
87
        $data = call_user_func([$this, $method]);
88
89
        // Render per-section
90
        if ('paths' === $current) {
91
            // $data = ['headers' => [...], 'data' => [CONST => value, ...]]
92
            $headers = $data['headers'] ?? ['Path', 'constant'];
93
            $rows = [];
94
            foreach (($data['data'] ?? []) as $const => $value) {
95
                $rows[] = [$value, $const];
96
            }
97
            echo $this->tw_table(headers: $headers, rows: $rows, dense: false);
98
        } elseif ('courses_space' === $current) {
99
            // $data = list of rows: [homeLink, code, sizeMB, quotaMB, editLink, lastVisit, dirAbs]
100
            $headers = [
101
                '', get_lang('Course code'), 'Space used on disk (MB)',
102
                'Set max course space (MB)', get_lang('Edit'), get_lang('Latest visit'),
103
                get_lang('Current folder'),
104
            ];
105
            echo $this->tw_table(headers: $headers, rows: $data, dense: true);
106
        } else {
107
            // Generic 6-column dataset from build_setting()
108
            $headers = [
109
                '', get_lang('Section'), get_lang('Setting'),
110
                get_lang('Current'), get_lang('Expected'), get_lang('Comment'),
111
            ];
112
            echo $this->tw_table(headers: $headers, rows: $data, dense: true);
113
        }
114
    }
115
116
    /* ---------- Tailwind view helpers (pure HTML, no Bootstrap) ---------- */
117
118
    /**
119
     * Nice page header.
120
     */
121
    private function tw_header(string $title, string $subtitle): string
122
    {
123
        // Tailwind header with subtle divider
124
        return '
125
<div class="mb-6">
126
  <h1 class="text-2xl font-semibold text-gray-900">'.$this->e($title).'</h1>
127
  <p class="mt-1 text-sm text-gray-600">'.$this->e($subtitle).'</p>
128
  <div class="mt-4 h-px w-full bg-gray-30"></div>
129
</div>';
130
    }
131
132
    /**
133
     * Info/notice card.
134
     */
135
    private function tw_notice(string $text): string
136
    {
137
        // Blue info card
138
        return '
139
<div class="mb-6 rounded-xl border border-blue-200 bg-blue-50 p-4 text-blue-800">
140
  <div class="flex items-start gap-3">
141
    <i class="mdi mdi-information-outline text-xl leading-none"></i>
142
    <p class="text-sm">'.$text.'</p>
143
  </div>
144
</div>';
145
    }
146
147
    /**
148
     * Icon card navigation for sections.
149
     * Highlights current section with ring + bg.
150
     */
151
    private function tw_nav_cards(array $sections, string $current): string
152
    {
153
        $html = '<div class="mb-6 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-3">';
154
        foreach ($sections as $key => $meta) {
155
            $active = $key === $current;
156
            $ring = $active ? 'ring-2 ring-primary/80 bg-primary/5' : 'ring-1 ring-gray-200 hover:ring-gray-300';
157
            $txt = $active ? 'text-primary' : 'text-gray-700 group-hover:text-gray-900';
158
            $badge = $active ? '<span class="ml-auto rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">Active</span>' : '';
159
            $url = 'system_status.php?section='.$key;
160
161
            $html .= '
162
  <a href="'.$url.'" class="group block rounded-2xl bg-white p-4 shadow-sm hover:shadow-md transition '.$ring.'">
163
    <div class="flex items-center gap-3">
164
      <div class="flex h-12 w-12 items-center justify-center rounded-xl bg-gray-10 group-hover:bg-gray-30">
165
        <i class="mdi '.$this->e($meta['icon']).' text-2xl '.$txt.'"></i>
166
      </div>
167
      <div class="min-w-0">
168
        <div class="flex items-center gap-2">
169
          <h3 class="truncate text-sm font-semibold '.$txt.'">'.$this->e($meta['label']).'</h3>
170
          '.$badge.'
171
        </div>
172
        <p class="mt-0.5 line-clamp-2 text-xs text-gray-500">'.$this->e($meta['info']).'</p>
173
      </div>
174
    </div>
175
  </a>';
176
        }
177
        $html .= '</div>';
178
179
        return $html;
180
    }
181
182
    /**
183
     * Tailwind table renderer.
184
     * - sticky header
185
     * - subtle row separators
186
     * - optional dense mode.
187
     */
188
    private function tw_table(array $headers, array $rows, bool $dense = true): string
189
    {
190
        $thPad = $dense ? 'px-3 py-2' : 'px-4 py-3';
191
        $tdPad = $dense ? 'px-3 py-2' : 'px-4 py-3';
192
193
        $html = '
194
<div class="overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-sm">
195
  <div class="overflow-x-auto">
196
    <table class="min-w-full text-sm text-gray-800">
197
      <thead class="bg-gray-20 text-left text-xs font-semibold text-gray-600">
198
        <tr>';
199
200
        foreach ($headers as $h) {
201
            $html .= '<th scope="col" class="sticky top-0 z-10 '.$thPad.'">'.$h.'</th>';
202
        }
203
204
        $html .= '
205
        </tr>
206
      </thead>
207
      <tbody class="divide-y divide-gray-100">';
208
209
        foreach ($rows as $row) {
210
            $html .= '<tr class="hover:bg-gray-20">';
211
            foreach ($row as $cell) {
212
                // Allow HTML for icons/links already generated upstream
213
                $html .= '<td class="'.$tdPad.' align-top">'.(string) $cell.'</td>';
214
            }
215
            $html .= '</tr>';
216
        }
217
218
        $html .= '
219
      </tbody>
220
    </table>
221
  </div>
222
</div>';
223
224
        return $html;
225
    }
226
227
    /**
228
     * Simple HTML escaper for plain strings.
229
     */
230
    private function e(string $v): string
231
    {
232
        return htmlspecialchars($v, \ENT_QUOTES, 'UTF-8');
233
    }
234
235
    /**
236
     * Paths section (robust to current constant set).
237
     * Fix: do NOT use $paths[api_get_path(WEB_PATH)] as array key anymore.
238
     *
239
     * @return array{headers:array<string>,data:array<string,string>}
240
     */
241
    public function get_paths_data()
242
    {
243
        // Keep this list in sync with the provided platform constants.
244
        $constNames = [
245
            // Relative helpers
246
            'REL_CODE_PATH',
247
            'REL_COURSE_PATH',
248
            'REL_HOME_PATH',
249
250
            // Registered path types for api_get_path()
251
            'WEB_PATH',
252
            'SYS_PATH',
253
            'SYMFONY_SYS_PATH',
254
255
            'REL_PATH',
256
            'WEB_COURSE_PATH',
257
            'WEB_CODE_PATH',
258
            'SYS_CODE_PATH',
259
            'SYS_LANG_PATH',
260
            'WEB_IMG_PATH',
261
            'WEB_CSS_PATH',
262
            'WEB_PUBLIC_PATH',
263
            'SYS_CSS_PATH',
264
            'SYS_PLUGIN_PATH',
265
            'WEB_PLUGIN_PATH',
266
            'WEB_PLUGIN_ASSET_PATH',
267
            'SYS_ARCHIVE_PATH',
268
            'WEB_ARCHIVE_PATH',
269
            'LIBRARY_PATH',
270
            'CONFIGURATION_PATH',
271
            'WEB_LIBRARY_PATH',
272
            'WEB_LIBRARY_JS_PATH',
273
            'WEB_AJAX_PATH',
274
            'SYS_TEST_PATH',
275
            'SYS_TEMPLATE_PATH',
276
            'SYS_PUBLIC_PATH',
277
            'SYS_FONTS_PATH',
278
        ];
279
280
        $list = [];
281
        foreach ($constNames as $name) {
282
            if (defined($name)) {
283
                $value = api_get_path(constant($name));
284
                if (false !== $value && null !== $value && '' !== $value) {
285
                    // Map CONSTANT => resolved value
286
                    $list[$name] = $value;
287
                }
288
            }
289
        }
290
291
        // Sort by resolved path for readability, preserving keys (constants)
292
        asort($list);
293
294
        return [
295
            'headers' => ['Path', 'constant'],
296
            'data' => $list,
297
        ];
298
    }
299
300
    /**
301
     * Chamilo requirements snapshot.
302
     *
303
     * @return array<int,array>
304
     */
305
    public function get_chamilo_data()
306
    {
307
        $array = [];
308
        $writable_folders = [
309
            api_get_path(SYS_ARCHIVE_PATH).'cache',
310
            api_get_path(SYS_PATH).'upload/users/',
311
        ];
312
        foreach ($writable_folders as $folder) {
313
            $writable = is_writable($folder);
314
            $status = $writable ? self::STATUS_OK : self::STATUS_ERROR;
315
            $array[] = $this->build_setting(
316
                $status,
317
                '[FILES]',
318
                get_lang('Is writable').': '.$folder,
319
                'http://be2.php.net/manual/en/function.is-writable.php',
320
                $writable,
321
                1,
322
                'yes_no',
323
                get_lang('The directory must be writable by the web server')
324
            );
325
        }
326
327
        $exists = file_exists(api_get_path(SYS_CODE_PATH).'install');
328
        $status = $exists ? self::STATUS_WARNING : self::STATUS_OK;
329
        $array[] = $this->build_setting(
330
            $status,
331
            '[FILES]',
332
            get_lang('The directory exists').': /install',
333
            'http://be2.php.net/file_exists',
334
            $exists,
335
            0,
336
            'yes_no',
337
            get_lang('The directory should be removed (it is no longer necessary)')
338
        );
339
340
        $app_version = api_get_setting('platform.chamilo_database_version');
341
        $array[] = $this->build_setting(
342
            self::STATUS_INFORMATION,
343
            '[DB]',
344
            'chamilo_database_version',
345
            '#',
346
            $app_version,
347
            0,
348
            null,
349
            'Chamilo DB version'
350
        );
351
352
        $access_url_id = api_get_current_access_url_id();
353
354
        if (1 === $access_url_id) {
355
            $size = '-';
356
            $message2 = '';
357
358
            if (api_is_windows_os()) {
359
                $message2 .= get_lang('The space used on disk cannot be measured properly on Windows-based systems.');
360
            } else {
361
                $dir = api_get_path(SYS_PATH);
362
                $du = exec('du -sh '.escapeshellarg($dir), $err);
363
                if (str_contains($du, "\t")) {
364
                    list($size, $none) = explode("\t", $du, 2);
365
                    unset($none);
366
                }
367
368
                $limit = get_hosting_limit($access_url_id, 'disk_space');
369
                if (null === $limit) {
370
                    $limit = 0;
371
                }
372
373
                $message2 .= sprintf(get_lang('Total space used by portal %s limit is %s MB'), $size, $limit);
374
            }
375
376
            $array[] = $this->build_setting(
377
                self::STATUS_OK,
378
                '[FILES]',
379
                'hosting_limit_disk_space',
380
                '#',
381
                $size,
382
                0,
383
                null,
384
                $message2
385
            );
386
        }
387
388
        $new_version = '-';
389
        $new_version_status = '';
390
        $file = api_get_path(SYS_CODE_PATH).'install/version.php';
391
        if (is_file($file)) {
392
            @include $file;
393
        }
394
        $array[] = $this->build_setting(
395
            self::STATUS_INFORMATION,
396
            '[CONFIG]',
397
            get_lang('Version from the version file'),
398
            '#',
399
            $new_version.' '.$new_version_status,
400
            '-',
401
            null,
402
            get_lang('The version from the version.php file is updated with each version but only available if the main/install/ directory is present.')
403
        );
404
        $array[] = $this->build_setting(
405
            self::STATUS_INFORMATION,
406
            '[CONFIG]',
407
            get_lang('Version from the config file'),
408
            '#',
409
            api_get_configuration_value('system_version'),
410
            $new_version,
411
            null,
412
            get_lang('The version from the main configuration file shows on the main administration page, but has to be changed manually on upgrade.')
413
        );
414
415
        return $array;
416
    }
417
418
    /**
419
     * PHP settings snapshot.
420
     *
421
     * @return array<int,array>
422
     */
423
    public function get_php_data()
424
    {
425
        $array = [];
426
427
        $version = \PHP_VERSION;
428
        $status = $version > REQUIRED_PHP_VERSION ? self::STATUS_OK : self::STATUS_ERROR;
429
        $array[] = $this->build_setting(
430
            $status,
431
            '[PHP]',
432
            'phpversion()',
433
            'https://php.net/manual/en/function.phpversion.php',
434
            \PHP_VERSION,
435
            '>= '.REQUIRED_PHP_VERSION,
436
            null,
437
            get_lang('PHP version')
438
        );
439
440
        $setting = ini_get('output_buffering');
441
        $req_setting = 1;
442
        $status = $setting >= $req_setting ? self::STATUS_OK : self::STATUS_ERROR;
443
        $array[] = $this->build_setting(
444
            $status,
445
            '[INI]',
446
            'output_buffering',
447
            'https://php.net/manual/en/outcontrol.configuration.php#ini.output-buffering',
448
            $setting,
449
            $req_setting,
450
            'on_off',
451
            get_lang('Output buffering setting is "On" for being enabled or "Off" for being disabled. This setting also may be enabled through an integer value (4096 for example) which is the output buffer size.')
452
        );
453
454
        $setting = ini_get('file_uploads');
455
        $req_setting = 1;
456
        $status = $setting == $req_setting ? self::STATUS_OK : self::STATUS_ERROR;
457
        $array[] = $this->build_setting(
458
            $status,
459
            '[INI]',
460
            'file_uploads',
461
            'https://php.net/manual/en/ini.core.php#ini.file-uploads',
462
            $setting,
463
            $req_setting,
464
            'on_off',
465
            get_lang('File uploads indicate whether file uploads are authorized at all')
466
        );
467
468
        $setting = ini_get('magic_quotes_runtime');
469
        $req_setting = 0;
470
        $status = $setting == $req_setting ? self::STATUS_OK : self::STATUS_ERROR;
471
        $array[] = $this->build_setting(
472
            $status,
473
            '[INI]',
474
            'magic_quotes_runtime',
475
            'https://php.net/manual/en/ini.core.php#ini.magic-quotes-runtime',
476
            $setting,
477
            $req_setting,
478
            'on_off',
479
            get_lang('This is a highly unrecommended feature which converts values returned by all functions that returned external values to slash-escaped values. This feature should *not* be enabled.')
480
        );
481
482
        $setting = ini_get('safe_mode');
483
        $req_setting = 0;
484
        $status = $setting == $req_setting ? self::STATUS_OK : self::STATUS_WARNING;
485
        $array[] = $this->build_setting(
486
            $status,
487
            '[INI]',
488
            'safe_mode',
489
            'https://php.net/manual/en/ini.core.php#ini.safe-mode',
490
            $setting,
491
            $req_setting,
492
            'on_off',
493
            get_lang('Safe mode is a deprecated PHP feature which (badly) limits the access of PHP scripts to other resources. It is recommended to leave it off.')
494
        );
495
496
        $setting = ini_get('register_globals');
497
        $req_setting = 0;
498
        $status = $setting == $req_setting ? self::STATUS_OK : self::STATUS_ERROR;
499
        $array[] = $this->build_setting(
500
            $status,
501
            '[INI]',
502
            'register_globals',
503
            'https://php.net/manual/en/ini.core.php#ini.register-globals',
504
            $setting,
505
            $req_setting,
506
            'on_off',
507
            get_lang('Whether to use the register globals feature or not. Using it represents potential security risks with this software.')
508
        );
509
510
        $setting = ini_get('short_open_tag');
511
        $req_setting = 0;
512
        $status = $setting == $req_setting ? self::STATUS_OK : self::STATUS_WARNING;
513
        $array[] = $this->build_setting(
514
            $status,
515
            '[INI]',
516
            'short_open_tag',
517
            'https://php.net/manual/en/ini.core.php#ini.short-open-tag',
518
            $setting,
519
            $req_setting,
520
            'on_off',
521
            get_lang('Whether to allow for short open tags to be used or not. This feature should not be used.')
522
        );
523
524
        $setting = ini_get('magic_quotes_gpc');
525
        $req_setting = 0;
526
        $status = $setting == $req_setting ? self::STATUS_OK : self::STATUS_ERROR;
527
        $array[] = $this->build_setting(
528
            $status,
529
            '[INI]',
530
            'magic_quotes_gpc',
531
            'https://php.net/manual/en/ini.core.php#ini.magic_quotes_gpc',
532
            $setting,
533
            $req_setting,
534
            'on_off',
535
            get_lang('Whether to automatically escape values from GET, POST and COOKIES arrays. A similar feature is provided for the required data inside this software, so using it provokes double slash-escaping of values.')
536
        );
537
538
        $setting = ini_get('display_errors');
539
        $req_setting = 0;
540
        $status = $setting == $req_setting ? self::STATUS_OK : self::STATUS_WARNING;
541
        $array[] = $this->build_setting(
542
            $status,
543
            '[INI]',
544
            'display_errors',
545
            'https://php.net/manual/en/ini.core.php#ini.display_errors',
546
            $setting,
547
            $req_setting,
548
            'on_off',
549
            get_lang('Show errors on screen. Turn this on on development servers, off on production servers.')
550
        );
551
552
        $setting = ini_get('default_charset');
553
        if ('' == $setting) {
554
            $setting = null;
555
        }
556
        $req_setting = 'UTF-8';
557
        $status = $setting == $req_setting ? self::STATUS_OK : self::STATUS_ERROR;
558
        $array[] = $this->build_setting(
559
            $status,
560
            '[INI]',
561
            'default_charset',
562
            'https://php.net/manual/en/ini.core.php#ini.default-charset',
563
            $setting,
564
            $req_setting,
565
            null,
566
            get_lang('The default character set to be sent when returning pages')
567
        );
568
569
        $setting = ini_get('max_execution_time');
570
        $req_setting = '300 ('.get_lang('minimum').')';
571
        $status = $setting >= 300 ? self::STATUS_OK : self::STATUS_WARNING;
572
        $array[] = $this->build_setting(
573
            $status,
574
            '[INI]',
575
            'max_execution_time',
576
            'https://php.net/manual/en/ini.core.php#ini.max-execution-time',
577
            $setting,
578
            $req_setting,
579
            null,
580
            get_lang('Maximum time a script can take to execute. If using more than that, the script is abandoned to avoid slowing down other users.')
581
        );
582
583
        $setting = ini_get('max_input_time');
584
        $req_setting = '300 ('.get_lang('minimum').')';
585
        $status = $setting >= 300 ? self::STATUS_OK : self::STATUS_WARNING;
586
        $array[] = $this->build_setting(
587
            $status,
588
            '[INI]',
589
            'max_input_time',
590
            'https://php.net/manual/en/ini.core.php#ini.max-input-time',
591
            $setting,
592
            $req_setting,
593
            null,
594
            get_lang('The maximum time allowed for a form to be processed by the server. If it takes longer, the process is abandonned and a blank page is returned.')
595
        );
596
597
        $setting = ini_get('memory_limit');
598
        $req_setting = '>= '.REQUIRED_MIN_MEMORY_LIMIT.'M';
599
        $status = self::STATUS_ERROR;
600
        if ((float) $setting >= REQUIRED_MIN_MEMORY_LIMIT) {
601
            $status = self::STATUS_OK;
602
        }
603
        $array[] = $this->build_setting(
604
            $status,
605
            '[INI]',
606
            'memory_limit',
607
            'https://php.net/manual/en/ini.core.php#ini.memory-limit',
608
            $setting,
609
            $req_setting,
610
            null,
611
            get_lang('Maximum memory limit for one single script run. If the memory needed is higher, the process will stop to avoid consuming all the server\'s available memory and thus slowing down other users.')
612
        );
613
614
        $setting = ini_get('post_max_size');
615
        $req_setting = '>= '.REQUIRED_MIN_POST_MAX_SIZE.'M';
616
        $status = self::STATUS_ERROR;
617
        if ((float) $setting >= REQUIRED_MIN_POST_MAX_SIZE) {
618
            $status = self::STATUS_OK;
619
        }
620
        $array[] = $this->build_setting(
621
            $status,
622
            '[INI]',
623
            'post_max_size',
624
            'https://php.net/manual/en/ini.core.php#ini.post-max-size',
625
            $setting,
626
            $req_setting,
627
            null,
628
            get_lang('This is the maximum size of uploads through forms using the POST method (i.e. classical file upload forms)')
629
        );
630
631
        $setting = ini_get('upload_max_filesize');
632
        $req_setting = '>= '.REQUIRED_MIN_UPLOAD_MAX_FILESIZE.'M';
633
        $status = self::STATUS_ERROR;
634
        if ((float) $setting >= REQUIRED_MIN_UPLOAD_MAX_FILESIZE) {
635
            $status = self::STATUS_OK;
636
        }
637
        $array[] = $this->build_setting(
638
            $status,
639
            '[INI]',
640
            'upload_max_filesize',
641
            'https://php.net/manual/en/ini.core.php#ini.upload_max_filesize',
642
            $setting,
643
            $req_setting,
644
            null,
645
            get_lang('Maximum volume of an uploaded file. This setting should, most of the time, be matched with the post_max_size variable.')
646
        );
647
648
        $setting = ini_get('upload_tmp_dir');
649
        $status = self::STATUS_OK;
650
        $array[] = $this->build_setting(
651
            $status,
652
            '[INI]',
653
            'upload_tmp_dir',
654
            'https://php.net/manual/en/ini.core.php#ini.upload_tmp_dir',
655
            $setting,
656
            '',
657
            null,
658
            get_lang('The temporary upload directory is a space on the server where files are uploaded before being filtered and treated by PHP.')
659
        );
660
661
        $setting = ini_get('variables_order');
662
        $req_setting = 'GPCS';
663
        $status = $setting == $req_setting ? self::STATUS_OK : self::STATUS_ERROR;
664
        $array[] = $this->build_setting(
665
            $status,
666
            '[INI]',
667
            'variables_order',
668
            'https://php.net/manual/en/ini.core.php#ini.variables-order',
669
            $setting,
670
            $req_setting,
671
            null,
672
            get_lang('The order of precedence of Environment, GET, POST, COOKIES and SESSION variables')
673
        );
674
675
        $setting = ini_get('session.gc_maxlifetime');
676
        $req_setting = '4320';
677
        $status = $setting == $req_setting ? self::STATUS_OK : self::STATUS_WARNING;
678
        $array[] = $this->build_setting(
679
            $status,
680
            '[SESSION]',
681
            'session.gc_maxlifetime',
682
            'https://php.net/manual/en/ini.core.php#session.gc-maxlifetime',
683
            $setting,
684
            $req_setting,
685
            null,
686
            get_lang('The session garbage collector maximum lifetime indicates which maximum time is given between two runs of the garbage collector.')
687
        );
688
689
        $setting = api_check_browscap() ? true : false;
690
        $req_setting = true;
691
        $status = $setting == $req_setting ? self::STATUS_OK : self::STATUS_WARNING;
692
        $array[] = $this->build_setting(
693
            $status,
694
            '[INI]',
695
            'browscap',
696
            'https://php.net/manual/en/misc.configuration.php#ini.browscap',
697
            $setting,
698
            $req_setting,
699
            'on_off',
700
            get_lang('Browscap loading browscap.ini file that contains a large amount of data on the browser and its capabilities, so it can be used by the function get_browser () PHP')
701
        );
702
703
        // Extensions
704
        $extensions = [
705
            'curl' => ['link' => 'https://php.net/curl', 'expected' => 1, 'comment' => get_lang('This extension must be loaded.')],
706
            'exif' => ['link' => 'https://www.php.net/exif', 'expected' => 1, 'comment' => get_lang('This extension should be loaded.')],
707
            'fileinfo' => ['link' => 'https://php.net/fileinfo', 'expected' => 1, 'comment' => get_lang('This extension must be loaded.')],
708
            'gd' => ['link' => 'https://php.net/gd', 'expected' => 1, 'comment' => get_lang('This extension must be loaded.')],
709
            'ldap' => ['link' => 'https://php.net/ldap', 'expected' => 1, 'comment' => get_lang('This extension should be loaded.')],
710
            'mbstring' => ['link' => 'https://www.php.net/mbstring', 'expected' => 1, 'comment' => get_lang('This extension should be loaded.')],
711
            'pcre' => ['link' => 'https://php.net/pcre', 'expected' => 1, 'comment' => get_lang('This extension must be loaded.')],
712
            'pdo_mysql' => ['link' => 'https://php.net/manual/en/ref.pdo-mysql.php', 'expected' => 1, 'comment' => get_lang('This extension must be loaded.')],
713
            'session' => ['link' => 'https://php.net/session', 'expected' => 1, 'comment' => get_lang('This extension must be loaded.')],
714
            'standard' => ['link' => 'https://php.net/spl', 'expected' => 1, 'comment' => get_lang('This extension must be loaded.')],
715
            'zlib' => ['link' => 'https://php.net/zlib', 'expected' => 1, 'comment' => get_lang('This extension must be loaded.')],
716
            'apcu' => ['link' => 'https://php.net/apcu', 'expected' => 2, 'comment' => get_lang('This extension should be loaded.')],
717
            'bcmath' => ['link' => 'https://php.net/bcmath', 'expected' => 2, 'comment' => get_lang('This extension should be loaded.')],
718
            'OPcache' => ['link' => 'https://php.net/opcache', 'expected' => 2, 'comment' => get_lang('This extension should be loaded.')],
719
            'openssl' => ['link' => 'https://php.net/openssl', 'expected' => 2, 'comment' => get_lang('This extension should be loaded.')],
720
            'xsl' => ['link' => 'https://php.net/xsl', 'expected' => 2, 'comment' => get_lang('This extension should be loaded.')],
721
        ];
722
723
        foreach ($extensions as $extension => $data) {
724
            $url = $data['link'];
725
            $expected_value = $data['expected'];
726
            $comment = $data['comment'];
727
728
            $loaded = extension_loaded($extension);
729
            $status = $loaded ? self::STATUS_OK : self::STATUS_ERROR;
730
            $array[] = $this->build_setting(
731
                $status,
732
                '[EXTENSION]',
733
                get_lang('Extension loaded').': '.$extension,
734
                $url,
735
                $loaded,
736
                $expected_value,
737
                'yes_no_optional',
738
                $comment
739
            );
740
        }
741
742
        return $array;
743
    }
744
745
    /**
746
     * Database diagnostics (DBAL-3 friendly).
747
     * Fix: avoid Connection::getHost(); rely on params + platform.
748
     *
749
     * @return array<int,array>
750
     */
751
    public function get_database_data()
752
    {
753
        $array = [];
754
        $em = Database::getManager();
755
        $connection = $em->getConnection();
756
757
        // Prefer platform name (mysql, postgresql, sqlite, …)
758
        try {
759
            $driver = $connection->getDatabasePlatform()->getName();
760
        } catch (Throwable $e) {
761
            $driver = (method_exists($connection, 'getDriver') && method_exists($connection->getDriver(), 'getName'))
762
                ? $connection->getDriver()->getName()
763
                : 'unknown';
764
        }
765
766
        $params = method_exists($connection, 'getParams') ? (array) $connection->getParams() : [];
767
        $primary = isset($params['primary']) && is_array($params['primary']) ? $params['primary'] : $params;
768
769
        $host = $primary['host'] ?? ($primary['unix_socket'] ?? 'localhost');
770
        $port = $primary['port'] ?? null;
771
772
        try {
773
            $db = $connection->getDatabase();
774
        } catch (Throwable $e) {
775
            $db = $primary['dbname'] ?? ($primary['path'] ?? 'unknown');
776
        }
777
778
        $array[] = $this->build_setting(self::STATUS_INFORMATION, '[Database]', 'driver', '', $driver, null, null, get_lang('Driver'));
779
        $array[] = $this->build_setting(self::STATUS_INFORMATION, '[Database]', 'host', '', $host, null, null, get_lang('MySQL server host'));
780
        $array[] = $this->build_setting(self::STATUS_INFORMATION, '[Database]', 'port', '', (string) $port, null, null, get_lang('Port'));
781
        $array[] = $this->build_setting(self::STATUS_INFORMATION, '[Database]', 'Database name', '', $db, null, null, get_lang('Name'));
782
783
        return $array;
784
    }
785
786
    /**
787
     * Webserver snapshot.
788
     *
789
     * @return array<int,array>
790
     */
791
    public function get_webserver_data()
792
    {
793
        $array = [];
794
795
        $array[] = $this->build_setting(
796
            self::STATUS_INFORMATION,
797
            '[SERVER]',
798
            '$_SERVER["SERVER_NAME"]',
799
            'http://be.php.net/reserved.variables.server',
800
            $_SERVER['SERVER_NAME'] ?? '',
801
            null,
802
            null,
803
            get_lang('Server name (as used in your request)')
804
        );
805
        $array[] = $this->build_setting(
806
            self::STATUS_INFORMATION,
807
            '[SERVER]',
808
            '$_SERVER["SERVER_ADDR"]',
809
            'http://be.php.net/reserved.variables.server',
810
            $_SERVER['SERVER_ADDR'] ?? '',
811
            null,
812
            null,
813
            get_lang('Server address')
814
        );
815
        $array[] = $this->build_setting(
816
            self::STATUS_INFORMATION,
817
            '[SERVER]',
818
            '$_SERVER["SERVER_PORT"]',
819
            'http://be.php.net/reserved.variables.server',
820
            $_SERVER['SERVER_PORT'] ?? '',
821
            null,
822
            null,
823
            get_lang('Server port')
824
        );
825
        $array[] = $this->build_setting(
826
            self::STATUS_INFORMATION,
827
            '[SERVER]',
828
            '$_SERVER["SERVER_SOFTWARE"]',
829
            'http://be.php.net/reserved.variables.server',
830
            $_SERVER['SERVER_SOFTWARE'] ?? '',
831
            null,
832
            null,
833
            get_lang('Software running as a web server')
834
        );
835
        $array[] = $this->build_setting(
836
            self::STATUS_INFORMATION,
837
            '[SERVER]',
838
            '$_SERVER["REMOTE_ADDR"]',
839
            'http://be.php.net/reserved.variables.server',
840
            $_SERVER['REMOTE_ADDR'] ?? '',
841
            null,
842
            null,
843
            get_lang('Remote address (your address as received by the server)')
844
        );
845
        $array[] = $this->build_setting(
846
            self::STATUS_INFORMATION,
847
            '[SERVER]',
848
            '$_SERVER["HTTP_USER_AGENT"]',
849
            'http://be.php.net/reserved.variables.server',
850
            $_SERVER['HTTP_USER_AGENT'] ?? '',
851
            null,
852
            null,
853
            get_lang('Your user agent as received by the server')
854
        );
855
        $array[] = $this->build_setting(
856
            self::STATUS_INFORMATION,
857
            '[SERVER]',
858
            '$_SERVER["SERVER_PROTOCOL"]',
859
            'http://be.php.net/reserved.variables.server',
860
            $_SERVER['SERVER_PROTOCOL'] ?? '',
861
            null,
862
            null,
863
            get_lang('Protocol used by this server')
864
        );
865
        $array[] = $this->build_setting(
866
            self::STATUS_INFORMATION,
867
            '[SERVER]',
868
            'php_uname()',
869
            'http://be2.php.net/php_uname',
870
            php_uname(),
871
            null,
872
            null,
873
            get_lang('Information on the system the current server is running on')
874
        );
875
        $array[] = $this->build_setting(
876
            self::STATUS_INFORMATION,
877
            '[SERVER]',
878
            '$_SERVER["HTTP_X_FORWARDED_FOR"]',
879
            'http://be.php.net/reserved.variables.server',
880
            !empty($_SERVER['HTTP_X_FORWARDED_FOR']) ? $_SERVER['HTTP_X_FORWARDED_FOR'] : '',
881
            null,
882
            null,
883
            get_lang('If the server is behind a proxy or firewall (and only in those cases), it might be using the X_FORWARDED_FOR HTTP header to show the remote user IP (yours, in this case).')
884
        );
885
886
        return $array;
887
    }
888
889
    /**
890
     * Return "Courses space" rows using DB sums (no filesystem scan).
891
     * Columns (legacy order):
892
     * [ homeLink, code, usedMB, quotaMB, editLink, last_visit, absPathHint ].
893
     *
894
     * v2 notes:
895
     * - There is no per-course public folder anymore.
896
     * - We compute sizes from ResourceFile (rf.size) linked through ResourceNode/ResourceLink.
897
     * - Use rl.c_id (NOT rl.course_id).
898
     * - We de-duplicate per (course_id, rf.id) to avoid double counting in the same course.
899
     * - We do NOT include Asset (global) files as they are not course-scoped.
900
     */
901
    public function get_courses_space_data()
902
    {
903
        $rows = [];
904
905
        $em = Database::getManager();
906
        $conn = $em->getConnection();
907
908
        // Aggregate used bytes from ResourceFile (no FS scan).
909
        $sql = <<<'SQL'
910
        SELECT
911
            c.id,
912
            c.code,
913
            c.disk_quota,
914
            c.last_visit,
915
            COALESCE(SUM(u.size), 0) AS used_bytes
916
        FROM course c
917
        LEFT JOIN (
918
            SELECT DISTINCT
919
                rl.c_id   AS course_id,
920
                rf.id     AS rf_id,
921
                rf.size   AS size
922
            FROM resource_link rl
923
            INNER JOIN resource_node rn ON rn.id = rl.resource_node_id
924
            INNER JOIN resource_file rf ON rf.resource_node_id = rn.id
925
        ) u ON u.course_id = c.id
926
        GROUP BY c.id, c.code, c.disk_quota, c.last_visit
927
        ORDER BY c.last_visit DESC, c.code ASC
928
        LIMIT 1000
929
    SQL;
930
931
        $data = $conn->executeQuery($sql)->fetchAllAssociative();
932
933
        // Icons (no dead links in v2)
934
        $homeIcon = Display::getMdiIcon(ObjectIcon::HOME, 'ch-tool-icon', null, ICON_SIZE_SMALL, get_lang('Course homepage'));
935
        $editIcon = Display::getMdiIcon(ActionIcon::EDIT, 'ch-tool-icon', null, ICON_SIZE_SMALL, get_lang('Edit'));
936
        $editBase = api_get_path(WEB_CODE_PATH).'admin/course_edit.php?id=';
937
938
        // v2 has no per-course absolute folder; provide a neutral hint.
939
        $storageHint = 'resource storage (v2 via Vich/Flysystem)';
940
941
        // Resolve platform default course quota once (MB)
942
        $defaultQuotaMb = $this->resolveDefaultCourseQuotaMb();
943
944
        foreach ($data as $row) {
945
            // Used bytes -> MB, min 1MB if > 0 to keep legacy semantics
946
            $bytes = (int) ($row['used_bytes'] ?? 0);
947
            $usedMb = $bytes > 0 ? max(1, (int) ceil($bytes / (1024 * 1024))) : 0;
948
949
            // Quota: per-course override if set (>0) else platform default (MB)
950
            // c.disk_quota is stored in BYTES; convert to MB if >0
951
            $quotaMb = ((int) $row['disk_quota'] > 0)
952
                ? (int) $row['disk_quota']
953
                : $defaultQuotaMb;
954
955
            $homeLink = $homeIcon; // no href by default in v2
956
            $editLink = '<a href="'.$editBase.(int) $row['id'].'">'.$editIcon.'</a>';
957
958
            $rows[] = [
959
                $homeLink,
960
                $row['code'],
961
                $usedMb,
962
                $quotaMb,
963
                $editLink,
964
                $row['last_visit'],
965
                $storageHint,
966
            ];
967
        }
968
969
        return $rows;
970
    }
971
972
    /**
973
     * Resolve the platform's default course quota in MB (robust).
974
     * Tries, in order:
975
     *  1) SettingsManager (v2)
976
     *  2) api_get_setting() (legacy)
977
     *  3) DB table settings (direct)
978
     *  4) DocumentManager::get_course_quota() fallback.
979
     */
980
    private function resolveDefaultCourseQuotaMb(): int
981
    {
982
        // 1) v2 SettingsManager (if available)
983
        try {
984
            if (class_exists('Container') && method_exists('Container', 'getSettingsManager')) {
985
                $sm = Container::getSettingsManager();
986
                if ($sm) {
987
                    $candidates = [
988
                        'course.course_quota',                 // expected v2 key
989
                        'document.default_course_quota',       // legacy-friendly
990
                        'document.default_document_quota',
991
                        'document.default_document_quotum',    // v1 spelling
992
                    ];
993
                    foreach ($candidates as $key) {
994
                        $raw = (string) $sm->getSetting($key);
995
                        if ('' !== $raw && '0' !== $raw && null !== $raw) {
996
                            $mb = $this->parseQuotaRawToMb($raw);
997
                            if ($mb >= 0) {
998
                                return $mb;
999
                            }
1000
                        }
1001
                    }
1002
                }
1003
            }
1004
        } catch (Throwable $e) {
1005
            // Ignore and continue with other strategies
1006
        }
1007
1008
        // 2) api_get_setting() (legacy accessor)
1009
        $candidates = [
1010
            'course.course_quota',
1011
            'document.default_course_quota',
1012
            'document.default_document_quota',
1013
            'document.default_document_quotum',
1014
        ];
1015
        foreach ($candidates as $key) {
1016
            $raw = api_get_setting($key);
1017
            if (false !== $raw && null !== $raw && '' !== $raw) {
1018
                $mb = $this->parseQuotaRawToMb((string) $raw);
1019
                if ($mb >= 0) {
1020
                    return $mb;
1021
                }
1022
            }
1023
        }
1024
1025
        // 3) Direct DB read from settings (works on most v1/v2 installs)
1026
        try {
1027
            $em = Database::getManager();
1028
            $conn = $em->getConnection();
1029
1030
            foreach ($candidates as $key) {
1031
                $val = $conn->fetchOne(
1032
                    'SELECT value FROM settings WHERE variable = ? LIMIT 1',
1033
                    [$key]
1034
                );
1035
                if (false !== $val && null !== $val && '' !== $val) {
1036
                    $mb = $this->parseQuotaRawToMb((string) $val);
1037
                    if ($mb >= 0) {
1038
                        return $mb;
1039
                    }
1040
                }
1041
            }
1042
        } catch (Throwable $e) {
1043
            // Ignore and continue
1044
        }
1045
1046
        // 4) Last resort: ask DocumentManager (may return platform default)
1047
        try {
1048
            if (class_exists('DocumentManager') && method_exists('DocumentManager', 'get_course_quota')) {
1049
                $v = DocumentManager::get_course_quota(); // usually returns MB
1050
                if (is_numeric($v)) {
1051
                    $mb = $this->parseQuotaRawToMb((string) $v);
1052
                    if ($mb >= 0) {
1053
                        return $mb;
1054
                    }
1055
                }
1056
            }
1057
        } catch (Throwable $e) {
1058
            // Ignore
1059
        }
1060
1061
        // Nothing found => keep legacy semantics (0 = no explicit default)
1062
        return 0;
1063
    }
1064
1065
    /**
1066
     * Parse a quota raw value into MB.
1067
     * Accepts:
1068
     *  - "500"               -> 500 MB
1069
     *  - "1G", "1GB", "1 g"  -> 1024 MB
1070
     *  - "200M", "200MB"     -> 200 MB
1071
     *  - large integers      -> assumed BYTES, converted to MB
1072
     *  - strings with noise  -> extracts digits & unit heuristically.
1073
     */
1074
    private function parseQuotaRawToMb(string $raw): int
1075
    {
1076
        $s = strtolower(trim($raw));
1077
1078
        // Pure integer?
1079
        if (preg_match('/^\d+$/', $s)) {
1080
            $num = (int) $s;
1081
1082
            // Heuristic: if looks like bytes (>= 1MB in bytes), convert to MB.
1083
            return ($num >= 1048576) ? (int) ceil($num / 1048576) : $num;
1084
        }
1085
1086
        // <number><unit> where unit is m/mb or g/gb
1087
        if (preg_match('/^\s*(\d+)\s*([mg])(?:b)?\s*$/i', $s, $m)) {
1088
            $num = (int) $m[1];
1089
            $unit = strtolower($m[2]);
1090
1091
            return 'g' === $unit ? $num * 1024 : $num;
1092
        }
1093
1094
        // Extract digits for numbers hidden inside strings (e.g. "500 MB", "524288000 bytes", etc.)
1095
        if (preg_match('/(\d+)/', $s, $m)) {
1096
            $num = (int) $m[1];
1097
1098
            return ($num >= 1048576) ? (int) ceil($num / 1048576) : $num;
1099
        }
1100
1101
        // Unknown
1102
        return 0;
1103
    }
1104
1105
    /**
1106
     * Count courses (simple and fast).
1107
     */
1108
    public function get_courses_space_count(): int
1109
    {
1110
        $em = Database::getManager();
1111
        $conn = $em->getConnection();
1112
1113
        $sql = 'SELECT COUNT(*) AS cnt FROM course';
1114
1115
        return (int) $conn->executeQuery($sql)->fetchOne();
1116
    }
1117
1118
    /**
1119
     * Helper to normalize a diagnostic row.
1120
     *
1121
     * @param int         $status
1122
     * @param string      $section
1123
     * @param string      $title
1124
     * @param string      $url
1125
     * @param mixed       $current_value
1126
     * @param mixed       $expected_value
1127
     * @param string|null $formatter
1128
     * @param string      $comment
1129
     *
1130
     * @return array
1131
     */
1132
    public function build_setting(
1133
        $status,
1134
        $section,
1135
        $title,
1136
        $url,
1137
        $current_value,
1138
        $expected_value,
1139
        $formatter,
1140
        $comment
1141
    ) {
1142
        switch ($status) {
1143
            case self::STATUS_OK:
1144
                $img = StateIcon::COMPLETE;
1145
1146
                break;
1147
1148
            case self::STATUS_WARNING:
1149
                $img = StateIcon::WARNING;
1150
1151
                break;
1152
1153
            case self::STATUS_ERROR:
1154
                $img = StateIcon::ERROR;
1155
1156
                break;
1157
1158
            case self::STATUS_INFORMATION:
1159
            default:
1160
                $img = ActionIcon::INFORMATION;
1161
1162
                break;
1163
        }
1164
1165
        $image = Display::getMdiIcon($img, 'ch-tool-icon', null, ICON_SIZE_SMALL, $title);
1166
        $url = $this->get_link($title, $url);
1167
1168
        $formatted_current_value = $current_value;
1169
        $formatted_expected_value = $expected_value;
1170
1171
        if ($formatter) {
1172
            if (method_exists($this, 'format_'.$formatter)) {
1173
                $formatted_current_value = call_user_func([$this, 'format_'.$formatter], $current_value);
1174
                $formatted_expected_value = call_user_func([$this, 'format_'.$formatter], $expected_value);
1175
            }
1176
        }
1177
1178
        return [$image, $section, $url, $formatted_current_value, $formatted_expected_value, $comment];
1179
    }
1180
1181
    /**
1182
     * Create an anchor HTML element.
1183
     *
1184
     * @param string $title
1185
     * @param string $url
1186
     *
1187
     * @return string
1188
     */
1189
    public function get_link($title, $url)
1190
    {
1191
        // Use about:blank (the legacy had a typo "about:bank")
1192
        return '<a href="'.$url.'" target="about:blank">'.$title.'</a>';
1193
    }
1194
1195
    /**
1196
     * @param int $value
1197
     *
1198
     * @return string
1199
     */
1200
    public function format_yes_no_optional($value)
1201
    {
1202
        $return = '';
1203
1204
        switch ($value) {
1205
            case 0:
1206
                $return = get_lang('No');
1207
1208
                break;
1209
1210
            case 1:
1211
                $return = get_lang('Yes');
1212
1213
                break;
1214
1215
            case 2:
1216
                $return = get_lang('Optional');
1217
1218
                break;
1219
        }
1220
1221
        return $return;
1222
    }
1223
1224
    /**
1225
     * @param mixed $value
1226
     *
1227
     * @return string
1228
     */
1229
    public function format_yes_no($value)
1230
    {
1231
        return $value ? get_lang('Yes') : get_lang('No');
1232
    }
1233
1234
    /**
1235
     * @param int $value
1236
     *
1237
     * @return string|int
1238
     */
1239
    public function format_on_off($value)
1240
    {
1241
        $value = (int) $value;
1242
        if ($value > 1) {
1243
            // Greater than 1 values are shown "as-is", they may be interpreted as "On" later.
1244
            return $value;
1245
        }
1246
1247
        // 'On'/'Off' as in php.ini; not translated.
1248
        return $value ? 'On' : 'Off';
1249
    }
1250
}
1251