]> git.mxchange.org Git - quix0rs-gnu-social.git/blob - plugins/OStatus/classes/Ostatus_profile.php
be01cdfe196236fb859e6feabe5f21260dc049b6
[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 FeedSubPlugin
22  * @maintainer Brion Vibber <brion@status.net>
23  */
24
25 /*
26 PuSH subscription flow:
27
28     $profile->subscribe()
29         generate random verification token
30             save to verify_token
31         sends a sub request to the hub...
32
33     main/push/callback
34         hub sends confirmation back to us via GET
35         We verify the request, then echo back the challenge.
36         On our end, we save the time we subscribed and the lease expiration
37
38     main/push/callback
39         hub sends us updates via POST
40
41 */
42
43 class FeedDBException extends FeedSubException
44 {
45     public $obj;
46
47     function __construct($obj)
48     {
49         parent::__construct('Database insert failure');
50         $this->obj = $obj;
51     }
52 }
53
54 class Ostatus_profile extends Memcached_DataObject
55 {
56     public $__table = 'ostatus_profile';
57
58     public $id;
59     public $profile_id;
60     public $group_id;
61
62     public $feeduri;
63     public $homeuri;
64
65     // PuSH subscription data
66     public $huburi;
67     public $secret;
68     public $verify_token;
69     public $sub_state; // subscribe, active, unsubscribe
70     public $sub_start;
71     public $sub_end;
72
73     public $salmonuri;
74
75     public $created;
76     public $lastupdate;
77
78     public /*static*/ function staticGet($k, $v=null)
79     {
80         return parent::staticGet(__CLASS__, $k, $v);
81     }
82
83     /**
84      * return table definition for DB_DataObject
85      *
86      * DB_DataObject needs to know something about the table to manipulate
87      * instances. This method provides all the DB_DataObject needs to know.
88      *
89      * @return array array of column definitions
90      */
91
92     function table()
93     {
94         return array('id' => DB_DATAOBJECT_INT + DB_DATAOBJECT_NOTNULL,
95                      'profile_id' => DB_DATAOBJECT_INT,
96                      'group_id' => DB_DATAOBJECT_INT,
97                      'feeduri' => DB_DATAOBJECT_STR + DB_DATAOBJECT_NOTNULL,
98                      'homeuri' => DB_DATAOBJECT_STR + DB_DATAOBJECT_NOTNULL,
99                      'huburi' =>  DB_DATAOBJECT_STR,
100                      'secret' => DB_DATAOBJECT_STR,
101                      'verify_token' => DB_DATAOBJECT_STR,
102                      'sub_state' => DB_DATAOBJECT_STR,
103                      'sub_start' => DB_DATAOBJECT_STR + DB_DATAOBJECT_DATE + DB_DATAOBJECT_TIME,
104                      'sub_end' => DB_DATAOBJECT_STR + DB_DATAOBJECT_DATE + DB_DATAOBJECT_TIME,
105                      'salmonuri' =>  DB_DATAOBJECT_STR,
106                      'created' => DB_DATAOBJECT_STR + DB_DATAOBJECT_DATE + DB_DATAOBJECT_TIME + DB_DATAOBJECT_NOTNULL,
107                      'lastupdate' => DB_DATAOBJECT_STR + DB_DATAOBJECT_DATE + DB_DATAOBJECT_TIME + DB_DATAOBJECT_NOTNULL);
108     }
109
110     static function schemaDef()
111     {
112         return array(new ColumnDef('id', 'integer',
113                                    /*size*/ null,
114                                    /*nullable*/ false,
115                                    /*key*/ 'PRI',
116                                    /*default*/ '0',
117                                    /*extra*/ null,
118                                    /*auto_increment*/ true),
119                      new ColumnDef('profile_id', 'integer',
120                                    null, true, 'UNI'),
121                      new ColumnDef('group_id', 'integer',
122                                    null, true, 'UNI'),
123                      new ColumnDef('feeduri', 'varchar',
124                                    255, false, 'UNI'),
125                      new ColumnDef('homeuri', 'varchar',
126                                    255, false),
127                      new ColumnDef('huburi', 'text',
128                                    null, true),
129                      new ColumnDef('verify_token', 'varchar',
130                                    32, true),
131                      new ColumnDef('secret', 'varchar',
132                                    64, true),
133                      new ColumnDef('sub_state', "enum('subscribe','active','unsubscribe')",
134                                    null, true),
135                      new ColumnDef('sub_start', 'datetime',
136                                    null, true),
137                      new ColumnDef('sub_end', 'datetime',
138                                    null, true),
139                      new ColumnDef('salmonuri', 'text',
140                                    null, true),
141                      new ColumnDef('created', 'datetime',
142                                    null, false),
143                      new ColumnDef('lastupdate', 'datetime',
144                                    null, false));
145     }
146
147     /**
148      * return key definitions for DB_DataObject
149      *
150      * DB_DataObject needs to know about keys that the table has; this function
151      * defines them.
152      *
153      * @return array key definitions
154      */
155
156     function keys()
157     {
158         return array_keys($this->keyTypes());
159     }
160
161     /**
162      * return key definitions for Memcached_DataObject
163      *
164      * Our caching system uses the same key definitions, but uses a different
165      * method to get them.
166      *
167      * @return array key definitions
168      */
169
170     function keyTypes()
171     {
172         return array('id' => 'K', 'profile_id' => 'U', 'group_id' => 'U', 'feeduri' => 'U');
173     }
174
175     function sequenceKey()
176     {
177         return array('id', true, false);
178     }
179
180     /**
181      * Fetch the StatusNet-side profile for this feed
182      * @return Profile
183      */
184     public function localProfile()
185     {
186         if ($this->profile_id) {
187             return Profile::staticGet('id', $this->profile_id);
188         }
189         return null;
190     }
191
192     /**
193      * Fetch the StatusNet-side profile for this feed
194      * @return Profile
195      */
196     public function localGroup()
197     {
198         if ($this->group_id) {
199             return User_group::staticGet('id', $this->group_id);
200         }
201         return null;
202     }
203
204     /**
205      * @param FeedMunger $munger
206      * @param boolean $isGroup is this a group record?
207      * @return Ostatus_profile
208      */
209     public static function ensureProfile($munger)
210     {
211         $profile = $munger->ostatusProfile();
212
213         $current = self::staticGet('feeduri', $profile->feeduri);
214         if ($current) {
215             // @fixme we should probably update info as necessary
216             return $current;
217         }
218
219         $profile->query('BEGIN');
220
221         try {
222             $local = $munger->profile();
223
224             if ($profile->isGroup()) {
225                 $group = new User_group();
226                 $group->nickname = $local->nickname . '@remote'; // @fixme
227                 $group->fullname = $local->fullname;
228                 $group->homepage = $local->homepage;
229                 $group->location = $local->location;
230                 $group->created = $local->created;
231                 $group->insert();
232                 if (empty($result)) {
233                     throw new FeedDBException($group);
234                 }
235                 $profile->group_id = $group->id;
236             } else {
237                 $result = $local->insert();
238                 if (empty($result)) {
239                     throw new FeedDBException($local);
240                 }
241                 $profile->profile_id = $local->id;
242             }
243
244             $profile->created = common_sql_now();
245             $profile->lastupdate = common_sql_now();
246             $result = $profile->insert();
247             if (empty($result)) {
248                 throw new FeedDBException($profile);
249             }
250
251             $profile->query('COMMIT');
252         } catch (FeedDBException $e) {
253             common_log_db_error($e->obj, 'INSERT', __FILE__);
254             $profile->query('ROLLBACK');
255             return false;
256         }
257
258         $avatar = $munger->getAvatar();
259         if ($avatar) {
260             try {
261                 $profile->updateAvatar($avatar);
262             } catch (Exception $e) {
263                 common_log(LOG_ERR, "Exception setting OStatus avatar: " .
264                                     $e->getMessage());
265             }
266         }
267
268         return $profile;
269     }
270
271     /**
272      * Download and update given avatar image
273      * @param string $url
274      * @throws Exception in various failure cases
275      */
276     public function updateAvatar($url)
277     {
278         // @fixme this should be better encapsulated
279         // ripped from oauthstore.php (for old OMB client)
280         $temp_filename = tempnam(sys_get_temp_dir(), 'listener_avatar');
281         copy($url, $temp_filename);
282         
283         // @fixme should we be using different ids?
284         $imagefile = new ImageFile($this->id, $temp_filename);
285         $filename = Avatar::filename($this->id,
286                                      image_type_to_extension($imagefile->type),
287                                      null,
288                                      common_timestamp());
289         rename($temp_filename, Avatar::path($filename));
290         if ($this->isGroup()) {
291             $group = $this->localGroup();
292             $group->setOriginal($filename);
293         } else {
294             $profile = $this->localProfile();
295             $profile->setOriginal($filename);
296         }
297     }
298
299     /**
300      * Returns an XML string fragment with profile information as an
301      * Activity Streams noun object with the given element type.
302      *
303      * Assumes that 'activity' namespace has been previously defined.
304      *
305      * @param string $element one of 'actor', 'subject', 'object', 'target'
306      * @return string
307      */
308     function asActivityNoun($element)
309     {
310         $xs = new XMLStringer(true);
311
312         $avatarHref = Avatar::defaultImage(AVATAR_PROFILE_SIZE);
313         $avatarType = 'image/png';
314         if ($this->isGroup()) {
315             $type = 'http://activitystrea.ms/schema/1.0/group';
316             $self = $this->localGroup();
317
318             // @fixme put a standard getAvatar() interface on groups too
319             if ($self->homepage_logo) {
320                 $avatarHref = $self->homepage_logo;
321                 $map = array('png' => 'image/png',
322                              'jpg' => 'image/jpeg',
323                              'jpeg' => 'image/jpeg',
324                              'gif' => 'image/gif');
325                 $extension = pathinfo(parse_url($avatarHref, PHP_URL_PATH), PATHINFO_EXTENSION);
326                 if (isset($map[$extension])) {
327                     $avatarType = $map[$extension];
328                 }
329             }
330         } else {
331             $type = 'http://activitystrea.ms/schema/1.0/person';
332             $self = $this->localProfile();
333             $avatar = $self->getAvatar(AVATAR_PROFILE_SIZE);
334             if ($avatar) {
335                 $avatarHref = $avatar->
336                 $avatarType = $avatar->mediatype;
337             }
338         }
339         $xs->elementStart('activity:' . $element);
340         $xs->element(
341             'activity:object-type',
342             null,
343             $type
344         );
345         $xs->element(
346             'id',
347             null,
348             $this->homeuri); // ?
349         $xs->element('title', null, $self->getBestName());
350
351         $xs->element(
352             'link', array(
353                 'type' => $avatarType,
354                 'href' => $avatarHref
355             ),
356             ''
357         );
358
359         $xs->elementEnd('activity:' . $element);
360
361         return $xs->getString();
362     }
363
364     /**
365      * Damn dirty hack!
366      */
367     function isGroup()
368     {
369         return (strpos($this->feeduri, '/groups/') !== false);
370     }
371
372     /**
373      * Send a subscription request to the hub for this feed.
374      * The hub will later send us a confirmation POST to /main/push/callback.
375      *
376      * @return bool true on success, false on failure
377      * @throws ServerException if feed state is not valid
378      */
379     public function subscribe($mode='subscribe')
380     {
381         if ($this->sub_state != '') {
382             throw new ServerException("Attempting to start PuSH subscription to feed in state $this->sub_state");
383         }
384         if (empty($this->huburi)) {
385             if (common_config('feedsub', 'nohub')) {
386                 // Fake it! We're just testing remote feeds w/o hubs.
387                 return true;
388             } else {
389                 throw new ServerException("Attempting to start PuSH subscription for feed with no hub");
390             }
391         }
392
393         return $this->doSubscribe('subscribe');
394     }
395
396     /**
397      * Send a PuSH unsubscription request to the hub for this feed.
398      * The hub will later send us a confirmation POST to /main/push/callback.
399      *
400      * @return bool true on success, false on failure
401      * @throws ServerException if feed state is not valid
402      */
403     public function unsubscribe() {
404         if ($this->sub_state != 'active') {
405             throw new ServerException("Attempting to end PuSH subscription to feed in state $this->sub_state");
406         }
407         if (empty($this->huburi)) {
408             if (common_config('feedsub', 'nohub')) {
409                 // Fake it! We're just testing remote feeds w/o hubs.
410                 return true;
411             } else {
412                 throw new ServerException("Attempting to end PuSH subscription for feed with no hub");
413             }
414         }
415
416         return $this->doSubscribe('unsubscribe');
417     }
418
419     protected function doSubscribe($mode)
420     {
421         $orig = clone($this);
422         $this->verify_token = common_good_rand(16);
423         if ($mode == 'subscribe') {
424             $this->secret = common_good_rand(32);
425         }
426         $this->sub_state = $mode;
427         $this->update($orig);
428         unset($orig);
429
430         try {
431             $callback = common_local_url('pushcallback', array('feed' => $this->id));
432             $headers = array('Content-Type: application/x-www-form-urlencoded');
433             $post = array('hub.mode' => $mode,
434                           'hub.callback' => $callback,
435                           'hub.verify' => 'async',
436                           'hub.verify_token' => $this->verify_token,
437                           'hub.secret' => $this->secret,
438                           //'hub.lease_seconds' => 0,
439                           'hub.topic' => $this->feeduri);
440             $client = new HTTPClient();
441             $response = $client->post($this->huburi, $headers, $post);
442             $status = $response->getStatus();
443             if ($status == 202) {
444                 common_log(LOG_INFO, __METHOD__ . ': sub req ok, awaiting verification callback');
445                 return true;
446             } else if ($status == 204) {
447                 common_log(LOG_INFO, __METHOD__ . ': sub req ok and verified');
448                 return true;
449             } else if ($status >= 200 && $status < 300) {
450                 common_log(LOG_ERR, __METHOD__ . ": sub req returned unexpected HTTP $status: " . $response->getBody());
451                 return false;
452             } else {
453                 common_log(LOG_ERR, __METHOD__ . ": sub req failed with HTTP $status: " . $response->getBody());
454                 return false;
455             }
456         } catch (Exception $e) {
457             // wtf!
458             common_log(LOG_ERR, __METHOD__ . ": error \"{$e->getMessage()}\" hitting hub $this->huburi subscribing to $this->feeduri");
459
460             $orig = clone($this);
461             $this->verify_token = null;
462             $this->sub_state = null;
463             $this->update($orig);
464             unset($orig);
465
466             return false;
467         }
468     }
469
470     /**
471      * Save PuSH subscription confirmation.
472      * Sets approximate lease start and end times and finalizes state.
473      *
474      * @param int $lease_seconds provided hub.lease_seconds parameter, if given
475      */
476     public function confirmSubscribe($lease_seconds=0)
477     {
478         $original = clone($this);
479
480         $this->sub_state = 'active';
481         $this->sub_start = common_sql_date(time());
482         if ($lease_seconds > 0) {
483             $this->sub_end = common_sql_date(time() + $lease_seconds);
484         } else {
485             $this->sub_end = null;
486         }
487         $this->lastupdate = common_sql_date();
488
489         return $this->update($original);
490     }
491
492     /**
493      * Save PuSH unsubscription confirmation.
494      * Wipes active PuSH sub info and resets state.
495      */
496     public function confirmUnsubscribe()
497     {
498         $original = clone($this);
499
500         $this->verify_token = null;
501         $this->secret = null;
502         $this->sub_state = null;
503         $this->sub_start = null;
504         $this->sub_end = null;
505         $this->lastupdate = common_sql_date();
506
507         return $this->update($original);
508     }
509
510     /**
511      * Send an Activity Streams notification to the remote Salmon endpoint,
512      * if so configured.
513      *
514      * @param Profile $actor
515      * @param $verb eg Activity::SUBSCRIBE or Activity::JOIN
516      * @param $object object of the action; if null, the remote entity itself is assumed
517      */
518     public function notify(Profile $actor, $verb, $object=null)
519     {
520         if ($object == null) {
521             $object = $this;
522         }
523         if ($this->salmonuri) {
524             $text = 'update'; // @fixme
525             $id = 'tag:' . common_config('site', 'server') .
526                 ':' . $verb .
527                 ':' . $actor->id .
528                 ':' . time(); // @fixme
529
530             $entry = new Atom10Entry();
531             $entry->elementStart('entry');
532             $entry->element('id', null, $id);
533             $entry->element('title', null, $text);
534             $entry->element('summary', null, $text);
535             $entry->element('published', null, common_date_w3dtf());
536
537             $entry->element('activity:verb', null, $verb);
538             $entry->raw($profile->asAtomAuthor());
539             $entry->raw($profile->asActivityActor());
540             $entry->raw($object->asActivityNoun('object'));
541             $entry->elmentEnd('entry');
542
543             $feed = $this->atomFeed($actor);
544             $feed->initFeed();
545             $feed->addEntry($entry);
546             $feed->renderEntries();
547             $feed->endFeed();
548
549             $xml = $feed->getString();
550             common_log(LOG_INFO, "Posting to Salmon endpoint $salmon: $xml");
551
552             $salmon = new Salmon(); // ?
553             $salmon->post($this->salmonuri, $xml);
554         }
555     }
556
557     function getBestName()
558     {
559         if ($this->isGroup()) {
560             return $this->localGroup()->getBestName();
561         } else {
562             return $this->localProfile()->getBestName();
563         }
564     }
565
566     function atomFeed($actor)
567     {
568         $feed = new Atom10Feed();
569         // @fixme should these be set up somewhere else?
570         $feed->addNamespace('activity', 'http://activitystrea.ms/spec/1.0/');
571         $feed->addNamesapce('thr', 'http://purl.org/syndication/thread/1.0');
572         $feed->addNamespace('georss', 'http://www.georss.org/georss');
573         $feed->addNamespace('ostatus', 'http://ostatus.org/schema/1.0');
574
575         $taguribase = common_config('integration', 'taguri');
576         $feed->setId("tag:{$taguribase}:UserTimeline:{$actor->id}"); // ???
577
578         $feed->setTitle($actor->getBestName() . ' timeline'); // @fixme
579         $feed->setUpdated(time());
580         $feed->setPublished(time());
581
582         $feed->addLink(common_url('ApiTimelineUser',
583                                   array('id' => $actor->id,
584                                         'type' => 'atom')),
585                        array('rel' => 'self',
586                              'type' => 'application/atom+xml'));
587
588         $feed->addLink(common_url('userbyid',
589                                   array('id' => $actor->id)),
590                        array('rel' => 'alternate',
591                              'type' => 'text/html'));
592
593         return $feed;
594     }
595
596     /**
597      * Read and post notices for updates from the feed.
598      * Currently assumes that all items in the feed are new,
599      * coming from a PuSH hub.
600      *
601      * @param string $post source of Atom or RSS feed
602      * @param string $hmac X-Hub-Signature header, if present
603      */
604     public function postUpdates($post, $hmac)
605     {
606         common_log(LOG_INFO, __METHOD__ . ": packet for \"$this->feeduri\"! $hmac $post");
607
608         if ($this->sub_state != 'active') {
609             common_log(LOG_ERR, __METHOD__ . ": ignoring PuSH for inactive feed $this->feeduri (in state '$this->sub_state')");
610             return;
611         }
612
613         if ($post === '') {
614             common_log(LOG_ERR, __METHOD__ . ": ignoring empty post");
615             return;
616         }
617
618         if (!$this->validatePushSig($post, $hmac)) {
619             // Per spec we silently drop input with a bad sig,
620             // while reporting receipt to the server.
621             return;
622         }
623
624         $feed = new DOMDocument();
625         if (!$feed->loadXML($post)) {
626             // @fixme might help to include the err message
627             common_log(LOG_ERR, __METHOD__ . ": ignoring invalid XML");
628             return;
629         }
630
631         $entries = $feed->getElementsByTagNameNS(Activity::ATOM, 'entry');
632         if ($entries->length == 0) {
633             common_log(LOG_ERR, __METHOD__ . ": no entries in feed update, ignoring");
634             return;
635         }
636
637         for ($i = 0; $i < $entries->length; $i++) {
638             $entry = $entries->item($i);
639             $this->processEntry($entry, $feed);
640         }
641     }
642
643     /**
644      * Validate the given Atom chunk and HMAC signature against our
645      * shared secret that was set up at subscription time.
646      *
647      * If we don't have a shared secret, there should be no signature.
648      * If we we do, our the calculated HMAC should match theirs.
649      *
650      * @param string $post raw XML source as POSTed to us
651      * @param string $hmac X-Hub-Signature HTTP header value, or empty
652      * @return boolean true for a match
653      */
654     protected function validatePushSig($post, $hmac)
655     {
656         if ($this->secret) {
657             if (preg_match('/^sha1=([0-9a-fA-F]{40})$/', $hmac, $matches)) {
658                 $their_hmac = strtolower($matches[1]);
659                 $our_hmac = hash_hmac('sha1', $post, $this->secret);
660                 if ($their_hmac === $our_hmac) {
661                     return true;
662                 }
663                 common_log(LOG_ERR, __METHOD__ . ": ignoring PuSH with bad SHA-1 HMAC: got $their_hmac, expected $our_hmac");
664             } else {
665                 common_log(LOG_ERR, __METHOD__ . ": ignoring PuSH with bogus HMAC '$hmac'");
666             }
667         } else {
668             if (empty($hmac)) {
669                 return true;
670             } else {
671                 common_log(LOG_ERR, __METHOD__ . ": ignoring PuSH with unexpected HMAC '$hmac'");
672             }
673         }
674         return false;
675     }
676
677     /**
678      * Process a posted entry from this feed source.
679      *
680      * @param DOMElement $entry
681      * @param DOMElement $feed for context
682      */
683     protected function processEntry($entry, $feed)
684     {
685         $activity = new Activity($entry, $feed);
686
687         $debug = var_export($activity, true);
688         common_log(LOG_DEBUG, $debug);
689
690         if ($activity->verb == ActivityVerb::POST) {
691             $this->processPost($activity);
692         } else {
693             common_log(LOG_INFO, "Ignoring activity with unrecognized verb $activity->verb");
694         }
695     }
696
697     /**
698      * Process an incoming post activity from this remote feed.
699      * @param Activity $activity
700      */
701     protected function processPost($activity)
702     {
703         if ($this->isGroup()) {
704             // @fixme validate these profiles in some way!
705             $oprofile = $this->ensureActorProfile($activity);
706         } else {
707             $actorUri = $this->getActorProfileURI($activity);
708             if ($actorUri == $this->homeuri) {
709                 // @fixme check if profile info has changed and update it
710             } else {
711                 // @fixme drop or reject the messages once we've got the canonical profile URI recorded sanely
712                 common_log(LOG_INFO, "OStatus: Warning: non-group post with unexpected author: $actorUri expected $this->homeuri");
713                 //return;
714             }
715             $oprofile = $this;
716         }
717
718         if ($activity->object->link) {
719             $sourceUri = $activity->object->link;
720         } else if (preg_match('!^https?://!', $activity->object->id)) {
721             $sourceUri = $activity->object->id;
722         } else {
723             common_log(LOG_INFO, "OStatus: ignoring post with no source link: id $activity->object->id");
724             return;
725         }
726
727         $dupe = Notice::staticGet('uri', $sourceUri);
728         if ($dupe) {
729             common_log(LOG_INFO, "OStatus: ignoring duplicate post: $noticeLink");
730             return;
731         }
732
733         // @fixme sanitize and save HTML content if available
734         $content = $activity->object->title;
735
736         $params = array('is_local' => Notice::REMOTE_OMB,
737                         'uri' => $sourceUri);
738
739         $location = $this->getEntryLocation($activity->entry);
740         if ($location) {
741             $params['lat'] = $location->lat;
742             $params['lon'] = $location->lon;
743             if ($location->location_id) {
744                 $params['location_ns'] = $location->location_ns;
745                 $params['location_id'] = $location->location_id;
746             }
747         }
748
749         // @fixme save detailed ostatus source info
750         // @fixme ensure that groups get handled correctly
751
752         $saved = Notice::saveNew($oprofile->localProfile()->id,
753                                  $content,
754                                  'ostatus',
755                                  $params);
756     }
757
758     /**
759      * Parse location given as a GeoRSS-simple point, if provided.
760      * http://www.georss.org/simple
761      *
762      * @param feed item $entry
763      * @return mixed Location or false
764      */
765     function getLocation($dom)
766     {
767         $points = $dom->getElementsByTagNameNS('http://www.georss.org/georss', 'point');
768         
769         for ($i = 0; $i < $points->length; $i++) {
770             $point = $points->item(0)->textContent;
771             $point = str_replace(',', ' ', $point); // per spec "treat commas as whitespace"
772             $point = preg_replace('/\s+/', ' ', $point);
773             $point = trim($point);
774             $coords = explode(' ', $point);
775             if (count($coords) == 2) {
776                 list($lat, $lon) = $coords;
777                 if (is_numeric($lat) && is_numeric($lon)) {
778                     common_log(LOG_INFO, "Looking up location for $lat $lon from georss");
779                     return Location::fromLatLon($lat, $lon);
780                 }
781             }
782             common_log(LOG_ERR, "Ignoring bogus georss:point value $point");
783         }
784
785         return false;
786     }
787
788     /**
789      * Get an appropriate avatar image source URL, if available.
790      *
791      * @param ActivityObject $actor
792      * @param DOMElement $feed
793      * @return string
794      */
795     function getAvatar($actor, $feed)
796     {
797         $url = '';
798         $icon = '';
799         if ($actor->avatar) {
800             $url = trim($actor->avatar);
801         }
802         if (!$url) {
803             // Check <atom:logo> and <atom:icon> on the feed
804             $els = $feed->childNodes();
805             if ($els && $els->length) {
806                 for ($i = 0; $i < $els->length; $i++) {
807                     $el = $els->item($i);
808                     if ($el->namespaceURI == Activity::ATOM) {
809                         if (empty($url) && $el->localName == 'logo') {
810                             $url = trim($el->textContent);
811                             break;
812                         }
813                         if (empty($icon) && $el->localName == 'icon') {
814                             // Use as a fallback
815                             $icon = trim($el->textContent);
816                         }
817                     }
818                 }
819             }
820             if ($icon && !$url) {
821                 $url = $icon;
822             }
823         }
824         if ($url) {
825             $opts = array('allowed_schemes' => array('http', 'https'));
826             if (Validate::uri($url, $opts)) {
827                 return $url;
828             }
829         }
830         return common_path('plugins/OStatus/images/96px-Feed-icon.svg.png');
831     }
832
833     /**
834      * @fixme move off of ostatus_profile or static?
835      */
836     function ensureActorProfile($activity)
837     {
838         $profile = $this->getActorProfile($activity);
839         if (!$profile) {
840             $profile = $this->createActorProfile($activity);
841         }
842         return $profile;
843     }
844
845     /**
846      * @param Activity $activity
847      * @return mixed matching Ostatus_profile or false if none known
848      */
849     function getActorProfile($activity)
850     {
851         $homeuri = $this->getActorProfileURI($activity);
852         return Ostatus_profile::staticGet('homeuri', $homeuri);
853     }
854
855     /**
856      * @param Activity $activity
857      * @return string
858      * @throws ServerException
859      */
860     function getActorProfileURI($activity)
861     {
862         $opts = array('allowed_schemes' => array('http', 'https'));
863         $actor = $activity->actor;
864         if ($actor->id && Validate::uri($actor->id, $opts)) {
865             return $actor->id;
866         }
867         if ($actor->link && Validate::uri($actor->link, $opts)) {
868             return $actor->link;
869         }
870         throw new ServerException("No author ID URI found");
871     }
872
873     /**
874      *
875      */
876     function createActorProfile($activity)
877     {
878         $actor = $activity->actor();
879         $homeuri = $this->getActivityProfileURI($activity);
880         $nickname = $this->getAuthorNick($activity);
881         $avatar = $this->getAvatar($actor, $feed);
882
883         $profile = new Profile();
884         $profile->nickname   = $nickname;
885         $profile->fullname   = $actor->displayName;
886         $profile->homepage   = $actor->link; // @fixme
887         $profile->profileurl = $homeuri;
888         // @fixme bio
889         // @fixme tags/categories
890         // @fixme location?
891         // @todo tags from categories
892         // @todo lat/lon/location?
893
894         $ok = $profile->insert();
895         if ($ok) {
896             $this->updateAvatar($profile, $avatar);
897         } else {
898             throw new ServerException("Can't save local profile");
899         }
900
901         // @fixme either need to do feed discovery here
902         // or need to split out some of the feed stuff
903         // so we can leave it empty until later.
904         $oprofile = new Ostatus_profile();
905         $oprofile->homeuri = $homeuri;
906         $oprofile->profile_id = $profile->id;
907
908         $ok = $oprofile->insert();
909         if ($ok) {
910             return $oprofile;
911         } else {
912             throw new ServerException("Can't save OStatus profile");
913         }
914     }
915
916     /**
917      * @fixme move this into Activity?
918      * @param Activity $activity
919      * @return string
920      */
921     function getAuthorNick($activity)
922     {
923         // @fixme not technically part of the actor?
924         foreach (array($activity->entry, $activity->feed) as $source) {
925             $author = ActivityUtil::child($source, 'author', Activity::ATOM);
926             if ($author) {
927                 $name = ActivityUtil::child($author, 'name', Activity::ATOM);
928                 if ($name) {
929                     return trim($name->textContent);
930                 }
931             }
932         }
933         return false;
934     }
935
936 }