]> git.mxchange.org Git - quix0rs-gnu-social.git/blob - plugins/OStatus/classes/Ostatus_profile.php
e47b6973d7f9293133c10979af5818fa5d0958a1
[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             return ActivityObject::fromGroup($this->localGroup());
154         } else {
155             return ActivityObject::fromProfile($this->localProfile());
156         }
157     }
158
159     /**
160      * Returns an XML string fragment with profile information as an
161      * Activity Streams noun object with the given element type.
162      *
163      * Assumes that 'activity' namespace has been previously defined.
164      *
165      * @fixme replace with wrappers on asActivityObject when it's got everything.
166      *
167      * @param string $element one of 'actor', 'subject', 'object', 'target'
168      * @return string
169      */
170     function asActivityNoun($element)
171     {
172         if ($this->isGroup()) {
173             $noun = ActivityObject::fromGroup($this->localGroup());
174             return $noun->asString('activity:' . $element);
175         } else {
176             $noun = ActivityObject::fromProfile($this->localProfile());
177             return $noun->asString('activity:' . $element);
178         }
179     }
180
181     /**
182      * @return boolean true if this is a remote group
183      */
184     function isGroup()
185     {
186         if ($this->profile_id && !$this->group_id) {
187             return false;
188         } else if ($this->group_id && !$this->profile_id) {
189             return true;
190         } else if ($this->group_id && $this->profile_id) {
191             throw new ServerException("Invalid ostatus_profile state: both group and profile IDs set for $this->uri");
192         } else {
193             throw new ServerException("Invalid ostatus_profile state: both group and profile IDs empty for $this->uri");
194         }
195     }
196
197     /**
198      * Subscribe a local user to this remote user.
199      * PuSH subscription will be started if necessary, and we'll
200      * send a Salmon notification to the remote server if available
201      * notifying them of the sub.
202      *
203      * @param User $user
204      * @return boolean success
205      * @throws FeedException
206      */
207     public function subscribeLocalToRemote(User $user)
208     {
209         if ($this->isGroup()) {
210             throw new ServerException("Can't subscribe to a remote group");
211         }
212
213         if ($this->subscribe()) {
214             if ($user->subscribeTo($this->localProfile())) {
215                 $this->notify($user->getProfile(), ActivityVerb::FOLLOW, $this);
216                 return true;
217             }
218         }
219         return false;
220     }
221
222     /**
223      * Mark this remote profile as subscribing to the given local user,
224      * and send appropriate notifications to the user.
225      *
226      * This will generally be in response to a subscription notification
227      * from a foreign site to our local Salmon response channel.
228      *
229      * @param User $user
230      * @return boolean success
231      */
232     public function subscribeRemoteToLocal(User $user)
233     {
234         if ($this->isGroup()) {
235             throw new ServerException("Remote groups can't subscribe to local users");
236         }
237
238         Subscription::start($this->localProfile(), $user->getProfile());
239
240         return true;
241     }
242
243     /**
244      * Send a subscription request to the hub for this feed.
245      * The hub will later send us a confirmation POST to /main/push/callback.
246      *
247      * @return bool true on success, false on failure
248      * @throws ServerException if feed state is not valid
249      */
250     public function subscribe()
251     {
252         $feedsub = FeedSub::ensureFeed($this->feeduri);
253         if ($feedsub->sub_state == 'active' || $feedsub->sub_state == 'subscribe') {
254             return true;
255         } else if ($feedsub->sub_state == '' || $feedsub->sub_state == 'inactive') {
256             return $feedsub->subscribe();
257         } else if ('unsubscribe') {
258             throw new FeedSubException("Unsub is pending, can't subscribe...");
259         }
260     }
261
262     /**
263      * Send a PuSH unsubscription request to the hub for this feed.
264      * The hub will later send us a confirmation POST to /main/push/callback.
265      *
266      * @return bool true on success, false on failure
267      * @throws ServerException if feed state is not valid
268      */
269     public function unsubscribe() {
270         $feedsub = FeedSub::staticGet('uri', $this->feeduri);
271         if (!$feedsub) {
272             return true;
273         }
274         if ($feedsub->sub_state == 'active') {
275             return $feedsub->unsubscribe();
276         } else if ($feedsub->sub_state == '' || $feedsub->sub_state == 'inactive' || $feedsub->sub_state == 'unsubscribe') {
277             return true;
278         } else if ($feedsub->sub_state == 'subscribe') {
279             throw new FeedSubException("Feed is awaiting subscription, can't unsub...");
280         }
281     }
282
283     /**
284      * Check if this remote profile has any active local subscriptions, and
285      * if not drop the PuSH subscription feed.
286      *
287      * @return boolean
288      */
289     public function garbageCollect()
290     {
291         if ($this->isGroup()) {
292             $members = $this->localGroup()->getMembers(0, 1);
293             $count = $members->N;
294         } else {
295             $count = $this->localProfile()->subscriberCount();
296         }
297         if ($count == 0) {
298             common_log(LOG_INFO, "Unsubscribing from now-unused remote feed $this->feeduri");
299             $this->unsubscribe();
300             return true;
301         } else {
302             return false;
303         }
304     }
305
306     /**
307      * Send an Activity Streams notification to the remote Salmon endpoint,
308      * if so configured.
309      *
310      * @param Profile $actor  Actor who did the activity
311      * @param string  $verb   Activity::SUBSCRIBE or Activity::JOIN
312      * @param Object  $object object of the action; must define asActivityNoun($tag)
313      */
314     public function notify($actor, $verb, $object=null)
315     {
316         if (!($actor instanceof Profile)) {
317             $type = gettype($actor);
318             if ($type == 'object') {
319                 $type = get_class($actor);
320             }
321             throw new ServerException("Invalid actor passed to " . __METHOD__ . ": " . $type);
322         }
323         if ($object == null) {
324             $object = $this;
325         }
326         if ($this->salmonuri) {
327
328             $text = 'update';
329             $id = TagURI::mint('%s:%s:%s',
330                                $verb,
331                                $actor->getURI(),
332                                common_date_iso8601(time()));
333
334             // @fixme consolidate all these NS settings somewhere
335             $attributes = array('xmlns' => Activity::ATOM,
336                                 'xmlns:activity' => 'http://activitystrea.ms/spec/1.0/',
337                                 'xmlns:thr' => 'http://purl.org/syndication/thread/1.0',
338                                 'xmlns:georss' => 'http://www.georss.org/georss',
339                                 'xmlns:ostatus' => 'http://ostatus.org/schema/1.0',
340                                 'xmlns:poco' => 'http://portablecontacts.net/spec/1.0',
341                                 'xmlns:media' => 'http://purl.org/syndication/atommedia');
342
343             $entry = new XMLStringer();
344             $entry->elementStart('entry', $attributes);
345             $entry->element('id', null, $id);
346             $entry->element('title', null, $text);
347             $entry->element('summary', null, $text);
348             $entry->element('published', null, common_date_w3dtf(common_sql_now()));
349
350             $entry->element('activity:verb', null, $verb);
351             $entry->raw($actor->asAtomAuthor());
352             $entry->raw($actor->asActivityActor());
353             $entry->raw($object->asActivityNoun('object'));
354             $entry->elementEnd('entry');
355
356             $xml = $entry->getString();
357             common_log(LOG_INFO, "Posting to Salmon endpoint $this->salmonuri: $xml");
358
359             $salmon = new Salmon(); // ?
360             return $salmon->post($this->salmonuri, $xml, $actor);
361         }
362         return false;
363     }
364
365     /**
366      * Send a Salmon notification ping immediately, and confirm that we got
367      * an acceptable response from the remote site.
368      *
369      * @param mixed $entry XML string, Notice, or Activity
370      * @return boolean success
371      */
372     public function notifyActivity($entry, $actor)
373     {
374         if ($this->salmonuri) {
375             $salmon = new Salmon();
376             return $salmon->post($this->salmonuri, $this->notifyPrepXml($entry), $actor);
377         }
378
379         return false;
380     }
381
382     /**
383      * Queue a Salmon notification for later. If queues are disabled we'll
384      * send immediately but won't get the return value.
385      *
386      * @param mixed $entry XML string, Notice, or Activity
387      * @return boolean success
388      */
389     public function notifyDeferred($entry, $actor)
390     {
391         if ($this->salmonuri) {
392             $data = array('salmonuri' => $this->salmonuri,
393                           'entry' => $this->notifyPrepXml($entry),
394                           'actor' => $actor->id);
395
396             $qm = QueueManager::get();
397             return $qm->enqueue($data, 'salmon');
398         }
399
400         return false;
401     }
402
403     protected function notifyPrepXml($entry)
404     {
405         $preamble = '<?xml version="1.0" encoding="UTF-8" ?' . '>';
406         if (is_string($entry)) {
407             return $entry;
408         } else if ($entry instanceof Activity) {
409             return $preamble . $entry->asString(true);
410         } else if ($entry instanceof Notice) {
411             return $preamble . $entry->asAtomEntry(true, true);
412         } else {
413             throw new ServerException("Invalid type passed to Ostatus_profile::notify; must be XML string or Activity entry");
414         }
415     }
416
417     function getBestName()
418     {
419         if ($this->isGroup()) {
420             return $this->localGroup()->getBestName();
421         } else {
422             return $this->localProfile()->getBestName();
423         }
424     }
425
426     /**
427      * Read and post notices for updates from the feed.
428      * Currently assumes that all items in the feed are new,
429      * coming from a PuSH hub.
430      *
431      * @param DOMDocument $doc
432      * @param string $source identifier ("push")
433      */
434     public function processFeed(DOMDocument $doc, $source)
435     {
436         $feed = $doc->documentElement;
437
438         if ($feed->localName != 'feed' || $feed->namespaceURI != Activity::ATOM) {
439             common_log(LOG_ERR, __METHOD__ . ": not an Atom feed, ignoring");
440             return;
441         }
442
443         $entries = $feed->getElementsByTagNameNS(Activity::ATOM, 'entry');
444         if ($entries->length == 0) {
445             common_log(LOG_ERR, __METHOD__ . ": no entries in feed update, ignoring");
446             return;
447         }
448
449         for ($i = 0; $i < $entries->length; $i++) {
450             $entry = $entries->item($i);
451             $this->processEntry($entry, $feed, $source);
452         }
453     }
454
455     /**
456      * Process a posted entry from this feed source.
457      *
458      * @param DOMElement $entry
459      * @param DOMElement $feed for context
460      * @param string $source identifier ("push" or "salmon")
461      */
462     public function processEntry($entry, $feed, $source)
463     {
464         $activity = new Activity($entry, $feed);
465
466         if ($activity->verb == ActivityVerb::POST) {
467             $this->processPost($activity, $source);
468         } else {
469             common_log(LOG_INFO, "Ignoring activity with unrecognized verb $activity->verb");
470         }
471     }
472
473     /**
474      * Process an incoming post activity from this remote feed.
475      * @param Activity $activity
476      * @param string $method 'push' or 'salmon'
477      * @return mixed saved Notice or false
478      * @fixme break up this function, it's getting nasty long
479      */
480     public function processPost($activity, $method)
481     {
482         if ($this->isGroup()) {
483             // A group feed will contain posts from multiple authors.
484             // @fixme validate these profiles in some way!
485             $oprofile = self::ensureActorProfile($activity);
486             if ($oprofile->isGroup()) {
487                 // Groups can't post notices in StatusNet.
488                 common_log(LOG_WARNING, "OStatus: skipping post with group listed as author: $oprofile->uri in feed from $this->uri");
489                 return false;
490             }
491         } else {
492             // Individual user feeds may contain only posts from themselves.
493             // Authorship is validated against the profile URI on upper layers,
494             // through PuSH setup or Salmon signature checks.
495             $actorUri = self::getActorProfileURI($activity);
496             if ($actorUri == $this->uri) {
497                 // Check if profile info has changed and update it
498                 $this->updateFromActivityObject($activity->actor);
499             } else {
500                 common_log(LOG_WARNING, "OStatus: skipping post with bad author: got $actorUri expected $this->uri");
501                 return false;
502             }
503             $oprofile = $this;
504         }
505
506         // The id URI will be used as a unique identifier for for the notice,
507         // protecting against duplicate saves. It isn't required to be a URL;
508         // tag: URIs for instance are found in Google Buzz feeds.
509         $sourceUri = $activity->object->id;
510         $dupe = Notice::staticGet('uri', $sourceUri);
511         if ($dupe) {
512             common_log(LOG_INFO, "OStatus: ignoring duplicate post: $sourceUri");
513             return false;
514         }
515
516         // We'll also want to save a web link to the original notice, if provided.
517         $sourceUrl = null;
518         if ($activity->object->link) {
519             $sourceUrl = $activity->object->link;
520         } else if ($activity->link) {
521             $sourceUrl = $activity->link;
522         } else if (preg_match('!^https?://!', $activity->object->id)) {
523             $sourceUrl = $activity->object->id;
524         }
525
526         // Get (safe!) HTML and text versions of the content
527         $rendered = $this->purify($activity->object->content);
528         $content = html_entity_decode(strip_tags($rendered));
529
530         $shortened = common_shorten_links($content);
531
532         // If it's too long, try using the summary, and make the
533         // HTML an attachment.
534
535         $attachment = null;
536
537         if (Notice::contentTooLong($shortened)) {
538             $attachment = $this->saveHTMLFile($activity->object->title, $rendered);
539             $summary = $activity->object->summary;
540             if (empty($summary)) {
541                 $summary = $content;
542             }
543             $shortSummary = common_shorten_links($summary);
544             if (Notice::contentTooLong($shortSummary)) {
545                 $url = common_shorten_url($sourceUrl);
546                 $shortSummary = substr($shortSummary,
547                                        0,
548                                        Notice::maxContent() - (mb_strlen($url) + 2));
549                 $content = $shortSummary . ' ' . $url;
550
551                 // We mark up the attachment link specially for the HTML output
552                 // so we can fold-out the full version inline.
553                 $attachUrl = common_local_url('attachment',
554                                               array('attachment' => $attachment->id));
555                 $rendered = common_render_text($shortSummary) .
556                             '<a href="' . htmlspecialchars($attachUrl) .'"'.
557                             ' class="attachment more"' .
558                             ' title="'. htmlspecialchars(_m('Show more')) . '">' .
559                             '&#8230;' .
560                             '</a>';
561             }
562         }
563
564         $options = array('is_local' => Notice::REMOTE_OMB,
565                         'url' => $sourceUrl,
566                         'uri' => $sourceUri,
567                         'rendered' => $rendered,
568                         'replies' => array(),
569                         'groups' => array(),
570                         'tags' => array(),
571                         'urls' => array());
572
573         // Check for optional attributes...
574
575         if (!empty($activity->time)) {
576             $options['created'] = common_sql_date($activity->time);
577         }
578
579         if ($activity->context) {
580             // Any individual or group attn: targets?
581             $replies = $activity->context->attention;
582             $options['groups'] = $this->filterReplies($oprofile, $replies);
583             $options['replies'] = $replies;
584
585             // Maintain direct reply associations
586             // @fixme what about conversation ID?
587             if (!empty($activity->context->replyToID)) {
588                 $orig = Notice::staticGet('uri',
589                                           $activity->context->replyToID);
590                 if (!empty($orig)) {
591                     $options['reply_to'] = $orig->id;
592                 }
593             }
594
595             $location = $activity->context->location;
596             if ($location) {
597                 $options['lat'] = $location->lat;
598                 $options['lon'] = $location->lon;
599                 if ($location->location_id) {
600                     $options['location_ns'] = $location->location_ns;
601                     $options['location_id'] = $location->location_id;
602                 }
603             }
604         }
605
606         // Atom categories <-> hashtags
607         foreach ($activity->categories as $cat) {
608             if ($cat->term) {
609                 $term = common_canonical_tag($cat->term);
610                 if ($term) {
611                     $options['tags'][] = $term;
612                 }
613             }
614         }
615
616         // Atom enclosures -> attachment URLs
617         foreach ($activity->enclosures as $href) {
618             // @fixme save these locally or....?
619             $options['urls'][] = $href;
620         }
621
622         try {
623             $saved = Notice::saveNew($oprofile->profile_id,
624                                      $content,
625                                      'ostatus',
626                                      $options);
627             if ($saved) {
628                 Ostatus_source::saveNew($saved, $this, $method);
629                 if (!empty($attachment)) {
630                     File_to_post::processNew($attachment->id, $saved->id);
631                 }
632             }
633         } catch (Exception $e) {
634             common_log(LOG_ERR, "OStatus save of remote message $sourceUri failed: " . $e->getMessage());
635             throw $e;
636         }
637         common_log(LOG_INFO, "OStatus saved remote message $sourceUri as notice id $saved->id");
638         return $saved;
639     }
640
641     /**
642      * Clean up HTML
643      */
644     protected function purify($html)
645     {
646         require_once INSTALLDIR.'/extlib/htmLawed/htmLawed.php';
647         $config = array('safe' => 1,
648                         'deny_attribute' => 'id,style,on*');
649         return htmLawed($html, $config);
650     }
651
652     /**
653      * Filters a list of recipient ID URIs to just those for local delivery.
654      * @param Ostatus_profile local profile of sender
655      * @param array in/out &$attention_uris set of URIs, will be pruned on output
656      * @return array of group IDs
657      */
658     protected function filterReplies($sender, &$attention_uris)
659     {
660         common_log(LOG_DEBUG, "Original reply recipients: " . implode(', ', $attention_uris));
661         $groups = array();
662         $replies = array();
663         foreach ($attention_uris as $recipient) {
664             // Is the recipient a local user?
665             $user = User::staticGet('uri', $recipient);
666             if ($user) {
667                 // @fixme sender verification, spam etc?
668                 $replies[] = $recipient;
669                 continue;
670             }
671
672             // Is the recipient a remote group?
673             $oprofile = Ostatus_profile::staticGet('uri', $recipient);
674             if ($oprofile) {
675                 if ($oprofile->isGroup()) {
676                     // Deliver to local members of this remote group.
677                     // @fixme sender verification?
678                     $groups[] = $oprofile->group_id;
679                 } else {
680                     common_log(LOG_DEBUG, "Skipping reply to remote profile $recipient");
681                 }
682                 continue;
683             }
684
685             // Is the recipient a local group?
686             // @fixme uri on user_group isn't reliable yet
687             // $group = User_group::staticGet('uri', $recipient);
688             $id = OStatusPlugin::localGroupFromUrl($recipient);
689             if ($id) {
690                 $group = User_group::staticGet('id', $id);
691                 if ($group) {
692                     // Deliver to all members of this local group if allowed.
693                     $profile = $sender->localProfile();
694                     if ($profile->isMember($group)) {
695                         $groups[] = $group->id;
696                     } else {
697                         common_log(LOG_DEBUG, "Skipping reply to local group $group->nickname as sender $profile->id is not a member");
698                     }
699                     continue;
700                 } else {
701                     common_log(LOG_DEBUG, "Skipping reply to bogus group $recipient");
702                 }
703             }
704
705             common_log(LOG_DEBUG, "Skipping reply to unrecognized profile $recipient");
706
707         }
708         $attention_uris = $replies;
709         common_log(LOG_DEBUG, "Local reply recipients: " . implode(', ', $replies));
710         common_log(LOG_DEBUG, "Local group recipients: " . implode(', ', $groups));
711         return $groups;
712     }
713
714     /**
715      * @param string $profile_url
716      * @return Ostatus_profile
717      * @throws FeedSubException
718      */
719
720     public static function ensureProfileURL($profile_url, $hints=array())
721     {
722         $oprofile = self::getFromProfileURL($profile_url);
723
724         if (!empty($oprofile)) {
725             return $oprofile;
726         }
727
728         $hints['profileurl'] = $profile_url;
729
730         // Fetch the URL
731         // XXX: HTTP caching
732
733         $client = new HTTPClient();
734         $client->setHeader('Accept', 'text/html,application/xhtml+xml');
735         $response = $client->get($profile_url);
736
737         if (!$response->isOk()) {
738             return null;
739         }
740
741         // Check if we have a non-canonical URL
742
743         $finalUrl = $response->getUrl();
744
745         if ($finalUrl != $profile_url) {
746
747             $hints['profileurl'] = $finalUrl;
748
749             $oprofile = self::getFromProfileURL($finalUrl);
750
751             if (!empty($oprofile)) {
752                 return $oprofile;
753             }
754         }
755
756         // Try to get some hCard data
757
758         $body = $response->getBody();
759
760         $hcardHints = DiscoveryHints::hcardHints($body, $finalUrl);
761
762         if (!empty($hcardHints)) {
763             $hints = array_merge($hints, $hcardHints);
764         }
765
766         // Check if they've got an LRDD header
767
768         $lrdd = LinkHeader::getLink($response, 'lrdd', 'application/xrd+xml');
769
770         if (!empty($lrdd)) {
771
772             $xrd = Discovery::fetchXrd($lrdd);
773             $xrdHints = DiscoveryHints::fromXRD($xrd);
774
775             $hints = array_merge($hints, $xrdHints);
776         }
777
778         // If discovery found a feedurl (probably from LRDD), use it.
779
780         if (array_key_exists('feedurl', $hints)) {
781             return self::ensureFeedURL($hints['feedurl'], $hints);
782         }
783
784         // Get the feed URL from HTML
785
786         $discover = new FeedDiscovery();
787
788         $feedurl = $discover->discoverFromHTML($finalUrl, $body);
789
790         if (!empty($feedurl)) {
791             $hints['feedurl'] = $feedurl;
792
793             return self::ensureFeedURL($feedurl, $hints);
794         }
795     }
796
797     static function getFromProfileURL($profile_url)
798     {
799         $profile = Profile::staticGet('profileurl', $profile_url);
800
801         if (empty($profile)) {
802             return null;
803         }
804
805         // Is it a known Ostatus profile?
806
807         $oprofile = Ostatus_profile::staticGet('profile_id', $profile->id);
808
809         if (!empty($oprofile)) {
810             return $oprofile;
811         }
812
813         // Is it a local user?
814
815         $user = User::staticGet('id', $profile->id);
816
817         if (!empty($user)) {
818             throw new Exception("'$profile_url' is the profile for local user '{$user->nickname}'.");
819         }
820
821         // Continue discovery; it's a remote profile
822         // for OMB or some other protocol, may also
823         // support OStatus
824
825         return null;
826     }
827
828     public static function ensureFeedURL($feed_url, $hints=array())
829     {
830         $discover = new FeedDiscovery();
831
832         $feeduri = $discover->discoverFromFeedURL($feed_url);
833         $hints['feedurl'] = $feeduri;
834
835         $huburi = $discover->getAtomLink('hub');
836         $hints['hub'] = $huburi;
837         $salmonuri = $discover->getAtomLink(Salmon::NS_REPLIES);
838         $hints['salmon'] = $salmonuri;
839
840         if (!$huburi) {
841             // We can only deal with folks with a PuSH hub
842             throw new FeedSubNoHubException();
843         }
844
845         // Try to get a profile from the feed activity:subject
846
847         $feedEl = $discover->feed->documentElement;
848
849         $subject = ActivityUtils::child($feedEl, Activity::SUBJECT, Activity::SPEC);
850
851         if (!empty($subject)) {
852             $subjObject = new ActivityObject($subject);
853             return self::ensureActivityObjectProfile($subjObject, $hints);
854         }
855
856         // Otherwise, try the feed author
857
858         $author = ActivityUtils::child($feedEl, Activity::AUTHOR, Activity::ATOM);
859
860         if (!empty($author)) {
861             $authorObject = new ActivityObject($author);
862             return self::ensureActivityObjectProfile($authorObject, $hints);
863         }
864
865         // Sheesh. Not a very nice feed! Let's try fingerpoken in the
866         // entries.
867
868         $entries = $discover->feed->getElementsByTagNameNS(Activity::ATOM, 'entry');
869
870         if (!empty($entries) && $entries->length > 0) {
871
872             $entry = $entries->item(0);
873
874             $actor = ActivityUtils::child($entry, Activity::ACTOR, Activity::SPEC);
875
876             if (!empty($actor)) {
877                 $actorObject = new ActivityObject($actor);
878                 return self::ensureActivityObjectProfile($actorObject, $hints);
879
880             }
881
882             $author = ActivityUtils::child($entry, Activity::AUTHOR, Activity::ATOM);
883
884             if (!empty($author)) {
885                 $authorObject = new ActivityObject($author);
886                 return self::ensureActivityObjectProfile($authorObject, $hints);
887             }
888         }
889
890         // XXX: make some educated guesses here
891
892         throw new FeedSubException("Can't find enough profile information to make a feed.");
893     }
894
895     /**
896      *
897      * Download and update given avatar image
898      * @param string $url
899      * @throws Exception in various failure cases
900      */
901     protected function updateAvatar($url)
902     {
903         if ($url == $this->avatar) {
904             // We've already got this one.
905             return;
906         }
907
908         if ($this->isGroup()) {
909             $self = $this->localGroup();
910         } else {
911             $self = $this->localProfile();
912         }
913         if (!$self) {
914             throw new ServerException(sprintf(
915                 _m("Tried to update avatar for unsaved remote profile %s"),
916                 $this->uri));
917         }
918
919         // @fixme this should be better encapsulated
920         // ripped from oauthstore.php (for old OMB client)
921         $temp_filename = tempnam(sys_get_temp_dir(), 'listener_avatar');
922         if (!copy($url, $temp_filename)) {
923             throw new ServerException(sprintf(_m("Unable to fetch avatar from %s"), $url));
924         }
925
926         if ($this->isGroup()) {
927             $id = $this->group_id;
928         } else {
929             $id = $this->profile_id;
930         }
931         // @fixme should we be using different ids?
932         $imagefile = new ImageFile($id, $temp_filename);
933         $filename = Avatar::filename($id,
934                                      image_type_to_extension($imagefile->type),
935                                      null,
936                                      common_timestamp());
937         rename($temp_filename, Avatar::path($filename));
938         $self->setOriginal($filename);
939
940         $orig = clone($this);
941         $this->avatar = $url;
942         $this->update($orig);
943     }
944
945     /**
946      * Pull avatar URL from ActivityObject or profile hints
947      *
948      * @param ActivityObject $object
949      * @param array $hints
950      * @return mixed URL string or false
951      */
952
953     protected static function getActivityObjectAvatar($object, $hints=array())
954     {
955         if ($object->avatarLinks) {
956             $best = false;
957             // Take the exact-size avatar, or the largest avatar, or the first avatar if all sizeless
958             foreach ($object->avatarLinks as $avatar) {
959                 if ($avatar->width == AVATAR_PROFILE_SIZE && $avatar->height = AVATAR_PROFILE_SIZE) {
960                     // Exact match!
961                     $best = $avatar;
962                     break;
963                 }
964                 if (!$best || $avatar->width > $best->width) {
965                     $best = $avatar;
966                 }
967             }
968             return $best->url;
969         } else if (array_key_exists('avatar', $hints)) {
970             return $hints['avatar'];
971         }
972         return false;
973     }
974
975     /**
976      * Get an appropriate avatar image source URL, if available.
977      *
978      * @param ActivityObject $actor
979      * @param DOMElement $feed
980      * @return string
981      */
982
983     protected static function getAvatar($actor, $feed)
984     {
985         $url = '';
986         $icon = '';
987         if ($actor->avatar) {
988             $url = trim($actor->avatar);
989         }
990         if (!$url) {
991             // Check <atom:logo> and <atom:icon> on the feed
992             $els = $feed->childNodes();
993             if ($els && $els->length) {
994                 for ($i = 0; $i < $els->length; $i++) {
995                     $el = $els->item($i);
996                     if ($el->namespaceURI == Activity::ATOM) {
997                         if (empty($url) && $el->localName == 'logo') {
998                             $url = trim($el->textContent);
999                             break;
1000                         }
1001                         if (empty($icon) && $el->localName == 'icon') {
1002                             // Use as a fallback
1003                             $icon = trim($el->textContent);
1004                         }
1005                     }
1006                 }
1007             }
1008             if ($icon && !$url) {
1009                 $url = $icon;
1010             }
1011         }
1012         if ($url) {
1013             $opts = array('allowed_schemes' => array('http', 'https'));
1014             if (Validate::uri($url, $opts)) {
1015                 return $url;
1016             }
1017         }
1018         return common_path('plugins/OStatus/images/96px-Feed-icon.svg.png');
1019     }
1020
1021     /**
1022      * Fetch, or build if necessary, an Ostatus_profile for the actor
1023      * in a given Activity Streams activity.
1024      *
1025      * @param Activity $activity
1026      * @param string $feeduri if we already know the canonical feed URI!
1027      * @param string $salmonuri if we already know the salmon return channel URI
1028      * @return Ostatus_profile
1029      */
1030
1031     public static function ensureActorProfile($activity, $hints=array())
1032     {
1033         return self::ensureActivityObjectProfile($activity->actor, $hints);
1034     }
1035
1036     public static function ensureActivityObjectProfile($object, $hints=array())
1037     {
1038         $profile = self::getActivityObjectProfile($object);
1039         if ($profile) {
1040             $profile->updateFromActivityObject($object, $hints);
1041         } else {
1042             $profile = self::createActivityObjectProfile($object, $hints);
1043         }
1044         return $profile;
1045     }
1046
1047     /**
1048      * @param Activity $activity
1049      * @return mixed matching Ostatus_profile or false if none known
1050      */
1051     public static function getActorProfile($activity)
1052     {
1053         return self::getActivityObjectProfile($activity->actor);
1054     }
1055
1056     protected static function getActivityObjectProfile($object)
1057     {
1058         $uri = self::getActivityObjectProfileURI($object);
1059         return Ostatus_profile::staticGet('uri', $uri);
1060     }
1061
1062     protected static function getActorProfileURI($activity)
1063     {
1064         return self::getActivityObjectProfileURI($activity->actor);
1065     }
1066
1067     /**
1068      * @param Activity $activity
1069      * @return string
1070      * @throws ServerException
1071      */
1072     protected static function getActivityObjectProfileURI($object)
1073     {
1074         $opts = array('allowed_schemes' => array('http', 'https'));
1075         if ($object->id && Validate::uri($object->id, $opts)) {
1076             return $object->id;
1077         }
1078         if ($object->link && Validate::uri($object->link, $opts)) {
1079             return $object->link;
1080         }
1081         throw new ServerException("No author ID URI found");
1082     }
1083
1084     /**
1085      * @fixme validate stuff somewhere
1086      */
1087
1088     /**
1089      * Create local ostatus_profile and profile/user_group entries for
1090      * the provided remote user or group.
1091      *
1092      * @param ActivityObject $object
1093      * @param array $hints
1094      *
1095      * @return Ostatus_profile
1096      */
1097     protected static function createActivityObjectProfile($object, $hints=array())
1098     {
1099         $homeuri = $object->id;
1100         $discover = false;
1101
1102         if (!$homeuri) {
1103             common_log(LOG_DEBUG, __METHOD__ . " empty actor profile URI: " . var_export($activity, true));
1104             throw new Exception("No profile URI");
1105         }
1106
1107         if (OStatusPlugin::localProfileFromUrl($homeuri)) {
1108             throw new Exception("Local user can't be referenced as remote.");
1109         }
1110
1111         if (OStatusPlugin::localGroupFromUrl($homeuri)) {
1112             throw new Exception("Local group can't be referenced as remote.");
1113         }
1114
1115         if (array_key_exists('feedurl', $hints)) {
1116             $feeduri = $hints['feedurl'];
1117         } else {
1118             $discover = new FeedDiscovery();
1119             $feeduri = $discover->discoverFromURL($homeuri);
1120         }
1121
1122         if (array_key_exists('salmon', $hints)) {
1123             $salmonuri = $hints['salmon'];
1124         } else {
1125             if (!$discover) {
1126                 $discover = new FeedDiscovery();
1127                 $discover->discoverFromFeedURL($hints['feedurl']);
1128             }
1129             $salmonuri = $discover->getAtomLink(Salmon::NS_REPLIES);
1130         }
1131
1132         if (array_key_exists('hub', $hints)) {
1133             $huburi = $hints['hub'];
1134         } else {
1135             if (!$discover) {
1136                 $discover = new FeedDiscovery();
1137                 $discover->discoverFromFeedURL($hints['feedurl']);
1138             }
1139             $huburi = $discover->getAtomLink('hub');
1140         }
1141
1142         if (!$huburi) {
1143             // We can only deal with folks with a PuSH hub
1144             throw new FeedSubNoHubException();
1145         }
1146
1147         $oprofile = new Ostatus_profile();
1148
1149         $oprofile->uri        = $homeuri;
1150         $oprofile->feeduri    = $feeduri;
1151         $oprofile->salmonuri  = $salmonuri;
1152
1153         $oprofile->created    = common_sql_now();
1154         $oprofile->modified   = common_sql_now();
1155
1156         if ($object->type == ActivityObject::PERSON) {
1157             $profile = new Profile();
1158             $profile->created = common_sql_now();
1159             self::updateProfile($profile, $object, $hints);
1160
1161             $oprofile->profile_id = $profile->insert();
1162             if (!$oprofile->profile_id) {
1163                 throw new ServerException("Can't save local profile");
1164             }
1165         } else {
1166             $group = new User_group();
1167             $group->uri = $homeuri;
1168             $group->created = common_sql_now();
1169             self::updateGroup($group, $object, $hints);
1170
1171             $oprofile->group_id = $group->insert();
1172             if (!$oprofile->group_id) {
1173                 throw new ServerException("Can't save local profile");
1174             }
1175         }
1176
1177         $ok = $oprofile->insert();
1178
1179         if ($ok) {
1180             $avatar = self::getActivityObjectAvatar($object, $hints);
1181             if ($avatar) {
1182                 $oprofile->updateAvatar($avatar);
1183             }
1184             return $oprofile;
1185         } else {
1186             throw new ServerException("Can't save OStatus profile");
1187         }
1188     }
1189
1190     /**
1191      * Save any updated profile information to our local copy.
1192      * @param ActivityObject $object
1193      * @param array $hints
1194      */
1195     public function updateFromActivityObject($object, $hints=array())
1196     {
1197         if ($this->isGroup()) {
1198             $group = $this->localGroup();
1199             self::updateGroup($group, $object, $hints);
1200         } else {
1201             $profile = $this->localProfile();
1202             self::updateProfile($profile, $object, $hints);
1203         }
1204         $avatar = self::getActivityObjectAvatar($object, $hints);
1205         if ($avatar) {
1206             $this->updateAvatar($avatar);
1207         }
1208     }
1209
1210     protected static function updateProfile($profile, $object, $hints=array())
1211     {
1212         $orig = clone($profile);
1213
1214         $profile->nickname = self::getActivityObjectNickname($object, $hints);
1215
1216         if (!empty($object->title)) {
1217             $profile->fullname = $object->title;
1218         } else if (array_key_exists('fullname', $hints)) {
1219             $profile->fullname = $hints['fullname'];
1220         }
1221
1222         if (!empty($object->link)) {
1223             $profile->profileurl = $object->link;
1224         } else if (array_key_exists('profileurl', $hints)) {
1225             $profile->profileurl = $hints['profileurl'];
1226         } else if (Validate::uri($object->id, array('allowed_schemes' => array('http', 'https')))) {
1227             $profile->profileurl = $object->id;
1228         }
1229
1230         $profile->bio      = self::getActivityObjectBio($object, $hints);
1231         $profile->location = self::getActivityObjectLocation($object, $hints);
1232         $profile->homepage = self::getActivityObjectHomepage($object, $hints);
1233
1234         if (!empty($object->geopoint)) {
1235             $location = ActivityContext::locationFromPoint($object->geopoint);
1236             if (!empty($location)) {
1237                 $profile->lat = $location->lat;
1238                 $profile->lon = $location->lon;
1239             }
1240         }
1241
1242         // @fixme tags/categories
1243         // @todo tags from categories
1244
1245         if ($profile->id) {
1246             common_log(LOG_DEBUG, "Updating OStatus profile $profile->id from remote info $object->id: " . var_export($object, true) . var_export($hints, true));
1247             $profile->update($orig);
1248         }
1249     }
1250
1251     protected static function updateGroup($group, $object, $hints=array())
1252     {
1253         $orig = clone($group);
1254
1255         $group->nickname = self::getActivityObjectNickname($object, $hints);
1256         $group->fullname = $object->title;
1257
1258         if (!empty($object->link)) {
1259             $group->mainpage = $object->link;
1260         } else if (array_key_exists('profileurl', $hints)) {
1261             $group->mainpage = $hints['profileurl'];
1262         }
1263
1264         // @todo tags from categories
1265         $group->description = self::getActivityObjectBio($object, $hints);
1266         $group->location = self::getActivityObjectLocation($object, $hints);
1267         $group->homepage = self::getActivityObjectHomepage($object, $hints);
1268
1269         if ($group->id) {
1270             common_log(LOG_DEBUG, "Updating OStatus group $group->id from remote info $object->id: " . var_export($object, true) . var_export($hints, true));
1271             $group->update($orig);
1272         }
1273     }
1274
1275     protected static function getActivityObjectHomepage($object, $hints=array())
1276     {
1277         $homepage = null;
1278         $poco     = $object->poco;
1279
1280         if (!empty($poco)) {
1281             $url = $poco->getPrimaryURL();
1282             if ($url && $url->type == 'homepage') {
1283                 $homepage = $url->value;
1284             }
1285         }
1286
1287         // @todo Try for a another PoCo URL?
1288
1289         return $homepage;
1290     }
1291
1292     protected static function getActivityObjectLocation($object, $hints=array())
1293     {
1294         $location = null;
1295
1296         if (!empty($object->poco) &&
1297             isset($object->poco->address->formatted)) {
1298             $location = $object->poco->address->formatted;
1299         } else if (array_key_exists('location', $hints)) {
1300             $location = $hints['location'];
1301         }
1302
1303         if (!empty($location)) {
1304             if (mb_strlen($location) > 255) {
1305                 $location = mb_substr($note, 0, 255 - 3) . ' â€¦ ';
1306             }
1307         }
1308
1309         // @todo Try to find location some othe way? Via goerss point?
1310
1311         return $location;
1312     }
1313
1314     protected static function getActivityObjectBio($object, $hints=array())
1315     {
1316         $bio  = null;
1317
1318         if (!empty($object->poco)) {
1319             $note = $object->poco->note;
1320         } else if (array_key_exists('bio', $hints)) {
1321             $note = $hints['bio'];
1322         }
1323
1324         if (!empty($note)) {
1325             if (Profile::bioTooLong($note)) {
1326                 // XXX: truncate ok?
1327                 $bio = mb_substr($note, 0, Profile::maxBio() - 3) . ' â€¦ ';
1328             } else {
1329                 $bio = $note;
1330             }
1331         }
1332
1333         // @todo Try to get bio info some other way?
1334
1335         return $bio;
1336     }
1337
1338     protected static function getActivityObjectNickname($object, $hints=array())
1339     {
1340         if ($object->poco) {
1341             if (!empty($object->poco->preferredUsername)) {
1342                 return common_nicknamize($object->poco->preferredUsername);
1343             }
1344         }
1345
1346         if (!empty($object->nickname)) {
1347             return common_nicknamize($object->nickname);
1348         }
1349
1350         if (array_key_exists('nickname', $hints)) {
1351             return $hints['nickname'];
1352         }
1353
1354         // Try the definitive ID
1355
1356         $nickname = self::nicknameFromURI($object->id);
1357
1358         // Try a Webfinger if one was passed (way) down
1359
1360         if (empty($nickname)) {
1361             if (array_key_exists('webfinger', $hints)) {
1362                 $nickname = self::nicknameFromURI($hints['webfinger']);
1363             }
1364         }
1365
1366         // Try the name
1367
1368         if (empty($nickname)) {
1369             $nickname = common_nicknamize($object->title);
1370         }
1371
1372         return $nickname;
1373     }
1374
1375     protected static function nicknameFromURI($uri)
1376     {
1377         preg_match('/(\w+):/', $uri, $matches);
1378
1379         $protocol = $matches[1];
1380
1381         switch ($protocol) {
1382         case 'acct':
1383         case 'mailto':
1384             if (preg_match("/^$protocol:(.*)?@.*\$/", $uri, $matches)) {
1385                 return common_canonical_nickname($matches[1]);
1386             }
1387             return null;
1388         case 'http':
1389             return common_url_to_nickname($uri);
1390             break;
1391         default:
1392             return null;
1393         }
1394     }
1395
1396     /**
1397      * @param string $addr webfinger address
1398      * @return Ostatus_profile
1399      * @throws Exception on error conditions
1400      */
1401     public static function ensureWebfinger($addr)
1402     {
1403         // First, try the cache
1404
1405         $uri = self::cacheGet(sprintf('ostatus_profile:webfinger:%s', $addr));
1406
1407         if ($uri !== false) {
1408             if (is_null($uri)) {
1409                 // Negative cache entry
1410                 throw new Exception('Not a valid webfinger address.');
1411             }
1412             $oprofile = Ostatus_profile::staticGet('uri', $uri);
1413             if (!empty($oprofile)) {
1414                 return $oprofile;
1415             }
1416         }
1417
1418         // Try looking it up
1419
1420         $oprofile = Ostatus_profile::staticGet('uri', 'acct:'.$addr);
1421
1422         if (!empty($oprofile)) {
1423             self::cacheSet(sprintf('ostatus_profile:webfinger:%s', $addr), $oprofile->uri);
1424             return $oprofile;
1425         }
1426
1427         // Now, try some discovery
1428
1429         $disco = new Discovery();
1430
1431         try {
1432             $xrd = $disco->lookup($addr);
1433         } catch (Exception $e) {
1434             // Save negative cache entry so we don't waste time looking it up again.
1435             // @fixme distinguish temporary failures?
1436             self::cacheSet(sprintf('ostatus_profile:webfinger:%s', $addr), null);
1437             throw new Exception('Not a valid webfinger address.');
1438         }
1439
1440         $hints = array('webfinger' => $addr);
1441
1442         $dhints = DiscoveryHints::fromXRD($xrd);
1443
1444         $hints = array_merge($hints, $dhints);
1445
1446         // If there's an Hcard, let's grab its info
1447
1448         if (array_key_exists('hcard', $hints)) {
1449             if (!array_key_exists('profileurl', $hints) ||
1450                 $hints['hcard'] != $hints['profileurl']) {
1451                 $hcardHints = DiscoveryHints::fromHcardUrl($hints['hcard']);
1452                 $hints = array_merge($hcardHints, $hints);
1453             }
1454         }
1455
1456         // If we got a feed URL, try that
1457
1458         if (array_key_exists('feedurl', $hints)) {
1459             try {
1460                 common_log(LOG_INFO, "Discovery on acct:$addr with feed URL $feedUrl");
1461                 $oprofile = self::ensureFeedURL($hints['feedurl'], $hints);
1462                 self::cacheSet(sprintf('ostatus_profile:webfinger:%s', $addr), $oprofile->uri);
1463                 return $oprofile;
1464             } catch (Exception $e) {
1465                 common_log(LOG_WARNING, "Failed creating profile from feed URL '$feedUrl': " . $e->getMessage());
1466                 // keep looking
1467             }
1468         }
1469
1470         // If we got a profile page, try that!
1471
1472         if (array_key_exists('profileurl', $hints)) {
1473             try {
1474                 common_log(LOG_INFO, "Discovery on acct:$addr with profile URL $profileUrl");
1475                 $oprofile = self::ensureProfile($hints['profileurl'], $hints);
1476                 self::cacheSet(sprintf('ostatus_profile:webfinger:%s', $addr), $oprofile->uri);
1477                 return $oprofile;
1478             } catch (Exception $e) {
1479                 common_log(LOG_WARNING, "Failed creating profile from profile URL '$profileUrl': " . $e->getMessage());
1480                 // keep looking
1481             }
1482         }
1483
1484         // XXX: try hcard
1485         // XXX: try FOAF
1486
1487         if (array_key_exists('salmon', $hints)) {
1488
1489             $salmonEndpoint = $hints['salmon'];
1490
1491             // An account URL, a salmon endpoint, and a dream? Not much to go
1492             // on, but let's give it a try
1493
1494             $uri = 'acct:'.$addr;
1495
1496             $profile = new Profile();
1497
1498             $profile->nickname = self::nicknameFromUri($uri);
1499             $profile->created  = common_sql_now();
1500
1501             if (isset($profileUrl)) {
1502                 $profile->profileurl = $profileUrl;
1503             }
1504
1505             $profile_id = $profile->insert();
1506
1507             if (!$profile_id) {
1508                 common_log_db_error($profile, 'INSERT', __FILE__);
1509                 throw new Exception("Couldn't save profile for '$addr'");
1510             }
1511
1512             $oprofile = new Ostatus_profile();
1513
1514             $oprofile->uri        = $uri;
1515             $oprofile->salmonuri  = $salmonEndpoint;
1516             $oprofile->profile_id = $profile_id;
1517             $oprofile->created    = common_sql_now();
1518
1519             if (isset($feedUrl)) {
1520                 $profile->feeduri = $feedUrl;
1521             }
1522
1523             $result = $oprofile->insert();
1524
1525             if (!$result) {
1526                 common_log_db_error($oprofile, 'INSERT', __FILE__);
1527                 throw new Exception("Couldn't save ostatus_profile for '$addr'");
1528             }
1529
1530             self::cacheSet(sprintf('ostatus_profile:webfinger:%s', $addr), $oprofile->uri);
1531             return $oprofile;
1532         }
1533
1534         throw new Exception("Couldn't find a valid profile for '$addr'");
1535     }
1536
1537     /**
1538      * Store the full-length scrubbed HTML of a remote notice to an attachment
1539      * file on our server. We'll link to this at the end of the cropped version.
1540      *
1541      * @param string $title plaintext for HTML page's title
1542      * @param string $rendered HTML fragment for HTML page's body
1543      * @return File
1544      */
1545     function saveHTMLFile($title, $rendered)
1546     {
1547         $final = sprintf("<!DOCTYPE html>\n<html><head><title>%s</title></head>".
1548                          '<body>%s</body></html>',
1549                          htmlspecialchars($title),
1550                          $rendered);
1551
1552         $filename = File::filename($this->localProfile(),
1553                                    'ostatus', // ignored?
1554                                    'text/html');
1555
1556         $filepath = File::path($filename);
1557
1558         file_put_contents($filepath, $final);
1559
1560         $file = new File;
1561
1562         $file->filename = $filename;
1563         $file->url      = File::url($filename);
1564         $file->size     = filesize($filepath);
1565         $file->date     = time();
1566         $file->mimetype = 'text/html';
1567
1568         $file_id = $file->insert();
1569
1570         if ($file_id === false) {
1571             common_log_db_error($file, "INSERT", __FILE__);
1572             throw new ServerException(_('Could not store HTML content of long post as file.'));
1573         }
1574
1575         return $file;
1576     }
1577 }