1<?php 2 3use dokuwiki\Search\Indexer; 4 5/** 6 * DokuWiki Plugin gitbacked (Action Component) 7 * 8 * @license GPL 2 http://www.gnu.org/licenses/gpl-2.0.html 9 * @author Wolfgang Gassler <wolfgang@gassler.org> 10 */ 11 12// phpcs:disable PSR1.Files.SideEffects.FoundWithSymbols 13// must be run within Dokuwiki 14if (!defined('DOKU_INC')) die(); 15 16if (!defined('DOKU_LF')) define('DOKU_LF', "\n"); 17if (!defined('DOKU_TAB')) define('DOKU_TAB', "\t"); 18if (!defined('DOKU_PLUGIN')) define('DOKU_PLUGIN', DOKU_INC . 'lib/plugins/'); 19 20require_once __DIR__ . '/../loader.php'; 21 22use dokuwiki\Extension\ActionPlugin; 23use dokuwiki\Extension\EventHandler; 24use dokuwiki\Extension\Event; 25 26use woolfg\dokuwiki\plugin\gitbacked\Git; 27use woolfg\dokuwiki\plugin\gitbacked\GitRepo; 28use woolfg\dokuwiki\plugin\gitbacked\GitBackedUtil; 29 30// phpcs:disable PSR1.Classes.ClassDeclaration.MissingNamespace 31// phpcs:disable Squiz.Classes.ValidClassName.NotCamelCaps 32class action_plugin_gitbacked_editcommit extends ActionPlugin 33{ 34 /** 35 * Temporary directory for this gitbacked plugin. 36 * 37 * @var string 38 */ 39 private $temp_dir; 40 41 public function __construct() 42 { 43 $this->temp_dir = GitBackedUtil::getTempDir(); 44 } 45 46 public function register(EventHandler $controller) 47 { 48 $controller->register_hook('IO_WIKIPAGE_WRITE', 'AFTER', $this, 'handleIOWikiPageWrite'); 49 $controller->register_hook('MEDIA_UPLOAD_FINISH', 'AFTER', $this, 'handleMediaUpload'); 50 $controller->register_hook('MEDIA_DELETE_FILE', 'AFTER', $this, 'handleMediaDeletion'); 51 $controller->register_hook('DOKUWIKI_DONE', 'AFTER', $this, 'handlePeriodicPull'); 52 } 53 54 private function initRepo() 55 { 56 //get path to the repo root (by default DokuWiki's savedir) 57 $repoPath = GitBackedUtil::getEffectivePath($this->getConf('repoPath')); 58 $gitPath = trim($this->getConf('gitPath')); 59 if ($gitPath !== '') { 60 Git::setBin($gitPath); 61 } 62 //init the repo and create a new one if it is not present 63 io_mkdir_p($repoPath); 64 $repo = new GitRepo($repoPath, $this, true, true); 65 //set git working directory (by default DokuWiki's savedir) 66 $repoWorkDir = $this->getConf('repoWorkDir'); 67 if (!empty($repoWorkDir)) { 68 $repoWorkDir = GitBackedUtil::getEffectivePath($repoWorkDir); 69 } 70 Git::setBin(empty($repoWorkDir) ? Git::getBin() 71 : Git::getBin() . ' --work-tree ' . escapeshellarg($repoWorkDir)); 72 $params = str_replace( 73 ['%mail%', '%user%'], 74 [$this->getAuthorMail(), $this->getAuthor()], 75 $this->getConf('addParams') 76 ); 77 if ($params) { 78 Git::setBin(Git::getBin() . ' ' . $params); 79 } 80 return $repo; 81 } 82 83 private function isIgnored($filePath) 84 { 85 $ignore = false; 86 $ignorePaths = trim($this->getConf('ignorePaths')); 87 if ($ignorePaths !== '') { 88 $paths = explode(',', $ignorePaths); 89 foreach ($paths as $path) { 90 if (strstr($filePath, $path)) { 91 $ignore = true; 92 } 93 } 94 } 95 return $ignore; 96 } 97 98 private function commitFile($filePath, $message) 99 { 100 if (!$this->isIgnored($filePath)) { 101 try { 102 $repo = $this->initRepo(); 103 104 //add the changed file and set the commit message 105 $repo->add($filePath); 106 $repo->commit($message); 107 108 //if the push after Commit option is set we push the active branch to origin 109 if ($this->getConf('pushAfterCommit')) { 110 $repo->push('origin', $repo->activeBranch()); 111 } 112 } catch (Exception $e) { 113 if (!$this->isNotifyByEmailOnGitCommandError()) { 114 throw new Exception('Git committing or pushing failed: ' . $e->getMessage(), 1, $e); 115 } 116 return; 117 } 118 } 119 } 120 121 private function getAuthor() 122 { 123 return $GLOBALS['USERINFO']['name']; 124 } 125 126 private function getAuthorMail() 127 { 128 return $GLOBALS['USERINFO']['mail']; 129 } 130 131 private function computeLocalPath() 132 { 133 global $conf; 134 $repoPath = str_replace('\\', '/', realpath(GitBackedUtil::getEffectivePath($this->getConf('repoPath')))); 135 $datadir = $conf['datadir']; // already normalized 136 if (substr($datadir, 0, strlen($repoPath)) !== $repoPath) { 137 throw new Exception('Datadir not inside repoPath ??'); 138 } 139 return substr($datadir, strlen($repoPath) + 1); 140 } 141 142 private function updatePage($page) 143 { 144 145 if (is_callable(Indexer::class . '::getInstance')) { 146 $Indexer = Indexer::getInstance(); 147 $success = $Indexer->addPage($page, false, false); 148 } elseif (class_exists('Doku_Indexer')) { 149 $success = idx_addPage($page, false, false); 150 } else { 151 // Failed to index the page. Your DokuWiki is older than release 2011-05-25 "Rincewind" 152 $success = false; 153 } 154 155 echo "Update $page: $success <br/>"; 156 } 157 158 public function handlePeriodicPull(Event &$event, $param) 159 { 160 if ($this->getConf('periodicPull')) { 161 $enableIndexUpdate = $this->getConf('updateIndexOnPull'); 162 $lastPullFile = $this->temp_dir . '/lastpull.txt'; 163 //check if the lastPullFile exists 164 if (is_file($lastPullFile)) { 165 $lastPull = unserialize(file_get_contents($lastPullFile)); 166 } else { 167 $lastPull = 0; 168 } 169 //calculate time between pulls in seconds 170 $timeToWait = $this->getConf('periodicMinutes') * 60; 171 $now = time(); 172 173 //if it is time to run a pull request 174 if ($lastPull + $timeToWait < $now) { 175 try { 176 $repo = $this->initRepo(); 177 if ($enableIndexUpdate) { 178 $localPath = $this->computeLocalPath(); 179 180 // store current revision id 181 $revBefore = $repo->run('rev-parse HEAD'); 182 } 183 184 //execute the pull request 185 $repo->pull('origin', $repo->activeBranch()); 186 187 if ($enableIndexUpdate) { 188 // store new revision id 189 $revAfter = $repo->run('rev-parse HEAD'); 190 191 if (strcmp($revBefore, $revAfter) != 0) { 192 // if there were some changes, get the list of all changed files 193 $changedFilesPage = $repo->run('diff --name-only ' . $revBefore . ' ' . $revAfter); 194 $changedFiles = preg_split("/\r\n|\n|\r/", $changedFilesPage); 195 196 foreach ($changedFiles as $cf) { 197 // check if the file is inside localPath, that is, it's a page 198 if (substr($cf, 0, strlen($localPath)) === $localPath) { 199 // convert from relative filename to page name 200 // for example: local/path/dir/subdir/test.txt -> dir:subdir:test 201 // -4 removes .txt 202 $page = str_replace('/', ':', substr($cf, strlen($localPath) + 1, -4)); 203 204 // update the page 205 $this->updatePage($page); 206 } else { 207 echo "Page NOT to update: $cf <br/>"; 208 } 209 } 210 } 211 } 212 } catch (Exception $e) { 213 if (!$this->isNotifyByEmailOnGitCommandError()) { 214 throw new Exception('Git command failed to perform periodic pull: ' . $e->getMessage(), 2, $e); 215 } 216 return; 217 } 218 219 //save the current time to the file to track the last pull execution 220 file_put_contents($lastPullFile, serialize(time())); 221 } 222 } 223 } 224 225 public function handleMediaDeletion(Event &$event, $param) 226 { 227 $mediaPath = $event->data['path']; 228 $mediaName = $event->data['name']; 229 230 $message = str_replace( 231 ['%media%', '%user%'], 232 [$mediaName, $this->getAuthor()], 233 $this->getConf('commitMediaMsgDel') 234 ); 235 236 $this->commitFile($mediaPath, $message); 237 } 238 239 public function handleMediaUpload(Event &$event, $param) 240 { 241 242 $mediaPath = $event->data[1]; 243 $mediaName = $event->data[2]; 244 245 $message = str_replace( 246 ['%media%', '%user%'], 247 [$mediaName, $this->getAuthor()], 248 $this->getConf('commitMediaMsg') 249 ); 250 251 $this->commitFile($mediaPath, $message); 252 } 253 254 public function handleIOWikiPageWrite(Event &$event, $param) 255 { 256 257 $rev = $event->data[3]; 258 259 /* On update to an existing page this event is called twice, 260 * once for the transfer of the old version to the attic (rev will have a value) 261 * and once to write the new version of the page into the wiki (rev is false) 262 */ 263 if (!$rev) { 264 $pagePath = $event->data[0][0]; 265 $pageName = $event->data[2]; 266 $pageContent = $event->data[0][1]; 267 268 // get the summary directly from the form input 269 // as the metadata hasn't updated yet 270 $editSummary = $GLOBALS['INPUT']->str('summary'); 271 272 // empty content indicates a page deletion 273 if ($pageContent == '') { 274 // get the commit text for deletions 275 $msgTemplate = $this->getConf('commitPageMsgDel'); 276 277 // bad hack as DokuWiki deletes the file after this event 278 // thus, let's delete the file by ourselves, so git can recognize the deletion 279 // DokuWiki uses @unlink as well, so no error should be thrown if we delete it twice 280 @unlink($pagePath); 281 } else { 282 //get the commit text for edits 283 $msgTemplate = $this->getConf('commitPageMsg'); 284 } 285 286 $message = str_replace( 287 ['%page%', '%summary%', '%user%'], 288 [$pageName, $editSummary, $this->getAuthor()], 289 $msgTemplate 290 ); 291 292 $this->commitFile($pagePath, $message); 293 } 294 } 295 296 // ====== Error notification helpers ====== 297 /** 298 * Notifies error on create_new 299 * 300 * @access public 301 * @param string repository path 302 * @param string reference path / remote reference 303 * @param string error message 304 * @return bool 305 */ 306 public function notifyCreateNewError($repo_path, $reference, $error_message) 307 { 308 $template_replacements = ['GIT_REPO_PATH' => $repo_path, 'GIT_REFERENCE' => (empty($reference) ? 'n/a' : $reference), 'GIT_ERROR_MESSAGE' => $error_message]; 309 return $this->notifyByMail('mail_create_new_error_subject', 'mail_create_new_error', $template_replacements); 310 } 311 312 /** 313 * Notifies error on setting repo path 314 * 315 * @access public 316 * @param string repository path 317 * @param string error message 318 * @return bool 319 */ 320 public function notifyRepoPathError($repo_path, $error_message) 321 { 322 $template_replacements = ['GIT_REPO_PATH' => $repo_path, 'GIT_ERROR_MESSAGE' => $error_message]; 323 return $this->notifyByMail('mail_repo_path_error_subject', 'mail_repo_path_error', $template_replacements); 324 } 325 326 /** 327 * Notifies error on git command 328 * 329 * @access public 330 * @param string repository path 331 * @param string current working dir 332 * @param string command line 333 * @param int exit code of command (status) 334 * @param string error message 335 * @return bool 336 */ 337 public function notifyCommandError($repo_path, $cwd, $command, $status, $error_message) 338 { 339 $template_replacements = ['GIT_REPO_PATH' => $repo_path, 'GIT_CWD' => $cwd, 'GIT_COMMAND' => $command, 'GIT_COMMAND_EXITCODE' => $status, 'GIT_ERROR_MESSAGE' => $error_message]; 340 return $this->notifyByMail('mail_command_error_subject', 'mail_command_error', $template_replacements); 341 } 342 343 /** 344 * Notifies success on git command 345 * 346 * @access public 347 * @param string repository path 348 * @param string current working dir 349 * @param string command line 350 * @return bool 351 */ 352 public function notifyCommandSuccess($repo_path, $cwd, $command) 353 { 354 if (!$this->getConf('notifyByMailOnSuccess')) { 355 return false; 356 } 357 $template_replacements = ['GIT_REPO_PATH' => $repo_path, 'GIT_CWD' => $cwd, 'GIT_COMMAND' => $command]; 358 return $this->notifyByMail('mail_command_success_subject', 'mail_command_success', $template_replacements); 359 } 360 361 /** 362 * Send an eMail, if eMail address is configured 363 * 364 * @access public 365 * @param string lang id for the subject 366 * @param string lang id for the template(.txt) 367 * @param array array of replacements 368 * @return bool 369 */ 370 public function notifyByMail($subject_id, $template_id, $template_replacements) 371 { 372 $ret = false; 373 //dbglog("GitBacked - notifyByMail: [subject_id=" . $subject_id 374 // . ", template_id=" . $template_id 375 // . ", template_replacements=" . $template_replacements . "]"); 376 if (!$this->isNotifyByEmailOnGitCommandError()) { 377 return $ret; 378 } 379 //$template_text = rawLocale($template_id); // this works for core artifacts only - not for plugins 380 $template_filename = $this->localFN($template_id); 381 $template_text = file_get_contents($template_filename); 382 $template_html = $this->render_text($template_text); 383 384 $mailer = new \Mailer(); 385 $mailer->to($this->getEmailAddressOnErrorConfigured()); 386 //dbglog("GitBacked - lang check['".$subject_id."']: ".$this->getLang($subject_id)); 387 //dbglog("GitBacked - template text['".$template_id."']: ".$template_text); 388 //dbglog("GitBacked - template html['".$template_id."']: ".$template_html); 389 $mailer->subject($this->getLang($subject_id)); 390 $mailer->setBody($template_text, $template_replacements, null, $template_html); 391 392 $ret = $mailer->send(); 393 394 return $ret; 395 } 396 397 /** 398 * Check, if eMail is to be sent on a Git command error. 399 * 400 * @access public 401 * @return bool 402 */ 403 public function isNotifyByEmailOnGitCommandError() 404 { 405 $emailAddressOnError = $this->getEmailAddressOnErrorConfigured(); 406 return !empty($emailAddressOnError); 407 } 408 409 /** 410 * Get the eMail address configured for notifications. 411 * 412 * @access public 413 * @return string 414 */ 415 public function getEmailAddressOnErrorConfigured() 416 { 417 $emailAddressOnError = trim($this->getConf('emailAddressOnError')); 418 return $emailAddressOnError; 419 } 420} 421// phpcs:enable Squiz.Classes.ValidClassName.NotCamelCaps 422// phpcs:enable PSR1.Classes.ClassDeclaration.MissingNamespace 423 424// vim:ts=4:sw=4:et: 425