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