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