2010-02-08 19:06:03 +00:00
< ? php
/*
* StatusNet - the distributed open - source microblogging tool
* Copyright ( C ) 2010 , StatusNet , Inc .
*
* This program is free software : you can redistribute it and / or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation , either version 3 of the License , or
* ( at your option ) any later version .
*
* This program 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 Affero General Public License for more details .
*
* You should have received a copy of the GNU Affero General Public License
* along with this program . If not , see < http :// www . gnu . org / licenses />.
*/
2016-03-23 14:22:34 +00:00
if ( ! defined ( 'GNUSOCIAL' )) { exit ( 1 ); }
2010-10-08 18:42:59 +01:00
2010-02-08 19:06:03 +00:00
/**
2017-05-01 10:04:27 +01:00
* WebSub ( previously PuSH ) feed subscription record
2010-02-08 19:06:03 +00:00
* @ package Hub
* @ author Brion Vibber < brion @ status . net >
*/
2013-08-18 11:10:44 +01:00
class HubSub extends Managed_DataObject
2010-02-08 19:06:03 +00:00
{
public $__table = 'hubsub' ;
public $hashkey ; // sha1(topic . '|' . $callback); (topic, callback) key is too long for myisam in utf8
2015-02-12 17:18:55 +00:00
public $topic ; // varchar(191) not 255 because utf8mb4 takes more space
public $callback ; // varchar(191) not 255 because utf8mb4 takes more space
2010-02-08 19:06:03 +00:00
public $secret ;
public $sub_start ;
public $sub_end ;
public $created ;
2010-02-21 22:46:26 +00:00
public $modified ;
2010-02-08 19:06:03 +00:00
2016-01-11 18:55:02 +00:00
static function hashkey ( $topic , $callback )
2010-02-08 19:06:03 +00:00
{
return sha1 ( $topic . '|' . $callback );
}
2013-08-21 10:25:08 +01:00
public static function getByHashkey ( $topic , $callback )
{
return self :: getKV ( 'hashkey' , self :: hashkey ( $topic , $callback ));
}
2013-08-21 10:01:31 +01:00
public static function schemaDef ()
2010-02-08 19:06:03 +00:00
{
2013-08-21 10:01:31 +01:00
return array (
'fields' => array (
'hashkey' => array ( 'type' => 'char' , 'not null' => true , 'length' => 40 , 'description' => 'HubSub hashkey' ),
2015-02-12 17:18:55 +00:00
'topic' => array ( 'type' => 'varchar' , 'not null' => true , 'length' => 191 , 'description' => 'HubSub topic' ),
'callback' => array ( 'type' => 'varchar' , 'not null' => true , 'length' => 191 , 'description' => 'HubSub callback' ),
2013-08-21 10:01:31 +01:00
'secret' => array ( 'type' => 'text' , 'description' => 'HubSub stored secret' ),
'sub_start' => array ( 'type' => 'datetime' , 'description' => 'subscription start' ),
'sub_end' => array ( 'type' => 'datetime' , 'description' => 'subscription end' ),
2019-09-11 10:07:54 +01:00
'errors' => array ( 'type' => 'int' , 'not null' => true , 'default' => 0 , 'description' => 'Queue handling error count, is reset on success.' ),
2017-07-10 19:28:45 +01:00
'error_start' => array ( 'type' => 'datetime' , 'default' => null , 'description' => 'time of first error since latest success, should be null if no errors have been counted' ),
'last_error' => array ( 'type' => 'datetime' , 'default' => null , 'description' => 'time of last failure, if ever' ),
'last_error_msg' => array ( 'type' => 'text' , 'default' => null , 'description' => 'Last error _message_' ),
2013-08-21 10:01:31 +01:00
'created' => array ( 'type' => 'datetime' , 'not null' => true , 'description' => 'date this record was created' ),
'modified' => array ( 'type' => 'timestamp' , 'not null' => true , 'description' => 'date this record was modified' ),
),
'primary key' => array ( 'hashkey' ),
'indexes' => array (
2016-01-13 21:57:42 +00:00
'hubsub_callback_idx' => array ( 'callback' ),
2013-08-21 10:01:31 +01:00
'hubsub_topic_idx' => array ( 'topic' ),
),
);
2010-02-08 19:06:03 +00:00
}
2017-07-10 19:28:45 +01:00
function getErrors ()
{
return intval ( $this -> errors );
}
// $msg is only set if $error_count is 0
function setErrors ( $error_count , $msg = null )
{
assert ( is_int ( $error_count ));
if ( ! is_int ( $error_count ) || $error_count < 0 ) {
common_log ( LOG_ERR , 'HubSub->setErrors was given a bad value: ' . _ve ( $error_count ));
throw new ServerException ( 'HubSub error count must be an integer higher or equal to 0.' );
}
$orig = clone ( $this );
$now = common_sql_now ();
if ( $error_count === 1 ) {
// Record when the errors started
$this -> error_start = $now ;
}
if ( $error_count > 0 ) {
// Record this error's occurrence in time
$this -> last_error = $now ;
$this -> last_error_msg = $msg ;
} else {
$this -> error_start = null ;
$this -> last_error = null ;
$this -> last_error_msg = null ;
}
$this -> errors = $error_count ;
$this -> update ( $orig );
}
function resetErrors ()
{
return $this -> setErrors ( 0 );
}
function incrementErrors ( $msg = null )
{
return $this -> setErrors ( $this -> getErrors () + 1 , $msg );
}
2010-02-08 19:06:03 +00:00
/**
* Validates a requested lease length , sets length plus
* subscription start & end dates .
*
* Does not save to database -- use before insert () or update () .
*
* @ param int $length in seconds
*/
function setLease ( $length )
{
2017-05-01 10:04:27 +01:00
common_debug ( 'WebSub hub got requested lease_seconds==' . _ve ( $length ));
2010-02-08 19:06:03 +00:00
assert ( is_int ( $length ));
2016-01-13 18:24:38 +00:00
$min = 86400 ; // 3600*24 (one day)
2010-02-08 19:06:03 +00:00
$max = 86400 * 30 ;
if ( $length == 0 ) {
// We want to garbage collect dead subscriptions!
$length = $max ;
} elseif ( $length < $min ) {
$length = $min ;
} else if ( $length > $max ) {
$length = $max ;
}
2017-05-01 10:04:27 +01:00
common_debug ( 'WebSub hub after sanitation: lease_seconds==' . _ve ( $length ));
2016-01-13 18:23:34 +00:00
$this -> sub_start = common_sql_now ();
$this -> sub_end = common_sql_date ( time () + $length );
2010-02-08 19:06:03 +00:00
}
2016-01-13 18:55:17 +00:00
function getLeaseTime ()
2016-01-13 18:45:20 +00:00
{
if ( empty ( $this -> sub_start ) || empty ( $this -> sub_end )) {
return null ;
}
$length = strtotime ( $this -> sub_end ) - strtotime ( $this -> sub_start );
assert ( $length > 0 );
return $length ;
}
function getLeaseRemaining ()
{
if ( empty ( $this -> sub_end )) {
return null ;
}
return strtotime ( $this -> sub_end ) - time ();
}
2010-02-08 19:06:03 +00:00
/**
2010-02-21 22:46:26 +00:00
* Schedule a future verification ping to the subscriber .
* If queues are disabled , will be immediate .
*
2010-02-08 19:06:03 +00:00
* @ param string $mode 'subscribe' or 'unsubscribe'
2010-02-18 18:20:48 +00:00
* @ param string $token hub . verify_token value , if provided by client
2010-02-08 19:06:03 +00:00
*/
2010-02-21 22:46:26 +00:00
function scheduleVerify ( $mode , $token = null , $retries = null )
{
if ( $retries === null ) {
$retries = intval ( common_config ( 'ostatus' , 'hub_retries' ));
}
$data = array ( 'sub' => clone ( $this ),
'mode' => $mode ,
2013-11-02 19:02:28 +00:00
'token' => $token , // let's put it in there if remote uses PuSH <0.4
2010-02-21 22:46:26 +00:00
'retries' => $retries );
$qm = QueueManager :: get ();
2010-02-24 20:36:36 +00:00
$qm -> enqueue ( $data , 'hubconf' );
2010-02-21 22:46:26 +00:00
}
2016-01-11 18:55:02 +00:00
public function getTopic ()
{
return $this -> topic ;
}
2010-02-21 22:46:26 +00:00
/**
* Send a verification ping to subscriber , and if confirmed apply the changes .
* This may create , update , or delete the database record .
*
* @ param string $mode 'subscribe' or 'unsubscribe'
* @ param string $token hub . verify_token value , if provided by client
* @ throws ClientException on failure
*/
2010-02-18 18:20:48 +00:00
function verify ( $mode , $token = null )
2010-02-08 19:06:03 +00:00
{
assert ( $mode == 'subscribe' || $mode == 'unsubscribe' );
2013-10-21 12:20:30 +01:00
$challenge = common_random_hexstr ( 32 );
2010-02-08 19:06:03 +00:00
$params = array ( 'hub.mode' => $mode ,
2016-01-11 18:55:02 +00:00
'hub.topic' => $this -> getTopic (),
2010-02-21 22:46:26 +00:00
'hub.challenge' => $challenge );
2010-02-08 19:06:03 +00:00
if ( $mode == 'subscribe' ) {
2016-01-13 18:55:17 +00:00
$params [ 'hub.lease_seconds' ] = $this -> getLeaseTime ();
2010-02-08 19:06:03 +00:00
}
2013-11-02 19:02:28 +00:00
if ( $token !== null ) { // TODO: deprecated in PuSH 0.4
$params [ 'hub.verify_token' ] = $token ; // let's put it in there if remote uses PuSH <0.4
2010-02-08 19:06:03 +00:00
}
2010-02-21 22:46:26 +00:00
// Any existing query string parameters must be preserved
$url = $this -> callback ;
2010-03-09 06:24:21 +00:00
if ( strpos ( $url , '?' ) !== false ) {
2010-02-21 22:46:26 +00:00
$url .= '&' ;
} else {
$url .= '?' ;
}
$url .= http_build_query ( $params , '' , '&' );
$request = new HTTPClient ();
$response = $request -> get ( $url );
$status = $response -> getStatus ();
if ( $status >= 200 && $status < 300 ) {
2016-01-11 18:55:02 +00:00
common_log ( LOG_INFO , " Verified { $mode } of { $this -> callback } : { $this -> getTopic () } " );
2010-02-21 22:46:26 +00:00
} else {
2010-09-19 14:17:36 +01:00
// TRANS: Client exception. %s is a HTTP status code.
throw new ClientException ( sprintf ( _m ( 'Hub subscriber verification returned HTTP %s.' ), $status ));
2010-02-21 22:46:26 +00:00
}
2010-02-08 19:06:03 +00:00
2016-01-11 18:55:02 +00:00
$old = HubSub :: getByHashkey ( $this -> getTopic (), $this -> callback );
2010-02-21 22:46:26 +00:00
if ( $mode == 'subscribe' ) {
2013-11-02 16:42:32 +00:00
if ( $old instanceof HubSub ) {
2010-02-21 22:46:26 +00:00
$this -> update ( $old );
2010-02-08 19:06:03 +00:00
} else {
2010-02-21 22:46:26 +00:00
$ok = $this -> insert ();
2010-02-08 19:06:03 +00:00
}
2010-02-21 22:46:26 +00:00
} else if ( $mode == 'unsubscribe' ) {
2013-11-02 16:42:32 +00:00
if ( $old instanceof HubSub ) {
2010-02-21 22:46:26 +00:00
$old -> delete ();
} else {
// That's ok, we're already unsubscribed.
2010-02-08 19:06:03 +00:00
}
}
}
2016-03-23 14:22:34 +00:00
// set the hashkey automagically on insert
protected function onInsert ()
2010-02-08 19:06:03 +00:00
{
2016-03-23 14:22:34 +00:00
$this -> setHashkey ();
2010-02-21 22:46:26 +00:00
$this -> created = common_sql_now ();
$this -> modified = common_sql_now ();
2016-03-23 14:22:34 +00:00
}
// update the hashkey automagically if needed
protected function onUpdateKeys ( Managed_DataObject $orig )
{
if ( $this -> topic !== $orig -> topic || $this -> callback !== $orig -> callback ) {
$this -> setHashkey ();
}
}
protected function setHashkey ()
{
$this -> hashkey = self :: hashkey ( $this -> topic , $this -> callback );
2010-02-08 19:06:03 +00:00
}
2010-02-21 22:28:06 +00:00
/**
* Schedule delivery of a 'fat ping' to the subscriber ' s callback
* endpoint . If queues are disabled , this will run immediately .
*
* @ param string $atom well - formed Atom feed
* @ param int $retries optional count of retries if POST fails ; defaults to hub_retries from config or 0 if unset
*/
function distribute ( $atom , $retries = null )
{
if ( $retries === null ) {
$retries = intval ( common_config ( 'ostatus' , 'hub_retries' ));
}
2017-07-10 13:43:28 +01:00
$data = array ( 'topic' => $this -> getTopic (),
'callback' => $this -> callback ,
2010-02-21 22:28:06 +00:00
'atom' => $atom ,
'retries' => $retries );
2017-07-10 13:43:28 +01:00
common_log ( LOG_INFO , sprintf ( 'Queuing WebSub: %s to %s' , _ve ( $data [ 'topic' ]), _ve ( $data [ 'callback' ])));
2010-02-21 22:28:06 +00:00
$qm = QueueManager :: get ();
$qm -> enqueue ( $data , 'hubout' );
}
2010-06-04 19:48:54 +01:00
/**
* Queue up a large batch of pushes to multiple subscribers
* for this same topic update .
2010-09-03 00:35:04 +01:00
*
2010-06-04 19:48:54 +01:00
* If queues are disabled , this will run immediately .
2010-09-03 00:35:04 +01:00
*
2010-06-04 19:48:54 +01:00
* @ param string $atom well - formed Atom feed
* @ param array $pushCallbacks list of callback URLs
*/
2016-01-16 16:18:14 +00:00
function bulkDistribute ( $atom , array $pushCallbacks )
2010-06-04 19:48:54 +01:00
{
2016-01-16 16:34:27 +00:00
if ( empty ( $pushCallbacks )) {
common_log ( LOG_ERR , 'Callback list empty for bulkDistribute.' );
return false ;
}
2010-06-04 19:48:54 +01:00
$data = array ( 'atom' => $atom ,
2016-01-11 18:55:02 +00:00
'topic' => $this -> getTopic (),
2010-06-04 19:48:54 +01:00
'pushCallbacks' => $pushCallbacks );
2017-05-01 10:04:27 +01:00
common_log ( LOG_INFO , " Queuing WebSub batch: { $this -> getTopic () } to " . count ( $pushCallbacks ) . " sites " );
2010-06-04 19:48:54 +01:00
$qm = QueueManager :: get ();
$qm -> enqueue ( $data , 'hubprep' );
2016-01-16 16:34:27 +00:00
return true ;
2010-06-04 19:48:54 +01:00
}
2010-02-08 19:06:03 +00:00
/**
2017-07-10 13:43:28 +01:00
* @ return boolean true / false for HTTP response
* @ throws Exception for lower - than - HTTP errors ( such as NS lookup failure , connection refused ... )
2010-02-08 19:06:03 +00:00
*/
2017-07-10 13:43:28 +01:00
public static function pushAtom ( $topic , $callback , $atom , $secret = null , $hashalg = 'sha1' )
2010-02-08 19:06:03 +00:00
{
$headers = array ( 'Content-Type: application/atom+xml' );
2017-07-10 13:43:28 +01:00
if ( $secret ) {
$hmac = hash_hmac ( $hashalg , $atom , $secret );
$headers [] = " X-Hub-Signature: { $hashalg } = { $hmac } " ;
2010-02-08 19:06:03 +00:00
} else {
$hmac = '(none)' ;
}
2017-07-10 13:43:28 +01:00
common_log ( LOG_INFO , sprintf ( 'About to WebSub-push feed to %s for %s, HMAC %s' , _ve ( $callback ), _ve ( $topic ), _ve ( $hmac )));
2010-02-08 19:06:03 +00:00
2010-02-21 22:28:06 +00:00
$request = new HTTPClient ();
2016-01-12 13:31:14 +00:00
$request -> setConfig ( array ( 'follow_redirects' => false ));
2010-02-21 22:28:06 +00:00
$request -> setBody ( $atom );
2017-07-10 13:43:28 +01:00
// This will throw exception on non-HTTP failures
2016-01-11 18:55:02 +00:00
try {
2017-07-10 13:43:28 +01:00
$response = $request -> post ( $callback , $headers );
} catch ( Exception $e ) {
common_debug ( sprintf ( 'WebSub callback to %s for %s failed with exception %s: %s' , _ve ( $callback ), _ve ( $topic ), _ve ( get_class ( $e )), _ve ( $e -> getMessage ())));
throw $e ;
}
2010-02-08 19:06:03 +00:00
2017-07-10 13:43:28 +01:00
return $response -> isOk ();
}
/**
* Send a 'fat ping' to the subscriber ' s callback endpoint
* containing the given Atom feed chunk .
*
* Determination of which feed items to send should be done at
* a higher level ; don ' t just shove in a complete feed !
*
* FIXME : Add 'failed' incremental count .
*
* @ param string $atom well - formed Atom feed
* @ return boolean Whether the PuSH was accepted or not .
* @ throws Exception ( HTTP or general )
*/
function push ( $atom )
{
try {
$success = self :: pushAtom ( $this -> getTopic (), $this -> callback , $atom , $this -> secret );
if ( $success ) {
2016-01-11 18:55:02 +00:00
return true ;
2017-07-10 13:43:28 +01:00
} elseif ( 'https' === parse_url ( $this -> callback , PHP_URL_SCHEME )) {
// Already HTTPS, no need to check remote http/https migration issues
return false ;
2016-01-11 18:55:02 +00:00
}
2017-07-10 13:43:28 +01:00
// if pushAtom returned false and we didn't try an HTTPS endpoint,
// let's try HTTPS too (assuming only http:// and https:// are used ;))
2016-01-11 18:55:02 +00:00
2017-07-10 13:43:28 +01:00
} catch ( Exception $e ) {
if ( 'https' === parse_url ( $this -> callback , PHP_URL_SCHEME )) {
// Already HTTPS, no need to check remote http/https migration issues
throw $e ;
}
2010-02-08 19:06:03 +00:00
}
2016-01-11 18:55:02 +00:00
2017-07-10 13:43:28 +01:00
// We failed the WebSub push, but it might be that the remote site has changed their configuration to HTTPS
common_debug ( 'WebSub HTTPSFIX: push failed, so we need to see if it can be the remote http->https migration issue.' );
2016-01-11 18:55:02 +00:00
// XXX: DO NOT trust a Location header here, _especially_ from 'http' protocols,
// but not 'https' either at least if we don't do proper CA verification. Trust that
// the most common change here is simply switching 'http' to 'https' and we will
// solve 99% of all of these issues for now. There should be a proper mechanism
// if we want to change the callback URLs, preferrably just manual resubscriptions
2017-05-01 10:04:27 +01:00
// from the remote side, combined with implemented WebSub subscription timeouts.
2016-01-11 18:55:02 +00:00
2017-07-10 13:43:28 +01:00
// Test if the feed callback for this node has already been migrated to HTTPS in our database
// (otherwise we'd get collisions when inserting it further down)
$httpscallback = preg_replace ( '/^http/' , 'https' , $this -> callback , 1 );
$alreadyreplaced = self :: getByHashKey ( $this -> getTopic (), $httpscallback );
if ( $alreadyreplaced instanceof HubSub ) {
// Let's remove the old HTTP callback object.
$this -> delete ();
// XXX: I think this means we might lose a message or two when
// remote side migrates to HTTPS because we only try _once_
// for _one_ WebSub push. The rest of the posts already
// stored in our queue (if any) will not find a HubSub
// object. This could maybe be fixed by handling migration
// in HubOutQueueHandler while handling the item there.
common_debug ( 'WebSub HTTPSFIX: Pushing Atom to HTTPS callback instead of HTTP, because of switch to HTTPS since enrolled in queue.' );
return self :: pushAtom ( $this -> getTopic (), $httpscallback , $atom , $this -> secret );
2016-01-11 18:55:02 +00:00
}
2017-07-10 13:43:28 +01:00
common_debug ( 'WebSub HTTPSFIX: callback to ' . _ve ( $this -> callback ) . ' for ' . _ve ( $this -> getTopic ()) . ' trying HTTPS callback: ' . _ve ( $httpscallback ));
$success = self :: pushAtom ( $this -> getTopic (), $httpscallback , $atom , $this -> secret );
if ( $success ) {
// Yay, we made a successful push to https://, let's remember this in the future!
$orig = clone ( $this );
$this -> callback = $httpscallback ;
// NOTE: hashkey will be set in $this->onUpdateKeys($orig) through updateWithKeys
$this -> updateWithKeys ( $orig );
return true ;
2016-01-11 18:55:02 +00:00
}
2017-07-10 13:43:28 +01:00
// If there have been any exceptions thrown before, they're handled
// higher up. This function's return value is just whether the WebSub
// push was accepted or not.
return $success ;
2010-02-08 19:06:03 +00:00
}
}