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