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