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