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 // 193 $event->advise_after(); 194 195 // adds an entry to the changelog and saves the metadata for the page 196 addLogEntry( 197 $data['newRevision'], 198 $this->id, 199 $data['changeType'], 200 $data['summary'], 201 $data['changeInfo'], 202 null, 203 $data['sizechange'] 204 ); 205 206 // update the purgefile (timestamp of the last time anything within the wiki was changed) 207 io_saveFile($conf['cachedir'].'/purgefile', time()); 208 209 unset($data['page']); 210 return $data; 211 } 212 213 /** 214 * Checks if the current page version is newer than the last entry in the page's changelog. 215 * If so, we assume it has been an external edit and we create an attic copy and add a proper 216 * changelog line. 217 * 218 * This check is only executed when the page is about to be saved again from the wiki, 219 * triggered in @see saveWikiText() 220 */ 221 public function detectExternalEdit() 222 { 223 $revInfo = $this->changelog->getCurrentRevisionInfo(); 224 225 // only interested in external revision 226 if (empty($revInfo) || !array_key_exists('timestamp', $revInfo)) return; 227 228 if ($revInfo['type'] != DOKU_CHANGE_TYPE_DELETE && !$revInfo['timestamp']) { 229 // file is older than last revision, that is erroneous/incorrect occurence. 230 // try to change file modification time 231 $fileLastMod = $this->getPath(); 232 $wrong_timestamp = filemtime($fileLastMod); 233 if (touch($fileLastMod, $revInfo['date'])) { 234 clearstatcache(); 235 $msg = "detectExternalEdit($id): timestamp successfully modified"; 236 $details = '('.$wrong_timestamp.' -> '.$revInfo['date'].')'; 237 Logger::error($msg, $details, $fileLastMod); 238 } else { 239 // runtime error 240 $msg = "detectExternalEdit($id): page file should be newer than last revision " 241 .'('.filemtime($fileLastMod).' < '. $this->changelog->lastRevision() .')'; 242 throw new \RuntimeException($msg); 243 } 244 } 245 246 // keep at least 1 sec before new page save 247 if ($revInfo['date'] == time()) sleep(1); // wait a tick 248 249 // store externally edited file to the attic folder 250 $this->saveOldRevision(); 251 // add a changelog entry for externally edited file 252 $revInfo = $this->changelog->addLogEntry($revInfo); 253 // remove soon to be stale instructions 254 $cache = new CacheInstructions($this->id, $this->getPath()); 255 $cache->removeCache(); 256 } 257 258 /** 259 * Moves the current version to the attic and returns its revision date 260 * 261 * @author Andreas Gohr <andi@splitbrain.org> 262 * 263 * @return int|string revision timestamp 264 */ 265 public function saveOldRevision() 266 { 267 $oldfile = $this->getPath(); 268 if (!file_exists($oldfile)) return ''; 269 $date = filemtime($oldfile); 270 $newfile = $this->getPath($date); 271 io_writeWikiPage($newfile, $this->rawWikiText(), $this->id, $date); 272 return $date; 273 } 274 275} 276