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