1 <?php
2 
3 namespace dokuwiki\File;
4 
5 use dokuwiki\Cache\CacheInstructions;
6 use dokuwiki\ChangeLog\PageChangeLog;
7 use dokuwiki\Extension\Event;
8 use dokuwiki\Input\Input;
9 use dokuwiki\Logger;
10 use RuntimeException;
11 
12 /**
13  * Class PageFile : handles wiki text file and its change management for specific page
14  */
15 class 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