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->headers->set('Content-Type', 'application/json'); $response->setContent(json_encode($params, JSON_UNESCAPED_SLASHES+JSON_PRETTY_PRINT)); 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 or csrf parameter if($csrf=$request->get('_csrf')) { session_start(); if(!isset($_SESSION['_csrf']) || $csrf != $_SESSION['_csrf']) { return $this->respond($response, 401, [ 'error' => 'invalid_csrf_token', 'error_description' => 'An error occurred. Make sure you have only one tab open.', ]); } } else if(!$token=$request->get('token')) { return $this->respond($response, 401, [ 'error' => 'authentication_required', 'error_description' => 'A token is required to use the API' ]); } else { # 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' ]); } } # Require source and target or target_domain parameters $target = $target_domain = null; if((!$source=$request->get('source')) || ((!$target=$request->get('target')) && (!$target_domain=$request->get('target_domain')))) { return $this->respond($response, 400, [ 'error' => 'missing_parameters', 'error_description' => 'The source or target or target_domain parameters were missing' ]); } if($target && $target_domain) { return $this->respond($response, 400, [ 'error' => 'invalid_parameter', 'error_description' => 'Can\'t provide both target and target_domain together' ]); } # Can only use source & target if no authentication(role) is set if(!isset($role)) { if($target_domain) { return $this->respond($response, 400, [ 'error' => 'unauthorized', 'error_description' => 'Can only use the target_domain feature when providing a token from the API', ]); } } $urlregex = '/^https?:\/\/[^ ]+\.[^ ]+$/'; $domainregex = '/^[^ ]+$/'; # Verify source, target, and callback are URLs $callback = $request->get('callback'); if(!preg_match($urlregex, $source) || (!preg_match($urlregex, $target) && !preg_match($domainregex, $target_domain)) || ($callback && !preg_match($urlregex, $callback))) { return $this->respond($response, 400, [ 'error' => 'invalid_parameter', 'error_description' => 'The source, target, or callback parameters were invalid' ]); } # Don't send anything if the source domain matches the target domain # The problem is someone pushing to Superfeedr who is also subscribed, will cause a # request to be sent with the source of one of their posts, and their own target domain. # This causes a whole slew of webmentions to be queued up, almost all of which are not needed. if($target_domain) { $source_domain = parse_url($source, PHP_URL_HOST); if($target_domain == $source_domain) { # Return 200 so Superfeedr doesn't think something is broken return $this->respond($response, 200, [ 'error' => 'not_supported', 'error_description' => 'You cannot use the target_domain feature to send webmentions to the same domain as the source URL' ]); } } # Check the blacklist of domains that are known to not accept webmentions if($target && !Telegraph\Webmention::isProbablySupported($target)) { return $this->respond($response, 400, [ 'error' => 'not_supported', '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' ]); } # If there is no code given, # 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. if($request->get('code')) { # target URL is required if(!$target) { return $this->respond($response, 400, [ 'error' => 'not_supported', 'error_description' => 'The target_domain parameter is not supported for sending private webmentions' ]); } $found[$target] = null; } else { $sourceData = $this->http->get($source, ['Accept: text/html, */*']); $doc = new DOMDocument(); libxml_use_internal_errors(true); # suppress parse errors and warnings @$doc->loadHTML(self::toHtmlEntities($sourceData['body']), LIBXML_NOWARNING|LIBXML_NOERROR); libxml_clear_errors(); 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 = []; $links = []; foreach($xpath->query('//a[@href]') as $href) { $url = $href->getAttribute('href'); if($target) { $links[] = $url; # target parameter was provided if($url == $target) { $found[$url] = null; } } elseif($target_domain) { # target_domain parameter was provided $domain = parse_url($url, PHP_URL_HOST); if($domain && ($domain == $target_domain || str_ends_with($domain, '.' . $target_domain))) { $found[$url] = null; } } } 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 or domain', 'links' => $links ]); } } # Write the webmention to the database and queue a job to start sending $statusURLs = []; foreach($found as $url=>$_) { $w = ORM::for_table('webmentions')->create(); $w->site_id = isset($role) ? $role->site_id : 0; $w->created_by = isset($role) ? $role->user_id : 0; $w->created_at = date('Y-m-d H:i:s'); $w->token = self::generateStatusToken(); $w->source = $source; $w->target = $url; $w->vouch = $request->get('vouch'); $w->code = $request->get('code'); $w->realm = $request->get('realm'); $w->callback = $callback; $w->save(); q()->queue('Telegraph\Webmention', 'send', [$w->id]); $statusURLs[] = Config::$base . 'webmention/' . $w->token; } if($target) { $body = [ 'status' => 'queued', 'location' => $statusURLs[0] ]; $headers = ['Location' => $statusURLs[0]]; } else { $body = [ 'status' => 'queued', 'location' => $statusURLs ]; $headers = []; } if($request->get('_redirect') == 'true') { $response->setStatusCode(302); $response->headers->set('Location', $body['location'].'/details'); return $response; } else { return $this->respond($response, 201, $body, $headers); } } public function superfeedr_tracker(Request $request, Response $response, $args) { logger()->addInfo("Got payload from superfeedr: " . $request->getContent()); $input = json_decode($request->getContent(), true); # Require the code parameter if(!$token=$args['token']) { return $this->respond($response, 401, [ 'error' => 'authentication_required', 'error_description' => 'A token is required to use the API' ]); } # 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' ]); } $site = ORM::for_table('sites')->where('id', $role->site_id)->find_one(); if(is_array($input) && array_key_exists('items', $input) && ($items = $input['items']) && is_array($items) && array_key_exists(0, $items) && ($item = $items[0]) && array_key_exists('permalinkUrl', $item)) { $url = $item['permalinkUrl']; $domain = parse_url($site->url, PHP_URL_HOST); # Create a new request that looks like a request to the API with a target_domain parameter $new_request = new Request(['token' => $token, 'source' => $url, 'target_domain' => $domain]); return $this->webmention($new_request, $response); } else { return $this->respond($response, 200, [ 'error' => 'invalid_request', 'error_description' => 'Could not find source URL from the superfeedr payload' ]); } } public function webmention_status(Request $request, Response $response, $args) { $webmention = ORM::for_table('webmentions')->where('token', $args['code'])->find_one(); if(!$webmention) { return $this->respond($response, 404, [ 'status' => 'not_found', ]); } $status = ORM::for_table('webmention_status')->where('webmention_id', $webmention->id)->order_by_desc('created_at')->find_one(); $statusURL = Config::$base . 'webmention/' . $webmention->token; if(!$status) { $code = 'queued'; } else { $code = $status->status; } $data = [ 'source' => $webmention->source, 'target' => $webmention->target, 'status' => $code, ]; if($webmention->webmention_endpoint) { $data['type'] = 'webmention'; $data['endpoint'] = $webmention->webmention_endpoint; } if($webmention->pingback_endpoint) { $data['type'] = 'pingback'; $data['endpoint'] = $webmention->pingback_endpoint; } switch($code) { case 'queued': $summary = 'The webmention is still in the processing queue'; break; case 'not_supported': $summary = 'No webmention or pingback endpoint were found at the target'; break; case 'accepted': $summary = 'The '.$data['type'].' request was accepted'; break; default: $summary = false; } if($status && $status->http_code) $data['http_code'] = (int)$status->http_code; if($status && $status->raw_response) { $data['http_body'] = $status->raw_response; } if($summary) $data['summary'] = $summary; if($webmention->complete == 0) $data['location'] = $statusURL; return $this->respond($response, 200, $data); } }