]> git.mxchange.org Git - quix0rs-gnu-social.git/blob - plugins/OStatus/classes/Ostatus_profile.php
more work on salmon
[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 ($entity->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 = sql_common_date();
249             $profile->lastupdate = sql_common_date();
250             $result = $profile->insert();
251             if (empty($result)) {
252                 throw new FeedDBException($profile);
253             }
254
255             $entity->query('COMMIT');
256         } catch (FeedDBException $e) {
257             common_log_db_error($e->obj, 'INSERT', __FILE__);
258             $entity->query('ROLLBACK');
259             return false;
260         }
261
262         $avatar = $munger->getAvatar();
263         if ($avatar) {
264             try {
265                 $this->updateAvatar($avatar);
266             } catch (Exception $e) {
267                 common_log(LOG_ERR, "Exception setting OStatus avatar: " .
268                                     $e->getMessage());
269             }
270         }
271
272         return $entity;
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         $imagefile = new ImageFile($profile->id, $temp_filename);
287         $filename = Avatar::filename($profile->id,
288                                      image_type_to_extension($imagefile->type),
289                                      null,
290                                      common_timestamp());
291         rename($temp_filename, Avatar::path($filename));
292         if ($this->isGroup()) {
293             $group = $this->localGroup();
294             $group->setOriginal($filename);
295         } else {
296             $profile = $this->localProfile();
297             $profile->setOriginal($filename);
298         }
299     }
300
301     /**
302      * Returns an XML string fragment with profile information as an
303      * Activity Streams noun object with the given element type.
304      *
305      * Assumes that 'activity' namespace has been previously defined.
306      *
307      * @param string $element one of 'actor', 'subject', 'object', 'target'
308      * @return string
309      */
310     function asActivityNoun($element)
311     {
312         $xs = new XMLStringer(true);
313
314         $avatarHref = Avatar::defaultImage(AVATAR_PROFILE_SIZE);
315         $avatarType = 'image/png';
316         if ($this->isGroup()) {
317             $type = 'http://activitystrea.ms/schema/1.0/group';
318             $self = $this->localGroup();
319
320             // @fixme put a standard getAvatar() interface on groups too
321             if ($self->homepage_logo) {
322                 $avatarHref = $self->homepage_logo;
323                 $map = array('png' => 'image/png',
324                              'jpg' => 'image/jpeg',
325                              'jpeg' => 'image/jpeg',
326                              'gif' => 'image/gif');
327                 $extension = pathinfo(parse_url($avatarHref, PHP_URL_PATH), PATHINFO_EXTENSION);
328                 if (isset($map[$extension])) {
329                     $avatarType = $map[$extension];
330                 }
331             }
332         } else {
333             $type = 'http://activitystrea.ms/schema/1.0/person';
334             $self = $this->localProfile();
335             $avatar = $self->getAvatar(AVATAR_PROFILE_SIZE);
336             if ($avatar) {
337                 $avatarHref = $avatar->
338                 $avatarType = $avatar->mediatype;
339             }
340         }
341         $xs->elementStart('activity:' . $element);
342         $xs->element(
343             'activity:object-type',
344             null,
345             $type
346         );
347         $xs->element(
348             'id',
349             null,
350             $this->homeuri); // ?
351         $xs->element('title', null, $self->getBestName());
352
353         $xs->element(
354             'link', array(
355                 'type' => $avatarType,
356                 'href' => $avatarHref
357             ),
358             ''
359         );
360
361         $xs->elementEnd('activity:' . $element);
362
363         return $xs->getString();
364     }
365
366     /**
367      * Damn dirty hack!
368      */
369     function isGroup()
370     {
371         return (strpos($this->feeduri, '/groups/') !== false);
372     }
373
374     /**
375      * Send a subscription request to the hub for this feed.
376      * The hub will later send us a confirmation POST to /main/push/callback.
377      *
378      * @return bool true on success, false on failure
379      */
380     public function subscribe($mode='subscribe')
381     {
382         if (common_config('feedsub', 'nohub')) {
383             // Fake it! We're just testing remote feeds w/o hubs.
384             return true;
385         }
386         // @fixme use the verification token
387         #$token = md5(mt_rand() . ':' . $this->feeduri);
388         #$this->verify_token = $token;
389         #$this->update(); // @fixme
390         try {
391             $callback = common_local_url('pushcallback', array('feed' => $this->id));
392             $headers = array('Content-Type: application/x-www-form-urlencoded');
393             $post = array('hub.mode' => $mode,
394                           'hub.callback' => $callback,
395                           'hub.verify' => 'async',
396                           'hub.verify_token' => $this->verify_token,
397                           'hub.secret' => $this->secret,
398                           //'hub.lease_seconds' => 0,
399                           'hub.topic' => $this->feeduri);
400             $client = new HTTPClient();
401             $response = $client->post($this->huburi, $headers, $post);
402             $status = $response->getStatus();
403             if ($status == 202) {
404                 common_log(LOG_INFO, __METHOD__ . ': sub req ok, awaiting verification callback');
405                 return true;
406             } else if ($status == 204) {
407                 common_log(LOG_INFO, __METHOD__ . ': sub req ok and verified');
408                 return true;
409             } else if ($status >= 200 && $status < 300) {
410                 common_log(LOG_ERR, __METHOD__ . ": sub req returned unexpected HTTP $status: " . $response->getBody());
411                 return false;
412             } else {
413                 common_log(LOG_ERR, __METHOD__ . ": sub req failed with HTTP $status: " . $response->getBody());
414                 return false;
415             }
416         } catch (Exception $e) {
417             // wtf!
418             common_log(LOG_ERR, __METHOD__ . ": error \"{$e->getMessage()}\" hitting hub $this->huburi subscribing to $this->feeduri");
419             return false;
420         }
421     }
422
423     /**
424      * Save PuSH subscription confirmation.
425      * Sets approximate lease start and end times and finalizes state.
426      *
427      * @param int $lease_seconds provided hub.lease_seconds parameter, if given
428      */
429     public function confirmSubscribe($lease_seconds=0)
430     {
431         $original = clone($this);
432
433         $this->sub_state = 'active';
434         $this->sub_start = common_sql_date(time());
435         if ($lease_seconds > 0) {
436             $this->sub_end = common_sql_date(time() + $lease_seconds);
437         } else {
438             $this->sub_end = null;
439         }
440         $this->lastupdate = common_sql_date();
441
442         return $this->update($original);
443     }
444
445     /**
446      * Save PuSH unsubscription confirmation.
447      * Wipes active PuSH sub info and resets state.
448      */
449     public function confirmUnsubscribe()
450     {
451         $original = clone($this);
452
453         $this->verify_token = null;
454         $this->secret = null;
455         $this->sub_state = null;
456         $this->sub_start = null;
457         $this->sub_end = null;
458         $this->lastupdate = common_sql_date();
459
460         return $this->update($original);
461     }
462
463     /**
464      * Send a PuSH unsubscription request to the hub for this feed.
465      * The hub will later send us a confirmation POST to /main/push/callback.
466      *
467      * @return bool true on success, false on failure
468      */
469     public function unsubscribe() {
470         return $this->subscribe('unsubscribe');
471     }
472
473     /**
474      * Send an Activity Streams notification to the remote Salmon endpoint,
475      * if so configured.
476      *
477      * @param Profile $actor
478      * @param $verb eg Activity::SUBSCRIBE or Activity::JOIN
479      * @param $object object of the action; if null, the remote entity itself is assumed
480      */
481     public function notify(Profile $actor, $verb, $object=null)
482     {
483         if ($object == null) {
484             $object = $this;
485         }
486         if ($this->salmonuri) {
487             $text = 'update'; // @fixme
488             $id = 'tag:' . common_config('site', 'server') .
489                 ':' . $verb .
490                 ':' . $actor->id .
491                 ':' . time(); // @fixme
492
493             $entry = new Atom10Entry();
494             $entry->elementStart('entry');
495             $entry->element('id', null, $id);
496             $entry->element('title', null, $text);
497             $entry->element('summary', null, $text);
498             $entry->element('published', null, common_date_w3dtf());
499
500             $entry->element('activity:verb', null, $verb);
501             $entry->raw($profile->asAtomAuthor());
502             $entry->raw($profile->asActivityActor());
503             $entry->raw($object->asActivityNoun('object'));
504             $entry->elmentEnd('entry');
505
506             $feed = $this->atomFeed($actor);
507             $feed->initFeed();
508             $feed->addEntry($entry);
509             $feed->renderEntries();
510             $feed->endFeed();
511
512             $xml = $feed->getString();
513             common_log(LOG_INFO, "Posting to Salmon endpoint $salmon: $xml");
514
515             $salmon = new Salmon(); // ?
516             $salmon->post($this->salmonuri, $xml);
517         }
518     }
519
520     function getBestName()
521     {
522         if ($this->isGroup()) {
523             return $this->localGroup()->getBestName();
524         } else {
525             return $this->localProfile()->getBestName();
526         }
527     }
528
529     function atomFeed($actor)
530     {
531         $feed = new Atom10Feed();
532         // @fixme should these be set up somewhere else?
533         $feed->addNamespace('activity', 'http://activitystrea.ms/spec/1.0/');
534         $feed->addNamesapce('thr', 'http://purl.org/syndication/thread/1.0');
535         $feed->addNamespace('georss', 'http://www.georss.org/georss');
536         $feed->addNamespace('ostatus', 'http://ostatus.org/schema/1.0');
537
538         $taguribase = common_config('integration', 'taguri');
539         $feed->setId("tag:{$taguribase}:UserTimeline:{$actor->id}"); // ???
540
541         $feed->setTitle($actor->getBestName() . ' timeline'); // @fixme
542         $feed->setUpdated(time());
543         $feed->setPublished(time());
544
545         $feed->addLink(common_url('ApiTimelineUser',
546                                   array('id' => $actor->id,
547                                         'type' => 'atom')),
548                        array('rel' => 'self',
549                              'type' => 'application/atom+xml'));
550
551         $feed->addLink(common_url('userbyid',
552                                   array('id' => $actor->id)),
553                        array('rel' => 'alternate',
554                              'type' => 'text/html'));
555
556         return $feed;
557     }
558
559     /**
560      * Read and post notices for updates from the feed.
561      * Currently assumes that all items in the feed are new,
562      * coming from a PuSH hub.
563      *
564      * @param string $xml source of Atom or RSS feed
565      * @param string $hmac X-Hub-Signature header, if present
566      */
567     public function postUpdates($xml, $hmac)
568     {
569         common_log(LOG_INFO, __METHOD__ . ": packet for \"$this->feeduri\"! $hmac $xml");
570
571         if ($this->secret) {
572             if (preg_match('/^sha1=([0-9a-fA-F]{40})$/', $hmac, $matches)) {
573                 $their_hmac = strtolower($matches[1]);
574                 $our_hmac = hash_hmac('sha1', $xml, $this->secret);
575                 if ($their_hmac !== $our_hmac) {
576                     common_log(LOG_ERR, __METHOD__ . ": ignoring PuSH with bad SHA-1 HMAC: got $their_hmac, expected $our_hmac");
577                     return;
578                 }
579             } else {
580                 common_log(LOG_ERR, __METHOD__ . ": ignoring PuSH with bogus HMAC '$hmac'");
581                 return;
582             }
583         } else if ($hmac) {
584             common_log(LOG_ERR, __METHOD__ . ": ignoring PuSH with unexpected HMAC '$hmac'");
585             return;
586         }
587
588         require_once "XML/Feed/Parser.php";
589         $feed = new XML_Feed_Parser($xml, false, false, true);
590         $munger = new FeedMunger($feed);
591
592         $hits = 0;
593         foreach ($feed as $index => $entry) {
594             // @fixme this might sort in wrong order if we get multiple updates
595
596             $notice = $munger->notice($index);
597
598             // Double-check for oldies
599             // @fixme this could explode horribly for multiple feeds on a blog. sigh
600
601             $dupe = Notice::staticGet('uri', $notice->uri);
602
603             if (!empty($dupe)) {
604                 common_log(LOG_WARNING, __METHOD__ . ": tried to save dupe notice for entry {$notice->uri} of feed {$this->feeduri}");
605                 continue;
606             }
607
608             // @fixme need to ensure that groups get handled correctly
609             $saved = Notice::saveNew($notice->profile_id,
610                                      $notice->content,
611                                      'ostatus',
612                                      array('is_local' => Notice::REMOTE_OMB,
613                                            'uri' => $notice->uri,
614                                            'lat' => $notice->lat,
615                                            'lon' => $notice->lon,
616                                            'location_ns' => $notice->location_ns,
617                                            'location_id' => $notice->location_id));
618
619             /*
620             common_log(LOG_DEBUG, "going to check group delivery...");
621             if ($this->group_id) {
622                 $group = User_group::staticGet($this->group_id);
623                 if ($group) {
624                     common_log(LOG_INFO, __METHOD__ . ": saving to local shadow group $group->id $group->nickname");
625                     $groups = array($group);
626                 } else {
627                     common_log(LOG_INFO, __METHOD__ . ": lost the local shadow group?");
628                 }
629             } else {
630                 common_log(LOG_INFO, __METHOD__ . ": no local shadow groups");
631                 $groups = array();
632             }
633             common_log(LOG_DEBUG, "going to add to inboxes...");
634             $notice->addToInboxes($groups, array());
635             common_log(LOG_DEBUG, "added to inboxes.");
636             */
637
638             $hits++;
639         }
640         if ($hits == 0) {
641             common_log(LOG_INFO, __METHOD__ . ": no updates in packet for \"$this->feeduri\"! $xml");
642         }
643     }
644 }