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