You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

280 lines
8.6 KiB

  1. <?php
  2. use Symfony\Component\HttpFoundation\Request;
  3. use Symfony\Component\HttpFoundation\Response;
  4. use Monolog\Logger;
  5. class API {
  6. public $http;
  7. public function __construct() {
  8. $this->http = new Telegraph\HTTP();
  9. }
  10. private function respond(Response $response, $code, $params, $headers=[]) {
  11. $response->setStatusCode($code);
  12. foreach($headers as $k=>$v) {
  13. $response->headers->set($k, $v);
  14. }
  15. $response->headers->set('Content-Type', 'application/json');
  16. $response->setContent(json_encode($params));
  17. return $response;
  18. }
  19. private static function toHtmlEntities($input) {
  20. return mb_convert_encoding($input, 'HTML-ENTITIES', mb_detect_encoding($input));
  21. }
  22. private static function generateStatusToken() {
  23. $str = dechex(date('y'));
  24. $chs = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
  25. $len = strlen($chs);
  26. for($i = 0; $i < 16; $i++) {
  27. $str .= $chs[mt_rand(0, $len - 1)];
  28. }
  29. return $str;
  30. }
  31. public function webmention(Request $request, Response $response) {
  32. # Require the token parameter
  33. if(!$token=$request->get('token')) {
  34. return $this->respond($response, 401, [
  35. 'error' => 'authentication_required',
  36. 'error_description' => 'A token is required to use the API'
  37. ]);
  38. }
  39. # Require source and target or target_domain parameters
  40. $target = $target_domain = null;
  41. if((!$source=$request->get('source')) || ((!$target=$request->get('target')) && (!$target_domain=$request->get('target_domain')))) {
  42. return $this->respond($response, 400, [
  43. 'error' => 'missing_parameters',
  44. 'error_description' => 'The source or target or target_domain parameters were missing'
  45. ]);
  46. }
  47. if($target && $target_domain) {
  48. return $this->respond($response, 400, [
  49. 'error' => 'invalid_parameter',
  50. 'error_description' => 'Can\'t provide both target and target_domain together'
  51. ]);
  52. }
  53. $urlregex = '/^https?:\/\/[^ ]+\.[^ ]+$/';
  54. $domainregex = '/^[^ ]+$/';
  55. # Verify source, target, and callback are URLs
  56. $callback = $request->get('callback');
  57. if(!preg_match($urlregex, $source) ||
  58. (!preg_match($urlregex, $target) && !preg_match($domainregex, $target_domain)) ||
  59. ($callback && !preg_match($urlregex, $callback))) {
  60. return $this->respond($response, 400, [
  61. 'error' => 'invalid_parameter',
  62. 'error_description' => 'The source, target, or callback parameters were invalid'
  63. ]);
  64. }
  65. # Verify the token is valid
  66. $role = ORM::for_table('roles')->where('token', $token)->find_one();
  67. if(!$role) {
  68. return $this->respond($response, 401, [
  69. 'error' => 'invalid_token',
  70. 'error_description' => 'The token provided is not valid'
  71. ]);
  72. }
  73. # Check the blacklist of domains that are known to not accept webmentions
  74. if($target && !Telegraph\Webmention::isProbablySupported($target)) {
  75. return $this->respond($response, 400, [
  76. 'error' => 'not_supported',
  77. 'error_description' => 'The target domain is known to not accept webmentions. If you believe this is in error, please file an issue at https://github.com/aaronpk/Telegraph/issues'
  78. ]);
  79. }
  80. # Synchronously check the source URL and verify that it actually contains
  81. # a link to the target. This way we prevent this API from sending known invalid mentions.
  82. $sourceData = $this->http->get($source);
  83. $doc = new DOMDocument();
  84. @$doc->loadHTML(self::toHtmlEntities($sourceData['body']));
  85. if(!$doc) {
  86. return $this->respond($response, 400, [
  87. 'error' => 'source_not_html',
  88. 'error_description' => 'The source document could not be parsed as HTML'
  89. ]);
  90. }
  91. $xpath = new DOMXPath($doc);
  92. $found = [];
  93. foreach($xpath->query('//a[@href]') as $href) {
  94. $url = $href->getAttribute('href');
  95. if($target) {
  96. # target parameter was provided
  97. if($url == $target) {
  98. $found[$url] = null;
  99. }
  100. } elseif($target_domain) {
  101. # target_domain parameter was provided
  102. $domain = parse_url($url, PHP_URL_HOST);
  103. if($domain && ($domain == $target_domain || str_ends_with($domain, '.' . $target_domain))) {
  104. $found[$url] = null;
  105. }
  106. }
  107. }
  108. if(!$found) {
  109. return $this->respond($response, 400, [
  110. 'error' => 'no_link_found',
  111. 'error_description' => 'The source document does not have a link to the target URL or domain'
  112. ]);
  113. }
  114. # Everything checked out, so write the webmention to the log and queue a job to start sending
  115. # TODO: database transaction?
  116. $statusURLs = [];
  117. foreach($found as $url=>$_) {
  118. $w = ORM::for_table('webmentions')->create();
  119. $w->site_id = $role->site_id;
  120. $w->created_by = $role->user_id;
  121. $w->created_at = date('Y-m-d H:i:s');
  122. $w->token = self::generateStatusToken();
  123. $w->source = $source;
  124. $w->target = $url;
  125. $w->vouch = $request->get('vouch');
  126. $w->callback = $callback;
  127. $w->save();
  128. q()->queue('Telegraph\Webmention', 'send', [$w->id]);
  129. $statusURLs[] = Config::$base . 'webmention/' . $w->token;
  130. }
  131. if ($target) {
  132. $body = [
  133. 'status' => 'queued',
  134. 'location' => $statusURLs[0]
  135. ];
  136. $headers = ['Location' => $statusURLs[0]];
  137. } else {
  138. $body = [
  139. 'status' => 'queued',
  140. 'location' => $statusURLs
  141. ];
  142. $headers = [];
  143. }
  144. return $this->respond($response, 201, $body, $headers);
  145. }
  146. public function superfeedr_tracker(Request $request, Response $response, $args) {
  147. logger()->addInfo("Got payload from superfeedr: " . $request->getContent());
  148. $input = json_decode($request->getContent(), true);
  149. # Require the code parameter
  150. if(!$token=$args['token']) {
  151. return $this->respond($response, 401, [
  152. 'error' => 'authentication_required',
  153. 'error_description' => 'A token is required to use the API'
  154. ]);
  155. }
  156. # Verify the token is valid
  157. $role = ORM::for_table('roles')->where('token', $token)->find_one();
  158. if(!$role) {
  159. return $this->respond($response, 401, [
  160. 'error' => 'invalid_token',
  161. 'error_description' => 'The token provided is not valid'
  162. ]);
  163. }
  164. $site = ORM::for_table('sites')->where('id', $role->site_id)->find_one();
  165. if(($items = $input['items'])
  166. && is_array($items)
  167. && array_key_exists(0, $items)
  168. && ($item = $items[0])
  169. && array_key_exists('permalinkUrl', $item)) {
  170. $url = $item['permalinkUrl'];
  171. $domain = parse_url($site->url, PHP_URL_HOST);
  172. # Create a new request that looks like a request to the API with a target_domain parameter
  173. $new_request = new Request(['token' => $token, 'source' => $url, 'target_domain' => $domain]);
  174. return $this->webmention($new_request, $response);
  175. } else {
  176. return $this->respond($response, 200, [
  177. 'error' => 'invalid_request',
  178. 'error_description' => 'Could not find source URL from the superfeedr payload'
  179. ]);
  180. }
  181. }
  182. public function webmention_status(Request $request, Response $response, $args) {
  183. $webmention = ORM::for_table('webmentions')->where('token', $args['code'])->find_one();
  184. if(!$webmention) {
  185. return $this->respond($response, 404, [
  186. 'status' => 'not_found',
  187. ]);
  188. }
  189. $status = ORM::for_table('webmention_status')->where('webmention_id', $webmention->id)->order_by_desc('created_at')->find_one();
  190. $statusURL = Config::$base . 'webmention/' . $webmention->token;
  191. if(!$status) {
  192. $code = 'queued';
  193. } else {
  194. $code = $status->status;
  195. }
  196. $data = [
  197. 'source' => $webmention->source,
  198. 'target' => $webmention->target,
  199. 'status' => $code,
  200. ];
  201. if($webmention->webmention_endpoint) {
  202. $data['type'] = 'webmention';
  203. $data['endpoint'] = $webmention->webmention_endpoint;
  204. }
  205. if($webmention->pingback_endpoint) {
  206. $data['type'] = 'pingback';
  207. $data['endpoint'] = $webmention->pingback_endpoint;
  208. }
  209. switch($code) {
  210. case 'queued':
  211. $summary = 'The webmention is still in the processing queue';
  212. break;
  213. case 'not_supported':
  214. $summary = 'No webmention or pingback endpoint were found at the target';
  215. break;
  216. case 'accepted':
  217. $summary = 'The '.$data['type'].' request was accepted';
  218. break;
  219. default:
  220. $summary = false;
  221. }
  222. if($status && $status->http_code)
  223. $data['http_code'] = (int)$status->http_code;
  224. if($summary)
  225. $data['summary'] = $summary;
  226. if($webmention->complete == 0)
  227. $data['location'] = $statusURL;
  228. return $this->respond($response, 200, $data);
  229. }
  230. }