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