]> git.mxchange.org Git - quix0rs-gnu-social.git/blob - plugins/OStatus/classes/Ostatus_profile.php
Merge commit 'refs/merge-requests/159' of git://gitorious.org/statusnet/mainline...
[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 if (!defined('STATUSNET')) {
21     exit(1);
22 }
23
24 /**
25  * @package OStatusPlugin
26  * @maintainer Brion Vibber <brion@status.net>
27  */
28 class Ostatus_profile extends Managed_DataObject
29 {
30     public $__table = 'ostatus_profile';
31
32     public $uri;
33
34     public $profile_id;
35     public $group_id;
36     public $peopletag_id;
37
38     public $feeduri;
39     public $salmonuri;
40     public $avatar; // remote URL of the last avatar we saved
41
42     public $created;
43     public $modified;
44
45     public /*static*/ function staticGet($k, $v=null)
46     {
47         return parent::staticGet(__CLASS__, $k, $v);
48     }
49
50     /**
51      * Return table definition for Schema setup and DB_DataObject usage.
52      *
53      * @return array array of column definitions
54      */
55     static function schemaDef()
56     {
57         return array(
58             'fields' => array(
59                 'uri' => array('type' => 'varchar', 'length' => 255, 'not null' => true),
60                 'profile_id' => array('type' => 'integer'),
61                 'group_id' => array('type' => 'integer'),
62                 'peopletag_id' => array('type' => 'integer'),
63                 'feeduri' => array('type' => 'varchar', 'length' => 255),
64                 'salmonuri' => array('type' => 'varchar', 'length' => 255),
65                 'avatar' => array('type' => 'text'),
66                 'created' => array('type' => 'datetime', 'not null' => true),
67                 'modified' => array('type' => 'datetime', 'not null' => true),
68             ),
69             'primary key' => array('uri'),
70             'unique keys' => array(
71                 'ostatus_profile_profile_id_idx' => array('profile_id'),
72                 'ostatus_profile_group_id_idx' => array('group_id'),
73                 'ostatus_profile_peopletag_id_idx' => array('peopletag_id'),
74                 'ostatus_profile_feeduri_idx' => array('feeduri'),
75             ),
76             'foreign keys' => array(
77                 'ostatus_profile_profile_id_fkey' => array('profile', array('profile_id' => 'id')),
78                 'ostatus_profile_group_id_fkey' => array('user_group', array('group_id' => 'id')),
79                 'ostatus_profile_peopletag_id_fkey' => array('profile_list', array('peopletag_id' => 'id')),
80             ),
81         );
82     }
83
84     /**
85      * Fetch the StatusNet-side profile for this feed
86      * @return Profile
87      */
88     public function localProfile()
89     {
90         if ($this->profile_id) {
91             return Profile::staticGet('id', $this->profile_id);
92         }
93         return null;
94     }
95
96     /**
97      * Fetch the StatusNet-side profile for this feed
98      * @return Profile
99      */
100     public function localGroup()
101     {
102         if ($this->group_id) {
103             return User_group::staticGet('id', $this->group_id);
104         }
105         return null;
106     }
107
108     /**
109      * Fetch the StatusNet-side peopletag for this feed
110      * @return Profile
111      */
112     public function localPeopletag()
113     {
114         if ($this->peopletag_id) {
115             return Profile_list::staticGet('id', $this->peopletag_id);
116         }
117         return null;
118     }
119
120     /**
121      * Returns an ActivityObject describing this remote user or group profile.
122      * Can then be used to generate Atom chunks.
123      *
124      * @return ActivityObject
125      */
126     function asActivityObject()
127     {
128         if ($this->isGroup()) {
129             return ActivityObject::fromGroup($this->localGroup());
130         } else if ($this->isPeopletag()) {
131             return ActivityObject::fromPeopletag($this->localPeopletag());
132         } else {
133             return ActivityObject::fromProfile($this->localProfile());
134         }
135     }
136
137     /**
138      * Returns an XML string fragment with profile information as an
139      * Activity Streams noun object with the given element type.
140      *
141      * Assumes that 'activity' namespace has been previously defined.
142      *
143      * @todo FIXME: Replace with wrappers on asActivityObject when it's got everything.
144      *
145      * @param string $element one of 'actor', 'subject', 'object', 'target'
146      * @return string
147      */
148     function asActivityNoun($element)
149     {
150         if ($this->isGroup()) {
151             $noun = ActivityObject::fromGroup($this->localGroup());
152             return $noun->asString('activity:' . $element);
153         } else if ($this->isPeopletag()) {
154             $noun = ActivityObject::fromPeopletag($this->localPeopletag());
155             return $noun->asString('activity:' . $element);
156         } else {
157             $noun = ActivityObject::fromProfile($this->localProfile());
158             return $noun->asString('activity:' . $element);
159         }
160     }
161
162     /**
163      * @return boolean true if this is a remote group
164      */
165     function isGroup()
166     {
167         if ($this->profile_id || $this->peopletag_id && !$this->group_id) {
168             return false;
169         } else if ($this->group_id && !$this->profile_id && !$this->peopletag_id) {
170             return true;
171         } else if ($this->group_id && ($this->profile_id || $this->peopletag_id)) {
172             // TRANS: Server exception. %s is a URI
173             throw new ServerException(sprintf(_m('Invalid ostatus_profile state: Two or more IDs set for %s.'), $this->uri));
174         } else {
175             // TRANS: Server exception. %s is a URI
176             throw new ServerException(sprintf(_m('Invalid ostatus_profile state: All IDs empty for %s.'), $this->uri));
177         }
178     }
179
180     /**
181      * @return boolean true if this is a remote peopletag
182      */
183     function isPeopletag()
184     {
185         if ($this->profile_id || $this->group_id && !$this->peopletag_id) {
186             return false;
187         } else if ($this->peopletag_id && !$this->profile_id && !$this->group_id) {
188             return true;
189         } else if ($this->peopletag_id && ($this->profile_id || $this->group_id)) {
190             // TRANS: Server exception. %s is a URI
191             throw new ServerException(sprintf(_m('Invalid ostatus_profile state: Two or more IDs set for %s.'), $this->uri));
192         } else {
193             // TRANS: Server exception. %s is a URI
194             throw new ServerException(sprintf(_m('Invalid ostatus_profile state: All IDs empty for %s.'), $this->uri));
195         }
196     }
197
198     /**
199      * Send a subscription request to the hub for this feed.
200      * The hub will later send us a confirmation POST to /main/push/callback.
201      *
202      * @return bool true on success, false on failure
203      * @throws ServerException if feed state is not valid
204      */
205     public function subscribe()
206     {
207         $feedsub = FeedSub::ensureFeed($this->feeduri);
208         if ($feedsub->sub_state == 'active') {
209             // Active subscription, we don't need to do anything.
210             return true;
211         } else {
212             // Inactive or we got left in an inconsistent state.
213             // Run a subscription request to make sure we're current!
214             return $feedsub->subscribe();
215         }
216     }
217
218     /**
219      * Check if this remote profile has any active local subscriptions, and
220      * if not drop the PuSH subscription feed.
221      *
222      * @return bool true on success, false on failure
223      */
224     public function unsubscribe() {
225         $this->garbageCollect();
226     }
227
228     /**
229      * Check if this remote profile has any active local subscriptions, and
230      * if not drop the PuSH subscription feed.
231      *
232      * @return boolean
233      */
234     public function garbageCollect()
235     {
236         $feedsub = FeedSub::staticGet('uri', $this->feeduri);
237         return $feedsub->garbageCollect();
238     }
239
240     /**
241      * Check if this remote profile has any active local subscriptions, so the
242      * PuSH subscription layer can decide if it can drop the feed.
243      *
244      * This gets called via the FeedSubSubscriberCount event when running
245      * FeedSub::garbageCollect().
246      *
247      * @return int
248      */
249     public function subscriberCount()
250     {
251         if ($this->isGroup()) {
252             $members = $this->localGroup()->getMembers(0, 1);
253             $count = $members->N;
254         } else if ($this->isPeopletag()) {
255             $subscribers = $this->localPeopletag()->getSubscribers(0, 1);
256             $count = $subscribers->N;
257         } else {
258             $profile = $this->localProfile();
259             $count = $profile->subscriberCount();
260             if ($profile->hasLocalTags()) {
261                 $count = 1;
262             }
263         }
264         common_log(LOG_INFO, __METHOD__ . " SUB COUNT BEFORE: $count");
265
266         // Other plugins may be piggybacking on OStatus without having
267         // an active group or user-to-user subscription we know about.
268         Event::handle('Ostatus_profileSubscriberCount', array($this, &$count));
269         common_log(LOG_INFO, __METHOD__ . " SUB COUNT AFTER: $count");
270
271         return $count;
272     }
273
274     /**
275      * Send an Activity Streams notification to the remote Salmon endpoint,
276      * if so configured.
277      *
278      * @param Profile $actor  Actor who did the activity
279      * @param string  $verb   Activity::SUBSCRIBE or Activity::JOIN
280      * @param Object  $object object of the action; must define asActivityNoun($tag)
281      */
282     public function notify($actor, $verb, $object=null, $target=null)
283     {
284         if (!($actor instanceof Profile)) {
285             $type = gettype($actor);
286             if ($type == 'object') {
287                 $type = get_class($actor);
288             }
289             // TRANS: Server exception.
290             // TRANS: %1$s is the method name the exception occured in, %2$s is the actor type.
291             throw new ServerException(sprintf(_m('Invalid actor passed to %1$s: %2$s.'),__METHOD__,$type));
292         }
293         if ($object == null) {
294             $object = $this;
295         }
296         if ($this->salmonuri) {
297             $text = 'update';
298             $id = TagURI::mint('%s:%s:%s',
299                                $verb,
300                                $actor->getURI(),
301                                common_date_iso8601(time()));
302
303             // @todo FIXME: Consolidate all these NS settings somewhere.
304             $attributes = array('xmlns' => Activity::ATOM,
305                                 'xmlns:activity' => 'http://activitystrea.ms/spec/1.0/',
306                                 'xmlns:thr' => 'http://purl.org/syndication/thread/1.0',
307                                 'xmlns:georss' => 'http://www.georss.org/georss',
308                                 'xmlns:ostatus' => 'http://ostatus.org/schema/1.0',
309                                 'xmlns:poco' => 'http://portablecontacts.net/spec/1.0',
310                                 'xmlns:media' => 'http://purl.org/syndication/atommedia');
311
312             $entry = new XMLStringer();
313             $entry->elementStart('entry', $attributes);
314             $entry->element('id', null, $id);
315             $entry->element('title', null, $text);
316             $entry->element('summary', null, $text);
317             $entry->element('published', null, common_date_w3dtf(common_sql_now()));
318
319             $entry->element('activity:verb', null, $verb);
320             $entry->raw($actor->asAtomAuthor());
321             $entry->raw($actor->asActivityActor());
322             $entry->raw($object->asActivityNoun('object'));
323             if ($target != null) {
324                 $entry->raw($target->asActivityNoun('target'));
325             }
326             $entry->elementEnd('entry');
327
328             $xml = $entry->getString();
329             common_log(LOG_INFO, "Posting to Salmon endpoint $this->salmonuri: $xml");
330
331             $salmon = new Salmon(); // ?
332             return $salmon->post($this->salmonuri, $xml, $actor);
333         }
334         return false;
335     }
336
337     /**
338      * Send a Salmon notification ping immediately, and confirm that we got
339      * an acceptable response from the remote site.
340      *
341      * @param mixed $entry XML string, Notice, or Activity
342      * @param Profile $actor
343      * @return boolean success
344      */
345     public function notifyActivity($entry, $actor)
346     {
347         if ($this->salmonuri) {
348             $salmon = new Salmon();
349             return $salmon->post($this->salmonuri, $this->notifyPrepXml($entry), $actor);
350         }
351
352         return false;
353     }
354
355     /**
356      * Queue a Salmon notification for later. If queues are disabled we'll
357      * send immediately but won't get the return value.
358      *
359      * @param mixed $entry XML string, Notice, or Activity
360      * @return boolean success
361      */
362     public function notifyDeferred($entry, $actor)
363     {
364         if ($this->salmonuri) {
365             $data = array('salmonuri' => $this->salmonuri,
366                           'entry' => $this->notifyPrepXml($entry),
367                           'actor' => $actor->id);
368
369             $qm = QueueManager::get();
370             return $qm->enqueue($data, 'salmon');
371         }
372
373         return false;
374     }
375
376     protected function notifyPrepXml($entry)
377     {
378         $preamble = '<?xml version="1.0" encoding="UTF-8" ?' . '>';
379         if (is_string($entry)) {
380             return $entry;
381         } else if ($entry instanceof Activity) {
382             return $preamble . $entry->asString(true);
383         } else if ($entry instanceof Notice) {
384             return $preamble . $entry->asAtomEntry(true, true);
385         } else {
386             // TRANS: Server exception.
387             throw new ServerException(_m('Invalid type passed to Ostatus_profile::notify. It must be XML string or Activity entry.'));
388         }
389     }
390
391     function getBestName()
392     {
393         if ($this->isGroup()) {
394             return $this->localGroup()->getBestName();
395         } else if ($this->isPeopletag()) {
396             return $this->localPeopletag()->getBestName();
397         } else {
398             return $this->localProfile()->getBestName();
399         }
400     }
401
402     /**
403      * Read and post notices for updates from the feed.
404      * Currently assumes that all items in the feed are new,
405      * coming from a PuSH hub.
406      *
407      * @param DOMDocument $doc
408      * @param string $source identifier ("push")
409      */
410     public function processFeed(DOMDocument $doc, $source)
411     {
412         $feed = $doc->documentElement;
413
414         if ($feed->localName == 'feed' && $feed->namespaceURI == Activity::ATOM) {
415             $this->processAtomFeed($feed, $source);
416         } else if ($feed->localName == 'rss') { // @todo FIXME: Check namespace.
417             $this->processRssFeed($feed, $source);
418         } else {
419             // TRANS: Exception.
420             throw new Exception(_m('Unknown feed format.'));
421         }
422     }
423
424     public function processAtomFeed(DOMElement $feed, $source)
425     {
426         $entries = $feed->getElementsByTagNameNS(Activity::ATOM, 'entry');
427         if ($entries->length == 0) {
428             common_log(LOG_ERR, __METHOD__ . ": no entries in feed update, ignoring");
429             return;
430         }
431
432         for ($i = 0; $i < $entries->length; $i++) {
433             $entry = $entries->item($i);
434             $this->processEntry($entry, $feed, $source);
435         }
436     }
437
438     public function processRssFeed(DOMElement $rss, $source)
439     {
440         $channels = $rss->getElementsByTagName('channel');
441
442         if ($channels->length == 0) {
443             // TRANS: Exception.
444             throw new Exception(_m('RSS feed without a channel.'));
445         } else if ($channels->length > 1) {
446             common_log(LOG_WARNING, __METHOD__ . ": more than one channel in an RSS feed");
447         }
448
449         $channel = $channels->item(0);
450
451         $items = $channel->getElementsByTagName('item');
452
453         for ($i = 0; $i < $items->length; $i++) {
454             $item = $items->item($i);
455             $this->processEntry($item, $channel, $source);
456         }
457     }
458
459     /**
460      * Process a posted entry from this feed source.
461      *
462      * @param DOMElement $entry
463      * @param DOMElement $feed for context
464      * @param string $source identifier ("push" or "salmon")
465      *
466      * @return Notice Notice representing the new (or existing) activity
467      */
468     public function processEntry($entry, $feed, $source)
469     {
470         $activity = new Activity($entry, $feed);
471         return $this->processActivity($activity, $source);
472     }
473
474     public function processActivity($activity, $source)
475     {
476         $notice = null;
477
478         // The "WithProfile" events were added later.
479
480         if (Event::handle('StartHandleFeedEntryWithProfile', array($activity, $this, &$notice)) &&
481             Event::handle('StartHandleFeedEntry', array($activity))) {
482
483             switch ($activity->verb) {
484             case ActivityVerb::POST:
485                 // @todo process all activity objects
486                 switch ($activity->objects[0]->type) {
487                 case ActivityObject::ARTICLE:
488                 case ActivityObject::BLOGENTRY:
489                 case ActivityObject::NOTE:
490                 case ActivityObject::STATUS:
491                 case ActivityObject::COMMENT:
492                 case null:
493                     $notice = $this->processPost($activity, $source);
494                     break;
495                 default:
496                     // TRANS: Client exception.
497                     throw new ClientException(_m('Cannot handle that kind of post.'));
498                 }
499                 break;
500             case ActivityVerb::SHARE:
501                 $notice = $this->processShare($activity, $source);
502                 break;
503             default:
504                 common_log(LOG_INFO, "Ignoring activity with unrecognized verb $activity->verb");
505             }
506
507             Event::handle('EndHandleFeedEntry', array($activity));
508             Event::handle('EndHandleFeedEntryWithProfile', array($activity, $this, $notice));
509         }
510
511         return $notice;
512     }
513
514     public function processShare($activity, $method)
515     {
516         $notice = null;
517
518         $oprofile = $this->checkAuthorship($activity);
519
520         if (empty($oprofile)) {
521             common_log(LOG_INFO, "No author matched share activity");
522             return null;
523         }
524
525         if (count($activity->objects) != 1) {
526             // TRANS: Client exception thrown when trying to share multiple activities at once.
527             throw new ClientException(_m('Can only handle share activities with exactly one object.'));
528         }
529
530         $shared = $activity->objects[0];
531
532         if (!($shared instanceof Activity)) {
533             // TRANS: Client exception thrown when trying to share a non-activity object.
534             throw new ClientException(_m('Can only handle shared activities.'));
535         }
536
537         $other = Ostatus_profile::ensureActivityObjectProfile($shared->actor);
538
539         // Save the item (or check for a dupe)
540
541         $sharedNotice = $other->processActivity($shared, $method);
542
543         if (empty($sharedNotice)) {
544             $sharedId = ($shared->id) ? $shared->id : $shared->objects[0]->id;
545             // TRANS: Client exception thrown when saving an activity share fails.
546             // TRANS: %s is a share ID.
547             throw new ClientException(sprintf(_m('Failed to save activity %s.'),
548                                               $sharedId));
549         }
550
551         // The id URI will be used as a unique identifier for for the notice,
552         // protecting against duplicate saves. It isn't required to be a URL;
553         // tag: URIs for instance are found in Google Buzz feeds.
554
555         $sourceUri = $activity->id;
556
557         $dupe = Notice::staticGet('uri', $sourceUri);
558         if ($dupe) {
559             common_log(LOG_INFO, "OStatus: ignoring duplicate post: $sourceUri");
560             return $dupe;
561         }
562
563         // We'll also want to save a web link to the original notice, if provided.
564
565         $sourceUrl = null;
566         if ($activity->link) {
567             $sourceUrl = $activity->link;
568         } else if ($activity->link) {
569             $sourceUrl = $activity->link;
570         } else if (preg_match('!^https?://!', $activity->id)) {
571             $sourceUrl = $activity->id;
572         }
573
574         // Use summary as fallback for content
575
576         if (!empty($activity->content)) {
577             $sourceContent = $activity->content;
578         } else if (!empty($activity->summary)) {
579             $sourceContent = $activity->summary;
580         } else if (!empty($activity->title)) {
581             $sourceContent = $activity->title;
582         } else {
583             // @todo FIXME: Fetch from $sourceUrl?
584             // TRANS: Client exception. %s is a source URI.
585             throw new ClientException(sprintf(_m('No content for notice %s.'),$sourceUri));
586         }
587
588         // Get (safe!) HTML and text versions of the content
589
590         $rendered = $this->purify($sourceContent);
591         $content = html_entity_decode(strip_tags($rendered), ENT_QUOTES, 'UTF-8');
592
593         $shortened = common_shorten_links($content);
594
595         // If it's too long, try using the summary, and make the
596         // HTML an attachment.
597
598         $attachment = null;
599
600         if (Notice::contentTooLong($shortened)) {
601             $attachment = $this->saveHTMLFile($activity->title, $rendered);
602             $summary = html_entity_decode(strip_tags($activity->summary), ENT_QUOTES, 'UTF-8');
603             if (empty($summary)) {
604                 $summary = $content;
605             }
606             $shortSummary = common_shorten_links($summary);
607             if (Notice::contentTooLong($shortSummary)) {
608                 $url = common_shorten_url($sourceUrl);
609                 $shortSummary = substr($shortSummary,
610                                        0,
611                                        Notice::maxContent() - (mb_strlen($url) + 2));
612                 $content = $shortSummary . ' ' . $url;
613
614                 // We mark up the attachment link specially for the HTML output
615                 // so we can fold-out the full version inline.
616
617                 // @todo FIXME i18n: This tooltip will be saved with the site's default language
618                 // TRANS: Shown when a notice is longer than supported and/or when attachments are present. At runtime
619                 // TRANS: this will usually be replaced with localised text from StatusNet core messages.
620                 $showMoreText = _m('Show more');
621                 $attachUrl = common_local_url('attachment',
622                                               array('attachment' => $attachment->id));
623                 $rendered = common_render_text($shortSummary) .
624                             '<a href="' . htmlspecialchars($attachUrl) .'"'.
625                             ' class="attachment more"' .
626                             ' title="'. htmlspecialchars($showMoreText) . '">' .
627                             '&#8230;' .
628                             '</a>';
629             }
630         }
631
632         $options = array('is_local' => Notice::REMOTE,
633                          'url' => $sourceUrl,
634                          'uri' => $sourceUri,
635                          'rendered' => $rendered,
636                          'replies' => array(),
637                          'groups' => array(),
638                          'peopletags' => array(),
639                          'tags' => array(),
640                          'urls' => array(),
641                          'repeat_of' => $sharedNotice->id,
642                          'scope' => $sharedNotice->scope);
643
644         // Check for optional attributes...
645
646         if (!empty($activity->time)) {
647             $options['created'] = common_sql_date($activity->time);
648         }
649
650         if ($activity->context) {
651             // Any individual or group attn: targets?
652             $replies = $activity->context->attention;
653             $options['groups'] = $this->filterReplies($oprofile, $replies);
654             $options['replies'] = $replies;
655
656             // Maintain direct reply associations
657             // @todo FIXME: What about conversation ID?
658             if (!empty($activity->context->replyToID)) {
659                 $orig = Notice::staticGet('uri',
660                                           $activity->context->replyToID);
661                 if (!empty($orig)) {
662                     $options['reply_to'] = $orig->id;
663                 }
664             }
665
666             $location = $activity->context->location;
667             if ($location) {
668                 $options['lat'] = $location->lat;
669                 $options['lon'] = $location->lon;
670                 if ($location->location_id) {
671                     $options['location_ns'] = $location->location_ns;
672                     $options['location_id'] = $location->location_id;
673                 }
674             }
675         }
676
677         if ($this->isPeopletag()) {
678             $options['peopletags'][] = $this->localPeopletag();
679         }
680
681         // Atom categories <-> hashtags
682         foreach ($activity->categories as $cat) {
683             if ($cat->term) {
684                 $term = common_canonical_tag($cat->term);
685                 if ($term) {
686                     $options['tags'][] = $term;
687                 }
688             }
689         }
690
691         // Atom enclosures -> attachment URLs
692         foreach ($activity->enclosures as $href) {
693             // @todo FIXME: Save these locally or....?
694             $options['urls'][] = $href;
695         }
696
697         $notice = Notice::saveNew($oprofile->profile_id,
698                                   $content,
699                                   'ostatus',
700                                   $options);
701
702         return $notice;
703     }
704
705     /**
706      * Process an incoming post activity from this remote feed.
707      * @param Activity $activity
708      * @param string $method 'push' or 'salmon'
709      * @return mixed saved Notice or false
710      * @todo FIXME: Break up this function, it's getting nasty long
711      */
712     public function processPost($activity, $method)
713     {
714         $notice = null;
715
716         $oprofile = $this->checkAuthorship($activity);
717
718         if (empty($oprofile)) {
719             return null;
720         }
721
722         // It's not always an ActivityObject::NOTE, but... let's just say it is.
723
724         $note = $activity->objects[0];
725
726         // The id URI will be used as a unique identifier for for the notice,
727         // protecting against duplicate saves. It isn't required to be a URL;
728         // tag: URIs for instance are found in Google Buzz feeds.
729         $sourceUri = $note->id;
730         $dupe = Notice::staticGet('uri', $sourceUri);
731         if ($dupe) {
732             common_log(LOG_INFO, "OStatus: ignoring duplicate post: $sourceUri");
733             return $dupe;
734         }
735
736         // We'll also want to save a web link to the original notice, if provided.
737         $sourceUrl = null;
738         if ($note->link) {
739             $sourceUrl = $note->link;
740         } else if ($activity->link) {
741             $sourceUrl = $activity->link;
742         } else if (preg_match('!^https?://!', $note->id)) {
743             $sourceUrl = $note->id;
744         }
745
746         // Use summary as fallback for content
747
748         if (!empty($note->content)) {
749             $sourceContent = $note->content;
750         } else if (!empty($note->summary)) {
751             $sourceContent = $note->summary;
752         } else if (!empty($note->title)) {
753             $sourceContent = $note->title;
754         } else {
755             // @todo FIXME: Fetch from $sourceUrl?
756             // TRANS: Client exception. %s is a source URI.
757             throw new ClientException(sprintf(_m('No content for notice %s.'),$sourceUri));
758         }
759
760         // Get (safe!) HTML and text versions of the content
761
762         $rendered = $this->purify($sourceContent);
763         $content = html_entity_decode(strip_tags($rendered), ENT_QUOTES, 'UTF-8');
764
765         $shortened = common_shorten_links($content);
766
767         // If it's too long, try using the summary, and make the
768         // HTML an attachment.
769
770         $attachment = null;
771
772         if (Notice::contentTooLong($shortened)) {
773             $attachment = $this->saveHTMLFile($note->title, $rendered);
774             $summary = html_entity_decode(strip_tags($note->summary), ENT_QUOTES, 'UTF-8');
775             if (empty($summary)) {
776                 $summary = $content;
777             }
778             $shortSummary = common_shorten_links($summary);
779             if (Notice::contentTooLong($shortSummary)) {
780                 $url = common_shorten_url($sourceUrl);
781                 $shortSummary = substr($shortSummary,
782                                        0,
783                                        Notice::maxContent() - (mb_strlen($url) + 2));
784                 $content = $shortSummary . ' ' . $url;
785
786                 // We mark up the attachment link specially for the HTML output
787                 // so we can fold-out the full version inline.
788
789                 // @todo FIXME i18n: This tooltip will be saved with the site's default language
790                 // TRANS: Shown when a notice is longer than supported and/or when attachments are present. At runtime
791                 // TRANS: this will usually be replaced with localised text from StatusNet core messages.
792                 $showMoreText = _m('Show more');
793                 $attachUrl = common_local_url('attachment',
794                                               array('attachment' => $attachment->id));
795                 $rendered = common_render_text($shortSummary) .
796                             '<a href="' . htmlspecialchars($attachUrl) .'"'.
797                             ' class="attachment more"' .
798                             ' title="'. htmlspecialchars($showMoreText) . '">' .
799                             '&#8230;' .
800                             '</a>';
801             }
802         }
803
804         $options = array('is_local' => Notice::REMOTE,
805                         'url' => $sourceUrl,
806                         'uri' => $sourceUri,
807                         'rendered' => $rendered,
808                         'replies' => array(),
809                         'groups' => array(),
810                         'peopletags' => array(),
811                         'tags' => array(),
812                         'urls' => array());
813
814         // Check for optional attributes...
815
816         if (!empty($activity->time)) {
817             $options['created'] = common_sql_date($activity->time);
818         }
819
820         if ($activity->context) {
821             // Any individual or group attn: targets?
822             $replies = $activity->context->attention;
823             $options['groups'] = $this->filterReplies($oprofile, $replies);
824             $options['replies'] = $replies;
825
826             // Maintain direct reply associations
827             // @todo FIXME: What about conversation ID?
828             if (!empty($activity->context->replyToID)) {
829                 $orig = Notice::staticGet('uri',
830                                           $activity->context->replyToID);
831                 if (!empty($orig)) {
832                     $options['reply_to'] = $orig->id;
833                 }
834             }
835
836             $location = $activity->context->location;
837             if ($location) {
838                 $options['lat'] = $location->lat;
839                 $options['lon'] = $location->lon;
840                 if ($location->location_id) {
841                     $options['location_ns'] = $location->location_ns;
842                     $options['location_id'] = $location->location_id;
843                 }
844             }
845         }
846
847         if ($this->isPeopletag()) {
848             $options['peopletags'][] = $this->localPeopletag();
849         }
850
851         // Atom categories <-> hashtags
852         foreach ($activity->categories as $cat) {
853             if ($cat->term) {
854                 $term = common_canonical_tag($cat->term);
855                 if ($term) {
856                     $options['tags'][] = $term;
857                 }
858             }
859         }
860
861         // Atom enclosures -> attachment URLs
862         foreach ($activity->enclosures as $href) {
863             // @todo FIXME: Save these locally or....?
864             $options['urls'][] = $href;
865         }
866
867         try {
868             $saved = Notice::saveNew($oprofile->profile_id,
869                                      $content,
870                                      'ostatus',
871                                      $options);
872             if ($saved) {
873                 Ostatus_source::saveNew($saved, $this, $method);
874                 if (!empty($attachment)) {
875                     File_to_post::processNew($attachment->id, $saved->id);
876                 }
877             }
878         } catch (Exception $e) {
879             common_log(LOG_ERR, "OStatus save of remote message $sourceUri failed: " . $e->getMessage());
880             throw $e;
881         }
882         common_log(LOG_INFO, "OStatus saved remote message $sourceUri as notice id $saved->id");
883         return $saved;
884     }
885
886     /**
887      * Clean up HTML
888      */
889     protected function purify($html)
890     {
891         require_once INSTALLDIR.'/extlib/htmLawed/htmLawed.php';
892         $config = array('safe' => 1,
893                         'deny_attribute' => 'id,style,on*');
894         return htmLawed($html, $config);
895     }
896
897     /**
898      * Filters a list of recipient ID URIs to just those for local delivery.
899      * @param Ostatus_profile local profile of sender
900      * @param array in/out &$attention_uris set of URIs, will be pruned on output
901      * @return array of group IDs
902      */
903     protected function filterReplies($sender, &$attention_uris)
904     {
905         common_log(LOG_DEBUG, "Original reply recipients: " . implode(', ', $attention_uris));
906         $groups = array();
907         $replies = array();
908         foreach (array_unique($attention_uris) as $recipient) {
909             // Is the recipient a local user?
910             $user = User::staticGet('uri', $recipient);
911             if ($user) {
912                 // @todo FIXME: Sender verification, spam etc?
913                 $replies[] = $recipient;
914                 continue;
915             }
916
917             // Is the recipient a local group?
918             // $group = User_group::staticGet('uri', $recipient);
919             $id = OStatusPlugin::localGroupFromUrl($recipient);
920             if ($id) {
921                 $group = User_group::staticGet('id', $id);
922                 if ($group) {
923                     // Deliver to all members of this local group if allowed.
924                     $profile = $sender->localProfile();
925                     if ($profile->isMember($group)) {
926                         $groups[] = $group->id;
927                     } else {
928                         common_log(LOG_DEBUG, "Skipping reply to local group $group->nickname as sender $profile->id is not a member");
929                     }
930                     continue;
931                 } else {
932                     common_log(LOG_DEBUG, "Skipping reply to bogus group $recipient");
933                 }
934             }
935
936             // Is the recipient a remote user or group?
937             try {
938                 $oprofile = Ostatus_profile::ensureProfileURI($recipient);
939                 if ($oprofile->isGroup()) {
940                     // Deliver to local members of this remote group.
941                     // @todo FIXME: Sender verification?
942                     $groups[] = $oprofile->group_id;
943                 } else {
944                     // may be canonicalized or something
945                     $replies[] = $oprofile->uri;
946                 }
947                 continue;
948             } catch (Exception $e) {
949                 // Neither a recognizable local nor remote user!
950                 common_log(LOG_DEBUG, "Skipping reply to unrecognized profile $recipient: " . $e->getMessage());
951             }
952
953         }
954         $attention_uris = $replies;
955         common_log(LOG_DEBUG, "Local reply recipients: " . implode(', ', $replies));
956         common_log(LOG_DEBUG, "Local group recipients: " . implode(', ', $groups));
957         return $groups;
958     }
959
960     /**
961      * Look up and if necessary create an Ostatus_profile for the remote entity
962      * with the given profile page URL. This should never return null -- you
963      * will either get an object or an exception will be thrown.
964      *
965      * @param string $profile_url
966      * @return Ostatus_profile
967      * @throws Exception on various error conditions
968      * @throws OStatusShadowException if this reference would obscure a local user/group
969      */
970     public static function ensureProfileURL($profile_url, $hints=array())
971     {
972         $oprofile = self::getFromProfileURL($profile_url);
973
974         if (!empty($oprofile)) {
975             return $oprofile;
976         }
977
978         $hints['profileurl'] = $profile_url;
979
980         // Fetch the URL
981         // XXX: HTTP caching
982
983         $client = new HTTPClient();
984         $client->setHeader('Accept', 'text/html,application/xhtml+xml');
985         $response = $client->get($profile_url);
986
987         if (!$response->isOk()) {
988             // TRANS: Exception. %s is a profile URL.
989             throw new Exception(sprintf(_m('Could not reach profile page %s.'),$profile_url));
990         }
991
992         // Check if we have a non-canonical URL
993
994         $finalUrl = $response->getUrl();
995
996         if ($finalUrl != $profile_url) {
997
998             $hints['profileurl'] = $finalUrl;
999
1000             $oprofile = self::getFromProfileURL($finalUrl);
1001
1002             if (!empty($oprofile)) {
1003                 return $oprofile;
1004             }
1005         }
1006
1007         // Try to get some hCard data
1008
1009         $body = $response->getBody();
1010
1011         $hcardHints = DiscoveryHints::hcardHints($body, $finalUrl);
1012
1013         if (!empty($hcardHints)) {
1014             $hints = array_merge($hints, $hcardHints);
1015         }
1016
1017         // Check if they've got an LRDD header
1018
1019         $lrdd = LinkHeader::getLink($response, 'lrdd', 'application/xrd+xml');
1020
1021         if (!empty($lrdd)) {
1022
1023             $xrd = Discovery::fetchXrd($lrdd);
1024             $xrdHints = DiscoveryHints::fromXRD($xrd);
1025
1026             $hints = array_merge($hints, $xrdHints);
1027         }
1028
1029         // If discovery found a feedurl (probably from LRDD), use it.
1030
1031         if (array_key_exists('feedurl', $hints)) {
1032             return self::ensureFeedURL($hints['feedurl'], $hints);
1033         }
1034
1035         // Get the feed URL from HTML
1036
1037         $discover = new FeedDiscovery();
1038
1039         $feedurl = $discover->discoverFromHTML($finalUrl, $body);
1040
1041         if (!empty($feedurl)) {
1042             $hints['feedurl'] = $feedurl;
1043             return self::ensureFeedURL($feedurl, $hints);
1044         }
1045
1046         // TRANS: Exception. %s is a URL.
1047         throw new Exception(sprintf(_m('Could not find a feed URL for profile page %s.'),$finalUrl));
1048     }
1049
1050     /**
1051      * Look up the Ostatus_profile, if present, for a remote entity with the
1052      * given profile page URL. Will return null for both unknown and invalid
1053      * remote profiles.
1054      *
1055      * @return mixed Ostatus_profile or null
1056      * @throws OStatusShadowException for local profiles
1057      */
1058     static function getFromProfileURL($profile_url)
1059     {
1060         $profile = Profile::staticGet('profileurl', $profile_url);
1061
1062         if (empty($profile)) {
1063             return null;
1064         }
1065
1066         // Is it a known Ostatus profile?
1067
1068         $oprofile = Ostatus_profile::staticGet('profile_id', $profile->id);
1069
1070         if (!empty($oprofile)) {
1071             return $oprofile;
1072         }
1073
1074         // Is it a local user?
1075
1076         $user = User::staticGet('id', $profile->id);
1077
1078         if (!empty($user)) {
1079             // @todo i18n FIXME: use sprintf and add i18n (?)
1080             throw new OStatusShadowException($profile, "'$profile_url' is the profile for local user '{$user->nickname}'.");
1081         }
1082
1083         // Continue discovery; it's a remote profile
1084         // for OMB or some other protocol, may also
1085         // support OStatus
1086
1087         return null;
1088     }
1089
1090     /**
1091      * Look up and if necessary create an Ostatus_profile for remote entity
1092      * with the given update feed. This should never return null -- you will
1093      * either get an object or an exception will be thrown.
1094      *
1095      * @return Ostatus_profile
1096      * @throws Exception
1097      */
1098     public static function ensureFeedURL($feed_url, $hints=array())
1099     {
1100         $discover = new FeedDiscovery();
1101
1102         $feeduri = $discover->discoverFromFeedURL($feed_url);
1103         $hints['feedurl'] = $feeduri;
1104
1105         $huburi = $discover->getHubLink();
1106         $hints['hub'] = $huburi;
1107         $salmonuri = $discover->getAtomLink(Salmon::NS_REPLIES);
1108         $hints['salmon'] = $salmonuri;
1109
1110         if (!$huburi && !common_config('feedsub', 'fallback_hub')) {
1111             // We can only deal with folks with a PuSH hub
1112             throw new FeedSubNoHubException();
1113         }
1114
1115         $feedEl = $discover->root;
1116
1117         if ($feedEl->tagName == 'feed') {
1118             return self::ensureAtomFeed($feedEl, $hints);
1119         } else if ($feedEl->tagName == 'channel') {
1120             return self::ensureRssChannel($feedEl, $hints);
1121         } else {
1122             throw new FeedSubBadXmlException($feeduri);
1123         }
1124     }
1125
1126     /**
1127      * Look up and, if necessary, create an Ostatus_profile for the remote
1128      * profile with the given Atom feed - actually loaded from the feed.
1129      * This should never return null -- you will either get an object or
1130      * an exception will be thrown.
1131      *
1132      * @param DOMElement $feedEl root element of a loaded Atom feed
1133      * @param array $hints additional discovery information passed from higher levels
1134      * @todo FIXME: Should this be marked public?
1135      * @return Ostatus_profile
1136      * @throws Exception
1137      */
1138     public static function ensureAtomFeed($feedEl, $hints)
1139     {
1140         $author = ActivityUtils::getFeedAuthor($feedEl);
1141
1142         if (empty($author)) {
1143             // XXX: make some educated guesses here
1144             // TRANS: Feed sub exception.
1145             throw new FeedSubException(_m('Cannot find enough profile '.
1146                                           'information to make a feed.'));
1147         }
1148
1149         return self::ensureActivityObjectProfile($author, $hints);
1150     }
1151
1152     /**
1153      * Look up and, if necessary, create an Ostatus_profile for the remote
1154      * profile with the given RSS feed - actually loaded from the feed.
1155      * This should never return null -- you will either get an object or
1156      * an exception will be thrown.
1157      *
1158      * @param DOMElement $feedEl root element of a loaded RSS feed
1159      * @param array $hints additional discovery information passed from higher levels
1160      * @todo FIXME: Should this be marked public?
1161      * @return Ostatus_profile
1162      * @throws Exception
1163      */
1164     public static function ensureRssChannel($feedEl, $hints)
1165     {
1166         // Special-case for Posterous. They have some nice metadata in their
1167         // posterous:author elements. We should use them instead of the channel.
1168
1169         $items = $feedEl->getElementsByTagName('item');
1170
1171         if ($items->length > 0) {
1172             $item = $items->item(0);
1173             $authorEl = ActivityUtils::child($item, ActivityObject::AUTHOR, ActivityObject::POSTEROUS);
1174             if (!empty($authorEl)) {
1175                 $obj = ActivityObject::fromPosterousAuthor($authorEl);
1176                 // Posterous has multiple authors per feed, and multiple feeds
1177                 // per author. We check if this is the "main" feed for this author.
1178                 if (array_key_exists('profileurl', $hints) &&
1179                     !empty($obj->poco) &&
1180                     common_url_to_nickname($hints['profileurl']) == $obj->poco->preferredUsername) {
1181                     return self::ensureActivityObjectProfile($obj, $hints);
1182                 }
1183             }
1184         }
1185
1186         // @todo FIXME: We should check whether this feed has elements
1187         // with different <author> or <dc:creator> elements, and... I dunno.
1188         // Do something about that.
1189
1190         $obj = ActivityObject::fromRssChannel($feedEl);
1191
1192         return self::ensureActivityObjectProfile($obj, $hints);
1193     }
1194
1195     /**
1196      * Download and update given avatar image
1197      *
1198      * @param string $url
1199      * @throws Exception in various failure cases
1200      */
1201     protected function updateAvatar($url)
1202     {
1203         if ($url == $this->avatar) {
1204             // We've already got this one.
1205             return;
1206         }
1207         if (!common_valid_http_url($url)) {
1208             // TRANS: Server exception. %s is a URL.
1209             throw new ServerException(sprintf(_m('Invalid avatar URL %s.'), $url));
1210         }
1211
1212         if ($this->isGroup()) {
1213             $self = $this->localGroup();
1214         } else {
1215             $self = $this->localProfile();
1216         }
1217         if (!$self) {
1218             throw new ServerException(sprintf(
1219                 // TRANS: Server exception. %s is a URI.
1220                 _m('Tried to update avatar for unsaved remote profile %s.'),
1221                 $this->uri));
1222         }
1223
1224         // @todo FIXME: This should be better encapsulated
1225         // ripped from oauthstore.php (for old OMB client)
1226         $temp_filename = tempnam(sys_get_temp_dir(), 'listener_avatar');
1227         try {
1228             if (!copy($url, $temp_filename)) {
1229                 // TRANS: Server exception. %s is a URL.
1230                 throw new ServerException(sprintf(_m('Unable to fetch avatar from %s.'), $url));
1231             }
1232
1233             if ($this->isGroup()) {
1234                 $id = $this->group_id;
1235             } else {
1236                 $id = $this->profile_id;
1237             }
1238             // @todo FIXME: Should we be using different ids?
1239             $imagefile = new ImageFile($id, $temp_filename);
1240             $filename = Avatar::filename($id,
1241                                          image_type_to_extension($imagefile->type),
1242                                          null,
1243                                          common_timestamp());
1244             rename($temp_filename, Avatar::path($filename));
1245         } catch (Exception $e) {
1246             unlink($temp_filename);
1247             throw $e;
1248         }
1249         // @todo FIXME: Hardcoded chmod is lame, but seems to be necessary to
1250         // keep from accidentally saving images from command-line (queues)
1251         // that can't be read from web server, which causes hard-to-notice
1252         // problems later on:
1253         //
1254         // http://status.net/open-source/issues/2663
1255         chmod(Avatar::path($filename), 0644);
1256
1257         $profile = $this->localProfile();
1258
1259         if (!empty($profile)) {
1260             $profile->setOriginal($filename);
1261         }
1262
1263         $orig = clone($this);
1264         $this->avatar = $url;
1265         $this->update($orig);
1266     }
1267
1268     /**
1269      * Pull avatar URL from ActivityObject or profile hints
1270      *
1271      * @param ActivityObject $object
1272      * @param array $hints
1273      * @return mixed URL string or false
1274      */
1275     public static function getActivityObjectAvatar($object, $hints=array())
1276     {
1277         if ($object->avatarLinks) {
1278             $best = false;
1279             // Take the exact-size avatar, or the largest avatar, or the first avatar if all sizeless
1280             foreach ($object->avatarLinks as $avatar) {
1281                 if ($avatar->width == AVATAR_PROFILE_SIZE && $avatar->height = AVATAR_PROFILE_SIZE) {
1282                     // Exact match!
1283                     $best = $avatar;
1284                     break;
1285                 }
1286                 if (!$best || $avatar->width > $best->width) {
1287                     $best = $avatar;
1288                 }
1289             }
1290             return $best->url;
1291         } else if (array_key_exists('avatar', $hints)) {
1292             return $hints['avatar'];
1293         }
1294         return false;
1295     }
1296
1297     /**
1298      * Get an appropriate avatar image source URL, if available.
1299      *
1300      * @param ActivityObject $actor
1301      * @param DOMElement $feed
1302      * @return string
1303      */
1304     protected static function getAvatar($actor, $feed)
1305     {
1306         $url = '';
1307         $icon = '';
1308         if ($actor->avatar) {
1309             $url = trim($actor->avatar);
1310         }
1311         if (!$url) {
1312             // Check <atom:logo> and <atom:icon> on the feed
1313             $els = $feed->childNodes();
1314             if ($els && $els->length) {
1315                 for ($i = 0; $i < $els->length; $i++) {
1316                     $el = $els->item($i);
1317                     if ($el->namespaceURI == Activity::ATOM) {
1318                         if (empty($url) && $el->localName == 'logo') {
1319                             $url = trim($el->textContent);
1320                             break;
1321                         }
1322                         if (empty($icon) && $el->localName == 'icon') {
1323                             // Use as a fallback
1324                             $icon = trim($el->textContent);
1325                         }
1326                     }
1327                 }
1328             }
1329             if ($icon && !$url) {
1330                 $url = $icon;
1331             }
1332         }
1333         if ($url) {
1334             $opts = array('allowed_schemes' => array('http', 'https'));
1335             if (Validate::uri($url, $opts)) {
1336                 return $url;
1337             }
1338         }
1339
1340         return Plugin::staticPath('OStatus', 'images/96px-Feed-icon.svg.png');
1341     }
1342
1343     /**
1344      * Fetch, or build if necessary, an Ostatus_profile for the actor
1345      * in a given Activity Streams activity.
1346      * This should never return null -- you will either get an object or
1347      * an exception will be thrown.
1348      *
1349      * @param Activity $activity
1350      * @param string $feeduri if we already know the canonical feed URI!
1351      * @param string $salmonuri if we already know the salmon return channel URI
1352      * @return Ostatus_profile
1353      * @throws Exception
1354      */
1355     public static function ensureActorProfile($activity, $hints=array())
1356     {
1357         return self::ensureActivityObjectProfile($activity->actor, $hints);
1358     }
1359
1360     /**
1361      * Fetch, or build if necessary, an Ostatus_profile for the profile
1362      * in a given Activity Streams object (can be subject, actor, or object).
1363      * This should never return null -- you will either get an object or
1364      * an exception will be thrown.
1365      *
1366      * @param ActivityObject $object
1367      * @param array $hints additional discovery information passed from higher levels
1368      * @return Ostatus_profile
1369      * @throws Exception
1370      */
1371     public static function ensureActivityObjectProfile($object, $hints=array())
1372     {
1373         $profile = self::getActivityObjectProfile($object);
1374         if ($profile) {
1375             $profile->updateFromActivityObject($object, $hints);
1376         } else {
1377             $profile = self::createActivityObjectProfile($object, $hints);
1378         }
1379         return $profile;
1380     }
1381
1382     /**
1383      * @param Activity $activity
1384      * @return mixed matching Ostatus_profile or false if none known
1385      * @throws ServerException if feed info invalid
1386      */
1387     public static function getActorProfile($activity)
1388     {
1389         return self::getActivityObjectProfile($activity->actor);
1390     }
1391
1392     /**
1393      * @param ActivityObject $activity
1394      * @return mixed matching Ostatus_profile or false if none known
1395      * @throws ServerException if feed info invalid
1396      */
1397     protected static function getActivityObjectProfile($object)
1398     {
1399         $uri = self::getActivityObjectProfileURI($object);
1400         return Ostatus_profile::staticGet('uri', $uri);
1401     }
1402
1403     /**
1404      * Get the identifier URI for the remote entity described
1405      * by this ActivityObject. This URI is *not* guaranteed to be
1406      * a resolvable HTTP/HTTPS URL.
1407      *
1408      * @param ActivityObject $object
1409      * @return string
1410      * @throws ServerException if feed info invalid
1411      */
1412     protected static function getActivityObjectProfileURI($object)
1413     {
1414         if ($object->id) {
1415             if (ActivityUtils::validateUri($object->id)) {
1416                 return $object->id;
1417             }
1418         }
1419
1420         // If the id is missing or invalid (we've seen feeds mistakenly listing
1421         // things like local usernames in that field) then we'll use the profile
1422         // page link, if valid.
1423         if ($object->link && common_valid_http_url($object->link)) {
1424             return $object->link;
1425         }
1426         // TRANS: Server exception.
1427         throw new ServerException(_m('No author ID URI found.'));
1428     }
1429
1430     /**
1431      * @todo FIXME: Validate stuff somewhere.
1432      */
1433
1434     /**
1435      * Create local ostatus_profile and profile/user_group entries for
1436      * the provided remote user or group.
1437      * This should never return null -- you will either get an object or
1438      * an exception will be thrown.
1439      *
1440      * @param ActivityObject $object
1441      * @param array $hints
1442      *
1443      * @return Ostatus_profile
1444      */
1445     protected static function createActivityObjectProfile($object, $hints=array())
1446     {
1447         $homeuri = $object->id;
1448         $discover = false;
1449
1450         if (!$homeuri) {
1451             common_log(LOG_DEBUG, __METHOD__ . " empty actor profile URI: " . var_export($activity, true));
1452             // TRANS: Exception.
1453             throw new Exception(_m('No profile URI.'));
1454         }
1455
1456         $user = User::staticGet('uri', $homeuri);
1457         if ($user) {
1458             // TRANS: Exception.
1459             throw new Exception(_m('Local user cannot be referenced as remote.'));
1460         }
1461
1462         if (OStatusPlugin::localGroupFromUrl($homeuri)) {
1463             // TRANS: Exception.
1464             throw new Exception(_m('Local group cannot be referenced as remote.'));
1465         }
1466
1467         $ptag = Profile_list::staticGet('uri', $homeuri);
1468         if ($ptag) {
1469             $local_user = User::staticGet('id', $ptag->tagger);
1470             if (!empty($local_user)) {
1471                 // TRANS: Exception.
1472                 throw new Exception(_m('Local list cannot be referenced as remote.'));
1473             }
1474         }
1475
1476         if (array_key_exists('feedurl', $hints)) {
1477             $feeduri = $hints['feedurl'];
1478         } else {
1479             $discover = new FeedDiscovery();
1480             $feeduri = $discover->discoverFromURL($homeuri);
1481         }
1482
1483         if (array_key_exists('salmon', $hints)) {
1484             $salmonuri = $hints['salmon'];
1485         } else {
1486             if (!$discover) {
1487                 $discover = new FeedDiscovery();
1488                 $discover->discoverFromFeedURL($hints['feedurl']);
1489             }
1490             $salmonuri = $discover->getAtomLink(Salmon::NS_REPLIES);
1491         }
1492
1493         if (array_key_exists('hub', $hints)) {
1494             $huburi = $hints['hub'];
1495         } else {
1496             if (!$discover) {
1497                 $discover = new FeedDiscovery();
1498                 $discover->discoverFromFeedURL($hints['feedurl']);
1499             }
1500             $huburi = $discover->getHubLink();
1501         }
1502
1503         if (!$huburi && !common_config('feedsub', 'fallback_hub')) {
1504             // We can only deal with folks with a PuSH hub
1505             throw new FeedSubNoHubException();
1506         }
1507
1508         $oprofile = new Ostatus_profile();
1509
1510         $oprofile->uri        = $homeuri;
1511         $oprofile->feeduri    = $feeduri;
1512         $oprofile->salmonuri  = $salmonuri;
1513
1514         $oprofile->created    = common_sql_now();
1515         $oprofile->modified   = common_sql_now();
1516
1517         if ($object->type == ActivityObject::PERSON) {
1518             $profile = new Profile();
1519             $profile->created = common_sql_now();
1520             self::updateProfile($profile, $object, $hints);
1521
1522             $oprofile->profile_id = $profile->insert();
1523             if (!$oprofile->profile_id) {
1524                 // TRANS: Server exception.
1525                 throw new ServerException(_m('Cannot save local profile.'));
1526             }
1527         } else if ($object->type == ActivityObject::GROUP) {
1528             $group = new User_group();
1529             $group->uri = $homeuri;
1530             $group->created = common_sql_now();
1531             self::updateGroup($group, $object, $hints);
1532
1533             $oprofile->group_id = $group->insert();
1534             if (!$oprofile->group_id) {
1535                 // TRANS: Server exception.
1536                 throw new ServerException(_m('Cannot save local profile.'));
1537             }
1538         } else if ($object->type == ActivityObject::_LIST) {
1539             $ptag = new Profile_list();
1540             $ptag->uri = $homeuri;
1541             $ptag->created = common_sql_now();
1542             self::updatePeopletag($ptag, $object, $hints);
1543
1544             $oprofile->peopletag_id = $ptag->insert();
1545             if (!$oprofile->peopletag_id) {
1546             // TRANS: Server exception.
1547                 throw new ServerException(_m('Cannot save local list.'));
1548             }
1549         }
1550
1551         $ok = $oprofile->insert();
1552
1553         if (!$ok) {
1554             // TRANS: Server exception.
1555             throw new ServerException(_m('Cannot save OStatus profile.'));
1556         }
1557
1558         $avatar = self::getActivityObjectAvatar($object, $hints);
1559
1560         if ($avatar) {
1561             try {
1562                 $oprofile->updateAvatar($avatar);
1563             } catch (Exception $ex) {
1564                 // Profile is saved, but Avatar is messed up. We're
1565                 // just going to continue.
1566                 common_log(LOG_WARNING, "Exception saving OStatus profile avatar: ". $ex->getMessage());
1567             }
1568         }
1569
1570         return $oprofile;
1571     }
1572
1573     /**
1574      * Save any updated profile information to our local copy.
1575      * @param ActivityObject $object
1576      * @param array $hints
1577      */
1578     public function updateFromActivityObject($object, $hints=array())
1579     {
1580         if ($this->isGroup()) {
1581             $group = $this->localGroup();
1582             self::updateGroup($group, $object, $hints);
1583         } else if ($this->isPeopletag()) {
1584             $ptag = $this->localPeopletag();
1585             self::updatePeopletag($ptag, $object, $hints);
1586         } else {
1587             $profile = $this->localProfile();
1588             self::updateProfile($profile, $object, $hints);
1589         }
1590
1591         $avatar = self::getActivityObjectAvatar($object, $hints);
1592         if ($avatar && !isset($ptag)) {
1593             try {
1594                 $this->updateAvatar($avatar);
1595             } catch (Exception $ex) {
1596                 common_log(LOG_WARNING, "Exception saving OStatus profile avatar: " . $ex->getMessage());
1597             }
1598         }
1599     }
1600
1601     public static function updateProfile($profile, $object, $hints=array())
1602     {
1603         $orig = clone($profile);
1604
1605         // Existing nickname is better than nothing.
1606
1607         if (!array_key_exists('nickname', $hints)) {
1608             $hints['nickname'] = $profile->nickname;
1609         }
1610
1611         $nickname = self::getActivityObjectNickname($object, $hints);
1612
1613         if (!empty($nickname)) {
1614             $profile->nickname = $nickname;
1615         }
1616
1617         if (!empty($object->title)) {
1618             $profile->fullname = $object->title;
1619         } else if (array_key_exists('fullname', $hints)) {
1620             $profile->fullname = $hints['fullname'];
1621         }
1622
1623         if (!empty($object->link)) {
1624             $profile->profileurl = $object->link;
1625         } else if (array_key_exists('profileurl', $hints)) {
1626             $profile->profileurl = $hints['profileurl'];
1627         } else if (Validate::uri($object->id, array('allowed_schemes' => array('http', 'https')))) {
1628             $profile->profileurl = $object->id;
1629         }
1630
1631         $bio = self::getActivityObjectBio($object, $hints);
1632
1633         if (!empty($bio)) {
1634             $profile->bio = $bio;
1635         }
1636
1637         $location = self::getActivityObjectLocation($object, $hints);
1638
1639         if (!empty($location)) {
1640             $profile->location = $location;
1641         }
1642
1643         $homepage = self::getActivityObjectHomepage($object, $hints);
1644
1645         if (!empty($homepage)) {
1646             $profile->homepage = $homepage;
1647         }
1648
1649         if (!empty($object->geopoint)) {
1650             $location = ActivityContext::locationFromPoint($object->geopoint);
1651             if (!empty($location)) {
1652                 $profile->lat = $location->lat;
1653                 $profile->lon = $location->lon;
1654             }
1655         }
1656
1657         // @todo FIXME: tags/categories
1658         // @todo tags from categories
1659
1660         if ($profile->id) {
1661             common_log(LOG_DEBUG, "Updating OStatus profile $profile->id from remote info $object->id: " . var_export($object, true) . var_export($hints, true));
1662             $profile->update($orig);
1663         }
1664     }
1665
1666     protected static function updateGroup($group, $object, $hints=array())
1667     {
1668         $orig = clone($group);
1669
1670         $group->nickname = self::getActivityObjectNickname($object, $hints);
1671         $group->fullname = $object->title;
1672
1673         if (!empty($object->link)) {
1674             $group->mainpage = $object->link;
1675         } else if (array_key_exists('profileurl', $hints)) {
1676             $group->mainpage = $hints['profileurl'];
1677         }
1678
1679         // @todo tags from categories
1680         $group->description = self::getActivityObjectBio($object, $hints);
1681         $group->location = self::getActivityObjectLocation($object, $hints);
1682         $group->homepage = self::getActivityObjectHomepage($object, $hints);
1683
1684         if ($group->id) {
1685             common_log(LOG_DEBUG, "Updating OStatus group $group->id from remote info $object->id: " . var_export($object, true) . var_export($hints, true));
1686             $group->update($orig);
1687         }
1688     }
1689
1690     protected static function updatePeopletag($tag, $object, $hints=array()) {
1691         $orig = clone($tag);
1692
1693         $tag->tag = $object->title;
1694
1695         if (!empty($object->link)) {
1696             $tag->mainpage = $object->link;
1697         } else if (array_key_exists('profileurl', $hints)) {
1698             $tag->mainpage = $hints['profileurl'];
1699         }
1700
1701         $tag->description = $object->summary;
1702         $tagger = self::ensureActivityObjectProfile($object->owner);
1703         $tag->tagger = $tagger->profile_id;
1704
1705         if ($tag->id) {
1706             common_log(LOG_DEBUG, "Updating OStatus peopletag $tag->id from remote info $object->id: " . var_export($object, true) . var_export($hints, true));
1707             $tag->update($orig);
1708         }
1709     }
1710
1711     protected static function getActivityObjectHomepage($object, $hints=array())
1712     {
1713         $homepage = null;
1714         $poco     = $object->poco;
1715
1716         if (!empty($poco)) {
1717             $url = $poco->getPrimaryURL();
1718             if ($url && $url->type == 'homepage') {
1719                 $homepage = $url->value;
1720             }
1721         }
1722
1723         // @todo Try for a another PoCo URL?
1724
1725         return $homepage;
1726     }
1727
1728     protected static function getActivityObjectLocation($object, $hints=array())
1729     {
1730         $location = null;
1731
1732         if (!empty($object->poco) &&
1733             isset($object->poco->address->formatted)) {
1734             $location = $object->poco->address->formatted;
1735         } else if (array_key_exists('location', $hints)) {
1736             $location = $hints['location'];
1737         }
1738
1739         if (!empty($location)) {
1740             if (mb_strlen($location) > 255) {
1741                 $location = mb_substr($note, 0, 255 - 3) . ' â€¦ ';
1742             }
1743         }
1744
1745         // @todo Try to find location some othe way? Via goerss point?
1746
1747         return $location;
1748     }
1749
1750     protected static function getActivityObjectBio($object, $hints=array())
1751     {
1752         $bio  = null;
1753
1754         if (!empty($object->poco)) {
1755             $note = $object->poco->note;
1756         } else if (array_key_exists('bio', $hints)) {
1757             $note = $hints['bio'];
1758         }
1759
1760         if (!empty($note)) {
1761             if (Profile::bioTooLong($note)) {
1762                 // XXX: truncate ok?
1763                 $bio = mb_substr($note, 0, Profile::maxBio() - 3) . ' â€¦ ';
1764             } else {
1765                 $bio = $note;
1766             }
1767         }
1768
1769         // @todo Try to get bio info some other way?
1770
1771         return $bio;
1772     }
1773
1774     public static function getActivityObjectNickname($object, $hints=array())
1775     {
1776         if ($object->poco) {
1777             if (!empty($object->poco->preferredUsername)) {
1778                 return common_nicknamize($object->poco->preferredUsername);
1779             }
1780         }
1781
1782         if (!empty($object->nickname)) {
1783             return common_nicknamize($object->nickname);
1784         }
1785
1786         if (array_key_exists('nickname', $hints)) {
1787             return $hints['nickname'];
1788         }
1789
1790         // Try the profile url (like foo.example.com or example.com/user/foo)
1791         if (!empty($object->link)) {
1792             $profileUrl = $object->link;
1793         } else if (!empty($hints['profileurl'])) {
1794             $profileUrl = $hints['profileurl'];
1795         }
1796
1797         if (!empty($profileUrl)) {
1798             $nickname = self::nicknameFromURI($profileUrl);
1799         }
1800
1801         // Try the URI (may be a tag:, http:, acct:, ...
1802
1803         if (empty($nickname)) {
1804             $nickname = self::nicknameFromURI($object->id);
1805         }
1806
1807         // Try a Webfinger if one was passed (way) down
1808
1809         if (empty($nickname)) {
1810             if (array_key_exists('webfinger', $hints)) {
1811                 $nickname = self::nicknameFromURI($hints['webfinger']);
1812             }
1813         }
1814
1815         // Try the name
1816
1817         if (empty($nickname)) {
1818             $nickname = common_nicknamize($object->title);
1819         }
1820
1821         return $nickname;
1822     }
1823
1824     protected static function nicknameFromURI($uri)
1825     {
1826         if (preg_match('/(\w+):/', $uri, $matches)) {
1827             $protocol = $matches[1];
1828         } else {
1829             return null;
1830         }
1831
1832         switch ($protocol) {
1833         case 'acct':
1834         case 'mailto':
1835             if (preg_match("/^$protocol:(.*)?@.*\$/", $uri, $matches)) {
1836                 return common_canonical_nickname($matches[1]);
1837             }
1838             return null;
1839         case 'http':
1840             return common_url_to_nickname($uri);
1841             break;
1842         default:
1843             return null;
1844         }
1845     }
1846
1847     /**
1848      * Look up, and if necessary create, an Ostatus_profile for the remote
1849      * entity with the given webfinger address.
1850      * This should never return null -- you will either get an object or
1851      * an exception will be thrown.
1852      *
1853      * @param string $addr webfinger address
1854      * @return Ostatus_profile
1855      * @throws Exception on error conditions
1856      * @throws OStatusShadowException if this reference would obscure a local user/group
1857      */
1858     public static function ensureWebfinger($addr)
1859     {
1860         // First, try the cache
1861
1862         $uri = self::cacheGet(sprintf('ostatus_profile:webfinger:%s', $addr));
1863
1864         if ($uri !== false) {
1865             if (is_null($uri)) {
1866                 // Negative cache entry
1867                 // TRANS: Exception.
1868                 throw new Exception(_m('Not a valid webfinger address.'));
1869             }
1870             $oprofile = Ostatus_profile::staticGet('uri', $uri);
1871             if (!empty($oprofile)) {
1872                 return $oprofile;
1873             }
1874         }
1875
1876         // Try looking it up
1877         $oprofile = Ostatus_profile::staticGet('uri', 'acct:'.$addr);
1878
1879         if (!empty($oprofile)) {
1880             self::cacheSet(sprintf('ostatus_profile:webfinger:%s', $addr), $oprofile->uri);
1881             return $oprofile;
1882         }
1883
1884         // Now, try some discovery
1885
1886         $disco = new Discovery();
1887
1888         try {
1889             $xrd = $disco->lookup($addr);
1890         } catch (Exception $e) {
1891             // Save negative cache entry so we don't waste time looking it up again.
1892             // @todo FIXME: Distinguish temporary failures?
1893             self::cacheSet(sprintf('ostatus_profile:webfinger:%s', $addr), null);
1894             // TRANS: Exception.
1895             throw new Exception(_m('Not a valid webfinger address.'));
1896         }
1897
1898         $hints = array('webfinger' => $addr);
1899
1900         $dhints = DiscoveryHints::fromXRD($xrd);
1901
1902         $hints = array_merge($hints, $dhints);
1903
1904         // If there's an Hcard, let's grab its info
1905         if (array_key_exists('hcard', $hints)) {
1906             if (!array_key_exists('profileurl', $hints) ||
1907                 $hints['hcard'] != $hints['profileurl']) {
1908                 $hcardHints = DiscoveryHints::fromHcardUrl($hints['hcard']);
1909                 $hints = array_merge($hcardHints, $hints);
1910             }
1911         }
1912
1913         // If we got a feed URL, try that
1914         if (array_key_exists('feedurl', $hints)) {
1915             try {
1916                 common_log(LOG_INFO, "Discovery on acct:$addr with feed URL " . $hints['feedurl']);
1917                 $oprofile = self::ensureFeedURL($hints['feedurl'], $hints);
1918                 self::cacheSet(sprintf('ostatus_profile:webfinger:%s', $addr), $oprofile->uri);
1919                 return $oprofile;
1920             } catch (Exception $e) {
1921                 common_log(LOG_WARNING, "Failed creating profile from feed URL '$feedUrl': " . $e->getMessage());
1922                 // keep looking
1923             }
1924         }
1925
1926         // If we got a profile page, try that!
1927         if (array_key_exists('profileurl', $hints)) {
1928             try {
1929                 common_log(LOG_INFO, "Discovery on acct:$addr with profile URL $profileUrl");
1930                 $oprofile = self::ensureProfileURL($hints['profileurl'], $hints);
1931                 self::cacheSet(sprintf('ostatus_profile:webfinger:%s', $addr), $oprofile->uri);
1932                 return $oprofile;
1933             } catch (OStatusShadowException $e) {
1934                 // We've ended up with a remote reference to a local user or group.
1935                 // @todo FIXME: Ideally we should be able to say who it was so we can
1936                 // go back and refer to it the regular way
1937                 throw $e;
1938             } catch (Exception $e) {
1939                 common_log(LOG_WARNING, "Failed creating profile from profile URL '$profileUrl': " . $e->getMessage());
1940                 // keep looking
1941                 //
1942                 // @todo FIXME: This means an error discovering from profile page
1943                 // may give us a corrupt entry using the webfinger URI, which
1944                 // will obscure the correct page-keyed profile later on.
1945             }
1946         }
1947
1948         // XXX: try hcard
1949         // XXX: try FOAF
1950
1951         if (array_key_exists('salmon', $hints)) {
1952             $salmonEndpoint = $hints['salmon'];
1953
1954             // An account URL, a salmon endpoint, and a dream? Not much to go
1955             // on, but let's give it a try
1956
1957             $uri = 'acct:'.$addr;
1958
1959             $profile = new Profile();
1960
1961             $profile->nickname = self::nicknameFromUri($uri);
1962             $profile->created  = common_sql_now();
1963
1964             if (isset($profileUrl)) {
1965                 $profile->profileurl = $profileUrl;
1966             }
1967
1968             $profile_id = $profile->insert();
1969
1970             if (!$profile_id) {
1971                 common_log_db_error($profile, 'INSERT', __FILE__);
1972                 // TRANS: Exception. %s is a webfinger address.
1973                 throw new Exception(sprintf(_m('Could not save profile for "%s".'),$addr));
1974             }
1975
1976             $oprofile = new Ostatus_profile();
1977
1978             $oprofile->uri        = $uri;
1979             $oprofile->salmonuri  = $salmonEndpoint;
1980             $oprofile->profile_id = $profile_id;
1981             $oprofile->created    = common_sql_now();
1982
1983             if (isset($feedUrl)) {
1984                 $profile->feeduri = $feedUrl;
1985             }
1986
1987             $result = $oprofile->insert();
1988
1989             if (!$result) {
1990                 common_log_db_error($oprofile, 'INSERT', __FILE__);
1991                 // TRANS: Exception. %s is a webfinger address.
1992                 throw new Exception(sprintf(_m('Could not save OStatus profile for "%s".'),$addr));
1993             }
1994
1995             self::cacheSet(sprintf('ostatus_profile:webfinger:%s', $addr), $oprofile->uri);
1996             return $oprofile;
1997         }
1998
1999         // TRANS: Exception. %s is a webfinger address.
2000         throw new Exception(sprintf(_m('Could not find a valid profile for "%s".'),$addr));
2001     }
2002
2003     /**
2004      * Store the full-length scrubbed HTML of a remote notice to an attachment
2005      * file on our server. We'll link to this at the end of the cropped version.
2006      *
2007      * @param string $title plaintext for HTML page's title
2008      * @param string $rendered HTML fragment for HTML page's body
2009      * @return File
2010      */
2011     function saveHTMLFile($title, $rendered)
2012     {
2013         $final = sprintf("<!DOCTYPE html>\n" .
2014                          '<html><head>' .
2015                          '<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">' .
2016                          '<title>%s</title>' .
2017                          '</head>' .
2018                          '<body>%s</body></html>',
2019                          htmlspecialchars($title),
2020                          $rendered);
2021
2022         $filename = File::filename($this->localProfile(),
2023                                    'ostatus', // ignored?
2024                                    'text/html');
2025
2026         $filepath = File::path($filename);
2027
2028         file_put_contents($filepath, $final);
2029
2030         $file = new File;
2031
2032         $file->filename = $filename;
2033         $file->url      = File::url($filename);
2034         $file->size     = filesize($filepath);
2035         $file->date     = time();
2036         $file->mimetype = 'text/html';
2037
2038         $file_id = $file->insert();
2039
2040         if ($file_id === false) {
2041             common_log_db_error($file, "INSERT", __FILE__);
2042             // TRANS: Server exception.
2043             throw new ServerException(_m('Could not store HTML content of long post as file.'));
2044         }
2045
2046         return $file;
2047     }
2048
2049     static function ensureProfileURI($uri)
2050     {
2051         $oprofile = null;
2052
2053         // First, try to query it
2054
2055         $oprofile = Ostatus_profile::staticGet('uri', $uri);
2056
2057         // If unfound, do discovery stuff
2058
2059         if (empty($oprofile)) {
2060             if (preg_match("/^(\w+)\:(.*)/", $uri, $match)) {
2061                 $protocol = $match[1];
2062                 switch ($protocol) {
2063                 case 'http':
2064                 case 'https':
2065                     $oprofile = Ostatus_profile::ensureProfileURL($uri);
2066                     break;
2067                 case 'acct':
2068                 case 'mailto':
2069                     $rest = $match[2];
2070                     $oprofile = Ostatus_profile::ensureWebfinger($rest);
2071                     break;
2072                 default:
2073                     // TRANS: Server exception.
2074                     // TRANS: %1$s is a protocol, %2$s is a URI.
2075                     throw new ServerException(sprintf(_m('Unrecognized URI protocol for profile: %1$s (%2$s).'),
2076                                                       $protocol,
2077                                                       $uri));
2078                     break;
2079                 }
2080             } else {
2081                 // TRANS: Server exception. %s is a URI.
2082                 throw new ServerException(sprintf(_m('No URI protocol for profile: %s.'),$uri));
2083             }
2084         }
2085
2086         return $oprofile;
2087     }
2088
2089     function checkAuthorship($activity)
2090     {
2091         if ($this->isGroup() || $this->isPeopletag()) {
2092             // A group or propletag feed will contain posts from multiple authors.
2093             $oprofile = self::ensureActorProfile($activity);
2094             if ($oprofile->isGroup() || $oprofile->isPeopletag()) {
2095                 // Groups can't post notices in StatusNet.
2096                 common_log(LOG_WARNING,
2097                     "OStatus: skipping post with group listed ".
2098                     "as author: $oprofile->uri in feed from $this->uri");
2099                 return false;
2100             }
2101         } else {
2102             $actor = $activity->actor;
2103
2104             if (empty($actor)) {
2105                 // OK here! assume the default
2106             } else if ($actor->id == $this->uri || $actor->link == $this->uri) {
2107                 $this->updateFromActivityObject($actor);
2108             } else if ($actor->id) {
2109                 // We have an ActivityStreams actor with an explicit ID that doesn't match the feed owner.
2110                 // This isn't what we expect from mainline OStatus person feeds!
2111                 // Group feeds go down another path, with different validation...
2112                 // Most likely this is a plain ol' blog feed of some kind which
2113                 // doesn't match our expectations. We'll take the entry, but ignore
2114                 // the <author> info.
2115                 common_log(LOG_WARNING, "Got an actor '{$actor->title}' ({$actor->id}) on single-user feed for {$this->uri}");
2116             } else {
2117                 // Plain <author> without ActivityStreams actor info.
2118                 // We'll just ignore this info for now and save the update under the feed's identity.
2119             }
2120
2121             $oprofile = $this;
2122         }
2123
2124         return $oprofile;
2125     }
2126 }
2127
2128 /**
2129  * Exception indicating we've got a remote reference to a local user,
2130  * not a remote user!
2131  *
2132  * If we can ue a local profile after all, it's available as $e->profile.
2133  */
2134 class OStatusShadowException extends Exception
2135 {
2136     public $profile;
2137
2138     /**
2139      * @param Profile $profile
2140      * @param string $message
2141      */
2142     function __construct($profile, $message) {
2143         $this->profile = $profile;
2144         parent::__construct($message);
2145     }
2146 }