Added the webhooks.

master
Loic d'Anterroches 2010-05-10 10:11:27 +02:00
parent 2f22d48dd0
commit 47acc73451
10 changed files with 340 additions and 35 deletions

View File

@ -0,0 +1,69 @@
<?php
/* -*- tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4 -*- */
/*
# ***** BEGIN LICENSE BLOCK *****
# This file is part of InDefero, an open source project management application.
# Copyright (C) 2008 Céondo Ltd and contributors.
#
# InDefero is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# InDefero is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
#
# ***** END LICENSE BLOCK ***** */
/**
* This script process the queue of items.
*
* At the moment the queue is only used for the webhooks, but it would
* be good in the future to use it for indexing and email
* notifications.
*
*/
require dirname(__FILE__).'/../src/IDF/conf/path.php';
require 'Pluf.php';
Pluf::start(dirname(__FILE__).'/../src/IDF/conf/idf.php');
Pluf_Dispatcher::loadControllers(Pluf::f('idf_views'));
#;*/ ::
$lock_file = Pluf::f('idf_queuecron_lock',
Pluf::f('tmp_folder', '/tmp').'/queuecron.lock');
if (file_exists($lock_file)) {
Pluf_Log::event(array('queuecron.php', 'skip'));
return;
}
file_put_contents($lock_file, time(), LOCK_EX);
/**
* [signal]
*
* queuecron.php::run
*
* [sender]
*
* queuecron.php
*
* [description]
*
* This signal allows an application to perform a set of tasks when
* the queue cron job is run. This is done usually every 5 minutes.
*
* [parameters]
*
* array()
*
*/
$params = array();
Pluf_Signal::send('queuecron.php::run', 'queuecron.php', $params);
unlink($lock_file);

View File

@ -288,5 +288,31 @@ class IDF_Commit extends Pluf_Model
$email->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();
}
}

View File

@ -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 <a href="%s">post-commit web hooks</a>.'), $url),
'widget_attrs' => array('size' => 35),
));
}
}

View File

@ -424,6 +424,17 @@ class IDF_Project extends Pluf_Model
$this, $user);
}
/**
* 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
*

View File

@ -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();
}
}
}

View File

@ -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);
$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);
}
} else {
$params = array();
foreach (array('svn_username', 'svn_password') as $key) {
$_val = $request->conf->getVal($key, false);
if ($_val !== false) {
$params[$key] = $_val;
}
}
if (count($params) == 0) {
$params = null; //Nothing in the db, so new form.
}
$form = new IDF_Form_SourceConf($params, $extra);
$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;
}
}
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);
}

100
src/IDF/Webhook.php 100644
View File

@ -0,0 +1,100 @@
<?php
/* -*- tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4 -*- */
/*
# ***** BEGIN LICENSE BLOCK *****
# This file is part of InDefero, an open source project management application.
# Copyright (C) 2008, 2009, 2010 Céondo Ltd and contributors.
#
# InDefero is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# InDefero is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
#
# ***** END LICENSE BLOCK ***** */
/**
* Management of the webhooks.
*
* The class provides the tools to perform the POST request with
* authentication for the webhooks.
*
*/
class IDF_Webhook
{
/**
* Perform the POST request given the webhook payload.
*
* @param array Payload
* @return bool Success or error
*/
public static function postNotification($payload)
{
$data = json_encode($payload['to_send']);
$sign = hash_hmac('md5', $data, $payload['authkey']);
$params = array('http' => 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;
}
}
}

View File

@ -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,

View File

@ -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;

View File

@ -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}
<div class="px-message-error">
<p>{trans 'The form contains some errors. Please correct them to update the source configuration.'}</p>
{if $form.get_top_errors}
@ -37,13 +37,25 @@
<td>{if $form.f.svn_password.errors}{$form.f.svn_password.fieldErrors}{/if}
{$form.f.svn_password|unsafe}
</td>
</tr>{/if}
<tr>
<th>{$form.f.webhook_url.labelTag}:</th>
<td>{if $form.f.webhook_url.errors}{$form.f.webhook_url.fieldErrors}{/if}
{$form.f.webhook_url|unsafe}<br>
</td>
</tr>
<tr>
<th>{trans 'Post-commit authentication key:'}</th>
<td>{$hookkey}
</td>
</tr>
<tr>
<td>&nbsp;</td>
<td>
<input type="submit" value="{trans 'Save Changes'}" name="submit" />
</td>
</tr>{/if}
</tr>
</table>
</form>
{/block}
@ -51,4 +63,31 @@
<div class="issue-submit-info">
<p>{blocktrans}You can find here the current repository configuration of your project.{/blocktrans}</p>
</div>
<br>
<div class="issue-submit-info">
{blocktrans}<p>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.</p>
<p>Only properly-escaped <strong>HTTP</strong> URLs are supported, for example:</p>
<ul>
<li>http://domain.com/commit</li>
<li>http://domain.com/commit?my%20param</li>
</ul>
<p>In addition, the URL may contain the following "%" notation, which
will be replaced with specific project values for each commit:</p>
<ul>
<li>%p - project name</li>
<li>%r - revision number</li>
</ul>
<p>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.</p>{/blocktrans}</div>
{/block}