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