]> git.mxchange.org Git - quix0rs-gnu-social.git/blob - plugins/OStatus/OStatusPlugin.php
666683affc4d8a8389e6e212115d8b2b46f2a181
[quix0rs-gnu-social.git] / plugins / OStatus / OStatusPlugin.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 OStatusPlugin
22  * @maintainer Brion Vibber <brion@status.net>
23  */
24
25 if (!defined('STATUSNET')) {
26     exit(1);
27 }
28
29 set_include_path(get_include_path() . PATH_SEPARATOR . dirname(__FILE__) . '/extlib/');
30
31 class FeedSubException extends Exception
32 {
33     function __construct($msg=null)
34     {
35         $type = get_class($this);
36         if ($msg) {
37             parent::__construct("$type: $msg");
38         } else {
39             parent::__construct($type);
40         }
41     }
42 }
43
44 class OStatusPlugin extends Plugin
45 {
46     /**
47      * Hook for RouterInitialized event.
48      *
49      * @param Net_URL_Mapper $m path-to-action mapper
50      * @return boolean hook return
51      */
52     function onRouterInitialized($m)
53     {
54         // Discovery actions
55         $m->connect('main/ownerxrd',
56                     array('action' => 'ownerxrd'));
57         $m->connect('main/ostatus',
58                     array('action' => 'ostatusinit'));
59         $m->connect('main/ostatustag',
60                     array('action' => 'ostatustag'));
61         $m->connect('main/ostatustag?nickname=:nickname',
62                     array('action' => 'ostatustag'), array('nickname' => '[A-Za-z0-9_-]+'));
63         $m->connect('main/ostatus?nickname=:nickname',
64                   array('action' => 'ostatusinit'), array('nickname' => '[A-Za-z0-9_-]+'));
65         $m->connect('main/ostatus?group=:group',
66                   array('action' => 'ostatusinit'), array('group' => '[A-Za-z0-9_-]+'));
67         $m->connect('main/ostatus?peopletag=:peopletag&tagger=:tagger',
68                   array('action' => 'ostatusinit'), array('tagger' => '[A-Za-z0-9_-]+',
69                                                           'peopletag' => '[A-Za-z0-9_-]+'));
70
71         // Remote subscription actions
72         $m->connect('main/ostatussub',
73                     array('action' => 'ostatussub'));
74         $m->connect('main/ostatusgroup',
75                     array('action' => 'ostatusgroup'));
76         $m->connect('main/ostatuspeopletag',
77                     array('action' => 'ostatuspeopletag'));
78
79         // PuSH actions
80         $m->connect('main/push/hub', array('action' => 'pushhub'));
81
82         $m->connect('main/push/callback/:feed',
83                     array('action' => 'pushcallback'),
84                     array('feed' => '[0-9]+'));
85
86         // Salmon endpoint
87         $m->connect('main/salmon/user/:id',
88                     array('action' => 'usersalmon'),
89                     array('id' => '[0-9]+'));
90         $m->connect('main/salmon/group/:id',
91                     array('action' => 'groupsalmon'),
92                     array('id' => '[0-9]+'));
93         $m->connect('main/salmon/peopletag/:id',
94                     array('action' => 'peopletagsalmon'),
95                     array('id' => '[0-9]+'));
96         return true;
97     }
98
99     /**
100      * Set up queue handlers for outgoing hub pushes
101      * @param QueueManager $qm
102      * @return boolean hook return
103      */
104     function onEndInitializeQueueManager(QueueManager $qm)
105     {
106         // Prepare outgoing distributions after notice save.
107         $qm->connect('ostatus', 'OStatusQueueHandler');
108
109         // Outgoing from our internal PuSH hub
110         $qm->connect('hubconf', 'HubConfQueueHandler');
111         $qm->connect('hubprep', 'HubPrepQueueHandler');
112
113         $qm->connect('hubout', 'HubOutQueueHandler');
114
115         // Outgoing Salmon replies (when we don't need a return value)
116         $qm->connect('salmon', 'SalmonQueueHandler');
117
118         // Incoming from a foreign PuSH hub
119         $qm->connect('pushin', 'PushInQueueHandler');
120         return true;
121     }
122
123     /**
124      * Put saved notices into the queue for pubsub distribution.
125      */
126     function onStartEnqueueNotice($notice, &$transports)
127     {
128         if ($notice->isLocal()) {
129             // put our transport first, in case there's any conflict (like OMB)
130             array_unshift($transports, 'ostatus');
131         }
132         return true;
133     }
134
135     /**
136      * Add a link header for LRDD Discovery
137      */
138     function onStartShowHTML($action)
139     {
140         if ($action instanceof ShowstreamAction) {
141             $acct = 'acct:'. $action->profile->nickname .'@'. common_config('site', 'server');
142             $url = common_local_url('userxrd');
143             $url.= '?uri='. $acct;
144
145             header('Link: <'.$url.'>; rel="'. Discovery::LRDD_REL.'"; type="application/xrd+xml"');
146         }
147     }
148
149     /**
150      * Set up a PuSH hub link to our internal link for canonical timeline
151      * Atom feeds for users and groups.
152      */
153     function onStartApiAtom($feed)
154     {
155         $id = null;
156
157         if ($feed instanceof AtomUserNoticeFeed) {
158             $salmonAction = 'usersalmon';
159             $user = $feed->getUser();
160             $id   = $user->id;
161             $profile = $user->getProfile();
162         } else if ($feed instanceof AtomGroupNoticeFeed) {
163             $salmonAction = 'groupsalmon';
164             $group = $feed->getGroup();
165             $id = $group->id;
166         } else if ($feed instanceof AtomListNoticeFeed) {
167             $salmonAction = 'peopletagsalmon';
168             $peopletag = $feed->getList();
169             $id = $peopletag->id;
170         } else {
171             return true;
172         }
173
174         if (!empty($id)) {
175             $hub = common_config('ostatus', 'hub');
176             if (empty($hub)) {
177                 // Updates will be handled through our internal PuSH hub.
178                 $hub = common_local_url('pushhub');
179             }
180             $feed->addLink($hub, array('rel' => 'hub'));
181
182             // Also, we'll add in the salmon link
183             $salmon = common_local_url($salmonAction, array('id' => $id));
184             $feed->addLink($salmon, array('rel' => Salmon::REL_SALMON));
185
186             // XXX: these are deprecated
187             $feed->addLink($salmon, array('rel' => Salmon::NS_REPLIES));
188             $feed->addLink($salmon, array('rel' => Salmon::NS_MENTIONS));
189         }
190
191         return true;
192     }
193
194     /**
195      * Automatically load the actions and libraries used by the plugin
196      *
197      * @param Class $cls the class
198      *
199      * @return boolean hook return
200      *
201      */
202     function onAutoload($cls)
203     {
204         $base = dirname(__FILE__);
205         $lower = strtolower($cls);
206         $map = array('activityverb' => 'activity',
207                      'activityobject' => 'activity',
208                      'activityutils' => 'activity');
209         if (isset($map[$lower])) {
210             $lower = $map[$lower];
211         }
212         $files = array("$base/classes/$cls.php",
213                        "$base/lib/$lower.php");
214         if (substr($lower, -6) == 'action') {
215             $files[] = "$base/actions/" . substr($lower, 0, -6) . ".php";
216         }
217         foreach ($files as $file) {
218             if (file_exists($file)) {
219                 include_once $file;
220                 return false;
221             }
222         }
223         return true;
224     }
225
226     /**
227      * Add in an OStatus subscribe button
228      */
229     function onStartProfileRemoteSubscribe($output, $profile)
230     {
231         $this->onStartProfileListItemActionElements($output, $profile);
232         return false;
233     }
234
235     function onStartGroupSubscribe($output, $group)
236     {
237         $cur = common_current_user();
238
239         if (empty($cur)) {
240             // Add an OStatus subscribe
241             $url = common_local_url('ostatusinit',
242                                     array('group' => $group->nickname));
243             $output->element('a', array('href' => $url,
244                                         'class' => 'entity_remote_subscribe'),
245                                 _m('Join'));
246
247         }
248
249         return true;
250     }
251
252     function onStartSubscribePeopletagForm($output, $peopletag)
253     {
254         $cur = common_current_user();
255
256         if (empty($cur)) {
257             $output->elementStart('li', 'entity_subscribe');
258             $profile = $peopletag->getTagger();
259             $url = common_local_url('ostatusinit',
260                                     array('tagger' => $profile->nickname, 'peopletag' => $peopletag->tag));
261             $output->element('a', array('href' => $url,
262                                         'class' => 'entity_remote_subscribe'),
263                                 _m('Subscribe'));
264
265             $output->elementEnd('li');
266             return false;
267         }
268
269         return true;
270     }
271
272     function onStartShowTagProfileForm($action, $profile)
273     {
274         $action->elementStart('form', array('method' => 'post',
275                                            'id' => 'form_tag_user',
276                                            'class' => 'form_settings',
277                                            'name' => 'tagprofile',
278                                            'action' => common_local_url('tagprofile', array('id' => @$profile->id))));
279
280         $action->elementStart('fieldset');
281         $action->element('legend', null, _('Tag remote profile'));
282         $action->hidden('token', common_session_token());
283
284         $user = common_current_user();
285
286         $action->elementStart('ul', 'form_data');
287         $action->elementStart('li');
288
289         $action->input('uri', _('Remote profile'), $action->trimmed('uri'),
290                      _('OStatus user\'s address, like nickname@example.com or http://example.net/nickname'));
291         $action->elementEnd('li');
292         $action->elementEnd('ul');
293         $action->submit('fetch', _('Fetch'));
294         $action->elementEnd('fieldset');
295         $action->elementEnd('form');
296     }
297
298     function onStartTagProfileAction($action, $profile)
299     {
300         $err = null;
301         $uri = $action->trimmed('uri');
302
303         if (!$profile && $uri) {
304             try {
305                 if (Validate::email($uri)) {
306                     $oprofile = Ostatus_profile::ensureWebfinger($uri);
307                 } else if (Validate::uri($uri)) {
308                     $oprofile = Ostatus_profile::ensureProfileURL($uri);
309                 } else {
310                     throw new Exception('Invalid URI');
311                 }
312
313                 // redirect to the new profile.
314                 common_redirect(common_local_url('tagprofile', array('id' => $oprofile->profile_id)), 303);
315                 return false;
316
317             } catch (Exception $e) {
318                 $err = _m("Sorry, we could not reach that address. Please make sure that the OStatus address is like nickname@example.com or http://example.net/nickname");
319             }
320
321             $action->showForm($err);
322             return false;
323         }
324         return true;
325     }
326
327     /*
328      * If the field being looked for is URI look for the profile
329      */
330     function onStartProfileCompletionSearch($action, $profile, $search_engine) {
331         if ($action->field == 'uri') {
332             $user = new User();
333             $profile->joinAdd($user);
334             $profile->whereAdd('uri LIKE "%' . $profile->escape($q) . '%"');
335             $profile->query();
336
337             if ($profile->N == 0) {
338                 try {
339                     if (Validate::email($q)) {
340                         $oprofile = Ostatus_profile::ensureWebfinger($q);
341                     } else if (Validate::uri($q)) {
342                         $oprofile = Ostatus_profile::ensureProfileURL($q);
343                     } else {
344                         throw new Exception('Invalid URI');
345                     }
346                     return $this->filter(array($oprofile->localProfile()));
347
348                 } catch (Exception $e) {
349                     $this->msg = _m("Sorry, we could not reach that address. Please make sure that the OStatus address is like nickname@example.com or http://example.net/nickname");
350                     return array();
351                 }
352             }
353             return false;
354         }
355         return true;
356     }
357
358     /**
359      * Find any explicit remote mentions. Accepted forms:
360      *   Webfinger: @user@example.com
361      *   Profile link: @example.com/mublog/user
362      * @param Profile $sender (os user?)
363      * @param string $text input markup text
364      * @param array &$mention in/out param: set of found mentions
365      * @return boolean hook return value
366      */
367
368     function onEndFindMentions($sender, $text, &$mentions)
369     {
370         $matches = array();
371
372         // Webfinger matches: @user@example.com
373         if (preg_match_all('!(?:^|\s+)@((?:\w+\.)*\w+@(?:\w+\-?\w+\.)*\w+(?:\w+\-\w+)*\.\w+)!',
374                        $text,
375                        $wmatches,
376                        PREG_OFFSET_CAPTURE)) {
377             foreach ($wmatches[1] as $wmatch) {
378                 list($target, $pos) = $wmatch;
379                 $this->log(LOG_INFO, "Checking webfinger '$target'");
380                 try {
381                     $oprofile = Ostatus_profile::ensureWebfinger($target);
382                     if ($oprofile && !$oprofile->isGroup()) {
383                         $profile = $oprofile->localProfile();
384                         $matches[$pos] = array('mentioned' => array($profile),
385                                                'text' => $target,
386                                                'position' => $pos,
387                                                'url' => $profile->profileurl);
388                     }
389                 } catch (Exception $e) {
390                     $this->log(LOG_ERR, "Webfinger check failed: " . $e->getMessage());
391                 }
392             }
393         }
394
395         // Profile matches: @example.com/mublog/user
396         if (preg_match_all('!(?:^|\s+)@((?:\w+\.)*\w+(?:\w+\-\w+)*\.\w+(?:/\w+)+)!',
397                        $text,
398                        $wmatches,
399                        PREG_OFFSET_CAPTURE)) {
400             foreach ($wmatches[1] as $wmatch) {
401                 list($target, $pos) = $wmatch;
402                 $schemes = array('http', 'https');
403                 foreach ($schemes as $scheme) {
404                     $url = "$scheme://$target";
405                     $this->log(LOG_INFO, "Checking profile address '$url'");
406                     try {
407                         $oprofile = Ostatus_profile::ensureProfileURL($url);
408                         if ($oprofile && !$oprofile->isGroup()) {
409                             $profile = $oprofile->localProfile();
410                             $matches[$pos] = array('mentioned' => array($profile),
411                                                    'text' => $target,
412                                                    'position' => $pos,
413                                                    'url' => $profile->profileurl);
414                             break;
415                         }
416                     } catch (Exception $e) {
417                         $this->log(LOG_ERR, "Profile check failed: " . $e->getMessage());
418                     }
419                 }
420             }
421         }
422
423         foreach ($mentions as $i => $other) {
424             // If we share a common prefix with a local user, override it!
425             $pos = $other['position'];
426             if (isset($matches[$pos])) {
427                 $mentions[$i] = $matches[$pos];
428                 unset($matches[$pos]);
429             }
430         }
431         foreach ($matches as $mention) {
432             $mentions[] = $mention;
433         }
434
435         return true;
436     }
437
438     /**
439      * Allow remote profile references to be used in commands:
440      *   sub update@status.net
441      *   whois evan@identi.ca
442      *   reply http://identi.ca/evan hey what's up
443      *
444      * @param Command $command
445      * @param string $arg
446      * @param Profile &$profile
447      * @return hook return code
448      */
449     function onStartCommandGetProfile($command, $arg, &$profile)
450     {
451         $oprofile = $this->pullRemoteProfile($arg);
452         if ($oprofile && !$oprofile->isGroup()) {
453             $profile = $oprofile->localProfile();
454             return false;
455         } else {
456             return true;
457         }
458     }
459
460     /**
461      * Allow remote group references to be used in commands:
462      *   join group+statusnet@identi.ca
463      *   join http://identi.ca/group/statusnet
464      *   drop identi.ca/group/statusnet
465      *
466      * @param Command $command
467      * @param string $arg
468      * @param User_group &$group
469      * @return hook return code
470      */
471     function onStartCommandGetGroup($command, $arg, &$group)
472     {
473         $oprofile = $this->pullRemoteProfile($arg);
474         if ($oprofile && $oprofile->isGroup()) {
475             $group = $oprofile->localGroup();
476             return false;
477         } else {
478             return true;
479         }
480     }
481
482     protected function pullRemoteProfile($arg)
483     {
484         $oprofile = null;
485         if (preg_match('!^((?:\w+\.)*\w+@(?:\w+\.)*\w+(?:\w+\-\w+)*\.\w+)$!', $arg)) {
486             // webfinger lookup
487             try {
488                 return Ostatus_profile::ensureWebfinger($arg);
489             } catch (Exception $e) {
490                 common_log(LOG_ERR, 'Webfinger lookup failed for ' .
491                                     $arg . ': ' . $e->getMessage());
492             }
493         }
494
495         // Look for profile URLs, with or without scheme:
496         $urls = array();
497         if (preg_match('!^https?://((?:\w+\.)*\w+(?:\w+\-\w+)*\.\w+(?:/\w+)+)$!', $arg)) {
498             $urls[] = $arg;
499         }
500         if (preg_match('!^((?:\w+\.)*\w+(?:\w+\-\w+)*\.\w+(?:/\w+)+)$!', $arg)) {
501             $schemes = array('http', 'https');
502             foreach ($schemes as $scheme) {
503                 $urls[] = "$scheme://$arg";
504             }
505         }
506
507         foreach ($urls as $url) {
508             try {
509                 return Ostatus_profile::ensureProfileURL($url);
510             } catch (Exception $e) {
511                 common_log(LOG_ERR, 'Profile lookup failed for ' .
512                                     $arg . ': ' . $e->getMessage());
513             }
514         }
515         return null;
516     }
517
518     /**
519      * Make sure necessary tables are filled out.
520      */
521     function onCheckSchema() {
522         $schema = Schema::get();
523         $schema->ensureTable('ostatus_profile', Ostatus_profile::schemaDef());
524         $schema->ensureTable('ostatus_source', Ostatus_source::schemaDef());
525         $schema->ensureTable('feedsub', FeedSub::schemaDef());
526         $schema->ensureTable('hubsub', HubSub::schemaDef());
527         $schema->ensureTable('magicsig', Magicsig::schemaDef());
528         return true;
529     }
530
531     function onEndShowStatusNetStyles($action) {
532         $action->cssLink($this->path('theme/base/css/ostatus.css'));
533         return true;
534     }
535
536     function onEndShowStatusNetScripts($action) {
537         $action->script($this->path('js/ostatus.js'));
538         return true;
539     }
540
541     /**
542      * Override the "from ostatus" bit in notice lists to link to the
543      * original post and show the domain it came from.
544      *
545      * @param Notice in $notice
546      * @param string out &$name
547      * @param string out &$url
548      * @param string out &$title
549      * @return mixed hook return code
550      */
551     function onStartNoticeSourceLink($notice, &$name, &$url, &$title)
552     {
553         if ($notice->source == 'ostatus') {
554             if ($notice->url) {
555                 $bits = parse_url($notice->url);
556                 $domain = $bits['host'];
557                 if (substr($domain, 0, 4) == 'www.') {
558                     $name = substr($domain, 4);
559                 } else {
560                     $name = $domain;
561                 }
562
563                 $url = $notice->url;
564                 // TRANSLATE: %s is a domain.
565                 $title = sprintf(_m("Sent from %s via OStatus"), $domain);
566                 return false;
567             }
568         }
569         return true;
570     }
571
572     /**
573      * Send incoming PuSH feeds for OStatus endpoints in for processing.
574      *
575      * @param FeedSub $feedsub
576      * @param DOMDocument $feed
577      * @return mixed hook return code
578      */
579     function onStartFeedSubReceive($feedsub, $feed)
580     {
581         $oprofile = Ostatus_profile::staticGet('feeduri', $feedsub->uri);
582         if ($oprofile) {
583             $oprofile->processFeed($feed, 'push');
584         } else {
585             common_log(LOG_DEBUG, "No ostatus profile for incoming feed $feedsub->uri");
586         }
587     }
588
589     /**
590      * Tell the FeedSub infrastructure whether we have any active OStatus
591      * usage for the feed; if not it'll be able to garbage-collect the
592      * feed subscription.
593      *
594      * @param FeedSub $feedsub
595      * @param integer $count in/out
596      * @return mixed hook return code
597      */
598     function onFeedSubSubscriberCount($feedsub, &$count)
599     {
600         $oprofile = Ostatus_profile::staticGet('feeduri', $feedsub->uri);
601         if ($oprofile) {
602             $count += $oprofile->subscriberCount();
603         }
604         return true;
605     }
606
607     /**
608      * When about to subscribe to a remote user, start a server-to-server
609      * PuSH subscription if needed. If we can't establish that, abort.
610      *
611      * @fixme If something else aborts later, we could end up with a stray
612      *        PuSH subscription. This is relatively harmless, though.
613      *
614      * @param Profile $subscriber
615      * @param Profile $other
616      *
617      * @return hook return code
618      *
619      * @throws Exception
620      */
621     function onStartSubscribe($subscriber, $other)
622     {
623         $user = User::staticGet('id', $subscriber->id);
624
625         if (empty($user)) {
626             return true;
627         }
628
629         $oprofile = Ostatus_profile::staticGet('profile_id', $other->id);
630
631         if (empty($oprofile)) {
632             return true;
633         }
634
635         if (!$oprofile->subscribe()) {
636             // TRANS: Exception.
637             throw new Exception(_m('Could not set up remote subscription.'));
638         }
639     }
640
641     /**
642      * Having established a remote subscription, send a notification to the
643      * remote OStatus profile's endpoint.
644      *
645      * @param Profile $subscriber
646      * @param Profile $other
647      *
648      * @return hook return code
649      *
650      * @throws Exception
651      */
652     function onEndSubscribe($subscriber, $other)
653     {
654         $user = User::staticGet('id', $subscriber->id);
655
656         if (empty($user)) {
657             return true;
658         }
659
660         $oprofile = Ostatus_profile::staticGet('profile_id', $other->id);
661
662         if (empty($oprofile)) {
663             return true;
664         }
665
666         $sub = Subscription::pkeyGet(array('subscriber' => $subscriber->id,
667                                            'subscribed' => $other->id));
668
669         $act = $sub->asActivity();
670
671         $oprofile->notifyActivity($act, $subscriber);
672
673         return true;
674     }
675
676     /**
677      * Notify remote server and garbage collect unused feeds on unsubscribe.
678      * @fixme send these operations to background queues
679      *
680      * @param User $user
681      * @param Profile $other
682      * @return hook return value
683      */
684     function onEndUnsubscribe($profile, $other)
685     {
686         $user = User::staticGet('id', $profile->id);
687
688         if (empty($user)) {
689             return true;
690         }
691
692         $oprofile = Ostatus_profile::staticGet('profile_id', $other->id);
693
694         if (empty($oprofile)) {
695             return true;
696         }
697
698         // Drop the PuSH subscription if there are no other subscribers.
699         $oprofile->garbageCollect();
700
701         $act = new Activity();
702
703         $act->verb = ActivityVerb::UNFOLLOW;
704
705         $act->id   = TagURI::mint('unfollow:%d:%d:%s',
706                                   $profile->id,
707                                   $other->id,
708                                   common_date_iso8601(time()));
709
710         $act->time    = time();
711         $act->title   = _m('Unfollow');
712         // TRANS: Success message for unsubscribe from user attempt through OStatus.
713         // TRANS: %1$s is the unsubscriber's name, %2$s is the unsubscribed user's name.
714         $act->content = sprintf(_m('%1$s stopped following %2$s.'),
715                                $profile->getBestName(),
716                                $other->getBestName());
717
718         $act->actor   = ActivityObject::fromProfile($profile);
719         $act->object  = ActivityObject::fromProfile($other);
720
721         $oprofile->notifyActivity($act, $profile);
722
723         return true;
724     }
725
726     /**
727      * When one of our local users tries to join a remote group,
728      * notify the remote server. If the notification is rejected,
729      * deny the join.
730      *
731      * @param User_group $group
732      * @param User $user
733      *
734      * @return mixed hook return value
735      */
736
737     function onStartJoinGroup($group, $user)
738     {
739         $oprofile = Ostatus_profile::staticGet('group_id', $group->id);
740         if ($oprofile) {
741             if (!$oprofile->subscribe()) {
742                 throw new Exception(_m('Could not set up remote group membership.'));
743             }
744
745             // NOTE: we don't use Group_member::asActivity() since that record
746             // has not yet been created.
747
748             $member = Profile::staticGet($user->id);
749
750             $act = new Activity();
751             $act->id = TagURI::mint('join:%d:%d:%s',
752                                     $member->id,
753                                     $group->id,
754                                     common_date_iso8601(time()));
755
756             $act->actor = ActivityObject::fromProfile($member);
757             $act->verb = ActivityVerb::JOIN;
758             $act->object = $oprofile->asActivityObject();
759
760             $act->time = time();
761             $act->title = _m("Join");
762             // TRANS: Success message for subscribe to group attempt through OStatus.
763             // TRANS: %1$s is the member name, %2$s is the subscribed group's name.
764             $act->content = sprintf(_m('%1$s has joined group %2$s.'),
765                                     $member->getBestName(),
766                                     $oprofile->getBestName());
767
768             if ($oprofile->notifyActivity($act, $member)) {
769                 return true;
770             } else {
771                 $oprofile->garbageCollect();
772                 // TRANS: Exception.
773                 throw new Exception(_m("Failed joining remote group."));
774             }
775         }
776     }
777
778     /**
779      * When one of our local users leaves a remote group, notify the remote
780      * server.
781      *
782      * @fixme Might be good to schedule a resend of the leave notification
783      * if it failed due to a transitory error. We've canceled the local
784      * membership already anyway, but if the remote server comes back up
785      * it'll be left with a stray membership record.
786      *
787      * @param User_group $group
788      * @param User $user
789      *
790      * @return mixed hook return value
791      */
792
793     function onEndLeaveGroup($group, $user)
794     {
795         $oprofile = Ostatus_profile::staticGet('group_id', $group->id);
796         if ($oprofile) {
797             // Drop the PuSH subscription if there are no other subscribers.
798             $oprofile->garbageCollect();
799
800             $member = Profile::staticGet($user->id);
801
802             $act = new Activity();
803             $act->id = TagURI::mint('leave:%d:%d:%s',
804                                     $member->id,
805                                     $group->id,
806                                     common_date_iso8601(time()));
807
808             $act->actor = ActivityObject::fromProfile($member);
809             $act->verb = ActivityVerb::LEAVE;
810             $act->object = $oprofile->asActivityObject();
811
812             $act->time = time();
813             $act->title = _m("Leave");
814             // TRANS: Success message for unsubscribe from group attempt through OStatus.
815             // TRANS: %1$s is the member name, %2$s is the unsubscribed group's name.
816             $act->content = sprintf(_m('%1$s has left group %2$s.'),
817                                     $member->getBestName(),
818                                     $oprofile->getBestName());
819
820             $oprofile->notifyActivity($act, $member);
821         }
822     }
823
824     /**
825      * When one of our local users tries to subscribe to a remote peopletag,
826      * notify the remote server. If the notification is rejected,
827      * deny the subscription.
828      *
829      * @param Profile_list $peopletag
830      * @param User         $user
831      *
832      * @return mixed hook return value
833      */
834
835     function onStartSubscribePeopletag($peopletag, $user)
836     {
837         $oprofile = Ostatus_profile::staticGet('peopletag_id', $peopletag->id);
838         if ($oprofile) {
839             if (!$oprofile->subscribe()) {
840                 throw new Exception(_m('Could not set up remote peopletag subscription.'));
841             }
842
843             $sub = $user->getProfile();
844             $tagger = Profile::staticGet($peopletag->tagger);
845
846             $act = new Activity();
847             $act->id = TagURI::mint('subscribe_peopletag:%d:%d:%s',
848                                     $sub->id,
849                                     $peopletag->id,
850                                     common_date_iso8601(time()));
851
852             $act->actor = ActivityObject::fromProfile($sub);
853             $act->verb = ActivityVerb::FOLLOW;
854             $act->object = $oprofile->asActivityObject();
855
856             $act->time = time();
857             $act->title = _m("Follow list");
858             $act->content = sprintf(_m("%s is now following people tagged %s by %s."),
859                                     $sub->getBestName(),
860                                     $oprofile->getBestName(),
861                                     $tagger->getBestName());
862
863             if ($oprofile->notifyActivity($act, $sub)) {
864                 return true;
865             } else {
866                 $oprofile->garbageCollect();
867                 throw new Exception(_m("Failed subscribing to remote peopletag."));
868             }
869         }
870     }
871
872     /**
873      * When one of our local users unsubscribes to a remote peopletag, notify the remote
874      * server.
875      *
876      * @param Profile_list $peopletag
877      * @param User         $user
878      *
879      * @return mixed hook return value
880      */
881
882     function onEndUnsubscribePeopletag($peopletag, $user)
883     {
884         $oprofile = Ostatus_profile::staticGet('peopletag_id', $peopletag->id);
885         if ($oprofile) {
886             // Drop the PuSH subscription if there are no other subscribers.
887             $oprofile->garbageCollect();
888
889             $sub = Profile::staticGet($user->id);
890             $tagger = Profile::staticGet($peopletag->tagger);
891
892             $act = new Activity();
893             $act->id = TagURI::mint('unsubscribe_peopletag:%d:%d:%s',
894                                     $sub->id,
895                                     $peopletag->id,
896                                     common_date_iso8601(time()));
897
898             $act->actor = ActivityObject::fromProfile($member);
899             $act->verb = ActivityVerb::UNFOLLOW;
900             $act->object = $oprofile->asActivityObject();
901
902             $act->time = time();
903             $act->title = _m("Unfollow peopletag");
904             $act->content = sprintf(_m("%s stopped following the list %s by %s."),
905                                     $sub->getBestName(),
906                                     $oprofile->getBestName(),
907                                     $tagger->getBestName());
908
909             $oprofile->notifyActivity($act, $user);
910         }
911     }
912
913     /**
914      * Notify remote users when their notices get favorited.
915      *
916      * @param Profile or User $profile of local user doing the faving
917      * @param Notice $notice being favored
918      * @return hook return value
919      */
920     function onEndFavorNotice(Profile $profile, Notice $notice)
921     {
922         $user = User::staticGet('id', $profile->id);
923
924         if (empty($user)) {
925             return true;
926         }
927
928         $oprofile = Ostatus_profile::staticGet('profile_id', $notice->profile_id);
929
930         if (empty($oprofile)) {
931             return true;
932         }
933
934         $fav = Fave::pkeyGet(array('user_id' => $user->id,
935                                    'notice_id' => $notice->id));
936
937         if (empty($fav)) {
938             // That's weird.
939             return true;
940         }
941
942         $act = $fav->asActivity();
943
944         $oprofile->notifyActivity($act, $profile);
945
946         return true;
947     }
948
949     function onEndTagProfile($ptag)
950     {
951         $oprofile = Ostatus_profile::staticGet('profile_id', $ptag->tagged);
952
953         if (empty($oprofile)) {
954             return true;
955         }
956
957         $plist = $ptag->getMeta();
958         if ($plist->private) {
959             return true;
960         }
961
962         $act = new Activity();
963
964         $tagger = $plist->getTagger();
965         $tagged = Profile::staticGet('id', $ptag->tagged);
966
967         $act->verb = ActivityVerb::TAG;
968         $act->id   = TagURI::mint('tag_profile:%d:%d:%s',
969                                   $plist->tagger, $plist->id,
970                                   common_date_iso8601(time()));
971         $act->time = time();
972         $act->title = _("Tag");
973         $act->content = sprintf(_("%s tagged %s in the list %s"),
974                                 $tagger->getBestName(),
975                                 $tagged->getBestName(),
976                                 $plist->getBestName());
977
978         $act->actor  = ActivityObject::fromProfile($tagger);
979         $act->objects = array(ActivityObject::fromProfile($tagged));
980         $act->target = ActivityObject::fromPeopletag($plist);
981
982         $oprofile->notifyActivity($act, $tagger);
983
984         // initiate a PuSH subscription for the person being tagged
985         if (!$oprofile->subscribe()) {
986             throw new Exception(sprintf(_('Could not complete subscription to remote '.
987                                           'profile\'s feed. Tag %s could not be saved.'), $ptag->tag));
988             return false;
989         }
990         return true;
991     }
992
993     function onEndUntagProfile($ptag)
994     {
995         $oprofile = Ostatus_profile::staticGet('profile_id', $ptag->tagged);
996
997         if (empty($oprofile)) {
998             return true;
999         }
1000
1001         $plist = $ptag->getMeta();
1002         if ($plist->private) {
1003             return true;
1004         }
1005
1006         $act = new Activity();
1007
1008         $tagger = $plist->getTagger();
1009         $tagged = Profile::staticGet('id', $ptag->tagged);
1010
1011         $act->verb = ActivityVerb::UNTAG;
1012         $act->id   = TagURI::mint('untag_profile:%d:%d:%s',
1013                                   $plist->tagger, $plist->id,
1014                                   common_date_iso8601(time()));
1015         $act->time = time();
1016         $act->title = _("Untag");
1017         $act->content = sprintf(_("%s untagged %s from the list %s"),
1018                                 $tagger->getBestName(),
1019                                 $tagged->getBestName(),
1020                                 $plist->getBestName());
1021
1022         $act->actor  = ActivityObject::fromProfile($tagger);
1023         $act->objects = array(ActivityObject::fromProfile($tagged));
1024         $act->target = ActivityObject::fromPeopletag($plist);
1025
1026         $oprofile->notifyActivity($act, $tagger);
1027
1028         // unsubscribe to PuSH feed if no more required
1029         $oprofile->garbageCollect();
1030
1031         return true;
1032     }
1033
1034     /**
1035      * Notify remote users when their notices get de-favorited.
1036      *
1037      * @param Profile $profile Profile person doing the de-faving
1038      * @param Notice  $notice  Notice being favored
1039      *
1040      * @return hook return value
1041      */
1042
1043     function onEndDisfavorNotice(Profile $profile, Notice $notice)
1044     {
1045         $user = User::staticGet('id', $profile->id);
1046
1047         if (empty($user)) {
1048             return true;
1049         }
1050
1051         $oprofile = Ostatus_profile::staticGet('profile_id', $notice->profile_id);
1052
1053         if (empty($oprofile)) {
1054             return true;
1055         }
1056
1057         $act = new Activity();
1058
1059         $act->verb = ActivityVerb::UNFAVORITE;
1060         $act->id   = TagURI::mint('disfavor:%d:%d:%s',
1061                                   $profile->id,
1062                                   $notice->id,
1063                                   common_date_iso8601(time()));
1064         $act->time    = time();
1065         $act->title   = _m('Disfavor');
1066         // TRANS: Success message for remove a favorite notice through OStatus.
1067         // TRANS: %1$s is the unfavoring user's name, %2$s is URI to the no longer favored notice.
1068         $act->content = sprintf(_m('%1$s marked notice %2$s as no longer a favorite.'),
1069                                $profile->getBestName(),
1070                                $notice->uri);
1071
1072         $act->actor   = ActivityObject::fromProfile($profile);
1073         $act->object  = ActivityObject::fromNotice($notice);
1074
1075         $oprofile->notifyActivity($act, $profile);
1076
1077         return true;
1078     }
1079
1080     function onStartGetProfileUri($profile, &$uri)
1081     {
1082         $oprofile = Ostatus_profile::staticGet('profile_id', $profile->id);
1083         if (!empty($oprofile)) {
1084             $uri = $oprofile->uri;
1085             return false;
1086         }
1087         return true;
1088     }
1089
1090     function onStartUserGroupHomeUrl($group, &$url)
1091     {
1092         return $this->onStartUserGroupPermalink($group, $url);
1093     }
1094
1095     function onStartUserGroupPermalink($group, &$url)
1096     {
1097         $oprofile = Ostatus_profile::staticGet('group_id', $group->id);
1098         if ($oprofile) {
1099             // @fixme this should probably be in the user_group table
1100             // @fixme this uri not guaranteed to be a profile page
1101             $url = $oprofile->uri;
1102             return false;
1103         }
1104     }
1105
1106     function onStartShowSubscriptionsContent($action)
1107     {
1108         $this->showEntityRemoteSubscribe($action);
1109
1110         return true;
1111     }
1112
1113     function onStartShowUserGroupsContent($action)
1114     {
1115         $this->showEntityRemoteSubscribe($action, 'ostatusgroup');
1116
1117         return true;
1118     }
1119
1120     function onEndShowSubscriptionsMiniList($action)
1121     {
1122         $this->showEntityRemoteSubscribe($action);
1123
1124         return true;
1125     }
1126
1127     function onEndShowGroupsMiniList($action)
1128     {
1129         $this->showEntityRemoteSubscribe($action, 'ostatusgroup');
1130
1131         return true;
1132     }
1133
1134     function showEntityRemoteSubscribe($action, $target='ostatussub')
1135     {
1136         $user = common_current_user();
1137         if ($user && ($user->id == $action->profile->id)) {
1138             $action->elementStart('div', 'entity_actions');
1139             $action->elementStart('p', array('id' => 'entity_remote_subscribe',
1140                                              'class' => 'entity_subscribe'));
1141             $action->element('a', array('href' => common_local_url($target),
1142                                         'class' => 'entity_remote_subscribe'),
1143                                 // TRANS: Link text for link to remote subscribe.
1144                                 _m('Remote'));
1145             $action->elementEnd('p');
1146             $action->elementEnd('div');
1147         }
1148     }
1149
1150     /**
1151      * Ping remote profiles with updates to this profile.
1152      * Salmon pings are queued for background processing.
1153      */
1154     function onEndBroadcastProfile(Profile $profile)
1155     {
1156         $user = User::staticGet('id', $profile->id);
1157
1158         // Find foreign accounts I'm subscribed to that support Salmon pings.
1159         //
1160         // @fixme we could run updates through the PuSH feed too,
1161         // in which case we can skip Salmon pings to folks who
1162         // are also subscribed to me.
1163         $sql = "SELECT * FROM ostatus_profile " .
1164                "WHERE profile_id IN " .
1165                "(SELECT subscribed FROM subscription WHERE subscriber=%d) " .
1166                "OR group_id IN " .
1167                "(SELECT group_id FROM group_member WHERE profile_id=%d)";
1168         $oprofile = new Ostatus_profile();
1169         $oprofile->query(sprintf($sql, $profile->id, $profile->id));
1170
1171         if ($oprofile->N == 0) {
1172             common_log(LOG_DEBUG, "No OStatus remote subscribees for $profile->nickname");
1173             return true;
1174         }
1175
1176         $act = new Activity();
1177
1178         $act->verb = ActivityVerb::UPDATE_PROFILE;
1179         $act->id   = TagURI::mint('update-profile:%d:%s',
1180                                   $profile->id,
1181                                   common_date_iso8601(time()));
1182         $act->time    = time();
1183         // TRANS: Title for activity.
1184         $act->title   = _m("Profile update");
1185         // TRANS: Ping text for remote profile update through OStatus.
1186         // TRANS: %s is user that updated their profile.
1187         $act->content = sprintf(_m("%s has updated their profile page."),
1188                                $profile->getBestName());
1189
1190         $act->actor   = ActivityObject::fromProfile($profile);
1191         $act->object  = $act->actor;
1192
1193         while ($oprofile->fetch()) {
1194             $oprofile->notifyDeferred($act, $profile);
1195         }
1196
1197         return true;
1198     }
1199
1200     function onStartProfileListItemActionElements($item, $profile=null)
1201     {
1202         if (!common_logged_in()) {
1203
1204             $profileUser = User::staticGet('id', $item->profile->id);
1205
1206             if (!empty($profileUser)) {
1207
1208                 if ($item instanceof Action) {
1209                     $output = $item;
1210                     $profile = $item->profile;
1211                 } else {
1212                     $output = $item->out;
1213                 }
1214
1215                 // Add an OStatus subscribe
1216                 $output->elementStart('li', 'entity_subscribe');
1217                 $url = common_local_url('ostatusinit',
1218                                         array('nickname' => $profileUser->nickname));
1219                 $output->element('a', array('href' => $url,
1220                                             'class' => 'entity_remote_subscribe'),
1221                                   // TRANS: Link text for a user to subscribe to an OStatus user.
1222                                  _m('Subscribe'));
1223                 $output->elementEnd('li');
1224
1225                 $output->elementStart('li', 'entity_tag');
1226                 $url = common_local_url('ostatustag',
1227                                         array('nickname' => $profileUser->nickname));
1228                 $output->element('a', array('href' => $url,
1229                                             'class' => 'entity_remote_tag'),
1230                                  _m('Tag'));
1231                 $output->elementEnd('li');
1232             }
1233         }
1234
1235         return true;
1236     }
1237
1238     function onPluginVersion(&$versions)
1239     {
1240         $versions[] = array('name' => 'OStatus',
1241                             'version' => STATUSNET_VERSION,
1242                             'author' => 'Evan Prodromou, James Walker, Brion Vibber, Zach Copley',
1243                             'homepage' => 'http://status.net/wiki/Plugin:OStatus',
1244                             // TRANS: Plugin description.
1245                             'rawdescription' => _m('Follow people across social networks that implement '.
1246                                '<a href="http://ostatus.org/">OStatus</a>.'));
1247
1248         return true;
1249     }
1250
1251     /**
1252      * Utility function to check if the given URI is a canonical group profile
1253      * page, and if so return the ID number.
1254      *
1255      * @param string $url
1256      * @return mixed int or false
1257      */
1258     public static function localGroupFromUrl($url)
1259     {
1260         $group = User_group::staticGet('uri', $url);
1261         if ($group) {
1262             $local = Local_group::staticGet('group_id', $group->id);
1263             if ($local) {
1264                 return $group->id;
1265             }
1266         } else {
1267             // To find local groups which haven't had their uri fields filled out...
1268             // If the domain has changed since a subscriber got the URI, it'll
1269             // be broken.
1270             $template = common_local_url('groupbyid', array('id' => '31337'));
1271             $template = preg_quote($template, '/');
1272             $template = str_replace('31337', '(\d+)', $template);
1273             if (preg_match("/$template/", $url, $matches)) {
1274                 return intval($matches[1]);
1275             }
1276         }
1277         return false;
1278     }
1279
1280     public function onStartProfileGetAtomFeed($profile, &$feed)
1281     {
1282         $oprofile = Ostatus_profile::staticGet('profile_id', $profile->id);
1283
1284         if (empty($oprofile)) {
1285             return true;
1286         }
1287
1288         $feed = $oprofile->feeduri;
1289         return false;
1290     }
1291
1292     function onStartGetProfileFromURI($uri, &$profile)
1293     {
1294         // Don't want to do Web-based discovery on our own server,
1295         // so we check locally first.
1296
1297         $user = User::staticGet('uri', $uri);
1298         
1299         if (!empty($user)) {
1300             $profile = $user->getProfile();
1301             return false;
1302         }
1303
1304         // Now, check remotely
1305
1306         $oprofile = Ostatus_profile::ensureProfileURI($uri);
1307
1308         if (!empty($oprofile)) {
1309             $profile = $oprofile->localProfile();
1310             return false;
1311         }
1312
1313         // Still not a hit, so give up.
1314
1315         return true;
1316     }
1317
1318     function onEndXrdActionLinks(&$xrd, $user)
1319     {
1320         $xrd->links[] = array('rel' => Discovery::UPDATESFROM,
1321                               'href' => common_local_url('ApiTimelineUser',
1322                                                          array('id' => $user->id,
1323                                                                'format' => 'atom')),
1324                               'type' => 'application/atom+xml');
1325         
1326                     // Salmon
1327         $salmon_url = common_local_url('usersalmon',
1328                                        array('id' => $user->id));
1329
1330         $xrd->links[] = array('rel' => Salmon::REL_SALMON,
1331                               'href' => $salmon_url);
1332         // XXX : Deprecated - to be removed.
1333         $xrd->links[] = array('rel' => Salmon::NS_REPLIES,
1334                               'href' => $salmon_url);
1335
1336         $xrd->links[] = array('rel' => Salmon::NS_MENTIONS,
1337                               'href' => $salmon_url);
1338
1339         // Get this user's keypair
1340         $magickey = Magicsig::staticGet('user_id', $user->id);
1341         if (!$magickey) {
1342             // No keypair yet, let's generate one.
1343             $magickey = new Magicsig();
1344             $magickey->generate($user->id);
1345         }
1346
1347         $xrd->links[] = array('rel' => Magicsig::PUBLICKEYREL,
1348                               'href' => 'data:application/magic-public-key,'. $magickey->toString(false));
1349
1350         // TODO - finalize where the redirect should go on the publisher
1351         $url = common_local_url('ostatussub') . '?profile={uri}';
1352         $xrd->links[] = array('rel' => 'http://ostatus.org/schema/1.0/subscribe',
1353                               'template' => $url );
1354         
1355         return true;
1356     }
1357 }