1<?php
2
3namespace dokuwiki\plugin\filelist;
4
5class Path
6{
7    protected $paths = [];
8
9    /**
10     * @param string $pathConfig The path configuration ftom the plugin settings
11     */
12    public function __construct($pathConfig)
13    {
14        $this->paths = $this->parsePathConfig($pathConfig);
15    }
16
17    /**
18     * Access the parsed paths
19     *
20     * @return array
21     */
22    public function getPaths()
23    {
24        return $this->paths;
25    }
26
27    /**
28     * Parse the path configuration into an internal array
29     *
30     * roots (and aliases) are always saved with a trailing slash
31     *
32     * @return array
33     */
34    protected function parsePathConfig($pathConfig)
35    {
36        $paths = [];
37        $lines = explode("\n", $pathConfig);
38        $lastRoot = '';
39        foreach ($lines as $line) {
40            $line = trim($line);
41            if (empty($line)) {
42                continue;
43            }
44
45            if (str_starts_with($line, 'A>')) {
46                // this is an alias for the last read root
47                $line = trim(substr($line, 2));
48                if (!isset($paths[$lastRoot])) continue; // no last root, no alias
49                $alias = static::cleanPath($line);
50                $paths[$lastRoot]['alias'] = $alias;
51                $paths[$alias] = &$paths[$lastRoot]; // alias references the original
52            } elseif (str_starts_with($line, 'W>')) {
53                // this is a web path for the last read root
54                $line = trim(substr($line, 2));
55                if (!isset($paths[$lastRoot])) continue; // no last path, no web path
56                $paths[$lastRoot]['web'] = $line;
57            } else {
58                // this is a new path
59                $line = static::cleanPath($line);
60                $lastRoot = $line;
61                $paths[$line] = [
62                    'root' => $line,
63                    'web' => DOKU_BASE . 'lib/plugins/filelist/file.php?root=' . rawurlencode($line) . '&file=',
64                ];
65            }
66        }
67
68        return $paths;
69    }
70
71    /**
72     * Check if a given path is listable and return it's configuration
73     *
74     * @param string $path
75     * @param bool $addTrailingSlash
76     * @return array
77     * @throws \Exception if the given path is not allowed
78     */
79    public function getPathInfo($path, $addTrailingSlash = true)
80    {
81        $path = static::cleanPath($path, $addTrailingSlash);
82
83        $paths = $this->paths;
84        if ($paths === []) {
85            throw new \Exception('No paths configured');
86        }
87
88        $allowed = array_keys($paths);
89        usort($allowed, static fn($a, $b) => strlen($a) - strlen($b));
90        $allowed = array_map('preg_quote_cb', $allowed);
91        $regex = '/^(' . implode('|', $allowed) . ')/';
92
93        if (!preg_match($regex, $path, $matches)) {
94            throw new \Exception('Path not allowed: ' . $path);
95        }
96        $match = $matches[1];
97
98        $pathInfo = $paths[$match];
99        $pathInfo['local'] = substr($path, strlen($match));
100        $pathInfo['path'] = static::toAbsolute($pathInfo['root'] . $pathInfo['local']);
101
102
103        return $pathInfo;
104    }
105
106    /**
107     * Clean a path for better comparison
108     *
109     * Converts all backslashes to forward slashes
110     * Keeps leading double backslashes for UNC paths
111     * Ensure a single trailing slash unless disabled
112     *
113     * @param string $path
114     * @return string
115     */
116    public static function cleanPath($path, $addTrailingSlash = true)
117    {
118        if (str_starts_with($path, '\\\\')) {
119            $unc = '\\\\';
120        } else {
121            $unc = '';
122        }
123        $path = ltrim($path, '\\');
124        $path = str_replace('\\', '/', $path);
125        $path = self::realpath($path);
126        if ($addTrailingSlash) {
127            $path = rtrim($path, '/');
128            $path .= '/';
129        }
130
131        return $unc . $path;
132    }
133
134    /**
135     * Canonicalizes a given path. A bit like realpath, but without the resolving of symlinks.
136     *
137     * @author anonymous
138     * @see <http://www.php.net/manual/en/function.realpath.php#73563>
139     */
140    public static function realpath($path)
141    {
142        $path = explode('/', $path);
143        $output = [];
144        $counter = count($path);
145        for ($i = 0; $i < $counter; $i++) {
146            if ('.' == $path[$i]) continue;
147            if ('' === $path[$i] && $i > 0) continue;
148            if ('..' == $path[$i] && '..' != ($output[count($output) - 1] ?? '')) {
149                array_pop($output);
150                continue;
151            }
152            $output[] = $path[$i];
153        }
154        return implode('/', $output);
155    }
156
157    /**
158     * Check if the given (already cleaned) path is absolute
159     *
160     * Recognizes unix paths, Windows drive letters and UNC paths.
161     *
162     * @param string $path an already cleaned path
163     * @return bool
164     */
165    public static function isAbsolute($path)
166    {
167        if (str_starts_with($path, '/')) return true; // unix
168        if (str_starts_with($path, '\\\\')) return true; // UNC
169        if (preg_match('/^[a-zA-Z]:/', $path)) return true; // windows drive letter
170        return false;
171    }
172
173    /**
174     * Resolve a (cleaned) path to an absolute one
175     *
176     * Relative paths are resolved against the DokuWiki directory (DOKU_INC), the same way
177     * DokuWiki itself treats relative config paths. This makes path handling independent of
178     * the current working directory, which otherwise differs between the wiki renderer
179     * (doku.php, cwd = wiki root) and the file delivery script (file.php, cwd = plugin dir).
180     *
181     * Absolute paths (unix, Windows drive letters, UNC) are returned unchanged.
182     *
183     * @param string $path an already cleaned path
184     * @return string
185     */
186    public static function toAbsolute($path)
187    {
188        if (self::isAbsolute($path)) {
189            return $path;
190        }
191        return self::cleanPath(DOKU_INC, false) . '/' . $path;
192    }
193
194    /**
195     * Check if the given path is within the data or dokuwiki dir
196     *
197     * This whould prevent accidental or deliberate circumvention of the ACLs
198     *
199     * Both the given path and the wiki/data directories are resolved to absolute paths
200     * before comparison. Without this, a relatively configured root (e.g. "firmware") would
201     * never match the absolute DOKU_INC and the check would be silently bypassed.
202     *
203     * @param string $path and already cleaned path
204     * @return bool
205     */
206    public static function isWikiControlled($path)
207    {
208        global $conf;
209        $path = self::toAbsolute($path);
210
211        $dataPath = self::toAbsolute(self::cleanPath($conf['savedir']));
212        if (str_starts_with($path, $dataPath)) {
213            return true;
214        }
215        $wikiDir = self::toAbsolute(self::cleanPath(DOKU_INC));
216        if (str_starts_with($path, $wikiDir)) {
217            return true;
218        }
219        return false;
220    }
221}
222