1<?php /** @noinspection DuplicatedCode */
2/**
3 * Mikio CSS/LESS Engine (hardened)
4 * @link    http://dokuwiki.org/template:mikio
5 * @license GPLv2
6 * @author  James Collins
7 */
8
9require(__DIR__ . '/inc/polyfill-ctype.php');
10
11if (!class_exists('lessc')) {
12    require(__DIR__ . '/inc/stemmechanics/lesserphp/lessc.inc.php');
13}
14
15function logInvalidRequest($reason, $input) {
16    error_log("[mikio css.php] $reason | input: $input | IP: " . ($_SERVER['REMOTE_ADDR'] ?? 'unknown'));
17}
18
19function arrayDeepMerge($arr1, $arr2) {
20    foreach ($arr2 as $key => $value) {
21        if (isset($arr1[$key]) && is_array($arr1[$key]) && is_array($value)) {
22            $arr1[$key] = arrayDeepMerge($arr1[$key], $value);
23        } else {
24            $arr1[$key] = $value;
25        }
26    }
27    return $arr1;
28}
29
30try {
31    if (!isset($_GET['css'])) {
32        http_response_code(404);
33        echo "The requested file could not be found";
34        exit;
35    }
36
37    $themeRoot = realpath(__DIR__ . '/');
38    if ($themeRoot === false) {
39        throw new RuntimeException('Theme root resolution failed');
40    }
41
42    // Only allow CSS/LESS inside these theme subdirectories
43    $allowedDirs = [
44        realpath($themeRoot . '/assets'),
45        realpath($themeRoot . '/styles'),
46        realpath($themeRoot . '/css'),
47    ];
48    $allowedExtensions = ['css', 'less'];
49
50    $css = '';
51    $failed = false;
52
53    // Support a comma-separated list like your plugin fix
54    $cssFileList = explode(',', $_GET['css']);
55
56    foreach ($cssFileList as $rawInput) {
57        // Strip query/hash and basic traversal chars
58        $clean = explode('?', $rawInput, 2)[0];
59        $clean = trim(str_replace(['..', '\\'], '', $clean));
60
61        if ($clean === '') {
62            $failed = true;
63            logInvalidRequest('Empty or invalid path', $rawInput);
64            continue;
65        }
66
67        $resolved = realpath($themeRoot . '/' . ltrim($clean, '/'));
68        if (!$resolved || !is_file($resolved)) {
69            $failed = true;
70            logInvalidRequest('Invalid file path', $rawInput);
71            continue;
72        }
73
74        $ext = strtolower(pathinfo($resolved, PATHINFO_EXTENSION));
75        if (!in_array($ext, $allowedExtensions, true)) {
76            $failed = true;
77            logInvalidRequest('Disallowed extension', $rawInput);
78            continue;
79        }
80
81        // Enforce allowed directories
82        $inside = false;
83        foreach ($allowedDirs as $dir) {
84            if ($dir && strpos($resolved, $dir) === 0) {
85                $inside = true;
86                break;
87            }
88        }
89        if (!$inside) {
90            $failed = true;
91            logInvalidRequest('File outside allowed directories', $rawInput);
92            continue;
93        }
94
95        $css .= file_get_contents($resolved);
96    }
97
98    if ($failed) {
99        http_response_code(404);
100        echo "The requested file could not be found";
101        exit;
102    }
103
104    // Load style.ini replacements from trusted locations
105    $rawVars = [];
106    $iniCandidates = [
107        $themeRoot . '/style.ini',
108        dirname($themeRoot, 3) . '/conf/tpl/mikio/style.ini' ?: null,
109        (isset($_SERVER['DOCUMENT_ROOT']) ? ($_SERVER['DOCUMENT_ROOT'] . '/conf/tpl/mikio/style.ini') : null),
110    ];
111
112    foreach ($iniCandidates as $ini) {
113        if ($ini && is_file($ini)) {
114            $parsed = @parse_ini_file($ini, true);
115            if (is_array($parsed)) {
116                $rawVars = arrayDeepMerge($rawVars, $parsed);
117            }
118        }
119    }
120
121    header('Content-Type: text/css; charset=utf-8');
122
123    $less = new lessc();
124    $less->setPreserveComments(false);
125
126    // Map __FOO__ => ini_FOO variables just like before
127    $vars = [];
128    if (isset($rawVars['replacements']) && is_array($rawVars['replacements'])) {
129        foreach ($rawVars['replacements'] as $k => $v) {
130            if (strpos($k, '__') === 0 && substr($k, -2) === '__') {
131                $vars['ini_' . substr($k, 2, -2)] = $v;
132            }
133        }
134    }
135    if ($vars) {
136        $less->setVariables($vars);
137    }
138
139    echo $less->compile($css);
140
141} catch (Throwable $e) {
142    // Log server-side; no path/stack to client
143    error_log('[mikio css.php] Exception: ' . $e->getMessage());
144    http_response_code(500);
145    header('Content-Type: text/css; charset=utf-8');
146    echo "/* An error occurred while processing the CSS. */";
147}