xref: /dokuwiki/inc/File/PageFile.php (revision 666bc21d99e4980d910005abf0a399fad1b33024)
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     */
76    public function saveWikiText($text, $summary, $minor = false)
77    {
78        /* Note to developers:
79           This code is subtle and delicate. Test the behavior of
80           the attic and changelog with dokuwiki and external edits
81           after any changes. External edits change the wiki page
82           directly without using php or dokuwiki.
83         */
84        global $conf;
85        global $lang;
86        global $REV;
87        /* @var Input $INPUT */
88        global $INPUT;
89
90        // prevent recursive call
91        if (isset($this->data)) return;
92
93        $pagefile = $this->getPath();
94        $currentRevision = @filemtime($pagefile);       // int or false
95        $currentContent = $this->rawWikiText();
96        $currentSize = file_exists($pagefile) ? filesize($pagefile) : 0;
97
98        // prepare data for event COMMON_WIKIPAGE_SAVE
99        $data = array(
100            'id'             => $this->id, // should not be altered by any handlers
101            'file'           => $pagefile, // same above
102            'changeType'     => null,      // set prior to event, and confirm later
103            'revertFrom'     => $REV,
104            'oldRevision'    => $currentRevision,
105            'oldContent'     => $currentContent,
106            'newRevision'    => 0,         // only available in the after hook
107            'newContent'     => $text,
108            'summary'        => $summary,
109            'contentChanged' => (bool)($text != $currentContent), // confirm later
110            'changeInfo'     => '',        // automatically determined by revertFrom
111            'sizechange'     => strlen($text) - strlen($currentContent), // TBD
112            'page'           => $this,     // allow handlers to use class methods
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        $event = new Event('COMMON_WIKIPAGE_SAVE', $data);
135        if (!$event->advise_before()) return;
136
137        // if the content has not been changed, no save happens (plugins may override this)
138        if (!$data['contentChanged']) return;
139
140        // Check whether the pagefile has modified during $event->advise_before()
141        clearstatcache();
142        $fileRev = @filemtime($pagefile);
143        if ($fileRev === $currentRevision) {
144            // pagefile has not touched by plugin's event handler
145            // add a potential external edit entry to changelog and store it into attic
146            $this->detectExternalEdit();
147            $filesize_old = $currentSize;
148        } else {
149            // pagefile has modified by plugin's event handler, confirm sizechange
150            $filesize_old = (
151                $data['changeType'] == DOKU_CHANGE_TYPE_CREATE || (
152                $data['changeType'] == DOKU_CHANGE_TYPE_REVERT && !file_exists($pagefile))
153            ) ? 0 : filesize($pagefile);
154        }
155
156        // make change to the current file
157        if ($data['changeType'] == DOKU_CHANGE_TYPE_DELETE) {
158            // nothing to do when the file has already deleted
159            if (!file_exists($pagefile)) return;
160            // autoset summary on deletion
161            if (blank($data['summary'])) {
162                $data['summary'] = $lang['deleted'];
163            }
164            // send "update" event with empty data, so plugins can react to page deletion
165            $ioData = array([$pagefile, '', false], getNS($this->id), noNS($this->id), false);
166            Event::createAndTrigger('IO_WIKIPAGE_WRITE', $ioData);
167            // pre-save deleted revision
168            @touch($pagefile);
169            clearstatcache();
170            $data['newRevision'] = $this->saveOldRevision();
171            // remove empty file
172            @unlink($pagefile);
173            $filesize_new = 0;
174            // don't remove old meta info as it should be saved, plugins can use
175            // IO_WIKIPAGE_WRITE for removing their metadata...
176            // purge non-persistant meta data
177            p_purge_metadata($this->id);
178            // remove empty namespaces
179            io_sweepNS($this->id, 'datadir');
180            io_sweepNS($this->id, 'mediadir');
181        } else {
182            // save file (namespace dir is created in io_writeWikiPage)
183            io_writeWikiPage($pagefile, $data['newContent'], $this->id);
184            // pre-save the revision, to keep the attic in sync
185            $data['newRevision'] = $this->saveOldRevision();
186            $filesize_new = filesize($pagefile);
187        }
188        $data['sizechange'] = $filesize_new - $filesize_old;
189
190        //
191        $event->advise_after();
192
193        // adds an entry to the changelog and saves the metadata for the page
194        addLogEntry(
195            $data['newRevision'],
196            $this->id,
197            $data['changeType'],
198            $data['summary'],
199            $data['changeInfo'],
200            null,
201            $data['sizechange']
202        );
203
204        // update the purgefile (timestamp of the last time anything within the wiki was changed)
205        io_saveFile($conf['cachedir'].'/purgefile', time());
206
207        return $data;
208    }
209
210    /**
211     * Checks if the current page version is newer than the last entry in the page's changelog.
212     * If so, we assume it has been an external edit and we create an attic copy and add a proper
213     * changelog line.
214     *
215     * This check is only executed when the page is about to be saved again from the wiki,
216     * triggered in @see saveWikiText()
217     */
218    public function detectExternalEdit()
219    {
220        $revInfo = $this->changelog->getCurrentRevisionInfo();
221
222        // only interested in external revision
223        if (empty($revInfo) || !array_key_exists('timestamp', $revInfo)) return;
224
225        if ($revInfo['type'] != DOKU_CHANGE_TYPE_DELETE && !$revInfo['timestamp']) {
226            // file is older than last revision, that is erroneous/incorrect occurence.
227            // try to change file modification time
228            $fileLastMod = $this->getPath();
229            $wrong_timestamp = filemtime($fileLastMod);
230            if (touch($fileLastMod, $revInfo['date'])) {
231                clearstatcache();
232                $msg = "detectExternalEdit($id): timestamp successfully modified";
233                $details = '('.$wrong_timestamp.' -> '.$revInfo['date'].')';
234                Logger::error($msg, $details, $fileLastMod);
235            } else {
236                // runtime error
237                $msg = "detectExternalEdit($id): page file should be newer than last revision "
238                      .'('.filemtime($fileLastMod).' < '. $this->changelog->lastRevision() .')';
239                throw new \RuntimeException($msg);
240            }
241        }
242
243        // keep at least 1 sec before new page save
244        if ($revInfo['date'] == time()) sleep(1); // wait a tick
245
246        // store externally edited file to the attic folder
247        $this->saveOldRevision();
248        // add a changelog entry for externally edited file
249        $revInfo = $this->changelog->addLogEntry($revInfo);
250        // remove soon to be stale instructions
251        $cache = new CacheInstructions($this->id, $this->getPath());
252        $cache->removeCache();
253    }
254
255    /**
256     * Moves the current version to the attic and returns its revision date
257     *
258     * @author Andreas Gohr <andi@splitbrain.org>
259     *
260     * @return int|string revision timestamp
261     */
262    public function saveOldRevision()
263    {
264        $oldfile = $this->getPath();
265        if (!file_exists($oldfile)) return '';
266        $date = filemtime($oldfile);
267        $newfile = $this->getPath($date);
268        io_writeWikiPage($newfile, $this->rawWikiText(), $this->id, $date);
269        return $date;
270    }
271
272}
273