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.

264 lines
8.0 KiB

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