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