Browse Source

beginning API

* API validates and accepts requests and writes to the DB
* tests check all API responses
pull/3/head
Aaron Parecki 8 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. 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 #### 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 * `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 * `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 * `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: 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: A callback from Telegraph will include the following post body parameters:
* `source` - the URL of your post * `source` - the URL of your post
* `target` - the URL you linked to * `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 ## Credits

+ 8
- 2
composer.json View File

@ -6,18 +6,24 @@
"indieauth/client": "0.1.*", "indieauth/client": "0.1.*",
"firebase/php-jwt": "~3.0", "firebase/php-jwt": "~3.0",
"league/route": "~1.2", "league/route": "~1.2",
"league/plates": "~3.1"
"league/plates": "~3.1",
"j4mie/idiorm": "1.5.*"
}, },
"require-dev": { "require-dev": {
"phpunit/phpunit": "*" "phpunit/phpunit": "*"
}, },
"autoload": { "autoload": {
"files": [ "files": [
"config.php",
"lib/helpers.php", "lib/helpers.php",
"lib/HTTP.php",
"controllers/Controller.php", "controllers/Controller.php",
"controllers/Auth.php", "controllers/Auth.php",
"controllers/API.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", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"hash": "5630557b773b8342de2ebfcfbe23f013",
"content-hash": "88dfc4a35925d318e92d4881b37d70a0",
"hash": "2ba7de03b37a6d84edc940e53b969dd8",
"content-hash": "a54d5a9f88a410c0295e0530b51e3abc",
"packages": [ "packages": [
{ {
"name": "barnabywalters/mf-cleaner", "name": "barnabywalters/mf-cleaner",
@ -217,6 +217,64 @@
"homepage": "https://github.com/indieweb/mention-client-php", "homepage": "https://github.com/indieweb/mention-client-php",
"time": "2015-12-09 19:10:25" "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", "name": "league/container",
"version": "1.3.2", "version": "1.3.2",
@ -440,16 +498,16 @@
}, },
{ {
"name": "nikic/fast-route", "name": "nikic/fast-route",
"version": "v0.6.0",
"version": "v0.7.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/nikic/FastRoute.git", "url": "https://github.com/nikic/FastRoute.git",
"reference": "31fa86924556b80735f98b294a7ffdfb26789f22"
"reference": "8164b4a0d8afde4eae5f1bfc39084972ba23ad36"
}, },
"dist": { "dist": {
"type": "zip", "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": "" "shasum": ""
}, },
"require": { "require": {
@ -479,7 +537,7 @@
"router", "router",
"routing" "routing"
], ],
"time": "2015-06-18 19:15:47"
"time": "2015-12-20 19:50:12"
}, },
{ {
"name": "symfony/http-foundation", "name": "symfony/http-foundation",

+ 131
- 0
controllers/API.php View File

@ -4,6 +4,137 @@ use Symfony\Component\HttpFoundation\Response;
class API { 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', [ $response->setContent(view('dashboard', [
'title' => 'Dashboard'
'title' => 'Telegraph Dashboard'
])); ]));
return $response; 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 <?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=[]) { function view($template, $data=[]) {
global $templates; 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