diff --git a/README.md b/README.md index 090404f..cb3e195 100644 --- a/README.md +++ b/README.md @@ -15,8 +15,11 @@ The Telegraph API will validate the parameters and then queue the webmention for The API will first make an HTTP request to the source URL, and look for a link to the target on the page. This happens synchronously so you will get this error reply immediately. #### Errors +* `authentication_required` - the token parameter was missing +* `invalid_token` - the token was invalid or expired * `missing_parameters` - one or more of the three parameters were not in the request * `invalid_parameter` - one or more of the parameters were invalid, e.g. the target was not a valid URL +* `source_not_html` - the source document could not be parsed as HTML (only in extreme cases, most of the time it just accepts whatever) * `no_link_found` - the link to the target URL was not found on the source document An error response in this case will be returned with an HTTP 400 status code an a JSON body: @@ -63,7 +66,7 @@ If the webmention endpoint provides status updates, either through a status URL A callback from Telegraph will include the following post body parameters: * `source` - the URL of your post * `target` - the URL you linked to -* `code` - one of the status codes above, e.g. `webmention_queued` +* `status` - one of the status codes above, e.g. `webmention_queued` ## Credits diff --git a/composer.json b/composer.json index 6bd3bd4..87a0120 100644 --- a/composer.json +++ b/composer.json @@ -6,18 +6,24 @@ "indieauth/client": "0.1.*", "firebase/php-jwt": "~3.0", "league/route": "~1.2", - "league/plates": "~3.1" + "league/plates": "~3.1", + "j4mie/idiorm": "1.5.*" }, "require-dev": { "phpunit/phpunit": "*" }, "autoload": { "files": [ - "config.php", "lib/helpers.php", + "lib/HTTP.php", "controllers/Controller.php", "controllers/Auth.php", "controllers/API.php" ] + }, + "autoload-dev": { + "files": [ + "lib/HTTPTest.php" + ] } } diff --git a/composer.lock b/composer.lock index d64b131..7a32b66 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,8 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file", "This file is @generated automatically" ], - "hash": "5630557b773b8342de2ebfcfbe23f013", - "content-hash": "88dfc4a35925d318e92d4881b37d70a0", + "hash": "2ba7de03b37a6d84edc940e53b969dd8", + "content-hash": "a54d5a9f88a410c0295e0530b51e3abc", "packages": [ { "name": "barnabywalters/mf-cleaner", @@ -217,6 +217,64 @@ "homepage": "https://github.com/indieweb/mention-client-php", "time": "2015-12-09 19:10:25" }, + { + "name": "j4mie/idiorm", + "version": "v1.5.1", + "source": { + "type": "git", + "url": "https://github.com/j4mie/idiorm.git", + "reference": "b0922d8719a94e3a0e0e4a0ca3876f4f91475dcf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/j4mie/idiorm/zipball/b0922d8719a94e3a0e0e4a0ca3876f4f91475dcf", + "reference": "b0922d8719a94e3a0e0e4a0ca3876f4f91475dcf", + "shasum": "" + }, + "require": { + "php": ">=5.2.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "idiorm.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-2-Clause", + "BSD-3-Clause", + "BSD-4-Clause" + ], + "authors": [ + { + "name": "Simon Holywell", + "email": "treffynnon@php.net", + "homepage": "http://simonholywell.com", + "role": "Maintainer" + }, + { + "name": "Jamie Matthews", + "email": "jamie.matthews@gmail.com", + "homepage": "http://j4mie.org", + "role": "Developer" + }, + { + "name": "Durham Hale", + "email": "me@durhamhale.com", + "homepage": "http://durhamhale.com", + "role": "Maintainer" + } + ], + "description": "A lightweight nearly-zero-configuration object-relational mapper and fluent query builder for PHP5", + "homepage": "http://j4mie.github.com/idiormandparis", + "keywords": [ + "idiorm", + "orm", + "query builder" + ], + "time": "2014-06-23 13:08:57" + }, { "name": "league/container", "version": "1.3.2", @@ -440,16 +498,16 @@ }, { "name": "nikic/fast-route", - "version": "v0.6.0", + "version": "v0.7.0", "source": { "type": "git", "url": "https://github.com/nikic/FastRoute.git", - "reference": "31fa86924556b80735f98b294a7ffdfb26789f22" + "reference": "8164b4a0d8afde4eae5f1bfc39084972ba23ad36" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/FastRoute/zipball/31fa86924556b80735f98b294a7ffdfb26789f22", - "reference": "31fa86924556b80735f98b294a7ffdfb26789f22", + "url": "https://api.github.com/repos/nikic/FastRoute/zipball/8164b4a0d8afde4eae5f1bfc39084972ba23ad36", + "reference": "8164b4a0d8afde4eae5f1bfc39084972ba23ad36", "shasum": "" }, "require": { @@ -479,7 +537,7 @@ "router", "routing" ], - "time": "2015-06-18 19:15:47" + "time": "2015-12-20 19:50:12" }, { "name": "symfony/http-foundation", diff --git a/controllers/API.php b/controllers/API.php index 8794760..edd0845 100644 --- a/controllers/API.php +++ b/controllers/API.php @@ -4,6 +4,137 @@ use Symfony\Component\HttpFoundation\Response; class API { + public $http; + public function __construct() { + $this->http = new Telegraph\HTTP(); + } + + private function respond(Response $response, $code, $params, $headers=[]) { + $response->setStatusCode($code); + foreach($headers as $k=>$v) { + $response->headers->set($k, $v); + } + $response->setContent(json_encode($params)); + return $response; + } + + private static function toHtmlEntities($input) { + return mb_convert_encoding($input, 'HTML-ENTITIES', mb_detect_encoding($input)); + } + + private static function generateStatusToken() { + $str = dechex(date('y')); + $chs = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; + $len = strlen($chs); + for($i = 0; $i < 16; $i++) { + $str .= $chs[mt_rand(0, $len - 1)]; + } + return $str; + } + + public function webmention(Request $request, Response $response) { + + # Require the token parameter + if(!$token=$request->get('token')) { + return $this->respond($response, 401, [ + 'error' => 'authentication_required', + 'error_description' => 'A token is required to use the API' + ]); + } + + # Require source and target parameters + if((!$source=$request->get('source')) || (!$target=$request->get('target'))) { + return $this->respond($response, 400, [ + 'error' => 'missing_parameters', + 'error_description' => 'The source or target parameters were missing' + ]); + } + + $urlregex = '/^https?:\/\/[^ ]+\.[^ ]+$/'; + + # Verify source and target are URLs + if(!preg_match($urlregex, $source) || !preg_match($urlregex, $target)) { + return $this->respond($response, 400, [ + 'error' => 'invalid_parameter', + 'error_description' => 'The source or target parameters were invalid' + ]); + } + + # If a callback was provided, verify it is a URL + if($callback=$request->get('callback')) { + if(!preg_match($urlregex, $source) || !preg_match($urlregex, $target)) { + return $this->respond($response, 400, [ + 'error' => 'invalid_parameter', + 'error_description' => 'The callback parameter was invalid' + ]); + } + } + + # Verify the token is valid + $role = ORM::for_table('roles')->where('token', $token)->find_one(); + + if(!$role) { + return $this->respond($response, 401, [ + 'error' => 'invalid_token', + 'error_description' => 'The token provided is not valid' + ]); + } + + # Synchronously check the source URL and verify that it actually contains + # a link to the target. This way we prevent this API from sending known invalid mentions. + $sourceData = $this->http->get($source); + + $doc = new DOMDocument(); + @$doc->loadHTML(self::toHtmlEntities($sourceData['body'])); + + if(!$doc) { + return $this->respond($response, 400, [ + 'error' => 'source_not_html', + 'error_description' => 'The source document could not be parsed as HTML' + ]); + } + + $xpath = new DOMXPath($doc); + + $found = false; + foreach($xpath->query('//a[@href]') as $href) { + if($href->getAttribute('href') == $target) { + $found = true; + continue; + } + } + + if(!$found) { + return $this->respond($response, 400, [ + 'error' => 'no_link_found', + 'error_description' => 'The source document does not have a link to the target URL' + ]); + } + + # Everything checked out, so write the webmention to the log and queue a job to start sending + + $w = ORM::for_table('webmentions')->create(); + $w->site_id = $role->site_id; + $w->created_by = $role->user_id; + $w->created_at = date('Y-m-d H:i:s'); + $w->token = self::generateStatusToken(); + $w->source = $source; + $w->target = $target; + $w->vouch = $request->get('vouch'); + $w->callback = $callback; + $w->save(); + + + + $statusURL = Config::$base . 'webmention/' . $w->token; + + return $this->respond($response, 201, [ + 'result' => 'queued', + 'status' => $statusURL + ], [ + 'Location' => $statusURL + ]); + } } diff --git a/controllers/Controller.php b/controllers/Controller.php index 5714f1c..7f99b06 100644 --- a/controllers/Controller.php +++ b/controllers/Controller.php @@ -29,7 +29,7 @@ class Controller { } $response->setContent(view('dashboard', [ - 'title' => 'Dashboard' + 'title' => 'Telegraph Dashboard' ])); return $response; } diff --git a/lib/HTTP.php b/lib/HTTP.php new file mode 100644 index 0000000..64e9117 --- /dev/null +++ b/lib/HTTP.php @@ -0,0 +1,73 @@ + curl_getinfo($ch, CURLINFO_HTTP_CODE), + 'headers' => self::_parse_headers(trim(substr($response, 0, $header_size))), + 'body' => substr($response, $header_size) + ); + } + + public static function post($url, $body, $headers=array()) { + $ch = curl_init($url); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, $body); + curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); + curl_setopt($ch, CURLOPT_HEADER, true); + if (self::$_proxy) curl_setopt($ch, CURLOPT_PROXY, self::$_proxy); + $response = curl_exec($ch); + self::_debug($response); + $header_size = curl_getinfo($ch, CURLINFO_HEADER_SIZE); + return array( + 'status' => curl_getinfo($ch, CURLINFO_HTTP_CODE), + 'headers' => self::_parse_headers(trim(substr($response, 0, $header_size))), + 'body' => substr($response, $header_size) + ); + } + + public static function head($url) { + $ch = curl_init($url); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_HEADER, true); + curl_setopt($ch, CURLOPT_NOBODY, true); + if (self::$_proxy) curl_setopt($ch, CURLOPT_PROXY, self::$_proxy); + $response = curl_exec($ch); + return array( + 'status' => curl_getinfo($ch, CURLINFO_HTTP_CODE), + 'headers' => self::_parse_headers(trim($response)), + ); + } + + public static function parse_headers($headers) { + $retVal = array(); + $fields = explode("\r\n", preg_replace('/\x0D\x0A[\x09\x20]+/', ' ', $headers)); + foreach($fields as $field) { + if(preg_match('/([^:]+): (.+)/m', $field, $match)) { + $match[1] = preg_replace_callback('/(?<=^|[\x09\x20\x2D])./', function($m) { + return strtoupper($m[0]); + }, strtolower(trim($match[1]))); + // If there's already a value set for the header name being returned, turn it into an array and add the new value + $match[1] = preg_replace_callback('/(?<=^|[\x09\x20\x2D])./', function($m) { + return strtoupper($m[0]); + }, strtolower(trim($match[1]))); + if(isset($retVal[$match[1]])) { + if(!is_array($retVal[$match[1]])) + $retVal[$match[1]] = array($retVal[$match[1]]); + $retVal[$match[1]][] = $match[2]; + } else { + $retVal[$match[1]] = trim($match[2]); + } + } + } + return $retVal; + } +} diff --git a/lib/HTTPTest.php b/lib/HTTPTest.php new file mode 100644 index 0000000..0ef5eaa --- /dev/null +++ b/lib/HTTPTest.php @@ -0,0 +1,71 @@ + $response['code'], + 'headers' => $response['headers'] + ); + } + + private static function _read_file($url) { + $filename = dirname(__FILE__).'/../tests/data/'.preg_replace('/https?:\/\//', '', $url); + if(!file_exists($filename)) { + $filename = dirname(__FILE__).'/../tests/data/404.response.txt'; + } + $response = file_get_contents($filename); + + $split = explode("\r\n\r\n", $response); + if(count($split) != 2) { + throw new \Exception("Invalid file contents in test data, check that newlines are CRLF: $url"); + } + list($headers, $body) = $split; + + if(preg_match('/HTTP\/1\.1 (\d+)/', $headers, $match)) { + $code = $match[1]; + } + + $headers = preg_replace('/HTTP\/1\.1 \d+ .+/', '', $headers); + + return array( + 'code' => $code, + 'headers' => self::parse_headers($headers), + 'body' => $body + ); + } + + public static function parse_headers($headers) { + $retVal = array(); + $fields = explode("\r\n", preg_replace('/\x0D\x0A[\x09\x20]+/', ' ', $headers)); + foreach($fields as $field) { + if(preg_match('/([^:]+): (.+)/m', $field, $match)) { + $match[1] = preg_replace_callback('/(?<=^|[\x09\x20\x2D])./', function($m) { + return strtoupper($m[0]); + }, strtolower(trim($match[1]))); + // If there's already a value set for the header name being returned, turn it into an array and add the new value + $match[1] = preg_replace_callback('/(?<=^|[\x09\x20\x2D])./', function($m) { + return strtoupper($m[0]); + }, strtolower(trim($match[1]))); + if(isset($retVal[$match[1]])) { + if(!is_array($retVal[$match[1]])) + $retVal[$match[1]] = array($retVal[$match[1]]); + $retVal[$match[1]][] = $match[2]; + } else { + $retVal[$match[1]] = trim($match[2]); + } + } + } + return $retVal; + } +} diff --git a/lib/helpers.php b/lib/helpers.php index c0c11f7..f65f606 100644 --- a/lib/helpers.php +++ b/lib/helpers.php @@ -1,4 +1,15 @@ + + + + + + + tests + + + diff --git a/tests/APITest.php b/tests/APITest.php new file mode 100644 index 0000000..3d5d048 --- /dev/null +++ b/tests/APITest.php @@ -0,0 +1,142 @@ +client = new API(); + $this->client->http = new Telegraph\HTTPTest(); + ORM::for_table('users')->raw_query('TRUNCATE users')->delete_many(); + ORM::for_table('roles')->raw_query('TRUNCATE roles')->delete_many(); + ORM::for_table('sites')->raw_query('TRUNCATE sites')->delete_many(); + ORM::for_table('webmentions')->raw_query('TRUNCATE webmentions')->delete_many(); + ORM::for_table('webmention_status')->raw_query('TRUNCATE webmention_status')->delete_many(); + } + + private function webmention($params) { + $request = new Request($params); + $response = new Response(); + return $this->client->webmention($request, $response); + } + + private function _createExampleAccount() { + $user = ORM::for_table('users')->create(); + $user->url = 'http://example.com'; + $user->save(); + + $site = ORM::for_table('sites')->create(); + $site->name = 'Example'; + $site->created_by = $user->id(); + $site->save(); + + $role = ORM::for_table('roles')->create(); + $role->site_id = $site->id(); + $role->user_id = $user->id(); + $role->role = 'owner'; + $role->token = 'a'; + $role->save(); + } + + public function testAuthentication() { + $response = $this->webmention([]); + $this->assertEquals(401, $response->getStatusCode()); + $data = json_decode($response->getContent()); + $this->assertEquals('authentication_required', $data->error); + + $this->_createExampleAccount(); + + $response = $this->webmention(['token'=>'x','source'=>'http://source.example','target'=>'http://target.example']); + $this->assertEquals(401, $response->getStatusCode()); + $data = json_decode($response->getContent()); + $this->assertEquals('invalid_token', $data->error); + + $response = $this->webmention(['token'=>'a']); + $this->assertEquals(400, $response->getStatusCode()); + $data = json_decode($response->getContent()); + $this->assertEquals('missing_parameters', $data->error); + } + + public function testMissingParameters() { + $this->_createExampleAccount(); + + $response = $this->webmention(['token'=>'a']); + $this->assertEquals(400, $response->getStatusCode()); + $data = json_decode($response->getContent()); + $this->assertEquals('missing_parameters', $data->error); + + $response = $this->webmention(['token'=>'a','source'=>'foo']); + $this->assertEquals(400, $response->getStatusCode()); + $data = json_decode($response->getContent()); + $this->assertEquals('missing_parameters', $data->error); + + $response = $this->webmention(['token'=>'a','target'=>'foo']); + $this->assertEquals(400, $response->getStatusCode()); + $data = json_decode($response->getContent()); + $this->assertEquals('missing_parameters', $data->error); + } + + public function testInvalidURLs() { + $this->_createExampleAccount(); + + $response = $this->webmention(['token'=>'a','source'=>'notaurl','target'=>'alsonotaurl']); + $this->assertEquals(400, $response->getStatusCode()); + $data = json_decode($response->getContent()); + $this->assertEquals('invalid_parameter', $data->error); + + $response = $this->webmention(['token'=>'a','source'=>'http://source.example','target'=>'alsonotaurl']); + $this->assertEquals(400, $response->getStatusCode()); + $data = json_decode($response->getContent()); + $this->assertEquals('invalid_parameter', $data->error); + + $response = $this->webmention(['token'=>'a','source'=>'notaurl','target'=>'http://target.example']); + $this->assertEquals(400, $response->getStatusCode()); + $data = json_decode($response->getContent()); + $this->assertEquals('invalid_parameter', $data->error); + } + + public function testNoLinkToSource() { + $this->_createExampleAccount(); + + $response = $this->webmention(['token'=>'a','source'=>'http://source.example.com/nolink','target'=>'http://target.example.com']); + $this->assertEquals(400, $response->getStatusCode()); + $data = json_decode($response->getContent()); + $this->assertEquals('no_link_found', $data->error); + + $response = $this->webmention(['token'=>'a','source'=>'http://source.example.com/nothtml','target'=>'http://target.example.com']); + $this->assertEquals(400, $response->getStatusCode()); + $data = json_decode($response->getContent()); + $this->assertEquals('no_link_found', $data->error); + } + + public function testHandlesMalformedHTMLWithLink() { + $this->_createExampleAccount(); + + $response = $this->webmention(['token'=>'a','source'=>'http://source.example.com/invalidhtml','target'=>'http://target.example.com']); + $this->assertEquals(201, $response->getStatusCode()); + $data = json_decode($response->getContent()); + $this->assertEquals(false, property_exists($data, 'error')); + } + + public function testQueuesWebmention() { + $this->_createExampleAccount(); + + $response = $this->webmention(['token'=>'a','source'=>'http://source.example.com/basictest','target'=>'http://target.example.com']); + $this->assertEquals(201, $response->getStatusCode()); + $data = json_decode($response->getContent()); + $this->assertEquals(false, property_exists($data, 'error')); + $this->assertEquals('queued', $data->result); + $this->assertEquals(true, property_exists($data, 'status')); + + preg_match('/\/webmention\/(.+)/', $data->status, $match); + $this->assertNotNull($match); + + # Verify it queued the mention in the database + $d = ORM::for_table('webmentions')->where(['source' => 'http://source.example.com/basictest', 'target' => 'http://target.example.com'])->find_one(); + $this->assertNotNull($d); + $this->assertEquals($match[1], $d->token); + } + +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..f9e62ac --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,2 @@ + + + Test + + +

This page has a link to target.example.com.

+ + diff --git a/tests/data/source.example.com/invalidhtml b/tests/data/source.example.com/invalidhtml new file mode 100644 index 0000000..a570a40 --- /dev/null +++ b/tests/data/source.example.com/invalidhtml @@ -0,0 +1,16 @@ +HTTP/1.1 200 OK +Server: Apache +Date: Wed, 09 Dec 2015 03:29:14 GMT +Content-Type: text/html; charset=utf-8 +Connection: keep-alive + + + + Test + + +

This page has a link to target.example.com but is broken HTML

+

+ + + diff --git a/tests/data/source.example.com/nolink b/tests/data/source.example.com/nolink new file mode 100644 index 0000000..ad8342d --- /dev/null +++ b/tests/data/source.example.com/nolink @@ -0,0 +1,14 @@ +HTTP/1.1 200 OK +Server: Apache +Date: Wed, 09 Dec 2015 03:29:14 GMT +Content-Type: text/html; charset=utf-8 +Connection: keep-alive + + + + Test + + +

This page doesn't have a link to target.example.com

+ + diff --git a/tests/data/source.example.com/nothtml b/tests/data/source.example.com/nothtml new file mode 100644 index 0000000..ab30869 --- /dev/null +++ b/tests/data/source.example.com/nothtml @@ -0,0 +1,190 @@ +HTTP/1.1 200 OK +Server: Apache +Date: Wed, 09 Dec 2015 03:29:14 GMT +Content-Type: image/jpeg; charset=utf-8 +Connection: keep-alive + +�����N�ri@���5}�yj�>��>(��v�T�7@� + � >�u� +�7_��S���yD��n;��26���Կ��ddu9�J8 4��P1n��O�]��h�26@�Kv<�$< +lz�OwQ}��?��"W79ٗ���7�QjN{�;��[S� +����>�qA��r4����3� �!+�%�� �z��� ��ΩJ��%nB�T�g�\#��sځ�w�L;��� �gzR����܉�Sk8ȶ(;�;�Z��pAS��La�A���DG��@����b�p($�P������� �0���˜B8�,G�A�"Ƙ �Q�~��悃� + ((���,s� u� ��ˎ���|sAr2��PHt�f� ��r2q� +3h��g��{��z�n��O�al߻&��y{����;؃����0(:?���-�6�"+��Dr�O�t�Y�� Oٖk�� +�tM��@�B�Jw�Y�>�'A��hؑ�o`�1V�@�������,�O�Pa��\�Y��Q�-�T���6�Չ>����D�7c�۰�Yt�gRa�w�r�� �ê 7�m����m�޼A���8�+�a�%/؁�fu�Xw��A����ei���������x4�y{��0�G��hu��@[j�;��޾�dk8Pp�=��c-#he���5s؃����coapL��T��_������>x ��t���+�\���5��Լ�m(��1�z����N#-&���n>o��ѷƑ��\�\�[��;y���#�ܟ��Q�ay�\fY�|�Z����Sك=���(䂭@�B��t��<`� )T��iA�…p��㍷�F@}�ke��K���{1��.;P|� ��8mbO9�=��tC��@|Q�)H&:$܀!roL��(Zۙr@ �큗����N2 �}��D1A��}���,��I���|� r�T�@�za�|3Ч.�t���ˆ ���Z��_ӭ�����表₏O�(h�3��ô��a��/� ���@�zE�@��oW��N?]�ơσ ��k���t�Iĩ����0�r�nd䏂Fv�@j�<01Pkf�Z��j Un�0��A�zNv��ă���oh�V1�4C��A��d�}EpK�s�|� �{X\ +k3�c���H|x�Am��*�d��.��&[�-��2 7�F��?�@�t��$pA3���d� J8��i + )\�.C��A"�܂';��N҃H�7Q��somB�y�0!l��CenR�}��z����HH���u����j��S��������A'� + a��.>�$�|8�x��$���}�4�H@���7�O��Ak� +Tw7L^��\�dn�9�>���Ku�ȄZ��G�~(7�>�� 鱹��pk-ܭ���P�ߔ�ά���W�|J��G��C��MF#��O��3��QTpn���FnPX���|�>(4��vE��5A�뛁Q+�dĠ��J1�7bND��^��c��k�����L��$�J +����^� +zd�D���*=iW���({��D�/H����Y����D�b�zzu�!�&+�kՌ@�KРq�U�~���@K�]HO��~�O� ��^��ܝؙ�pA�}��E�N2���2����pz�%��NL�2��'���R���D�W�4j���}J3""%Ww��s��N� + ��D~�}��Z1M��PM�{�K�� + Db�x�‏���$��"���t�r�h� �"#��#׷w/��=�&=o�DJ�)�6<�~�əʄ�A��� +��ڃ}�� + ё�,^�>�g��h��N��� ?��RJ2#�`�B�'��,@A�k�]�kD@%�Y���w�{S�H�p�0�4�ز>��&/9r�CS� q�Q�cIJ_4�19��;��Mˑ��t-�"@�4��H + {�E�µ$�n�kgubfA9P:�(�\� _�!��%�"���V���X�O�b + =R���li� +;�P�n�YDsq��v�� +�Q�)NP�R9{�=cNU/@#ԧ&x��]��[�wP �]B�Ţr@��7�&$�}f�G���]�����[�� ��� H��d + �(���R1�!�P��b@�4����p=$���@��A���u?� + ���u��a�ʀ %�n�ck���2&:���Yw:��qhً���)����܏�}p�AG� ��c�`x6H2��-ă�c��;ִ +�0��.7��7;ܬŸ3 V���n((��0���o�b��?)�@���gxA��㉊����]R1���]z͸��+��r�U,�$� `����kW�H�s�Ɔ@��,d�|PtB�Z��meL�T�A�pAv�DT�����@�K|���6�PJ���=f +S7(0��,ʗ!8�����KPA�����5}1c؃�W�#�d�� +��EƐ�qA���+�Ǩ�VǛ� �R�%��z[��b�2zd��C�A��l]��(&wvN�ף!������Ư +n�: m�����P-> + R���V��A�osK� + { ���`J�ٱ��S������E�;� ���)_۷��}F�_�?�n^�~w�A��~Fdw0>k�Dw��(;PW�Y�D + n�����N�hKz�bw9ffoۑm]́��A?mh B0���%�?�A���N�*ږ�� ��~�ļ��*�A��V�Yݦ�7�Ӷ�58惿o��N��x=Oi+R"�pC �\��� + �Q�b�9�D=1@��Z��}'D�Y� �-��b@(;� ʡ�h�pA���MY۵WA�,3@��ʃ(/�P�"��F2�%���w��D�L|B�T�s@�Ȝ+� + Z'�� +�k��J�b�Q��L��ڂ�Ng��PG��M�L@v���}:�rIǧܙ����AP�]v}D �ӦڮT ����N��5A���@N�%��E����Kp@���3zd���fHx����\��M�giw���oq��6nm&\B���Zٮ + L���`;#jR�m�l� ������9��t +;S�� ����@�PdvgH2"��Xlaƕ� +�+��$���W �#�I���$a��A� (�� �N��cY�C�"�zD���6LB�1�&?�c���|D���a-��Tnn #�q�1�A��g#"uj�A�gG@����z �� + ��p$ �Йm�v�.; + �H�:�pA���bG�9�D\�=)Ę��� �{���yz��x R7�&�r�e=�fb#! X�� + �{4��pP�6$���A�f�� +wH�{��G�@�v�%,�J�"�>N�9� @��F W7�5D����ȁ��� + ��s�PW��?�i��w&�qۊ + ��s:¨/U��~��"����g�1���5�\��2a����Cѻ�De}��H.3�Y��P9}C�#��v:�"�9��a�:K�#RHA�'a�?y`� oF_�A~����\��c�I2/ƨd�A1������A�ol�� + �-�0A�;d�aj{�L�mۯO�z������$ �fA��܁���ڂ�&�L� + n/���Qz���3�����;�m�yd;h�9 +�C�@���zv�"³�PK�@$�Q; A&P��.� ���;X ���+�8 �[3 + �#K ���F5��#zmP狠���!��3���3$�zH�4A�f�=�i:�'(�S�+�F�A��HK�J�9�#֖Qo� �(z��v��9�%�)�bf[0� + �jJ�$���p!귞�\ + �iM��jM�I����-�V�S��M�ԫ׊Kqv{�V�ݾ$$j�/ڃ���/�kD�R��$^2,sA0�A�t+�]c�����Pd�1!� �ɨ1����,x F3��<�7�,C���p� + �A0j R0 +A�spt��64(w�.��P8�0��(+L�l&�9�kB+��lK<~�8g���ZTb��bp�@L�P��dt�@W0���OR���%� �<;|%�čc��t����� + DX�Ap�"Ƨ�A�oY�29�M�I:�V��(�$�;Pif�y���M�`�{C���s�R�U�&v=������� +��hĜ�tGwq�E��Ǩ�ɥh�dY;�u�_��Pă؀��Χ�#� � +��q��� ٖA�.=2E� + �A�H��z�I�=h���dQ���<���&�q�g�8�`w[Q���UX���y��Th�-B1ƀ��wF�h� �f�g;�1�.��[�p2���5�(�Ug+�I��k�H/ִ�ķ�|P)JƒM�[��wS��P�9pA��3/B�HH>(*3 � ��q��&�;�^���,���)4��+lH��-���f���ޝ�܂�a�3�p,�~�5rx1@�mȓ�#G���v�A���SY� +K� +�-̇/.�J՘\2��S�� + �GxA���a@Z��.�pA���c�hm` ��9� + G����$�e��6�s`��f%�K�j�� +�'���2�����:mY�#����/��&H��� +W + i-J _Kn �9=�L�-��A3�)�H�ga(�`�.[��q@�<�: + 4�Dw��Z�*�,�"�c�\�"�::�v(��q�"�@�(&�s�A��KMo82����*a��O0@� ��$ г1� ����W�7�)Āp�Py&��A� +�&~�Y�T#0k���K���U>�S� �dməM��Q�G� �����.�0��G��T.a�d���zn�Y�Ĕ + [/���G��0�j� + q8����� + �@ď� S� + ��P�=��&���AҀf�}5�P�J�@��@�� + ҙ�����RY3���[�Ӧ!�L�Af�Xi%�F�g�N��X�}/� �]�� G�\ ����8 �ҘƝ�,@������9BD~(0�c�b�[ �����z��,�-{��v S�� + ���,��V��Ncl��1c�� ��z + k}� +^r@3�~�w���jF=��^q��� �oV�@�Jo�ڃ0�\Ҍ�X3}��8C��"ゃ���^���L39�����"9�[m(��/X#��@��$68q�A�G( +�D�M��AR9\��H9�'�q�HvaO�:�G��k�@�0#C^ 38 � I���8�X�r?� + �����ڝ˧� ���n�����>j��b����.۷!�H<�bPo Ͱ=�5��ı�(&7�F��� ~����A_VH��{�>~�73-��AF�#�F�%������{ @�14�A��^��� PA�W�'��؂1���aހ���ي + �ԦY۱�U�������V�C^�/꛽D�rst:��rX꒎܂��\�N��է�(*=f9����m�1$���X�Hz���L5�A���GJ +�X�����]��������#� ���ǞZ��A���ߙ�qt�ć4 �h� ���� ��b3� + w��s��\�y��5("P�.�6���;Ї,�!a�bB��] +E�Rd��J�?jLF� �I����c-CO`!� + ����}P)ډD���D6V�3�z +c��8͸��6A�oi�C����F�AR��i\��[M�f� ��j���=2$��#�}0� �ېIq���;G)v@���/� + ��$3�D� (,ܴ�e�b + ��CT�e�h)�� +�"(��T���� + Ɍ)��^�cCHi�$ ��2��g>[:C�A����&A��p��A�tV���`��.2'� ����5s��w]u�N@�( +�]�1��"(o�F@�~aT>#���"E��/��� R4}H$ܘ�Ti���r +B9G�A��cF�$��@=���X���l��D���ft}t��F( ��/�������4�w���u#'a�s�n�&o� �3-OWtv�N��As�;Aշ��,��'9����T��/�����ȌK�����\0��� +-��: R$�%G��)O6C��ct�1�A��J1���A�u���x W'&� 2 + � + ��õ:�c��WV�I#�:�B?��D9Ձ�KP�9�@7!���@D/�@k��ڀ��qA������<��p9�lܹ�Q��c�[16�� + �D� ��J�,Ny2���-��I��l��}'A���b�COw��vE%h�N=�7=l�}� bx��tM���F'�0�k~���ܟ!Tsۛ\���(.״�R��Dx��]��l9*�G"{p]�M7D-_��Y2fێ�#���R���`�#�貺c=�G�!�Js�~�kf�A~v9b����zD�nX�3Mq4�A��c�n6u +�A(1����d7�:���Pko�c��1�zq�05?�D��}����,3�}r��;�Nۋs�8�c��?۝ԭ��E�Ē�����Csb�Ř����m�`g�Pg�@�qG�SA��L��2t��m|��o,ȳ���Es��uX �;_��(9�����\ +��{� ��$�b�����Y� + 2�7n>��@�W� + O��� D���(�fu��cfձ+�1��>lx �������,�M���f�1(2>���ٻڈs6��q���]���KS�a� �.GsnM(�A���7d>Z�� + �|�%�d���}����H���s�u8Z�vv�-]ӑͨP�}T�gQ�v�(�ޡR-ȶ8����FU��AP��jЙ`P�P'�7�A"0���k�� �����D��$~(g��"��m��v�3�@�Q1��3ԱA���cjT�( �t�aDF���A@n��]Ж�D��0�V�[t�1��}MԜ��c��wP�1�@�pFlPTon��׵�� + "I�ACs��@1��@������Gs|b�Tw{�������{B ��j悆������ϖ�ڀߘ��{x�ǹ�P�>b�Q�wY���R�5n�ȡ'�@��^���nF2,�3��H��'K���Fh$�dՑn��܁�����١(/X�7�zH g|3��۵&�"\ �]P�,��(3't!�5� +a��m�ۑ�K5~(0����И���u(�� �K�\jyc\� �=d<��� + Pv��MT6-�I���v��@&�86� +�(�41�6=�er J!�X�(�d/�����r�(3�ۑ��vE�N8qA�����H�-MQ��I�j���S܂�w}��2��������ݷn��KKgŻ�|GS�E�-Ɲ�<������I��X�P}���/m�oq�#P<��)�`i�����;77��ں����(7k�ދ��H��q��<��Ԡo��x�:P��glPc+w3bs��W�c(�A������zwA�8�?�(̃�m.���2.u�4�f�dn���)D�� ���z�7�32�jj +aD +P� 5KP$Ȓ$�j�3��+R �X?aA;=��7��uڐ�"X��t����\1��JF�0r^����rW&R�d{ ����k� + r�8i�i� + !���Of�mk�ļ�bǀA�X Õ�;{}ƻS�OLPD=�:���F��ŀ�A�{ejw<���GdZ��+f���[���F�m��4GLd�sA���=(����΂���Dڶ"�*�E��q�`���G�"љ1"�qI�P]��j�^q-�(����X��z��D�nZ���yc��[��#�l�Om + b��uA� ����bG@�����6a�O�?���@ [��eͶ��܋H�Q� �[(��A���XĸA�v�'�3A'g�x �m@ +�H���N(7�F�0��� �>�-=��� ��mj�ǘl� 3�Z Dh�DͿR:I +�t=��R��d�7;i�N.�ͻft���]��8�;Cl9-�P`-��~�[V�P�ېHx�s�F�x���ȥ��@�[��H��F��� 舿��T��e!Lç\2�� ���sCKdB + �������a�O���ߏ-؏D�F�r�"�PLzm� + �(5���4�v@ٻk��"N_�A0�r��h3�M + ��Pz}pbsA�;;�Q�0A�ڻ.)���n-� � +9�A�㽂 + �ǯ�����w�Je +1h�A�����.Q@58.Fm����k�H3�+�S��1�)­��.� Z���|Pw9�H�� + c� sp8`PP2���x��^-S�sȠ"��S0��D�J +�����'"�0�(��x �{��{y�۱I6rA���pA�\�(Z������ + �C�� Gkv�Y��j4�J +Q�.%�J�j�F� ؀���8��MG~�bm.{�/[O�x �^V("{�8B�\ �n k(7oY���T�&�nep٤I/�e��"�n���^��6���u��;����c�A�7���M���v��R����>?� �j5���n� + 8�-��2�n�9���q�'�� + ����� �/����Ysdx���8�PN�E<��@Â�H�x� + ��� z"�#�$�c��I�?)�}:��@�$+�����C�:~�rH��cĚA���q;��Je�"��!�0��sOqr��e2kJ���w���}v�D�L�%�boߑ`O�@k�Ϭ�����$�B��-ނ}k��n�;� +>�A�����tР����M{PU��v�h0�Ǩݙ}E� +-{�{<�J=��臺�p�1�K�ǻ� �� ���o��J=��DLD�A���؀�ᆪ`7�[��g����eL�Z�F��ܓ�t�tnd�"�2Ps�7���� +1<*�h{��=��O�>�ꑤ_��7���8�@j=�4��?W!�F���L��� G�m�/Q@:b2w�es��2&<0�i�.�!�r�1�@����}t��� ��q���c�đT}�$�Ͻ'�[���G^����Ks3)H5�6��,���(0����90��$&�1� Ԇr +��T��H�[�A��qz��[a� +r"�B|M��&ߤu�s�{ �vf�f$$C�N�f:��PT���H�A��Xj �Z�Ψ�9t�M���0f=����P+�B1�@/�ӱ��-_.MD}M� + y3�*�gzq�i�^v� + �M��J�`� +���Q�� +Z(+�3r�V��2���9R��r=^@yj +M��tsu!o�@�E× ��o����KzU��x pݽ�# +�n�b����<";�m9]������ո����q�D�p@A��o@��,Pa.��v�j� +��7 �As�X,�qJ�n��U�,�oFd +Mb + �j;���#� �7f(9�۴-j:D���A�^��$� + �2�E0d���kӳ���/�el� �<� �A�f�-�=o + c���A_^W $���I��: ����F�Ҁ��v��x��j�f +��lL����d2�u�m(��2[�n��P��1����ߊ��5��FL"���J�`Hɨ�����wr5" �]>k��A��H��|�hv�[���@ +�/G�rd + � +ڃH�f(t9� �/)��D��cC.u(���PT���h2�P�,$���Y�n�O�Yni[�PTw0�%� +���\�{�GDZ5A���ټ�-�Kq���I@ + ׌X;�s�yƭe���P!r��"8��=%�2ܗ�@10�/5��/ +a����@CC��^�2����@�pDRd7j �: +V���l �߈�8��n6��!�wl7�";�Sl����k���ۉ�V��z�qA����9A��� � ꉗ�:��jr$ +���a� + ��uX2��ۃ���B583�j�x����nP9��˖��(�dd�e�^����z���0�Xeނt�yk�zvΡ�2�3�a(�e/�#�(%��r�