Browse Source

beginning API

* API validates and accepts requests and writes to the DB
* tests check all API responses
pull/3/head
Aaron Parecki 5 years ago
parent
commit
01bc6a8962
15 changed files with 755 additions and 11 deletions
  1. +4
    -1
      README.md
  2. +8
    -2
      composer.json
  3. +65
    -7
      composer.lock
  4. +131
    -0
      controllers/API.php
  5. +1
    -1
      controllers/Controller.php
  6. +73
    -0
      lib/HTTP.php
  7. +71
    -0
      lib/HTTPTest.php
  8. +11
    -0
      lib/helpers.php
  9. +13
    -0
      phpunit.xml
  10. +142
    -0
      tests/APITest.php
  11. +2
    -0
      tests/bootstrap.php
  12. +14
    -0
      tests/data/source.example.com/basictest
  13. +16
    -0
      tests/data/source.example.com/invalidhtml
  14. +14
    -0
      tests/data/source.example.com/nolink
  15. +190
    -0
      tests/data/source.example.com/nothtml

+ 4
- 1
README.md View File

@ -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

+ 8
- 2
composer.json View File

@ -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"
]
}
}

+ 65
- 7
composer.lock View File

@ -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",

+ 131
- 0
controllers/API.php View File

@ -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
]);
}
}

+ 1
- 1
controllers/Controller.php View File

@ -29,7 +29,7 @@ class Controller {
}
$response->setContent(view('dashboard', [
'title' => 'Dashboard'
'title' => 'Telegraph Dashboard'
]));
return $response;
}

+ 73
- 0
lib/HTTP.php View File

@ -0,0 +1,73 @@
<?php
namespace Telegraph;
class HTTP {
public static function get($url) {
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HEADER, true);
$response = curl_exec($ch);
$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 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;
}
}

+ 71
- 0
lib/HTTPTest.php View File

@ -0,0 +1,71 @@
<?php
namespace Telegraph;
class HTTPTest {
public static function get($url) {
return self::_read_file($url);
}
public static function post($url, $body, $headers=array()) {
return self::_read_file($url);
}
public static function head($url) {
$response = self::_read_file($url);
return array(
'code' => $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;
}
}

+ 11
- 0
lib/helpers.php View File

@ -1,4 +1,15 @@
<?php
date_default_timezone_set('UTC');
if(array_key_exists('ENV', $_ENV)) {
require(dirname(__FILE__).'/../config.'.$_ENV['ENV'].'.php');
} else {
require(dirname(__FILE__).'/../config.php');
}
ORM::configure('mysql:host=' . Config::$db['host'] . ';dbname=' . Config::$db['database']);
ORM::configure('username', Config::$db['username']);
ORM::configure('password', Config::$db['password']);
function view($template, $data=[]) {
global $templates;

+ 13
- 0
phpunit.xml View File

@ -0,0 +1,13 @@
<?xml version="1.0"?>
<phpunit
bootstrap="tests/bootstrap.php"
beStrictAboutTestsThatDoNotTestAnything="true">
<php>
<env name="ENV" value="test"/>
</php>
<testsuites>
<testsuite name="comments">
<directory suffix="Test.php">tests</directory>
</testsuite>
</testsuites>
</phpunit>

+ 142
- 0
tests/APITest.php View File

@ -0,0 +1,142 @@
<?php
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
class APITest extends PHPUnit_Framework_TestCase {
private $client;
public function setUp() {
$this->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);
}
}

+ 2
- 0
tests/bootstrap.php View File

@ -0,0 +1,2 @@
<?php
require_once __DIR__ . '/../vendor/autoload.php';

+ 14
- 0
tests/data/source.example.com/basictest View File

@ -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
<html>
<head>
<title>Test</title>
</head>
<body class="h-entry">
<p class="e-content">This page has a link to <a href="http://target.example.com">target.example.com</a>.</p>
</body>
</html>

+ 16
- 0
tests/data/source.example.com/invalidhtml View File

@ -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
<html>
<head>
<title>Test</title>
</head>
<body class="h-entry">
<p class="e-content">This page has a link to <a href="http://target.example.com">target.example.com</a> but is broken HTML</p>
<p><div><a>
</body>
</body>
</html>

+ 14
- 0
tests/data/source.example.com/nolink View File

@ -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
<html>
<head>
<title>Test</title>
</head>
<body class="h-entry">
<p class="e-content">This page doesn't have a link to target.example.com</p>
</body>
</html>

+ 190
- 0
tests/data/source.example.com/nothtml View File

@ -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;��� <H($�:Y��
�����59����ٶb"�TA�\ؘ��z���7֯�k� �1�H(>�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@��ʃ<L���0�h�6����b��J RXbt�#���i�!�1�"( � S>(/�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$ܘ�T<PH����.���T%1C" ���s=�9�D~��
�� Z 3�A7%^R@�vΐ�!z��
,�Bo��F����h����{��c'ʈ4��C�z�U�
!+�(2���w�@��؃�@��%7 Ñ���X��
��H���D����ȇ�X%FmS'�A�kr���A��M��p��N�P�|J
��96��'���@j��($�Q�'�S�A��
x�--F@�'g/A�����>i���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�<H����lx��쓂��E�x �n�x�-� �W��e�P�]��
Ԩ�P�]j�@��p��P1�ܜr�p���n.��+�/H� ����$C �o�%��X�((��s��3��vq�a�w[��3\PL�WdR*;��C�}n��";�/��?��h��lJ�n#z�8��. @�[乑��t�Sw:
��A���2@�_�ځ5���P��
!��4JE�A��� M����A�@�E((X��R2!�7�c?�x��;{�l]�(�_�ci�1ՠ�6׏ʁ�-�ʀ;M��b�CmzRa=���93 %
��Ÿf���KL���Pz3�D=~���4pF<Ph�N�Q4T���&A�1�E�Ć�PD%L��p@@��
PL�$qn�꘍M���P���
�$7w� ��us��XA�\8��1� �
p'Q��*2� �ÂND�IĴMy��)_�i��h�
�o�ĘNs|^ս��:�Ӗq-� r���ڕ�\�k��x �{]���A����A�Y���].P�GO�X�"G/؂c�vC*pz�[����Bْ��b
i(�P
=c�a؃cҶ�4�� ��/����i��R{({{h+��d�g���A_�?��l���J�>�۵&�"\ �]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<Jrř����A���"
��$�����%��3�K� i=��BE0A#c's!�J�+�k�J$��r
�H�|�� ��;�8�����mfħ��O�����<õ1�C_���B&Fn{Pcpى���fe��;L��z��66�7z
�ݍA��Z,� ���=#*��V"&�8��V-ѣ��;E��'A�>�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<IA���?Q��r�Q�WN�g�����昏4�H:�o��E�r�ʈ.;�_�#h���A�ڝ��K�"B�wA�}�'I�6��P9��"�!3�ƣ�P8�m�e^҃?��'p­
�)|�TqA���\�7bF@O��Z$�Ps�u��hi�z����������O���72yJ�1��A���/NQ�A�7FRAcG�A��]��Uw�x�1#�$ݽ #��{�zS�� ��n��NT���"9E6Aw%�i�xH�27�N��&$��^���f5`�Kc�;i���;�-��
��{Xm�&�I1r
�[�������� /�~��,J��{12�
՞[�bA4|��\J-�cq�'ՁA�z��c\f"I��8�A�suc�.���@�T
���=����&r �Dw���j�&D��=[Q���&[��L$pA77��,d@�8޽z�f05�@�7{��@A���"��籐g-���bx�ꎸF2�u~�8� ���[&��sw��J{q-D�Yb"4��f$"u����wej j%��A��' =I;��f�T�|�a{H
m�n#&2�mPL���� ����,�v��r�(��N���B��X��ר�H��s8���4���Kw
��Q�d�~�+��.NM�B;K�:E�ӈ�@��V��E����2~�ڃQ��1�샠n�
�sp�!���;��V܁��L��Ȑ�-��r���є"�j=�]�s��|� ɳ
u�ˆ97}?p$��z~��Oh��{c(@($ٴC��q����2|c� O�w
T6��oA� ��n@�� �����A��zTg2~�A��CN��e-���Pi�q���PX�m��w�D-��P8���ݵ#���������7�+3bzV��:"�9.m-�E�P`:tA��%� ���
����� �@����;[x;���q�˒@w ���b^bG�Av� D���o 4�
�r#M��Ӕ
C
:Jv�<���޷�ރt0� Q1�Aq�肧 ��9� Ip݁I�1%�AP�C���a=�������Ł0 �A7l�b9����27�
Z�qAV,ݜN�{���з"q'�q�k�gs�$8 q�n�,>�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�<P����0���X�&�~�Pԡñ�K=xIi�
]�W�p#��D�(�� ~���A��\̓�N7�� �@���D�
8@��2(/O������(���:FP2-��Pg��9��+|*��2�
��Z8���h'҃:-D��[�zLx�5�c0��$� ��ӄ� �T�ڐ?�,΂F��~�j�L�7�c\�8]���@z���

Loading…
Cancel
Save