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