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