1<?php
2
3namespace dokuwiki\File;
4
5use dokuwiki\Cache\CacheInstructions;
6use dokuwiki\ChangeLog\PageChangeLog;
7use dokuwiki\Extension\Event;
8use dokuwiki\Input\Input;
9use dokuwiki\Logger;
10use RuntimeException;
11
12/**
13 * Class PageFile : handles wiki text file and its change management for specific page
14 */
15class PageFile
16{
17    protected $id;
18
19    /* @var PageChangeLog $changelog */
20    public $changelog;
21
22    /* @var array $data  initial data when event COMMON_WIKIPAGE_SAVE triggered */
23    protected $data;
24
25    /**
26     * PageFile constructor.
27     *
28     * @param string $id
29     */
30    public function __construct($id)
31    {
32        $this->id = $id;
33        $this->changelog = new PageChangeLog($this->id);
34    }
35
36    /** @return string */
37    public function getId()
38    {
39        return $this->id;
40    }
41
42    /** @return string */
43    public function getPath($rev = '')
44    {
45        return wikiFN($this->id, $rev);
46    }
47
48    /**
49     * Get raw WikiText of the page, considering change type at revision date
50     * similar to function rawWiki($id, $rev = '')
51     *
52     * @param int|false $rev  timestamp when a revision of wikitext is desired
53     * @return string
54     */
55    public function rawWikiText($rev = null)
56    {
57        if ($rev !== null) {
58            $revInfo = $rev ? $this->changelog->getRevisionInfo($rev) : false;
59            return (!$revInfo || $revInfo['type'] == DOKU_CHANGE_TYPE_DELETE)
60                ? '' // attic stores complete last page version for a deleted page
61                : io_readWikiPage($this->getPath($rev), $this->id, $rev); // retrieve from attic
62        } else {
63            return io_readWikiPage($this->getPath(), $this->id, '');
64        }
65    }
66
67    /**
68     * Saves a wikitext by calling io_writeWikiPage.
69     * Also directs changelog and attic updates.
70     *
71     * @author Andreas Gohr <andi@splitbrain.org>
72     * @author Ben Coburn <btcoburn@silicodon.net>
73     *
74     * @param string $text     wikitext being saved
75     * @param string $summary  summary of text update
76     * @param bool   $minor    mark this saved version as minor update
77     * @return array|void data of event COMMON_WIKIPAGE_SAVE
78     */
79    public function saveWikiText($text, $summary, $minor = false)
80    {
81        /* Note to developers:
82           This code is subtle and delicate. Test the behavior of
83           the attic and changelog with dokuwiki and external edits
84           after any changes. External edits change the wiki page
85           directly without using php or dokuwiki.
86         */
87        global $conf;
88        global $lang;
89        global $REV;
90        /* @var Input $INPUT */
91        global $INPUT;
92
93        // prevent recursive call
94        if (isset($this->data)) return;
95
96        $pagefile = $this->getPath();
97        $currentRevision = @filemtime($pagefile);       // int or false
98        $currentContent = $this->rawWikiText();
99        $currentSize = file_exists($pagefile) ? filesize($pagefile) : 0;
100
101        // prepare data for event COMMON_WIKIPAGE_SAVE
102        $data = [
103            'id'             => $this->id,// should not be altered by any handlers
104            'file'           => $pagefile,// same above
105            'changeType'     => null,// set prior to event, and confirm later
106            'revertFrom'     => $REV,
107            'oldRevision'    => $currentRevision,
108            'oldContent'     => $currentContent,
109            'newRevision'    => 0,// only available in the after hook
110            'newContent'     => $text,
111            'summary'        => $summary,
112            'contentChanged' => ($text != $currentContent),// confirm later
113            'changeInfo'     => '',// automatically determined by revertFrom
114            'sizechange'     => strlen($text) - strlen($currentContent),
115        ];
116
117        // determine tentatively change type and relevant elements of event data
118        if ($data['revertFrom']) {
119            // new text may differ from exact revert revision
120            $data['changeType'] = DOKU_CHANGE_TYPE_REVERT;
121            $data['changeInfo'] = $REV;
122        } elseif (trim($data['newContent']) == '') {
123            // empty or whitespace only content deletes
124            $data['changeType'] = DOKU_CHANGE_TYPE_DELETE;
125        } elseif (!file_exists($pagefile)) {
126            $data['changeType'] = DOKU_CHANGE_TYPE_CREATE;
127        } else {
128            // minor edits allowable only for logged in users
129            $is_minor_change = ($minor && $conf['useacl'] && $INPUT->server->str('REMOTE_USER'));
130            $data['changeType'] = $is_minor_change
131                ? DOKU_CHANGE_TYPE_MINOR_EDIT
132                : DOKU_CHANGE_TYPE_EDIT;
133        }
134
135        $this->data = $data;
136        $data['page'] = $this; // allow event handlers to use this class methods
137
138        $event = new Event('COMMON_WIKIPAGE_SAVE', $data);
139        if (!$event->advise_before()) return;
140
141        // if the content has not been changed, no save happens (plugins may override this)
142        if (!$data['contentChanged']) return;
143
144        // Check whether the pagefile has modified during $event->advise_before()
145        clearstatcache();
146        $fileRev = @filemtime($pagefile);
147        if ($fileRev === $currentRevision) {
148            // pagefile has not touched by plugin's event handler
149            // add a potential external edit entry to changelog and store it into attic
150            $this->detectExternalEdit();
151            $filesize_old = $currentSize;
152        } else {
153            // pagefile has modified by plugin's event handler, confirm sizechange
154            $filesize_old = (
155                $data['changeType'] == DOKU_CHANGE_TYPE_CREATE || (
156                $data['changeType'] == DOKU_CHANGE_TYPE_REVERT && !file_exists($pagefile))
157            ) ? 0 : filesize($pagefile);
158        }
159
160        // make change to the current file
161        if ($data['changeType'] == DOKU_CHANGE_TYPE_DELETE) {
162            // nothing to do when the file has already deleted
163            if (!file_exists($pagefile)) return;
164            // autoset summary on deletion
165            if (blank($data['summary'])) {
166                $data['summary'] = $lang['deleted'];
167            }
168            // send "update" event with empty data, so plugins can react to page deletion
169            $ioData = [[$pagefile, '', false], getNS($this->id), noNS($this->id), false];
170            Event::createAndTrigger('IO_WIKIPAGE_WRITE', $ioData);
171            // pre-save deleted revision
172            @touch($pagefile);
173            clearstatcache();
174            $data['newRevision'] = $this->saveOldRevision();
175            // remove empty file
176            @unlink($pagefile);
177            $filesize_new = 0;
178            // don't remove old meta info as it should be saved, plugins can use
179            // IO_WIKIPAGE_WRITE for removing their metadata...
180            // purge non-persistant meta data
181            p_purge_metadata($this->id);
182            // remove empty namespaces
183            io_sweepNS($this->id, 'datadir');
184            io_sweepNS($this->id, 'mediadir');
185        } else {
186            // save file (namespace dir is created in io_writeWikiPage)
187            io_writeWikiPage($pagefile, $data['newContent'], $this->id);
188            // pre-save the revision, to keep the attic in sync
189            $data['newRevision'] = $this->saveOldRevision();
190            $filesize_new = filesize($pagefile);
191        }
192        $data['sizechange'] = $filesize_new - $filesize_old;
193
194        $event->advise_after();
195
196        unset($data['page']);
197
198        // adds an entry to the changelog and saves the metadata for the page
199        $logEntry = $this->changelog->addLogEntry([
200            'date'       => $data['newRevision'],
201            'ip'         => clientIP(true),
202            'type'       => $data['changeType'],
203            'id'         => $this->id,
204            'user'       => $INPUT->server->str('REMOTE_USER'),
205            'sum'        => $data['summary'],
206            'extra'      => $data['changeInfo'],
207            'sizechange' => $data['sizechange'],
208        ]);
209        // update metadata
210        $this->updateMetadata($logEntry);
211
212        // update the purgefile (timestamp of the last time anything within the wiki was changed)
213        io_saveFile($conf['cachedir'] . '/purgefile', time());
214
215        return $data;
216    }
217
218    /**
219     * Checks if the current page version is newer than the last entry in the page's changelog.
220     * If so, we assume it has been an external edit and we create an attic copy and add a proper
221     * changelog line.
222     *
223     * This check is only executed when the page is about to be saved again from the wiki,
224     * triggered in @see saveWikiText()
225     */
226    public function detectExternalEdit()
227    {
228        $revInfo = $this->changelog->getCurrentRevisionInfo();
229
230        // only interested in external revision
231        if (empty($revInfo) || !array_key_exists('timestamp', $revInfo)) return;
232
233        if ($revInfo['type'] != DOKU_CHANGE_TYPE_DELETE && !$revInfo['timestamp']) {
234            // file is older than last revision, that is erroneous/incorrect occurence.
235            // try to change file modification time
236            $fileLastMod = $this->getPath();
237            $wrong_timestamp = filemtime($fileLastMod);
238            if (touch($fileLastMod, $revInfo['date'])) {
239                clearstatcache();
240                $msg = "PageFile($this->id)::detectExternalEdit(): timestamp successfully modified";
241                $details = '(' . $wrong_timestamp . ' -> ' . $revInfo['date'] . ')';
242                Logger::error($msg, $details, $fileLastMod);
243            } else {
244                // runtime error
245                $msg = "PageFile($this->id)::detectExternalEdit(): page file should be newer than last revision "
246                      . '(' . filemtime($fileLastMod) . ' < ' . $this->changelog->lastRevision() . ')';
247                throw new RuntimeException($msg);
248            }
249        }
250
251        // keep at least 1 sec before new page save
252        if ($revInfo['date'] == time()) sleep(1); // wait a tick
253
254        // store externally edited file to the attic folder
255        $this->saveOldRevision();
256        // add a changelog entry for externally edited file
257        $this->changelog->addLogEntry($revInfo);
258        // remove soon to be stale instructions
259        $cache = new CacheInstructions($this->id, $this->getPath());
260        $cache->removeCache();
261    }
262
263    /**
264     * Moves the current version to the attic and returns its revision date
265     *
266     * @author Andreas Gohr <andi@splitbrain.org>
267     *
268     * @return int|string revision timestamp
269     */
270    public function saveOldRevision()
271    {
272        $oldfile = $this->getPath();
273        if (!file_exists($oldfile)) return '';
274        $date = filemtime($oldfile);
275        $newfile = $this->getPath($date);
276        io_writeWikiPage($newfile, $this->rawWikiText(), $this->id, $date);
277        return $date;
278    }
279
280    /**
281     * Update metadata of changed page
282     *
283     * @param array $logEntry  changelog entry
284     */
285    public function updateMetadata(array $logEntry)
286    {
287        global $INFO;
288
289        ['date' => $date, 'type' => $changeType, 'user' => $user, ] = $logEntry;
290
291        $wasRemoved   = ($changeType === DOKU_CHANGE_TYPE_DELETE);
292        $wasCreated   = ($changeType === DOKU_CHANGE_TYPE_CREATE);
293        $wasReverted  = ($changeType === DOKU_CHANGE_TYPE_REVERT);
294        $wasMinorEdit = ($changeType === DOKU_CHANGE_TYPE_MINOR_EDIT);
295
296        $createdDate = @filectime($this->getPath());
297
298        if ($wasRemoved) return;
299
300        $oldmeta = p_read_metadata($this->id)['persistent'];
301        $meta    = [];
302
303        if (
304            $wasCreated &&
305            (empty($oldmeta['date']['created']) || $oldmeta['date']['created'] === $createdDate)
306        ) {
307            // newly created
308            $meta['date']['created'] = $createdDate;
309            if ($user) {
310                $meta['creator'] = $INFO['userinfo']['name'] ?? null;
311                $meta['user']    = $user;
312            }
313        } elseif (($wasCreated || $wasReverted) && !empty($oldmeta['date']['created'])) {
314            // re-created / restored
315            $meta['date']['created']  = $oldmeta['date']['created'];
316            $meta['date']['modified'] = $createdDate; // use the files ctime here
317            $meta['creator'] = $oldmeta['creator'] ?? null;
318            if ($user) {
319                $meta['contributor'][$user] = $INFO['userinfo']['name'] ?? null;
320            }
321        } elseif (!$wasMinorEdit) {   // non-minor modification
322            $meta['date']['modified'] = $date;
323            if ($user) {
324                $meta['contributor'][$user] = $INFO['userinfo']['name'] ?? null;
325            }
326        }
327        $meta['last_change'] = $logEntry;
328        p_set_metadata($this->id, $meta);
329    }
330}
331