]> git.mxchange.org Git - quix0rs-gnu-social.git/blob - plugins/OStatus/OStatusPlugin.php
merge 0.9.x into 1.0.x
[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. %s is a domain name.
579                 $title = sprintf(_m('Sent from %s via OStatus'), $domain);
580                 return false;
581             }
582         }
583     return true;
584     }
585
586     /**
587      * Send incoming PuSH feeds for OStatus endpoints in for processing.
588      *
589      * @param FeedSub $feedsub
590      * @param DOMDocument $feed
591      * @return mixed hook return code
592      */
593     function onStartFeedSubReceive($feedsub, $feed)
594     {
595         $oprofile = Ostatus_profile::staticGet('feeduri', $feedsub->uri);
596         if ($oprofile) {
597             $oprofile->processFeed($feed, 'push');
598         } else {
599             common_log(LOG_DEBUG, "No ostatus profile for incoming feed $feedsub->uri");
600         }
601     }
602
603     /**
604      * Tell the FeedSub infrastructure whether we have any active OStatus
605      * usage for the feed; if not it'll be able to garbage-collect the
606      * feed subscription.
607      *
608      * @param FeedSub $feedsub
609      * @param integer $count in/out
610      * @return mixed hook return code
611      */
612     function onFeedSubSubscriberCount($feedsub, &$count)
613     {
614         $oprofile = Ostatus_profile::staticGet('feeduri', $feedsub->uri);
615         if ($oprofile) {
616             $count += $oprofile->subscriberCount();
617         }
618         return true;
619     }
620
621     /**
622      * When about to subscribe to a remote user, start a server-to-server
623      * PuSH subscription if needed. If we can't establish that, abort.
624      *
625      * @fixme If something else aborts later, we could end up with a stray
626      *        PuSH subscription. This is relatively harmless, though.
627      *
628      * @param Profile $subscriber
629      * @param Profile $other
630      *
631      * @return hook return code
632      *
633      * @throws Exception
634      */
635     function onStartSubscribe($subscriber, $other)
636     {
637         $user = User::staticGet('id', $subscriber->id);
638
639         if (empty($user)) {
640             return true;
641         }
642
643         $oprofile = Ostatus_profile::staticGet('profile_id', $other->id);
644
645         if (empty($oprofile)) {
646             return true;
647         }
648
649         if (!$oprofile->subscribe()) {
650             // TRANS: Exception thrown when setup of remote subscription fails.
651             throw new Exception(_m('Could not set up remote subscription.'));
652         }
653     }
654
655     /**
656      * Having established a remote subscription, send a notification to the
657      * remote OStatus profile's endpoint.
658      *
659      * @param Profile $subscriber
660      * @param Profile $other
661      *
662      * @return hook return code
663      *
664      * @throws Exception
665      */
666     function onEndSubscribe($subscriber, $other)
667     {
668         $user = User::staticGet('id', $subscriber->id);
669
670         if (empty($user)) {
671             return true;
672         }
673
674         $oprofile = Ostatus_profile::staticGet('profile_id', $other->id);
675
676         if (empty($oprofile)) {
677             return true;
678         }
679
680         $sub = Subscription::pkeyGet(array('subscriber' => $subscriber->id,
681                                            'subscribed' => $other->id));
682
683         $act = $sub->asActivity();
684
685         $oprofile->notifyActivity($act, $subscriber);
686
687         return true;
688     }
689
690     /**
691      * Notify remote server and garbage collect unused feeds on unsubscribe.
692      * @todo FIXME: Send these operations to background queues
693      *
694      * @param User $user
695      * @param Profile $other
696      * @return hook return value
697      */
698     function onEndUnsubscribe($profile, $other)
699     {
700         $user = User::staticGet('id', $profile->id);
701
702         if (empty($user)) {
703             return true;
704         }
705
706         $oprofile = Ostatus_profile::staticGet('profile_id', $other->id);
707
708         if (empty($oprofile)) {
709             return true;
710         }
711
712         // Drop the PuSH subscription if there are no other subscribers.
713         $oprofile->garbageCollect();
714
715         $act = new Activity();
716
717         $act->verb = ActivityVerb::UNFOLLOW;
718
719         $act->id   = TagURI::mint('unfollow:%d:%d:%s',
720                                   $profile->id,
721                                   $other->id,
722                                   common_date_iso8601(time()));
723
724         $act->time    = time();
725         // TRANS: Title for unfollowing a remote profile.
726         $act->title   = _m('TITLE','Unfollow');
727         // TRANS: Success message for unsubscribe from user attempt through OStatus.
728         // TRANS: %1$s is the unsubscriber's name, %2$s is the unsubscribed user's name.
729         $act->content = sprintf(_m('%1$s stopped following %2$s.'),
730                                $profile->getBestName(),
731                                $other->getBestName());
732
733         $act->actor   = ActivityObject::fromProfile($profile);
734         $act->object  = ActivityObject::fromProfile($other);
735
736         $oprofile->notifyActivity($act, $profile);
737
738         return true;
739     }
740
741     /**
742      * When one of our local users tries to join a remote group,
743      * notify the remote server. If the notification is rejected,
744      * deny the join.
745      *
746      * @param User_group $group
747      * @param User $user
748      *
749      * @return mixed hook return value
750      */
751     function onStartJoinGroup($group, $user)
752     {
753         $oprofile = Ostatus_profile::staticGet('group_id', $group->id);
754         if ($oprofile) {
755             if (!$oprofile->subscribe()) {
756                 // TRANS: Exception thrown when setup of remote group membership fails.
757                 throw new Exception(_m('Could not set up remote group membership.'));
758             }
759
760             // NOTE: we don't use Group_member::asActivity() since that record
761             // has not yet been created.
762
763             $member = Profile::staticGet($user->id);
764
765             $act = new Activity();
766             $act->id = TagURI::mint('join:%d:%d:%s',
767                                     $member->id,
768                                     $group->id,
769                                     common_date_iso8601(time()));
770
771             $act->actor = ActivityObject::fromProfile($member);
772             $act->verb = ActivityVerb::JOIN;
773             $act->object = $oprofile->asActivityObject();
774
775             $act->time = time();
776             // TRANS: Title for joining a remote groep.
777             $act->title = _m('TITLE','Join');
778             // TRANS: Success message for subscribe to group attempt through OStatus.
779             // TRANS: %1$s is the member name, %2$s is the subscribed group's name.
780             $act->content = sprintf(_m('%1$s has joined group %2$s.'),
781                                     $member->getBestName(),
782                                     $oprofile->getBestName());
783
784             if ($oprofile->notifyActivity($act, $member)) {
785                 return true;
786             } else {
787                 $oprofile->garbageCollect();
788                 // TRANS: Exception thrown when joining a remote group fails.
789                 throw new Exception(_m('Failed joining remote group.'));
790             }
791         }
792     }
793
794     /**
795      * When one of our local users leaves a remote group, notify the remote
796      * server.
797      *
798      * @fixme Might be good to schedule a resend of the leave notification
799      * if it failed due to a transitory error. We've canceled the local
800      * membership already anyway, but if the remote server comes back up
801      * it'll be left with a stray membership record.
802      *
803      * @param User_group $group
804      * @param Profile $user
805      *
806      * @return mixed hook return value
807      */
808     function onEndLeaveGroup($group, $user)
809     {
810         $oprofile = Ostatus_profile::staticGet('group_id', $group->id);
811         if ($oprofile) {
812             // Drop the PuSH subscription if there are no other subscribers.
813             $oprofile->garbageCollect();
814
815             $member = Profile::staticGet($user->id);
816
817             $act = new Activity();
818             $act->id = TagURI::mint('leave:%d:%d:%s',
819                                     $member->id,
820                                     $group->id,
821                                     common_date_iso8601(time()));
822
823             $act->actor = ActivityObject::fromProfile($member);
824             $act->verb = ActivityVerb::LEAVE;
825             $act->object = $oprofile->asActivityObject();
826
827             $act->time = time();
828             // TRANS: Title for leaving a remote group.
829             $act->title = _m('TITLE','Leave');
830             // TRANS: Success message for unsubscribe from group attempt through OStatus.
831             // TRANS: %1$s is the member name, %2$s is the unsubscribed group's name.
832             $act->content = sprintf(_m('%1$s has left group %2$s.'),
833                                     $member->getBestName(),
834                                     $oprofile->getBestName());
835
836             $oprofile->notifyActivity($act, $member);
837         }
838     }
839
840     /**
841      * When one of our local users tries to subscribe to a remote peopletag,
842      * notify the remote server. If the notification is rejected,
843      * deny the subscription.
844      *
845      * @param Profile_list $peopletag
846      * @param User         $user
847      *
848      * @return mixed hook return value
849      */
850
851     function onStartSubscribePeopletag($peopletag, $user)
852     {
853         $oprofile = Ostatus_profile::staticGet('peopletag_id', $peopletag->id);
854         if ($oprofile) {
855             if (!$oprofile->subscribe()) {
856                 // TRANS: Exception thrown when setup of remote list subscription fails.
857                 throw new Exception(_m('Could not set up remote list subscription.'));
858             }
859
860             $sub = $user->getProfile();
861             $tagger = Profile::staticGet($peopletag->tagger);
862
863             $act = new Activity();
864             $act->id = TagURI::mint('subscribe_peopletag:%d:%d:%s',
865                                     $sub->id,
866                                     $peopletag->id,
867                                     common_date_iso8601(time()));
868
869             $act->actor = ActivityObject::fromProfile($sub);
870             $act->verb = ActivityVerb::FOLLOW;
871             $act->object = $oprofile->asActivityObject();
872
873             $act->time = time();
874             // TRANS: Title for following a remote list.
875             $act->title = _m('TITLE','Follow list');
876             // TRANS: Success message for remote list follow through OStatus.
877             // TRANS: %1$s is the subscriber name, %2$s is the list, %3$s is the tagger's name.
878             $act->content = sprintf(_m('%1$s is now following people listed in %2$s by %3$s.'),
879                                     $sub->getBestName(),
880                                     $oprofile->getBestName(),
881                                     $tagger->getBestName());
882
883             if ($oprofile->notifyActivity($act, $sub)) {
884                 return true;
885             } else {
886                 $oprofile->garbageCollect();
887                 // TRANS: Exception thrown when subscription to remote list fails.
888                 throw new Exception(_m('Failed subscribing to remote list.'));
889             }
890         }
891     }
892
893     /**
894      * When one of our local users unsubscribes to a remote peopletag, notify the remote
895      * server.
896      *
897      * @param Profile_list $peopletag
898      * @param User         $user
899      *
900      * @return mixed hook return value
901      */
902
903     function onEndUnsubscribePeopletag($peopletag, $user)
904     {
905         $oprofile = Ostatus_profile::staticGet('peopletag_id', $peopletag->id);
906         if ($oprofile) {
907             // Drop the PuSH subscription if there are no other subscribers.
908             $oprofile->garbageCollect();
909
910             $sub = Profile::staticGet($user->id);
911             $tagger = Profile::staticGet($peopletag->tagger);
912
913             $act = new Activity();
914             $act->id = TagURI::mint('unsubscribe_peopletag:%d:%d:%s',
915                                     $sub->id,
916                                     $peopletag->id,
917                                     common_date_iso8601(time()));
918
919             $act->actor = ActivityObject::fromProfile($member);
920             $act->verb = ActivityVerb::UNFOLLOW;
921             $act->object = $oprofile->asActivityObject();
922
923             $act->time = time();
924             // TRANS: Title for unfollowing a remote list.
925             $act->title = _m('Unfollow list');
926             // TRANS: Success message for remote list unfollow through OStatus.
927             // TRANS: %1$s is the subscriber name, %2$s is the list, %3$s is the tagger's name.
928             $act->content = sprintf(_m('%1$s stopped following the list %2$s by %3$s.'),
929                                     $sub->getBestName(),
930                                     $oprofile->getBestName(),
931                                     $tagger->getBestName());
932
933             $oprofile->notifyActivity($act, $user);
934         }
935     }
936
937     /**
938      * Notify remote users when their notices get favorited.
939      *
940      * @param Profile or User $profile of local user doing the faving
941      * @param Notice $notice being favored
942      * @return hook return value
943      */
944     function onEndFavorNotice(Profile $profile, Notice $notice)
945     {
946         $user = User::staticGet('id', $profile->id);
947
948         if (empty($user)) {
949             return true;
950         }
951
952         $oprofile = Ostatus_profile::staticGet('profile_id', $notice->profile_id);
953
954         if (empty($oprofile)) {
955             return true;
956         }
957
958         $fav = Fave::pkeyGet(array('user_id' => $user->id,
959                                    'notice_id' => $notice->id));
960
961         if (empty($fav)) {
962             // That's weird.
963             return true;
964         }
965
966         $act = $fav->asActivity();
967
968         $oprofile->notifyActivity($act, $profile);
969
970         return true;
971     }
972
973     /**
974      * Notify remote user it has got a new people tag
975      *   - tag verb is queued
976      *   - the subscription is done immediately if not present
977      *
978      * @param Profile_tag $ptag the people tag that was created
979      * @return hook return value
980      */
981     function onEndTagProfile($ptag)
982     {
983         $oprofile = Ostatus_profile::staticGet('profile_id', $ptag->tagged);
984
985         if (empty($oprofile)) {
986             return true;
987         }
988
989         $plist = $ptag->getMeta();
990         if ($plist->private) {
991             return true;
992         }
993
994         $act = new Activity();
995
996         $tagger = $plist->getTagger();
997         $tagged = Profile::staticGet('id', $ptag->tagged);
998
999         $act->verb = ActivityVerb::TAG;
1000         $act->id   = TagURI::mint('tag_profile:%d:%d:%s',
1001                                   $plist->tagger, $plist->id,
1002                                   common_date_iso8601(time()));
1003         $act->time = time();
1004         // TRANS: Title for listing a remote profile.
1005         $act->title = _m('TITLE','List');
1006         // TRANS: Success message for remote list addition through OStatus.
1007         // TRANS: %1$s is the list creator's name, %2$s is the added list member, %3$s is the list name.
1008         $act->content = sprintf(_m('%1$s listed %2$s in the list %3$s.'),
1009                                 $tagger->getBestName(),
1010                                 $tagged->getBestName(),
1011                                 $plist->getBestName());
1012
1013         $act->actor  = ActivityObject::fromProfile($tagger);
1014         $act->objects = array(ActivityObject::fromProfile($tagged));
1015         $act->target = ActivityObject::fromPeopletag($plist);
1016
1017         $oprofile->notifyDeferred($act, $tagger);
1018
1019         // initiate a PuSH subscription for the person being tagged
1020         if (!$oprofile->subscribe()) {
1021             // TRANS: Exception thrown when subscribing to a remote list fails.
1022             throw new Exception(sprintf(_m('Could not complete subscription to remote '.
1023                                           'profile\'s feed. List %s could not be saved.'), $ptag->tag));
1024             return false;
1025         }
1026         return true;
1027     }
1028
1029     /**
1030      * Notify remote user that a people tag has been removed
1031      *   - untag verb is queued
1032      *   - the subscription is undone immediately if not required
1033      *     i.e garbageCollect()'d
1034      *
1035      * @param Profile_tag $ptag the people tag that was deleted
1036      * @return hook return value
1037      */
1038     function onEndUntagProfile($ptag)
1039     {
1040         $oprofile = Ostatus_profile::staticGet('profile_id', $ptag->tagged);
1041
1042         if (empty($oprofile)) {
1043             return true;
1044         }
1045
1046         $plist = $ptag->getMeta();
1047         if ($plist->private) {
1048             return true;
1049         }
1050
1051         $act = new Activity();
1052
1053         $tagger = $plist->getTagger();
1054         $tagged = Profile::staticGet('id', $ptag->tagged);
1055
1056         $act->verb = ActivityVerb::UNTAG;
1057         $act->id   = TagURI::mint('untag_profile:%d:%d:%s',
1058                                   $plist->tagger, $plist->id,
1059                                   common_date_iso8601(time()));
1060         $act->time = time();
1061         // TRANS: Title for unlisting a remote profile.
1062         $act->title = _m('TITLE','Unlist');
1063         // TRANS: Success message for remote list removal through OStatus.
1064         // TRANS: %1$s is the list creator's name, %2$s is the removed list member, %3$s is the list name.
1065         $act->content = sprintf(_m('%1$s removed %2$s from the list %3$s.'),
1066                                 $tagger->getBestName(),
1067                                 $tagged->getBestName(),
1068                                 $plist->getBestName());
1069
1070         $act->actor  = ActivityObject::fromProfile($tagger);
1071         $act->objects = array(ActivityObject::fromProfile($tagged));
1072         $act->target = ActivityObject::fromPeopletag($plist);
1073
1074         $oprofile->notifyDeferred($act, $tagger);
1075
1076         // unsubscribe to PuSH feed if no more required
1077         $oprofile->garbageCollect();
1078
1079         return true;
1080     }
1081
1082     /**
1083      * Notify remote users when their notices get de-favorited.
1084      *
1085      * @param Profile $profile Profile person doing the de-faving
1086      * @param Notice  $notice  Notice being favored
1087      *
1088      * @return hook return value
1089      */
1090     function onEndDisfavorNotice(Profile $profile, Notice $notice)
1091     {
1092         $user = User::staticGet('id', $profile->id);
1093
1094         if (empty($user)) {
1095             return true;
1096         }
1097
1098         $oprofile = Ostatus_profile::staticGet('profile_id', $notice->profile_id);
1099
1100         if (empty($oprofile)) {
1101             return true;
1102         }
1103
1104         $act = new Activity();
1105
1106         $act->verb = ActivityVerb::UNFAVORITE;
1107         $act->id   = TagURI::mint('disfavor:%d:%d:%s',
1108                                   $profile->id,
1109                                   $notice->id,
1110                                   common_date_iso8601(time()));
1111         $act->time    = time();
1112         // TRANS: Title for unliking a remote notice.
1113         $act->title   = _m('Unlike');
1114         // TRANS: Success message for remove a favorite notice through OStatus.
1115         // TRANS: %1$s is the unfavoring user's name, %2$s is URI to the no longer favored notice.
1116         $act->content = sprintf(_m('%1$s no longer likes %2$s.'),
1117                                $profile->getBestName(),
1118                                $notice->uri);
1119
1120         $act->actor   = ActivityObject::fromProfile($profile);
1121         $act->object  = ActivityObject::fromNotice($notice);
1122
1123         $oprofile->notifyActivity($act, $profile);
1124
1125         return true;
1126     }
1127
1128     function onStartGetProfileUri($profile, &$uri)
1129     {
1130         $oprofile = Ostatus_profile::staticGet('profile_id', $profile->id);
1131         if (!empty($oprofile)) {
1132             $uri = $oprofile->uri;
1133             return false;
1134         }
1135         return true;
1136     }
1137
1138     function onStartUserGroupHomeUrl($group, &$url)
1139     {
1140         return $this->onStartUserGroupPermalink($group, $url);
1141     }
1142
1143     function onStartUserGroupPermalink($group, &$url)
1144     {
1145         $oprofile = Ostatus_profile::staticGet('group_id', $group->id);
1146         if ($oprofile) {
1147             // @fixme this should probably be in the user_group table
1148             // @fixme this uri not guaranteed to be a profile page
1149             $url = $oprofile->uri;
1150             return false;
1151         }
1152     }
1153
1154     function onStartShowSubscriptionsContent($action)
1155     {
1156         $this->showEntityRemoteSubscribe($action);
1157
1158         return true;
1159     }
1160
1161     function onStartShowUserGroupsContent($action)
1162     {
1163         $this->showEntityRemoteSubscribe($action, 'ostatusgroup');
1164
1165         return true;
1166     }
1167
1168     function onEndShowSubscriptionsMiniList($action)
1169     {
1170         $this->showEntityRemoteSubscribe($action);
1171
1172         return true;
1173     }
1174
1175     function onEndShowGroupsMiniList($action)
1176     {
1177         $this->showEntityRemoteSubscribe($action, 'ostatusgroup');
1178
1179         return true;
1180     }
1181
1182     function showEntityRemoteSubscribe($action, $target='ostatussub')
1183     {
1184         $user = common_current_user();
1185         if ($user && ($user->id == $action->profile->id)) {
1186             $action->elementStart('div', 'entity_actions');
1187             $action->elementStart('p', array('id' => 'entity_remote_subscribe',
1188                                              'class' => 'entity_subscribe'));
1189             $action->element('a', array('href' => common_local_url($target),
1190                                         'class' => 'entity_remote_subscribe'),
1191                                 // TRANS: Link text for link to remote subscribe.
1192                                 _m('Remote'));
1193             $action->elementEnd('p');
1194             $action->elementEnd('div');
1195         }
1196     }
1197
1198     /**
1199      * Ping remote profiles with updates to this profile.
1200      * Salmon pings are queued for background processing.
1201      */
1202     function onEndBroadcastProfile(Profile $profile)
1203     {
1204         $user = User::staticGet('id', $profile->id);
1205
1206         // Find foreign accounts I'm subscribed to that support Salmon pings.
1207         //
1208         // @fixme we could run updates through the PuSH feed too,
1209         // in which case we can skip Salmon pings to folks who
1210         // are also subscribed to me.
1211         $sql = "SELECT * FROM ostatus_profile " .
1212                "WHERE profile_id IN " .
1213                "(SELECT subscribed FROM subscription WHERE subscriber=%d) " .
1214                "OR group_id IN " .
1215                "(SELECT group_id FROM group_member WHERE profile_id=%d)";
1216         $oprofile = new Ostatus_profile();
1217         $oprofile->query(sprintf($sql, $profile->id, $profile->id));
1218
1219         if ($oprofile->N == 0) {
1220             common_log(LOG_DEBUG, "No OStatus remote subscribees for $profile->nickname");
1221             return true;
1222         }
1223
1224         $act = new Activity();
1225
1226         $act->verb = ActivityVerb::UPDATE_PROFILE;
1227         $act->id   = TagURI::mint('update-profile:%d:%s',
1228                                   $profile->id,
1229                                   common_date_iso8601(time()));
1230         $act->time    = time();
1231         // TRANS: Title for activity.
1232         $act->title   = _m('Profile update');
1233         // TRANS: Ping text for remote profile update through OStatus.
1234         // TRANS: %s is user that updated their profile.
1235         $act->content = sprintf(_m('%s has updated their profile page.'),
1236                                $profile->getBestName());
1237
1238         $act->actor   = ActivityObject::fromProfile($profile);
1239         $act->object  = $act->actor;
1240
1241         while ($oprofile->fetch()) {
1242             $oprofile->notifyDeferred($act, $profile);
1243         }
1244
1245         return true;
1246     }
1247
1248     function onStartProfileListItemActionElements($item, $profile=null)
1249     {
1250         if (!common_logged_in()) {
1251
1252             $profileUser = User::staticGet('id', $item->profile->id);
1253
1254             if (!empty($profileUser)) {
1255
1256                 if ($item instanceof Action) {
1257                     $output = $item;
1258                     $profile = $item->profile;
1259                 } else {
1260                     $output = $item->out;
1261                 }
1262
1263                 // Add an OStatus subscribe
1264                 $output->elementStart('li', 'entity_subscribe');
1265                 $url = common_local_url('ostatusinit',
1266                                         array('nickname' => $profileUser->nickname));
1267                 $output->element('a', array('href' => $url,
1268                                             'class' => 'entity_remote_subscribe'),
1269                                   // TRANS: Link text for a user to subscribe to an OStatus user.
1270                                  _m('Subscribe'));
1271                 $output->elementEnd('li');
1272
1273                 $output->elementStart('li', 'entity_tag');
1274                 $url = common_local_url('ostatustag',
1275                                         array('nickname' => $profileUser->nickname));
1276                 $output->element('a', array('href' => $url,
1277                                             'class' => 'entity_remote_tag'),
1278                                   // TRANS: Link text for a user to tag an OStatus user.
1279                                  _m('Tag'));
1280                 $output->elementEnd('li');
1281             }
1282         }
1283
1284         return true;
1285     }
1286
1287     function onPluginVersion(&$versions)
1288     {
1289         $versions[] = array('name' => 'OStatus',
1290                             'version' => STATUSNET_VERSION,
1291                             'author' => 'Evan Prodromou, James Walker, Brion Vibber, Zach Copley',
1292                             'homepage' => 'http://status.net/wiki/Plugin:OStatus',
1293                             // TRANS: Plugin description.
1294                             'rawdescription' => _m('Follow people across social networks that implement '.
1295                                '<a href="http://ostatus.org/">OStatus</a>.'));
1296
1297         return true;
1298     }
1299
1300     /**
1301      * Utility function to check if the given URI is a canonical group profile
1302      * page, and if so return the ID number.
1303      *
1304      * @param string $url
1305      * @return mixed int or false
1306      */
1307     public static function localGroupFromUrl($url)
1308     {
1309         $group = User_group::staticGet('uri', $url);
1310         if ($group) {
1311             $local = Local_group::staticGet('group_id', $group->id);
1312             if ($local) {
1313                 return $group->id;
1314             }
1315         } else {
1316             // To find local groups which haven't had their uri fields filled out...
1317             // If the domain has changed since a subscriber got the URI, it'll
1318             // be broken.
1319             $template = common_local_url('groupbyid', array('id' => '31337'));
1320             $template = preg_quote($template, '/');
1321             $template = str_replace('31337', '(\d+)', $template);
1322             if (preg_match("/$template/", $url, $matches)) {
1323                 return intval($matches[1]);
1324             }
1325         }
1326         return false;
1327     }
1328
1329     public function onStartProfileGetAtomFeed($profile, &$feed)
1330     {
1331         $oprofile = Ostatus_profile::staticGet('profile_id', $profile->id);
1332
1333         if (empty($oprofile)) {
1334             return true;
1335         }
1336
1337         $feed = $oprofile->feeduri;
1338         return false;
1339     }
1340
1341     function onStartGetProfileFromURI($uri, &$profile)
1342     {
1343         // Don't want to do Web-based discovery on our own server,
1344         // so we check locally first.
1345
1346         $user = User::staticGet('uri', $uri);
1347
1348         if (!empty($user)) {
1349             $profile = $user->getProfile();
1350             return false;
1351         }
1352
1353         // Now, check remotely
1354
1355         $oprofile = Ostatus_profile::ensureProfileURI($uri);
1356
1357         if (!empty($oprofile)) {
1358             $profile = $oprofile->localProfile();
1359             return false;
1360         }
1361
1362         // Still not a hit, so give up.
1363
1364         return true;
1365     }
1366
1367     function onEndXrdActionLinks(&$xrd, $user)
1368     {
1369         $xrd->links[] = array('rel' => Discovery::UPDATESFROM,
1370                   'href' => common_local_url('ApiTimelineUser',
1371                              array('id' => $user->id,
1372                                    'format' => 'atom')),
1373                   'type' => 'application/atom+xml');
1374
1375                 // Salmon
1376         $salmon_url = common_local_url('usersalmon',
1377                                        array('id' => $user->id));
1378
1379         $xrd->links[] = array('rel' => Salmon::REL_SALMON,
1380                               'href' => $salmon_url);
1381         // XXX : Deprecated - to be removed.
1382         $xrd->links[] = array('rel' => Salmon::NS_REPLIES,
1383                               'href' => $salmon_url);
1384
1385         $xrd->links[] = array('rel' => Salmon::NS_MENTIONS,
1386                               'href' => $salmon_url);
1387
1388         // Get this user's keypair
1389         $magickey = Magicsig::staticGet('user_id', $user->id);
1390         if (!$magickey) {
1391             // No keypair yet, let's generate one.
1392             $magickey = new Magicsig();
1393             $magickey->generate($user->id);
1394         }
1395
1396         $xrd->links[] = array('rel' => Magicsig::PUBLICKEYREL,
1397                               'href' => 'data:application/magic-public-key,'. $magickey->toString(false));
1398
1399         // TODO - finalize where the redirect should go on the publisher
1400         $url = common_local_url('ostatussub') . '?profile={uri}';
1401         $xrd->links[] = array('rel' => 'http://ostatus.org/schema/1.0/subscribe',
1402                               'template' => $url );
1403
1404         return true;
1405     }
1406 }