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