xref: /dokuwiki/inc/File/PageFile.php (revision 01e8d739c8b53aeb1d0a653331d65eb1f8394002)
1<?php
2
3namespace dokuwiki\File;
4
5use dokuwiki\ChangeLog\PageChangeLog;
6use dokuwiki\Extension\Event;
7use dokuwiki\Input\Input;
8
9/**
10 * Class PageFile : handles wiki text file and its change management for specific page
11 */
12class PageFile
13{
14    protected $id;
15
16    /* @var PageChangeLog $changelog */
17    public $changelog;
18
19    /* @var array $data  initial data when event COMMON_WIKIPAGE_SAVE triggered */
20    protected $data;
21
22    /**
23     * PageFile constructor.
24     *
25     * @param string $id
26     */
27    public function __construct($id)
28    {
29        $this->id = $id;
30        $this->changelog = new PageChangeLog($this->id);
31    }
32
33    /** @return string */
34    public function getId()
35    {
36        return $this->id;
37    }
38
39    /** @return string */
40    public function getPath($rev = '')
41    {
42        return wikiFN($this->id, $rev);
43    }
44
45    /**
46     * Get raw WikiText of the page, considering change type at revision date
47     * similar to function rawWiki($id, $rev = '')
48     *
49     * @param int|false $rev  timestamp when a revision of wikitext is desired
50     * @return string
51     */
52    public function rawWikiText($rev = null)
53    {
54        if ($rev !== null) {
55            $revInfo = $rev ? $this->changelog->getRevisionInfo($rev) : false;
56            return (!$revInfo || $revInfo['type'] == DOKU_CHANGE_TYPE_DELETE)
57                ? '' // attic stores complete last page version for a deleted page
58                : io_readWikiPage($this->getPath($rev), $this->id, $rev); // retrieve from attic
59        } else {
60            return io_readWikiPage($this->getPath(), $this->id, '');
61        }
62    }
63
64    /**
65     * Saves a wikitext by calling io_writeWikiPage.
66     * Also directs changelog and attic updates.
67     *
68     * @author Andreas Gohr <andi@splitbrain.org>
69     * @author Ben Coburn <btcoburn@silicodon.net>
70     *
71     * @param string $text     wikitext being saved
72     * @param string $summary  summary of text update
73     * @param bool   $minor    mark this saved version as minor update
74     * @return array|void data of event COMMON_WIKIPAGE_SAVE
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 = [
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' => ($text != $currentContent),// confirm later
110            'changeInfo'     => '',// automatically determined by revertFrom
111            'sizechange'     => strlen($text) - strlen($currentContent),
112        ];
113
114        // determine tentatively change type and relevant elements of event data
115        if ($data['revertFrom']) {
116            // new text may differ from exact revert revision
117            $data['changeType'] = DOKU_CHANGE_TYPE_REVERT;
118            $data['changeInfo'] = $REV;
119        } elseif (trim($data['newContent']) == '') {
120            // empty or whitespace only content deletes
121            $data['changeType'] = DOKU_CHANGE_TYPE_DELETE;
122        } elseif (!file_exists($pagefile)) {
123            $data['changeType'] = DOKU_CHANGE_TYPE_CREATE;
124        } else {
125            // minor edits allowable only for logged in users
126            $is_minor_change = ($minor && $conf['useacl'] && $INPUT->server->str('REMOTE_USER'));
127            $data['changeType'] = $is_minor_change
128                ? DOKU_CHANGE_TYPE_MINOR_EDIT
129                : DOKU_CHANGE_TYPE_EDIT;
130        }
131
132        $this->data = $data;
133        $data['page'] = $this; // allow event handlers to use this class methods
134
135        $event = new Event('COMMON_WIKIPAGE_SAVE', $data);
136        if (!$event->advise_before()) return;
137
138        // if the content has not been changed, no save happens (plugins may override this)
139        if (!$data['contentChanged']) return;
140
141        // Check whether the pagefile has modified during $event->advise_before()
142        clearstatcache();
143        $fileRev = @filemtime($pagefile);
144        if ($fileRev === $currentRevision) {
145            // pagefile has not been touched by plugin's event handler — trigger external-edit
146            // detection. ChangeLog persists the synthesized entry (and copies to attic for
147            // non-deletes) on first observation, so this also covers the "external edit
148            // happens just before save" case.
149            $revInfo = $this->changelog->getCurrentRevisionInfo();
150            // ensure the upcoming save timestamp is at least 1 sec after a persisted external entry
151            if (is_array($revInfo) && isset($revInfo['date']) && $revInfo['date'] == time()) {
152                sleep(1);
153            }
154            $filesize_old = $currentSize;
155        } else {
156            // pagefile has modified by plugin's event handler, confirm sizechange
157            $filesize_old = (
158                $data['changeType'] == DOKU_CHANGE_TYPE_CREATE || (
159                $data['changeType'] == DOKU_CHANGE_TYPE_REVERT && !file_exists($pagefile))
160            ) ? 0 : filesize($pagefile);
161        }
162
163        // make change to the current file
164        if ($data['changeType'] == DOKU_CHANGE_TYPE_DELETE) {
165            // nothing to do when the file has already deleted
166            if (!file_exists($pagefile)) return;
167            // autoset summary on deletion
168            if (blank($data['summary'])) {
169                $data['summary'] = $lang['deleted'];
170            }
171            // send "update" event with empty data, so plugins can react to page deletion
172            $ioData = [[$pagefile, '', false], getNS($this->id), noNS($this->id), false];
173            Event::createAndTrigger('IO_WIKIPAGE_WRITE', $ioData);
174            // pre-save deleted revision
175            @touch($pagefile);
176            clearstatcache();
177            $data['newRevision'] = $this->saveOldRevision();
178            // remove empty file
179            @unlink($pagefile);
180            $filesize_new = 0;
181            // don't remove old meta info as it should be saved, plugins can use
182            // IO_WIKIPAGE_WRITE for removing their metadata...
183            // purge non-persistant meta data
184            p_purge_metadata($this->id);
185            // remove empty namespaces
186            io_sweepNS($this->id, 'datadir');
187            io_sweepNS($this->id, 'mediadir');
188        } else {
189            // save file (namespace dir is created in io_writeWikiPage)
190            io_writeWikiPage($pagefile, $data['newContent'], $this->id);
191            // pre-save the revision, to keep the attic in sync
192            $data['newRevision'] = $this->saveOldRevision();
193            $filesize_new = filesize($pagefile);
194        }
195        $data['sizechange'] = $filesize_new - $filesize_old;
196
197        $event->advise_after();
198
199        unset($data['page']);
200
201        // adds an entry to the changelog and saves the metadata for the page
202        $logEntry = $this->changelog->addLogEntry([
203            'date'       => $data['newRevision'],
204            'ip'         => clientIP(true),
205            'type'       => $data['changeType'],
206            'id'         => $this->id,
207            'user'       => $INPUT->server->str('REMOTE_USER'),
208            'sum'        => $data['summary'],
209            'extra'      => $data['changeInfo'],
210            'sizechange' => $data['sizechange'],
211        ]);
212        // update metadata
213        $this->updateMetadata($logEntry);
214
215        // update the purgefile (timestamp of the last time anything within the wiki was changed)
216        io_saveFile($conf['cachedir'] . '/purgefile', time());
217
218        return $data;
219    }
220
221    /**
222     * Moves the current version to the attic and returns its revision date
223     *
224     * @author Andreas Gohr <andi@splitbrain.org>
225     *
226     * @return int|string revision timestamp
227     */
228    public function saveOldRevision()
229    {
230        $oldfile = $this->getPath();
231        if (!file_exists($oldfile)) return '';
232        $date = filemtime($oldfile);
233        $newfile = $this->getPath($date);
234        io_writeWikiPage($newfile, $this->rawWikiText(), $this->id, $date);
235        return $date;
236    }
237
238    /**
239     * Update metadata of changed page
240     *
241     * @param array $logEntry  changelog entry
242     */
243    public function updateMetadata(array $logEntry)
244    {
245        global $INFO;
246
247        ['date' => $date, 'type' => $changeType, 'user' => $user, ] = $logEntry;
248
249        $wasRemoved   = ($changeType === DOKU_CHANGE_TYPE_DELETE);
250        $wasCreated   = ($changeType === DOKU_CHANGE_TYPE_CREATE);
251        $wasReverted  = ($changeType === DOKU_CHANGE_TYPE_REVERT);
252        $wasMinorEdit = ($changeType === DOKU_CHANGE_TYPE_MINOR_EDIT);
253
254        $createdDate = @filectime($this->getPath());
255
256        if ($wasRemoved) return;
257
258        $oldmeta = p_read_metadata($this->id)['persistent'];
259        $meta    = [];
260
261        if (
262            $wasCreated &&
263            (empty($oldmeta['date']['created']) || $oldmeta['date']['created'] === $createdDate)
264        ) {
265            // newly created
266            $meta['date']['created'] = $createdDate;
267            if ($user) {
268                $meta['creator'] = $INFO['userinfo']['name'] ?? null;
269                $meta['user']    = $user;
270            }
271        } elseif (($wasCreated || $wasReverted) && !empty($oldmeta['date']['created'])) {
272            // re-created / restored
273            $meta['date']['created']  = $oldmeta['date']['created'];
274            $meta['date']['modified'] = $createdDate; // use the files ctime here
275            $meta['creator'] = $oldmeta['creator'] ?? null;
276            if ($user) {
277                $meta['contributor'][$user] = $INFO['userinfo']['name'] ?? null;
278            }
279        } elseif (!$wasMinorEdit) {   // non-minor modification
280            $meta['date']['modified'] = $date;
281            if ($user) {
282                $meta['contributor'][$user] = $INFO['userinfo']['name'] ?? null;
283            }
284        }
285        $meta['last_change'] = $logEntry;
286        p_set_metadata($this->id, $meta);
287    }
288}
289