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 @@
+
This page has a link to target.example.com.
+ +