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