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