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.

346 lines
11 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, JSON_UNESCAPED_SLASHES+JSON_PRETTY_PRINT));
  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 or csrf parameter
  33. if($csrf=$request->get('_csrf')) {
  34. session_start();
  35. if(!isset($_SESSION['_csrf']) || $csrf != $_SESSION['_csrf']) {
  36. return $this->respond($response, 401, [
  37. 'error' => 'invalid_csrf_token',
  38. 'error_description' => 'An error occurred. Make sure you have only one tab open.',
  39. ]);
  40. }
  41. } else if(!$token=$request->get('token')) {
  42. return $this->respond($response, 401, [
  43. 'error' => 'authentication_required',
  44. 'error_description' => 'A token is required to use the API'
  45. ]);
  46. } else {
  47. # Verify the token is valid
  48. $role = ORM::for_table('roles')->where('token', $token)->find_one();
  49. if(!$role) {
  50. return $this->respond($response, 401, [
  51. 'error' => 'invalid_token',
  52. 'error_description' => 'The token provided is not valid'
  53. ]);
  54. }
  55. }
  56. # Require source and target or target_domain parameters
  57. $target = $target_domain = null;
  58. if((!$source=$request->get('source')) || ((!$target=$request->get('target')) && (!$target_domain=$request->get('target_domain')))) {
  59. return $this->respond($response, 400, [
  60. 'error' => 'missing_parameters',
  61. 'error_description' => 'The source or target or target_domain parameters were missing'
  62. ]);
  63. }
  64. if($target && $target_domain) {
  65. return $this->respond($response, 400, [
  66. 'error' => 'invalid_parameter',
  67. 'error_description' => 'Can\'t provide both target and target_domain together'
  68. ]);
  69. }
  70. # Can only use source & target if no authentication(role) is set
  71. if(!isset($role)) {
  72. if($target_domain) {
  73. return $this->respond($response, 400, [
  74. 'error' => 'unauthorized',
  75. 'error_description' => 'Can only use the target_domain feature when providing a token from the API',
  76. ]);
  77. }
  78. }
  79. $urlregex = '/^https?:\/\/[^ ]+\.[^ ]+$/';
  80. $domainregex = '/^[^ ]+$/';
  81. # Verify source, target, and callback are URLs
  82. $callback = $request->get('callback');
  83. if(!preg_match($urlregex, $source) ||
  84. (!preg_match($urlregex, $target) && !preg_match($domainregex, $target_domain)) ||
  85. ($callback && !preg_match($urlregex, $callback))) {
  86. return $this->respond($response, 400, [
  87. 'error' => 'invalid_parameter',
  88. 'error_description' => 'The source, target, or callback parameters were invalid'
  89. ]);
  90. }
  91. # Don't send anything if the source domain matches the target domain
  92. # The problem is someone pushing to Superfeedr who is also subscribed, will cause a
  93. # request to be sent with the source of one of their posts, and their own target domain.
  94. # This causes a whole slew of webmentions to be queued up, almost all of which are not needed.
  95. if($target_domain) {
  96. $source_domain = parse_url($source, PHP_URL_HOST);
  97. if($target_domain == $source_domain) {
  98. # Return 200 so Superfeedr doesn't think something is broken
  99. return $this->respond($response, 200, [
  100. 'error' => 'not_supported',
  101. 'error_description' => 'You cannot use the target_domain feature to send webmentions to the same domain as the source URL'
  102. ]);
  103. }
  104. }
  105. # Check the list of domains that are known to not accept webmentions
  106. if($target && !Telegraph\Webmention::isProbablySupported($target)) {
  107. return $this->respond($response, 400, [
  108. 'error' => 'not_supported',
  109. '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'
  110. ]);
  111. }
  112. # If there is no code given,
  113. # Synchronously check the source URL and verify that it actually contains
  114. # a link to the target. This way we prevent this API from sending known invalid mentions.
  115. if($request->get('code')) {
  116. # target URL is required
  117. if(!$target) {
  118. return $this->respond($response, 400, [
  119. 'error' => 'not_supported',
  120. 'error_description' => 'The target_domain parameter is not supported for sending private webmentions'
  121. ]);
  122. }
  123. $found[$target] = null;
  124. } else {
  125. $sourceData = $this->http->get($source, ['Accept: text/html, */*']);
  126. $doc = new DOMDocument();
  127. libxml_use_internal_errors(true); # suppress parse errors and warnings
  128. @$doc->loadHTML(self::toHtmlEntities($sourceData['body']), LIBXML_NOWARNING|LIBXML_NOERROR);
  129. libxml_clear_errors();
  130. if(!$doc) {
  131. return $this->respond($response, 400, [
  132. 'error' => 'source_not_html',
  133. 'error_description' => 'The source document could not be parsed as HTML'
  134. ]);
  135. }
  136. $xpath = new DOMXPath($doc);
  137. $found = [];
  138. $links = [];
  139. foreach($xpath->query('//a[@href]') as $href) {
  140. $url = $href->getAttribute('href');
  141. if($target) {
  142. $links[] = $url;
  143. # target parameter was provided
  144. if($url == $target) {
  145. $found[$url] = null;
  146. }
  147. } elseif($target_domain) {
  148. # target_domain parameter was provided
  149. $domain = parse_url($url, PHP_URL_HOST);
  150. if($domain && ($domain == $target_domain || str_ends_with($domain, '.' . $target_domain))) {
  151. $found[$url] = null;
  152. }
  153. }
  154. }
  155. if(!$found) {
  156. return $this->respond($response, 400, [
  157. 'error' => 'no_link_found',
  158. 'error_description' => 'The source document does not have a link to the target URL or domain',
  159. 'links' => $links
  160. ]);
  161. }
  162. }
  163. # Write the webmention to the database and queue a job to start sending
  164. $statusURLs = [];
  165. foreach($found as $url=>$_) {
  166. $w = ORM::for_table('webmentions')->create();
  167. $w->site_id = isset($role) ? $role->site_id : 0;
  168. $w->created_by = isset($role) ? $role->user_id : 0;
  169. $w->created_at = date('Y-m-d H:i:s');
  170. $w->token = self::generateStatusToken();
  171. $w->source = $source;
  172. $w->target = $url;
  173. $w->vouch = $request->get('vouch');
  174. $w->code = $request->get('code');
  175. $w->realm = $request->get('realm');
  176. $w->callback = $callback;
  177. $w->save();
  178. q()->queue('Telegraph\Webmention', 'send', [$w->id]);
  179. $statusURLs[] = Config::$base . 'webmention/' . $w->token;
  180. }
  181. if($target) {
  182. $body = [
  183. 'status' => 'queued',
  184. 'location' => $statusURLs[0]
  185. ];
  186. $headers = ['Location' => $statusURLs[0]];
  187. } else {
  188. $body = [
  189. 'status' => 'queued',
  190. 'location' => $statusURLs
  191. ];
  192. $headers = [];
  193. }
  194. if($request->get('_redirect') == 'true') {
  195. $response->setStatusCode(302);
  196. $response->headers->set('Location', $body['location'].'/details');
  197. return $response;
  198. } else {
  199. return $this->respond($response, 201, $body, $headers);
  200. }
  201. }
  202. public function superfeedr_tracker(Request $request, Response $response, $args) {
  203. logger()->addInfo("Got payload from superfeedr: " . $request->getContent());
  204. $input = json_decode($request->getContent(), true);
  205. # Require the code parameter
  206. if(!$token=$args['token']) {
  207. return $this->respond($response, 401, [
  208. 'error' => 'authentication_required',
  209. 'error_description' => 'A token is required to use the API'
  210. ]);
  211. }
  212. # Verify the token is valid
  213. $role = ORM::for_table('roles')->where('token', $token)->find_one();
  214. if(!$role) {
  215. return $this->respond($response, 401, [
  216. 'error' => 'invalid_token',
  217. 'error_description' => 'The token provided is not valid'
  218. ]);
  219. }
  220. $site = ORM::for_table('sites')->where('id', $role->site_id)->find_one();
  221. if(is_array($input)
  222. && array_key_exists('items', $input)
  223. && ($items = $input['items'])
  224. && is_array($items)
  225. && array_key_exists(0, $items)
  226. && ($item = $items[0])
  227. && array_key_exists('permalinkUrl', $item)) {
  228. $url = $item['permalinkUrl'];
  229. $domain = parse_url($site->url, PHP_URL_HOST);
  230. # Create a new request that looks like a request to the API with a target_domain parameter
  231. $new_request = new Request(['token' => $token, 'source' => $url, 'target_domain' => $domain]);
  232. return $this->webmention($new_request, $response);
  233. } else {
  234. return $this->respond($response, 200, [
  235. 'error' => 'invalid_request',
  236. 'error_description' => 'Could not find source URL from the superfeedr payload'
  237. ]);
  238. }
  239. }
  240. public function webmention_status(Request $request, Response $response, $args) {
  241. $webmention = ORM::for_table('webmentions')->where('token', $args['code'])->find_one();
  242. if(!$webmention) {
  243. return $this->respond($response, 404, [
  244. 'status' => 'not_found',
  245. ]);
  246. }
  247. $status = ORM::for_table('webmention_status')->where('webmention_id', $webmention->id)->order_by_desc('created_at')->find_one();
  248. $statusURL = Config::$base . 'webmention/' . $webmention->token;
  249. if(!$status) {
  250. $code = 'queued';
  251. } else {
  252. $code = $status->status;
  253. }
  254. $data = [
  255. 'source' => $webmention->source,
  256. 'target' => $webmention->target,
  257. 'status' => $code,
  258. ];
  259. if($webmention->webmention_endpoint) {
  260. $data['type'] = 'webmention';
  261. $data['endpoint'] = $webmention->webmention_endpoint;
  262. }
  263. if($webmention->pingback_endpoint) {
  264. $data['type'] = 'pingback';
  265. $data['endpoint'] = $webmention->pingback_endpoint;
  266. }
  267. switch($code) {
  268. case 'queued':
  269. $summary = 'The webmention is still in the processing queue';
  270. break;
  271. case 'not_supported':
  272. $summary = 'No webmention or pingback endpoint were found at the target';
  273. break;
  274. case 'accepted':
  275. $summary = 'The '.$data['type'].' request was accepted';
  276. break;
  277. default:
  278. $summary = false;
  279. }
  280. if($status && $status->http_code)
  281. $data['http_code'] = (int)$status->http_code;
  282. if($status && $status->raw_response) {
  283. $data['http_body'] = $status->raw_response;
  284. }
  285. if($summary)
  286. $data['summary'] = $summary;
  287. if($webmention->complete == 0)
  288. $data['location'] = $statusURL;
  289. return $this->respond($response, 200, $data);
  290. }
  291. }