PageFile.php 13 KB

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