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