]> git.mxchange.org Git - quix0rs-gnu-social.git/blob - plugins/OStatus/classes/Ostatus_profile.php
243211c31fcef0635ad0eb392d139fa5f1cc906c
[quix0rs-gnu-social.git] / plugins / OStatus / classes / Ostatus_profile.php
1 <?php
2 /*
3  * StatusNet - the distributed open-source microblogging tool
4  * Copyright (C) 2009-2010, StatusNet, Inc.
5  *
6  * This program is free software: you can redistribute it and/or modify
7  * it under the terms of the GNU Affero General Public License as published by
8  * the Free Software Foundation, either version 3 of the License, or
9  * (at your option) any later version.
10  *
11  * This program is distributed in the hope that it will be useful,
12  * but WITHOUT ANY WARRANTY; without even the implied warranty of
13  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14  * GNU Affero General Public License for more details.
15  *
16  * You should have received a copy of the GNU Affero General Public License
17  * along with this program.  If not, see <http://www.gnu.org/licenses/>.
18  */
19
20 /**
21  * @package FeedSubPlugin
22  * @maintainer Brion Vibber <brion@status.net>
23  */
24
25 /*
26 PuSH subscription flow:
27
28     $profile->subscribe()
29         generate random verification token
30             save to verify_token
31         sends a sub request to the hub...
32
33     main/push/callback
34         hub sends confirmation back to us via GET
35         We verify the request, then echo back the challenge.
36         On our end, we save the time we subscribed and the lease expiration
37
38     main/push/callback
39         hub sends us updates via POST
40
41 */
42
43 class FeedDBException extends FeedSubException
44 {
45     public $obj;
46
47     function __construct($obj)
48     {
49         parent::__construct('Database insert failure');
50         $this->obj = $obj;
51     }
52 }
53
54 class Ostatus_profile extends Memcached_DataObject
55 {
56     public $__table = 'ostatus_profile';
57
58     public $id;
59     public $profile_id;
60     public $group_id;
61
62     public $feeduri;
63     public $homeuri;
64
65     // PuSH subscription data
66     public $huburi;
67     public $secret;
68     public $verify_token;
69     public $sub_state; // subscribe, active, unsubscribe
70     public $sub_start;
71     public $sub_end;
72
73     public $salmonuri;
74
75     public $created;
76     public $lastupdate;
77
78     public /*static*/ function staticGet($k, $v=null)
79     {
80         return parent::staticGet(__CLASS__, $k, $v);
81     }
82
83     /**
84      * return table definition for DB_DataObject
85      *
86      * DB_DataObject needs to know something about the table to manipulate
87      * instances. This method provides all the DB_DataObject needs to know.
88      *
89      * @return array array of column definitions
90      */
91
92     function table()
93     {
94         return array('id' => DB_DATAOBJECT_INT + DB_DATAOBJECT_NOTNULL,
95                      'profile_id' => DB_DATAOBJECT_INT,
96                      'group_id' => DB_DATAOBJECT_INT,
97                      'feeduri' => DB_DATAOBJECT_STR + DB_DATAOBJECT_NOTNULL,
98                      'homeuri' => DB_DATAOBJECT_STR + DB_DATAOBJECT_NOTNULL,
99                      'huburi' =>  DB_DATAOBJECT_STR,
100                      'secret' => DB_DATAOBJECT_STR,
101                      'verify_token' => DB_DATAOBJECT_STR,
102                      'sub_state' => DB_DATAOBJECT_STR,
103                      'sub_start' => DB_DATAOBJECT_STR + DB_DATAOBJECT_DATE + DB_DATAOBJECT_TIME,
104                      'sub_end' => DB_DATAOBJECT_STR + DB_DATAOBJECT_DATE + DB_DATAOBJECT_TIME,
105                      'salmonuri' =>  DB_DATAOBJECT_STR,
106                      'created' => DB_DATAOBJECT_STR + DB_DATAOBJECT_DATE + DB_DATAOBJECT_TIME + DB_DATAOBJECT_NOTNULL,
107                      'lastupdate' => DB_DATAOBJECT_STR + DB_DATAOBJECT_DATE + DB_DATAOBJECT_TIME + DB_DATAOBJECT_NOTNULL);
108     }
109
110     static function schemaDef()
111     {
112         return array(new ColumnDef('id', 'integer',
113                                    /*size*/ null,
114                                    /*nullable*/ false,
115                                    /*key*/ 'PRI',
116                                    /*default*/ '0',
117                                    /*extra*/ null,
118                                    /*auto_increment*/ true),
119                      new ColumnDef('profile_id', 'integer',
120                                    null, true, 'UNI'),
121                      new ColumnDef('group_id', 'integer',
122                                    null, true, 'UNI'),
123                      new ColumnDef('feeduri', 'varchar',
124                                    255, false, 'UNI'),
125                      new ColumnDef('homeuri', 'varchar',
126                                    255, false),
127                      new ColumnDef('huburi', 'text',
128                                    null, true),
129                      new ColumnDef('verify_token', 'varchar',
130                                    32, true),
131                      new ColumnDef('secret', 'varchar',
132                                    64, true),
133                      new ColumnDef('sub_state', "enum('subscribe','active','unsubscribe')",
134                                    null, true),
135                      new ColumnDef('sub_start', 'datetime',
136                                    null, true),
137                      new ColumnDef('sub_end', 'datetime',
138                                    null, true),
139                      new ColumnDef('salmonuri', 'text',
140                                    null, true),
141                      new ColumnDef('created', 'datetime',
142                                    null, false),
143                      new ColumnDef('lastupdate', 'datetime',
144                                    null, false));
145     }
146
147     /**
148      * return key definitions for DB_DataObject
149      *
150      * DB_DataObject needs to know about keys that the table has; this function
151      * defines them.
152      *
153      * @return array key definitions
154      */
155
156     function keys()
157     {
158         return array_keys($this->keyTypes());
159     }
160
161     /**
162      * return key definitions for Memcached_DataObject
163      *
164      * Our caching system uses the same key definitions, but uses a different
165      * method to get them.
166      *
167      * @return array key definitions
168      */
169
170     function keyTypes()
171     {
172         return array('id' => 'K', 'profile_id' => 'U', 'group_id' => 'U', 'feeduri' => 'U');
173     }
174
175     function sequenceKey()
176     {
177         return array('id', true, false);
178     }
179
180     /**
181      * Fetch the StatusNet-side profile for this feed
182      * @return Profile
183      */
184     public function localProfile()
185     {
186         if ($this->profile_id) {
187             return Profile::staticGet('id', $this->profile_id);
188         }
189         return null;
190     }
191
192     /**
193      * Fetch the StatusNet-side profile for this feed
194      * @return Profile
195      */
196     public function localGroup()
197     {
198         if ($this->group_id) {
199             return User_group::staticGet('id', $this->group_id);
200         }
201         return null;
202     }
203
204     /**
205      * @param FeedMunger $munger
206      * @param boolean $isGroup is this a group record?
207      * @return Ostatus_profile
208      */
209     public static function ensureProfile($munger)
210     {
211         $profile = $munger->ostatusProfile();
212
213         $current = self::staticGet('feeduri', $profile->feeduri);
214         if ($current) {
215             // @fixme we should probably update info as necessary
216             return $current;
217         }
218
219         $profile->query('BEGIN');
220
221         // Awful hack! Awful hack!
222         $profile->verify = common_good_rand(16);
223         $profile->secret = common_good_rand(32);
224
225         try {
226             $local = $munger->profile();
227
228             if ($profile->isGroup()) {
229                 $group = new User_group();
230                 $group->nickname = $local->nickname . '@remote'; // @fixme
231                 $group->fullname = $local->fullname;
232                 $group->homepage = $local->homepage;
233                 $group->location = $local->location;
234                 $group->created = $local->created;
235                 $group->insert();
236                 if (empty($result)) {
237                     throw new FeedDBException($group);
238                 }
239                 $profile->group_id = $group->id;
240             } else {
241                 $result = $local->insert();
242                 if (empty($result)) {
243                     throw new FeedDBException($local);
244                 }
245                 $profile->profile_id = $local->id;
246             }
247
248             $profile->created = common_sql_now();
249             $profile->lastupdate = common_sql_now();
250             $result = $profile->insert();
251             if (empty($result)) {
252                 throw new FeedDBException($profile);
253             }
254
255             $profile->query('COMMIT');
256         } catch (FeedDBException $e) {
257             common_log_db_error($e->obj, 'INSERT', __FILE__);
258             $profile->query('ROLLBACK');
259             return false;
260         }
261
262         $avatar = $munger->getAvatar();
263         if ($avatar) {
264             try {
265                 $profile->updateAvatar($avatar);
266             } catch (Exception $e) {
267                 common_log(LOG_ERR, "Exception setting OStatus avatar: " .
268                                     $e->getMessage());
269             }
270         }
271
272         return $profile;
273     }
274
275     /**
276      * Download and update given avatar image
277      * @param string $url
278      * @throws Exception in various failure cases
279      */
280     public function updateAvatar($url)
281     {
282         // @fixme this should be better encapsulated
283         // ripped from oauthstore.php (for old OMB client)
284         $temp_filename = tempnam(sys_get_temp_dir(), 'listener_avatar');
285         copy($url, $temp_filename);
286         
287         // @fixme should we be using different ids?
288         $imagefile = new ImageFile($this->id, $temp_filename);
289         $filename = Avatar::filename($this->id,
290                                      image_type_to_extension($imagefile->type),
291                                      null,
292                                      common_timestamp());
293         rename($temp_filename, Avatar::path($filename));
294         if ($this->isGroup()) {
295             $group = $this->localGroup();
296             $group->setOriginal($filename);
297         } else {
298             $profile = $this->localProfile();
299             $profile->setOriginal($filename);
300         }
301     }
302
303     /**
304      * Returns an XML string fragment with profile information as an
305      * Activity Streams noun object with the given element type.
306      *
307      * Assumes that 'activity' namespace has been previously defined.
308      *
309      * @param string $element one of 'actor', 'subject', 'object', 'target'
310      * @return string
311      */
312     function asActivityNoun($element)
313     {
314         $xs = new XMLStringer(true);
315
316         $avatarHref = Avatar::defaultImage(AVATAR_PROFILE_SIZE);
317         $avatarType = 'image/png';
318         if ($this->isGroup()) {
319             $type = 'http://activitystrea.ms/schema/1.0/group';
320             $self = $this->localGroup();
321
322             // @fixme put a standard getAvatar() interface on groups too
323             if ($self->homepage_logo) {
324                 $avatarHref = $self->homepage_logo;
325                 $map = array('png' => 'image/png',
326                              'jpg' => 'image/jpeg',
327                              'jpeg' => 'image/jpeg',
328                              'gif' => 'image/gif');
329                 $extension = pathinfo(parse_url($avatarHref, PHP_URL_PATH), PATHINFO_EXTENSION);
330                 if (isset($map[$extension])) {
331                     $avatarType = $map[$extension];
332                 }
333             }
334         } else {
335             $type = 'http://activitystrea.ms/schema/1.0/person';
336             $self = $this->localProfile();
337             $avatar = $self->getAvatar(AVATAR_PROFILE_SIZE);
338             if ($avatar) {
339                 $avatarHref = $avatar->
340                 $avatarType = $avatar->mediatype;
341             }
342         }
343         $xs->elementStart('activity:' . $element);
344         $xs->element(
345             'activity:object-type',
346             null,
347             $type
348         );
349         $xs->element(
350             'id',
351             null,
352             $this->homeuri); // ?
353         $xs->element('title', null, $self->getBestName());
354
355         $xs->element(
356             'link', array(
357                 'type' => $avatarType,
358                 'href' => $avatarHref
359             ),
360             ''
361         );
362
363         $xs->elementEnd('activity:' . $element);
364
365         return $xs->getString();
366     }
367
368     /**
369      * Damn dirty hack!
370      */
371     function isGroup()
372     {
373         return (strpos($this->feeduri, '/groups/') !== false);
374     }
375
376     /**
377      * Send a subscription request to the hub for this feed.
378      * The hub will later send us a confirmation POST to /main/push/callback.
379      *
380      * @return bool true on success, false on failure
381      * @throws ServerException if feed state is not valid
382      */
383     public function subscribe($mode='subscribe')
384     {
385         if ($this->sub_state != '') {
386             throw new ServerException("Attempting to start PuSH subscription to feed in state $this->sub_state");
387         }
388         if (empty($this->huburi)) {
389             if (common_config('feedsub', 'nohub')) {
390                 // Fake it! We're just testing remote feeds w/o hubs.
391                 return true;
392             } else {
393                 throw new ServerException("Attempting to start PuSH subscription for feed with no hub");
394             }
395         }
396
397         return $this->doSubscribe('subscribe');
398     }
399
400     /**
401      * Send a PuSH unsubscription request to the hub for this feed.
402      * The hub will later send us a confirmation POST to /main/push/callback.
403      *
404      * @return bool true on success, false on failure
405      * @throws ServerException if feed state is not valid
406      */
407     public function unsubscribe() {
408         if ($this->sub_state != 'active') {
409             throw new ServerException("Attempting to end PuSH subscription to feed in state $this->sub_state");
410         }
411         if (empty($this->huburi)) {
412             if (common_config('feedsub', 'nohub')) {
413                 // Fake it! We're just testing remote feeds w/o hubs.
414                 return true;
415             } else {
416                 throw new ServerException("Attempting to end PuSH subscription for feed with no hub");
417             }
418         }
419
420         return $this->doSubscribe('unsubscribe');
421     }
422
423     protected function doSubscribe($mode)
424     {
425         $orig = clone($this);
426         $this->verify_token = md5(mt_rand() . ':' . $this->feeduri);
427         $this->sub_state = $mode;
428         $this->update($orig);
429         unset($orig);
430
431         try {
432             $callback = common_local_url('pushcallback', array('feed' => $this->id));
433             $headers = array('Content-Type: application/x-www-form-urlencoded');
434             $post = array('hub.mode' => $mode,
435                           'hub.callback' => $callback,
436                           'hub.verify' => 'async',
437                           'hub.verify_token' => $this->verify_token,
438                           'hub.secret' => $this->secret,
439                           //'hub.lease_seconds' => 0,
440                           'hub.topic' => $this->feeduri);
441             $client = new HTTPClient();
442             $response = $client->post($this->huburi, $headers, $post);
443             $status = $response->getStatus();
444             if ($status == 202) {
445                 common_log(LOG_INFO, __METHOD__ . ': sub req ok, awaiting verification callback');
446                 return true;
447             } else if ($status == 204) {
448                 common_log(LOG_INFO, __METHOD__ . ': sub req ok and verified');
449                 return true;
450             } else if ($status >= 200 && $status < 300) {
451                 common_log(LOG_ERR, __METHOD__ . ": sub req returned unexpected HTTP $status: " . $response->getBody());
452                 return false;
453             } else {
454                 common_log(LOG_ERR, __METHOD__ . ": sub req failed with HTTP $status: " . $response->getBody());
455                 return false;
456             }
457         } catch (Exception $e) {
458             // wtf!
459             common_log(LOG_ERR, __METHOD__ . ": error \"{$e->getMessage()}\" hitting hub $this->huburi subscribing to $this->feeduri");
460
461             $orig = clone($this);
462             $this->verify_token = null;
463             $this->sub_state = null;
464             $this->update($orig);
465             unset($orig);
466
467             return false;
468         }
469     }
470
471     /**
472      * Save PuSH subscription confirmation.
473      * Sets approximate lease start and end times and finalizes state.
474      *
475      * @param int $lease_seconds provided hub.lease_seconds parameter, if given
476      */
477     public function confirmSubscribe($lease_seconds=0)
478     {
479         $original = clone($this);
480
481         $this->sub_state = 'active';
482         $this->sub_start = common_sql_date(time());
483         if ($lease_seconds > 0) {
484             $this->sub_end = common_sql_date(time() + $lease_seconds);
485         } else {
486             $this->sub_end = null;
487         }
488         $this->lastupdate = common_sql_date();
489
490         return $this->update($original);
491     }
492
493     /**
494      * Save PuSH unsubscription confirmation.
495      * Wipes active PuSH sub info and resets state.
496      */
497     public function confirmUnsubscribe()
498     {
499         $original = clone($this);
500
501         $this->verify_token = null;
502         $this->secret = null;
503         $this->sub_state = null;
504         $this->sub_start = null;
505         $this->sub_end = null;
506         $this->lastupdate = common_sql_date();
507
508         return $this->update($original);
509     }
510
511     /**
512      * Send an Activity Streams notification to the remote Salmon endpoint,
513      * if so configured.
514      *
515      * @param Profile $actor
516      * @param $verb eg Activity::SUBSCRIBE or Activity::JOIN
517      * @param $object object of the action; if null, the remote entity itself is assumed
518      */
519     public function notify(Profile $actor, $verb, $object=null)
520     {
521         if ($object == null) {
522             $object = $this;
523         }
524         if ($this->salmonuri) {
525             $text = 'update'; // @fixme
526             $id = 'tag:' . common_config('site', 'server') .
527                 ':' . $verb .
528                 ':' . $actor->id .
529                 ':' . time(); // @fixme
530
531             $entry = new Atom10Entry();
532             $entry->elementStart('entry');
533             $entry->element('id', null, $id);
534             $entry->element('title', null, $text);
535             $entry->element('summary', null, $text);
536             $entry->element('published', null, common_date_w3dtf());
537
538             $entry->element('activity:verb', null, $verb);
539             $entry->raw($profile->asAtomAuthor());
540             $entry->raw($profile->asActivityActor());
541             $entry->raw($object->asActivityNoun('object'));
542             $entry->elmentEnd('entry');
543
544             $feed = $this->atomFeed($actor);
545             $feed->initFeed();
546             $feed->addEntry($entry);
547             $feed->renderEntries();
548             $feed->endFeed();
549
550             $xml = $feed->getString();
551             common_log(LOG_INFO, "Posting to Salmon endpoint $salmon: $xml");
552
553             $salmon = new Salmon(); // ?
554             $salmon->post($this->salmonuri, $xml);
555         }
556     }
557
558     function getBestName()
559     {
560         if ($this->isGroup()) {
561             return $this->localGroup()->getBestName();
562         } else {
563             return $this->localProfile()->getBestName();
564         }
565     }
566
567     function atomFeed($actor)
568     {
569         $feed = new Atom10Feed();
570         // @fixme should these be set up somewhere else?
571         $feed->addNamespace('activity', 'http://activitystrea.ms/spec/1.0/');
572         $feed->addNamesapce('thr', 'http://purl.org/syndication/thread/1.0');
573         $feed->addNamespace('georss', 'http://www.georss.org/georss');
574         $feed->addNamespace('ostatus', 'http://ostatus.org/schema/1.0');
575
576         $taguribase = common_config('integration', 'taguri');
577         $feed->setId("tag:{$taguribase}:UserTimeline:{$actor->id}"); // ???
578
579         $feed->setTitle($actor->getBestName() . ' timeline'); // @fixme
580         $feed->setUpdated(time());
581         $feed->setPublished(time());
582
583         $feed->addLink(common_url('ApiTimelineUser',
584                                   array('id' => $actor->id,
585                                         'type' => 'atom')),
586                        array('rel' => 'self',
587                              'type' => 'application/atom+xml'));
588
589         $feed->addLink(common_url('userbyid',
590                                   array('id' => $actor->id)),
591                        array('rel' => 'alternate',
592                              'type' => 'text/html'));
593
594         return $feed;
595     }
596
597     /**
598      * Read and post notices for updates from the feed.
599      * Currently assumes that all items in the feed are new,
600      * coming from a PuSH hub.
601      *
602      * @param string $xml source of Atom or RSS feed
603      * @param string $hmac X-Hub-Signature header, if present
604      */
605     public function postUpdates($xml, $hmac)
606     {
607         common_log(LOG_INFO, __METHOD__ . ": packet for \"$this->feeduri\"! $hmac $xml");
608
609         if ($this->sub_state != 'active') {
610             common_log(LOG_ERR, __METHOD__ . ": ignoring PuSH for inactive feed $this->feeduri (in state '$this->sub_state')");
611             return;
612         }
613
614         if ($this->secret) {
615             if (preg_match('/^sha1=([0-9a-fA-F]{40})$/', $hmac, $matches)) {
616                 $their_hmac = strtolower($matches[1]);
617                 $our_hmac = hash_hmac('sha1', $xml, $this->secret);
618                 if ($their_hmac !== $our_hmac) {
619                     common_log(LOG_ERR, __METHOD__ . ": ignoring PuSH with bad SHA-1 HMAC: got $their_hmac, expected $our_hmac");
620                     return;
621                 }
622             } else {
623                 common_log(LOG_ERR, __METHOD__ . ": ignoring PuSH with bogus HMAC '$hmac'");
624                 return;
625             }
626         } else if ($hmac) {
627             common_log(LOG_ERR, __METHOD__ . ": ignoring PuSH with unexpected HMAC '$hmac'");
628             return;
629         }
630
631         require_once "XML/Feed/Parser.php";
632         $feed = new XML_Feed_Parser($xml, false, false, true);
633         $munger = new FeedMunger($feed);
634
635         $hits = 0;
636         foreach ($feed as $index => $entry) {
637             // @fixme this might sort in wrong order if we get multiple updates
638
639             $notice = $munger->notice($index);
640
641             // Double-check for oldies
642             // @fixme this could explode horribly for multiple feeds on a blog. sigh
643
644             $dupe = Notice::staticGet('uri', $notice->uri);
645
646             if (!empty($dupe)) {
647                 common_log(LOG_WARNING, __METHOD__ . ": tried to save dupe notice for entry {$notice->uri} of feed {$this->feeduri}");
648                 continue;
649             }
650
651             // @fixme need to ensure that groups get handled correctly
652             $saved = Notice::saveNew($notice->profile_id,
653                                      $notice->content,
654                                      'ostatus',
655                                      array('is_local' => Notice::REMOTE_OMB,
656                                            'uri' => $notice->uri,
657                                            'lat' => $notice->lat,
658                                            'lon' => $notice->lon,
659                                            'location_ns' => $notice->location_ns,
660                                            'location_id' => $notice->location_id));
661
662             /*
663             common_log(LOG_DEBUG, "going to check group delivery...");
664             if ($this->group_id) {
665                 $group = User_group::staticGet($this->group_id);
666                 if ($group) {
667                     common_log(LOG_INFO, __METHOD__ . ": saving to local shadow group $group->id $group->nickname");
668                     $groups = array($group);
669                 } else {
670                     common_log(LOG_INFO, __METHOD__ . ": lost the local shadow group?");
671                 }
672             } else {
673                 common_log(LOG_INFO, __METHOD__ . ": no local shadow groups");
674                 $groups = array();
675             }
676             common_log(LOG_DEBUG, "going to add to inboxes...");
677             $notice->addToInboxes($groups, array());
678             common_log(LOG_DEBUG, "added to inboxes.");
679             */
680
681             $hits++;
682         }
683         if ($hits == 0) {
684             common_log(LOG_INFO, __METHOD__ . ": no updates in packet for \"$this->feeduri\"! $xml");
685         }
686     }
687 }