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 $message = str_replace( 101 ['%user%', '%mail%', '\n'], 102 [$this->getAuthor(), $this->getAuthorMail(), "\n"], 103 $message 104 ); 105 106 try { 107 $repo = $this->initRepo(); 108 109 //add the changed file and set the commit message 110 $repo->add($filePath); 111 $repo->commit($message); 112 113 //if the push after Commit option is set we push the active branch to origin 114 if ($this->getConf('pushAfterCommit')) { 115 $repo->push('origin', $repo->activeBranch()); 116 } 117 } catch (Exception $e) { 118 if (!$this->isNotifyByEmailOnGitCommandError()) { 119 throw new Exception('Git committing or pushing failed: ' . $e->getMessage(), 1, $e); 120 } 121 return; 122 } 123 } 124 } 125 126 private function getUserInfo($key) 127 { 128 return $GLOBALS['USERINFO'][$key] ?? ''; 129 } 130 131 private function getAuthor() 132 { 133 return $this->getUserInfo('name'); 134 } 135 136 private function getAuthorMail() 137 { 138 return $this->getUserInfo('mail'); 139 } 140 141 private function computeLocalPath() 142 { 143 global $conf; 144 $repoPath = str_replace('\\', '/', realpath(GitBackedUtil::getEffectivePath($this->getConf('repoPath')))); 145 $datadir = $conf['datadir']; // already normalized 146 if (substr($datadir, 0, strlen($repoPath)) !== $repoPath) { 147 throw new Exception('Datadir not inside repoPath ??'); 148 } 149 return substr($datadir, strlen($repoPath) + 1); 150 } 151 152 private function updatePage($page) 153 { 154 155 if (is_callable(Indexer::class . '::getInstance')) { 156 $Indexer = Indexer::getInstance(); 157 $success = $Indexer->addPage($page, false, false); 158 } elseif (class_exists('Doku_Indexer')) { 159 $success = idx_addPage($page, false, false); 160 } else { 161 // Failed to index the page. Your DokuWiki is older than release 2011-05-25 "Rincewind" 162 $success = false; 163 } 164 165 echo "Update $page: $success <br/>"; 166 } 167 168 public function handlePeriodicPull(Event &$event, $param) 169 { 170 if ($this->getConf('periodicPull')) { 171 $enableIndexUpdate = $this->getConf('updateIndexOnPull'); 172 $lastPullFile = $this->temp_dir . '/lastpull.txt'; 173 //check if the lastPullFile exists 174 if (is_file($lastPullFile)) { 175 $lastPull = unserialize(file_get_contents($lastPullFile)); 176 } else { 177 $lastPull = 0; 178 } 179 //calculate time between pulls in seconds 180 $timeToWait = $this->getConf('periodicMinutes') * 60; 181 $now = time(); 182 183 //if it is time to run a pull request 184 if ($lastPull + $timeToWait < $now) { 185 try { 186 $repo = $this->initRepo(); 187 if ($enableIndexUpdate) { 188 $localPath = $this->computeLocalPath(); 189 190 // store current revision id 191 $revBefore = $repo->run('rev-parse HEAD'); 192 } 193 194 //execute the pull request 195 $repo->pull('origin', $repo->activeBranch()); 196 197 if ($enableIndexUpdate) { 198 // store new revision id 199 $revAfter = $repo->run('rev-parse HEAD'); 200 201 if (strcmp($revBefore, $revAfter) != 0) { 202 // if there were some changes, get the list of all changed files 203 $changedFilesPage = $repo->run('diff --name-only ' . $revBefore . ' ' . $revAfter); 204 $changedFiles = preg_split("/\r\n|\n|\r/", $changedFilesPage); 205 206 foreach ($changedFiles as $cf) { 207 // check if the file is inside localPath, that is, it's a page 208 if (substr($cf, 0, strlen($localPath)) === $localPath) { 209 // convert from relative filename to page name 210 // for example: local/path/dir/subdir/test.txt -> dir:subdir:test 211 // -4 removes .txt 212 $page = str_replace('/', ':', substr($cf, strlen($localPath) + 1, -4)); 213 214 // update the page 215 $this->updatePage($page); 216 } else { 217 echo "Page NOT to update: $cf <br/>"; 218 } 219 } 220 } 221 } 222 } catch (Exception $e) { 223 if (!$this->isNotifyByEmailOnGitCommandError()) { 224 throw new Exception('Git command failed to perform periodic pull: ' . $e->getMessage(), 2, $e); 225 } 226 return; 227 } 228 229 //save the current time to the file to track the last pull execution 230 file_put_contents($lastPullFile, serialize(time())); 231 } 232 } 233 } 234 235 public function handleMediaDeletion(Event &$event, $param) 236 { 237 $mediaPath = $event->data['path']; 238 $mediaName = $event->data['name']; 239 240 $message = str_replace( 241 ['%media%'], 242 [$mediaName], 243 $this->getConf('commitMediaMsgDel') 244 ); 245 246 $this->commitFile($mediaPath, $message); 247 } 248 249 public function handleMediaUpload(Event &$event, $param) 250 { 251 252 $mediaPath = $event->data[1]; 253 $mediaName = $event->data[2]; 254 255 $message = str_replace( 256 ['%media%'], 257 [$mediaName], 258 $this->getConf('commitMediaMsg') 259 ); 260 261 $this->commitFile($mediaPath, $message); 262 } 263 264 public function handleIOWikiPageWrite(Event &$event, $param) 265 { 266 267 $rev = $event->data[3]; 268 269 /* On update to an existing page this event is called twice, 270 * once for the transfer of the old version to the attic (rev will have a value) 271 * and once to write the new version of the page into the wiki (rev is false) 272 */ 273 if (!$rev) { 274 $pagePath = $event->data[0][0]; 275 $pageName = $event->data[2]; 276 $pageNs = $event->data[1]; 277 $pageContent = $event->data[0][1]; 278 279 // get the summary directly from the form input 280 // as the metadata hasn't updated yet 281 $editSummary = $GLOBALS['INPUT']->str('summary'); 282 283 // empty content indicates a page deletion 284 if ($pageContent == '') { 285 // get the commit text for deletions 286 $msgTemplate = $this->getConf('commitPageMsgDel'); 287 288 // bad hack as DokuWiki deletes the file after this event 289 // thus, let's delete the file by ourselves, so git can recognize the deletion 290 // DokuWiki uses @unlink as well, so no error should be thrown if we delete it twice 291 @unlink($pagePath); 292 } else { 293 //get the commit text for edits 294 $msgTemplate = $this->getConf('commitPageMsg'); 295 } 296 297 $message = str_replace( 298 ['%page%', '%summary%', '%ns%'], 299 [$pageName, $editSummary, $pageNs], 300 $msgTemplate 301 ); 302 303 $this->commitFile($pagePath, $message); 304 } 305 } 306 307 // ====== Error notification helpers ====== 308 /** 309 * Notifies error on create_new 310 * 311 * @access public 312 * @param string repository path 313 * @param string reference path / remote reference 314 * @param string error message 315 * @return bool 316 */ 317 public function notifyCreateNewError($repo_path, $reference, $error_message) 318 { 319 $template_replacements = [ 320 'GIT_REPO_PATH' => $repo_path, 321 'GIT_REFERENCE' => (empty($reference) ? 'n/a' : $reference), 322 'GIT_ERROR_MESSAGE' => $error_message 323 ]; 324 return $this->notifyByMail('mail_create_new_error_subject', 'mail_create_new_error', $template_replacements); 325 } 326 327 /** 328 * Notifies error on setting repo path 329 * 330 * @access public 331 * @param string repository path 332 * @param string error message 333 * @return bool 334 */ 335 public function notifyRepoPathError($repo_path, $error_message) 336 { 337 $template_replacements = [ 338 'GIT_REPO_PATH' => $repo_path, 339 'GIT_ERROR_MESSAGE' => $error_message 340 ]; 341 return $this->notifyByMail('mail_repo_path_error_subject', 'mail_repo_path_error', $template_replacements); 342 } 343 344 /** 345 * Notifies error on git command 346 * 347 * @access public 348 * @param string repository path 349 * @param string current working dir 350 * @param string command line 351 * @param int exit code of command (status) 352 * @param string error message 353 * @return bool 354 */ 355 public function notifyCommandError($repo_path, $cwd, $command, $status, $error_message) 356 { 357 $template_replacements = [ 358 'GIT_REPO_PATH' => $repo_path, 359 'GIT_CWD' => $cwd, 360 'GIT_COMMAND' => $command, 361 'GIT_COMMAND_EXITCODE' => $status, 362 'GIT_ERROR_MESSAGE' => $error_message 363 ]; 364 return $this->notifyByMail('mail_command_error_subject', 'mail_command_error', $template_replacements); 365 } 366 367 /** 368 * Notifies success on git command 369 * 370 * @access public 371 * @param string repository path 372 * @param string current working dir 373 * @param string command line 374 * @return bool 375 */ 376 public function notifyCommandSuccess($repo_path, $cwd, $command) 377 { 378 if (!$this->getConf('notifyByMailOnSuccess')) { 379 return false; 380 } 381 $template_replacements = [ 382 'GIT_REPO_PATH' => $repo_path, 383 'GIT_CWD' => $cwd, 384 'GIT_COMMAND' => $command 385 ]; 386 return $this->notifyByMail('mail_command_success_subject', 'mail_command_success', $template_replacements); 387 } 388 389 /** 390 * Send an eMail, if eMail address is configured 391 * 392 * @access public 393 * @param string lang id for the subject 394 * @param string lang id for the template(.txt) 395 * @param array array of replacements 396 * @return bool 397 */ 398 public function notifyByMail($subject_id, $template_id, $template_replacements) 399 { 400 $ret = false; 401 //dbglog("GitBacked - notifyByMail: [subject_id=" . $subject_id 402 // . ", template_id=" . $template_id 403 // . ", template_replacements=" . $template_replacements . "]"); 404 if (!$this->isNotifyByEmailOnGitCommandError()) { 405 return $ret; 406 } 407 //$template_text = rawLocale($template_id); // this works for core artifacts only - not for plugins 408 $template_filename = $this->localFN($template_id); 409 $template_text = file_get_contents($template_filename); 410 $template_html = $this->render_text($template_text); 411 412 $mailer = new \Mailer(); 413 $mailer->to($this->getEmailAddressOnErrorConfigured()); 414 //dbglog("GitBacked - lang check['".$subject_id."']: ".$this->getLang($subject_id)); 415 //dbglog("GitBacked - template text['".$template_id."']: ".$template_text); 416 //dbglog("GitBacked - template html['".$template_id."']: ".$template_html); 417 $mailer->subject($this->getLang($subject_id)); 418 $mailer->setBody($template_text, $template_replacements, null, $template_html); 419 420 $ret = $mailer->send(); 421 422 return $ret; 423 } 424 425 /** 426 * Check, if eMail is to be sent on a Git command error. 427 * 428 * @access public 429 * @return bool 430 */ 431 public function isNotifyByEmailOnGitCommandError() 432 { 433 $emailAddressOnError = $this->getEmailAddressOnErrorConfigured(); 434 return !empty($emailAddressOnError); 435 } 436 437 /** 438 * Get the eMail address configured for notifications. 439 * 440 * @access public 441 * @return string 442 */ 443 public function getEmailAddressOnErrorConfigured() 444 { 445 $emailAddressOnError = trim($this->getConf('emailAddressOnError')); 446 return $emailAddressOnError; 447 } 448} 449// phpcs:enable Squiz.Classes.ValidClassName.NotCamelCaps 450// phpcs:enable PSR1.Classes.ClassDeclaration.MissingNamespace 451 452// vim:ts=4:sw=4:et: 453