diff --git a/scripts/queuecron.php b/scripts/queuecron.php new file mode 100644 index 0000000..c95da71 --- /dev/null +++ b/scripts/queuecron.php @@ -0,0 +1,69 @@ +addTextMessage($text_email); $email->sendMail(); Pluf_Translation::loadSetLocale($current_locale); + + // Now we add to the queue, soon we will push everything in + // the queue, including email notifications and indexing. + // Even if the url is empty, we add to the queue as some + // plugins may want to do something with this information in + // an asynchronous way. + + $url = str_replace(array('%p', '%r'), + array($project->shortname, $this->scm_id), + $conf->getVal('webhook_url', '')); + $payload = array('to_send' => array( + 'project' => $project->shortname, + 'rev' => $this->scm_id, + 'summary' => $this->summary, + 'fullmessage' => $this->fullmessage, + 'author' => $this->origauthor, + 'creation_date' => $this->creation_dtime, + ), + 'project_id' => $project->id, + 'authkey' => $project->getPostCommitHookKey(), + 'url' => $url, + ); + $item = new IDF_Queue(); + $item->type = 'new_commit'; + $item->payload = $payload; + $item->create(); } } diff --git a/src/IDF/Form/SourceConf.php b/src/IDF/Form/SourceConf.php index b4183d3..311d677 100644 --- a/src/IDF/Form/SourceConf.php +++ b/src/IDF/Form/SourceConf.php @@ -34,7 +34,7 @@ class IDF_Form_SourceConf extends Pluf_Form public function initFields($extra=array()) { $this->conf = $extra['conf']; - if ($this->conf->getVal('scm', 'git') == 'svn') { + if ($extra['remote_svn']) { $this->fields['svn_username'] = new Pluf_Form_Field_Varchar( array('required' => false, 'label' => __('Repository username'), @@ -49,6 +49,16 @@ class IDF_Form_SourceConf extends Pluf_Form 'widget' => 'Pluf_Form_Widget_PasswordInput', )); } + Pluf::loadFunction('Pluf_HTTP_URL_urlForView'); + $url = Pluf_HTTP_URL_urlForView('idf_faq').'#webhooks'; + $this->fields['webhook_url'] = new Pluf_Form_Field_Url( + array('required' => false, + 'label' => __('Webhook URL'), + 'initial' => $this->conf->getVal('webhook_url', ''), + 'help_text' => sprintf(__('Learn more about the post-commit web hooks.'), $url), + 'widget_attrs' => array('size' => 35), + )); + } } diff --git a/src/IDF/Project.php b/src/IDF/Project.php index d9895db..ea5b262 100644 --- a/src/IDF/Project.php +++ b/src/IDF/Project.php @@ -425,6 +425,17 @@ class IDF_Project extends Pluf_Model } /** + * Get the post commit hook key. + * + * The goal is to get something predictable but from which one + * cannot reverse find the secret key. + */ + public function getPostCommitHookKey() + { + return md5($this->id.sha1(Pluf::f('secret_key')).$this->shortname); + } + + /** * Get the root name of the project scm * * @return string SCM root diff --git a/src/IDF/Queue.php b/src/IDF/Queue.php index 19242dc..4f8733c 100644 --- a/src/IDF/Queue.php +++ b/src/IDF/Queue.php @@ -138,6 +138,8 @@ class IDF_Queue extends Pluf_Model $this->creation_dtime = gmdate('Y-m-d H:i:s'); $this->lasttry_dtime = gmdate('Y-m-d H:i:s'); $this->results = array(); + $this->trials = 0; + $this->status = 0; } } @@ -183,4 +185,38 @@ class IDF_Queue extends Pluf_Model $this->lasttry_dtime = gmdate('Y-m-d H:i:s'); $this->update(); } + + /** + * Parse the queue. + * + * It is a signal handler to just hook itself at the right time in + * the cron job performing the maintainance work. + * + * The processing relies on the fact that no other processing jobs + * must run at the same time. That is, your cron job must use a + * lock file or something like to not run in parallel. + * + * The processing is simple, first get 500 queue items, mark them + * as being processed and for each of them call the processItem() + * method which will trigger another event for processing. + * + * If you are processing more than 500 items per batch, you need + * to switch to a different solution. + * + */ + public static function process($sender, &$params) + { + $where = 'status=0 OR status=2'; + $items = Pluf::factory('IDF_Queue')->getList(array('filter'=>$where, + 'nb'=> 500)); + Pluf_Log::event(array('IDF_Queue::process', $items->count())); + foreach ($items as $item) { + $item->status = 1; + $item->update(); + } + foreach ($items as $item) { + $item->status = 1; + $item->processItem(); + } + } } diff --git a/src/IDF/Views/Project.php b/src/IDF/Views/Project.php index d66e0ea..d68b4ff 100644 --- a/src/IDF/Views/Project.php +++ b/src/IDF/Views/Project.php @@ -475,44 +475,45 @@ class IDF_Views_Project /** * Administrate the source control. + * + * There, the login/password of the subversion remote repo can be + * change together with the webhook url. */ public $adminSource_precond = array('IDF_Precondition::projectOwner'); public function adminSource($request, $match) { $prj = $request->project; $title = sprintf(__('%s Source'), (string) $prj); - $form = null; - $remote_svn = false; - if ($request->conf->getVal('scm') == 'svn' and - strlen($request->conf->getVal('svn_remote_url')) > 0) { - $remote_svn = true; - $extra = array( - 'conf' => $request->conf, - ); - if ($request->method == 'POST') { - $form = new IDF_Form_SourceConf($request->POST, $extra); - if ($form->isValid()) { - foreach ($form->cleaned_data as $key=>$val) { - $request->conf->setVal($key, $val); - } - $request->user->setMessage(__('The project source configuration has been saved.')); - $url = Pluf_HTTP_URL_urlForView('IDF_Views_Project::adminSource', - array($prj->shortname)); - return new Pluf_HTTP_Response_Redirect($url); - } - } else { - $params = array(); - foreach (array('svn_username', 'svn_password') as $key) { - $_val = $request->conf->getVal($key, false); - if ($_val !== false) { - $params[$key] = $_val; - } + + $remote_svn = ($request->conf->getVal('scm') == 'svn' and + strlen($request->conf->getVal('svn_remote_url')) > 0); + $extra = array( + 'conf' => $request->conf, + 'remote_svn' => $remote_svn, + ); + if ($request->method == 'POST') { + $form = new IDF_Form_SourceConf($request->POST, $extra); + if ($form->isValid()) { + foreach ($form->cleaned_data as $key=>$val) { + $request->conf->setVal($key, $val); } - if (count($params) == 0) { - $params = null; //Nothing in the db, so new form. + $request->user->setMessage(__('The project source configuration has been saved.')); + $url = Pluf_HTTP_URL_urlForView('IDF_Views_Project::adminSource', + array($prj->shortname)); + return new Pluf_HTTP_Response_Redirect($url); + } + } else { + $params = array(); + foreach (array('svn_username', 'svn_password', 'webhook_url') as $key) { + $_val = $request->conf->getVal($key, false); + if ($_val !== false) { + $params[$key] = $_val; } - $form = new IDF_Form_SourceConf($params, $extra); } + if (count($params) == 0) { + $params = null; //Nothing in the db, so new form. + } + $form = new IDF_Form_SourceConf($params, $extra); } $scm = $request->conf->getVal('scm', 'git'); $options = array( @@ -529,6 +530,7 @@ class IDF_Views_Project 'repository_size' => $prj->getRepositorySize(), 'page_title' => $title, 'form' => $form, + 'hookkey' => $prj->getPostCommitHookKey(), ), $request); } diff --git a/src/IDF/Webhook.php b/src/IDF/Webhook.php new file mode 100644 index 0000000..d291744 --- /dev/null +++ b/src/IDF/Webhook.php @@ -0,0 +1,100 @@ + array( + 'method' => 'POST', + 'content' => $data, + 'user_agent' => 'Indefero Hook Sender (http://www.indefero.net)', + 'max_redirects' => 0, + 'timeout' => 15, + 'header'=> 'Indefero-Hook-Hmac: '.$sign."\r\n" + .'Content-Type: application/json'."\r\n", + ) + ); + $url = $payload['url']; + $ctx = stream_context_create($params); + $fp = @fopen($url, 'rb', false, $ctx); + if (!$fp) { + return false; + } + $meta = stream_get_meta_data($fp); + @fclose($fp); + if (!isset($meta['wrapper_data'][0]) or $meta['timed_out']) { + return false; + } + if (0 === strpos($meta['wrapper_data'][0], 'HTTP/1.1 2') or + 0 === strpos($meta['wrapper_data'][0], 'HTTP/1.1 3')) { + return true; + } + return false; + } + + + /** + * Process the webhook. + * + */ + public static function process($sender, &$params) + { + $item = $params['item']; + if ($item->type != 'new_commit') { + // We do nothing. + return; + } + if (isset($params['res']['IDF_Webhook::process']) and + $params['res']['IDF_Webhook::process'] == true) { + // Already processed. + return; + } + // We have either to retry or to push for the first time. + $res = self::postNotification($item->payload); + if ($res) { + $params['res']['IDF_Webhook::process'] = true; + } elseif ($item->trials >= 9) { + // We are at trial 10, give up + $params['res']['IDF_Webhook::process'] = true; + } else { + // Need to try again + $params['res']['IDF_Webhook::process'] = false; + } + } +} diff --git a/src/IDF/conf/urls.php b/src/IDF/conf/urls.php index c4b3e08..f6fef8b 100644 --- a/src/IDF/conf/urls.php +++ b/src/IDF/conf/urls.php @@ -66,7 +66,8 @@ $ctl[] = array('regex' => '#^/logout/$#', $ctl[] = array('regex' => '#^/help/$#', 'base' => $base, 'model' => 'IDF_Views', - 'method' => 'faq'); + 'method' => 'faq', + 'name' => 'idf_faq'); $ctl[] = array('regex' => '#^/p/([\-\w]+)/$#', 'base' => $base, diff --git a/src/IDF/relations.php b/src/IDF/relations.php index c4166a8..f5a5925 100644 --- a/src/IDF/relations.php +++ b/src/IDF/relations.php @@ -84,5 +84,16 @@ Pluf_Signal::connect('IDF_Key::preDelete', Pluf_Signal::connect('gitpostupdate.php::run', array('IDF_Plugin_SyncGit', 'entry')); +# +# -- Processing of the webhook queue -- +Pluf_Signal::connect('queuecron.php::run', + array('IDF_Queue', 'process')); +# +# Processing of a given webhook, the hook can be configured +# directly in the configuration file if a different solution +# is required. +Pluf_Signal::connect('IDF_Queue::processItem', + Pluf::f('idf_hook_process_item', + array('IDF_Webhook', 'process'))); return $m; diff --git a/src/IDF/templates/idf/admin/source.html b/src/IDF/templates/idf/admin/source.html index 5fba9ef..3885227 100644 --- a/src/IDF/templates/idf/admin/source.html +++ b/src/IDF/templates/idf/admin/source.html @@ -1,7 +1,7 @@ {extends "idf/admin/base.html"} -{block docclass}yui-t1{assign $inSource = true}{/block} +{block docclass}yui-t3{assign $inSource = true}{/block} {block body} -{if $remote_svn and $form.errors} +{if $form.errors}

{trans 'The form contains some errors. Please correct them to update the source configuration.'}

{if $form.get_top_errors} @@ -37,13 +37,25 @@ {if $form.f.svn_password.errors}{$form.f.svn_password.fieldErrors}{/if} {$form.f.svn_password|unsafe} +{/if} + +{$form.f.webhook_url.labelTag}: +{if $form.f.webhook_url.errors}{$form.f.webhook_url.fieldErrors}{/if} +{$form.f.webhook_url|unsafe}
+ + + + +{trans 'Post-commit authentication key:'} +{$hookkey} +   -{/if} + {/block} @@ -51,4 +63,31 @@

{blocktrans}You can find here the current repository configuration of your project.{/blocktrans}

+ + +
+
+ +{blocktrans}

The webhook URL setting specifies a URL to which a HTTP POST +request is sent after each repository commit. If this field is empty, +notifications are disabled.

+ +

Only properly-escaped HTTP URLs are supported, for example:

+ + + +

In addition, the URL may contain the following "%" notation, which +will be replaced with specific project values for each commit:

+ + + +

For example, committing revision 123 to project 'my-project' with +post-commit URL http://mydomain.com/%p/%r would send a request to +http://mydomain.com/my-project/123.

{/blocktrans}
{/block}