logger = empty($log_path) ? new NullLogger() : new Logger($this->app_name); $this->token_session_key = $this->app_name.'access_token'; $this->token_redirect_after_auth = $this->app_name .'redirect_after_auth'; $handler = new RotatingFileHandler($log_path, 5, Logger::DEBUG, true); $this->logger->pushHandler($handler); $this->server_token = null; $this->logger->debug('__construct API'); if (isset($_SESSION[$this->token_session_key])) { $this->access_token = $_SESSION[$this->token_session_key]; $this->logger->debug('Access token in session'); } else { $this->logger->debug('No access token in session'); } if (!empty($client_secret)) { $this->client_secret = $client_secret; } if (!empty($client_id)) { $this->client_id = $client_id; } if (!empty($needed_scope)) { $this->needed_scope = $needed_scope; } if (!empty($app_name)) { $this->app_name = $app_name; } } /** * Internal function, parses out important information pnut.io adds * to the headers. Mostly taken from PHPnut */ protected function parseHeaders(string $response): string { // take out the headers // set internal variables // return the body/content $this->rate_limit = null; $this->rate_limit_remaining = null; $this->rate_limit_reset = null; $this->scope = []; $response = explode("\r\n\r\n", $response, 2); $headers = $response[0]; if ($headers === 'HTTP/1.1 100 Continue') { $response = explode("\r\n\r\n", $response[1], 2); $headers = $response[0]; } if (isset($response[1])) { $content = $response[1]; } else { $content = ''; } // this is not a good way to parse http headers // it will not (for example) take into account multiline headers // but what we're looking for is pretty basic, so we can ignore those shortcomings $this->headers = explode("\r\n", $headers); foreach ($this->headers as $header) { $header = explode(': ', $header, 2); if (count($header) < 2) { continue; } list($k,$v) = $header; switch ($k) { case 'X-RateLimit-Remaining': $this->rate_limit_remaining = (int)$v; break; case 'X-RateLimit-Limit': $this->rate_limit = (int)$v; break; case 'X-RateLimit-Reset': $this->rate_limit_reset = (int)$v; break; case 'X-OAuth-Scopes': $this->scope = (int)$v; $this->scopes = explode(',', (int)$v); break; case 'location': case 'Location': $this->redirectTarget = (int)$v; break; } } return $content; } public function makeRequest( string $method, string $end_point, array $parameters, string $content_type = 'application/x-www-form-urlencoded' ): string { $this->redirect_target = null; $this->meta = null; $method = strtoupper($method); $url = $this->api_url.$end_point; $this->logger->info("{$method} Request to {$url}"); $ch = curl_init($url); $headers = []; $use_server_token = false; if ($method !== 'GET') { // if they passed an array, build a list of parameters from it curl_setopt($ch, CURLOPT_POST, true); if (is_array($parameters) && $method !== 'POST-RAW') { $parameters = http_build_query($parameters); } curl_setopt($ch, CURLOPT_POSTFIELDS, $parameters); $headers[] = "Content-Type: ".$content_type; } if ($method !== 'POST' && $method !== 'POST-RAW') { curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method); } if ($method === 'GET' && isset($parameters['access_token'])) { $headers[] = 'Authorization: Bearer '.$params['access_token']; } elseif (!empty($this->access_token)) { $headers[] = 'Authorization: Bearer '.$this->access_token; } elseif (!empty($this->server_token)) { $use_server_token = true; $this->logger->info("Using server token for auth"); $headers[] = 'Authorization: Bearer '.$this->server_token; } #$this->logger->info("Access token: ".$this->access_token); #$this->logger->info("Headers: ".json_encode($headers)); curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLINFO_HEADER_OUT, true); curl_setopt($ch, CURLOPT_HEADER, true); curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); $response = curl_exec($ch); $request = curl_getinfo($ch, CURLINFO_HEADER_OUT); $http_status = curl_getinfo($ch, CURLINFO_HTTP_CODE); $effectiveURL = curl_getinfo($ch, CURLINFO_EFFECTIVE_URL); curl_close($ch); if ($http_status === 0) { throw new Exception('Unable to connect to Pnut ' . $url); } if ($request === false) { if (!curl_getinfo($ch, CURLINFO_SSL_VERIFYRESULT)) { throw new Exception('SSL verification failed, connection terminated: ' . $url); } } if (!empty($response)) { $response = $this->parseHeaders($response); if (!empty($response)) { $response = json_decode($response, true); try { $this->meta = new Meta($response); } catch (NotAuthorizedException $nae) { $headers_string = print_r($this->headers, true); $this->logger->error("Error, not authorized: {$nae->getMessage()}"); $this->logger->error("Headers: {$headers_string}"); # Force re-auth if (!$use_server_token) { $this->logout(); } throw $nae; } catch (PnutException $pe) { $response_headers_string = print_r($this->headers, true); $request_headers_string = print_r($headers, true); $parameters_string = print_r($parameters, true); $this->logger->error("Error, no authorized: {$pe->getMessage()}"); $this->logger->error("Method: {$method}"); $this->logger->error("Parameters: {$parameters_string}"); $this->logger->error("Request headers: {$request_headers_string}"); $this->logger->error("Response headers: {$response_headers_string}"); throw $pe; } // look for errors if (isset($response['error'])) { if (is_array($response['error'])) { throw new PnutException($response['error']['message'], $response['error']['code']); } else { throw new PnutException($response['error']); } // look for response migration errors } elseif (isset($response['meta'], $response['meta']['error_message'])) { throw new PnutException($response['meta']['error_message'], $response['meta']['code']); } } } if ($http_status < 200 || $http_status >= 300) { if ($http_status == 302) { #echo json_encode(preg_match_all('/^Location:(.*)$/mi', $response, $matches)); throw new HttpPnutRedirectException($response); } else { throw new HttpPnutException('HTTP error '.$http_status); } } elseif (isset($response['meta'], $response['data'])) { return $response['data']; } elseif (isset($response['access_token'])) { return $response; } elseif (!empty($this->redirect_target)) { return $this->redirect_target; } else { throw new PnutException("No response ".json_encode($response).", http status: ${http_status}"); } } public function post( string $end_point, array $parameters, string $content_type = 'application/x-www-form-urlencoded' ): string { $method = $content_type === 'multipart/form-data' ? 'POST-RAW' : 'POST'; return $this->make_request($method, $end_point, $parameters, $content_type); } public function get( string $end_point, array $parameters = [], string $content_type = 'application/json' ): string { if (!empty($parameters)) { $end_point .= '?'.http_build_query($parameters); $parameters = []; } return $this->make_request('get', $end_point, $parameters, $content_type); } public function getAuthURL() { $url = $this->auth_url . '?client_id=' . $this->client_id . '&redirect_uri=' . urlencode($this->redirect_uri) . '&scope='.$this->needed_scope . '&response_type=code'; $this->logger->debug('Auth URL: ' . $url); return $url; } //TODO: Ping server and validate token public function isAuthenticated(bool $allow_server_token = false): bool { $is_authenticated = ($allow_server_token && !empty($this->server_token)) || isset($this->access_token); $log_str = $is_authenticated ? 'Authenticated' : 'Not authenticated'; $this->logger->info( "Checking auth status for app: {$this->app_name}: {$log_str}" ); $this->logger->info('Referrer: '.($_SERVER['HTTP_REFERER'] ?? 'Unknown')); $_SESSION[$this->token_redirect_after_auth] = $_SERVER['HTTP_REFERER']; return $is_authenticated; } public function authenticate(string $auth_code): bool { $this->logger->debug("Authenticating: {$auth_code}"); $parameters = [ 'client_id' => $this->client_id, 'client_secret' => $this->client_secret, 'code' => $auth_code, 'redirect_uri' => $this->redirect_uri, 'grant_type'=> 'authorization_code' ]; $resp = $this->post( '/oauth/access_token', $parameters, 'application/x-www-form-urlencoded' ); if ($resp === null || !isset($resp['access_token'])) { $this->logger->error("No access token ".json_encode($resp)); return false; } else { $this->logger->debug('Received access token ' . $resp['access_token']); $_SESSION[$this->token_session_key] = $resp['access_token']; $this->logger->debug('Saved access token'); $this->access_token = $resp['access_token']; return true; } } public function logout() { unset($_SESSION[$this->token_session_key]); $this->access_token = null; } // TODO public function getPostsForUser(User $user, $count = null) { } // TODO public function getPostsForUsername(string $username, int $count = 0) { /* if(!$this->isAuthenticated()) { throw new NotAuthorizedException("Cannot retrieve posts, "); } */ if (mb_substr($username, 0, 1) !== '@') { $username = '@'.$username; } $params = []; if ($count > 0) { $params['count'] = $count; } $posts = $this->get('/users/' . $username . '/posts', $params); $p = []; foreach ($posts as $post) { $p[] = new Post($post); } var_dump($p); } public function searchPosts( array $args, bool $order_by_id = true, int $count = 0 ): array { if ($order_by_id) { $args['order'] = 'id'; } if ($count > 0) { $args['count'] = $count; } $post_obj = []; /* * Stop fetching if: * - count($posts) >= $count and $count != 0 * - OR: meta['more'] is false */ do { $posts = $this->get('/posts/search', $args); if ($this->meta->more) { $args['before_id'] = $this->meta->min_id; } foreach ($posts as $post) { $post_obj[] = new Post($post); } } while ($this->meta != null && $this->meta->more && (count($post_obj) < $count || $count !== 0)); return $post_obj; } // TODO Maybe support additional polls? public function getPollsFromUser(int $user_id, array $params = []): array { $parameters = [ 'raw_types' => 'io.pnut.core.poll-notice', 'creator_id' => $user_id, 'include_deleted' => false, 'include_client' => false, 'include_counts' => false, 'include_html' => false, 'include_mention_posts' => false, 'include_copy_mentions' => false, 'include_post_raw' => true ]; foreach ($params as $param => $value) { $parameters[$param] = $value; } $response = $this->get('posts/search', $parameters); if (count($response) === 0) { return []; } $polls = []; foreach ($response as $post) { if (!empty($post['raw'])) { foreach ($post['raw'] as $raw) { if (Poll::$notice_type === $raw['type']) { $polls[] = $this->getPoll($raw['value']['poll_id']); } } } } return $polls; } public function getPoll(int $poll_id): Poll { return new Poll($this->get('/polls/' . $poll_id)); } public function getAuthorizedUser(): User { return $this->getUser('/me'); } public function getUser(int $user_id, array $args = []) { return new User($this->get('/users/'.$user_id, $args)); } public function getPost(int $post_id, array $args = []) { if (!empty($this->access_token)) { #$this->logger->info("AT:".$this->access_token); } else { $this->logger->info("No AT"); } // Remove in production again try { $p = new Post($this->get('/posts/'.$post_id, $args)); $this->logger->debug(json_encode($p)); return $p; } catch (NotAuthorizedException $nae) { $this->logger->warning( 'NotAuthorizedException when getting post, trying without access token' ); //try again not authorized $r = $this->make_request( '/get', '/posts/' . $post_id, $args, 'application/json', true ); return new Post($r); } } public function getAvatar(int $user_id, array $args = []): string { return $this->get('/users/'.$user_id.'/avatar', $args); } public function updateAvatar( string $file_path, ?string $filename = null, ?string $content_type = null ): User { if (empty($content_type)) { $content_type = mime_content_type($file_path); } if (empty($filename)) { $filename = basename($file_path); } $cf = new \CURLFile($file_path, $content_type, $filename); $parameters = ['avatar' => $cf]; return new User( $this->post('/users/me/avatar', $parameters, 'multipart/form-data') ); } public function updateAvatarFromUploaded($uploaded_file): User { $filename = $uploaded_file['name']; $filedata = $uploaded_file['tmp_name']; $filetype = $uploaded_file['type']; return $this->updateAvatar($filedata, $filename, $filetype); } public function replyToPost( string $text, int $reply_to, bool $is_nsfw = false, bool $auto_crop = false ): Post { return createPost($text, $reply_to, $is_nsfw, $auto_crop); } public function createPost( string $text, bool $is_nsfw = false, bool $auto_crop = false, ?int $reply_to = null ): Post { $text = $auto_crop ? substr($text, 0, $this->getMaxPostLength()) : $text; $parameters = [ 'text' => $text, 'reply_to' => $reply_to, 'is_nsfw' => $is_nsfw, ]; return new Post($this->post('posts', $parameters)); } protected function fetchPnutSystemConfig() { $config = $this->get('/sys/config'); self::$POST_MAX_LENGTH = $config['post']['max_length']; //self::$POST_MAX_LENGTH_REPOST = $config['post']['repost_max_length']; self::$POST_MAX_LENGTH_REPOST = self::$POST_MAX_LENGTH; self::$POST_SECONDS_BETWEEN_DUPLICATES = $config['post']['seconds_between_duplicates']; self::$MESSAGE_MAX_LENGTH = $config['message']['max_length']; self::$RAW_MAX_LENGTH = $config['raw']['max_length']; self::$USER_DESCRIPTION_MAX_LENGTH = $config['user']['description_max_length']; self::$USER_USERNAME_MAX_LENGTH = $config['user']['username_max_length']; $this->logger->info('-----------Pnut API config-----------'); $this->logger->info(''); $this->logger->info("Max post length: ".self::$POST_MAX_LENGTH); $this->logger->info("Max repost length: ".self::$POST_MAX_LENGTH_REPOST); $this->logger->info("Seconds between post duplicates: ".self::$POST_SECONDS_BETWEEN_DUPLICATES); $this->logger->info("Max raw length: ".self::$RAW_MAX_LENGTH); $this->logger->info("Max user description length: ".self::$USER_DESCRIPTION_MAX_LENGTH); $this->logger->info("Max username length: ".self::$USER_USERNAME_MAX_LENGTH); $this->logger->info('--------------------------------------'); } public function getMaxPostLength(): int { if (empty(self::$POST_MAX_LENGTH)) { $this->fetchPnutSystemConfig(); } return self::$POST_MAX_LENGTH; } public function authenticateServerToken() { $token = $this->getServerToken(); $this->server_token = $token; $this->logger->info("ST:".$this->server_token); } protected function getServerToken(): string { $this->logger->info('Requesting server access token from pnut'); $params = [ 'client_id' => $this->client_id, 'client_secret' => $this->client_secret, 'grant_type' => 'client_credentials' ]; $resp = $this->post('oauth/access_token', $params); if (!empty($resp['access_token'])) { $this->logger->info(json_encode($resp)); return $resp['access_token']; } else { throw new PnutException("Error retrieving app access token: ".json_encode($resp)); } } }