]> git.mxchange.org Git - quix0rs-gnu-social.git/blob - plugins/OStatus/classes/Ostatus_profile.php
486417617c365091f68cc43ca2e2b99abbe196ca
[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_now();
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         // @fixme these should all be null, but DB_DataObject doesn't save null values...?????
501         $this->verify_token = '';
502         $this->secret = '';
503         $this->sub_state = '';
504         $this->sub_start = '';
505         $this->sub_end = '';
506         $this->lastupdate = common_sql_now();
507
508         return $this->update($original);
509     }
510
511     /**
512      * Send an Activity Streams notification to the remote Salmon endpoint,
513      * if so configured.
514      *
515      * @param Profile $actor
516      * @param $verb eg Activity::SUBSCRIBE or Activity::JOIN
517      * @param $object object of the action; if null, the remote entity itself is assumed
518      */
519     public function notify(Profile $actor, $verb, $object=null)
520     {
521         if ($object == null) {
522             $object = $this;
523         }
524         if ($this->salmonuri) {
525             $text = 'update'; // @fixme
526             $id = 'tag:' . common_config('site', 'server') .
527                 ':' . $verb .
528                 ':' . $actor->id .
529                 ':' . time(); // @fixme
530
531             //$entry = new Atom10Entry();
532             $entry = new XMLStringer();
533             $entry->elementStart('entry');
534             $entry->element('id', null, $id);
535             $entry->element('title', null, $text);
536             $entry->element('summary', null, $text);
537             $entry->element('published', null, common_date_w3dtf(time()));
538
539             $entry->element('activity:verb', null, $verb);
540             $entry->raw($actor->asAtomAuthor());
541             $entry->raw($actor->asActivityActor());
542             $entry->raw($object->asActivityNoun('object'));
543             $entry->elementEnd('entry');
544
545             $feed = $this->atomFeed($actor);
546             #$feed->initFeed();
547             $feed->addEntry($entry);
548             #$feed->renderEntries();
549             #$feed->endFeed();
550
551             $xml = $feed->getString();
552             common_log(LOG_INFO, "Posting to Salmon endpoint $salmon: $xml");
553
554             $salmon = new Salmon(); // ?
555             $salmon->post($this->salmonuri, $xml);
556         }
557     }
558
559     function getBestName()
560     {
561         if ($this->isGroup()) {
562             return $this->localGroup()->getBestName();
563         } else {
564             return $this->localProfile()->getBestName();
565         }
566     }
567
568     function atomFeed($actor)
569     {
570         $feed = new Atom10Feed();
571         // @fixme should these be set up somewhere else?
572         $feed->addNamespace('activity', 'http://activitystrea.ms/spec/1.0/');
573         $feed->addNamespace('thr', 'http://purl.org/syndication/thread/1.0');
574         $feed->addNamespace('georss', 'http://www.georss.org/georss');
575         $feed->addNamespace('ostatus', 'http://ostatus.org/schema/1.0');
576
577         $taguribase = common_config('integration', 'taguri');
578         $feed->setId("tag:{$taguribase}:UserTimeline:{$actor->id}"); // ???
579
580         $feed->setTitle($actor->getBestName() . ' timeline'); // @fixme
581         $feed->setUpdated(time());
582         $feed->setPublished(time());
583
584         $feed->addLink(common_local_url('ApiTimelineUser',
585                                         array('id' => $actor->id,
586                                               'type' => 'atom')),
587                        array('rel' => 'self',
588                              'type' => 'application/atom+xml'));
589
590         $feed->addLink(common_local_url('userbyid',
591                                         array('id' => $actor->id)),
592                        array('rel' => 'alternate',
593                              'type' => 'text/html'));
594
595         return $feed;
596     }
597
598     /**
599      * Read and post notices for updates from the feed.
600      * Currently assumes that all items in the feed are new,
601      * coming from a PuSH hub.
602      *
603      * @param string $post source of Atom or RSS feed
604      * @param string $hmac X-Hub-Signature header, if present
605      */
606     public function postUpdates($post, $hmac)
607     {
608         common_log(LOG_INFO, __METHOD__ . ": packet for \"$this->feeduri\"! $hmac $post");
609
610         if ($this->sub_state != 'active') {
611             common_log(LOG_ERR, __METHOD__ . ": ignoring PuSH for inactive feed $this->feeduri (in state '$this->sub_state')");
612             return;
613         }
614
615         if ($post === '') {
616             common_log(LOG_ERR, __METHOD__ . ": ignoring empty post");
617             return;
618         }
619
620         if (!$this->validatePushSig($post, $hmac)) {
621             // Per spec we silently drop input with a bad sig,
622             // while reporting receipt to the server.
623             return;
624         }
625
626         $feed = new DOMDocument();
627         if (!$feed->loadXML($post)) {
628             // @fixme might help to include the err message
629             common_log(LOG_ERR, __METHOD__ . ": ignoring invalid XML");
630             return;
631         }
632
633         $entries = $feed->getElementsByTagNameNS(Activity::ATOM, 'entry');
634         if ($entries->length == 0) {
635             common_log(LOG_ERR, __METHOD__ . ": no entries in feed update, ignoring");
636             return;
637         }
638
639         for ($i = 0; $i < $entries->length; $i++) {
640             $entry = $entries->item($i);
641             $this->processEntry($entry, $feed);
642         }
643     }
644
645     /**
646      * Validate the given Atom chunk and HMAC signature against our
647      * shared secret that was set up at subscription time.
648      *
649      * If we don't have a shared secret, there should be no signature.
650      * If we we do, our the calculated HMAC should match theirs.
651      *
652      * @param string $post raw XML source as POSTed to us
653      * @param string $hmac X-Hub-Signature HTTP header value, or empty
654      * @return boolean true for a match
655      */
656     protected function validatePushSig($post, $hmac)
657     {
658         if ($this->secret) {
659             if (preg_match('/^sha1=([0-9a-fA-F]{40})$/', $hmac, $matches)) {
660                 $their_hmac = strtolower($matches[1]);
661                 $our_hmac = hash_hmac('sha1', $post, $this->secret);
662                 if ($their_hmac === $our_hmac) {
663                     return true;
664                 }
665                 common_log(LOG_ERR, __METHOD__ . ": ignoring PuSH with bad SHA-1 HMAC: got $their_hmac, expected $our_hmac");
666             } else {
667                 common_log(LOG_ERR, __METHOD__ . ": ignoring PuSH with bogus HMAC '$hmac'");
668             }
669         } else {
670             if (empty($hmac)) {
671                 return true;
672             } else {
673                 common_log(LOG_ERR, __METHOD__ . ": ignoring PuSH with unexpected HMAC '$hmac'");
674             }
675         }
676         return false;
677     }
678
679     /**
680      * Process a posted entry from this feed source.
681      *
682      * @param DOMElement $entry
683      * @param DOMElement $feed for context
684      */
685     protected function processEntry($entry, $feed)
686     {
687         $activity = new Activity($entry, $feed);
688
689         $debug = var_export($activity, true);
690         common_log(LOG_DEBUG, $debug);
691
692         if ($activity->verb == ActivityVerb::POST) {
693             $this->processPost($activity);
694         } else {
695             common_log(LOG_INFO, "Ignoring activity with unrecognized verb $activity->verb");
696         }
697     }
698
699     /**
700      * Process an incoming post activity from this remote feed.
701      * @param Activity $activity
702      */
703     protected function processPost($activity)
704     {
705         if ($this->isGroup()) {
706             // @fixme validate these profiles in some way!
707             $oprofile = $this->ensureActorProfile($activity);
708         } else {
709             $actorUri = $this->getActorProfileURI($activity);
710             if ($actorUri == $this->homeuri) {
711                 // @fixme check if profile info has changed and update it
712             } else {
713                 // @fixme drop or reject the messages once we've got the canonical profile URI recorded sanely
714                 common_log(LOG_INFO, "OStatus: Warning: non-group post with unexpected author: $actorUri expected $this->homeuri");
715                 //return;
716             }
717             $oprofile = $this;
718         }
719
720         if ($activity->object->link) {
721             $sourceUri = $activity->object->link;
722         } else if (preg_match('!^https?://!', $activity->object->id)) {
723             $sourceUri = $activity->object->id;
724         } else {
725             common_log(LOG_INFO, "OStatus: ignoring post with no source link: id $activity->object->id");
726             return;
727         }
728
729         $dupe = Notice::staticGet('uri', $sourceUri);
730         if ($dupe) {
731             common_log(LOG_INFO, "OStatus: ignoring duplicate post: $noticeLink");
732             return;
733         }
734
735         // @fixme sanitize and save HTML content if available
736         $content = $activity->object->title;
737
738         $params = array('is_local' => Notice::REMOTE_OMB,
739                         'uri' => $sourceUri);
740
741         $location = $this->getEntryLocation($activity->entry);
742         if ($location) {
743             $params['lat'] = $location->lat;
744             $params['lon'] = $location->lon;
745             if ($location->location_id) {
746                 $params['location_ns'] = $location->location_ns;
747                 $params['location_id'] = $location->location_id;
748             }
749         }
750
751         // @fixme save detailed ostatus source info
752         // @fixme ensure that groups get handled correctly
753
754         $saved = Notice::saveNew($oprofile->localProfile()->id,
755                                  $content,
756                                  'ostatus',
757                                  $params);
758     }
759
760     /**
761      * Parse location given as a GeoRSS-simple point, if provided.
762      * http://www.georss.org/simple
763      *
764      * @param feed item $entry
765      * @return mixed Location or false
766      */
767     function getLocation($dom)
768     {
769         $points = $dom->getElementsByTagNameNS('http://www.georss.org/georss', 'point');
770         
771         for ($i = 0; $i < $points->length; $i++) {
772             $point = $points->item(0)->textContent;
773             $point = str_replace(',', ' ', $point); // per spec "treat commas as whitespace"
774             $point = preg_replace('/\s+/', ' ', $point);
775             $point = trim($point);
776             $coords = explode(' ', $point);
777             if (count($coords) == 2) {
778                 list($lat, $lon) = $coords;
779                 if (is_numeric($lat) && is_numeric($lon)) {
780                     common_log(LOG_INFO, "Looking up location for $lat $lon from georss");
781                     return Location::fromLatLon($lat, $lon);
782                 }
783             }
784             common_log(LOG_ERR, "Ignoring bogus georss:point value $point");
785         }
786
787         return false;
788     }
789
790     /**
791      * Get an appropriate avatar image source URL, if available.
792      *
793      * @param ActivityObject $actor
794      * @param DOMElement $feed
795      * @return string
796      */
797     function getAvatar($actor, $feed)
798     {
799         $url = '';
800         $icon = '';
801         if ($actor->avatar) {
802             $url = trim($actor->avatar);
803         }
804         if (!$url) {
805             // Check <atom:logo> and <atom:icon> on the feed
806             $els = $feed->childNodes();
807             if ($els && $els->length) {
808                 for ($i = 0; $i < $els->length; $i++) {
809                     $el = $els->item($i);
810                     if ($el->namespaceURI == Activity::ATOM) {
811                         if (empty($url) && $el->localName == 'logo') {
812                             $url = trim($el->textContent);
813                             break;
814                         }
815                         if (empty($icon) && $el->localName == 'icon') {
816                             // Use as a fallback
817                             $icon = trim($el->textContent);
818                         }
819                     }
820                 }
821             }
822             if ($icon && !$url) {
823                 $url = $icon;
824             }
825         }
826         if ($url) {
827             $opts = array('allowed_schemes' => array('http', 'https'));
828             if (Validate::uri($url, $opts)) {
829                 return $url;
830             }
831         }
832         return common_path('plugins/OStatus/images/96px-Feed-icon.svg.png');
833     }
834
835     /**
836      * @fixme move off of ostatus_profile or static?
837      */
838     function ensureActorProfile($activity)
839     {
840         $profile = $this->getActorProfile($activity);
841         if (!$profile) {
842             $profile = $this->createActorProfile($activity);
843         }
844         return $profile;
845     }
846
847     /**
848      * @param Activity $activity
849      * @return mixed matching Ostatus_profile or false if none known
850      */
851     function getActorProfile($activity)
852     {
853         $homeuri = $this->getActorProfileURI($activity);
854         return Ostatus_profile::staticGet('homeuri', $homeuri);
855     }
856
857     /**
858      * @param Activity $activity
859      * @return string
860      * @throws ServerException
861      */
862     function getActorProfileURI($activity)
863     {
864         $opts = array('allowed_schemes' => array('http', 'https'));
865         $actor = $activity->actor;
866         if ($actor->id && Validate::uri($actor->id, $opts)) {
867             return $actor->id;
868         }
869         if ($actor->link && Validate::uri($actor->link, $opts)) {
870             return $actor->link;
871         }
872         throw new ServerException("No author ID URI found");
873     }
874
875     /**
876      *
877      */
878     function createActorProfile($activity)
879     {
880         $actor = $activity->actor();
881         $homeuri = $this->getActivityProfileURI($activity);
882         $nickname = $this->getAuthorNick($activity);
883         $avatar = $this->getAvatar($actor, $feed);
884
885         $profile = new Profile();
886         $profile->nickname   = $nickname;
887         $profile->fullname   = $actor->displayName;
888         $profile->homepage   = $actor->link; // @fixme
889         $profile->profileurl = $homeuri;
890         // @fixme bio
891         // @fixme tags/categories
892         // @fixme location?
893         // @todo tags from categories
894         // @todo lat/lon/location?
895
896         $ok = $profile->insert();
897         if ($ok) {
898             $this->updateAvatar($profile, $avatar);
899         } else {
900             throw new ServerException("Can't save local profile");
901         }
902
903         // @fixme either need to do feed discovery here
904         // or need to split out some of the feed stuff
905         // so we can leave it empty until later.
906         $oprofile = new Ostatus_profile();
907         $oprofile->homeuri = $homeuri;
908         $oprofile->profile_id = $profile->id;
909
910         $ok = $oprofile->insert();
911         if ($ok) {
912             return $oprofile;
913         } else {
914             throw new ServerException("Can't save OStatus profile");
915         }
916     }
917
918     /**
919      * @fixme move this into Activity?
920      * @param Activity $activity
921      * @return string
922      */
923     function getAuthorNick($activity)
924     {
925         // @fixme not technically part of the actor?
926         foreach (array($activity->entry, $activity->feed) as $source) {
927             $author = ActivityUtil::child($source, 'author', Activity::ATOM);
928             if ($author) {
929                 $name = ActivityUtil::child($author, 'name', Activity::ATOM);
930                 if ($name) {
931                     return trim($name->textContent);
932                 }
933             }
934         }
935         return false;
936     }
937
938 }