aboutsummaryrefslogtreecommitdiffstatshomepage
path: root/inc/File/PageFile.php
blob: 8fc95e55dec76857f22ae5abf561ef5c3d247ba2 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
<?php

namespace dokuwiki\File;

use dokuwiki\Cache\CacheInstructions;
use dokuwiki\ChangeLog\PageChangeLog;
use dokuwiki\Extension\Event;
use dokuwiki\Input\Input;
use dokuwiki\Logger;
use RuntimeException;

/**
 * Class PageFile : handles wiki text file and its change management for specific page
 */
class PageFile
{
    protected $id;

    /* @var PageChangeLog $changelog */
    public $changelog;

    /* @var array $data  initial data when event COMMON_WIKIPAGE_SAVE triggered */
    protected $data;

    /**
     * PageFile constructor.
     *
     * @param string $id
     */
    public function __construct($id)
    {
        $this->id = $id;
        $this->changelog = new PageChangeLog($this->id);
    }

    /** @return string */
    public function getId()
    {
        return $this->id;
    }

    /** @return string */
    public function getPath($rev = '')
    {
        return wikiFN($this->id, $rev);
    }

    /**
     * Get raw WikiText of the page, considering change type at revision date
     * similar to function rawWiki($id, $rev = '')
     *
     * @param int|false $rev  timestamp when a revision of wikitext is desired
     * @return string
     */
    public function rawWikiText($rev = null)
    {
        if ($rev !== null) {
            $revInfo = $rev ? $this->changelog->getRevisionInfo($rev) : false;
            return (!$revInfo || $revInfo['type'] == DOKU_CHANGE_TYPE_DELETE)
                ? '' // attic stores complete last page version for a deleted page
                : io_readWikiPage($this->getPath($rev), $this->id, $rev); // retrieve from attic
        } else {
            return io_readWikiPage($this->getPath(), $this->id, '');
        }
    }

    /**
     * Saves a wikitext by calling io_writeWikiPage.
     * Also directs changelog and attic updates.
     *
     * @author Andreas Gohr <andi@splitbrain.org>
     * @author Ben Coburn <btcoburn@silicodon.net>
     *
     * @param string $text     wikitext being saved
     * @param string $summary  summary of text update
     * @param bool   $minor    mark this saved version as minor update
     * @return array|void data of event COMMON_WIKIPAGE_SAVE
     */
    public function saveWikiText($text, $summary, $minor = false)
    {
        /* Note to developers:
           This code is subtle and delicate. Test the behavior of
           the attic and changelog with dokuwiki and external edits
           after any changes. External edits change the wiki page
           directly without using php or dokuwiki.
         */
        global $conf;
        global $lang;
        global $REV;
        /* @var Input $INPUT */
        global $INPUT;

        // prevent recursive call
        if (isset($this->data)) return;

        $pagefile = $this->getPath();
        $currentRevision = @filemtime($pagefile);       // int or false
        $currentContent = $this->rawWikiText();
        $currentSize = file_exists($pagefile) ? filesize($pagefile) : 0;

        // prepare data for event COMMON_WIKIPAGE_SAVE
        $data = [
            'id'             => $this->id,// should not be altered by any handlers
            'file'           => $pagefile,// same above
            'changeType'     => null,// set prior to event, and confirm later
            'revertFrom'     => $REV,
            'oldRevision'    => $currentRevision,
            'oldContent'     => $currentContent,
            'newRevision'    => 0,// only available in the after hook
            'newContent'     => $text,
            'summary'        => $summary,
            'contentChanged' => ($text != $currentContent),// confirm later
            'changeInfo'     => '',// automatically determined by revertFrom
            'sizechange'     => strlen($text) - strlen($currentContent),
        ];

        // determine tentatively change type and relevant elements of event data
        if ($data['revertFrom']) {
            // new text may differ from exact revert revision
            $data['changeType'] = DOKU_CHANGE_TYPE_REVERT;
            $data['changeInfo'] = $REV;
        } elseif (trim($data['newContent']) == '') {
            // empty or whitespace only content deletes
            $data['changeType'] = DOKU_CHANGE_TYPE_DELETE;
        } elseif (!file_exists($pagefile)) {
            $data['changeType'] = DOKU_CHANGE_TYPE_CREATE;
        } else {
            // minor edits allowable only for logged in users
            $is_minor_change = ($minor && $conf['useacl'] && $INPUT->server->str('REMOTE_USER'));
            $data['changeType'] = $is_minor_change
                ? DOKU_CHANGE_TYPE_MINOR_EDIT
                : DOKU_CHANGE_TYPE_EDIT;
        }

        $this->data = $data;
        $data['page'] = $this; // allow event handlers to use this class methods

        $event = new Event('COMMON_WIKIPAGE_SAVE', $data);
        if (!$event->advise_before()) return;

        // if the content has not been changed, no save happens (plugins may override this)
        if (!$data['contentChanged']) return;

        // Check whether the pagefile has modified during $event->advise_before()
        clearstatcache();
        $fileRev = @filemtime($pagefile);
        if ($fileRev === $currentRevision) {
            // pagefile has not touched by plugin's event handler
            // add a potential external edit entry to changelog and store it into attic
            $this->detectExternalEdit();
            $filesize_old = $currentSize;
        } else {
            // pagefile has modified by plugin's event handler, confirm sizechange
            $filesize_old = (
                $data['changeType'] == DOKU_CHANGE_TYPE_CREATE || (
                $data['changeType'] == DOKU_CHANGE_TYPE_REVERT && !file_exists($pagefile))
            ) ? 0 : filesize($pagefile);
        }

        // make change to the current file
        if ($data['changeType'] == DOKU_CHANGE_TYPE_DELETE) {
            // nothing to do when the file has already deleted
            if (!file_exists($pagefile)) return;
            // autoset summary on deletion
            if (blank($data['summary'])) {
                $data['summary'] = $lang['deleted'];
            }
            // send "update" event with empty data, so plugins can react to page deletion
            $ioData = [[$pagefile, '', false], getNS($this->id), noNS($this->id), false];
            Event::createAndTrigger('IO_WIKIPAGE_WRITE', $ioData);
            // pre-save deleted revision
            @touch($pagefile);
            clearstatcache();
            $data['newRevision'] = $this->saveOldRevision();
            // remove empty file
            @unlink($pagefile);
            $filesize_new = 0;
            // don't remove old meta info as it should be saved, plugins can use
            // IO_WIKIPAGE_WRITE for removing their metadata...
            // purge non-persistant meta data
            p_purge_metadata($this->id);
            // remove empty namespaces
            io_sweepNS($this->id, 'datadir');
            io_sweepNS($this->id, 'mediadir');
        } else {
            // save file (namespace dir is created in io_writeWikiPage)
            io_writeWikiPage($pagefile, $data['newContent'], $this->id);
            // pre-save the revision, to keep the attic in sync
            $data['newRevision'] = $this->saveOldRevision();
            $filesize_new = filesize($pagefile);
        }
        $data['sizechange'] = $filesize_new - $filesize_old;

        $event->advise_after();

        unset($data['page']);

        // adds an entry to the changelog and saves the metadata for the page
        $logEntry = $this->changelog->addLogEntry([
            'date'       => $data['newRevision'],
            'ip'         => clientIP(true),
            'type'       => $data['changeType'],
            'id'         => $this->id,
            'user'       => $INPUT->server->str('REMOTE_USER'),
            'sum'        => $data['summary'],
            'extra'      => $data['changeInfo'],
            'sizechange' => $data['sizechange'],
        ]);
        // update metadata
        $this->updateMetadata($logEntry);

        // update the purgefile (timestamp of the last time anything within the wiki was changed)
        io_saveFile($conf['cachedir'].'/purgefile', time());

        return $data;
    }

    /**
     * Checks if the current page version is newer than the last entry in the page's changelog.
     * If so, we assume it has been an external edit and we create an attic copy and add a proper
     * changelog line.
     *
     * This check is only executed when the page is about to be saved again from the wiki,
     * triggered in @see saveWikiText()
     */
    public function detectExternalEdit()
    {
        $revInfo = $this->changelog->getCurrentRevisionInfo();

        // only interested in external revision
        if (empty($revInfo) || !array_key_exists('timestamp', $revInfo)) return;

        if ($revInfo['type'] != DOKU_CHANGE_TYPE_DELETE && !$revInfo['timestamp']) {
            // file is older than last revision, that is erroneous/incorrect occurence.
            // try to change file modification time
            $fileLastMod = $this->getPath();
            $wrong_timestamp = filemtime($fileLastMod);
            if (touch($fileLastMod, $revInfo['date'])) {
                clearstatcache();
                $msg = "PageFile($this->id)::detectExternalEdit(): timestamp successfully modified";
                $details = '('.$wrong_timestamp.' -> '.$revInfo['date'].')';
                Logger::error($msg, $details, $fileLastMod);
            } else {
                // runtime error
                $msg = "PageFile($this->id)::detectExternalEdit(): page file should be newer than last revision "
                      .'('.filemtime($fileLastMod).' < '. $this->changelog->lastRevision() .')';
                throw new RuntimeException($msg);
            }
        }

        // keep at least 1 sec before new page save
        if ($revInfo['date'] == time()) sleep(1); // wait a tick

        // store externally edited file to the attic folder
        $this->saveOldRevision();
        // add a changelog entry for externally edited file
        $this->changelog->addLogEntry($revInfo);
        // remove soon to be stale instructions
        $cache = new CacheInstructions($this->id, $this->getPath());
        $cache->removeCache();
    }

    /**
     * Moves the current version to the attic and returns its revision date
     *
     * @author Andreas Gohr <andi@splitbrain.org>
     *
     * @return int|string revision timestamp
     */
    public function saveOldRevision()
    {
        $oldfile = $this->getPath();
        if (!file_exists($oldfile)) return '';
        $date = filemtime($oldfile);
        $newfile = $this->getPath($date);
        io_writeWikiPage($newfile, $this->rawWikiText(), $this->id, $date);
        return $date;
    }

    /**
     * Update metadata of changed page
     *
     * @param array $logEntry  changelog entry
     */
    public function updateMetadata(array $logEntry)
    {
        global $INFO;

        ['date' => $date, 'type' => $changeType, 'user' => $user, ] = $logEntry;

        $wasRemoved   = ($changeType === DOKU_CHANGE_TYPE_DELETE);
        $wasCreated   = ($changeType === DOKU_CHANGE_TYPE_CREATE);
        $wasReverted  = ($changeType === DOKU_CHANGE_TYPE_REVERT);
        $wasMinorEdit = ($changeType === DOKU_CHANGE_TYPE_MINOR_EDIT);

        $createdDate = @filectime($this->getPath());

        if ($wasRemoved) return;

        $oldmeta = p_read_metadata($this->id)['persistent'];
        $meta    = [];

        if ($wasCreated &&
            (empty($oldmeta['date']['created']) || $oldmeta['date']['created'] === $createdDate)
        ) {
            // newly created
            $meta['date']['created'] = $createdDate;
            if ($user) {
                $meta['creator'] = $INFO['userinfo']['name'] ?? null;
                $meta['user']    = $user;
            }
        } elseif (($wasCreated || $wasReverted) && !empty($oldmeta['date']['created'])) {
            // re-created / restored
            $meta['date']['created']  = $oldmeta['date']['created'];
            $meta['date']['modified'] = $createdDate; // use the files ctime here
            $meta['creator'] = $oldmeta['creator'] ?? null;
            if ($user) {
                $meta['contributor'][$user] = $INFO['userinfo']['name'] ?? null;
            }
        } elseif (!$wasMinorEdit) {   // non-minor modification
            $meta['date']['modified'] = $date;
            if ($user) {
                $meta['contributor'][$user] = $INFO['userinfo']['name'] ?? null;
            }
        }
        $meta['last_change'] = $logEntry;
        p_set_metadata($this->id, $meta);
    }

}