1<?php 2 3use dokuwiki\Logger; 4use dokuwiki\Extension\ActionPlugin; 5use dokuwiki\Extension\Event; 6use dokuwiki\Extension\EventHandler; 7use dokuwiki\File\PageFile; 8 9/** 10 * DokuWiki Plugin renderrevisions (Action Component) 11 * 12 * @license GPL 2 http://www.gnu.org/licenses/gpl-2.0.html 13 * @author Andreas Gohr <dokuwiki@cosmocode.de> 14 */ 15class action_plugin_renderrevisions_save extends ActionPlugin 16{ 17 /** @var array list of pages that are processed by the plugin */ 18 protected $pages = []; 19 20 /** @var string|null the current page being saved, used to overwrite the contentchanged check */ 21 protected $current = null; 22 23 /** @inheritDoc */ 24 public function register(EventHandler $controller) 25 { 26 $controller->register_hook('PARSER_CACHE_USE', 'AFTER', $this, 'handleParserCacheUse'); 27 28 $controller->register_hook( 29 'RENDERER_CONTENT_POSTPROCESS', 30 'AFTER', 31 $this, 32 'handleRenderContent', 33 null, 34 PHP_INT_MAX // other plugins might want to change the content before we see it 35 ); 36 37 $controller->register_hook('COMMON_WIKIPAGE_SAVE', 'BEFORE', $this, 'handleCommonWikipageSave'); 38 } 39 40 /** 41 * Event handler for PARSER_CACHE_USE 42 * 43 * @see https://www.dokuwiki.org/devel:event:PARSER_CACHE_USE 44 * @param Event $event Event object 45 * @param mixed $param optional parameter passed when event was registered 46 * @return void 47 */ 48 public function handleParserCacheUse(Event $event, $param) 49 { 50 $cacheObject = $event->data; 51 52 if (!$cacheObject->page) return; 53 if ($cacheObject->mode !== 'xhtml') return; 54 55 // only process pages that match both the skip and match regex 56 57 $page = $cacheObject->page; 58 try { 59 [$skipRE, $matchRE] = $this->getRegexps(); 60 } catch (\Exception $e) { 61 msg(hsc($e->getMessage()), -1); 62 return; 63 } 64 if ( 65 ($skipRE && preg_match($skipRE, ":$page")) || 66 ($matchRE && !preg_match($matchRE, ":$page")) 67 ) { 68 return; 69 } 70 71 // remember that this page was processed 72 // This is a somewhat ugly workaround for when text snippets are rendered within the same page. 73 // Those snippets will not have a page context set during cache use event and thus not be processed 74 // later on in the RENDERER_CONTENT_POSTPROCESS event 75 $this->pages[$page] = true; 76 } 77 78 79 /** 80 * Event handler for RENDERER_CONTENT_POSTPROCESS 81 * 82 * @see https://www.dokuwiki.org/devel:event:RENDERER_CONTENT_POSTPROCESS 83 * @param Event $event Event object 84 * @param mixed $param optional parameter passed when event was registered 85 * @return void 86 */ 87 public function handleRenderContent(Event $event, $param) 88 { 89 [$format, $xhtml] = $event->data; 90 if ($format !== 'xhtml') return; 91 92 // thanks to the $this->pages property we might be able to skip some of those checks, but they don't hurt 93 global $ACT; 94 global $REV; 95 global $DATE_AT; 96 global $ID; 97 global $INFO; 98 if ($ACT !== 'show') return; 99 if ($REV) return; 100 if ($DATE_AT) return; 101 if (!$INFO['exists']) return; 102 if (!$ID) return; 103 if (!isset($this->pages[$ID])) return; 104 105 // all the above still does not ensure we skip sub renderings, so this is our last resort 106 if (count(array_filter(debug_backtrace(), fn($t) => $t['function'] === 'p_render')) > 1) return; 107 108 $md5cache = getCacheName($ID, '.renderrevision'); 109 $md5xhtml = $this->getContentHash($xhtml); 110 111 // depending on config, load storage helper 112 /** @var helper_plugin_renderrevisions_storage $storage */ 113 $storage = $this->getConf('store') ? plugin_load('helper', 'renderrevisions_storage') : null; 114 115 // no or outdated MD5 cache, create new one 116 // this means a new revision of the page has been created naturally 117 // we store the new render result and are done 118 if (!file_exists($md5cache) || filemtime(wikiFN($ID)) > filemtime($md5cache)) { 119 file_put_contents($md5cache, $md5xhtml); 120 $this->logDebug($ID . ' Wrote render hash cache: path=' . $md5cache . ' hash=' . $md5xhtml); 121 122 if ($storage) { 123 $storage->saveRevision($ID, filemtime(wikiFN($ID)), $xhtml); 124 $this->logDebug( 125 $ID . ' Found new revision and stored render for rev=' . filemtime(wikiFN($ID)) . 126 ' in ' . $storage->getFilename($ID, filemtime(wikiFN($ID))) 127 ); 128 129 $storage->cleanUp($ID); 130 } 131 132 return; 133 } 134 135 // only act on pages that have not been changed very recently 136 if (time() - filemtime(wikiFN($ID)) < $this->getConf('maxfrequency')) { 137 return; 138 } 139 140 // get the render result as it were when the page was last changed 141 $oldMd5 = file_get_contents($md5cache); 142 143 // did the rendered content change? 144 if ($oldMd5 === $md5xhtml) { 145 return; 146 } 147 148 // time to create a new revision 149 $oldPageMtime = filemtime(wikiFN($ID)); 150 $this->current = $ID; 151 (new PageFile($ID))->saveWikiText(rawWiki($ID), $this->getLang('summary')); 152 153 $this->logDebug($ID . ' Created new wiki revision'); 154 155 if ($storage) { 156 $diff = $this->getRenderDiff($storage->getRevision($ID, $oldPageMtime), $xhtml); 157 $this->logDebug( 158 $ID . ' Render diff between stored rev ' . $oldPageMtime . 159 ' (' . $storage->getFilename($ID, $oldPageMtime) . ') vs current:' . "\n" . $diff 160 ); 161 } else { 162 $this->logDebug($ID . ' Render diff unavailable: no stored render for rev ' . filemtime(wikiFN($ID))); 163 } 164 165 166 $this->current = null; 167 } 168 169 170 /** 171 * Event handler for COMMON_WIKIPAGE_SAVE 172 * 173 * Overwrite the contentChanged flag to force a new revision even though the content did not change 174 * 175 * @see https://www.dokuwiki.org/devel:event:COMMON_WIKIPAGE_SAVE 176 * @param Event $event Event object 177 * @param mixed $param optional parameter passed when event was registered 178 * @return void 179 */ 180 public function handleCommonWikipageSave(Event $event, $param) 181 { 182 if ($this->current !== $event->data['id']) return; 183 $event->data['contentChanged'] = true; 184 } 185 186 187 /** 188 * Read the skip and match regex from the config 189 * 190 * Ensures the regular expressions are valid 191 * 192 * @return string[] [$skipRE, $matchRE] 193 * @throws \Exception if the regular expressions are invalid 194 */ 195 public function getRegexps() 196 { 197 $skip = $this->getConf('skipRegex'); 198 $skipRE = ''; 199 $match = $this->getConf('matchRegex'); 200 $matchRE = ''; 201 202 if ($skip) { 203 $skipRE = '/' . $skip . '/'; 204 if (@preg_match($skipRE, '') === false) { 205 throw new \Exception('Invalid regular expression in $conf[\'skipRegex\']. ' . preg_last_error_msg()); 206 } 207 } 208 209 if ($match) { 210 $matchRE = '/' . $match . '/'; 211 if (@preg_match($matchRE, '') === false) { 212 throw new \Exception('Invalid regular expression in $conf[\'matchRegex\']. ' . preg_last_error_msg()); 213 } 214 } 215 return [$skipRE, $matchRE]; 216 } 217 218 /** 219 * Get the hash for the given content 220 * 221 * Strips all whitespace and HTML tags to ensure only real content changes are detected 222 * 223 * @param string $xhtml 224 */ 225 protected function getContentHash(string $xhtml): string 226 { 227 return md5(preg_replace('/\s+/', '', strip_tags($xhtml))); 228 } 229 230 /** 231 * Log a message when debug logging is enabled 232 * 233 * @param string $message 234 * @return void 235 */ 236 protected function logDebug(string $message) 237 { 238 if (!$this->getConf('debug')) return; 239 $logger = Logger::getInstance('renderrevisions'); 240 $logger->log($message); 241 } 242 243 /** 244 * Build a render diff for logging purposes 245 * 246 * @param string $before 247 * @param string $after 248 * @return string 249 */ 250 protected function getRenderDiff(string $before, string $after): string 251 { 252 if ($before === $after) return 'No render diff (content identical).'; 253 254 $Difference = new \Diff( 255 explode("\n", $before), 256 explode("\n", $after) 257 ); 258 259 $DiffFormatter = new UnifiedDiffFormatter(); 260 return $DiffFormatter->format($Difference); 261 } 262} 263