1<?php
2
3namespace ComboStrap;
4
5require_once(__DIR__ . '/PluginUtility.php');
6
7/**
8 * Class DokuPath
9 * @package ComboStrap
10 * A dokuwiki path has the same structure than a windows path
11 * with a drive and a path
12 *
13 * The drive is just a local path on the local file system
14 *
15 * Dokuwiki knows only two drives ({@link DokuPath::PAGE_DRIVE} and {@link DokuPath::MEDIA_DRIVE}
16 * but we have added a couple more such as the {@link DokuPath::COMBO_DRIVE combo resources}
17 * and the {@link DokuPath::CACHE_DRIVE}
18 *
19 */
20class DokuPath extends PathAbs
21{
22    const MEDIA_DRIVE = "media";
23    const PAGE_DRIVE = "page";
24    const UNKNOWN_DRIVE = "unknown";
25    const PATH_SEPARATOR = ":";
26
27    // https://www.dokuwiki.org/config:useslash
28    const SEPARATOR_SLASH = "/";
29
30    const SEPARATORS = [self::PATH_SEPARATOR, self::SEPARATOR_SLASH];
31
32    /**
33     * For whatever reason, dokuwiki uses also on windows
34     * the linux separator
35     */
36    public const DIRECTORY_SEPARATOR = "/";
37    public const SLUG_SEPARATOR = "-";
38
39
40    /**
41     * Dokuwiki has a file system that starts at a page and/or media
42     * directory that depends on the used syntax.
43     *
44     * It's a little bit the same than as the icon library (we set it as library then)
45     *
46     * This parameters is an URL parameter
47     * that permits to set an another one
48     * when retrieving the file via HTTP
49     * For now, there is only one value: {@link DokuPath::COMBO_DRIVE}
50     */
51    public const DRIVE_ATTRIBUTE = "drive";
52
53    /**
54     * The interwiki scheme that points to the
55     * combo resources directory ie {@link DokuPath::COMBO_DRIVE}
56     * ie
57     *   combo>library:
58     *   combo>image:
59     */
60    const COMBO_DRIVE = "combo";
61    const CACHE_DRIVE = "cache";
62    const DRIVES = [self::COMBO_DRIVE, self::CACHE_DRIVE, self::MEDIA_DRIVE];
63
64    /**
65     * @var string[]
66     */
67    private static $reservedWords;
68
69    /**
70     * @var string the path id passed to function (cleaned)
71     */
72    private $id;
73
74    /**
75     * @var string the absolute id with the root separator
76     * See {@link $id} for the absolute id without root separator for the index
77     */
78    private $absolutePath;
79
80    /**
81     * @var string
82     */
83    private $drive;
84    /**
85     * @var string|null - ie mtime
86     */
87    private $rev;
88
89
90    /**
91     * @var string the path scheme one constant that starts with SCHEME
92     * ie
93     * {@link DokuFs::SCHEME}
94     */
95    private $scheme;
96    private $filePath;
97
98    /**
99     * The separator from the {@link DokuPath::getDrive()}
100     * Same as {@link InterWikiPath}
101     */
102    const DRIVE_SEPARATOR = ">";
103
104    /**
105     * DokuPath constructor.
106     *
107     * A path for the Dokuwiki File System
108     *
109     * @param string $path - the dokuwiki absolute path (may not be relative but may be a namespace)
110     * @param string $drive - the drive (media, page, combo) - same as in windows for the drive prefix (c, d, ...)
111     * @param string|null $rev - the revision (mtime)
112     *
113     * Thee path should be a qualified/absolute path because in Dokuwiki, a link to a {@link Page}
114     * that ends with the {@link DokuPath::PATH_SEPARATOR} points to a start page
115     * and not to a namespace. The qualification occurs in the transformation
116     * from ref to page.
117     *   For a page: in {@link MarkupRef::getInternalPage()}
118     *   For a media: in the {@link MediaLink::createMediaLinkFromId()}
119     * Because this class is mostly the file representation, it should be able to
120     * represents also a namespace
121     */
122    protected function __construct(string $path, string $drive, string $rev = null)
123    {
124
125        if (empty($path)) {
126            LogUtility::msg("A null path was given", LogUtility::LVL_MSG_WARNING);
127        }
128
129
130        /**
131         * Scheme determination
132         */
133        $this->scheme = $this->schemeDetermination($path);
134
135        switch ($this->scheme) {
136            case InterWikiPath::scheme:
137                /**
138                 * We use interwiki to define the combo resources
139                 * (Internal use only)
140                 */
141                $comboInterWikiScheme = "combo>";
142                if (strpos($path, $comboInterWikiScheme) === 0) {
143                    $this->scheme = DokuFs::SCHEME;
144                    $this->id = substr($path, strlen($comboInterWikiScheme));
145                    $drive = self::COMBO_DRIVE;
146                };
147                break;
148            case DokuFs::SCHEME:
149            default:
150                DokuPath::addRootSeparatorIfNotPresent($path);
151                $this->id = DokuPath::toDokuwikiId($path);
152
153        }
154        $this->absolutePath = $path;
155
156
157        /**
158         * ACL check does not care about the type of id
159         * https://www.dokuwiki.org/devel:event:auth_acl_check
160         * https://github.com/splitbrain/dokuwiki/issues/3476
161         *
162         * We check if there is an extension
163         * If this is the case, this is a media
164         */
165        if ($drive == self::UNKNOWN_DRIVE) {
166            $lastPosition = StringUtility::lastIndexOf($path, ".");
167            if ($lastPosition === FALSE) {
168                $drive = self::PAGE_DRIVE;
169            } else {
170                $drive = self::MEDIA_DRIVE;
171            }
172        }
173        $this->drive = $drive;
174        $this->rev = $rev;
175
176        /**
177         * File path
178         */
179        $filePath = $this->absolutePath;
180        if ($this->scheme == DokuFs::SCHEME) {
181
182            $isNamespacePath = false;
183            if (\mb_substr($this->absolutePath, -1) == self::PATH_SEPARATOR) {
184                $isNamespacePath = true;
185            }
186
187            global $ID;
188
189            if (!$isNamespacePath) {
190
191                switch ($drive) {
192
193                    case self::MEDIA_DRIVE:
194                        if (!empty($rev)) {
195                            $filePath = mediaFN($this->id, $rev);
196                        } else {
197                            $filePath = mediaFN($this->id);
198                        }
199                        break;
200                    case self::PAGE_DRIVE:
201                        if (!empty($rev)) {
202                            $filePath = wikiFN($this->id, $rev);
203                        } else {
204                            $filePath = wikiFN($this->id);
205                        }
206                        break;
207                    default:
208                        $baseDirectory = DokuPath::getDriveRoots()[$drive];
209                        if ($baseDirectory === null) {
210                            // We don't throw, the file will just not exist
211                            // this is metadata
212                            LogUtility::msg("The drive ($drive) is unknown, the local file system path could not be found");
213                        } else {
214                            $relativeFsPath = DokuPath::toFileSystemSeparator($this->id);
215                            $filePath = $baseDirectory->resolve($relativeFsPath)->toString();
216                        }
217                        break;
218                }
219            } else {
220                /**
221                 * Namespace
222                 * (Fucked up is fucked up)
223                 * We qualify for the namespace here
224                 * because there is no link or media for a namespace
225                 */
226                $this->id = resolve_id(getNS($ID), $this->id, true);
227                global $conf;
228                if ($drive == self::MEDIA_DRIVE) {
229                    $filePath = $conf['mediadir'] . '/' . utf8_encodeFN($this->id);
230                } else {
231                    $filePath = $conf['datadir'] . '/' . utf8_encodeFN($this->id);
232                }
233            }
234        }
235        $this->filePath = $filePath;
236    }
237
238
239    /**
240     *
241     * @param $absolutePath
242     * @return DokuPath
243     */
244    public static function createPagePathFromPath($absolutePath): DokuPath
245    {
246        return new DokuPath($absolutePath, DokuPath::PAGE_DRIVE);
247    }
248
249    public static function createMediaPathFromAbsolutePath($absolutePath, $rev = ''): DokuPath
250    {
251        return new DokuPath($absolutePath, DokuPath::MEDIA_DRIVE, $rev);
252    }
253
254    /**
255     * If the media may come from the
256     * dokuwiki media or combo resources media,
257     * you should use this function
258     *
259     * The constructor will determine the type based on
260     * the id structure.
261     * @param $id
262     * @return DokuPath
263     */
264    public static function createFromUnknownRoot($id): DokuPath
265    {
266        return new DokuPath($id, DokuPath::UNKNOWN_DRIVE);
267    }
268
269    /**
270     * @param $url - a URL path http://whatever/hello/my/lord (The canonical)
271     * @return DokuPath - a dokuwiki Id hello:my:lord
272     */
273    public static function createFromUrl($url): DokuPath
274    {
275        // Replace / by : and suppress the first : because the global $ID does not have it
276        $parsedQuery = parse_url($url, PHP_URL_QUERY);
277        $parsedQueryArray = [];
278        parse_str($parsedQuery, $parsedQueryArray);
279        $queryId = 'id';
280        if (array_key_exists($queryId, $parsedQueryArray)) {
281            // Doku form (ie doku.php?id=)
282            $id = $parsedQueryArray[$queryId];
283        } else {
284            // Slash form ie (/my/id)
285            $urlPath = parse_url($url, PHP_URL_PATH);
286            $id = substr(str_replace("/", ":", $urlPath), 1);
287        }
288        return self::createPagePathFromPath(":$id");
289    }
290
291    /**
292     * Static don't ask why
293     * @param $pathId
294     * @return false|string
295     */
296    public static function getLastPart($pathId)
297    {
298        $endSeparatorLocation = StringUtility::lastIndexOf($pathId, DokuPath::PATH_SEPARATOR);
299        if ($endSeparatorLocation === false) {
300            $endSeparatorLocation = StringUtility::lastIndexOf($pathId, DokuPath::SEPARATOR_SLASH);
301        }
302        if ($endSeparatorLocation === false) {
303            $lastPathPart = $pathId;
304        } else {
305            $lastPathPart = substr($pathId, $endSeparatorLocation + 1);
306        }
307        return $lastPathPart;
308    }
309
310    /**
311     * @param $id
312     * @return string
313     * Return an path from a id
314     */
315    public static function IdToAbsolutePath($id)
316    {
317        if (is_null($id)) {
318            LogUtility::msg("The id passed should not be null");
319        }
320        return DokuPath::PATH_SEPARATOR . $id;
321    }
322
323    public
324    static function toDokuwikiId($absolutePath)
325    {
326        // Root ?
327        if ($absolutePath == DokuPath::PATH_SEPARATOR) {
328            return "";
329        }
330        if ($absolutePath[0] === DokuPath::PATH_SEPARATOR) {
331            return substr($absolutePath, 1);
332        }
333        return $absolutePath;
334
335    }
336
337    public static function createMediaPathFromId($id, $rev = ''): DokuPath
338    {
339        DokuPath::addRootSeparatorIfNotPresent($id);
340        return self::createMediaPathFromAbsolutePath($id, $rev);
341    }
342
343
344    public static function createPagePathFromId($id): DokuPath
345    {
346        return new DokuPath(DokuPath::PATH_SEPARATOR . $id, self::PAGE_DRIVE);
347    }
348
349    /**
350     * If the path does not have a root separator,
351     * it's added (ie to transform an id to a path)
352     * @param string $path
353     */
354    public static function addRootSeparatorIfNotPresent(string &$path)
355    {
356        if (substr($path, 0, 1) !== ":") {
357            $path = DokuPath::PATH_SEPARATOR . $path;
358        }
359    }
360
361    /**
362     * @param string $relativePath
363     * @return string - a dokuwiki path (replacing the windows or linux path separator to the dokuwiki separator)
364     */
365    public static function toDokuWikiSeparator(string $relativePath): string
366    {
367        return preg_replace('/[\\\\\/]/', ":", $relativePath);
368    }
369
370    public static function toFileSystemSeparator($dokuPath)
371    {
372        return str_replace(":", self::DIRECTORY_SEPARATOR, $dokuPath);
373    }
374
375    /**
376     * @param $path - a manual path value
377     * @return string -  a valid path
378     */
379    public static function toValidAbsolutePath($path): string
380    {
381        $path = cleanID($path);
382        DokuPath::addRootSeparatorIfNotPresent($path);
383        return $path;
384    }
385
386    /**
387     */
388    public static function createComboResource($dokuwikiId): DokuPath
389    {
390        return new DokuPath($dokuwikiId, self::COMBO_DRIVE);
391    }
392
393    /**
394     */
395    public static function createDokuPath($path, $drive, $rev = ''): DokuPath
396    {
397        return new DokuPath($path, $drive, $rev);
398    }
399
400    public static function getDriveRoots(): array
401    {
402        return [
403            self::MEDIA_DRIVE => Site::getMediaDirectory(),
404            self::PAGE_DRIVE => Site::getPageDirectory(),
405            self::COMBO_DRIVE => Site::getComboResourcesDirectory(),
406            self::CACHE_DRIVE => Site::getCacheDirectory()
407        ];
408    }
409
410
411    /**
412     * The last part of the path
413     */
414    public
415    function getLastName()
416    {
417        /**
418         * See also {@link noNSorNS}
419         */
420        $names = $this->getNames();
421        return $names[sizeOf($names) - 1];
422    }
423
424    /**
425     * @return null|string
426     */
427    public function getLastNameWithoutExtension(): ?string
428    {
429        /**
430         * A page doku path has no extension for now
431         */
432        if ($this->drive === self::PAGE_DRIVE) {
433            return $this->getLastName();
434        }
435        return parent::getLastNameWithoutExtension();
436
437    }
438
439
440    public
441    function getNames()
442    {
443
444        $names = explode(self::PATH_SEPARATOR, $this->getDokuwikiId());
445
446        if ($names[0] === "") {
447            /**
448             * Case of only one string without path separator
449             * the first element returned is an empty string
450             */
451            $names = array_splice($names, 1);
452        }
453        return $names;
454    }
455
456    /**
457     * @return bool true if this id represents a page
458     */
459    public function isPage(): bool
460    {
461
462        if (
463            $this->drive === self::PAGE_DRIVE
464            &&
465            !$this->isGlob()
466        ) {
467            return true;
468        } else {
469            return false;
470        }
471
472    }
473
474
475    public function isGlob(): bool
476    {
477        /**
478         * {@link search_universal} triggers ACL check
479         * with id of the form :path:*
480         * (for directory ?)
481         */
482        return StringUtility::endWiths($this->getDokuwikiId(), ":*");
483    }
484
485    public
486    function __toString()
487    {
488        return $this->toUriString();
489    }
490
491    /**
492     *
493     *
494     * @return string - the id of dokuwiki is the absolute path
495     * without the root separator (ie normalized)
496     *
497     * The index stores needs this value
498     * And most of the function that are not links related
499     * use this format (What fucked up is fucked up)
500     * /**
501     * The absolute path without root separator
502     * Heavily used inside Dokuwiki
503     */
504    public
505    function getDokuwikiId(): string
506    {
507
508        if ($this->getScheme() == DokuFs::SCHEME) {
509            return $this->id;
510        } else {
511            // the url (it's stored as id in the metadata)
512            return $this->getPath();
513        }
514
515    }
516
517    public
518    function getPath(): string
519    {
520
521        return $this->absolutePath;
522
523    }
524
525    public
526    function getScheme()
527    {
528
529        return $this->scheme;
530
531    }
532
533    /**
534     * The dokuwiki revision value
535     * as seen in the {@link basicinfo()} function
536     * is the {@link File::getModifiedTime()} of the file
537     *
538     * Let op passing a revision to Dokuwiki will
539     * make it search to the history
540     * The actual file will then not be found
541     *
542     * @return string|null
543     */
544    public
545    function getRevision(): ?string
546    {
547        if ($this->rev === null) {
548            $localPath = $this->toLocalPath();
549            if (FileSystems::exists($localPath)) {
550                return FileSystems::getModifiedTime($localPath)->getTimestamp();
551            }
552        }
553        return $this->rev;
554    }
555
556
557    /**
558     * @return string
559     *
560     * This is the local absolute path WITH the root separator.
561     * It's used in ref present in {@link MarkupRef link} or {@link MediaLink}
562     * when creating test, otherwise the ref is considered as relative
563     *
564     *
565     * Otherwise everywhere in Dokuwiki, they use the {@link DokuPath::getDokuwikiId()} absolute value that does not have any root separator
566     * and is absolute (internal index, function, ...)
567     *
568     */
569    public
570    function getAbsolutePath(): string
571    {
572
573        return $this->absolutePath;
574
575    }
576
577    /**
578     * @return array the pages where the dokuwiki file (page or media) is used
579     *   * backlinks for page
580     *   * page with media for media
581     */
582    public
583    function getReferencedBy(): array
584    {
585        $absoluteId = $this->getDokuwikiId();
586        if ($this->drive == self::MEDIA_DRIVE) {
587            return idx_get_indexer()->lookupKey('relation_media', $absoluteId);
588        } else {
589            return idx_get_indexer()->lookupKey('relation_references', $absoluteId);
590        }
591    }
592
593
594    /**
595     * Return the path relative to the base directory
596     * (ie $conf[basedir])
597     * @return string
598     */
599    public
600    function toRelativeFileSystemPath()
601    {
602        $relativeSystemPath = ".";
603        if (!empty($this->getDokuwikiId())) {
604            $relativeSystemPath .= "/" . utf8_encodeFN(str_replace(':', '/', $this->getDokuwikiId()));
605        }
606        return $relativeSystemPath;
607
608    }
609
610    public function isPublic(): bool
611    {
612        return $this->getAuthAclValue() >= AUTH_READ;
613    }
614
615    /**
616     * @return int - An AUTH_ value for this page for the current logged user
617     * See the file defines.php
618     *
619     */
620    public function getAuthAclValue(): int
621    {
622        return auth_quickaclcheck($this->getDokuwikiId());
623    }
624
625
626    public static function getReservedWords(): array
627    {
628        if (self::$reservedWords == null) {
629            self::$reservedWords = array_merge(Url::RESERVED_WORDS, LocalPath::RESERVED_WINDOWS_CHARACTERS);
630        }
631        return self::$reservedWords;
632    }
633
634    /**
635     * @return string - a label from a path (used in link when their is no label available)
636     * The path is separated in words and every word gets an uppercase letter
637     */
638    public function toLabel(): string
639    {
640        $words = preg_split("/\s/", preg_replace("/-|_|:/", " ", $this->getPath()));
641        $wordsUc = [];
642        foreach ($words as $word) {
643            $wordsUc[] = ucfirst($word);
644        }
645        return implode(" ", $wordsUc);
646    }
647
648    public function toLocalPath(): LocalPath
649    {
650        return LocalPath::create($this->filePath);
651    }
652
653    /**
654     * @return string - Returns the string representation of this path (to be able to use it in url)
655     * To get the full string version see {@link DokuPath::toUriString()}
656     */
657    function toString(): string
658    {
659        return $this->absolutePath;
660    }
661
662    function toUriString(): string
663    {
664        $driveSep = self::DRIVE_SEPARATOR;
665        $string = "{$this->scheme}://$this->drive$driveSep$this->id";
666        if ($this->rev !== null) {
667            return "$string?rev={$this->rev}";
668        }
669        return $string;
670    }
671
672    function toAbsolutePath(): Path
673    {
674        return new DokuPath($this->absolutePath, $this->drive, $this->rev);
675    }
676
677    /**
678     * The parent path is a directory (namespace)
679     * The parent of page in the root does return null.
680     *
681     * @return DokuPath|null
682     */
683    function getParent(): ?Path
684    {
685
686        /**
687         * Same as {@link getNS()}
688         */
689        $names = $this->getNames();
690        switch (sizeof($names)) {
691            case 0:
692                return null;
693            case 1:
694                return new DokuPath(DokuPath::PATH_SEPARATOR, $this->drive, $this->rev);
695            default:
696                $names = array_slice($names, 0, sizeof($names) - 1);
697                $path = implode(DokuPath::PATH_SEPARATOR, $names);
698                return new DokuPath($path, $this->drive, $this->rev);
699        }
700
701    }
702
703    function getMime(): ?Mime
704    {
705        if ($this->drive === self::PAGE_DRIVE) {
706            return new Mime(Mime::PLAIN_TEXT);
707        }
708        return parent::getMime();
709
710    }
711
712    public function getLibrary(): string
713    {
714        return $this->drive;
715    }
716
717    private function schemeDetermination($absolutePath): string
718    {
719
720        if (media_isexternal($absolutePath)) {
721            /**
722             * This code should not be here
723             * Because it should be another path (ie http path)
724             * but for historical reason due to compatibility with
725             * dokuwiki, it's here.
726             */
727            return InternetPath::scheme;
728
729        }
730        if (link_isinterwiki($absolutePath)) {
731
732            return InterWikiPath::scheme;
733
734        }
735
736        DokuPath::addRootSeparatorIfNotPresent($absolutePath);
737        $this->absolutePath = $absolutePath;
738
739        if (substr($absolutePath, 1, 1) === DokuPath::PATH_SEPARATOR) {
740            /**
741             * path given is `::path`
742             */
743            if (PluginUtility::isDevOrTest()) {
744                LogUtility::msg("The path given ($absolutePath) has too much separator", LogUtility::LVL_MSG_ERROR);
745            }
746        }
747        return DokuFs::SCHEME;
748
749
750    }
751
752    public function getDrive(): string
753    {
754        return $this->drive;
755    }
756
757    public function resolve(string $name): DokuPath
758    {
759        return new DokuPath($this->absolutePath . self::PATH_SEPARATOR . $name, $this->getDrive());
760    }
761}
762