]> git.mxchange.org Git - quix0rs-gnu-social.git/blob - plugins/OStatus/classes/Ostatus_profile.php
Merge branch 'testing' into 0.9.x
[quix0rs-gnu-social.git] / plugins / OStatus / classes / Ostatus_profile.php
1 <?php
2 /*
3  * StatusNet - the distributed open-source microblogging tool
4  * Copyright (C) 2009-2010, StatusNet, Inc.
5  *
6  * This program is free software: you can redistribute it and/or modify
7  * it under the terms of the GNU Affero General Public License as published by
8  * the Free Software Foundation, either version 3 of the License, or
9  * (at your option) any later version.
10  *
11  * This program is distributed in the hope that it will be useful,
12  * but WITHOUT ANY WARRANTY; without even the implied warranty of
13  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14  * GNU Affero General Public License for more details.
15  *
16  * You should have received a copy of the GNU Affero General Public License
17  * along with this program.  If not, see <http://www.gnu.org/licenses/>.
18  */
19
20 /**
21  * @package OStatusPlugin
22  * @maintainer Brion Vibber <brion@status.net>
23  */
24
25 class Ostatus_profile extends Memcached_DataObject
26 {
27     public $__table = 'ostatus_profile';
28
29     public $uri;
30
31     public $profile_id;
32     public $group_id;
33
34     public $feeduri;
35     public $salmonuri;
36     public $avatar; // remote URL of the last avatar we saved
37
38     public $created;
39     public $modified;
40
41     public /*static*/ function staticGet($k, $v=null)
42     {
43         return parent::staticGet(__CLASS__, $k, $v);
44     }
45
46     /**
47      * return table definition for DB_DataObject
48      *
49      * DB_DataObject needs to know something about the table to manipulate
50      * instances. This method provides all the DB_DataObject needs to know.
51      *
52      * @return array array of column definitions
53      */
54
55     function table()
56     {
57         return array('uri' => DB_DATAOBJECT_STR + DB_DATAOBJECT_NOTNULL,
58                      'profile_id' => DB_DATAOBJECT_INT,
59                      'group_id' => DB_DATAOBJECT_INT,
60                      'feeduri' => DB_DATAOBJECT_STR,
61                      'salmonuri' =>  DB_DATAOBJECT_STR,
62                      'avatar' =>  DB_DATAOBJECT_STR,
63                      'created' => DB_DATAOBJECT_STR + DB_DATAOBJECT_DATE + DB_DATAOBJECT_TIME + DB_DATAOBJECT_NOTNULL,
64                      'modified' => DB_DATAOBJECT_STR + DB_DATAOBJECT_DATE + DB_DATAOBJECT_TIME + DB_DATAOBJECT_NOTNULL);
65     }
66
67     static function schemaDef()
68     {
69         return array(new ColumnDef('uri', 'varchar',
70                                    255, false, 'PRI'),
71                      new ColumnDef('profile_id', 'integer',
72                                    null, true, 'UNI'),
73                      new ColumnDef('group_id', 'integer',
74                                    null, true, 'UNI'),
75                      new ColumnDef('feeduri', 'varchar',
76                                    255, true, 'UNI'),
77                      new ColumnDef('salmonuri', 'text',
78                                    null, true),
79                      new ColumnDef('avatar', 'text',
80                                    null, true),
81                      new ColumnDef('created', 'datetime',
82                                    null, false),
83                      new ColumnDef('modified', 'datetime',
84                                    null, false));
85     }
86
87     /**
88      * return key definitions for DB_DataObject
89      *
90      * DB_DataObject needs to know about keys that the table has; this function
91      * defines them.
92      *
93      * @return array key definitions
94      */
95
96     function keys()
97     {
98         return array_keys($this->keyTypes());
99     }
100
101     /**
102      * return key definitions for Memcached_DataObject
103      *
104      * Our caching system uses the same key definitions, but uses a different
105      * method to get them.
106      *
107      * @return array key definitions
108      */
109
110     function keyTypes()
111     {
112         return array('uri' => 'K', 'profile_id' => 'U', 'group_id' => 'U', 'feeduri' => 'U');
113     }
114
115     function sequenceKey()
116     {
117         return array(false, false, false);
118     }
119
120     /**
121      * Fetch the StatusNet-side profile for this feed
122      * @return Profile
123      */
124     public function localProfile()
125     {
126         if ($this->profile_id) {
127             return Profile::staticGet('id', $this->profile_id);
128         }
129         return null;
130     }
131
132     /**
133      * Fetch the StatusNet-side profile for this feed
134      * @return Profile
135      */
136     public function localGroup()
137     {
138         if ($this->group_id) {
139             return User_group::staticGet('id', $this->group_id);
140         }
141         return null;
142     }
143
144     /**
145      * Returns an ActivityObject describing this remote user or group profile.
146      * Can then be used to generate Atom chunks.
147      *
148      * @return ActivityObject
149      */
150     function asActivityObject()
151     {
152         if ($this->isGroup()) {
153             return ActivityObject::fromGroup($this->localGroup());
154         } else {
155             return ActivityObject::fromProfile($this->localProfile());
156         }
157     }
158
159     /**
160      * Returns an XML string fragment with profile information as an
161      * Activity Streams noun object with the given element type.
162      *
163      * Assumes that 'activity' namespace has been previously defined.
164      *
165      * @fixme replace with wrappers on asActivityObject when it's got everything.
166      *
167      * @param string $element one of 'actor', 'subject', 'object', 'target'
168      * @return string
169      */
170     function asActivityNoun($element)
171     {
172         if ($this->isGroup()) {
173             $noun = ActivityObject::fromGroup($this->localGroup());
174             return $noun->asString('activity:' . $element);
175         } else {
176             $noun = ActivityObject::fromProfile($this->localProfile());
177             return $noun->asString('activity:' . $element);
178         }
179     }
180
181     /**
182      * @return boolean true if this is a remote group
183      */
184     function isGroup()
185     {
186         if ($this->profile_id && !$this->group_id) {
187             return false;
188         } else if ($this->group_id && !$this->profile_id) {
189             return true;
190         } else if ($this->group_id && $this->profile_id) {
191             throw new ServerException("Invalid ostatus_profile state: both group and profile IDs set for $this->uri");
192         } else {
193             throw new ServerException("Invalid ostatus_profile state: both group and profile IDs empty for $this->uri");
194         }
195     }
196
197     /**
198      * Send a subscription request to the hub for this feed.
199      * The hub will later send us a confirmation POST to /main/push/callback.
200      *
201      * @return bool true on success, false on failure
202      * @throws ServerException if feed state is not valid
203      */
204     public function subscribe()
205     {
206         $feedsub = FeedSub::ensureFeed($this->feeduri);
207         if ($feedsub->sub_state == 'active') {
208             // Active subscription, we don't need to do anything.
209             return true;
210         } else {
211             // Inactive or we got left in an inconsistent state.
212             // Run a subscription request to make sure we're current!
213             return $feedsub->subscribe();
214         }
215     }
216
217     /**
218      * Send a PuSH unsubscription request to the hub for this feed.
219      * The hub will later send us a confirmation POST to /main/push/callback.
220      *
221      * @return bool true on success, false on failure
222      * @throws ServerException if feed state is not valid
223      */
224     public function unsubscribe() {
225         $feedsub = FeedSub::staticGet('uri', $this->feeduri);
226         if (!$feedsub || $feedsub->sub_state == '' || $feedsub->sub_state == 'inactive') {
227             // No active PuSH subscription, we can just leave it be.
228             return true;
229         } else {
230             // PuSH subscription is either active or in an indeterminate state.
231             // Send an unsubscribe.
232             return $feedsub->unsubscribe();
233         }
234     }
235
236     /**
237      * Check if this remote profile has any active local subscriptions, and
238      * if not drop the PuSH subscription feed.
239      *
240      * @return boolean
241      */
242     public function garbageCollect()
243     {
244         if ($this->isGroup()) {
245             $members = $this->localGroup()->getMembers(0, 1);
246             $count = $members->N;
247         } else {
248             $count = $this->localProfile()->subscriberCount();
249         }
250         if ($count == 0) {
251             common_log(LOG_INFO, "Unsubscribing from now-unused remote feed $this->feeduri");
252             $this->unsubscribe();
253             return true;
254         } else {
255             return false;
256         }
257     }
258
259     /**
260      * Send an Activity Streams notification to the remote Salmon endpoint,
261      * if so configured.
262      *
263      * @param Profile $actor  Actor who did the activity
264      * @param string  $verb   Activity::SUBSCRIBE or Activity::JOIN
265      * @param Object  $object object of the action; must define asActivityNoun($tag)
266      */
267     public function notify($actor, $verb, $object=null)
268     {
269         if (!($actor instanceof Profile)) {
270             $type = gettype($actor);
271             if ($type == 'object') {
272                 $type = get_class($actor);
273             }
274             throw new ServerException("Invalid actor passed to " . __METHOD__ . ": " . $type);
275         }
276         if ($object == null) {
277             $object = $this;
278         }
279         if ($this->salmonuri) {
280
281             $text = 'update';
282             $id = TagURI::mint('%s:%s:%s',
283                                $verb,
284                                $actor->getURI(),
285                                common_date_iso8601(time()));
286
287             // @fixme consolidate all these NS settings somewhere
288             $attributes = array('xmlns' => Activity::ATOM,
289                                 'xmlns:activity' => 'http://activitystrea.ms/spec/1.0/',
290                                 'xmlns:thr' => 'http://purl.org/syndication/thread/1.0',
291                                 'xmlns:georss' => 'http://www.georss.org/georss',
292                                 'xmlns:ostatus' => 'http://ostatus.org/schema/1.0',
293                                 'xmlns:poco' => 'http://portablecontacts.net/spec/1.0',
294                                 'xmlns:media' => 'http://purl.org/syndication/atommedia');
295
296             $entry = new XMLStringer();
297             $entry->elementStart('entry', $attributes);
298             $entry->element('id', null, $id);
299             $entry->element('title', null, $text);
300             $entry->element('summary', null, $text);
301             $entry->element('published', null, common_date_w3dtf(common_sql_now()));
302
303             $entry->element('activity:verb', null, $verb);
304             $entry->raw($actor->asAtomAuthor());
305             $entry->raw($actor->asActivityActor());
306             $entry->raw($object->asActivityNoun('object'));
307             $entry->elementEnd('entry');
308
309             $xml = $entry->getString();
310             common_log(LOG_INFO, "Posting to Salmon endpoint $this->salmonuri: $xml");
311
312             $salmon = new Salmon(); // ?
313             return $salmon->post($this->salmonuri, $xml, $actor);
314         }
315         return false;
316     }
317
318     /**
319      * Send a Salmon notification ping immediately, and confirm that we got
320      * an acceptable response from the remote site.
321      *
322      * @param mixed $entry XML string, Notice, or Activity
323      * @return boolean success
324      */
325     public function notifyActivity($entry, $actor)
326     {
327         if ($this->salmonuri) {
328             $salmon = new Salmon();
329             return $salmon->post($this->salmonuri, $this->notifyPrepXml($entry), $actor);
330         }
331
332         return false;
333     }
334
335     /**
336      * Queue a Salmon notification for later. If queues are disabled we'll
337      * send immediately but won't get the return value.
338      *
339      * @param mixed $entry XML string, Notice, or Activity
340      * @return boolean success
341      */
342     public function notifyDeferred($entry, $actor)
343     {
344         if ($this->salmonuri) {
345             $data = array('salmonuri' => $this->salmonuri,
346                           'entry' => $this->notifyPrepXml($entry),
347                           'actor' => $actor->id);
348
349             $qm = QueueManager::get();
350             return $qm->enqueue($data, 'salmon');
351         }
352
353         return false;
354     }
355
356     protected function notifyPrepXml($entry)
357     {
358         $preamble = '<?xml version="1.0" encoding="UTF-8" ?' . '>';
359         if (is_string($entry)) {
360             return $entry;
361         } else if ($entry instanceof Activity) {
362             return $preamble . $entry->asString(true);
363         } else if ($entry instanceof Notice) {
364             return $preamble . $entry->asAtomEntry(true, true);
365         } else {
366             throw new ServerException("Invalid type passed to Ostatus_profile::notify; must be XML string or Activity entry");
367         }
368     }
369
370     function getBestName()
371     {
372         if ($this->isGroup()) {
373             return $this->localGroup()->getBestName();
374         } else {
375             return $this->localProfile()->getBestName();
376         }
377     }
378
379     /**
380      * Read and post notices for updates from the feed.
381      * Currently assumes that all items in the feed are new,
382      * coming from a PuSH hub.
383      *
384      * @param DOMDocument $doc
385      * @param string $source identifier ("push")
386      */
387     public function processFeed(DOMDocument $doc, $source)
388     {
389         $feed = $doc->documentElement;
390
391         if ($feed->localName == 'feed' && $feed->namespaceURI == Activity::ATOM) {
392             $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         // It's not always an ActivityObject::NOTE, but... let's just say it is.
485
486         $note = $activity->object;
487
488         // The id URI will be used as a unique identifier for for the notice,
489         // protecting against duplicate saves. It isn't required to be a URL;
490         // tag: URIs for instance are found in Google Buzz feeds.
491         $sourceUri = $note->id;
492         $dupe = Notice::staticGet('uri', $sourceUri);
493         if ($dupe) {
494             common_log(LOG_INFO, "OStatus: ignoring duplicate post: $sourceUri");
495             return false;
496         }
497
498         // We'll also want to save a web link to the original notice, if provided.
499         $sourceUrl = null;
500         if ($note->link) {
501             $sourceUrl = $note->link;
502         } else if ($activity->link) {
503             $sourceUrl = $activity->link;
504         } else if (preg_match('!^https?://!', $note->id)) {
505             $sourceUrl = $note->id;
506         }
507
508         // Use summary as fallback for content
509
510         if (!empty($note->content)) {
511             $sourceContent = $note->content;
512         } else if (!empty($note->summary)) {
513             $sourceContent = $note->summary;
514         } else if (!empty($note->title)) {
515             $sourceContent = $note->title;
516         } else {
517             // @fixme fetch from $sourceUrl?
518             throw new ClientException("No content for notice {$sourceUri}");
519         }
520
521         // Get (safe!) HTML and text versions of the content
522
523         $rendered = $this->purify($sourceContent);
524         $content = html_entity_decode(strip_tags($rendered));
525
526         $shortened = common_shorten_links($content);
527
528         // If it's too long, try using the summary, and make the
529         // HTML an attachment.
530
531         $attachment = null;
532
533         if (Notice::contentTooLong($shortened)) {
534             $attachment = $this->saveHTMLFile($note->title, $rendered);
535             $summary = html_entity_decode(strip_tags($note->summary));
536             if (empty($summary)) {
537                 $summary = $content;
538             }
539             $shortSummary = common_shorten_links($summary);
540             if (Notice::contentTooLong($shortSummary)) {
541                 $url = common_shorten_url($sourceUrl);
542                 $shortSummary = substr($shortSummary,
543                                        0,
544                                        Notice::maxContent() - (mb_strlen($url) + 2));
545                 $content = $shortSummary . ' ' . $url;
546
547                 // We mark up the attachment link specially for the HTML output
548                 // so we can fold-out the full version inline.
549                 $attachUrl = common_local_url('attachment',
550                                               array('attachment' => $attachment->id));
551                 $rendered = common_render_text($shortSummary) .
552                             '<a href="' . htmlspecialchars($attachUrl) .'"'.
553                             ' class="attachment more"' .
554                             ' title="'. htmlspecialchars(_m('Show more')) . '">' .
555                             '&#8230;' .
556                             '</a>';
557             }
558         }
559
560         $options = array('is_local' => Notice::REMOTE_OMB,
561                         'url' => $sourceUrl,
562                         'uri' => $sourceUri,
563                         'rendered' => $rendered,
564                         'replies' => array(),
565                         'groups' => array(),
566                         'tags' => array(),
567                         'urls' => array());
568
569         // Check for optional attributes...
570
571         if (!empty($activity->time)) {
572             $options['created'] = common_sql_date($activity->time);
573         }
574
575         if ($activity->context) {
576             // Any individual or group attn: targets?
577             $replies = $activity->context->attention;
578             $options['groups'] = $this->filterReplies($oprofile, $replies);
579             $options['replies'] = $replies;
580
581             // Maintain direct reply associations
582             // @fixme what about conversation ID?
583             if (!empty($activity->context->replyToID)) {
584                 $orig = Notice::staticGet('uri',
585                                           $activity->context->replyToID);
586                 if (!empty($orig)) {
587                     $options['reply_to'] = $orig->id;
588                 }
589             }
590
591             $location = $activity->context->location;
592             if ($location) {
593                 $options['lat'] = $location->lat;
594                 $options['lon'] = $location->lon;
595                 if ($location->location_id) {
596                     $options['location_ns'] = $location->location_ns;
597                     $options['location_id'] = $location->location_id;
598                 }
599             }
600         }
601
602         // Atom categories <-> hashtags
603         foreach ($activity->categories as $cat) {
604             if ($cat->term) {
605                 $term = common_canonical_tag($cat->term);
606                 if ($term) {
607                     $options['tags'][] = $term;
608                 }
609             }
610         }
611
612         // Atom enclosures -> attachment URLs
613         foreach ($activity->enclosures as $href) {
614             // @fixme save these locally or....?
615             $options['urls'][] = $href;
616         }
617
618         try {
619             $saved = Notice::saveNew($oprofile->profile_id,
620                                      $content,
621                                      'ostatus',
622                                      $options);
623             if ($saved) {
624                 Ostatus_source::saveNew($saved, $this, $method);
625                 if (!empty($attachment)) {
626                     File_to_post::processNew($attachment->id, $saved->id);
627                 }
628             }
629         } catch (Exception $e) {
630             common_log(LOG_ERR, "OStatus save of remote message $sourceUri failed: " . $e->getMessage());
631             throw $e;
632         }
633         common_log(LOG_INFO, "OStatus saved remote message $sourceUri as notice id $saved->id");
634         return $saved;
635     }
636
637     /**
638      * Clean up HTML
639      */
640     protected function purify($html)
641     {
642         require_once INSTALLDIR.'/extlib/htmLawed/htmLawed.php';
643         $config = array('safe' => 1,
644                         'deny_attribute' => 'id,style,on*');
645         return htmLawed($html, $config);
646     }
647
648     /**
649      * Filters a list of recipient ID URIs to just those for local delivery.
650      * @param Ostatus_profile local profile of sender
651      * @param array in/out &$attention_uris set of URIs, will be pruned on output
652      * @return array of group IDs
653      */
654     protected function filterReplies($sender, &$attention_uris)
655     {
656         common_log(LOG_DEBUG, "Original reply recipients: " . implode(', ', $attention_uris));
657         $groups = array();
658         $replies = array();
659         foreach ($attention_uris as $recipient) {
660             // Is the recipient a local user?
661             $user = User::staticGet('uri', $recipient);
662             if ($user) {
663                 // @fixme sender verification, spam etc?
664                 $replies[] = $recipient;
665                 continue;
666             }
667
668             // Is the recipient a remote group?
669             $oprofile = Ostatus_profile::staticGet('uri', $recipient);
670             if ($oprofile) {
671                 if ($oprofile->isGroup()) {
672                     // Deliver to local members of this remote group.
673                     // @fixme sender verification?
674                     $groups[] = $oprofile->group_id;
675                 } else {
676                     common_log(LOG_DEBUG, "Skipping reply to remote profile $recipient");
677                 }
678                 continue;
679             }
680
681             // Is the recipient a local group?
682             // @fixme uri on user_group isn't reliable yet
683             // $group = User_group::staticGet('uri', $recipient);
684             $id = OStatusPlugin::localGroupFromUrl($recipient);
685             if ($id) {
686                 $group = User_group::staticGet('id', $id);
687                 if ($group) {
688                     // Deliver to all members of this local group if allowed.
689                     $profile = $sender->localProfile();
690                     if ($profile->isMember($group)) {
691                         $groups[] = $group->id;
692                     } else {
693                         common_log(LOG_DEBUG, "Skipping reply to local group $group->nickname as sender $profile->id is not a member");
694                     }
695                     continue;
696                 } else {
697                     common_log(LOG_DEBUG, "Skipping reply to bogus group $recipient");
698                 }
699             }
700
701             common_log(LOG_DEBUG, "Skipping reply to unrecognized profile $recipient");
702
703         }
704         $attention_uris = $replies;
705         common_log(LOG_DEBUG, "Local reply recipients: " . implode(', ', $replies));
706         common_log(LOG_DEBUG, "Local group recipients: " . implode(', ', $groups));
707         return $groups;
708     }
709
710     /**
711      * @param string $profile_url
712      * @return Ostatus_profile
713      * @throws FeedSubException
714      */
715
716     public static function ensureProfileURL($profile_url, $hints=array())
717     {
718         $oprofile = self::getFromProfileURL($profile_url);
719
720         if (!empty($oprofile)) {
721             return $oprofile;
722         }
723
724         $hints['profileurl'] = $profile_url;
725
726         // Fetch the URL
727         // XXX: HTTP caching
728
729         $client = new HTTPClient();
730         $client->setHeader('Accept', 'text/html,application/xhtml+xml');
731         $response = $client->get($profile_url);
732
733         if (!$response->isOk()) {
734             return null;
735         }
736
737         // Check if we have a non-canonical URL
738
739         $finalUrl = $response->getUrl();
740
741         if ($finalUrl != $profile_url) {
742
743             $hints['profileurl'] = $finalUrl;
744
745             $oprofile = self::getFromProfileURL($finalUrl);
746
747             if (!empty($oprofile)) {
748                 return $oprofile;
749             }
750         }
751
752         // Try to get some hCard data
753
754         $body = $response->getBody();
755
756         $hcardHints = DiscoveryHints::hcardHints($body, $finalUrl);
757
758         if (!empty($hcardHints)) {
759             $hints = array_merge($hints, $hcardHints);
760         }
761
762         // Check if they've got an LRDD header
763
764         $lrdd = LinkHeader::getLink($response, 'lrdd', 'application/xrd+xml');
765
766         if (!empty($lrdd)) {
767
768             $xrd = Discovery::fetchXrd($lrdd);
769             $xrdHints = DiscoveryHints::fromXRD($xrd);
770
771             $hints = array_merge($hints, $xrdHints);
772         }
773
774         // If discovery found a feedurl (probably from LRDD), use it.
775
776         if (array_key_exists('feedurl', $hints)) {
777             return self::ensureFeedURL($hints['feedurl'], $hints);
778         }
779
780         // Get the feed URL from HTML
781
782         $discover = new FeedDiscovery();
783
784         $feedurl = $discover->discoverFromHTML($finalUrl, $body);
785
786         if (!empty($feedurl)) {
787             $hints['feedurl'] = $feedurl;
788
789             return self::ensureFeedURL($feedurl, $hints);
790         }
791     }
792
793     static function getFromProfileURL($profile_url)
794     {
795         $profile = Profile::staticGet('profileurl', $profile_url);
796
797         if (empty($profile)) {
798             return null;
799         }
800
801         // Is it a known Ostatus profile?
802
803         $oprofile = Ostatus_profile::staticGet('profile_id', $profile->id);
804
805         if (!empty($oprofile)) {
806             return $oprofile;
807         }
808
809         // Is it a local user?
810
811         $user = User::staticGet('id', $profile->id);
812
813         if (!empty($user)) {
814             throw new Exception("'$profile_url' is the profile for local user '{$user->nickname}'.");
815         }
816
817         // Continue discovery; it's a remote profile
818         // for OMB or some other protocol, may also
819         // support OStatus
820
821         return null;
822     }
823
824     public static function ensureFeedURL($feed_url, $hints=array())
825     {
826         $discover = new FeedDiscovery();
827
828         $feeduri = $discover->discoverFromFeedURL($feed_url);
829         $hints['feedurl'] = $feeduri;
830
831         $huburi = $discover->getAtomLink('hub');
832         $hints['hub'] = $huburi;
833         $salmonuri = $discover->getAtomLink(Salmon::NS_REPLIES);
834         $hints['salmon'] = $salmonuri;
835
836         if (!$huburi) {
837             // We can only deal with folks with a PuSH hub
838             throw new FeedSubNoHubException();
839         }
840
841         $feedEl = $discover->root;
842
843         if ($feedEl->tagName == 'feed') {
844             return self::ensureAtomFeed($feedEl, $hints);
845         } else if ($feedEl->tagName == 'channel') {
846             return self::ensureRssChannel($feedEl, $hints);
847         } else {
848             throw new FeedSubBadXmlException($feeduri);
849         }
850     }
851
852     public static function ensureAtomFeed($feedEl, $hints)
853     {
854         // Try to get a profile from the feed activity:subject
855
856         $subject = ActivityUtils::child($feedEl, Activity::SUBJECT, Activity::SPEC);
857
858         if (!empty($subject)) {
859             $subjObject = new ActivityObject($subject);
860             return self::ensureActivityObjectProfile($subjObject, $hints);
861         }
862
863         // Otherwise, try the feed author
864
865         $author = ActivityUtils::child($feedEl, Activity::AUTHOR, Activity::ATOM);
866
867         if (!empty($author)) {
868             $authorObject = new ActivityObject($author);
869             return self::ensureActivityObjectProfile($authorObject, $hints);
870         }
871
872         // Sheesh. Not a very nice feed! Let's try fingerpoken in the
873         // entries.
874
875         $entries = $feedEl->getElementsByTagNameNS(Activity::ATOM, 'entry');
876
877         if (!empty($entries) && $entries->length > 0) {
878
879             $entry = $entries->item(0);
880
881             $actor = ActivityUtils::child($entry, Activity::ACTOR, Activity::SPEC);
882
883             if (!empty($actor)) {
884                 $actorObject = new ActivityObject($actor);
885                 return self::ensureActivityObjectProfile($actorObject, $hints);
886
887             }
888
889             $author = ActivityUtils::child($entry, Activity::AUTHOR, Activity::ATOM);
890
891             if (!empty($author)) {
892                 $authorObject = new ActivityObject($author);
893                 return self::ensureActivityObjectProfile($authorObject, $hints);
894             }
895         }
896
897         // XXX: make some educated guesses here
898
899         throw new FeedSubException("Can't find enough profile information to make a feed.");
900     }
901
902     public static function ensureRssChannel($feedEl, $hints)
903     {
904         // @fixme we should check whether this feed has elements
905         // with different <author> or <dc:creator> elements, and... I dunno.
906         // Do something about that.
907
908         $obj = ActivityObject::fromRssChannel($feedEl);
909
910         return self::ensureActivityObjectProfile($obj, $hints);
911     }
912
913     /**
914      * Download and update given avatar image
915      *
916      * @param string $url
917      * @throws Exception in various failure cases
918      */
919     protected function updateAvatar($url)
920     {
921         if ($url == $this->avatar) {
922             // We've already got this one.
923             return;
924         }
925         if (!common_valid_http_url($url)) {
926             throw new ServerException(_m("Invalid avatar URL %s"), $url);
927         }
928
929         if ($this->isGroup()) {
930             $self = $this->localGroup();
931         } else {
932             $self = $this->localProfile();
933         }
934         if (!$self) {
935             throw new ServerException(sprintf(
936                 _m("Tried to update avatar for unsaved remote profile %s"),
937                 $this->uri));
938         }
939
940         // @fixme this should be better encapsulated
941         // ripped from oauthstore.php (for old OMB client)
942         $temp_filename = tempnam(sys_get_temp_dir(), 'listener_avatar');
943         if (!copy($url, $temp_filename)) {
944             throw new ServerException(sprintf(_m("Unable to fetch avatar from %s"), $url));
945         }
946
947         if ($this->isGroup()) {
948             $id = $this->group_id;
949         } else {
950             $id = $this->profile_id;
951         }
952         // @fixme should we be using different ids?
953         $imagefile = new ImageFile($id, $temp_filename);
954         $filename = Avatar::filename($id,
955                                      image_type_to_extension($imagefile->type),
956                                      null,
957                                      common_timestamp());
958         rename($temp_filename, Avatar::path($filename));
959         $self->setOriginal($filename);
960
961         $orig = clone($this);
962         $this->avatar = $url;
963         $this->update($orig);
964     }
965
966     /**
967      * Pull avatar URL from ActivityObject or profile hints
968      *
969      * @param ActivityObject $object
970      * @param array $hints
971      * @return mixed URL string or false
972      */
973
974     protected static function getActivityObjectAvatar($object, $hints=array())
975     {
976         if ($object->avatarLinks) {
977             $best = false;
978             // Take the exact-size avatar, or the largest avatar, or the first avatar if all sizeless
979             foreach ($object->avatarLinks as $avatar) {
980                 if ($avatar->width == AVATAR_PROFILE_SIZE && $avatar->height = AVATAR_PROFILE_SIZE) {
981                     // Exact match!
982                     $best = $avatar;
983                     break;
984                 }
985                 if (!$best || $avatar->width > $best->width) {
986                     $best = $avatar;
987                 }
988             }
989             return $best->url;
990         } else if (array_key_exists('avatar', $hints)) {
991             return $hints['avatar'];
992         }
993         return false;
994     }
995
996     /**
997      * Get an appropriate avatar image source URL, if available.
998      *
999      * @param ActivityObject $actor
1000      * @param DOMElement $feed
1001      * @return string
1002      */
1003
1004     protected static function getAvatar($actor, $feed)
1005     {
1006         $url = '';
1007         $icon = '';
1008         if ($actor->avatar) {
1009             $url = trim($actor->avatar);
1010         }
1011         if (!$url) {
1012             // Check <atom:logo> and <atom:icon> on the feed
1013             $els = $feed->childNodes();
1014             if ($els && $els->length) {
1015                 for ($i = 0; $i < $els->length; $i++) {
1016                     $el = $els->item($i);
1017                     if ($el->namespaceURI == Activity::ATOM) {
1018                         if (empty($url) && $el->localName == 'logo') {
1019                             $url = trim($el->textContent);
1020                             break;
1021                         }
1022                         if (empty($icon) && $el->localName == 'icon') {
1023                             // Use as a fallback
1024                             $icon = trim($el->textContent);
1025                         }
1026                     }
1027                 }
1028             }
1029             if ($icon && !$url) {
1030                 $url = $icon;
1031             }
1032         }
1033         if ($url) {
1034             $opts = array('allowed_schemes' => array('http', 'https'));
1035             if (Validate::uri($url, $opts)) {
1036                 return $url;
1037             }
1038         }
1039         return common_path('plugins/OStatus/images/96px-Feed-icon.svg.png');
1040     }
1041
1042     /**
1043      * Fetch, or build if necessary, an Ostatus_profile for the actor
1044      * in a given Activity Streams activity.
1045      *
1046      * @param Activity $activity
1047      * @param string $feeduri if we already know the canonical feed URI!
1048      * @param string $salmonuri if we already know the salmon return channel URI
1049      * @return Ostatus_profile
1050      */
1051
1052     public static function ensureActorProfile($activity, $hints=array())
1053     {
1054         return self::ensureActivityObjectProfile($activity->actor, $hints);
1055     }
1056
1057     public static function ensureActivityObjectProfile($object, $hints=array())
1058     {
1059         $profile = self::getActivityObjectProfile($object);
1060         if ($profile) {
1061             $profile->updateFromActivityObject($object, $hints);
1062         } else {
1063             $profile = self::createActivityObjectProfile($object, $hints);
1064         }
1065         return $profile;
1066     }
1067
1068     /**
1069      * @param Activity $activity
1070      * @return mixed matching Ostatus_profile or false if none known
1071      */
1072     public static function getActorProfile($activity)
1073     {
1074         return self::getActivityObjectProfile($activity->actor);
1075     }
1076
1077     protected static function getActivityObjectProfile($object)
1078     {
1079         $uri = self::getActivityObjectProfileURI($object);
1080         return Ostatus_profile::staticGet('uri', $uri);
1081     }
1082
1083     protected static function getActorProfileURI($activity)
1084     {
1085         return self::getActivityObjectProfileURI($activity->actor);
1086     }
1087
1088     /**
1089      * @param Activity $activity
1090      * @return string
1091      * @throws ServerException
1092      */
1093     protected static function getActivityObjectProfileURI($object)
1094     {
1095         $opts = array('allowed_schemes' => array('http', 'https'));
1096         if ($object->id && Validate::uri($object->id, $opts)) {
1097             return $object->id;
1098         }
1099         if ($object->link && Validate::uri($object->link, $opts)) {
1100             return $object->link;
1101         }
1102         throw new ServerException("No author ID URI found");
1103     }
1104
1105     /**
1106      * @fixme validate stuff somewhere
1107      */
1108
1109     /**
1110      * Create local ostatus_profile and profile/user_group entries for
1111      * the provided remote user or group.
1112      *
1113      * @param ActivityObject $object
1114      * @param array $hints
1115      *
1116      * @return Ostatus_profile
1117      */
1118     protected static function createActivityObjectProfile($object, $hints=array())
1119     {
1120         $homeuri = $object->id;
1121         $discover = false;
1122
1123         if (!$homeuri) {
1124             common_log(LOG_DEBUG, __METHOD__ . " empty actor profile URI: " . var_export($activity, true));
1125             throw new Exception("No profile URI");
1126         }
1127
1128         if (OStatusPlugin::localProfileFromUrl($homeuri)) {
1129             throw new Exception("Local user can't be referenced as remote.");
1130         }
1131
1132         if (OStatusPlugin::localGroupFromUrl($homeuri)) {
1133             throw new Exception("Local group can't be referenced as remote.");
1134         }
1135
1136         if (array_key_exists('feedurl', $hints)) {
1137             $feeduri = $hints['feedurl'];
1138         } else {
1139             $discover = new FeedDiscovery();
1140             $feeduri = $discover->discoverFromURL($homeuri);
1141         }
1142
1143         if (array_key_exists('salmon', $hints)) {
1144             $salmonuri = $hints['salmon'];
1145         } else {
1146             if (!$discover) {
1147                 $discover = new FeedDiscovery();
1148                 $discover->discoverFromFeedURL($hints['feedurl']);
1149             }
1150             $salmonuri = $discover->getAtomLink(Salmon::NS_REPLIES);
1151         }
1152
1153         if (array_key_exists('hub', $hints)) {
1154             $huburi = $hints['hub'];
1155         } else {
1156             if (!$discover) {
1157                 $discover = new FeedDiscovery();
1158                 $discover->discoverFromFeedURL($hints['feedurl']);
1159             }
1160             $huburi = $discover->getAtomLink('hub');
1161         }
1162
1163         if (!$huburi) {
1164             // We can only deal with folks with a PuSH hub
1165             throw new FeedSubNoHubException();
1166         }
1167
1168         $oprofile = new Ostatus_profile();
1169
1170         $oprofile->uri        = $homeuri;
1171         $oprofile->feeduri    = $feeduri;
1172         $oprofile->salmonuri  = $salmonuri;
1173
1174         $oprofile->created    = common_sql_now();
1175         $oprofile->modified   = common_sql_now();
1176
1177         if ($object->type == ActivityObject::PERSON) {
1178             $profile = new Profile();
1179             $profile->created = common_sql_now();
1180             self::updateProfile($profile, $object, $hints);
1181
1182             $oprofile->profile_id = $profile->insert();
1183             if (!$oprofile->profile_id) {
1184                 throw new ServerException("Can't save local profile");
1185             }
1186         } else {
1187             $group = new User_group();
1188             $group->uri = $homeuri;
1189             $group->created = common_sql_now();
1190             self::updateGroup($group, $object, $hints);
1191
1192             $oprofile->group_id = $group->insert();
1193             if (!$oprofile->group_id) {
1194                 throw new ServerException("Can't save local profile");
1195             }
1196         }
1197
1198         $ok = $oprofile->insert();
1199
1200         if ($ok) {
1201             $avatar = self::getActivityObjectAvatar($object, $hints);
1202             if ($avatar) {
1203                 $oprofile->updateAvatar($avatar);
1204             }
1205             return $oprofile;
1206         } else {
1207             throw new ServerException("Can't save OStatus profile");
1208         }
1209     }
1210
1211     /**
1212      * Save any updated profile information to our local copy.
1213      * @param ActivityObject $object
1214      * @param array $hints
1215      */
1216     public function updateFromActivityObject($object, $hints=array())
1217     {
1218         if ($this->isGroup()) {
1219             $group = $this->localGroup();
1220             self::updateGroup($group, $object, $hints);
1221         } else {
1222             $profile = $this->localProfile();
1223             self::updateProfile($profile, $object, $hints);
1224         }
1225         $avatar = self::getActivityObjectAvatar($object, $hints);
1226         if ($avatar) {
1227             $this->updateAvatar($avatar);
1228         }
1229     }
1230
1231     protected static function updateProfile($profile, $object, $hints=array())
1232     {
1233         $orig = clone($profile);
1234
1235         $profile->nickname = self::getActivityObjectNickname($object, $hints);
1236
1237         if (!empty($object->title)) {
1238             $profile->fullname = $object->title;
1239         } else if (array_key_exists('fullname', $hints)) {
1240             $profile->fullname = $hints['fullname'];
1241         }
1242
1243         if (!empty($object->link)) {
1244             $profile->profileurl = $object->link;
1245         } else if (array_key_exists('profileurl', $hints)) {
1246             $profile->profileurl = $hints['profileurl'];
1247         } else if (Validate::uri($object->id, array('allowed_schemes' => array('http', 'https')))) {
1248             $profile->profileurl = $object->id;
1249         }
1250
1251         $profile->bio      = self::getActivityObjectBio($object, $hints);
1252         $profile->location = self::getActivityObjectLocation($object, $hints);
1253         $profile->homepage = self::getActivityObjectHomepage($object, $hints);
1254
1255         if (!empty($object->geopoint)) {
1256             $location = ActivityContext::locationFromPoint($object->geopoint);
1257             if (!empty($location)) {
1258                 $profile->lat = $location->lat;
1259                 $profile->lon = $location->lon;
1260             }
1261         }
1262
1263         // @fixme tags/categories
1264         // @todo tags from categories
1265
1266         if ($profile->id) {
1267             common_log(LOG_DEBUG, "Updating OStatus profile $profile->id from remote info $object->id: " . var_export($object, true) . var_export($hints, true));
1268             $profile->update($orig);
1269         }
1270     }
1271
1272     protected static function updateGroup($group, $object, $hints=array())
1273     {
1274         $orig = clone($group);
1275
1276         $group->nickname = self::getActivityObjectNickname($object, $hints);
1277         $group->fullname = $object->title;
1278
1279         if (!empty($object->link)) {
1280             $group->mainpage = $object->link;
1281         } else if (array_key_exists('profileurl', $hints)) {
1282             $group->mainpage = $hints['profileurl'];
1283         }
1284
1285         // @todo tags from categories
1286         $group->description = self::getActivityObjectBio($object, $hints);
1287         $group->location = self::getActivityObjectLocation($object, $hints);
1288         $group->homepage = self::getActivityObjectHomepage($object, $hints);
1289
1290         if ($group->id) {
1291             common_log(LOG_DEBUG, "Updating OStatus group $group->id from remote info $object->id: " . var_export($object, true) . var_export($hints, true));
1292             $group->update($orig);
1293         }
1294     }
1295
1296     protected static function getActivityObjectHomepage($object, $hints=array())
1297     {
1298         $homepage = null;
1299         $poco     = $object->poco;
1300
1301         if (!empty($poco)) {
1302             $url = $poco->getPrimaryURL();
1303             if ($url && $url->type == 'homepage') {
1304                 $homepage = $url->value;
1305             }
1306         }
1307
1308         // @todo Try for a another PoCo URL?
1309
1310         return $homepage;
1311     }
1312
1313     protected static function getActivityObjectLocation($object, $hints=array())
1314     {
1315         $location = null;
1316
1317         if (!empty($object->poco) &&
1318             isset($object->poco->address->formatted)) {
1319             $location = $object->poco->address->formatted;
1320         } else if (array_key_exists('location', $hints)) {
1321             $location = $hints['location'];
1322         }
1323
1324         if (!empty($location)) {
1325             if (mb_strlen($location) > 255) {
1326                 $location = mb_substr($note, 0, 255 - 3) . ' â€¦ ';
1327             }
1328         }
1329
1330         // @todo Try to find location some othe way? Via goerss point?
1331
1332         return $location;
1333     }
1334
1335     protected static function getActivityObjectBio($object, $hints=array())
1336     {
1337         $bio  = null;
1338
1339         if (!empty($object->poco)) {
1340             $note = $object->poco->note;
1341         } else if (array_key_exists('bio', $hints)) {
1342             $note = $hints['bio'];
1343         }
1344
1345         if (!empty($note)) {
1346             if (Profile::bioTooLong($note)) {
1347                 // XXX: truncate ok?
1348                 $bio = mb_substr($note, 0, Profile::maxBio() - 3) . ' â€¦ ';
1349             } else {
1350                 $bio = $note;
1351             }
1352         }
1353
1354         // @todo Try to get bio info some other way?
1355
1356         return $bio;
1357     }
1358
1359     protected static function getActivityObjectNickname($object, $hints=array())
1360     {
1361         if ($object->poco) {
1362             if (!empty($object->poco->preferredUsername)) {
1363                 return common_nicknamize($object->poco->preferredUsername);
1364             }
1365         }
1366
1367         if (!empty($object->nickname)) {
1368             return common_nicknamize($object->nickname);
1369         }
1370
1371         if (array_key_exists('nickname', $hints)) {
1372             return $hints['nickname'];
1373         }
1374
1375         // Try the profile url (like foo.example.com or example.com/user/foo)
1376
1377         $profileUrl = ($object->link) ? $object->link : $hints['profileurl'];
1378
1379         if (!empty($profileUrl)) {
1380             $nickname = self::nicknameFromURI($profileUrl);
1381         }
1382
1383         // Try the URI (may be a tag:, http:, acct:, ...
1384
1385         if (empty($nickname)) {
1386             $nickname = self::nicknameFromURI($object->id);
1387         }
1388
1389         // Try a Webfinger if one was passed (way) down
1390
1391         if (empty($nickname)) {
1392             if (array_key_exists('webfinger', $hints)) {
1393                 $nickname = self::nicknameFromURI($hints['webfinger']);
1394             }
1395         }
1396
1397         // Try the name
1398
1399         if (empty($nickname)) {
1400             $nickname = common_nicknamize($object->title);
1401         }
1402
1403         return $nickname;
1404     }
1405
1406     protected static function nicknameFromURI($uri)
1407     {
1408         preg_match('/(\w+):/', $uri, $matches);
1409
1410         $protocol = $matches[1];
1411
1412         switch ($protocol) {
1413         case 'acct':
1414         case 'mailto':
1415             if (preg_match("/^$protocol:(.*)?@.*\$/", $uri, $matches)) {
1416                 return common_canonical_nickname($matches[1]);
1417             }
1418             return null;
1419         case 'http':
1420             return common_url_to_nickname($uri);
1421             break;
1422         default:
1423             return null;
1424         }
1425     }
1426
1427     /**
1428      * @param string $addr webfinger address
1429      * @return Ostatus_profile
1430      * @throws Exception on error conditions
1431      */
1432     public static function ensureWebfinger($addr)
1433     {
1434         // First, try the cache
1435
1436         $uri = self::cacheGet(sprintf('ostatus_profile:webfinger:%s', $addr));
1437
1438         if ($uri !== false) {
1439             if (is_null($uri)) {
1440                 // Negative cache entry
1441                 throw new Exception('Not a valid webfinger address.');
1442             }
1443             $oprofile = Ostatus_profile::staticGet('uri', $uri);
1444             if (!empty($oprofile)) {
1445                 return $oprofile;
1446             }
1447         }
1448
1449         // Try looking it up
1450
1451         $oprofile = Ostatus_profile::staticGet('uri', 'acct:'.$addr);
1452
1453         if (!empty($oprofile)) {
1454             self::cacheSet(sprintf('ostatus_profile:webfinger:%s', $addr), $oprofile->uri);
1455             return $oprofile;
1456         }
1457
1458         // Now, try some discovery
1459
1460         $disco = new Discovery();
1461
1462         try {
1463             $xrd = $disco->lookup($addr);
1464         } catch (Exception $e) {
1465             // Save negative cache entry so we don't waste time looking it up again.
1466             // @fixme distinguish temporary failures?
1467             self::cacheSet(sprintf('ostatus_profile:webfinger:%s', $addr), null);
1468             throw new Exception('Not a valid webfinger address.');
1469         }
1470
1471         $hints = array('webfinger' => $addr);
1472
1473         $dhints = DiscoveryHints::fromXRD($xrd);
1474
1475         $hints = array_merge($hints, $dhints);
1476
1477         // If there's an Hcard, let's grab its info
1478
1479         if (array_key_exists('hcard', $hints)) {
1480             if (!array_key_exists('profileurl', $hints) ||
1481                 $hints['hcard'] != $hints['profileurl']) {
1482                 $hcardHints = DiscoveryHints::fromHcardUrl($hints['hcard']);
1483                 $hints = array_merge($hcardHints, $hints);
1484             }
1485         }
1486
1487         // If we got a feed URL, try that
1488
1489         if (array_key_exists('feedurl', $hints)) {
1490             try {
1491                 common_log(LOG_INFO, "Discovery on acct:$addr with feed URL " . $hints['feedurl']);
1492                 $oprofile = self::ensureFeedURL($hints['feedurl'], $hints);
1493                 self::cacheSet(sprintf('ostatus_profile:webfinger:%s', $addr), $oprofile->uri);
1494                 return $oprofile;
1495             } catch (Exception $e) {
1496                 common_log(LOG_WARNING, "Failed creating profile from feed URL '$feedUrl': " . $e->getMessage());
1497                 // keep looking
1498             }
1499         }
1500
1501         // If we got a profile page, try that!
1502
1503         if (array_key_exists('profileurl', $hints)) {
1504             try {
1505                 common_log(LOG_INFO, "Discovery on acct:$addr with profile URL $profileUrl");
1506                 $oprofile = self::ensureProfileURL($hints['profileurl'], $hints);
1507                 self::cacheSet(sprintf('ostatus_profile:webfinger:%s', $addr), $oprofile->uri);
1508                 return $oprofile;
1509             } catch (Exception $e) {
1510                 common_log(LOG_WARNING, "Failed creating profile from profile URL '$profileUrl': " . $e->getMessage());
1511                 // keep looking
1512             }
1513         }
1514
1515         // XXX: try hcard
1516         // XXX: try FOAF
1517
1518         if (array_key_exists('salmon', $hints)) {
1519
1520             $salmonEndpoint = $hints['salmon'];
1521
1522             // An account URL, a salmon endpoint, and a dream? Not much to go
1523             // on, but let's give it a try
1524
1525             $uri = 'acct:'.$addr;
1526
1527             $profile = new Profile();
1528
1529             $profile->nickname = self::nicknameFromUri($uri);
1530             $profile->created  = common_sql_now();
1531
1532             if (isset($profileUrl)) {
1533                 $profile->profileurl = $profileUrl;
1534             }
1535
1536             $profile_id = $profile->insert();
1537
1538             if (!$profile_id) {
1539                 common_log_db_error($profile, 'INSERT', __FILE__);
1540                 throw new Exception("Couldn't save profile for '$addr'");
1541             }
1542
1543             $oprofile = new Ostatus_profile();
1544
1545             $oprofile->uri        = $uri;
1546             $oprofile->salmonuri  = $salmonEndpoint;
1547             $oprofile->profile_id = $profile_id;
1548             $oprofile->created    = common_sql_now();
1549
1550             if (isset($feedUrl)) {
1551                 $profile->feeduri = $feedUrl;
1552             }
1553
1554             $result = $oprofile->insert();
1555
1556             if (!$result) {
1557                 common_log_db_error($oprofile, 'INSERT', __FILE__);
1558                 throw new Exception("Couldn't save ostatus_profile for '$addr'");
1559             }
1560
1561             self::cacheSet(sprintf('ostatus_profile:webfinger:%s', $addr), $oprofile->uri);
1562             return $oprofile;
1563         }
1564
1565         throw new Exception("Couldn't find a valid profile for '$addr'");
1566     }
1567
1568     /**
1569      * Store the full-length scrubbed HTML of a remote notice to an attachment
1570      * file on our server. We'll link to this at the end of the cropped version.
1571      *
1572      * @param string $title plaintext for HTML page's title
1573      * @param string $rendered HTML fragment for HTML page's body
1574      * @return File
1575      */
1576     function saveHTMLFile($title, $rendered)
1577     {
1578         $final = sprintf("<!DOCTYPE html>\n<html><head><title>%s</title></head>".
1579                          '<body>%s</body></html>',
1580                          htmlspecialchars($title),
1581                          $rendered);
1582
1583         $filename = File::filename($this->localProfile(),
1584                                    'ostatus', // ignored?
1585                                    'text/html');
1586
1587         $filepath = File::path($filename);
1588
1589         file_put_contents($filepath, $final);
1590
1591         $file = new File;
1592
1593         $file->filename = $filename;
1594         $file->url      = File::url($filename);
1595         $file->size     = filesize($filepath);
1596         $file->date     = time();
1597         $file->mimetype = 'text/html';
1598
1599         $file_id = $file->insert();
1600
1601         if ($file_id === false) {
1602             common_log_db_error($file, "INSERT", __FILE__);
1603             throw new ServerException(_('Could not store HTML content of long post as file.'));
1604         }
1605
1606         return $file;
1607     }
1608 }