1<?php
2
3// phpcs:disable PSR1.Files.SideEffects
4
5/**
6 * Core Manager for the Farm functionality
7 *
8 * This class is initialized before any other DokuWiki code runs. Therefore it is
9 * completely selfcontained and does not use any of DokuWiki's utility functions.
10 *
11 * It's registered as a global $FARMCORE variable but you should not interact with
12 * it directly. Instead use the Farmer plugin's helper component.
13 *
14 * @license GPL 2 http://www.gnu.org/licenses/gpl-2.0.html
15 * @author  Andreas Gohr <gohr@cosmocode.de>
16 */
17class DokuWikiFarmCore
18{
19    /**
20     * @var array The default config - changed by loadConfig
21     */
22    protected $config = [
23        'base' => [
24            'farmdir' => '',
25            'farmhost' => '',
26            'basedomain' => ''
27        ],
28        'notfound' => [
29            'show' => 'farmer',
30            'url' => ''
31        ],
32        'inherit' => [
33            'main' => 1,
34            'acronyms' => 1,
35            'entities' => 1,
36            'interwiki' => 1,
37            'license' => 1,
38            'mime' => 1,
39            'scheme' => 1,
40            'smileys' => 1,
41            'wordblock' => 1,
42            'users' => 0,
43            'plugins' => 0,
44            'userstyle' => 0,
45            'userscript' => 0,
46            'styleini' => 0
47        ]
48    ];
49
50    /** @var string|false The current animal, false for farmer */
51    protected $animal = false;
52    /** @var bool true if an animal was requested but was not found */
53    protected $notfound = false;
54    /** @var bool true if the current animal was requested by host */
55    protected $hostbased = false;
56
57    /**
58     * DokuWikiFarmCore constructor.
59     *
60     * This initializes the whole farm by loading the configuration and setting
61     * DOKU_CONF depending on the requested animal
62     */
63    public function __construct()
64    {
65        $this->loadConfig();
66        if ($this->config['base']['farmdir'] === '') return; // farm setup not complete
67        $this->config['base']['farmdir'] = rtrim($this->config['base']['farmdir'], '/') . '/'; // trailing slash always
68        define('DOKU_FARMDIR', $this->config['base']['farmdir']);
69
70        // animal?
71        $this->detectAnimal();
72
73        // setup defines
74        define('DOKU_FARM_ANIMAL', $this->animal);
75        if ($this->animal) {
76            define('DOKU_CONF', DOKU_FARMDIR . $this->animal . '/conf/');
77        } else {
78            define('DOKU_CONF', DOKU_INC . '/conf/');
79        }
80
81        $this->setupCascade();
82        $this->adjustCascade();
83    }
84
85    /**
86     * @return array the current farm configuration
87     */
88    public function getConfig()
89    {
90        return $this->config;
91    }
92
93    /**
94     * @return false|string
95     */
96    public function getAnimal()
97    {
98        return $this->animal;
99    }
100
101    /**
102     * @return boolean
103     */
104    public function isHostbased()
105    {
106        return $this->hostbased;
107    }
108
109    /**
110     * @return boolean
111     */
112    public function wasNotfound()
113    {
114        return $this->notfound;
115    }
116
117    /**
118     * @return string
119     */
120    public function getAnimalDataDir()
121    {
122        return DOKU_FARMDIR . $this->getAnimal() . '/data/';
123    }
124
125    /**
126     * @return string
127     */
128    public function getAnimalBaseDir()
129    {
130        if ($this->isHostbased()) return '/';
131        return getBaseURL() . '!' . $this->getAnimal();
132    }
133
134    /**
135     * Set the animal
136     *
137     * Checks if the animal exists and is a valid directory name.
138     *
139     * @param mixed $animal the animal name
140     * @return bool returns true if the animal was set successfully, false otherwise
141     */
142    protected function setAnimal($animal)
143    {
144        $farmdir = $this->config['base']['farmdir'];
145
146        // invalid animal stuff is always a not found
147        if (!is_string($animal) || strpbrk($animal, '\\/') !== false) {
148            $this->notfound = true;
149            return false;
150        }
151        $animal = strtolower($animal);
152
153        // check if animal exists
154        if (is_dir("$farmdir/$animal/conf")) {
155            $this->animal = $animal;
156            $this->notfound = false;
157            return true;
158        } else {
159            $this->notfound = true;
160            return false;
161        }
162    }
163
164    /**
165     * Detect the animal from the given query string
166     *
167     * This removes the animal parameter from the given string and sets the animal
168     *
169     * @param string $queryString The query string to extract the animal from, will be modified
170     * @return bool true if the animal was set successfully, false otherwise
171     */
172    protected function detectAnimalFromQueryString(string &$queryString): bool
173    {
174        $params = [];
175        parse_str($queryString, $params);
176        if (!isset($params['animal'])) return false;
177        $animal = $params['animal'];
178        unset($params['animal']);
179        $queryString = http_build_query($params);
180
181        $this->hostbased = false;
182        return $this->setAnimal($animal);
183    }
184
185    /**
186     * Detect the animal from the bang path
187     *
188     * This is used to detect the animal from a bang path like `/!animal/my:page` or '/dokuwiki/!animal/my:page'.
189     *
190     * @param string $path The bang path to extract the animal from
191     * @return bool true if the animal was set successfully, false otherwise
192     */
193    protected function detectAnimalFromBangPath(string $path): bool
194    {
195        $bangregex = '#^(/(?:[^/]*/)*)!([^/]+)/#';
196        if (preg_match($bangregex, $path, $matches)) {
197            // found a bang path
198            $animal = $matches[2];
199
200            $this->hostbased = false;
201            return $this->setAnimal($animal);
202        }
203        return false;
204    }
205
206    /**
207     * Detect the animal from the host name
208     *
209     * @param string $host The hostname
210     * @return bool true if the animal was set successfully, false otherwise
211     */
212    protected function detectAnimalFromHostName(string $host): bool
213    {
214        $possible = $this->getAnimalNamesForHost($host);
215        foreach ($possible as $animal) {
216            if ($this->setAnimal($animal)) {
217                $this->hostbased = true;
218                return true;
219            }
220        }
221        return false;
222    }
223
224    /**
225     * Detect the current animal
226     *
227     * Sets internal members $animal, $notfound and $hostbased
228     *
229     * This borrows form DokuWiki's inc/farm.php but does not support a default conf dir
230     *
231     * @params string|null $sapi the SAPI to use. Only changed during testing
232     */
233    protected function detectAnimal($sapi = null)
234    {
235        $sapi = $sapi ?: PHP_SAPI;
236        $farmhost = $this->config['base']['farmhost'];
237
238        if ('cli' == $sapi) {
239            if (!isset($_SERVER['animal'])) return; // no animal parameter given - we're the farmer
240
241            if (preg_match('#^https?://#i', $_SERVER['animal'])) {
242                // CLI animal parameter is a URL
243                $urlparts = parse_url($_SERVER['animal']);
244                $urlparts['query'] ??= '';
245
246                // detect the animal from the URL
247                $this->detectAnimalFromQueryString($urlparts['query']) ||
248                $this->detectAnimalFromBangPath($urlparts['path']) ||
249                $this->detectAnimalFromHostName($urlparts['host']);
250
251                // fake baseurl etc.
252                $this->injectServerEnvironment($urlparts);
253            } else {
254                // CLI animal parameter is just a name
255                $this->setAnimal(strtolower($_SERVER['animal']));
256            }
257        } else {
258            // an animal url parameter has been set
259            if (isset($_GET['animal'])) {
260                $this->detectAnimalFromQueryString($_SERVER['QUERY_STRING']);
261                unset($_GET['animal']);
262                return;
263            }
264
265            // no host - no host based setup. if we're still here then it's the farmer
266            if (empty($_SERVER['HTTP_HOST'])) return;
267
268            // is this the farmer?
269            if (strtolower($_SERVER['HTTP_HOST']) == $farmhost) {
270                return;
271            }
272
273            // we're in host based mode now
274            $this->hostbased = true;
275
276            // we should get an animal now
277            if (!$this->detectAnimalFromHostName($_SERVER['HTTP_HOST'])) {
278                $this->notfound = true;
279            }
280        }
281    }
282
283    /**
284     * Create Server environment variables for the current animal
285     *
286     * This is called when the animal is initialized on the command line using a full URL.
287     * Since the initialization is running before any configuration is loaded, we instead
288     * set the $_SERVER variables that will later be used to autodetect the base URL. This
289     * way a manually set base URL will still take precedence.
290     *
291     * @param array $urlparts A parse_url() result array
292     * @return void
293     * @see is_ssl()
294     * @see getBaseURL()
295     */
296    protected function injectServerEnvironment(array $urlparts)
297    {
298        // prepare data for DOKU_REL
299        $path = $urlparts['path'] ?? '/';
300        if (($bangpos = strpos($path, '!')) !== false) {
301            // strip from the bang path
302            $path = substr($path, 0, $bangpos);
303        }
304        if (!str_ends_with($path, '.php')) {
305            // make sure we have a script name
306            $path = rtrim($path, '/') . '/doku.php';
307        }
308        $_SERVER['SCRIPT_NAME'] = $path;
309
310        // prepare data for is_ssl()
311        if (($urlparts['scheme'] ?? '') === 'https') {
312            $_SERVER['HTTPS'] = 'on';
313        } else {
314            $_SERVER['HTTPS'] = 'off';
315        }
316
317        // prepare data for DOKU_URL
318        $_SERVER['HTTP_HOST'] = $urlparts['host'] ?? '';
319        if (isset($urlparts['port'])) {
320            $_SERVER['HTTP_HOST'] .= ':' . $urlparts['port'];
321        }
322    }
323
324    /**
325     * Return a list of possible animal names for the given host
326     *
327     * @param string $host the HTTP_HOST header
328     * @return array
329     */
330    protected function getAnimalNamesForHost($host)
331    {
332        $animals = [];
333        $parts = explode('.', implode('.', explode(':', rtrim($host, '.'))));
334        for ($j = count($parts); $j > 0; $j--) {
335            // strip from the end
336            $animals[] = implode('.', array_slice($parts, 0, $j));
337            // strip from the end without host part
338            $animals[] = implode('.', array_slice($parts, 1, $j));
339        }
340        $animals = array_unique($animals);
341        $animals = array_filter($animals);
342        usort(
343            $animals,
344            // compare by length, then alphabet
345            function ($a, $b) {
346                $ret = strlen($b) - strlen($a);
347                if ($ret != 0) return $ret;
348                return $a <=> $b;
349            }
350        );
351        return $animals;
352    }
353
354    /**
355     * This sets up the default farming config cascade
356     */
357    protected function setupCascade()
358    {
359        global $config_cascade;
360        $config_cascade = [
361            'main' => [
362                'default' => [DOKU_INC . 'conf/dokuwiki.php'],
363                'local' => [DOKU_CONF . 'local.php'],
364                'protected' => [DOKU_CONF . 'local.protected.php']
365            ],
366            'acronyms' => [
367                'default' => [DOKU_INC . 'conf/acronyms.conf'],
368                'local' => [DOKU_CONF . 'acronyms.local.conf']
369            ],
370            'entities' => [
371                'default' => [DOKU_INC . 'conf/entities.conf'],
372                'local' => [DOKU_CONF . 'entities.local.conf']
373            ],
374            'interwiki' => [
375                'default' => [DOKU_INC . 'conf/interwiki.conf'],
376                'local' => [DOKU_CONF . 'interwiki.local.conf']
377            ],
378            'license' => [
379                'default' => [DOKU_INC . 'conf/license.php'],
380                'local' => [DOKU_CONF . 'license.local.php']
381            ],
382            'manifest' => [
383                'default' => [DOKU_INC . 'conf/manifest.json'],
384                'local' => [DOKU_CONF . 'manifest.local.json']
385            ],
386            'mediameta' => [
387                'default' => [DOKU_INC . 'conf/mediameta.php'],
388                'local' => [DOKU_CONF . 'mediameta.local.php']
389            ],
390            'mime' => [
391                'default' => [DOKU_INC . 'conf/mime.conf'],
392                'local' => [DOKU_CONF . 'mime.local.conf']
393            ],
394            'scheme' => [
395                'default' => [DOKU_INC . 'conf/scheme.conf'],
396                'local' => [DOKU_CONF . 'scheme.local.conf']
397            ],
398            'smileys' => [
399                'default' => [DOKU_INC . 'conf/smileys.conf'],
400                'local' => [DOKU_CONF . 'smileys.local.conf']
401            ],
402            'wordblock' => [
403                'default' => [DOKU_INC . 'conf/wordblock.conf'],
404                'local' => [DOKU_CONF . 'wordblock.local.conf']
405            ],
406            'acl' => [
407                'default' => DOKU_CONF . 'acl.auth.php'
408            ],
409            'plainauth.users' => [
410                'default' => DOKU_CONF . 'users.auth.php'
411            ],
412            'plugins' => [
413                'default' => [DOKU_INC . 'conf/plugins.php'],
414                'local' => [DOKU_CONF . 'plugins.local.php'],
415                'protected' => [
416                    DOKU_INC . 'conf/plugins.required.php',
417                    DOKU_CONF . 'plugins.protected.php'
418                ]
419            ],
420            'userstyle' => [
421                'screen' => [
422                    DOKU_CONF . 'userstyle.css',
423                    DOKU_CONF . 'userstyle.less'
424                ],
425                'print' => [
426                    DOKU_CONF . 'userprint.css',
427                    DOKU_CONF . 'userprint.less'
428                ],
429                'feed' => [
430                    DOKU_CONF . 'userfeed.css',
431                    DOKU_CONF . 'userfeed.less'
432                ],
433                'all' => [
434                    DOKU_CONF . 'userall.css',
435                    DOKU_CONF . 'userall.less'
436                ]
437            ],
438            'userscript' => [
439                'default' => [DOKU_CONF . 'userscript.js']
440            ],
441            'styleini' => [
442                'default' => [DOKU_INC . 'lib/tpl/%TEMPLATE%/' . 'style.ini'],
443                'local' => [DOKU_CONF . 'tpl/%TEMPLATE%/' . 'style.ini']
444            ]
445        ];
446    }
447
448    /**
449     * This adds additional files to the config cascade based on the inheritence settings
450     *
451     * These are only added for animals, not the farmer
452     */
453    protected function adjustCascade()
454    {
455        // nothing to do when on the farmer:
456        if (!$this->animal) return;
457
458        global $config_cascade;
459        foreach ($this->config['inherit'] as $key => $val) {
460            if (!$val) continue;
461
462            // prepare what is to append or prepend
463            $append = [];
464            $prepend = [];
465            if ($key == 'main') {
466                $prepend = [
467                    'protected' => [DOKU_INC . 'conf/local.protected.php']
468                ];
469                $append = [
470                    'default' => [DOKU_INC . 'conf/local.php'],
471                    'protected' => [DOKU_INC . 'lib/plugins/farmer/includes/config.php']
472                ];
473            } elseif ($key == 'license') {
474                $append = [
475                    'default' => [DOKU_INC . 'conf/' . $key . '.local.php']
476                ];
477            } elseif ($key == 'userscript') {
478                $prepend = [
479                    'default' => [DOKU_INC . 'conf/userscript.js']
480                ];
481            } elseif ($key == 'userstyle') {
482                $prepend = [
483                    'screen' => [
484                        DOKU_INC . 'conf/userstyle.css',
485                        DOKU_INC . 'conf/userstyle.less'
486                    ],
487                    'print' => [
488                        DOKU_INC . 'conf/userprint.css',
489                        DOKU_INC . 'conf/userprint.less'
490                    ],
491                    'feed' => [
492                        DOKU_INC . 'conf/userfeed.css',
493                        DOKU_INC . 'conf/userfeed.less'
494                    ],
495                    'all' => [
496                        DOKU_INC . 'conf/userall.css',
497                        DOKU_INC . 'conf/userall.less'
498                    ]
499                ];
500            } elseif ($key == 'styleini') {
501                $append = [
502                    'local' => [DOKU_INC . 'conf/tpl/%TEMPLATE%/style.ini']
503                ];
504            } elseif ($key == 'users') {
505                $config_cascade['plainauth.users']['protected'] = DOKU_INC . 'conf/users.auth.php';
506            } elseif ($key == 'plugins') {
507                $prepend = [
508                    'protected' => [DOKU_INC . 'conf/local.protected.php']
509                ];
510                $append = [
511                    'default' => [DOKU_INC . 'conf/plugins.local.php']
512                ];
513            } else {
514                $append = [
515                    'default' => [DOKU_INC . 'conf/' . $key . '.local.conf']
516                ];
517            }
518
519            // add to cascade
520            foreach ($prepend as $section => $data) {
521                $config_cascade[$key][$section] = array_merge($data, $config_cascade[$key][$section]);
522            }
523            foreach ($append as $section => $data) {
524                $config_cascade[$key][$section] = array_merge($config_cascade[$key][$section], $data);
525            }
526        }
527
528        // add plugin overrides
529        $config_cascade['plugins']['protected'][] = DOKU_INC . 'lib/plugins/farmer/includes/plugins.php';
530    }
531
532    /**
533     * Loads the farm config
534     */
535    protected function loadConfig()
536    {
537        $ini = DOKU_INC . 'conf/farm.ini';
538        if (file_exists($ini)) {
539            $config = parse_ini_file($ini, true);
540            foreach (array_keys($this->config) as $section) {
541                if (isset($config[$section])) {
542                    $this->config[$section] = array_merge(
543                        $this->config[$section],
544                        $config[$section]
545                    );
546                }
547            }
548        }
549
550        // farmdir setup can be done via environment
551        if ($this->config['base']['farmdir'] === '' && isset($_ENV['DOKU_FARMDIR'])) {
552            $this->config['base']['farmdir'] = $_ENV['DOKU_FARMDIR'];
553        }
554
555        $this->config['base']['farmdir'] = trim($this->config['base']['farmdir']);
556        $this->config['base']['farmhost'] = strtolower(trim($this->config['base']['farmhost']));
557    }
558}
559
560// initialize it globally
561if (!defined('DOKU_UNITTEST')) {
562    global $FARMCORE;
563    $FARMCORE = new DokuWikiFarmCore();
564}
565