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