xref: /plugin/gitbacked/action/editcommit.php (revision 477c7cb4e85002ab4cbe9a9f555cfe6115d5ce25)
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