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