1<?php 2 3namespace dokuwiki\File; 4 5use dokuwiki\ChangeLog\PageChangeLog; 6use dokuwiki\Extension\Event; 7use dokuwiki\Input\Input; 8 9/** 10 * Class PageFile : handles wiki text file and its change management for specific page 11 */ 12class PageFile 13{ 14 protected $id; 15 16 /* @var PageChangeLog $changelog */ 17 public $changelog; 18 19 /* @var array $data initial data when event COMMON_WIKIPAGE_SAVE triggered */ 20 protected $data; 21 22 /** 23 * PageFile constructor. 24 * 25 * @param string $id 26 */ 27 public function __construct($id) 28 { 29 $this->id = $id; 30 $this->changelog = new PageChangeLog($this->id); 31 } 32 33 /** @return string */ 34 public function getId() 35 { 36 return $this->id; 37 } 38 39 /** @return string */ 40 public function getPath($rev = '') 41 { 42 return wikiFN($this->id, $rev); 43 } 44 45 /** 46 * Get raw WikiText of the page, considering change type at revision date 47 * similar to function rawWiki($id, $rev = '') 48 * 49 * @param int|false $rev timestamp when a revision of wikitext is desired 50 * @return string 51 */ 52 public function rawWikiText($rev = null) 53 { 54 if ($rev !== null) { 55 $revInfo = $rev ? $this->changelog->getRevisionInfo($rev) : false; 56 return (!$revInfo || $revInfo['type'] == DOKU_CHANGE_TYPE_DELETE) 57 ? '' // attic stores complete last page version for a deleted page 58 : io_readWikiPage($this->getPath($rev), $this->id, $rev); // retrieve from attic 59 } else { 60 return io_readWikiPage($this->getPath(), $this->id, ''); 61 } 62 } 63 64 /** 65 * Saves a wikitext by calling io_writeWikiPage. 66 * Also directs changelog and attic updates. 67 * 68 * @author Andreas Gohr <andi@splitbrain.org> 69 * @author Ben Coburn <btcoburn@silicodon.net> 70 * 71 * @param string $text wikitext being saved 72 * @param string $summary summary of text update 73 * @param bool $minor mark this saved version as minor update 74 * @return array|void data of event COMMON_WIKIPAGE_SAVE 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 = [ 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' => ($text != $currentContent),// confirm later 110 'changeInfo' => '',// automatically determined by revertFrom 111 'sizechange' => strlen($text) - strlen($currentContent), 112 ]; 113 114 // determine tentatively change type and relevant elements of event data 115 if ($data['revertFrom']) { 116 // new text may differ from exact revert revision 117 $data['changeType'] = DOKU_CHANGE_TYPE_REVERT; 118 $data['changeInfo'] = $REV; 119 } elseif (trim($data['newContent']) == '') { 120 // empty or whitespace only content deletes 121 $data['changeType'] = DOKU_CHANGE_TYPE_DELETE; 122 } elseif (!file_exists($pagefile)) { 123 $data['changeType'] = DOKU_CHANGE_TYPE_CREATE; 124 } else { 125 // minor edits allowable only for logged in users 126 $is_minor_change = ($minor && $conf['useacl'] && $INPUT->server->str('REMOTE_USER')); 127 $data['changeType'] = $is_minor_change 128 ? DOKU_CHANGE_TYPE_MINOR_EDIT 129 : DOKU_CHANGE_TYPE_EDIT; 130 } 131 132 $this->data = $data; 133 $data['page'] = $this; // allow event handlers to use this class methods 134 135 $event = new Event('COMMON_WIKIPAGE_SAVE', $data); 136 if (!$event->advise_before()) return; 137 138 // if the content has not been changed, no save happens (plugins may override this) 139 if (!$data['contentChanged']) return; 140 141 // Check whether the pagefile has modified during $event->advise_before() 142 clearstatcache(); 143 $fileRev = @filemtime($pagefile); 144 if ($fileRev === $currentRevision) { 145 // pagefile has not been touched by plugin's event handler — trigger external-edit 146 // detection. ChangeLog persists the synthesized entry (and copies to attic for 147 // non-deletes) on first observation, so this also covers the "external edit 148 // happens just before save" case. 149 $revInfo = $this->changelog->getCurrentRevisionInfo(); 150 // ensure the upcoming save timestamp is at least 1 sec after a persisted external entry 151 if (is_array($revInfo) && isset($revInfo['date']) && $revInfo['date'] == time()) { 152 sleep(1); 153 } 154 $filesize_old = $currentSize; 155 } else { 156 // pagefile has modified by plugin's event handler, confirm sizechange 157 $filesize_old = ( 158 $data['changeType'] == DOKU_CHANGE_TYPE_CREATE || ( 159 $data['changeType'] == DOKU_CHANGE_TYPE_REVERT && !file_exists($pagefile)) 160 ) ? 0 : filesize($pagefile); 161 } 162 163 // make change to the current file 164 if ($data['changeType'] == DOKU_CHANGE_TYPE_DELETE) { 165 // nothing to do when the file has already deleted 166 if (!file_exists($pagefile)) return; 167 // autoset summary on deletion 168 if (blank($data['summary'])) { 169 $data['summary'] = $lang['deleted']; 170 } 171 // send "update" event with empty data, so plugins can react to page deletion 172 $ioData = [[$pagefile, '', false], getNS($this->id), noNS($this->id), false]; 173 Event::createAndTrigger('IO_WIKIPAGE_WRITE', $ioData); 174 // pre-save deleted revision 175 @touch($pagefile); 176 clearstatcache(); 177 $data['newRevision'] = $this->saveOldRevision(); 178 // remove empty file 179 @unlink($pagefile); 180 $filesize_new = 0; 181 // don't remove old meta info as it should be saved, plugins can use 182 // IO_WIKIPAGE_WRITE for removing their metadata... 183 // purge non-persistant meta data 184 p_purge_metadata($this->id); 185 // remove empty namespaces 186 io_sweepNS($this->id, 'datadir'); 187 io_sweepNS($this->id, 'mediadir'); 188 } else { 189 // save file (namespace dir is created in io_writeWikiPage) 190 io_writeWikiPage($pagefile, $data['newContent'], $this->id); 191 // pre-save the revision, to keep the attic in sync 192 $data['newRevision'] = $this->saveOldRevision(); 193 $filesize_new = filesize($pagefile); 194 } 195 $data['sizechange'] = $filesize_new - $filesize_old; 196 197 $event->advise_after(); 198 199 unset($data['page']); 200 201 // adds an entry to the changelog and saves the metadata for the page 202 $logEntry = $this->changelog->addLogEntry([ 203 'date' => $data['newRevision'], 204 'ip' => clientIP(true), 205 'type' => $data['changeType'], 206 'id' => $this->id, 207 'user' => $INPUT->server->str('REMOTE_USER'), 208 'sum' => $data['summary'], 209 'extra' => $data['changeInfo'], 210 'sizechange' => $data['sizechange'], 211 ]); 212 // update metadata 213 $this->updateMetadata($logEntry); 214 215 // update the purgefile (timestamp of the last time anything within the wiki was changed) 216 io_saveFile($conf['cachedir'] . '/purgefile', time()); 217 218 return $data; 219 } 220 221 /** 222 * Moves the current version to the attic and returns its revision date 223 * 224 * @author Andreas Gohr <andi@splitbrain.org> 225 * 226 * @return int|string revision timestamp 227 */ 228 public function saveOldRevision() 229 { 230 $oldfile = $this->getPath(); 231 if (!file_exists($oldfile)) return ''; 232 $date = filemtime($oldfile); 233 $newfile = $this->getPath($date); 234 io_writeWikiPage($newfile, $this->rawWikiText(), $this->id, $date); 235 return $date; 236 } 237 238 /** 239 * Update metadata of changed page 240 * 241 * @param array $logEntry changelog entry 242 */ 243 public function updateMetadata(array $logEntry) 244 { 245 global $INFO; 246 247 ['date' => $date, 'type' => $changeType, 'user' => $user, ] = $logEntry; 248 249 $wasRemoved = ($changeType === DOKU_CHANGE_TYPE_DELETE); 250 $wasCreated = ($changeType === DOKU_CHANGE_TYPE_CREATE); 251 $wasReverted = ($changeType === DOKU_CHANGE_TYPE_REVERT); 252 $wasMinorEdit = ($changeType === DOKU_CHANGE_TYPE_MINOR_EDIT); 253 254 $createdDate = @filectime($this->getPath()); 255 256 if ($wasRemoved) return; 257 258 $oldmeta = p_read_metadata($this->id)['persistent']; 259 $meta = []; 260 261 if ( 262 $wasCreated && 263 (empty($oldmeta['date']['created']) || $oldmeta['date']['created'] === $createdDate) 264 ) { 265 // newly created 266 $meta['date']['created'] = $createdDate; 267 if ($user) { 268 $meta['creator'] = $INFO['userinfo']['name'] ?? null; 269 $meta['user'] = $user; 270 } 271 } elseif (($wasCreated || $wasReverted) && !empty($oldmeta['date']['created'])) { 272 // re-created / restored 273 $meta['date']['created'] = $oldmeta['date']['created']; 274 $meta['date']['modified'] = $createdDate; // use the files ctime here 275 $meta['creator'] = $oldmeta['creator'] ?? null; 276 if ($user) { 277 $meta['contributor'][$user] = $INFO['userinfo']['name'] ?? null; 278 } 279 } elseif (!$wasMinorEdit) { // non-minor modification 280 $meta['date']['modified'] = $date; 281 if ($user) { 282 $meta['contributor'][$user] = $INFO['userinfo']['name'] ?? null; 283 } 284 } 285 $meta['last_change'] = $logEntry; 286 p_set_metadata($this->id, $meta); 287 } 288} 289