]> git.mxchange.org Git - quix0rs-gnu-social.git/blob - plugins/OStatus/classes/Ostatus_profile.php
d66939399a3c29115c8150a781f7995cb1572ffe
[quix0rs-gnu-social.git] / plugins / OStatus / classes / Ostatus_profile.php
1 <?php
2 /*
3  * StatusNet - the distributed open-source microblogging tool
4  * Copyright (C) 2009-2010, StatusNet, Inc.
5  *
6  * This program is free software: you can redistribute it and/or modify
7  * it under the terms of the GNU Affero General Public License as published by
8  * the Free Software Foundation, either version 3 of the License, or
9  * (at your option) any later version.
10  *
11  * This program is distributed in the hope that it will be useful,
12  * but WITHOUT ANY WARRANTY; without even the implied warranty of
13  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14  * GNU Affero General Public License for more details.
15  *
16  * You should have received a copy of the GNU Affero General Public License
17  * along with this program.  If not, see <http://www.gnu.org/licenses/>.
18  */
19
20 /**
21  * @package OStatusPlugin
22  * @maintainer Brion Vibber <brion@status.net>
23  */
24
25 class Ostatus_profile extends Memcached_DataObject
26 {
27     public $__table = 'ostatus_profile';
28
29     public $uri;
30
31     public $profile_id;
32     public $group_id;
33
34     public $feeduri;
35     public $salmonuri;
36     public $avatar; // remote URL of the last avatar we saved
37
38     public $created;
39     public $modified;
40
41     public /*static*/ function staticGet($k, $v=null)
42     {
43         return parent::staticGet(__CLASS__, $k, $v);
44     }
45
46     /**
47      * return table definition for DB_DataObject
48      *
49      * DB_DataObject needs to know something about the table to manipulate
50      * instances. This method provides all the DB_DataObject needs to know.
51      *
52      * @return array array of column definitions
53      */
54
55     function table()
56     {
57         return array('uri' => DB_DATAOBJECT_STR + DB_DATAOBJECT_NOTNULL,
58                      'profile_id' => DB_DATAOBJECT_INT,
59                      'group_id' => DB_DATAOBJECT_INT,
60                      'feeduri' => DB_DATAOBJECT_STR,
61                      'salmonuri' =>  DB_DATAOBJECT_STR,
62                      'avatar' =>  DB_DATAOBJECT_STR,
63                      'created' => DB_DATAOBJECT_STR + DB_DATAOBJECT_DATE + DB_DATAOBJECT_TIME + DB_DATAOBJECT_NOTNULL,
64                      'modified' => DB_DATAOBJECT_STR + DB_DATAOBJECT_DATE + DB_DATAOBJECT_TIME + DB_DATAOBJECT_NOTNULL);
65     }
66
67     static function schemaDef()
68     {
69         return array(new ColumnDef('uri', 'varchar',
70                                    255, false, 'PRI'),
71                      new ColumnDef('profile_id', 'integer',
72                                    null, true, 'UNI'),
73                      new ColumnDef('group_id', 'integer',
74                                    null, true, 'UNI'),
75                      new ColumnDef('feeduri', 'varchar',
76                                    255, true, 'UNI'),
77                      new ColumnDef('salmonuri', 'text',
78                                    null, true),
79                      new ColumnDef('avatar', 'text',
80                                    null, true),
81                      new ColumnDef('created', 'datetime',
82                                    null, false),
83                      new ColumnDef('modified', 'datetime',
84                                    null, false));
85     }
86
87     /**
88      * return key definitions for DB_DataObject
89      *
90      * DB_DataObject needs to know about keys that the table has; this function
91      * defines them.
92      *
93      * @return array key definitions
94      */
95
96     function keys()
97     {
98         return array_keys($this->keyTypes());
99     }
100
101     /**
102      * return key definitions for Memcached_DataObject
103      *
104      * Our caching system uses the same key definitions, but uses a different
105      * method to get them.
106      *
107      * @return array key definitions
108      */
109
110     function keyTypes()
111     {
112         return array('uri' => 'K', 'profile_id' => 'U', 'group_id' => 'U', 'feeduri' => 'U');
113     }
114
115     function sequenceKey()
116     {
117         return array(false, false, false);
118     }
119
120     /**
121      * Fetch the StatusNet-side profile for this feed
122      * @return Profile
123      */
124     public function localProfile()
125     {
126         if ($this->profile_id) {
127             return Profile::staticGet('id', $this->profile_id);
128         }
129         return null;
130     }
131
132     /**
133      * Fetch the StatusNet-side profile for this feed
134      * @return Profile
135      */
136     public function localGroup()
137     {
138         if ($this->group_id) {
139             return User_group::staticGet('id', $this->group_id);
140         }
141         return null;
142     }
143
144     /**
145      * Returns an ActivityObject describing this remote user or group profile.
146      * Can then be used to generate Atom chunks.
147      *
148      * @return ActivityObject
149      */
150     function asActivityObject()
151     {
152         if ($this->isGroup()) {
153             $object = new ActivityObject();
154             $object->type = 'http://activitystrea.ms/schema/1.0/group';
155             $object->id = $this->uri;
156             $self = $this->localGroup();
157
158             // @fixme put a standard getAvatar() interface on groups too
159             if ($self->homepage_logo) {
160                 $object->avatar = $self->homepage_logo;
161                 $map = array('png' => 'image/png',
162                              'jpg' => 'image/jpeg',
163                              'jpeg' => 'image/jpeg',
164                              'gif' => 'image/gif');
165                 $extension = pathinfo(parse_url($avatarHref, PHP_URL_PATH), PATHINFO_EXTENSION);
166                 if (isset($map[$extension])) {
167                     // @fixme this ain't used/saved yet
168                     $object->avatarType = $map[$extension];
169                 }
170             }
171
172             $object->link = $this->uri; // @fixme accurate?
173             return $object;
174         } else {
175             return ActivityObject::fromProfile($this->localProfile());
176         }
177     }
178
179     /**
180      * Returns an XML string fragment with profile information as an
181      * Activity Streams noun object with the given element type.
182      *
183      * Assumes that 'activity' namespace has been previously defined.
184      *
185      * @fixme replace with wrappers on asActivityObject when it's got everything.
186      *
187      * @param string $element one of 'actor', 'subject', 'object', 'target'
188      * @return string
189      */
190     function asActivityNoun($element)
191     {
192         $xs = new XMLStringer(true);
193         $avatarHref = Avatar::defaultImage(AVATAR_PROFILE_SIZE);
194         $avatarType = 'image/png';
195         if ($this->isGroup()) {
196             $type = 'http://activitystrea.ms/schema/1.0/group';
197             $self = $this->localGroup();
198
199             // @fixme put a standard getAvatar() interface on groups too
200             if ($self->homepage_logo) {
201                 $avatarHref = $self->homepage_logo;
202                 $map = array('png' => 'image/png',
203                              'jpg' => 'image/jpeg',
204                              'jpeg' => 'image/jpeg',
205                              'gif' => 'image/gif');
206                 $extension = pathinfo(parse_url($avatarHref, PHP_URL_PATH), PATHINFO_EXTENSION);
207                 if (isset($map[$extension])) {
208                     $avatarType = $map[$extension];
209                 }
210             }
211         } else {
212             $type = 'http://activitystrea.ms/schema/1.0/person';
213             $self = $this->localProfile();
214             $avatar = $self->getAvatar(AVATAR_PROFILE_SIZE);
215             if ($avatar) {
216                   $avatarHref = $avatar->url;
217                   $avatarType = $avatar->mediatype;
218             }
219         }
220         $xs->elementStart('activity:' . $element);
221         $xs->element(
222             'activity:object-type',
223             null,
224             $type
225         );
226         $xs->element(
227             'id',
228             null,
229             $this->uri); // ?
230         $xs->element('title', null, $self->getBestName());
231
232         $xs->element(
233             'link', array(
234                 'type' => $avatarType,
235                 'href' => $avatarHref
236             ),
237             ''
238         );
239
240         $xs->elementEnd('activity:' . $element);
241
242         return $xs->getString();
243     }
244
245     /**
246      * @return boolean true if this is a remote group
247      */
248     function isGroup()
249     {
250         if ($this->profile_id && !$this->group_id) {
251             return false;
252         } else if ($this->group_id && !$this->profile_id) {
253             return true;
254         } else if ($this->group_id && $this->profile_id) {
255             throw new ServerException("Invalid ostatus_profile state: both group and profile IDs set for $this->uri");
256         } else {
257             throw new ServerException("Invalid ostatus_profile state: both group and profile IDs empty for $this->uri");
258         }
259     }
260
261     /**
262      * Subscribe a local user to this remote user.
263      * PuSH subscription will be started if necessary, and we'll
264      * send a Salmon notification to the remote server if available
265      * notifying them of the sub.
266      *
267      * @param User $user
268      * @return boolean success
269      * @throws FeedException
270      */
271     public function subscribeLocalToRemote(User $user)
272     {
273         if ($this->isGroup()) {
274             throw new ServerException("Can't subscribe to a remote group");
275         }
276
277         if ($this->subscribe()) {
278             if ($user->subscribeTo($this->localProfile())) {
279                 $this->notify($user->getProfile(), ActivityVerb::FOLLOW, $this);
280                 return true;
281             }
282         }
283         return false;
284     }
285
286     /**
287      * Mark this remote profile as subscribing to the given local user,
288      * and send appropriate notifications to the user.
289      *
290      * This will generally be in response to a subscription notification
291      * from a foreign site to our local Salmon response channel.
292      *
293      * @param User $user
294      * @return boolean success
295      */
296     public function subscribeRemoteToLocal(User $user)
297     {
298         if ($this->isGroup()) {
299             throw new ServerException("Remote groups can't subscribe to local users");
300         }
301
302         Subscription::start($this->localProfile(), $user->getProfile());
303
304         return true;
305     }
306
307     /**
308      * Send a subscription request to the hub for this feed.
309      * The hub will later send us a confirmation POST to /main/push/callback.
310      *
311      * @return bool true on success, false on failure
312      * @throws ServerException if feed state is not valid
313      */
314     public function subscribe()
315     {
316         $feedsub = FeedSub::ensureFeed($this->feeduri);
317         if ($feedsub->sub_state == 'active' || $feedsub->sub_state == 'subscribe') {
318             return true;
319         } else if ($feedsub->sub_state == '' || $feedsub->sub_state == 'inactive') {
320             return $feedsub->subscribe();
321         } else if ('unsubscribe') {
322             throw new FeedSubException("Unsub is pending, can't subscribe...");
323         }
324     }
325
326     /**
327      * Send a PuSH unsubscription request to the hub for this feed.
328      * The hub will later send us a confirmation POST to /main/push/callback.
329      *
330      * @return bool true on success, false on failure
331      * @throws ServerException if feed state is not valid
332      */
333     public function unsubscribe() {
334         $feedsub = FeedSub::staticGet('uri', $this->feeduri);
335         if ($feedsub->sub_state == 'active') {
336             return $feedsub->unsubscribe();
337         } else if ($feedsub->sub_state == '' || $feedsub->sub_state == 'inactive' || $feedsub->sub_state == 'unsubscribe') {
338             return true;
339         } else if ($feedsub->sub_state == 'subscribe') {
340             throw new FeedSubException("Feed is awaiting subscription, can't unsub...");
341         }
342     }
343
344     /**
345      * Check if this remote profile has any active local subscriptions, and
346      * if not drop the PuSH subscription feed.
347      *
348      * @return boolean
349      */
350     public function garbageCollect()
351     {
352         if ($this->isGroup()) {
353             $members = $this->localGroup()->getMembers(0, 1);
354             $count = $members->N;
355         } else {
356             $count = $this->localProfile()->subscriberCount();
357         }
358         if ($count == 0) {
359             common_log(LOG_INFO, "Unsubscribing from now-unused remote feed $oprofile->feeduri");
360             $this->unsubscribe();
361             return true;
362         } else {
363             return false;
364         }
365     }
366
367     /**
368      * Send an Activity Streams notification to the remote Salmon endpoint,
369      * if so configured.
370      *
371      * @param Profile $actor  Actor who did the activity
372      * @param string  $verb   Activity::SUBSCRIBE or Activity::JOIN
373      * @param Object  $object object of the action; must define asActivityNoun($tag)
374      */
375     public function notify($actor, $verb, $object=null)
376     {
377         if (!($actor instanceof Profile)) {
378             $type = gettype($actor);
379             if ($type == 'object') {
380                 $type = get_class($actor);
381             }
382             throw new ServerException("Invalid actor passed to " . __METHOD__ . ": " . $type);
383         }
384         if ($object == null) {
385             $object = $this;
386         }
387         if ($this->salmonuri) {
388
389             $text = 'update';
390             $id = TagURI::mint('%s:%s:%s',
391                                $verb,
392                                $actor->getURI(),
393                                common_date_iso8601(time()));
394
395             // @fixme consolidate all these NS settings somewhere
396             $attributes = array('xmlns' => Activity::ATOM,
397                                 'xmlns:activity' => 'http://activitystrea.ms/spec/1.0/',
398                                 'xmlns:thr' => 'http://purl.org/syndication/thread/1.0',
399                                 'xmlns:georss' => 'http://www.georss.org/georss',
400                                 'xmlns:ostatus' => 'http://ostatus.org/schema/1.0',
401                                 'xmlns:poco' => 'http://portablecontacts.net/spec/1.0');
402
403             $entry = new XMLStringer();
404             $entry->elementStart('entry', $attributes);
405             $entry->element('id', null, $id);
406             $entry->element('title', null, $text);
407             $entry->element('summary', null, $text);
408             $entry->element('published', null, common_date_w3dtf(common_sql_now()));
409
410             $entry->element('activity:verb', null, $verb);
411             $entry->raw($actor->asAtomAuthor());
412             $entry->raw($actor->asActivityActor());
413             $entry->raw($object->asActivityNoun('object'));
414             $entry->elementEnd('entry');
415
416             $xml = $entry->getString();
417             common_log(LOG_INFO, "Posting to Salmon endpoint $this->salmonuri: $xml");
418
419             $salmon = new Salmon(); // ?
420             return $salmon->post($this->salmonuri, $xml);
421         }
422         return false;
423     }
424
425     /**
426      * Send a Salmon notification ping immediately, and confirm that we got
427      * an acceptable response from the remote site.
428      *
429      * @param mixed $entry XML string, Notice, or Activity
430      * @return boolean success
431      */
432     public function notifyActivity($entry)
433     {
434         if ($this->salmonuri) {
435             $salmon = new Salmon();
436             return $salmon->post($this->salmonuri, $this->notifyPrepXml($entry));
437         }
438
439         return false;
440     }
441
442     /**
443      * Queue a Salmon notification for later. If queues are disabled we'll
444      * send immediately but won't get the return value.
445      *
446      * @param mixed $entry XML string, Notice, or Activity
447      * @return boolean success
448      */
449     public function notifyDeferred($entry)
450     {
451         if ($this->salmonuri) {
452             $data = array('salmonuri' => $this->salmonuri,
453                           'entry' => $this->notifyPrepXml($entry));
454
455             $qm = QueueManager::get();
456             return $qm->enqueue($data, 'salmon');
457         }
458
459         return false;
460     }
461
462     protected function notifyPrepXml($entry)
463     {
464         $preamble = '<?xml version="1.0" encoding="UTF-8" ?' . '>';
465         if (is_string($entry)) {
466             return $entry;
467         } else if ($entry instanceof Activity) {
468             return $preamble . $entry->asString(true);
469         } else if ($entry instanceof Notice) {
470             return $preamble . $entry->asAtomEntry(true, true);
471         } else {
472             throw new ServerException("Invalid type passed to Ostatus_profile::notify; must be XML string or Activity entry");
473         }
474     }
475
476     function getBestName()
477     {
478         if ($this->isGroup()) {
479             return $this->localGroup()->getBestName();
480         } else {
481             return $this->localProfile()->getBestName();
482         }
483     }
484
485     function atomFeed($actor)
486     {
487         $feed = new Atom10Feed();
488         // @fixme should these be set up somewhere else?
489         $feed->addNamespace('activity', 'http://activitystrea.ms/spec/1.0/');
490         $feed->addNamespace('thr', 'http://purl.org/syndication/thread/1.0');
491         $feed->addNamespace('georss', 'http://www.georss.org/georss');
492         $feed->addNamespace('ostatus', 'http://ostatus.org/schema/1.0');
493
494         $taguribase = common_config('integration', 'taguri');
495         $feed->setId("tag:{$taguribase}:UserTimeline:{$actor->id}"); // ???
496
497         $feed->setTitle($actor->getBestName() . ' timeline'); // @fixme
498         $feed->setUpdated(time());
499         $feed->setPublished(time());
500
501         $feed->addLink(common_local_url('ApiTimelineUser',
502                                         array('id' => $actor->id,
503                                               'type' => 'atom')),
504                        array('rel' => 'self',
505                              'type' => 'application/atom+xml'));
506
507         $feed->addLink(common_local_url('userbyid',
508                                         array('id' => $actor->id)),
509                        array('rel' => 'alternate',
510                              'type' => 'text/html'));
511
512         return $feed;
513     }
514
515     /**
516      * Read and post notices for updates from the feed.
517      * Currently assumes that all items in the feed are new,
518      * coming from a PuSH hub.
519      *
520      * @param DOMDocument $feed
521      */
522     public function processFeed($feed, $source)
523     {
524         $entries = $feed->getElementsByTagNameNS(Activity::ATOM, 'entry');
525         if ($entries->length == 0) {
526             common_log(LOG_ERR, __METHOD__ . ": no entries in feed update, ignoring");
527             return;
528         }
529
530         for ($i = 0; $i < $entries->length; $i++) {
531             $entry = $entries->item($i);
532             $this->processEntry($entry, $feed, $source);
533         }
534     }
535
536     /**
537      * Process a posted entry from this feed source.
538      *
539      * @param DOMElement $entry
540      * @param DOMElement $feed for context
541      */
542     public function processEntry($entry, $feed, $source)
543     {
544         $activity = new Activity($entry, $feed);
545
546         if ($activity->verb == ActivityVerb::POST) {
547             $this->processPost($activity, $source);
548         } else {
549             common_log(LOG_INFO, "Ignoring activity with unrecognized verb $activity->verb");
550         }
551     }
552
553     /**
554      * Process an incoming post activity from this remote feed.
555      * @param Activity $activity
556      * @param string $method 'push' or 'salmon'
557      * @return mixed saved Notice or false
558      * @fixme break up this function, it's getting nasty long
559      */
560     public function processPost($activity, $method)
561     {
562         if ($this->isGroup()) {
563             // A group feed will contain posts from multiple authors.
564             // @fixme validate these profiles in some way!
565             $oprofile = self::ensureActorProfile($activity);
566             if ($oprofile->isGroup()) {
567                 // Groups can't post notices in StatusNet.
568                 common_log(LOG_WARNING, "OStatus: skipping post with group listed as author: $oprofile->uri in feed from $this->uri");
569                 return false;
570             }
571         } else {
572             // Individual user feeds may contain only posts from themselves.
573             // Authorship is validated against the profile URI on upper layers,
574             // through PuSH setup or Salmon signature checks.
575             $actorUri = self::getActorProfileURI($activity);
576             if ($actorUri == $this->uri) {
577                 // Check if profile info has changed and update it
578                 $this->updateFromActivityObject($activity->actor);
579             } else {
580                 common_log(LOG_WARNING, "OStatus: skipping post with bad author: got $actorUri expected $this->uri");
581                 return false;
582             }
583             $oprofile = $this;
584         }
585
586         // The id URI will be used as a unique identifier for for the notice,
587         // protecting against duplicate saves. It isn't required to be a URL;
588         // tag: URIs for instance are found in Google Buzz feeds.
589         $sourceUri = $activity->object->id;
590         $dupe = Notice::staticGet('uri', $sourceUri);
591         if ($dupe) {
592             common_log(LOG_INFO, "OStatus: ignoring duplicate post: $sourceUri");
593             return false;
594         }
595
596         // We'll also want to save a web link to the original notice, if provided.
597         $sourceUrl = null;
598         if ($activity->object->link) {
599             $sourceUrl = $activity->object->link;
600         } else if ($activity->link) {
601             $sourceUrl = $activity->link;
602         } else if (preg_match('!^https?://!', $activity->object->id)) {
603             $sourceUrl = $activity->object->id;
604         }
605
606         // Get (safe!) HTML and text versions of the content
607         $rendered = $this->purify($activity->object->content);
608         $content = html_entity_decode(strip_tags($rendered));
609
610         $shortened = common_shorten_links($content);
611
612         // If it's too long, try using the summary, and make the
613         // HTML an attachment.
614
615         $attachment = null;
616
617         if (Notice::contentTooLong($shortened)) {
618             $attachment = $this->saveHTMLFile($activity->object->title, $rendered);
619             $summary = $activity->object->summary;
620             if (empty($summary)) {
621                 $summary = $content;
622             }
623             $shortSummary = common_shorten_links($summary);
624             if (Notice::contentTooLong($shortSummary)) {
625                 $url = common_shorten_url(common_local_url('attachment',
626                                                            array('attachment' => $attachment->id)));
627                 $shortSummary = substr($shortSummary,
628                                        0,
629                                        Notice::maxContent() - (mb_strlen($url) + 2));
630                 $shortSummary .= '… ' . $url;
631                 $content = $shortSummary;
632                 $rendered = common_render_text($content);
633             }
634         }
635
636         $options = array('is_local' => Notice::REMOTE_OMB,
637                         'url' => $sourceUrl,
638                         'uri' => $sourceUri,
639                         'rendered' => $rendered,
640                         'replies' => array(),
641                         'groups' => array(),
642                         'tags' => array());
643
644
645         // Check for optional attributes...
646
647         if (!empty($activity->time)) {
648             $options['created'] = common_sql_date($activity->time);
649         }
650
651         if ($activity->context) {
652             // Any individual or group attn: targets?
653             $replies = $activity->context->attention;
654             $options['groups'] = $this->filterReplies($oprofile, $replies);
655             $options['replies'] = $replies;
656
657             // Maintain direct reply associations
658             // @fixme what about conversation ID?
659             if (!empty($activity->context->replyToID)) {
660                 $orig = Notice::staticGet('uri',
661                                           $activity->context->replyToID);
662                 if (!empty($orig)) {
663                     $options['reply_to'] = $orig->id;
664                 }
665             }
666
667             $location = $activity->context->location;
668             if ($location) {
669                 $options['lat'] = $location->lat;
670                 $options['lon'] = $location->lon;
671                 if ($location->location_id) {
672                     $options['location_ns'] = $location->location_ns;
673                     $options['location_id'] = $location->location_id;
674                 }
675             }
676         }
677
678         // Atom categories <-> hashtags
679         foreach ($activity->categories as $cat) {
680             if ($cat->term) {
681                 $term = common_canonical_tag($cat->term);
682                 if ($term) {
683                     $options['tags'][] = $term;
684                 }
685             }
686         }
687
688         try {
689             $saved = Notice::saveNew($oprofile->profile_id,
690                                      $content,
691                                      'ostatus',
692                                      $options);
693             if ($saved) {
694                 Ostatus_source::saveNew($saved, $this, $method);
695                 if (!empty($attachment)) {
696                     File_to_post::processNew($attachment->id, $saved->id);
697                 }
698             }
699         } catch (Exception $e) {
700             common_log(LOG_ERR, "OStatus save of remote message $sourceUri failed: " . $e->getMessage());
701             throw $e;
702         }
703         common_log(LOG_INFO, "OStatus saved remote message $sourceUri as notice id $saved->id");
704         return $saved;
705     }
706
707     /**
708      * Clean up HTML
709      */
710     protected function purify($html)
711     {
712         require_once INSTALLDIR.'/extlib/htmLawed/htmLawed.php';
713         $config = array('safe' => 1);
714         return htmLawed($html, $config);
715     }
716
717     /**
718      * Filters a list of recipient ID URIs to just those for local delivery.
719      * @param Ostatus_profile local profile of sender
720      * @param array in/out &$attention_uris set of URIs, will be pruned on output
721      * @return array of group IDs
722      */
723     protected function filterReplies($sender, &$attention_uris)
724     {
725         common_log(LOG_DEBUG, "Original reply recipients: " . implode(', ', $attention_uris));
726         $groups = array();
727         $replies = array();
728         foreach ($attention_uris as $recipient) {
729             // Is the recipient a local user?
730             $user = User::staticGet('uri', $recipient);
731             if ($user) {
732                 // @fixme sender verification, spam etc?
733                 $replies[] = $recipient;
734                 continue;
735             }
736
737             // Is the recipient a remote group?
738             $oprofile = Ostatus_profile::staticGet('uri', $recipient);
739             if ($oprofile) {
740                 if ($oprofile->isGroup()) {
741                     // Deliver to local members of this remote group.
742                     // @fixme sender verification?
743                     $groups[] = $oprofile->group_id;
744                 } else {
745                     common_log(LOG_DEBUG, "Skipping reply to remote profile $recipient");
746                 }
747                 continue;
748             }
749
750             // Is the recipient a local group?
751             // @fixme we need a uri on user_group
752             // $group = User_group::staticGet('uri', $recipient);
753             $template = common_local_url('groupbyid', array('id' => '31337'));
754             $template = preg_quote($template, '/');
755             $template = str_replace('31337', '(\d+)', $template);
756             if (preg_match("/$template/", $recipient, $matches)) {
757                 $id = $matches[1];
758                 $group = User_group::staticGet('id', $id);
759                 if ($group) {
760                     // Deliver to all members of this local group if allowed.
761                     $profile = $sender->localProfile();
762                     if ($profile->isMember($group)) {
763                         $groups[] = $group->id;
764                     } else {
765                         common_log(LOG_DEBUG, "Skipping reply to local group $group->nickname as sender $profile->id is not a member");
766                     }
767                     continue;
768                 } else {
769                     common_log(LOG_DEBUG, "Skipping reply to bogus group $recipient");
770                 }
771             }
772
773             common_log(LOG_DEBUG, "Skipping reply to unrecognized profile $recipient");
774
775         }
776         $attention_uris = $replies;
777         common_log(LOG_DEBUG, "Local reply recipients: " . implode(', ', $replies));
778         common_log(LOG_DEBUG, "Local group recipients: " . implode(', ', $groups));
779         return $groups;
780     }
781
782     /**
783      * @param string $profile_url
784      * @return Ostatus_profile
785      * @throws FeedSubException
786      */
787     public static function ensureProfile($profile_uri, $hints=array())
788     {
789         // Get the canonical feed URI and check it
790         $discover = new FeedDiscovery();
791         $feeduri = $discover->discoverFromURL($profile_uri);
792
793         //$feedsub = FeedSub::ensureFeed($feeduri, $discover->feed);
794         $huburi = $discover->getAtomLink('hub');
795         $salmonuri = $discover->getAtomLink('salmon');
796
797         if (!$huburi) {
798             // We can only deal with folks with a PuSH hub
799             throw new FeedSubNoHubException();
800         }
801
802         // Try to get a profile from the feed activity:subject
803
804         $feedEl = $discover->feed->documentElement;
805
806         $subject = ActivityUtils::child($feedEl, Activity::SUBJECT, Activity::SPEC);
807
808         if (!empty($subject)) {
809             $subjObject = new ActivityObject($subject);
810             return self::ensureActivityObjectProfile($subjObject, $feeduri, $salmonuri, $hints);
811         }
812
813         // Otherwise, try the feed author
814
815         $author = ActivityUtils::child($feedEl, Activity::AUTHOR, Activity::ATOM);
816
817         if (!empty($author)) {
818             $authorObject = new ActivityObject($author);
819             return self::ensureActivityObjectProfile($authorObject, $feeduri, $salmonuri, $hints);
820         }
821
822         // Sheesh. Not a very nice feed! Let's try fingerpoken in the
823         // entries.
824
825         $entries = $discover->feed->getElementsByTagNameNS(Activity::ATOM, 'entry');
826
827         if (!empty($entries) && $entries->length > 0) {
828
829             $entry = $entries->item(0);
830
831             $actor = ActivityUtils::child($entry, Activity::ACTOR, Activity::SPEC);
832
833             if (!empty($actor)) {
834                 $actorObject = new ActivityObject($actor);
835                 return self::ensureActivityObjectProfile($actorObject, $feeduri, $salmonuri, $hints);
836
837             }
838
839             $author = ActivityUtils::child($entry, Activity::AUTHOR, Activity::ATOM);
840
841             if (!empty($author)) {
842                 $authorObject = new ActivityObject($author);
843                 return self::ensureActivityObjectProfile($authorObject, $feeduri, $salmonuri, $hints);
844             }
845         }
846
847         // XXX: make some educated guesses here
848
849         throw new FeedSubException("Can't find enough profile information to make a feed.");
850     }
851
852     /**
853      *
854      * Download and update given avatar image
855      * @param string $url
856      * @throws Exception in various failure cases
857      */
858     protected function updateAvatar($url)
859     {
860         if ($url == $this->avatar) {
861             // We've already got this one.
862             return;
863         }
864
865         if ($this->isGroup()) {
866             $self = $this->localGroup();
867         } else {
868             $self = $this->localProfile();
869         }
870         if (!$self) {
871             throw new ServerException(sprintf(
872                 _m("Tried to update avatar for unsaved remote profile %s"),
873                 $this->uri));
874         }
875
876         // @fixme this should be better encapsulated
877         // ripped from oauthstore.php (for old OMB client)
878         $temp_filename = tempnam(sys_get_temp_dir(), 'listener_avatar');
879         if (!copy($url, $temp_filename)) {
880             throw new ServerException(sprintf(_m("Unable to fetch avatar from %s"), $url));
881         }
882
883         if ($this->isGroup()) {
884             $id = $this->group_id;
885         } else {
886             $id = $this->profile_id;
887         }
888         // @fixme should we be using different ids?
889         $imagefile = new ImageFile($id, $temp_filename);
890         $filename = Avatar::filename($id,
891                                      image_type_to_extension($imagefile->type),
892                                      null,
893                                      common_timestamp());
894         rename($temp_filename, Avatar::path($filename));
895         $self->setOriginal($filename);
896
897         $orig = clone($this);
898         $this->avatar = $url;
899         $this->update($orig);
900     }
901
902     /**
903      * Pull avatar URL from ActivityObject or profile hints
904      *
905      * @param ActivityObject $object
906      * @param array $hints
907      * @return mixed URL string or false
908      */
909
910     protected static function getActivityObjectAvatar($object, $hints=array())
911     {
912         if ($object->avatar) {
913             return $object->avatar;
914         } else if (array_key_exists('avatar', $hints)) {
915             return $hints['avatar'];
916         }
917         return false;
918     }
919
920     /**
921      * Get an appropriate avatar image source URL, if available.
922      *
923      * @param ActivityObject $actor
924      * @param DOMElement $feed
925      * @return string
926      */
927
928     protected static function getAvatar($actor, $feed)
929     {
930         $url = '';
931         $icon = '';
932         if ($actor->avatar) {
933             $url = trim($actor->avatar);
934         }
935         if (!$url) {
936             // Check <atom:logo> and <atom:icon> on the feed
937             $els = $feed->childNodes();
938             if ($els && $els->length) {
939                 for ($i = 0; $i < $els->length; $i++) {
940                     $el = $els->item($i);
941                     if ($el->namespaceURI == Activity::ATOM) {
942                         if (empty($url) && $el->localName == 'logo') {
943                             $url = trim($el->textContent);
944                             break;
945                         }
946                         if (empty($icon) && $el->localName == 'icon') {
947                             // Use as a fallback
948                             $icon = trim($el->textContent);
949                         }
950                     }
951                 }
952             }
953             if ($icon && !$url) {
954                 $url = $icon;
955             }
956         }
957         if ($url) {
958             $opts = array('allowed_schemes' => array('http', 'https'));
959             if (Validate::uri($url, $opts)) {
960                 return $url;
961             }
962         }
963         return common_path('plugins/OStatus/images/96px-Feed-icon.svg.png');
964     }
965
966     /**
967      * Fetch, or build if necessary, an Ostatus_profile for the actor
968      * in a given Activity Streams activity.
969      *
970      * @param Activity $activity
971      * @param string $feeduri if we already know the canonical feed URI!
972      * @param string $salmonuri if we already know the salmon return channel URI
973      * @return Ostatus_profile
974      */
975
976     public static function ensureActorProfile($activity, $feeduri=null, $salmonuri=null)
977     {
978         return self::ensureActivityObjectProfile($activity->actor, $feeduri, $salmonuri);
979     }
980
981     public static function ensureActivityObjectProfile($object, $feeduri=null, $salmonuri=null, $hints=array())
982     {
983         $profile = self::getActivityObjectProfile($object);
984         if ($profile) {
985             $profile->updateFromActivityObject($object, $hints);
986         } else {
987             $profile = self::createActivityObjectProfile($object, $feeduri, $salmonuri, $hints);
988         }
989         return $profile;
990     }
991
992     /**
993      * @param Activity $activity
994      * @return mixed matching Ostatus_profile or false if none known
995      */
996     public static function getActorProfile($activity)
997     {
998         return self::getActivityObjectProfile($activity->actor);
999     }
1000
1001     protected static function getActivityObjectProfile($object)
1002     {
1003         $uri = self::getActivityObjectProfileURI($object);
1004         return Ostatus_profile::staticGet('uri', $uri);
1005     }
1006
1007     protected static function getActorProfileURI($activity)
1008     {
1009         return self::getActivityObjectProfileURI($activity->actor);
1010     }
1011
1012     /**
1013      * @param Activity $activity
1014      * @return string
1015      * @throws ServerException
1016      */
1017     protected static function getActivityObjectProfileURI($object)
1018     {
1019         $opts = array('allowed_schemes' => array('http', 'https'));
1020         if ($object->id && Validate::uri($object->id, $opts)) {
1021             return $object->id;
1022         }
1023         if ($object->link && Validate::uri($object->link, $opts)) {
1024             return $object->link;
1025         }
1026         throw new ServerException("No author ID URI found");
1027     }
1028
1029     /**
1030      * @fixme validate stuff somewhere
1031      */
1032
1033     protected static function createActorProfile($activity, $feeduri=null, $salmonuri=null)
1034     {
1035         $actor = $activity->actor;
1036
1037         self::createActivityObjectProfile($actor, $feeduri, $salmonuri);
1038     }
1039
1040     /**
1041      * Create local ostatus_profile and profile/user_group entries for
1042      * the provided remote user or group.
1043      *
1044      * @param ActivityObject $object
1045      * @param string $feeduri
1046      * @param string $salmonuri
1047      * @param array $hints
1048      *
1049      * @fixme fold $feeduri/$salmonuri into $hints
1050      * @return Ostatus_profile
1051      */
1052     protected static function createActivityObjectProfile($object, $feeduri=null, $salmonuri=null, $hints=array())
1053     {
1054         $homeuri  = $object->id;
1055
1056         if (!$homeuri) {
1057             common_log(LOG_DEBUG, __METHOD__ . " empty actor profile URI: " . var_export($activity, true));
1058             throw new ServerException("No profile URI");
1059         }
1060
1061         if (empty($feeduri)) {
1062             if (array_key_exists('feedurl', $hints)) {
1063                 $feeduri = $hints['feedurl'];
1064             }
1065         }
1066
1067         if (empty($salmonuri)) {
1068             if (array_key_exists('salmon', $hints)) {
1069                 $salmonuri = $hints['salmon'];
1070             }
1071         }
1072
1073         if (!$feeduri || !$salmonuri) {
1074             // Get the canonical feed URI and check it
1075             $discover = new FeedDiscovery();
1076             $feeduri = $discover->discoverFromURL($homeuri);
1077
1078             $huburi = $discover->getAtomLink('hub');
1079             $salmonuri = $discover->getAtomLink('salmon');
1080
1081             if (!$huburi) {
1082                 // We can only deal with folks with a PuSH hub
1083                 throw new FeedSubNoHubException();
1084             }
1085         }
1086
1087         $oprofile = new Ostatus_profile();
1088
1089         $oprofile->uri        = $homeuri;
1090         $oprofile->feeduri    = $feeduri;
1091         $oprofile->salmonuri  = $salmonuri;
1092
1093         $oprofile->created    = common_sql_now();
1094         $oprofile->modified   = common_sql_now();
1095
1096         if ($object->type == ActivityObject::PERSON) {
1097             $profile = new Profile();
1098             self::updateProfile($profile, $object, $hints);
1099             $profile->created  = common_sql_now();
1100
1101             $oprofile->profile_id = $profile->insert();
1102             if (!$oprofile->profile_id) {
1103                 throw new ServerException("Can't save local profile");
1104             }
1105         } else {
1106             $group = new User_group();
1107             $group->created = common_sql_now();
1108             self::updateGroup($group, $object, $hints);
1109
1110             $oprofile->group_id = $group->insert();
1111             if (!$oprofile->group_id) {
1112                 throw new ServerException("Can't save local profile");
1113             }
1114         }
1115
1116         $ok = $oprofile->insert();
1117
1118         if ($ok) {
1119             $avatar = self::getActivityObjectAvatar($object, $hints);
1120             if ($avatar) {
1121                 $oprofile->updateAvatar($avatar);
1122             }
1123             return $oprofile;
1124         } else {
1125             throw new ServerException("Can't save OStatus profile");
1126         }
1127     }
1128
1129     /**
1130      * Save any updated profile information to our local copy.
1131      * @param ActivityObject $object
1132      * @param array $hints
1133      */
1134     public function updateFromActivityObject($object, $hints=array())
1135     {
1136         if ($this->isGroup()) {
1137             $group = $this->localGroup();
1138             self::updateGroup($group, $object, $hints);
1139         } else {
1140             $profile = $this->localProfile();
1141             self::updateProfile($profile, $object, $hints);
1142         }
1143         $avatar = self::getActivityObjectAvatar($object, $hints);
1144         if ($avatar) {
1145             $this->updateAvatar($avatar);
1146         }
1147     }
1148
1149     protected static function updateProfile($profile, $object, $hints=array())
1150     {
1151         $orig = clone($profile);
1152
1153         $profile->nickname = self::getActivityObjectNickname($object, $hints);
1154         $profile->fullname = $object->title;
1155         if (!empty($object->link)) {
1156             $profile->profileurl = $object->link;
1157         } else if (array_key_exists('profileurl', $hints)) {
1158             $profile->profileurl = $hints['profileurl'];
1159         }
1160
1161         $profile->bio      = self::getActivityObjectBio($object, $hints);
1162         $profile->location = self::getActivityObjectLocation($object, $hints);
1163         $profile->homepage = self::getActivityObjectHomepage($object, $hints);
1164
1165         if (!empty($object->geopoint)) {
1166             $location = ActivityContext::locationFromPoint($object->geopoint);
1167             if (!empty($location)) {
1168                 $profile->lat = $location->lat;
1169                 $profile->lon = $location->lon;
1170             }
1171         }
1172
1173         // @fixme tags/categories
1174         // @todo tags from categories
1175
1176         if ($profile->id) {
1177             common_log(LOG_DEBUG, "Updating OStatus profile $profile->id from remote info $object->id: " . var_export($object, true) . var_export($hints, true));
1178             $profile->update($orig);
1179         }
1180     }
1181
1182     protected static function updateGroup($group, $object, $hints=array())
1183     {
1184         $orig = clone($group);
1185
1186         // @fixme need to make nick unique etc *hack hack*
1187         $group->nickname = self::getActivityObjectNickname($object, $hints);
1188         $group->fullname = $object->title;
1189
1190         // @fixme no canonical profileurl; using homepage instead for now
1191         $group->homepage = $object->id;
1192
1193         // @fixme homepage
1194         // @fixme bio
1195         // @fixme tags/categories
1196         // @fixme location?
1197         // @todo tags from categories
1198         // @todo lat/lon/location?
1199
1200         if ($group->id) {
1201             common_log(LOG_DEBUG, "Updating OStatus group $group->id from remote info $object->id: " . var_export($object, true) . var_export($hints, true));
1202             $group->update($orig);
1203         }
1204     }
1205
1206     protected static function getActivityObjectHomepage($object, $hints=array())
1207     {
1208         $homepage = null;
1209         $poco     = $object->poco;
1210
1211         if (!empty($poco)) {
1212             $url = $poco->getPrimaryURL();
1213             if ($url->type == 'homepage') {
1214                 $homepage = $url->value;
1215             }
1216         }
1217
1218         // @todo Try for a another PoCo URL?
1219
1220         return $homepage;
1221     }
1222
1223     protected static function getActivityObjectLocation($object, $hints=array())
1224     {
1225         $location = null;
1226
1227         if (!empty($object->poco)) {
1228             if (isset($object->poco->address->formatted)) {
1229                 $location = $object->poco->address->formatted;
1230                 if (mb_strlen($location) > 255) {
1231                     $location = mb_substr($note, 0, 255 - 3) . ' â€¦ ';
1232                 }
1233             }
1234         }
1235
1236         // @todo Try to find location some othe way? Via goerss point?
1237
1238         return $location;
1239     }
1240
1241     protected static function getActivityObjectBio($object, $hints=array())
1242     {
1243         $bio  = null;
1244
1245         if (!empty($object->poco)) {
1246             $note = $object->poco->note;
1247             if (!empty($note)) {
1248                 if (mb_strlen($note) > Profile::maxBio()) {
1249                     // XXX: truncate ok?
1250                     $bio = mb_substr($note, 0, Profile::maxBio() - 3) . ' â€¦ ';
1251                 } else {
1252                     $bio = $note;
1253                 }
1254             }
1255         }
1256
1257         // @todo Try to get bio info some other way?
1258
1259         return $bio;
1260     }
1261
1262     protected static function getActivityObjectNickname($object, $hints=array())
1263     {
1264         if ($object->poco) {
1265             if (!empty($object->poco->preferredUsername)) {
1266                 return common_nicknamize($object->poco->preferredUsername);
1267             }
1268         }
1269         if (!empty($object->nickname)) {
1270             return common_nicknamize($object->nickname);
1271         }
1272
1273         // Try the definitive ID
1274
1275         $nickname = self::nicknameFromURI($object->id);
1276
1277         // Try a Webfinger if one was passed (way) down
1278
1279         if (empty($nickname)) {
1280             if (array_key_exists('webfinger', $hints)) {
1281                 $nickname = self::nicknameFromURI($hints['webfinger']);
1282             }
1283         }
1284
1285         // Try the name
1286
1287         if (empty($nickname)) {
1288             $nickname = common_nicknamize($object->title);
1289         }
1290
1291         return $nickname;
1292     }
1293
1294     protected static function nicknameFromURI($uri)
1295     {
1296         preg_match('/(\w+):/', $uri, $matches);
1297
1298         $protocol = $matches[1];
1299
1300         switch ($protocol) {
1301         case 'acct':
1302         case 'mailto':
1303             if (preg_match("/^$protocol:(.*)?@.*\$/", $uri, $matches)) {
1304                 return common_canonical_nickname($matches[1]);
1305             }
1306             return null;
1307         case 'http':
1308             return common_url_to_nickname($uri);
1309             break;
1310         default:
1311             return null;
1312         }
1313     }
1314
1315     public static function ensureWebfinger($addr)
1316     {
1317         // First, look it up
1318
1319         $oprofile = Ostatus_profile::staticGet('uri', 'acct:'.$addr);
1320
1321         if (!empty($oprofile)) {
1322             return $oprofile;
1323         }
1324
1325         // Now, try some discovery
1326
1327         $wf = new Webfinger();
1328
1329         $result = $wf->lookup($addr);
1330
1331         if (!$result) {
1332             return null;
1333         }
1334
1335         foreach ($result->links as $link) {
1336             switch ($link['rel']) {
1337             case Webfinger::PROFILEPAGE:
1338                 $profileUrl = $link['href'];
1339                 break;
1340             case 'salmon':
1341                 $salmonEndpoint = $link['href'];
1342                 break;
1343             case Webfinger::UPDATESFROM:
1344                 $feedUrl = $link['href'];
1345                 break;
1346             default:
1347                 common_log(LOG_NOTICE, "Don't know what to do with rel = '{$link['rel']}'");
1348                 break;
1349             }
1350         }
1351
1352         $hints = array('webfinger' => $addr,
1353                        'profileurl' => $profileUrl,
1354                        'feedurl' => $feedUrl,
1355                        'salmon' => $salmonEndpoint);
1356
1357         // If we got a feed URL, try that
1358
1359         if (isset($feedUrl)) {
1360             try {
1361                 $oprofile = self::ensureProfile($feedUrl, $hints);
1362                 return $oprofile;
1363             } catch (Exception $e) {
1364                 common_log(LOG_WARNING, "Failed creating profile from feed URL '$feedUrl': " . $e->getMessage());
1365                 // keep looking
1366             }
1367         }
1368
1369         // If we got a profile page, try that!
1370
1371         if (isset($profileUrl)) {
1372             try {
1373                 $oprofile = self::ensureProfile($profileUrl, $hints);
1374                 return $oprofile;
1375             } catch (Exception $e) {
1376                 common_log(LOG_WARNING, "Failed creating profile from profile URL '$profileUrl': " . $e->getMessage());
1377                 // keep looking
1378             }
1379         }
1380
1381         // XXX: try hcard
1382         // XXX: try FOAF
1383
1384         if (isset($salmonEndpoint)) {
1385
1386             // An account URL, a salmon endpoint, and a dream? Not much to go
1387             // on, but let's give it a try
1388
1389             $uri = 'acct:'.$addr;
1390
1391             $profile = new Profile();
1392
1393             $profile->nickname = self::nicknameFromUri($uri);
1394             $profile->created  = common_sql_now();
1395
1396             if (isset($profileUrl)) {
1397                 $profile->profileurl = $profileUrl;
1398             }
1399
1400             $profile_id = $profile->insert();
1401
1402             if (!$profile_id) {
1403                 common_log_db_error($profile, 'INSERT', __FILE__);
1404                 throw new Exception("Couldn't save profile for '$addr'");
1405             }
1406
1407             $oprofile = new Ostatus_profile();
1408
1409             $oprofile->uri        = $uri;
1410             $oprofile->salmonuri  = $salmonEndpoint;
1411             $oprofile->profile_id = $profile_id;
1412             $oprofile->created    = common_sql_now();
1413
1414             if (isset($feedUrl)) {
1415                 $profile->feeduri = $feedUrl;
1416             }
1417
1418             $result = $oprofile->insert();
1419
1420             if (!$result) {
1421                 common_log_db_error($oprofile, 'INSERT', __FILE__);
1422                 throw new Exception("Couldn't save ostatus_profile for '$addr'");
1423             }
1424
1425             return $oprofile;
1426         }
1427
1428         return null;
1429     }
1430
1431     function saveHTMLFile($title, $rendered)
1432     {
1433         $final = sprintf("<!DOCTYPE html>\n<html><head><title>%s</title></head>".
1434                          '<body><div>%s</div></body></html>',
1435                          htmlspecialchars($title),
1436                          $rendered);
1437
1438         $filename = File::filename($this->localProfile(),
1439                                    'ostatus', // ignored?
1440                                    'text/html');
1441
1442         $filepath = File::path($filename);
1443
1444         file_put_contents($filepath, $final);
1445
1446         $file = new File;
1447
1448         $file->filename = $filename;
1449         $file->url      = File::url($filename);
1450         $file->size     = filesize($filepath);
1451         $file->date     = time();
1452         $file->mimetype = 'text/html';
1453
1454         $file_id = $file->insert();
1455
1456         if ($file_id === false) {
1457             common_log_db_error($file, "INSERT", __FILE__);
1458             throw new ServerException(_('Could not store HTML content of long post as file.'));
1459         }
1460
1461         return $file;
1462     }
1463 }