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 * @return array data of event COMMON_WIKIPAGE_SAVE 76 */ 77 public function saveWikiText($text, $summary, $minor = false) 78 { 79 /* Note to developers: 80 This code is subtle and delicate. Test the behavior of 81 the attic and changelog with dokuwiki and external edits 82 after any changes. External edits change the wiki page 83 directly without using php or dokuwiki. 84 */ 85 global $conf; 86 global $lang; 87 global $REV; 88 /* @var Input $INPUT */ 89 global $INPUT; 90 91 // prevent recursive call 92 if (isset($this->data)) return; 93 94 $pagefile = $this->getPath(); 95 $currentRevision = @filemtime($pagefile); // int or false 96 $currentContent = $this->rawWikiText(); 97 $currentSize = file_exists($pagefile) ? filesize($pagefile) : 0; 98 99 // prepare data for event COMMON_WIKIPAGE_SAVE 100 $data = array( 101 'id' => $this->id, // should not be altered by any handlers 102 'file' => $pagefile, // same above 103 'changeType' => null, // set prior to event, and confirm later 104 'revertFrom' => $REV, 105 'oldRevision' => $currentRevision, 106 'oldContent' => $currentContent, 107 'newRevision' => 0, // only available in the after hook 108 'newContent' => $text, 109 'summary' => $summary, 110 'contentChanged' => (bool)($text != $currentContent), // confirm later 111 'changeInfo' => '', // automatically determined by revertFrom 112 'sizechange' => strlen($text) - strlen($currentContent), // TBD 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 $data['page'] = $this; // allow event handlers to use this class methods 135 136 $event = new Event('COMMON_WIKIPAGE_SAVE', $data); 137 if (!$event->advise_before()) return; 138 139 // if the content has not been changed, no save happens (plugins may override this) 140 if (!$data['contentChanged']) return; 141 142 // Check whether the pagefile has modified during $event->advise_before() 143 clearstatcache(); 144 $fileRev = @filemtime($pagefile); 145 if ($fileRev === $currentRevision) { 146 // pagefile has not touched by plugin's event handler 147 // add a potential external edit entry to changelog and store it into attic 148 $this->detectExternalEdit(); 149 $filesize_old = $currentSize; 150 } else { 151 // pagefile has modified by plugin's event handler, confirm sizechange 152 $filesize_old = ( 153 $data['changeType'] == DOKU_CHANGE_TYPE_CREATE || ( 154 $data['changeType'] == DOKU_CHANGE_TYPE_REVERT && !file_exists($pagefile)) 155 ) ? 0 : filesize($pagefile); 156 } 157 158 // make change to the current file 159 if ($data['changeType'] == DOKU_CHANGE_TYPE_DELETE) { 160 // nothing to do when the file has already deleted 161 if (!file_exists($pagefile)) return; 162 // autoset summary on deletion 163 if (blank($data['summary'])) { 164 $data['summary'] = $lang['deleted']; 165 } 166 // send "update" event with empty data, so plugins can react to page deletion 167 $ioData = array([$pagefile, '', false], getNS($this->id), noNS($this->id), false); 168 Event::createAndTrigger('IO_WIKIPAGE_WRITE', $ioData); 169 // pre-save deleted revision 170 @touch($pagefile); 171 clearstatcache(); 172 $data['newRevision'] = $this->saveOldRevision(); 173 // remove empty file 174 @unlink($pagefile); 175 $filesize_new = 0; 176 // don't remove old meta info as it should be saved, plugins can use 177 // IO_WIKIPAGE_WRITE for removing their metadata... 178 // purge non-persistant meta data 179 p_purge_metadata($this->id); 180 // remove empty namespaces 181 io_sweepNS($this->id, 'datadir'); 182 io_sweepNS($this->id, 'mediadir'); 183 } else { 184 // save file (namespace dir is created in io_writeWikiPage) 185 io_writeWikiPage($pagefile, $data['newContent'], $this->id); 186 // pre-save the revision, to keep the attic in sync 187 $data['newRevision'] = $this->saveOldRevision(); 188 $filesize_new = filesize($pagefile); 189 } 190 $data['sizechange'] = $filesize_new - $filesize_old; 191 192 $event->advise_after(); 193 194 unset($data['page']); 195 196 // adds an entry to the changelog and saves the metadata for the page 197 $logEntry = $this->changelog->addLogEntry([ 198 'date' => $data['newRevision'], 199 'ip' => clientIP(true), 200 'type' => $data['changeType'], 201 'id' => $this->id, 202 'user' => $INPUT->server->str('REMOTE_USER'), 203 'sum' => $data['summary'], 204 'extra' => $data['changeInfo'], 205 'sizechange' => $data['sizechange'], 206 ]); 207 // update metadata 208 $this->updateMetadata($logEntry); 209 210 // update the purgefile (timestamp of the last time anything within the wiki was changed) 211 io_saveFile($conf['cachedir'].'/purgefile', time()); 212 213 return $data; 214 } 215 216 /** 217 * Checks if the current page version is newer than the last entry in the page's changelog. 218 * If so, we assume it has been an external edit and we create an attic copy and add a proper 219 * changelog line. 220 * 221 * This check is only executed when the page is about to be saved again from the wiki, 222 * triggered in @see saveWikiText() 223 */ 224 public function detectExternalEdit() 225 { 226 $revInfo = $this->changelog->getCurrentRevisionInfo(); 227 228 // only interested in external revision 229 if (empty($revInfo) || !array_key_exists('timestamp', $revInfo)) return; 230 231 if ($revInfo['type'] != DOKU_CHANGE_TYPE_DELETE && !$revInfo['timestamp']) { 232 // file is older than last revision, that is erroneous/incorrect occurence. 233 // try to change file modification time 234 $fileLastMod = $this->getPath(); 235 $wrong_timestamp = filemtime($fileLastMod); 236 if (touch($fileLastMod, $revInfo['date'])) { 237 clearstatcache(); 238 $msg = "detectExternalEdit($id): timestamp successfully modified"; 239 $details = '('.$wrong_timestamp.' -> '.$revInfo['date'].')'; 240 Logger::error($msg, $details, $fileLastMod); 241 } else { 242 // runtime error 243 $msg = "detectExternalEdit($id): page file should be newer than last revision " 244 .'('.filemtime($fileLastMod).' < '. $this->changelog->lastRevision() .')'; 245 throw new \RuntimeException($msg); 246 } 247 } 248 249 // keep at least 1 sec before new page save 250 if ($revInfo['date'] == time()) sleep(1); // wait a tick 251 252 // store externally edited file to the attic folder 253 $this->saveOldRevision(); 254 // add a changelog entry for externally edited file 255 $revInfo = $this->changelog->addLogEntry($revInfo); 256 // remove soon to be stale instructions 257 $cache = new CacheInstructions($this->id, $this->getPath()); 258 $cache->removeCache(); 259 } 260 261 /** 262 * Moves the current version to the attic and returns its revision date 263 * 264 * @author Andreas Gohr <andi@splitbrain.org> 265 * 266 * @return int|string revision timestamp 267 */ 268 public function saveOldRevision() 269 { 270 $oldfile = $this->getPath(); 271 if (!file_exists($oldfile)) return ''; 272 $date = filemtime($oldfile); 273 $newfile = $this->getPath($date); 274 io_writeWikiPage($newfile, $this->rawWikiText(), $this->id, $date); 275 return $date; 276 } 277 278 /** 279 * Update metadata of changed page 280 * 281 * @param array $logEntry changelog entry 282 */ 283 public function updateMetadata(array $logEntry) 284 { 285 global $INFO; 286 287 list( 288 'date' => $date, 289 'type' => $changeType, 290 'user' => $user, 291 ) = $logEntry; 292 293 $wasRemoved = ($changeType === DOKU_CHANGE_TYPE_DELETE); 294 $wasCreated = ($changeType === DOKU_CHANGE_TYPE_CREATE); 295 $wasReverted = ($changeType === DOKU_CHANGE_TYPE_REVERT); 296 $wasMinorEdit = ($changeType === DOKU_CHANGE_TYPE_MINOR_EDIT); 297 298 $createdDate = @filectime($this->getPath()); 299 300 if ($wasRemoved) return; 301 302 $oldmeta = p_read_metadata($this->id)['persistent']; 303 $meta = array(); 304 305 if ($wasCreated && 306 (empty($oldmeta['date']['created']) || $oldmeta['date']['created'] === $createdDate) 307 ) { 308 // newly created 309 $meta['date']['created'] = $createdDate; 310 if ($user) { 311 $meta['creator'] = $INFO['userinfo']['name'] ?? null; 312 $meta['user'] = $user; 313 } 314 } elseif (($wasCreated || $wasReverted) && !empty($oldmeta['date']['created'])) { 315 // re-created / restored 316 $meta['date']['created'] = $oldmeta['date']['created']; 317 $meta['date']['modified'] = $createdDate; // use the files ctime here 318 $meta['creator'] = $oldmeta['creator'] ?? null; 319 if ($user) { 320 $meta['contributor'][$user] = $INFO['userinfo']['name'] ?? null; 321 } 322 } elseif (!$wasMinorEdit) { // non-minor modification 323 $meta['date']['modified'] = $date; 324 if ($user) { 325 $meta['contributor'][$user] = $INFO['userinfo']['name'] ?? null; 326 } 327 } 328 $meta['last_change'] = $logEntry; 329 p_set_metadata($this->id, $meta); 330 } 331 332} 333