]> git.mxchange.org Git - quix0rs-gnu-social.git/blob - plugins/OStatus/classes/Ostatus_profile.php
73f5d23229c705df09405a8e34e25e3ad8e3d0c3
[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(common_local_url('attachment',
546                                                            array('attachment' => $attachment->id)));
547                 $shortSummary = substr($shortSummary,
548                                        0,
549                                        Notice::maxContent() - (mb_strlen($url) + 2));
550                 $shortSummary .= '… ' . $url;
551                 $content = $shortSummary;
552                 $rendered = common_render_text($content);
553             }
554         }
555
556         $options = array('is_local' => Notice::REMOTE_OMB,
557                         'url' => $sourceUrl,
558                         'uri' => $sourceUri,
559                         'rendered' => $rendered,
560                         'replies' => array(),
561                         'groups' => array(),
562                         'tags' => array(),
563                         'urls' => array());
564
565         // Check for optional attributes...
566
567         if (!empty($activity->time)) {
568             $options['created'] = common_sql_date($activity->time);
569         }
570
571         if ($activity->context) {
572             // Any individual or group attn: targets?
573             $replies = $activity->context->attention;
574             $options['groups'] = $this->filterReplies($oprofile, $replies);
575             $options['replies'] = $replies;
576
577             // Maintain direct reply associations
578             // @fixme what about conversation ID?
579             if (!empty($activity->context->replyToID)) {
580                 $orig = Notice::staticGet('uri',
581                                           $activity->context->replyToID);
582                 if (!empty($orig)) {
583                     $options['reply_to'] = $orig->id;
584                 }
585             }
586
587             $location = $activity->context->location;
588             if ($location) {
589                 $options['lat'] = $location->lat;
590                 $options['lon'] = $location->lon;
591                 if ($location->location_id) {
592                     $options['location_ns'] = $location->location_ns;
593                     $options['location_id'] = $location->location_id;
594                 }
595             }
596         }
597
598         // Atom categories <-> hashtags
599         foreach ($activity->categories as $cat) {
600             if ($cat->term) {
601                 $term = common_canonical_tag($cat->term);
602                 if ($term) {
603                     $options['tags'][] = $term;
604                 }
605             }
606         }
607
608         // Atom enclosures -> attachment URLs
609         foreach ($activity->enclosures as $href) {
610             // @fixme save these locally or....?
611             $options['urls'][] = $href;
612         }
613
614         try {
615             $saved = Notice::saveNew($oprofile->profile_id,
616                                      $content,
617                                      'ostatus',
618                                      $options);
619             if ($saved) {
620                 Ostatus_source::saveNew($saved, $this, $method);
621                 if (!empty($attachment)) {
622                     File_to_post::processNew($attachment->id, $saved->id);
623                 }
624             }
625         } catch (Exception $e) {
626             common_log(LOG_ERR, "OStatus save of remote message $sourceUri failed: " . $e->getMessage());
627             throw $e;
628         }
629         common_log(LOG_INFO, "OStatus saved remote message $sourceUri as notice id $saved->id");
630         return $saved;
631     }
632
633     /**
634      * Clean up HTML
635      */
636     protected function purify($html)
637     {
638         require_once INSTALLDIR.'/extlib/htmLawed/htmLawed.php';
639         $config = array('safe' => 1,
640                         'deny_attribute' => 'id,style,on*');
641         return htmLawed($html, $config);
642     }
643
644     /**
645      * Filters a list of recipient ID URIs to just those for local delivery.
646      * @param Ostatus_profile local profile of sender
647      * @param array in/out &$attention_uris set of URIs, will be pruned on output
648      * @return array of group IDs
649      */
650     protected function filterReplies($sender, &$attention_uris)
651     {
652         common_log(LOG_DEBUG, "Original reply recipients: " . implode(', ', $attention_uris));
653         $groups = array();
654         $replies = array();
655         foreach ($attention_uris as $recipient) {
656             // Is the recipient a local user?
657             $user = User::staticGet('uri', $recipient);
658             if ($user) {
659                 // @fixme sender verification, spam etc?
660                 $replies[] = $recipient;
661                 continue;
662             }
663
664             // Is the recipient a remote group?
665             $oprofile = Ostatus_profile::staticGet('uri', $recipient);
666             if ($oprofile) {
667                 if ($oprofile->isGroup()) {
668                     // Deliver to local members of this remote group.
669                     // @fixme sender verification?
670                     $groups[] = $oprofile->group_id;
671                 } else {
672                     common_log(LOG_DEBUG, "Skipping reply to remote profile $recipient");
673                 }
674                 continue;
675             }
676
677             // Is the recipient a local group?
678             // @fixme uri on user_group isn't reliable yet
679             // $group = User_group::staticGet('uri', $recipient);
680             $id = OStatusPlugin::localGroupFromUrl($recipient);
681             if ($id) {
682                 $group = User_group::staticGet('id', $id);
683                 if ($group) {
684                     // Deliver to all members of this local group if allowed.
685                     $profile = $sender->localProfile();
686                     if ($profile->isMember($group)) {
687                         $groups[] = $group->id;
688                     } else {
689                         common_log(LOG_DEBUG, "Skipping reply to local group $group->nickname as sender $profile->id is not a member");
690                     }
691                     continue;
692                 } else {
693                     common_log(LOG_DEBUG, "Skipping reply to bogus group $recipient");
694                 }
695             }
696
697             common_log(LOG_DEBUG, "Skipping reply to unrecognized profile $recipient");
698
699         }
700         $attention_uris = $replies;
701         common_log(LOG_DEBUG, "Local reply recipients: " . implode(', ', $replies));
702         common_log(LOG_DEBUG, "Local group recipients: " . implode(', ', $groups));
703         return $groups;
704     }
705
706     /**
707      * @param string $profile_url
708      * @return Ostatus_profile
709      * @throws FeedSubException
710      */
711
712     public static function ensureProfileURL($profile_url, $hints=array())
713     {
714         $oprofile = self::getFromProfileURL($profile_url);
715
716         if (!empty($oprofile)) {
717             return $oprofile;
718         }
719
720         $hints['profileurl'] = $profile_url;
721
722         // Fetch the URL
723         // XXX: HTTP caching
724
725         $client = new HTTPClient();
726         $client->setHeader('Accept', 'text/html,application/xhtml+xml');
727         $response = $client->get($profile_url);
728
729         if (!$response->isOk()) {
730             return null;
731         }
732
733         // Check if we have a non-canonical URL
734
735         $finalUrl = $response->getUrl();
736
737         if ($finalUrl != $profile_url) {
738
739             $hints['profileurl'] = $finalUrl;
740
741             $oprofile = self::getFromProfileURL($finalUrl);
742
743             if (!empty($oprofile)) {
744                 return $oprofile;
745             }
746         }
747
748         // Try to get some hCard data
749
750         $body = $response->getBody();
751
752         $hcardHints = DiscoveryHints::hcardHints($body, $finalUrl);
753
754         if (!empty($hcardHints)) {
755             $hints = array_merge($hints, $hcardHints);
756         }
757
758         // Check if they've got an LRDD header
759
760         $lrdd = LinkHeader::getLink($response, 'lrdd', 'application/xrd+xml');
761
762         if (!empty($lrdd)) {
763
764             $xrd = Discovery::fetchXrd($lrdd);
765             $xrdHints = DiscoveryHints::fromXRD($xrd);
766
767             $hints = array_merge($hints, $xrdHints);
768         }
769
770         // If discovery found a feedurl (probably from LRDD), use it.
771
772         if (array_key_exists('feedurl', $hints)) {
773             return self::ensureFeedURL($hints['feedurl'], $hints);
774         }
775
776         // Get the feed URL from HTML
777
778         $discover = new FeedDiscovery();
779
780         $feedurl = $discover->discoverFromHTML($finalUrl, $body);
781
782         if (!empty($feedurl)) {
783             $hints['feedurl'] = $feedurl;
784
785             return self::ensureFeedURL($feedurl, $hints);
786         }
787     }
788
789     static function getFromProfileURL($profile_url)
790     {
791         $profile = Profile::staticGet('profileurl', $profile_url);
792
793         if (empty($profile)) {
794             return null;
795         }
796
797         // Is it a known Ostatus profile?
798
799         $oprofile = Ostatus_profile::staticGet('profile_id', $profile->id);
800
801         if (!empty($oprofile)) {
802             return $oprofile;
803         }
804
805         // Is it a local user?
806
807         $user = User::staticGet('id', $profile->id);
808
809         if (!empty($user)) {
810             throw new Exception("'$profile_url' is the profile for local user '{$user->nickname}'.");
811         }
812
813         // Continue discovery; it's a remote profile
814         // for OMB or some other protocol, may also
815         // support OStatus
816
817         return null;
818     }
819
820     public static function ensureFeedURL($feed_url, $hints=array())
821     {
822         $discover = new FeedDiscovery();
823
824         $feeduri = $discover->discoverFromFeedURL($feed_url);
825         $hints['feedurl'] = $feeduri;
826
827         $huburi = $discover->getAtomLink('hub');
828         $hints['hub'] = $huburi;
829         $salmonuri = $discover->getAtomLink(Salmon::NS_REPLIES);
830         $hints['salmon'] = $salmonuri;
831
832         if (!$huburi) {
833             // We can only deal with folks with a PuSH hub
834             throw new FeedSubNoHubException();
835         }
836
837         // Try to get a profile from the feed activity:subject
838
839         $feedEl = $discover->feed->documentElement;
840
841         $subject = ActivityUtils::child($feedEl, Activity::SUBJECT, Activity::SPEC);
842
843         if (!empty($subject)) {
844             $subjObject = new ActivityObject($subject);
845             return self::ensureActivityObjectProfile($subjObject, $hints);
846         }
847
848         // Otherwise, try the feed author
849
850         $author = ActivityUtils::child($feedEl, Activity::AUTHOR, Activity::ATOM);
851
852         if (!empty($author)) {
853             $authorObject = new ActivityObject($author);
854             return self::ensureActivityObjectProfile($authorObject, $hints);
855         }
856
857         // Sheesh. Not a very nice feed! Let's try fingerpoken in the
858         // entries.
859
860         $entries = $discover->feed->getElementsByTagNameNS(Activity::ATOM, 'entry');
861
862         if (!empty($entries) && $entries->length > 0) {
863
864             $entry = $entries->item(0);
865
866             $actor = ActivityUtils::child($entry, Activity::ACTOR, Activity::SPEC);
867
868             if (!empty($actor)) {
869                 $actorObject = new ActivityObject($actor);
870                 return self::ensureActivityObjectProfile($actorObject, $hints);
871
872             }
873
874             $author = ActivityUtils::child($entry, Activity::AUTHOR, Activity::ATOM);
875
876             if (!empty($author)) {
877                 $authorObject = new ActivityObject($author);
878                 return self::ensureActivityObjectProfile($authorObject, $hints);
879             }
880         }
881
882         // XXX: make some educated guesses here
883
884         throw new FeedSubException("Can't find enough profile information to make a feed.");
885     }
886
887     /**
888      *
889      * Download and update given avatar image
890      * @param string $url
891      * @throws Exception in various failure cases
892      */
893     protected function updateAvatar($url)
894     {
895         if ($url == $this->avatar) {
896             // We've already got this one.
897             return;
898         }
899
900         if ($this->isGroup()) {
901             $self = $this->localGroup();
902         } else {
903             $self = $this->localProfile();
904         }
905         if (!$self) {
906             throw new ServerException(sprintf(
907                 _m("Tried to update avatar for unsaved remote profile %s"),
908                 $this->uri));
909         }
910
911         // @fixme this should be better encapsulated
912         // ripped from oauthstore.php (for old OMB client)
913         $temp_filename = tempnam(sys_get_temp_dir(), 'listener_avatar');
914         if (!copy($url, $temp_filename)) {
915             throw new ServerException(sprintf(_m("Unable to fetch avatar from %s"), $url));
916         }
917
918         if ($this->isGroup()) {
919             $id = $this->group_id;
920         } else {
921             $id = $this->profile_id;
922         }
923         // @fixme should we be using different ids?
924         $imagefile = new ImageFile($id, $temp_filename);
925         $filename = Avatar::filename($id,
926                                      image_type_to_extension($imagefile->type),
927                                      null,
928                                      common_timestamp());
929         rename($temp_filename, Avatar::path($filename));
930         $self->setOriginal($filename);
931
932         $orig = clone($this);
933         $this->avatar = $url;
934         $this->update($orig);
935     }
936
937     /**
938      * Pull avatar URL from ActivityObject or profile hints
939      *
940      * @param ActivityObject $object
941      * @param array $hints
942      * @return mixed URL string or false
943      */
944
945     protected static function getActivityObjectAvatar($object, $hints=array())
946     {
947         if ($object->avatarLinks) {
948             $best = false;
949             // Take the exact-size avatar, or the largest avatar, or the first avatar if all sizeless
950             foreach ($object->avatarLinks as $avatar) {
951                 if ($avatar->width == AVATAR_PROFILE_SIZE && $avatar->height = AVATAR_PROFILE_SIZE) {
952                     // Exact match!
953                     $best = $avatar;
954                     break;
955                 }
956                 if (!$best || $avatar->width > $best->width) {
957                     $best = $avatar;
958                 }
959             }
960             return $best->url;
961         } else if (array_key_exists('avatar', $hints)) {
962             return $hints['avatar'];
963         }
964         return false;
965     }
966
967     /**
968      * Get an appropriate avatar image source URL, if available.
969      *
970      * @param ActivityObject $actor
971      * @param DOMElement $feed
972      * @return string
973      */
974
975     protected static function getAvatar($actor, $feed)
976     {
977         $url = '';
978         $icon = '';
979         if ($actor->avatar) {
980             $url = trim($actor->avatar);
981         }
982         if (!$url) {
983             // Check <atom:logo> and <atom:icon> on the feed
984             $els = $feed->childNodes();
985             if ($els && $els->length) {
986                 for ($i = 0; $i < $els->length; $i++) {
987                     $el = $els->item($i);
988                     if ($el->namespaceURI == Activity::ATOM) {
989                         if (empty($url) && $el->localName == 'logo') {
990                             $url = trim($el->textContent);
991                             break;
992                         }
993                         if (empty($icon) && $el->localName == 'icon') {
994                             // Use as a fallback
995                             $icon = trim($el->textContent);
996                         }
997                     }
998                 }
999             }
1000             if ($icon && !$url) {
1001                 $url = $icon;
1002             }
1003         }
1004         if ($url) {
1005             $opts = array('allowed_schemes' => array('http', 'https'));
1006             if (Validate::uri($url, $opts)) {
1007                 return $url;
1008             }
1009         }
1010         return common_path('plugins/OStatus/images/96px-Feed-icon.svg.png');
1011     }
1012
1013     /**
1014      * Fetch, or build if necessary, an Ostatus_profile for the actor
1015      * in a given Activity Streams activity.
1016      *
1017      * @param Activity $activity
1018      * @param string $feeduri if we already know the canonical feed URI!
1019      * @param string $salmonuri if we already know the salmon return channel URI
1020      * @return Ostatus_profile
1021      */
1022
1023     public static function ensureActorProfile($activity, $hints=array())
1024     {
1025         return self::ensureActivityObjectProfile($activity->actor, $hints);
1026     }
1027
1028     public static function ensureActivityObjectProfile($object, $hints=array())
1029     {
1030         $profile = self::getActivityObjectProfile($object);
1031         if ($profile) {
1032             $profile->updateFromActivityObject($object, $hints);
1033         } else {
1034             $profile = self::createActivityObjectProfile($object, $hints);
1035         }
1036         return $profile;
1037     }
1038
1039     /**
1040      * @param Activity $activity
1041      * @return mixed matching Ostatus_profile or false if none known
1042      */
1043     public static function getActorProfile($activity)
1044     {
1045         return self::getActivityObjectProfile($activity->actor);
1046     }
1047
1048     protected static function getActivityObjectProfile($object)
1049     {
1050         $uri = self::getActivityObjectProfileURI($object);
1051         return Ostatus_profile::staticGet('uri', $uri);
1052     }
1053
1054     protected static function getActorProfileURI($activity)
1055     {
1056         return self::getActivityObjectProfileURI($activity->actor);
1057     }
1058
1059     /**
1060      * @param Activity $activity
1061      * @return string
1062      * @throws ServerException
1063      */
1064     protected static function getActivityObjectProfileURI($object)
1065     {
1066         $opts = array('allowed_schemes' => array('http', 'https'));
1067         if ($object->id && Validate::uri($object->id, $opts)) {
1068             return $object->id;
1069         }
1070         if ($object->link && Validate::uri($object->link, $opts)) {
1071             return $object->link;
1072         }
1073         throw new ServerException("No author ID URI found");
1074     }
1075
1076     /**
1077      * @fixme validate stuff somewhere
1078      */
1079
1080     /**
1081      * Create local ostatus_profile and profile/user_group entries for
1082      * the provided remote user or group.
1083      *
1084      * @param ActivityObject $object
1085      * @param array $hints
1086      *
1087      * @return Ostatus_profile
1088      */
1089     protected static function createActivityObjectProfile($object, $hints=array())
1090     {
1091         $homeuri = $object->id;
1092         $discover = false;
1093
1094         if (!$homeuri) {
1095             common_log(LOG_DEBUG, __METHOD__ . " empty actor profile URI: " . var_export($activity, true));
1096             throw new Exception("No profile URI");
1097         }
1098
1099         if (OStatusPlugin::localProfileFromUrl($homeuri)) {
1100             throw new Exception("Local user can't be referenced as remote.");
1101         }
1102
1103         if (OStatusPlugin::localGroupFromUrl($homeuri)) {
1104             throw new Exception("Local group can't be referenced as remote.");
1105         }
1106
1107         if (array_key_exists('feedurl', $hints)) {
1108             $feeduri = $hints['feedurl'];
1109         } else {
1110             $discover = new FeedDiscovery();
1111             $feeduri = $discover->discoverFromURL($homeuri);
1112         }
1113
1114         if (array_key_exists('salmon', $hints)) {
1115             $salmonuri = $hints['salmon'];
1116         } else {
1117             if (!$discover) {
1118                 $discover = new FeedDiscovery();
1119                 $discover->discoverFromFeedURL($hints['feedurl']);
1120             }
1121             $salmonuri = $discover->getAtomLink(Salmon::NS_REPLIES);
1122         }
1123
1124         if (array_key_exists('hub', $hints)) {
1125             $huburi = $hints['hub'];
1126         } else {
1127             if (!$discover) {
1128                 $discover = new FeedDiscovery();
1129                 $discover->discoverFromFeedURL($hints['feedurl']);
1130             }
1131             $huburi = $discover->getAtomLink('hub');
1132         }
1133
1134         if (!$huburi) {
1135             // We can only deal with folks with a PuSH hub
1136             throw new FeedSubNoHubException();
1137         }
1138
1139         $oprofile = new Ostatus_profile();
1140
1141         $oprofile->uri        = $homeuri;
1142         $oprofile->feeduri    = $feeduri;
1143         $oprofile->salmonuri  = $salmonuri;
1144
1145         $oprofile->created    = common_sql_now();
1146         $oprofile->modified   = common_sql_now();
1147
1148         if ($object->type == ActivityObject::PERSON) {
1149             $profile = new Profile();
1150             $profile->created = common_sql_now();
1151             self::updateProfile($profile, $object, $hints);
1152
1153             $oprofile->profile_id = $profile->insert();
1154             if (!$oprofile->profile_id) {
1155                 throw new ServerException("Can't save local profile");
1156             }
1157         } else {
1158             $group = new User_group();
1159             $group->uri = $homeuri;
1160             $group->created = common_sql_now();
1161             self::updateGroup($group, $object, $hints);
1162
1163             $oprofile->group_id = $group->insert();
1164             if (!$oprofile->group_id) {
1165                 throw new ServerException("Can't save local profile");
1166             }
1167         }
1168
1169         $ok = $oprofile->insert();
1170
1171         if ($ok) {
1172             $avatar = self::getActivityObjectAvatar($object, $hints);
1173             if ($avatar) {
1174                 $oprofile->updateAvatar($avatar);
1175             }
1176             return $oprofile;
1177         } else {
1178             throw new ServerException("Can't save OStatus profile");
1179         }
1180     }
1181
1182     /**
1183      * Save any updated profile information to our local copy.
1184      * @param ActivityObject $object
1185      * @param array $hints
1186      */
1187     public function updateFromActivityObject($object, $hints=array())
1188     {
1189         if ($this->isGroup()) {
1190             $group = $this->localGroup();
1191             self::updateGroup($group, $object, $hints);
1192         } else {
1193             $profile = $this->localProfile();
1194             self::updateProfile($profile, $object, $hints);
1195         }
1196         $avatar = self::getActivityObjectAvatar($object, $hints);
1197         if ($avatar) {
1198             $this->updateAvatar($avatar);
1199         }
1200     }
1201
1202     protected static function updateProfile($profile, $object, $hints=array())
1203     {
1204         $orig = clone($profile);
1205
1206         $profile->nickname = self::getActivityObjectNickname($object, $hints);
1207
1208         if (!empty($object->title)) {
1209             $profile->fullname = $object->title;
1210         } else if (array_key_exists('fullname', $hints)) {
1211             $profile->fullname = $hints['fullname'];
1212         }
1213
1214         if (!empty($object->link)) {
1215             $profile->profileurl = $object->link;
1216         } else if (array_key_exists('profileurl', $hints)) {
1217             $profile->profileurl = $hints['profileurl'];
1218         } else if (Validate::uri($object->id, array('allowed_schemes' => array('http', 'https')))) {
1219             $profile->profileurl = $object->id;
1220         }
1221
1222         $profile->bio      = self::getActivityObjectBio($object, $hints);
1223         $profile->location = self::getActivityObjectLocation($object, $hints);
1224         $profile->homepage = self::getActivityObjectHomepage($object, $hints);
1225
1226         if (!empty($object->geopoint)) {
1227             $location = ActivityContext::locationFromPoint($object->geopoint);
1228             if (!empty($location)) {
1229                 $profile->lat = $location->lat;
1230                 $profile->lon = $location->lon;
1231             }
1232         }
1233
1234         // @fixme tags/categories
1235         // @todo tags from categories
1236
1237         if ($profile->id) {
1238             common_log(LOG_DEBUG, "Updating OStatus profile $profile->id from remote info $object->id: " . var_export($object, true) . var_export($hints, true));
1239             $profile->update($orig);
1240         }
1241     }
1242
1243     protected static function updateGroup($group, $object, $hints=array())
1244     {
1245         $orig = clone($group);
1246
1247         $group->nickname = self::getActivityObjectNickname($object, $hints);
1248         $group->fullname = $object->title;
1249
1250         if (!empty($object->link)) {
1251             $group->mainpage = $object->link;
1252         } else if (array_key_exists('profileurl', $hints)) {
1253             $group->mainpage = $hints['profileurl'];
1254         }
1255
1256         // @todo tags from categories
1257         $group->description = self::getActivityObjectBio($object, $hints);
1258         $group->location = self::getActivityObjectLocation($object, $hints);
1259         $group->homepage = self::getActivityObjectHomepage($object, $hints);
1260
1261         if ($group->id) {
1262             common_log(LOG_DEBUG, "Updating OStatus group $group->id from remote info $object->id: " . var_export($object, true) . var_export($hints, true));
1263             $group->update($orig);
1264         }
1265     }
1266
1267     protected static function getActivityObjectHomepage($object, $hints=array())
1268     {
1269         $homepage = null;
1270         $poco     = $object->poco;
1271
1272         if (!empty($poco)) {
1273             $url = $poco->getPrimaryURL();
1274             if ($url && $url->type == 'homepage') {
1275                 $homepage = $url->value;
1276             }
1277         }
1278
1279         // @todo Try for a another PoCo URL?
1280
1281         return $homepage;
1282     }
1283
1284     protected static function getActivityObjectLocation($object, $hints=array())
1285     {
1286         $location = null;
1287
1288         if (!empty($object->poco) &&
1289             isset($object->poco->address->formatted)) {
1290             $location = $object->poco->address->formatted;
1291         } else if (array_key_exists('location', $hints)) {
1292             $location = $hints['location'];
1293         }
1294
1295         if (!empty($location)) {
1296             if (mb_strlen($location) > 255) {
1297                 $location = mb_substr($note, 0, 255 - 3) . ' â€¦ ';
1298             }
1299         }
1300
1301         // @todo Try to find location some othe way? Via goerss point?
1302
1303         return $location;
1304     }
1305
1306     protected static function getActivityObjectBio($object, $hints=array())
1307     {
1308         $bio  = null;
1309
1310         if (!empty($object->poco)) {
1311             $note = $object->poco->note;
1312         } else if (array_key_exists('bio', $hints)) {
1313             $note = $hints['bio'];
1314         }
1315
1316         if (!empty($note)) {
1317             if (Profile::bioTooLong($note)) {
1318                 // XXX: truncate ok?
1319                 $bio = mb_substr($note, 0, Profile::maxBio() - 3) . ' â€¦ ';
1320             } else {
1321                 $bio = $note;
1322             }
1323         }
1324
1325         // @todo Try to get bio info some other way?
1326
1327         return $bio;
1328     }
1329
1330     protected static function getActivityObjectNickname($object, $hints=array())
1331     {
1332         if ($object->poco) {
1333             if (!empty($object->poco->preferredUsername)) {
1334                 return common_nicknamize($object->poco->preferredUsername);
1335             }
1336         }
1337
1338         if (!empty($object->nickname)) {
1339             return common_nicknamize($object->nickname);
1340         }
1341
1342         if (array_key_exists('nickname', $hints)) {
1343             return $hints['nickname'];
1344         }
1345
1346         // Try the definitive ID
1347
1348         $nickname = self::nicknameFromURI($object->id);
1349
1350         // Try a Webfinger if one was passed (way) down
1351
1352         if (empty($nickname)) {
1353             if (array_key_exists('webfinger', $hints)) {
1354                 $nickname = self::nicknameFromURI($hints['webfinger']);
1355             }
1356         }
1357
1358         // Try the name
1359
1360         if (empty($nickname)) {
1361             $nickname = common_nicknamize($object->title);
1362         }
1363
1364         return $nickname;
1365     }
1366
1367     protected static function nicknameFromURI($uri)
1368     {
1369         preg_match('/(\w+):/', $uri, $matches);
1370
1371         $protocol = $matches[1];
1372
1373         switch ($protocol) {
1374         case 'acct':
1375         case 'mailto':
1376             if (preg_match("/^$protocol:(.*)?@.*\$/", $uri, $matches)) {
1377                 return common_canonical_nickname($matches[1]);
1378             }
1379             return null;
1380         case 'http':
1381             return common_url_to_nickname($uri);
1382             break;
1383         default:
1384             return null;
1385         }
1386     }
1387
1388     /**
1389      * @param string $addr webfinger address
1390      * @return Ostatus_profile
1391      * @throws Exception on error conditions
1392      */
1393     public static function ensureWebfinger($addr)
1394     {
1395         // First, try the cache
1396
1397         $uri = self::cacheGet(sprintf('ostatus_profile:webfinger:%s', $addr));
1398
1399         if ($uri !== false) {
1400             if (is_null($uri)) {
1401                 // Negative cache entry
1402                 throw new Exception('Not a valid webfinger address.');
1403             }
1404             $oprofile = Ostatus_profile::staticGet('uri', $uri);
1405             if (!empty($oprofile)) {
1406                 return $oprofile;
1407             }
1408         }
1409
1410         // Try looking it up
1411
1412         $oprofile = Ostatus_profile::staticGet('uri', 'acct:'.$addr);
1413
1414         if (!empty($oprofile)) {
1415             self::cacheSet(sprintf('ostatus_profile:webfinger:%s', $addr), $oprofile->uri);
1416             return $oprofile;
1417         }
1418
1419         // Now, try some discovery
1420
1421         $disco = new Discovery();
1422
1423         try {
1424             $xrd = $disco->lookup($addr);
1425         } catch (Exception $e) {
1426             // Save negative cache entry so we don't waste time looking it up again.
1427             // @fixme distinguish temporary failures?
1428             self::cacheSet(sprintf('ostatus_profile:webfinger:%s', $addr), null);
1429             throw new Exception('Not a valid webfinger address.');
1430         }
1431
1432         $hints = array('webfinger' => $addr);
1433
1434         $dhints = DiscoveryHints::fromXRD($xrd);
1435
1436         $hints = array_merge($hints, $dhints);
1437
1438         // If there's an Hcard, let's grab its info
1439
1440         if (array_key_exists('hcard', $hints)) {
1441             if (!array_key_exists('profileurl', $hints) ||
1442                 $hints['hcard'] != $hints['profileurl']) {
1443                 $hcardHints = DiscoveryHints::fromHcardUrl($hints['hcard']);
1444                 $hints = array_merge($hcardHints, $hints);
1445             }
1446         }
1447
1448         // If we got a feed URL, try that
1449
1450         if (array_key_exists('feedurl', $hints)) {
1451             try {
1452                 common_log(LOG_INFO, "Discovery on acct:$addr with feed URL $feedUrl");
1453                 $oprofile = self::ensureFeedURL($hints['feedurl'], $hints);
1454                 self::cacheSet(sprintf('ostatus_profile:webfinger:%s', $addr), $oprofile->uri);
1455                 return $oprofile;
1456             } catch (Exception $e) {
1457                 common_log(LOG_WARNING, "Failed creating profile from feed URL '$feedUrl': " . $e->getMessage());
1458                 // keep looking
1459             }
1460         }
1461
1462         // If we got a profile page, try that!
1463
1464         if (array_key_exists('profileurl', $hints)) {
1465             try {
1466                 common_log(LOG_INFO, "Discovery on acct:$addr with profile URL $profileUrl");
1467                 $oprofile = self::ensureProfile($hints['profileurl'], $hints);
1468                 self::cacheSet(sprintf('ostatus_profile:webfinger:%s', $addr), $oprofile->uri);
1469                 return $oprofile;
1470             } catch (Exception $e) {
1471                 common_log(LOG_WARNING, "Failed creating profile from profile URL '$profileUrl': " . $e->getMessage());
1472                 // keep looking
1473             }
1474         }
1475
1476         // XXX: try hcard
1477         // XXX: try FOAF
1478
1479         if (array_key_exists('salmon', $hints)) {
1480
1481             $salmonEndpoint = $hints['salmon'];
1482
1483             // An account URL, a salmon endpoint, and a dream? Not much to go
1484             // on, but let's give it a try
1485
1486             $uri = 'acct:'.$addr;
1487
1488             $profile = new Profile();
1489
1490             $profile->nickname = self::nicknameFromUri($uri);
1491             $profile->created  = common_sql_now();
1492
1493             if (isset($profileUrl)) {
1494                 $profile->profileurl = $profileUrl;
1495             }
1496
1497             $profile_id = $profile->insert();
1498
1499             if (!$profile_id) {
1500                 common_log_db_error($profile, 'INSERT', __FILE__);
1501                 throw new Exception("Couldn't save profile for '$addr'");
1502             }
1503
1504             $oprofile = new Ostatus_profile();
1505
1506             $oprofile->uri        = $uri;
1507             $oprofile->salmonuri  = $salmonEndpoint;
1508             $oprofile->profile_id = $profile_id;
1509             $oprofile->created    = common_sql_now();
1510
1511             if (isset($feedUrl)) {
1512                 $profile->feeduri = $feedUrl;
1513             }
1514
1515             $result = $oprofile->insert();
1516
1517             if (!$result) {
1518                 common_log_db_error($oprofile, 'INSERT', __FILE__);
1519                 throw new Exception("Couldn't save ostatus_profile for '$addr'");
1520             }
1521
1522             self::cacheSet(sprintf('ostatus_profile:webfinger:%s', $addr), $oprofile->uri);
1523             return $oprofile;
1524         }
1525
1526         throw new Exception("Couldn't find a valid profile for '$addr'");
1527     }
1528
1529     function saveHTMLFile($title, $rendered)
1530     {
1531         $final = sprintf("<!DOCTYPE html>\n<html><head><title>%s</title></head>".
1532                          '<body><div>%s</div></body></html>',
1533                          htmlspecialchars($title),
1534                          $rendered);
1535
1536         $filename = File::filename($this->localProfile(),
1537                                    'ostatus', // ignored?
1538                                    'text/html');
1539
1540         $filepath = File::path($filename);
1541
1542         file_put_contents($filepath, $final);
1543
1544         $file = new File;
1545
1546         $file->filename = $filename;
1547         $file->url      = File::url($filename);
1548         $file->size     = filesize($filepath);
1549         $file->date     = time();
1550         $file->mimetype = 'text/html';
1551
1552         $file_id = $file->insert();
1553
1554         if ($file_id === false) {
1555             common_log_db_error($file, "INSERT", __FILE__);
1556             throw new ServerException(_('Could not store HTML content of long post as file.'));
1557         }
1558
1559         return $file;
1560     }
1561 }