xref: /plugin/farmer/DokuWikiFarmCore.php (revision 83ef0d71ee7a79727a8dac5e9f5981d2a4b79825)
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
237        $farmdir = $this->config['base']['farmdir'];
238        $farmhost = $this->config['base']['farmhost'];
239
240        if ('cli' == $sapi) {
241            if (!isset($_SERVER['animal'])) return; // no animal parameter given - we're the farmer
242
243            if (preg_match('#^https?://#i', $_SERVER['animal'])) {
244                // CLI animal parameter is a URL
245                $urlparts = parse_url($_SERVER['animal']);
246                $urlparts['query'] ??= '';
247
248                // detect the animal from the URL
249                $this->detectAnimalFromQueryString($urlparts['query']) ||
250                $this->detectAnimalFromBangPath($urlparts['path']) ||
251                $this->detectAnimalFromHostName($urlparts['host']);
252
253                // fake baseurl etc.
254                $this->injectServerEnvironment($urlparts);
255            } else {
256                // CLI animal parameter is just a name
257                $this->setAnimal(strtolower($_SERVER['animal']));
258            }
259
260        } else {
261            // an animal url parameter has been set
262            if (isset($_GET['animal'])) {
263                $this->detectAnimalFromQueryString($_SERVER['QUERY_STRING']);
264                unset($_GET['animal']);
265                return;
266            }
267
268            // no host - no host based setup. if we're still here then it's the farmer
269            if (empty($_SERVER['HTTP_HOST'])) return;
270
271            // is this the farmer?
272            if (strtolower($_SERVER['HTTP_HOST']) == $farmhost) {
273                return;
274            }
275
276            // we're in host based mode now
277            $this->hostbased = true;
278
279            // we should get an animal now
280            if (!$this->detectAnimalFromHostName($_SERVER['HTTP_HOST'])) {
281                $this->notfound = true;
282            }
283        }
284    }
285
286    /**
287     * Create Server environment variables for the current animal
288     *
289     * This is called when the animal is initialized on the command line using a full URL.
290     * Since the initialization is running before any configuration is loaded, we instead
291     * set the $_SERVER variables that will later be used to autodetect the base URL. This
292     * way a manually set base URL will still take precedence.
293     *
294     * @param array $urlparts A parse_url() result array
295     * @return void
296     * @see is_ssl()
297     * @see getBaseURL()
298     */
299    protected function injectServerEnvironment(array $urlparts)
300    {
301        // prepare data for DOKU_REL
302        $path = $urlparts['path'] ?? '/';
303        if (($bangpos = strpos($path, '!')) !== false) {
304            // strip from the bang path
305            $path = substr($path, 0, $bangpos);
306        }
307        if (!str_ends_with($path, '.php')) {
308            // make sure we have a script name
309            $path = rtrim($path, '/') . '/doku.php';
310        }
311        $_SERVER['SCRIPT_NAME'] = $path;
312
313        // prepare data for is_ssl()
314        if (($urlparts['scheme'] ?? '') === 'https') {
315            $_SERVER['HTTPS'] = 'on';
316        } else {
317            $_SERVER['HTTPS'] = 'off';
318        }
319
320        // prepare data for DOKU_URL
321        $_SERVER['HTTP_HOST'] = $urlparts['host'] ?? '';
322        if (isset($urlparts['port'])) {
323            $_SERVER['HTTP_HOST'] .= ':' . $urlparts['port'];
324        }
325    }
326
327    /**
328     * Return a list of possible animal names for the given host
329     *
330     * @param string $host the HTTP_HOST header
331     * @return array
332     */
333    protected function getAnimalNamesForHost($host)
334    {
335        $animals = [];
336        $parts = explode('.', implode('.', explode(':', rtrim($host, '.'))));
337        for ($j = count($parts); $j > 0; $j--) {
338            // strip from the end
339            $animals[] = implode('.', array_slice($parts, 0, $j));
340            // strip from the end without host part
341            $animals[] = implode('.', array_slice($parts, 1, $j));
342        }
343        $animals = array_unique($animals);
344        $animals = array_filter($animals);
345        usort(
346            $animals,
347            // compare by length, then alphabet
348            function ($a, $b) {
349                $ret = strlen($b) - strlen($a);
350                if ($ret != 0) return $ret;
351                return $a <=> $b;
352            }
353        );
354        return $animals;
355    }
356
357    /**
358     * This sets up the default farming config cascade
359     */
360    protected function setupCascade()
361    {
362        global $config_cascade;
363        $config_cascade = [
364            'main' => [
365                'default' => [DOKU_INC . 'conf/dokuwiki.php'],
366                'local' => [DOKU_CONF . 'local.php'],
367                'protected' => [DOKU_CONF . 'local.protected.php']
368            ],
369            'acronyms' => [
370                'default' => [DOKU_INC . 'conf/acronyms.conf'],
371                'local' => [DOKU_CONF . 'acronyms.local.conf']
372            ],
373            'entities' => [
374                'default' => [DOKU_INC . 'conf/entities.conf'],
375                'local' => [DOKU_CONF . 'entities.local.conf']
376            ],
377            'interwiki' => [
378                'default' => [DOKU_INC . 'conf/interwiki.conf'],
379                'local' => [DOKU_CONF . 'interwiki.local.conf']
380            ],
381            'license' => [
382                'default' => [DOKU_INC . 'conf/license.php'],
383                'local' => [DOKU_CONF . 'license.local.php']
384            ],
385            'manifest' => [
386                'default' => [DOKU_INC . 'conf/manifest.json'],
387                'local' => [DOKU_CONF . 'manifest.local.json']
388            ],
389            'mediameta' => [
390                'default' => [DOKU_INC . 'conf/mediameta.php'],
391                'local' => [DOKU_CONF . 'mediameta.local.php']
392            ],
393            'mime' => [
394                'default' => [DOKU_INC . 'conf/mime.conf'],
395                'local' => [DOKU_CONF . 'mime.local.conf']
396            ],
397            'scheme' => [
398                'default' => [DOKU_INC . 'conf/scheme.conf'],
399                'local' => [DOKU_CONF . 'scheme.local.conf']
400            ],
401            'smileys' => [
402                'default' => [DOKU_INC . 'conf/smileys.conf'],
403                'local' => [DOKU_CONF . 'smileys.local.conf']
404            ],
405            'wordblock' => [
406                'default' => [DOKU_INC . 'conf/wordblock.conf'],
407                'local' => [DOKU_CONF . 'wordblock.local.conf']
408            ],
409            'acl' => [
410                'default' => DOKU_CONF . 'acl.auth.php'
411            ],
412            'plainauth.users' => [
413                'default' => DOKU_CONF . 'users.auth.php'
414            ],
415            'plugins' => [
416                'default' => [DOKU_INC . 'conf/plugins.php'],
417                'local' => [DOKU_CONF . 'plugins.local.php'],
418                'protected' => [
419                    DOKU_INC . 'conf/plugins.required.php',
420                    DOKU_CONF . 'plugins.protected.php'
421                ]
422            ],
423            'userstyle' => [
424                'screen' => [
425                    DOKU_CONF . 'userstyle.css',
426                    DOKU_CONF . 'userstyle.less'
427                ],
428                'print' => [
429                    DOKU_CONF . 'userprint.css',
430                    DOKU_CONF . 'userprint.less'
431                ],
432                'feed' => [
433                    DOKU_CONF . 'userfeed.css',
434                    DOKU_CONF . 'userfeed.less'
435                ],
436                'all' => [
437                    DOKU_CONF . 'userall.css',
438                    DOKU_CONF . 'userall.less'
439                ]
440            ],
441            'userscript' => [
442                'default' => [DOKU_CONF . 'userscript.js']
443            ],
444            'styleini' => [
445                'default' => [DOKU_INC . 'lib/tpl/%TEMPLATE%/' . 'style.ini'],
446                'local' => [DOKU_CONF . 'tpl/%TEMPLATE%/' . 'style.ini']
447            ]
448        ];
449    }
450
451    /**
452     * This adds additional files to the config cascade based on the inheritence settings
453     *
454     * These are only added for animals, not the farmer
455     */
456    protected function adjustCascade()
457    {
458        // nothing to do when on the farmer:
459        if (!$this->animal) return;
460
461        global $config_cascade;
462        foreach ($this->config['inherit'] as $key => $val) {
463            if (!$val) continue;
464
465            // prepare what is to append or prepend
466            $append = [];
467            $prepend = [];
468            if ($key == 'main') {
469                $prepend = [
470                    'protected' => [DOKU_INC . 'conf/local.protected.php']
471                ];
472                $append = [
473                    'default' => [DOKU_INC . 'conf/local.php'],
474                    'protected' => [DOKU_INC . 'lib/plugins/farmer/includes/config.php']
475                ];
476            } elseif ($key == 'license') {
477                $append = [
478                    'default' => [DOKU_INC . 'conf/' . $key . '.local.php']
479                ];
480            } elseif ($key == 'userscript') {
481                $prepend = [
482                    'default' => [DOKU_INC . 'conf/userscript.js']
483                ];
484            } elseif ($key == 'userstyle') {
485                $prepend = [
486                    'screen' => [
487                        DOKU_INC . 'conf/userstyle.css',
488                        DOKU_INC . 'conf/userstyle.less'
489                    ],
490                    'print' => [
491                        DOKU_INC . 'conf/userprint.css',
492                        DOKU_INC . 'conf/userprint.less'
493                    ],
494                    'feed' => [
495                        DOKU_INC . 'conf/userfeed.css',
496                        DOKU_INC . 'conf/userfeed.less'
497                    ],
498                    'all' => [
499                        DOKU_INC . 'conf/userall.css',
500                        DOKU_INC . 'conf/userall.less'
501                    ]
502                ];
503            } elseif ($key == 'styleini') {
504                $append = [
505                    'local' => [DOKU_INC . 'conf/tpl/%TEMPLATE%/style.ini']
506                ];
507            } elseif ($key == 'users') {
508                $config_cascade['plainauth.users']['protected'] = DOKU_INC . 'conf/users.auth.php';
509            } elseif ($key == 'plugins') {
510                $prepend = [
511                    'protected' => [DOKU_INC . 'conf/local.protected.php']
512                ];
513                $append = [
514                    'default' => [DOKU_INC . 'conf/plugins.local.php']
515                ];
516            } else {
517                $append = [
518                    'default' => [DOKU_INC . 'conf/' . $key . '.local.conf']
519                ];
520            }
521
522            // add to cascade
523            foreach ($prepend as $section => $data) {
524                $config_cascade[$key][$section] = array_merge($data, $config_cascade[$key][$section]);
525            }
526            foreach ($append as $section => $data) {
527                $config_cascade[$key][$section] = array_merge($config_cascade[$key][$section], $data);
528            }
529        }
530
531        // add plugin overrides
532        $config_cascade['plugins']['protected'][] = DOKU_INC . 'lib/plugins/farmer/includes/plugins.php';
533    }
534
535    /**
536     * Loads the farm config
537     */
538    protected function loadConfig()
539    {
540        $ini = DOKU_INC . 'conf/farm.ini';
541        if (file_exists($ini)) {
542            $config = parse_ini_file($ini, true);
543            foreach (array_keys($this->config) as $section) {
544                if (isset($config[$section])) {
545                    $this->config[$section] = array_merge(
546                        $this->config[$section],
547                        $config[$section]
548                    );
549                }
550            }
551        }
552
553        // farmdir setup can be done via environment
554        if ($this->config['base']['farmdir'] === '' && isset($_ENV['DOKU_FARMDIR'])) {
555            $this->config['base']['farmdir'] = $_ENV['DOKU_FARMDIR'];
556        }
557
558        $this->config['base']['farmdir'] = trim($this->config['base']['farmdir']);
559        $this->config['base']['farmhost'] = strtolower(trim($this->config['base']['farmhost']));
560    }
561}
562
563// initialize it globally
564if (!defined('DOKU_UNITTEST')) {
565    global $FARMCORE;
566    $FARMCORE = new DokuWikiFarmCore();
567}
568