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