├── .gitignore ├── LICENSE.md ├── README.md ├── app ├── Collector.class.php ├── Consumer.class.php ├── Getter.class.php └── Universe.class.php ├── collect.php ├── config-sample.php ├── consume.php ├── examples └── getItems.php ├── fetch.php ├── init.php └── lib ├── Curl.class.php ├── DbHandle.class.php ├── Logger.class.php ├── Metadata.class.php ├── Phirehose ├── OAuthPhirehose.class.php ├── Phirehose.class.php └── gpl.txt ├── TwitterHandle.class.php ├── TwitterOAuth ├── LICENSE ├── OAuth.class.php └── TwitterOAuth.class.php └── UrlExpander.class.php /.gitignore: -------------------------------------------------------------------------------- 1 | # OS generated files # 2 | ###################### 3 | .DS_Store 4 | .DS_Store? 5 | ._* 6 | .Spotlight-V100 7 | .Trashes 8 | Icon? 9 | ehthumbs.db 10 | Thumbs.db -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2011-2013 President and Fellows of Harvard College 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # OpenFuego 2 | 3 | OpenFuego is the open-source version of [Fuego](http://www.niemanlab.org/fuego), a Twitter bot created to track the future-of-journalism crowd and the links they’re sharing. 4 | 5 | OpenFuego is essentially “Fuego in a box,” allowing you to monitor your own universe of people on Twitter — celebrities, tech writers, film critics, Canadians, or just people you like. 6 | 7 | Fuego and OpenFuego were created by [Andrew Phelps](https://twitter.com/andrewphelps) for the [Nieman Journalism Lab](http://www.niemanlab.org/). It was influenced by [Hourly Press](http://www.theatlantic.com/technology/archive/2011/08/fuego-a-bot-that-turns-the-twitter-firehose-into-a-trickle/244355/), created by Steve Farrell and Lyn Headley. OpenFuego relies heavily on @fennb’s [Phirehose](https://github.com/fennb/phirehose) library and @abraham’s [twitteroauth](https://github.com/abraham/twitteroauth) library. 8 | 9 | ### How it works 10 | 11 | 1. __Curate.__ You select up to 15 Twitter users — authorities — to form the center of OpenFuego’s universe. 12 | 13 | 2. __Automate.__ OpenFuego follows those authorities, as well as all of the people they follow, up to a total of 5,000 sources. Each and every time one of those sources shares a link, OpenFuego captures it into a database with some simple analytics. OpenFuego is running in the background 24 hours a day. 14 | 15 | 3. __Query.__ You can query OpenFuego to determine which links are being talked about most in that universe. OpenFuego does some math to strike a good balance between quality and freshness, then returns a ranked list of URLs and metadata. 16 | 17 | ### Installation 18 | 19 | OpenFuego is a backend application that runs at the command line. There is nothing to look at! It’s up to you to build a cool webpage or app that does something with the data. To see a real example, visit [Fuego](http://www.niemanlab.org/fuego). 20 | 21 | Follow the instructions in config.php. Create a MySQL database and enter the credentials in that file, along with Twitter credentials and optional (but recommended) API keys for Bitly, Goo.gl, and Embed.ly. 22 | 23 | ### Usage 24 | 25 | Once config.php is edited, run `fetch.php` at the command line. You may or not get further instructions, depending on whether your version of PHP is compiled with process control. 26 | 27 | __Recommended option for new users:__ To run OpenFuego in verbose mode, which displays helpful errors and warnings on screen, run `fetch.php -v`. 28 | 29 | You can `kill` the two processes at any time. The script may take a few seconds to clean up before terminating. Always make sure to kill any and all old OpenFuego processes before initializing. 30 | 31 | Include init.php at the top of any script that queries OpenFuego. See examples/getLinks.php for a dead-simple example and more instructions. 32 | 33 | ### Requirements and notes 34 | 35 | OpenFuego is PHP. It requires PHP 5.3.0 or higher, MySQL 5.0 or higher, and a *nix environment. In many cases the program won’t work in shared hosting environments and you’ll need root access. This is because OpenFuego is designed to run continuously in the background, like a daemon. (If you know much about programming, you know PHP is a bad language for this type of program. PHP is what I knew when I first sat down to write the program, and by the time it became big and complex, it would have been too much work to learn a different language and start from scratch.) 36 | 37 | OpenFuego is really three discrete programs: the Collector, the Consumer, and the Getter. The Collector is constantly connected to [Twitter’s streaming API](https://dev.twitter.com/docs/streaming-apis), saving all new tweets to temporary files on disk. The Consumer is constantly parsing those files, extracting the URLs, cleaning them up, and saving them to a database. The Collector and the Consumer run concurrently in separate processes. The Getter is a class that retrieves URLs from the database and does some math to tell you what’s most popular in a given timeframe. If you specify an Embed.ly API key, the Getter can optionally return fully hydrated metadata for the URLs. 38 | 39 | --- 40 | 41 | ### About Nieman Journalism Lab 42 | 43 | The [Nieman Journalism Lab](http://www.niemanlab.org/) ([@niemanlab](https://twitter.com/niemanlab)) is a collaborative attempt to figure out how quality journalism can survive and thrive in the Internet age. It’s a project of the [Nieman Foundation for Journalism](http://www.nieman.harvard.edu) at Harvard University. 44 | -------------------------------------------------------------------------------- /app/Collector.class.php: -------------------------------------------------------------------------------- 1 | queueDir = $queueDir; 33 | $this->rotateInterval = $rotateInterval; 34 | $this->_pcntlEnabled = function_exists('pcntl_signal_dispatch') ? TRUE : FALSE; 35 | 36 | // Call parent constructor 37 | return parent::__construct($token, $secret, \Phirehose::METHOD_FILTER); 38 | } 39 | 40 | /** 41 | * Enqueue each status 42 | * 43 | * @param string $status 44 | */ 45 | public function enqueueStatus($status) { 46 | 47 | // Write the status to the stream (must be via getStream()) 48 | fputs($this->getStream(), $status . PHP_EOL); 49 | 50 | /* Are we due for a file rotate? Note this won't be called if there are no statuses coming through. 51 | */ 52 | $now = time(); 53 | if (($now - $this->lastRotated) > $this->rotateInterval) { 54 | // Mark last rotation time as now 55 | $this->lastRotated = $now; 56 | 57 | // Rotate it 58 | $this->rotateStreamFile(); 59 | } 60 | } 61 | 62 | /** 63 | * Returns a stream resource for the current file being written/enqueued to 64 | * 65 | * @return resource 66 | */ 67 | private function getStream() { 68 | 69 | // Check for SIGTERM to shut down gracefully 70 | if ($this->_pcntlEnabled == TRUE) { 71 | $this->handleSignals(); 72 | } 73 | 74 | // If we have a valid stream, return it 75 | if (is_resource($this->statusStream)) { 76 | return $this->statusStream; 77 | } 78 | 79 | // If it's not a valid resource, we need to create one 80 | if (!is_dir($this->queueDir) || !is_writable($this->queueDir)) { 81 | throw new Exception('Unable to write to queueDir: ' . $this->queueDir); 82 | } 83 | 84 | // Construct stream file name, log and open 85 | $this->streamFile = $this->queueDir . '/' . self::QUEUE_FILE_ACTIVE; 86 | // $this->log('Opening new active status stream: ' . $this->streamFile); 87 | $this->statusStream = fopen($this->streamFile, 'a'); // Append if present (crash recovery) 88 | 89 | // Okay? 90 | if (!is_resource($this->statusStream)) { 91 | throw new Exception('Unable to open stream file for writing: ' . $this->streamFile); 92 | } 93 | 94 | // If we don't have a last rotated time, it's effectively now 95 | if ($this->lastRotated == NULL) { 96 | $this->lastRotated = time(); 97 | } 98 | 99 | // Looking good, return the resource 100 | return $this->statusStream; 101 | } 102 | 103 | /** 104 | * Rotates the stream file if due 105 | */ 106 | private function rotateStreamFile() { 107 | // Close the stream 108 | fclose($this->statusStream); 109 | 110 | // Create queue file with timestamp so they're both unique and naturally ordered 111 | $queueFile = $this->queueDir . '/' . self::QUEUE_FILE_PREFIX . '.' . date('Ymd-His') . '.queue'; 112 | 113 | // Do the rotate 114 | rename($this->streamFile, $queueFile); 115 | 116 | // Did it work? 117 | if (!file_exists($queueFile)) { 118 | throw new Exception('Failed to rotate queue file to: ' . $queueFile); 119 | } 120 | 121 | // At this point, all looking good - the next call to getStream() will create a new active file 122 | // $this->log('Successfully rotated active stream to queue file: ' . $queueFile) . "\n"; 123 | } 124 | 125 | protected function log($message, $level = 'notice') { 126 | 127 | } 128 | 129 | 130 | public function handleSignals() { 131 | 132 | pcntl_signal_dispatch(); 133 | 134 | global $_should_stop; 135 | 136 | if (isset($_should_stop) && $_should_stop == TRUE) { 137 | exit(); 138 | } 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /app/Consumer.class.php: -------------------------------------------------------------------------------- 1 | _queueDir = $queueDir; 24 | $this->_filePattern = $filePattern; 25 | $this->_checkInterval = $checkInterval; 26 | $this->_pcntlEnabled = function_exists('pcntl_signal_dispatch') ? TRUE : FALSE; 27 | 28 | // Sanity checks 29 | if (!is_dir($queueDir)) { 30 | throw new ErrorException('Invalid directory: ' . $queueDir); 31 | } 32 | } 33 | 34 | /** 35 | * Method that actually starts the processing task (never returns) 36 | */ 37 | public function process() { 38 | 39 | // Init some things 40 | $lastCheck = 0; 41 | 42 | // Infinite loop 43 | while (TRUE) { 44 | 45 | // Keep the DB tidy. Remove entries older than EXPIRATION_DAYS days. 46 | $this->cleanUp(); 47 | 48 | // Get a list of queue files 49 | $queueFiles = glob($this->_queueDir . '/' . $this->_filePattern); 50 | $lastCheck = time(); 51 | 52 | Logger::debug('Found ' . count($queueFiles) . ' queue files to process...'); 53 | 54 | // Iterate over each file (if any) 55 | foreach ($queueFiles as $queueFile) { 56 | $this->processQueueFile($queueFile); 57 | } 58 | 59 | // Check for SIGTERM to shut down gracefully 60 | if ($this->_pcntlEnabled == TRUE) { 61 | $this->handleSignals(); 62 | } 63 | 64 | // Wait until ready for next check 65 | Logger::debug('Sleeping...'); 66 | while (time() - $lastCheck < $this->_checkInterval) { 67 | sleep(1); 68 | } 69 | } // Infinite loop 70 | 71 | } // End process() 72 | 73 | /** 74 | * Processes a queue file and does something with it (example only) 75 | * @param string $queueFile The queue file 76 | */ 77 | protected function processQueueFile($queueFile) { 78 | Logger::debug('Processing file: ' . $queueFile); 79 | 80 | // Open file 81 | $fp = fopen($queueFile, 'r'); 82 | 83 | // Check if something has gone wrong, or perhaps the file is just locked by another process 84 | if (!is_resource($fp)) { 85 | Logger::error('WARN: Unable to open file or file already open: ' . $queueFile . ' - Skipping.'); 86 | return FALSE; 87 | } 88 | 89 | // Lock file 90 | flock($fp, LOCK_EX); 91 | 92 | // Loop over each line (1 line per status) 93 | $statusCounter = 0; 94 | while ($rawStatus = fgets($fp, 8192)) { 95 | 96 | $statusCounter++; 97 | 98 | $status = json_decode($rawStatus, TRUE); // convert JSON data into PHP array 99 | 100 | // if data is invalid (e.g., if a user has deleted a tweet; surprisingly frequent) 101 | if (is_array($status) == FALSE || !isset($status['user']['id_str'])) { 102 | Logger::debug('Status is invalid, continuing.'); 103 | continue; // skip it 104 | } 105 | 106 | if (array_key_exists(0, $status['entities']['urls']) == FALSE) { // if tweet does not contain link 107 | continue; // skip it 108 | } 109 | 110 | /* Weed out statuses created by undesired user. (The streaming API also returns _retweets of_ 111 | ** statuses by desired user, which we don't want.) */ 112 | if (!\OpenFuego\app\Universe::isCitizen($status['user']['id_str'])) { // if the tweeter is not a citizen 113 | continue; // skip it 114 | } 115 | 116 | $this->processUrls($status); 117 | 118 | Logger::debug('Decoded tweet: ' . $status['user']['screen_name'] . ': ' . urldecode($status['text'])); 119 | 120 | set_time_limit(60); 121 | 122 | unset($status, $entities); 123 | } // End while 124 | 125 | // Release lock and close 126 | flock($fp, LOCK_UN); 127 | fclose($fp); 128 | 129 | // All done with this file 130 | Logger::debug('Successfully processed ' . $statusCounter . ' tweets from ' . $queueFile . ' - deleting.'); 131 | unset($rawStatus); 132 | unlink($queueFile); 133 | } 134 | 135 | 136 | protected function processUrls($status) { 137 | 138 | $dbh = $this->getDbh(); 139 | 140 | if (!$this->_urlExpander) { 141 | $this->_urlExpander = new UrlExpander(); 142 | } 143 | 144 | $urlExpander = $this->_urlExpander; 145 | 146 | $urls = $status['entities']['urls']; 147 | 148 | foreach($urls as $url) { 149 | 150 | $expanded_url = $url['expanded_url']; 151 | 152 | $output_url = $urlExpander->expand($expanded_url); // sometimes "expanded url" returned by t.co is a bitly link, etc. 153 | $output_url = rtrim($output_url, '/'); 154 | 155 | $first_seen = $status['created_at']; 156 | $first_seen = strtotime($first_seen); 157 | $first_seen = date('Y-m-d H:i:s', $first_seen); 158 | 159 | $first_tweet = $status['id_str']; 160 | 161 | $first_user = $status['user']['screen_name']; 162 | 163 | $first_user_id = $status['user']['id_str']; 164 | 165 | $weighted_count = Universe::getInfluence($first_user_id); 166 | 167 | try { 168 | $sql = "INSERT INTO openfuego_links ( 169 | url, 170 | first_seen, 171 | first_tweet, 172 | first_user, 173 | first_user_id, 174 | weighted_count, 175 | count, 176 | last_seen 177 | ) 178 | VALUES ( 179 | :url, 180 | :first_seen, 181 | :first_tweet, 182 | :first_user, 183 | :first_user_id, 184 | :weighted_count, 185 | 1, 186 | :first_seen 187 | ) 188 | ON DUPLICATE KEY UPDATE 189 | weighted_count = CASE WHEN 190 | first_tweet != VALUES(first_tweet) 191 | AND first_user = VALUES(first_user) 192 | THEN 193 | weighted_count 194 | ELSE 195 | weighted_count + VALUES(weighted_count) 196 | END, 197 | count = CASE WHEN 198 | first_tweet != VALUES(first_tweet) 199 | AND first_user = VALUES(first_user) 200 | THEN 201 | count 202 | ELSE 203 | count + 1 204 | END, 205 | last_seen = CASE WHEN 206 | first_tweet != VALUES(first_tweet) 207 | AND first_user = VALUES(first_user) 208 | THEN 209 | last_seen 210 | ELSE 211 | VALUES(last_seen) 212 | END;"; 213 | $sth = $dbh->prepare($sql); 214 | $sth->bindParam('url', $output_url); 215 | $sth->bindParam('first_seen', $first_seen); 216 | $sth->bindParam('first_tweet', $first_tweet); 217 | $sth->bindParam('first_user', $first_user); 218 | $sth->bindParam('first_user_id', $first_user_id); 219 | $sth->bindParam('weighted_count', $weighted_count); 220 | $sth->execute(); 221 | } catch (\PDOException $e) { 222 | echo 'PDO exception in ' . __FUNCTION__ . ', ' . date('Y-m-d H:i:s'), $e; 223 | continue; // on to the next url 224 | } 225 | } 226 | } 227 | 228 | 229 | public function cleanUp() { 230 | $expiration_days = \OpenFuego\EXPIRATION_DAYS; 231 | $now = time(); 232 | $date = date('Y-m-d H:i:s', $now); 233 | 234 | $dbh = $this->getDbh(); 235 | 236 | $sql = " 237 | DELETE FROM openfuego_links 238 | WHERE first_seen < DATE_SUB(:date, INTERVAL :expiration_int DAY); 239 | 240 | DELETE FROM openfuego_short_links 241 | WHERE last_seen < DATE_SUB(:date, INTERVAL :expiration_int DAY); 242 | "; 243 | $sth = $dbh->prepare($sql); 244 | $sth->bindParam('date', $date, \PDO::PARAM_INT); 245 | $sth->bindParam('expiration_int', $expiration_days, \PDO::PARAM_INT); 246 | $sth->execute(); 247 | 248 | return TRUE; 249 | } 250 | 251 | 252 | protected function getDbh() { 253 | if (!$this->_dbh) { 254 | $this->_dbh = new DbHandle(); 255 | } 256 | 257 | return $this->_dbh; 258 | } 259 | 260 | 261 | public function handleSignals() { 262 | 263 | pcntl_signal_dispatch(); 264 | 265 | global $_should_stop; 266 | 267 | if (isset($_should_stop) && $_should_stop == TRUE) { 268 | exit(); 269 | } 270 | } 271 | } -------------------------------------------------------------------------------- /app/Getter.class.php: -------------------------------------------------------------------------------- 1 | _dbh) { 12 | $this->_dbh = new DbHandle(); 13 | } 14 | 15 | return $this->_dbh; 16 | } 17 | 18 | public function getItems($quantity = 10, $hours = 24, $scoring = TRUE, $metadata = FALSE) { 19 | 20 | $now = time(); 21 | 22 | $quantity = (int)$quantity; 23 | $hours = (int)$hours; 24 | 25 | $date = date('Y-m-d H:i:s', $now); 26 | 27 | if ($scoring) { 28 | $min_weighted_count = floor($hours/2.5+8); 29 | $limit = 100; 30 | } else { 31 | $min_weighted_count = 1; 32 | $limit = $quantity; 33 | } 34 | 35 | try { 36 | $dbh = $this->getDbh(); 37 | $sql = " 38 | SELECT link_id, url, first_seen, first_user, weighted_count, count 39 | FROM openfuego_links 40 | WHERE weighted_count >= :min_weighted_count 41 | AND count > 1 42 | AND first_seen BETWEEN DATE_SUB(:date, INTERVAL :hours HOUR) AND :date 43 | ORDER BY weighted_count DESC 44 | LIMIT :limit; 45 | "; 46 | $sth = $this->_dbh->prepare($sql); 47 | $sth->bindParam('date', $date, \PDO::PARAM_STR); 48 | $sth->bindParam('hours', $hours, \PDO::PARAM_INT); 49 | $sth->bindParam('min_weighted_count', $min_weighted_count, \PDO::PARAM_INT); 50 | $sth->bindParam('limit', $limit, \PDO::PARAM_INT); 51 | $sth->execute(); 52 | 53 | } catch (\PDOException $e) { 54 | Logger::error($e); 55 | return FALSE; 56 | } 57 | 58 | $items = $sth->fetchAll(\PDO::FETCH_ASSOC); 59 | 60 | if (!$items) { 61 | return FALSE; 62 | } 63 | 64 | foreach ($items as $item) { 65 | 66 | $link_id = (int)$item['link_id']; 67 | 68 | $url = $item['url']; 69 | $weighted_count = $item['weighted_count']; 70 | $multiplier = NULL; 71 | $score = NULL; 72 | 73 | $first_seen = $item['first_seen']; 74 | $first_seen = strtotime($first_seen); 75 | $age = $now - $first_seen; 76 | $age = $age / 3600; // to get hours 77 | $age = round($age, 1); 78 | 79 | $first_user = $item['first_user']; 80 | 81 | if ($age < ($hours/6)) { $multiplier = 1.20-$age/$hours; } // freshness boost! 82 | elseif ($age >= ($hours/6) && $age < ($hours/2)) { $multiplier = 1.05-$age/$hours; } 83 | elseif ($age > ($hours/2)) { $multiplier = 1.01-$age/$hours; } 84 | 85 | $score = round($weighted_count * $multiplier); 86 | 87 | $items_filtered[] = array( 88 | 'link_id' => $link_id, 89 | 'url' => $url, 90 | 'weighted_count' => $weighted_count, 91 | 'first_seen' => $first_seen, 92 | 'first_user' => $first_user, 93 | 'age' => $age, 94 | 'multiplier' => $multiplier, 95 | 'score' => $score 96 | ); 97 | } 98 | 99 | $scores = array(); 100 | $ages = array(); 101 | foreach ($items_filtered as $key => $item) { 102 | $scores[$key] = $scoring ? $item['score'] : $item['weighted_count']; 103 | $ages[$key] = $item['age']; 104 | } 105 | 106 | /* 107 | foreach ($fuego_links_popular_filtered_rows as $key => $fuego_links_popular_filtered_row) { 108 | if ($scoring) { $a[$key] = $fuego_links_popular_filtered_row[6]; } // sort by score 109 | else { $a[$key] = $fuego_links_popular_filtered_row[2]; } // sort by count 110 | $b[$key] = $fuego_links_popular_filtered_row[4]; // then by age 111 | } 112 | 113 | array_multisort($a, SORT_DESC, $b, SORT_ASC, $fuego_links_popular_filtered_rows); 114 | */ 115 | 116 | array_multisort($scores, SORT_DESC, $ages, SORT_ASC, $items_filtered); // sort by score, then by age 117 | 118 | $items_filtered = array_slice($items_filtered, 0, $quantity); 119 | 120 | if ($metadata && defined('\OpenFuego\EMBEDLY_API_KEY') && \OpenFuego\EMBEDLY_API_KEY) { 121 | 122 | $metadata_params = is_array($metadata) ? $metadata : NULL; 123 | 124 | foreach ($items_filtered as $item_filtered) { 125 | $urls[] = $item_filtered['url']; 126 | } 127 | 128 | $link_meta = array(); 129 | $urls_chunked = array_chunk($urls, 20); // Embedly handles maximum 20 URLs per request 130 | foreach ($urls_chunked as $urls_chunk) { 131 | $link_meta_chunk = Metadata::instantiate()->get($urls_chunk, $metadata_params); 132 | $link_meta_chunk = json_decode($link_meta_chunk, TRUE); 133 | $link_meta = array_merge($link_meta, $link_meta_chunk); 134 | } 135 | unset($urls, $urls_chunked, $urls_chunk, $link_meta_chunk); 136 | } 137 | 138 | $row_count = count($items_filtered); 139 | 140 | foreach ($items_filtered as $key => &$item_filtered) { 141 | $link_id = $item_filtered['link_id']; 142 | $url = $item_filtered['url']; 143 | 144 | preg_match('@^(?:https?://)?([^/]+)@i', $url, $matches); 145 | $domain = $matches[1]; 146 | 147 | if (strlen($domain) > 24) { 148 | preg_match('/[^.]+\.[^.]+$/', $domain, $matches); 149 | $domain = $matches[0]; 150 | } 151 | 152 | $item_filtered['domain'] = $domain; 153 | 154 | $item_filtered['rank'] = $key + 1; 155 | 156 | $metadata = new Metadata(); 157 | $status = $metadata->getTweet($link_id); 158 | 159 | $tw_id_str = $status['id_str']; 160 | $tw_screen_name = $status['screen_name']; 161 | $tw_text = $status['text']; 162 | $tw_profile_image_url = $status['profile_image_url']; 163 | $tw_profile_image_url_bigger = null; 164 | $tw_tweet_url = null; 165 | 166 | if ($tw_profile_image_url && $tw_screen_name && $tw_id_str) { 167 | $tw_profile_image_url_bigger = str_replace('_normal.', '_bigger.', $tw_profile_image_url); 168 | $tw_tweet_url = 'https://twitter.com/' . $tw_screen_name . '/status/' . $tw_id_str; 169 | } 170 | 171 | $item_filtered['tw_id_str'] = $tw_id_str; 172 | $item_filtered['tw_screen_name'] = $tw_screen_name; 173 | $item_filtered['tw_text'] = $tw_text; 174 | $item_filtered['tw_profile_image_url'] = $tw_profile_image_url; 175 | $item_filtered['tw_profile_image_url_bigger'] = $tw_profile_image_url_bigger; 176 | $item_filtered['tw_tweet_url'] = $tw_tweet_url; 177 | 178 | if (isset($link_meta)) { 179 | $item_filtered['metadata'] = $link_meta[$key]; 180 | } 181 | 182 | } 183 | 184 | return $items_filtered; 185 | } 186 | } -------------------------------------------------------------------------------- /app/Universe.class.php: -------------------------------------------------------------------------------- 1 | = :min_influence; 26 | "; 27 | $sth = $dbh->prepare($sql); 28 | $sth->bindParam('min_influence', $min_influence); 29 | $sth->execute(); 30 | 31 | $user_ids = $sth->fetchAll(\PDO::FETCH_COLUMN); 32 | 33 | return $user_ids; 34 | 35 | } catch (\PDOException $e) { 36 | Logger::error($e); 37 | return FALSE; 38 | } 39 | } 40 | 41 | 42 | public function populate($authorities, $min_influence = 1) { 43 | 44 | if (count($authorities) > 15) { 45 | $error_message = __METHOD__ . " failed. The maximum number of authorities is 15. Dying."; 46 | Logger::fatal($error_message); 47 | die($error_message); 48 | } 49 | 50 | $owner_screen_name = \OpenFuego\TWITTER_SCREEN_NAME; 51 | 52 | $twitter = new TwitterHandle(); 53 | 54 | $authorities = implode(',', $authorities); 55 | $authorities = str_replace('@', '', $authorities); 56 | $authorities = $twitter->get('users/lookup', array('screen_name' => $authorities)); 57 | 58 | foreach ($authorities as $authority) { 59 | $authorities_ids[] = $authority['id_str']; 60 | } 61 | 62 | $universe_ids = $authorities_ids; 63 | 64 | foreach ($authorities as $authority) { 65 | $authority_friends_ids = $twitter->get('friends/ids', array('screen_name' => $authority['screen_name'])); 66 | 67 | if ($twitter->http_code != 200) { 68 | $error_message = __METHOD__ . " failed, Twitter error {$twitter->http_code}. Dying."; 69 | Logger::fatal($error_message); 70 | die(); 71 | } 72 | 73 | $authority_friends_ids = $authority_friends_ids['ids']; 74 | $universe_ids = array_merge($universe_ids, $authority_friends_ids); // append more ids to universe 75 | } 76 | 77 | $universe_ids_sorted = $this->array_most_common($universe_ids); 78 | 79 | unset($authority_friends_ids, $owner_screen_name, $twitter, $universe_ids); 80 | 81 | $dbh = self::getDbh(); 82 | $dbh->exec("TRUNCATE TABLE openfuego_citizens;"); 83 | $sql = "INSERT INTO openfuego_citizens (user_id, influence) VALUES (:user_id, :influence);"; 84 | $sth = self::$dbh->prepare($sql); 85 | 86 | foreach ($universe_ids_sorted as $key=>$value) { 87 | try { 88 | $sth->bindParam('user_id', $key, \PDO::PARAM_INT); 89 | $sth->bindParam('influence', $value, \PDO::PARAM_INT); 90 | $sth->execute(); 91 | } 92 | 93 | catch (\PDOException $e) { 94 | Logger::fatal($e); 95 | die(); 96 | } 97 | } 98 | 99 | return TRUE; 100 | } 101 | 102 | 103 | public static function isCitizen($user_id_str) { 104 | 105 | try { 106 | $dbh = self::getDbh(); 107 | $sth = self::$dbh->prepare("SELECT user_id FROM openfuego_citizens WHERE user_id = :user_id LIMIT 1;"); 108 | $sth->bindParam('user_id', $user_id_str); 109 | $sth->execute(); 110 | 111 | if ($sth->fetchColumn(0)) { 112 | return TRUE; 113 | } 114 | else { 115 | return FALSE; 116 | } 117 | 118 | } catch (\PDOException $e) { 119 | Logger::error($e); 120 | return FALSE; 121 | } 122 | } 123 | 124 | 125 | public static function getInfluence($user_id_str) { 126 | 127 | try { 128 | $dbh = self::getDbh(); 129 | $sql = "SELECT influence FROM openfuego_citizens WHERE user_id = :user_id LIMIT 1;"; 130 | $sth = self::$dbh->prepare($sql); 131 | $sth->bindParam('user_id', $user_id_str); 132 | $sth->execute(); 133 | 134 | $influence = $sth->fetchColumn(0); 135 | 136 | return $influence; 137 | 138 | } catch (\PDOException $e) { 139 | Logger::error($e); 140 | return FALSE; 141 | } 142 | } 143 | 144 | 145 | protected static function getDbh() { 146 | if (!self::$dbh) { 147 | self::$dbh = new DbHandle(); 148 | } 149 | 150 | return self::$dbh; 151 | } 152 | 153 | public function __destruct() { 154 | 155 | } 156 | } -------------------------------------------------------------------------------- /collect.php: -------------------------------------------------------------------------------- 1 | get("account/verify_credentials", array("include_entities" => 0, "skip_status" => 1)); 35 | if ($twitter->http_code !== 200) { 36 | $error_message = "Cannot continue. Your Twitter credentials appear to be invalid. Error code {$twitter->http_code}"; 37 | Logger::info($error_message); 38 | die($error_message); 39 | } 40 | unset($twitter_handle); 41 | 42 | $authorities = unserialize(\OpenFuego\AUTHORITIES); 43 | 44 | $universe = new Universe(); 45 | 46 | /** The next line is commented out by default. 47 | * Uncomment it to repopulate the universe on each fetch. */ 48 | 49 | // $universe->populate($authorities, 1); 50 | 51 | $citizens = $universe->getCitizens(1); 52 | 53 | if (!$citizens) { 54 | $universe->populate($authorities, 1); 55 | $citizens = $universe->getCitizens(1); 56 | } 57 | 58 | $citizens = array_slice($citizens, 0, TWITTER_PREDICATE_LIMIT); 59 | 60 | // Start streaming/collecting 61 | $collector = new Collector(TWITTER_OAUTH_TOKEN, TWITTER_OAUTH_SECRET); 62 | 63 | $collector->setFollow($citizens); 64 | 65 | $collector->consume(); 66 | 67 | exit; 68 | -------------------------------------------------------------------------------- /config-sample.php: -------------------------------------------------------------------------------- 1 | = 5.3.0 and MySQL 5. 9 | * Only POSIX operating systems (not Windows) are supported. 10 | * This program must be run from a command line. 11 | * 12 | * @author Andrew Phelps 13 | * @version 1.0 14 | */ 15 | 16 | namespace OpenFuego; 17 | 18 | /* Your time zone. Harvard time is 'America/New_York'. 19 | * http://php.net/manual/en/timezones.php 20 | **/ 21 | date_default_timezone_set('America/New_York'); 22 | 23 | /** Time to curate. 24 | * Specify the authorities at the center of the universe. 25 | * Minimum 1, maximum 15. No @ symbol necessary. 26 | **/ 27 | define(__NAMESPACE__ . '\AUTHORITIES', serialize( 28 | array( 29 | 'someHandle1', 30 | 'someHandle2', 31 | 'someHandle3', 32 | 'someHandle4', 33 | 'someHandle5', 34 | 'someHandle6', 35 | 'someHandle7', 36 | 'someHandle8', 37 | 'someHandle9', 38 | ) 39 | )); 40 | 41 | /** Your email address, for error reporting. Separate multiple addresses with commas. */ 42 | const WEBMASTER = 'xxxx@harvard.edu'; 43 | 44 | /** For how many days should links remain in the database? Default: 1. Must be an integer. */ 45 | const EXPIRATION_DAYS = 1; 46 | 47 | /** Your database credentials. 48 | * Only MySQL is supported, hipsters. */ 49 | const 50 | DB_NAME = 'xxxxx', 51 | DB_USER = 'xxxxx', 52 | DB_PASS = 'xxxxx', 53 | DB_HOST = 'localhost', // default: localhost 54 | DB_PORT = 3306; // default: 3306 55 | 56 | /** Your Twitter credentials. If you don't have any yet, visit https://dev.twitter.com and sign in. 57 | * Click "Create an app" and follow the instructions. Create an app with READ and WRITE access. 58 | * Then generate new access tokens. Don't share the secrets with anyone. 59 | **/ 60 | const 61 | TWITTER_SCREEN_NAME = 'xxxx', 62 | TWITTER_CONSUMER_KEY = 'xxxx', 63 | TWITTER_CONSUMER_SECRET = 'xxxx', 64 | TWITTER_OAUTH_TOKEN = 'xxxx-xxxx', 65 | TWITTER_OAUTH_SECRET = 'xxxx'; 66 | 67 | /** Your Bitly credentials. Visit this page to grab them: http://bitly.com/a/your_api_key 68 | * Optional, recommended. Leave blank to disable. 69 | **/ 70 | const 71 | BITLY_USERNAME = '', 72 | BITLY_API_KEY = ''; 73 | 74 | /** Your Goo.gl API credentials. Visit this page for instructions: 75 | * https://developers.google.com/url-shortener/v1/getting_started#APIKey 76 | * Optional, recommended. Leave blank to disable. 77 | **/ 78 | const GOOGL_API_KEY = ''; 79 | 80 | /** Your Embed.ly API credentials. Visit this page for a free account: http://embed.ly/embed/pricing 81 | * Optional, but required to return metadata with URLs. Leave blank to disable. 82 | **/ 83 | const EMBEDLY_API_KEY = ''; 84 | 85 | /** 86 | * All done, no more editing! Now run fetch.php at the command line. 87 | **/ 88 | ?> -------------------------------------------------------------------------------- /consume.php: -------------------------------------------------------------------------------- 1 | prepare($sql); 80 | $sth->execute(); 81 | 82 | } catch (\PDOException $e) { 83 | die($e); 84 | } 85 | 86 | $dbh = NULL; 87 | 88 | $consumer = new Consumer(); 89 | $consumer->process(); 90 | 91 | exit; 92 | -------------------------------------------------------------------------------- /examples/getItems.php: -------------------------------------------------------------------------------- 1 | getItems(20, 24, TRUE, TRUE); // quantity, hours, scoring, metadata 21 | 22 | print '
';
23 | print_r($items);
24 | print '
'; 25 | ?> -------------------------------------------------------------------------------- /fetch.php: -------------------------------------------------------------------------------- 1 | /dev/null 2> /dev/null & echo $!' 20 | . "\n" 21 | . "\tnohup " . \PHP_BINDIR . '/php ' . BASE_DIR . '/consume.php > /dev/null 2> /dev/null & echo $!' 22 | . "\n\n"; 23 | 24 | die($error_message); 25 | } 26 | 27 | // Ignore hangup signal (when user exits shell) 28 | pcntl_signal(SIGHUP, SIG_IGN); 29 | 30 | // Handle shutdown tasks 31 | pcntl_signal(SIGTERM, function() { 32 | 33 | global $_should_stop; 34 | $_should_stop = TRUE; 35 | 36 | Logger::info("Received shutdown request, finishing up."); 37 | 38 | return; 39 | }); 40 | 41 | $pids = array(); 42 | 43 | $pids[0] = pcntl_fork(); 44 | 45 | if (!$pids[0]) { 46 | include_once(__DIR__ . '/collect.php'); 47 | } 48 | 49 | $pids[1] = pcntl_fork(); 50 | 51 | if (!$pids[1]) { 52 | include_once(__DIR__ . '/consume.php'); 53 | } 54 | 55 | echo __NAMESPACE__ . ' collector running as PID ' . $pids[0] . "\n"; 56 | echo __NAMESPACE__ . ' consumer running as PID ' . $pids[1] . "\n"; 57 | 58 | @file_put_contents(\OpenFuego\TMP_DIR . '/OpenFuego-collect.pid', $pids[0]); 59 | @file_put_contents(\OpenFuego\TMP_DIR . '/OpenFuego-consume.pid', $pids[1]); 60 | 61 | exit; 62 | ?> -------------------------------------------------------------------------------- /init.php: -------------------------------------------------------------------------------- 1 | _headers = array( 19 | 'Expect:' 20 | ); 21 | 22 | /* Options common to all requests */ 23 | $this->_options = array( 24 | CURLOPT_USERAGENT => \OpenFuego\USER_AGENT, 25 | CURLOPT_REFERER => \OpenFuego\REFERRER, 26 | CURLOPT_CONNECTTIMEOUT => 15, 27 | CURLOPT_TIMEOUT => 15, 28 | CURLOPT_RETURNTRANSFER => TRUE, 29 | CURLOPT_FOLLOWLOCATION => TRUE, 30 | CURLOPT_AUTOREFERER => FALSE, 31 | CURLOPT_SSL_VERIFYPEER => FALSE, 32 | CURLOPT_ENCODING => '', // blank supports all encodings 33 | CURLOPT_HTTPHEADER => $this->_headers, 34 | CURLOPT_COOKIESESSION => TRUE 35 | ); 36 | 37 | // curl_setopt_array($this->_curlHandle, $this->_options); 38 | } 39 | 40 | 41 | public function getChunk($url) { 42 | 43 | $this->_curlHandle = curl_init($url); 44 | $ch = $this->_curlHandle; 45 | 46 | $curlData = ''; 47 | $limit = 8000; 48 | $writefn = function($ch, $chunk) use (&$curlData, $limit) { 49 | static $data = ''; 50 | 51 | $len = strlen($data) + strlen($chunk); 52 | if ($len >= $limit) { 53 | $data .= substr($chunk, 0, $limit-strlen($data)); 54 | $curlData = $data; 55 | return -1; 56 | } 57 | 58 | $data .= $chunk; 59 | return strlen($chunk); 60 | }; 61 | 62 | curl_setopt_array($this->_curlHandle, $this->_options); 63 | 64 | curl_setopt($this->_curlHandle, CURLOPT_RANGE, '0-8000'); 65 | curl_setopt($this->_curlHandle, CURLOPT_WRITEFUNCTION, $writefn); 66 | curl_setopt($this->_curlHandle, CURLOPT_HEADER, FALSE); 67 | curl_setopt($this->_curlHandle, CURLOPT_NOBODY, FALSE); 68 | 69 | curl_exec($ch); 70 | 71 | $this->error = curl_errno($this->_curlHandle); 72 | 73 | $curlData = $this->encode($curlData); 74 | 75 | $this->close(); 76 | 77 | return $curlData; 78 | } 79 | 80 | 81 | public function get($url) { 82 | 83 | $this->_curlHandle = curl_init($url); 84 | 85 | curl_setopt_array($this->_curlHandle, $this->_options); 86 | 87 | curl_setopt($this->_curlHandle, CURLOPT_HEADER, FALSE); 88 | curl_setopt($this->_curlHandle, CURLOPT_NOBODY, FALSE); 89 | 90 | $curlData = curl_exec($this->_curlHandle); 91 | 92 | $this->error = curl_errno($this->_curlHandle); 93 | 94 | if ($this->error > 0) { // 0 means no error 95 | Logger::error('cURL error getting ' . $url . ': ' . $this->error, 2); 96 | } 97 | 98 | $curlData = $this->encode($curlData); 99 | 100 | $this->close(); 101 | 102 | return $curlData; 103 | } 104 | 105 | 106 | protected function encode($curlData) { 107 | if (mb_detect_encoding($curlData, NULL, TRUE) == 'ASCII') { 108 | $curlData = utf8_encode($curlData); 109 | } 110 | 111 | return $curlData; 112 | } 113 | 114 | 115 | public function getLocation($url) { 116 | 117 | $this->_curlHandle = curl_init($url); 118 | 119 | curl_setopt_array($this->_curlHandle, $this->_options); 120 | 121 | curl_setopt($this->_curlHandle, CURLOPT_HEADER, TRUE); 122 | curl_setopt($this->_curlHandle, CURLOPT_NOBODY, TRUE); 123 | 124 | curl_exec($this->_curlHandle); 125 | 126 | $curlInfo = $this->getInfo(); 127 | $location = $curlInfo['url']; 128 | 129 | $location = $this->encode($location); 130 | 131 | $this->close(); 132 | 133 | return $location; 134 | } 135 | 136 | 137 | protected function getInfo() { 138 | return curl_getinfo($this->_curlHandle); 139 | } 140 | 141 | 142 | protected function close() { 143 | if (is_resource($this->_curlHandle)) { 144 | curl_close($this->_curlHandle); 145 | } 146 | } 147 | 148 | 149 | public function __destruct() { 150 | // $this->close(); 151 | } 152 | } -------------------------------------------------------------------------------- /lib/DbHandle.class.php: -------------------------------------------------------------------------------- 1 | "SET NAMES 'utf8' COLLATE 'utf8_unicode_ci';", 16 | \PDO::ATTR_PERSISTENT => true 17 | )); 18 | 19 | $this->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION); 20 | } 21 | 22 | catch (\PDOException $e) { 23 | Logger::error($e); 24 | } 25 | } 26 | 27 | public function __destruct() { 28 | // close db connection 29 | } 30 | } -------------------------------------------------------------------------------- /lib/Logger.class.php: -------------------------------------------------------------------------------- 1 | _apiRoot = 'http://api.embed.ly/1'; 13 | $this->_endpoint = defined('\OpenFuego\EMBEDLY_API_ENDPOINT') ? \OpenFuego\EMBEDLY_API_ENDPOINT : 'oembed'; 14 | if (defined('\OpenFuego\EMBEDLY_API_KEY')) { 15 | $this->_apiKey = \OpenFuego\EMBEDLY_API_KEY; 16 | } 17 | } 18 | 19 | 20 | static public function instantiate() { // This ain't too pretty. 21 | return new self(); 22 | } 23 | 24 | 25 | protected function curl($url) { 26 | $options = array( 27 | CURLOPT_URL => $url, 28 | CURLOPT_HEADER => FALSE, 29 | CURLOPT_CONNECTTIMEOUT => 30, 30 | CURLOPT_TIMEOUT => 30, 31 | CURLOPT_RETURNTRANSFER => TRUE, 32 | CURLOPT_BINARYTRANSFER => TRUE, 33 | CURLOPT_FOLLOWLOCATION => TRUE, 34 | CURLOPT_MAXREDIRS => 1, 35 | CURLOPT_AUTOREFERER => TRUE, 36 | CURLOPT_SSL_VERIFYPEER => FALSE, 37 | CURLOPT_HTTPHEADER => array('Expect:') 38 | ); 39 | $ch = curl_init(); 40 | curl_setopt_array($ch, $options); 41 | $response = curl_exec($ch); 42 | curl_close($ch); 43 | return $response; 44 | } 45 | 46 | 47 | public function get($input_urls, $params = NULL, $format = 'json') { 48 | 49 | if (is_array($input_urls)) { 50 | 51 | $urls = ''; 52 | foreach ($input_urls as $input_url) { 53 | $url = urldecode($input_url); 54 | $url = urlencode($url); 55 | $urls = $urls . $url . ','; 56 | } 57 | $urls = substr_replace($urls, '', -1); 58 | 59 | } else { 60 | 61 | $urls = $input_urls; 62 | } 63 | 64 | if ($params) { 65 | $params = implode('&', $params); 66 | } 67 | 68 | $query = $this->_apiRoot . '/' . $this->_endpoint . '?key=' . $this->_apiKey . '&urls=' . $urls . '&format=' . $format . '&' . $params; 69 | 70 | $metadata = $this->curl($query); 71 | 72 | if ($metadata) { 73 | return $metadata; 74 | } 75 | 76 | else { 77 | return FALSE; 78 | } 79 | } 80 | 81 | 82 | public function getTweet($link_id) { 83 | 84 | $dbh = $this->getDbh(); 85 | 86 | try { 87 | $sql = " 88 | SELECT id_str, screen_name, text, profile_image_url 89 | FROM openfuego_tweets_cache 90 | WHERE link_id = :link_id 91 | LIMIT 1; 92 | "; 93 | $sth = $dbh->prepare($sql); 94 | $sth->bindParam('link_id', $link_id); 95 | $sth->execute(); 96 | 97 | } catch (\PDOException $e) { 98 | Logger::error($e); 99 | return FALSE; 100 | } 101 | 102 | $tweets_cache_row = $sth->fetch(); 103 | 104 | if ($tweets_cache_row) { // if tweet CACHED 105 | 106 | $id_str = $tweets_cache_row[0]; 107 | $screen_name = $tweets_cache_row[1]; 108 | $text = $tweets_cache_row[2]; 109 | $profile_image_url = $tweets_cache_row[3]; 110 | 111 | } else { // if associated tweet is NOT cached 112 | 113 | try { 114 | $sql = " 115 | SELECT first_tweet 116 | FROM openfuego_links 117 | WHERE link_id = :link_id 118 | LIMIT 1; 119 | "; 120 | $sth = $dbh->prepare($sql); 121 | $sth->bindParam('link_id', $link_id); 122 | $sth->execute(); 123 | 124 | $id_str = $sth->fetchColumn(0); 125 | 126 | } catch (\PDOException $e) { 127 | Logger::error($e); 128 | return FALSE; 129 | } 130 | 131 | if (empty($id_str) || $id_str == NULL) { 132 | $status = $this->updateTweet($link_id); 133 | $id_str = $status['id_str']; 134 | $screen_name = $status['screen_name']; 135 | $text = $status['text']; 136 | $profile_image_url = $status['profile_image_url']; 137 | 138 | } else { 139 | 140 | $twitter = new TwitterHandle(); 141 | $status = $twitter->get("statuses/show/$id_str", array('include_entities' => false)); 142 | 143 | if (preg_match("/2../", $twitter->http_code)) { 144 | $id_str = $status['id_str']; 145 | $screen_name = $status['user']['screen_name']; 146 | $text = $status['text']; 147 | $profile_image_url = $status['user']['profile_image_url']; 148 | 149 | try { 150 | $sql = " 151 | INSERT IGNORE INTO openfuego_tweets_cache (link_id, id_str, screen_name, text, profile_image_url) 152 | VALUES (:link_id, :id_str, :screen_name, :text, :profile_image_url); 153 | "; 154 | $sth = $dbh->prepare($sql); 155 | $sth->bindParam('link_id', $link_id); 156 | $sth->bindParam('id_str', $id_str); 157 | $sth->bindParam('screen_name', $screen_name); 158 | $sth->bindParam('text', $text); 159 | $sth->bindParam('profile_image_url', $profile_image_url); 160 | $sth->execute(); 161 | 162 | } catch (\PDOException $e) { 163 | Logger::error($e); 164 | return FALSE; 165 | } 166 | } 167 | 168 | elseif (preg_match("/4../", $twitter->http_code)) { 169 | $status = $this->updateTweet($link_id); 170 | $id_str = $status['id_str']; 171 | $screen_name = $status['screen_name']; 172 | $text = $status['text']; 173 | $profile_image_url = $status['profile_image_url']; 174 | } 175 | 176 | else { 177 | Logger::error("Twitter error {$twitter->http_code}"); 178 | return FALSE; 179 | } 180 | } 181 | } 182 | 183 | $tweet = array('id_str' => $id_str, 'screen_name' => $screen_name, 'text' => $text, 'profile_image_url' => $profile_image_url); 184 | 185 | return $tweet; 186 | } 187 | 188 | 189 | public function updateTweet($link_id) { 190 | 191 | $dbh = $this->getDbh(); 192 | 193 | $sql = " 194 | SELECT sl.input_url, l.url, l.first_user_id 195 | FROM openfuego_links AS l 196 | LEFT JOIN (openfuego_short_links AS sl) ON (sl.long_url = l.url) 197 | WHERE l.link_id = $link_id; 198 | "; 199 | $sth = $dbh->query($sql); 200 | $row = $sth->fetch(\PDO::FETCH_ASSOC); 201 | 202 | $short_url = $row['input_url']; 203 | $long_url = $row['url']; 204 | $first_user_id = $row['first_user_id']; 205 | 206 | $query = $short_url ? $short_url . ' OR ' . $long_url : $long_url; 207 | 208 | $twitter = new TwitterHandle(); 209 | $search = $twitter->get("search/tweets", array('q' => $query, 'count' => 100, 'result_type' => 'mixed')); 210 | 211 | if ($search['statuses']) { 212 | $search_results = $search['statuses']; 213 | 214 | foreach ($search_results as $search_result) { 215 | if ($search_result['user']['id_str'] == $first_user_id) { 216 | break; 217 | } 218 | } 219 | } 220 | else { 221 | Logger::error("No Twitter search results. Query: {$twitter->url}"); 222 | return FALSE; // not sure what else to do, really. 223 | } 224 | 225 | $id_str = $search_result['user']['id_str']; 226 | $screen_name = $search_result['user']['screen_name']; 227 | $profile_image_url = $search_result['user']['profile_image_url']; 228 | $text = $search_result['text']; 229 | 230 | try { 231 | $sql = " 232 | INSERT INTO openfuego_tweets_cache 233 | (link_id, id_str, screen_name, text, profile_image_url) 234 | VALUES 235 | (:link_id, :id_str, :screen_name, :text, :profile_image_url) 236 | ON DUPLICATE KEY UPDATE 237 | id_str=VALUES(id_str), 238 | screen_name=VALUES(screen_name), 239 | text=VALUES(text), 240 | profile_image_url=VALUES(profile_image_url); 241 | "; 242 | $sth = $dbh->prepare($sql); 243 | $sth->bindParam('link_id', $link_id); 244 | $sth->bindParam('id_str', $id_str); 245 | $sth->bindParam('screen_name', $screen_name); 246 | $sth->bindParam('text', $text); 247 | $sth->bindParam('profile_image_url', $profile_image_url); 248 | $sth->execute(); 249 | 250 | } catch (\PDOException $e) { 251 | Logger::error($e); 252 | return FALSE; 253 | } 254 | 255 | $tweet = array('id_str' => $id_str, 'screen_name' => $screen_name, 'text' => $text, 'profile_image_url' => $profile_image_url); 256 | 257 | return $tweet; 258 | } 259 | 260 | 261 | public function getDbh() { 262 | if (!$this->_dbh) { 263 | $this->_dbh = new DbHandle(); 264 | } 265 | 266 | return $this->_dbh; 267 | } 268 | } -------------------------------------------------------------------------------- /lib/Phirehose/OAuthPhirehose.class.php: -------------------------------------------------------------------------------- 1 | consumerKey?$this->consumerKey:\OpenFuego\TWITTER_CONSUMER_KEY; 38 | $oauth['oauth_nonce'] = md5(uniqid(rand(), true)); 39 | $oauth['oauth_signature_method'] = 'HMAC-SHA1'; 40 | $oauth['oauth_timestamp'] = time(); 41 | $oauth['oauth_version'] = '1.0A'; 42 | $oauth['oauth_token'] = $this->username; 43 | if (isset($params['oauth_verifier'])) 44 | { 45 | $oauth['oauth_verifier'] = $params['oauth_verifier']; 46 | unset($params['oauth_verifier']); 47 | } 48 | // encode all oauth values 49 | foreach ($oauth as $k => $v) 50 | $oauth[$k] = $this->encode_rfc3986($v); 51 | 52 | // encode all non '@' params 53 | // keep sigParams for signature generation (exclude '@' params) 54 | // rename '@key' to 'key' 55 | $sigParams = array(); 56 | $hasFile = false; 57 | if (is_array($params)) 58 | { 59 | foreach ($params as $k => $v) 60 | { 61 | if (strncmp('@', $k, 1) !== 0) 62 | { 63 | $sigParams[$k] = $this->encode_rfc3986($v); 64 | $params[$k] = $this->encode_rfc3986($v); 65 | } 66 | else 67 | { 68 | $params[substr($k, 1)] = $v; 69 | unset($params[$k]); 70 | $hasFile = true; 71 | } 72 | } 73 | 74 | if ($hasFile === true) 75 | $sigParams = array(); 76 | } 77 | 78 | $sigParams = array_merge($oauth, (array) $sigParams); 79 | 80 | // sorting 81 | ksort($sigParams); 82 | 83 | // signing 84 | $oauth['oauth_signature'] = $this->encode_rfc3986($this->generateSignature($method, $url, $sigParams)); 85 | return array('request' => $params, 'oauth' => $oauth); 86 | } 87 | 88 | protected function encode_rfc3986($string) 89 | { 90 | return str_replace('+', ' ', str_replace('%7E', '~', rawurlencode(($string)))); 91 | } 92 | 93 | protected function generateSignature($method = null, $url = null, 94 | $params = null) 95 | { 96 | if (empty($method) || empty($url)) 97 | return false; 98 | 99 | // concatenating and encode 100 | $concat = ''; 101 | foreach ((array) $params as $key => $value) 102 | $concat .= "{$key}={$value}&"; 103 | $concat = substr($concat, 0, -1); 104 | $concatenatedParams = $this->encode_rfc3986($concat); 105 | 106 | // normalize url 107 | $urlParts = parse_url($url); 108 | $scheme = strtolower($urlParts['scheme']); 109 | $host = strtolower($urlParts['host']); 110 | $port = isset($urlParts['port']) ? intval($urlParts['port']) : 0; 111 | $retval = strtolower($scheme) . '://' . strtolower($host); 112 | if (!empty($port) && (($scheme === 'http' && $port != 80) || ($scheme === 'https' && $port != 443))) 113 | $retval .= ":{$port}"; 114 | 115 | $retval .= $urlParts['path']; 116 | if (!empty($urlParts['query'])) 117 | $retval .= "?{$urlParts['query']}"; 118 | 119 | $normalizedUrl = $this->encode_rfc3986($retval); 120 | $method = $this->encode_rfc3986($method); // don't need this but why not? 121 | 122 | $signatureBaseString = "{$method}&{$normalizedUrl}&{$concatenatedParams}"; 123 | 124 | # sign the signature string 125 | $key = $this->encode_rfc3986($this->consumerSecret?$this->consumerSecret:\OpenFuego\TWITTER_CONSUMER_SECRET) . '&' . $this->encode_rfc3986($this->password); 126 | return base64_encode(hash_hmac('sha1', $signatureBaseString, $key, true)); 127 | } 128 | 129 | protected function getOAuthHeader($method, $url, $params = array()) 130 | { 131 | $params = $this->prepareParameters($method, $url, $params); 132 | $oauthHeaders = $params['oauth']; 133 | 134 | $urlParts = parse_url($url); 135 | $oauth = 'OAuth realm="",'; 136 | foreach ($oauthHeaders as $name => $value) 137 | { 138 | $oauth .= "{$name}=\"{$value}\","; 139 | } 140 | $oauth = substr($oauth, 0, -1); 141 | 142 | return $oauth; 143 | } 144 | 145 | /** Overrides base class function */ 146 | protected function getAuthorizationHeader($url,$requestParams) 147 | { 148 | return $this->getOAuthHeader('POST', $url, $requestParams); 149 | } 150 | } -------------------------------------------------------------------------------- /lib/Phirehose/Phirehose.class.php: -------------------------------------------------------------------------------- 1 | 9 | * @version 1.0RC 10 | */ 11 | abstract class Phirehose 12 | { 13 | 14 | /** 15 | * Class constants 16 | */ 17 | const FORMAT_JSON = 'json'; 18 | const FORMAT_XML = 'xml'; 19 | const METHOD_FILTER = 'filter'; 20 | const METHOD_SAMPLE = 'sample'; 21 | const METHOD_RETWEET = 'retweet'; 22 | const METHOD_FIREHOSE = 'firehose'; 23 | const METHOD_LINKS = 'links'; 24 | const METHOD_USER = 'user'; //See UserstreamPhirehose.php 25 | const METHOD_SITE = 'site'; //See UserstreamPhirehose.php 26 | 27 | const EARTH_RADIUS_KM = 6371; 28 | 29 | /** 30 | * @internal Moved from being a const to a variable, because some methods (user and site) need to change it. 31 | */ 32 | protected $URL_BASE = 'https://stream.twitter.com/1.1/statuses/'; 33 | 34 | 35 | /** 36 | * Member Attribs 37 | */ 38 | protected $username; 39 | protected $password; 40 | protected $method; 41 | protected $format; 42 | protected $count; //Can be -150,000 to 150,000. @see http://dev.twitter.com/pages/streaming_api_methods#count 43 | protected $followIds; 44 | protected $trackWords; 45 | protected $locationBoxes; 46 | protected $conn; 47 | protected $fdrPool; 48 | protected $buff; 49 | // State vars 50 | protected $filterChanged; 51 | protected $reconnect; 52 | 53 | /** 54 | * The number of tweets received per second in previous minute; calculated fresh 55 | * just before each call to statusUpdate() 56 | * I.e. if fewer than 30 tweets in last minute then this will be zero; if 30 to 90 then it 57 | * will be 1, if 90 to 150 then 2, etc. 58 | * 59 | * @var integer 60 | */ 61 | protected $statusRate; 62 | 63 | protected $lastErrorNo; 64 | protected $lastErrorMsg; 65 | 66 | /** 67 | * Number of tweets received. 68 | * 69 | * Note: by default this is the sum for last 60 seconds, and is therefore 70 | * reset every 60 seconds. 71 | * To change this behaviour write a custom statusUpdate() function. 72 | * 73 | * @var integer 74 | */ 75 | protected $statusCount=0; 76 | 77 | /** 78 | * The number of calls to $this->checkFilterPredicates(). 79 | * 80 | * By default it is called every 5 seconds, so if doing statusUpdates every 81 | * 60 seconds and then resetting it, this will usually be 12. 82 | * 83 | * @var integer 84 | */ 85 | protected $filterCheckCount=0; 86 | 87 | /** 88 | * Total number of seconds (fractional) spent in the enqueueStatus() calls (i.e. the customized 89 | * function that handles each received tweet). 90 | * 91 | * @var float 92 | */ 93 | protected $enqueueSpent=0; 94 | 95 | /** 96 | * Total number of seconds (fractional) spent in the checkFilterPredicates() calls 97 | * 98 | * @var float 99 | */ 100 | protected $filterCheckSpent=0; 101 | 102 | /** 103 | * Number of seconds since the last tweet arrived (or the keep-alive newline) 104 | * 105 | * @var integer 106 | */ 107 | protected $idlePeriod=0; 108 | 109 | /** 110 | * The maximum value $this->idlePeriod has reached. 111 | * 112 | * @var integer 113 | */ 114 | protected $maxIdlePeriod=0; 115 | 116 | /** 117 | * Time spent on each call to enqueueStatus() (i.e. average time spent, in milliseconds, 118 | * spent processing received tweet). 119 | * 120 | * Simply: enqueueSpent divided by statusCount 121 | * Note: by default, calculated fresh for past 60 seconds, every 60 seconds. 122 | * 123 | * @var float 124 | */ 125 | protected $enqueueTimeMS=0; 126 | 127 | /** 128 | * Like $enqueueTimeMS but for the checkFilterPredicates() function. 129 | * @var float 130 | */ 131 | protected $filterCheckTimeMS=0; 132 | 133 | /** 134 | * Seconds since the last call to statusUpdate() 135 | * 136 | * Reset to zero after each call to statusUpdate() 137 | * Highest value it should ever reach is $this->avgPeriod 138 | * 139 | * @var integer 140 | */ 141 | protected $avgElapsed=0; 142 | 143 | // Config type vars - override in subclass if desired 144 | protected $connectFailuresMax = 20; 145 | protected $connectTimeout = 5; 146 | protected $readTimeout = 5; 147 | protected $idleReconnectTimeout = 90; 148 | protected $avgPeriod = 60; 149 | protected $status_length_base = 10; 150 | protected $userAgent = 'Phirehose/1.0RC +https://github.com/fennb/phirehose'; 151 | protected $filterCheckMin = 5; 152 | protected $filterUpdMin = 120; 153 | protected $tcpBackoff = 1; 154 | protected $tcpBackoffMax = 16; 155 | protected $httpBackoff = 10; 156 | protected $httpBackoffMax = 240; 157 | protected $hostPort = 80; 158 | protected $secureHostPort = 443; 159 | 160 | /** 161 | * Create a new Phirehose object attached to the appropriate twitter stream method. 162 | * Methods are: METHOD_FIREHOSE, METHOD_RETWEET, METHOD_SAMPLE, METHOD_FILTER, METHOD_LINKS, METHOD_USER, METHOD_SITE. Note: the method might cause the use of a different endpoint URL. 163 | * Formats are: FORMAT_JSON, FORMAT_XML 164 | * @see Phirehose::METHOD_SAMPLE 165 | * @see Phirehose::FORMAT_JSON 166 | * 167 | * @param string $username Any twitter username. When using oAuth, this is the 'oauth_token'. 168 | * @param string $password Any twitter password. When using oAuth this is you oAuth secret. 169 | * @param string $method 170 | * @param string $format 171 | * 172 | * @todo I've kept the "/2/" at the end of the URL for user streams, as that is what 173 | * was there before AND it works for me! But the official docs say to use /1.1/ 174 | * so that is what I have used for site. 175 | * https://dev.twitter.com/docs/api/1.1/get/user 176 | * 177 | * @todo Shouldn't really hard-code URL strings in this function. 178 | */ 179 | public function __construct($username, $password, $method = Phirehose::METHOD_SAMPLE, $format = self::FORMAT_JSON, $lang = FALSE) 180 | { 181 | $this->username = $username; 182 | $this->password = $password; 183 | $this->method = $method; 184 | $this->format = $format; 185 | $this->lang = $lang; 186 | switch($method){ 187 | case self::METHOD_USER:$this->URL_BASE = 'https://userstream.twitter.com/2/';break; 188 | case self::METHOD_SITE:$this->URL_BASE = 'https://sitestream.twitter.com/1.1/';break; 189 | default:break; //Stick to the default 190 | } 191 | } 192 | 193 | /** 194 | * Returns public statuses from or in reply to a set of users. Mentions ("Hello @user!") and implicit replies 195 | * ("@user Hello!" created without pressing the reply button) are not matched. It is up to you to find the integer 196 | * IDs of each twitter user. 197 | * Applies to: METHOD_FILTER 198 | * 199 | * @param array $userIds Array of Twitter integer userIDs 200 | */ 201 | public function setFollow($userIds) 202 | { 203 | $userIds = ($userIds === NULL) ? array() : $userIds; 204 | sort($userIds); // Non-optimal but necessary 205 | if ($this->followIds != $userIds) { 206 | $this->filterChanged = TRUE; 207 | } 208 | $this->followIds = $userIds; 209 | } 210 | 211 | /** 212 | * Returns an array of followed Twitter userIds (integers) 213 | * 214 | * @return array 215 | */ 216 | public function getFollow() 217 | { 218 | return $this->followIds; 219 | } 220 | 221 | /** 222 | * Specifies keywords to track. Track keywords are case-insensitive logical ORs. Terms are exact-matched, ignoring 223 | * punctuation. Phrases, keywords with spaces, are not supported. Queries are subject to Track Limitations. 224 | * Applies to: METHOD_FILTER 225 | * 226 | * See: http://apiwiki.twitter.com/Streaming-API-Documentation#TrackLimiting 227 | * 228 | * @param array $trackWords 229 | */ 230 | public function setTrack(array $trackWords) 231 | { 232 | $trackWords = ($trackWords === NULL) ? array() : $trackWords; 233 | sort($trackWords); // Non-optimal, but necessary 234 | if ($this->trackWords != $trackWords) { 235 | $this->filterChanged = TRUE; 236 | } 237 | $this->trackWords = $trackWords; 238 | } 239 | 240 | /** 241 | * Returns an array of keywords being tracked 242 | * 243 | * @return array 244 | */ 245 | public function getTrack() 246 | { 247 | return $this->trackWords; 248 | } 249 | 250 | /** 251 | * Specifies a set of bounding boxes to track as an array of 4 element lon/lat pairs denoting , 252 | * . Only tweets that are both created using the Geotagging API and are placed from within a tracked 253 | * bounding box will be included in the stream. The user's location field is not used to filter tweets. Bounding boxes 254 | * are logical ORs and must be less than or equal to 1 degree per side. A locations parameter may be combined with 255 | * track parameters, but note that all terms are logically ORd. 256 | * 257 | * NOTE: The argument order is Longitude/Latitude (to match the Twitter API and GeoJSON specifications). 258 | * 259 | * Applies to: METHOD_FILTER 260 | * 261 | * See: http://apiwiki.twitter.com/Streaming-API-Documentation#locations 262 | * 263 | * Eg: 264 | * setLocations(array( 265 | * array(-122.75, 36.8, -121.75, 37.8), // San Francisco 266 | * array(-74, 40, -73, 41), // New York 267 | * )); 268 | * 269 | * @param array $boundingBoxes 270 | */ 271 | public function setLocations($boundingBoxes) 272 | { 273 | $boundingBoxes = ($boundingBoxes === NULL) ? array() : $boundingBoxes; 274 | sort($boundingBoxes); // Non-optimal, but necessary 275 | // Flatten to single dimensional array 276 | $locationBoxes = array(); 277 | foreach ($boundingBoxes as $boundingBox) { 278 | // Sanity check 279 | if (count($boundingBox) != 4) { 280 | // Invalid - Not much we can do here but log error 281 | $this->log('Invalid location bounding box: [' . implode(', ', $boundingBox) . ']','error'); 282 | return FALSE; 283 | } 284 | // Append this lat/lon pairs to flattened array 285 | $locationBoxes = array_merge($locationBoxes, $boundingBox); 286 | } 287 | // If it's changed, make note 288 | if ($this->locationBoxes != $locationBoxes) { 289 | $this->filterChanged = TRUE; 290 | } 291 | // Set flattened value 292 | $this->locationBoxes = $locationBoxes; 293 | } 294 | 295 | /** 296 | * Returns an array of 4 element arrays that denote the monitored location bounding boxes for tweets using the 297 | * Geotagging API. 298 | * 299 | * @see setLocations() 300 | * @return array 301 | */ 302 | public function getLocations() { 303 | if ($this->locationBoxes == NULL) { 304 | return NULL; 305 | } 306 | $locationBoxes = $this->locationBoxes; // Copy array 307 | $ret = array(); 308 | while (count($locationBoxes) >= 4) { 309 | $ret[] = array_splice($locationBoxes, 0, 4); // Append to ret array in blocks of 4 310 | } 311 | return $ret; 312 | } 313 | 314 | /** 315 | * Convenience method that sets location bounding boxes by an array of lon/lat/radius sets, rather than manually 316 | * specified bounding boxes. Each array element should contain 3 element subarray containing a latitude, longitude and 317 | * radius. Radius is specified in kilometers and is approximate (as boxes are square). 318 | * 319 | * NOTE: The argument order is Longitude/Latitude (to match the Twitter API and GeoJSON specifications). 320 | * 321 | * Eg: 322 | * setLocationsByCircle(array( 323 | * array(144.9631, -37.8142, 30), // Melbourne, 3km radius 324 | * array(-0.1262, 51.5001, 25), // London 10km radius 325 | * )); 326 | * 327 | * 328 | * @see setLocations() 329 | * @param array 330 | */ 331 | public function setLocationsByCircle($locations) { 332 | $boundingBoxes = array(); 333 | foreach ($locations as $locTriplet) { 334 | // Sanity check 335 | if (count($locTriplet) != 3) { 336 | // Invalid - Not much we can do here but log error 337 | $this->log('Invalid location triplet for ' . __METHOD__ . ': [' . implode(', ', $locTriplet) . ']','error'); 338 | return FALSE; 339 | } 340 | list($lon, $lat, $radius) = $locTriplet; 341 | 342 | // Calc bounding boxes 343 | $maxLat = round($lat + rad2deg($radius / self::EARTH_RADIUS_KM), 2); 344 | $minLat = round($lat - rad2deg($radius / self::EARTH_RADIUS_KM), 2); 345 | // Compensate for degrees longitude getting smaller with increasing latitude 346 | $maxLon = round($lon + rad2deg($radius / self::EARTH_RADIUS_KM / cos(deg2rad($lat))), 2); 347 | $minLon = round($lon - rad2deg($radius / self::EARTH_RADIUS_KM / cos(deg2rad($lat))), 2); 348 | // Add to bounding box array 349 | $boundingBoxes[] = array($minLon, $minLat, $maxLon, $maxLat); 350 | // Debugging is handy 351 | $this->log('Resolved location circle [' . $lon . ', ' . $lat . ', r: ' . $radius . '] -> bbox: [' . $minLon . 352 | ', ' . $minLat . ', ' . $maxLon . ', ' . $maxLat . ']'); 353 | } 354 | // Set by bounding boxes 355 | $this->setLocations($boundingBoxes); 356 | } 357 | 358 | /** 359 | * Sets the number of previous statuses to stream before transitioning to the live stream. Applies only to firehose 360 | * and filter + track methods. This is generally used internally and should not be needed by client applications. 361 | * Applies to: METHOD_FILTER, METHOD_FIREHOSE, METHOD_LINKS 362 | * 363 | * @param integer $count 364 | */ 365 | public function setCount($count) 366 | { 367 | $this->count = $count; 368 | } 369 | 370 | /** 371 | * Restricts tweets to the given language, given by an ISO 639-1 code (http://en.wikipedia.org/wiki/List_of_ISO_639-1_codes). 372 | * 373 | * @param string $lang 374 | */ 375 | public function setLang($lang) 376 | { 377 | $this->lang = $lang; 378 | } 379 | 380 | /** 381 | * Returns the ISO 639-1 code formatted language string of the current setting. (http://en.wikipedia.org/wiki/List_of_ISO_639-1_codes). 382 | * 383 | * @param string $lang 384 | */ 385 | public function getLang() 386 | { 387 | return $this->lang; 388 | } 389 | 390 | /** 391 | * Connects to the stream API and consumes the stream. Each status update in the stream will cause a call to the 392 | * handleStatus() method. 393 | * 394 | * Note: in normal use this function does not return. 395 | * If you pass $reconnect as false, it will still not return in normal use: it will only return 396 | * if the remote side (Twitter) close the socket. (Or the socket dies for some other external reason.) 397 | * 398 | * @see handleStatus() 399 | * @param boolean $reconnect Reconnects as per recommended 400 | * @throws ErrorException 401 | */ 402 | public function consume($reconnect = TRUE) 403 | { 404 | // Persist connection? 405 | $this->reconnect = $reconnect; 406 | 407 | // Loop indefinitely based on reconnect 408 | do { 409 | 410 | // (Re)connect 411 | $this->reconnect(); 412 | 413 | // Init state 414 | $lastAverage = $lastFilterCheck = $lastFilterUpd = $lastStreamActivity = time(); 415 | $fdw = $fde = NULL; // Placeholder write/error file descriptors for stream_select 416 | 417 | // We use a blocking-select with timeout, to allow us to continue processing on idle streams 418 | //TODO: there is a bug lurking here. If $this->conn is fine, but $numChanged returns zero, because readTimeout was 419 | // reached, then we should consider we still need to call statusUpdate() every 60 seconds, etc. 420 | // ($this->readTimeout is 5 seconds.) This can be quite annoying. E.g. Been getting data regularly for 55 seconds, 421 | // then it goes quiet for just 10 or so seconds. It is now 65 seconds since last call to statusUpdate() has been 422 | // called, which might mean a monitoring system kills the script assuming it has died. 423 | while ($this->conn !== NULL && !feof($this->conn) && 424 | ($numChanged = stream_select($this->fdrPool, $fdw, $fde, $this->readTimeout)) !== FALSE) { 425 | /* Unfortunately, we need to do a safety check for dead twitter streams - This seems to be able to happen where 426 | * you end up with a valid connection, but NO tweets coming along the wire (or keep alives). The below guards 427 | * against this. 428 | */ 429 | if ((time() - $lastStreamActivity) > $this->idleReconnectTimeout) { 430 | $this->log('Idle timeout: No stream activity for > ' . $this->idleReconnectTimeout . ' seconds. ' . 431 | ' Reconnecting.','info'); 432 | $this->reconnect(); 433 | $lastStreamActivity = time(); 434 | continue; 435 | } 436 | // Process stream/buffer 437 | $this->fdrPool = array($this->conn); // Must reassign for stream_select() 438 | 439 | //Get a full HTTP chunk. 440 | //NB. This is a tight loop, not using stream_select. 441 | //NB. If that causes problems, then perhaps put something to give up after say trying for 10 seconds? (but 442 | // the stream will be all messed up, so will need to do a reconnect). 443 | $chunk_info=trim(fgets($this->conn)); //First line is hex digits giving us the length 444 | if($chunk_info=='')continue; //Usually indicates a time-out. If we wanted to be sure, 445 | //then stream_get_meta_data($this->conn)['timed_out']==1. (We could instead 446 | // look at the 'eof' member, which appears to be boolean false if just a time-out.) 447 | //TODO: need to consider calling statusUpdate() every 60 seconds, etc. 448 | 449 | // Track maximum idle period 450 | // (We got start of an HTTP chunk, this is stream activity) 451 | $this->idlePeriod = (time() - $lastStreamActivity); 452 | $this->maxIdlePeriod = ($this->idlePeriod > $this->maxIdlePeriod) ? $this->idlePeriod : $this->maxIdlePeriod; 453 | $lastStreamActivity = time(); 454 | 455 | //Append one HTTP chunk to $this->buff 456 | $len=hexdec($chunk_info); //$len includes the \r\n at the end of the chunk (despite what wikipedia says) 457 | //TODO: could do a check for data corruption here. E.g. if($len>100000){...} 458 | $s=''; 459 | $len+=2; //For the \r\n at the end of the chunk 460 | while(!feof($this->conn)){ 461 | $s.=fread($this->conn,$len-strlen($s)); 462 | if(strlen($s)>=$len)break; //TODO: Can never be >$len, only ==$len?? 463 | } 464 | $this->buff.=substr($s,0,-2); //This is our HTTP chunk 465 | 466 | //Process each full tweet inside $this->buff 467 | while(1){ 468 | $eol = strpos($this->buff,"\r\n"); //Find next line ending 469 | if($eol===0) { // if 0, then buffer starts with "\r\n", so trim it and loop again 470 | $this->buff = substr($this->buff,$eol+2); // remove the "\r\n" from line start 471 | continue; // loop again 472 | } 473 | if($eol===false)break; //Time to get more data 474 | $enqueueStart = microtime(TRUE); 475 | $this->enqueueStatus(substr($this->buff,0,$eol)); 476 | $this->enqueueSpent += (microtime(TRUE) - $enqueueStart); 477 | $this->statusCount++; 478 | $this->buff = substr($this->buff,$eol+2); //+2 to allow for the \r\n 479 | } 480 | 481 | //NOTE: if $this->buff is not empty, it is tempting to go round and get the next HTTP chunk, as 482 | // we know there is data on the incoming stream. However, this could mean the below functions (heartbeat 483 | // and statusUpdate) *never* get called, which would be bad. 484 | 485 | // Calc counter averages 486 | $this->avgElapsed = time() - $lastAverage; 487 | if ($this->avgElapsed >= $this->avgPeriod) { 488 | $this->statusRate = round($this->statusCount / $this->avgElapsed, 0); // Calc tweets-per-second 489 | // Calc time spent per enqueue in ms 490 | $this->enqueueTimeMS = ($this->statusCount > 0) ? 491 | round($this->enqueueSpent / $this->statusCount * 1000, 2) : 0; 492 | // Calc time spent total in filter predicate checking 493 | $this->filterCheckTimeMS = ($this->filterCheckCount > 0) ? 494 | round($this->filterCheckSpent / $this->filterCheckCount * 1000, 2) : 0; 495 | 496 | $this->heartbeat(); 497 | $this->statusUpdate(); 498 | $lastAverage = time(); 499 | } 500 | // Check if we're ready to check filter predicates 501 | if ($this->method == self::METHOD_FILTER && (time() - $lastFilterCheck) >= $this->filterCheckMin) { 502 | $this->filterCheckCount++; 503 | $lastFilterCheck = time(); 504 | $filterCheckStart = microtime(TRUE); 505 | $this->checkFilterPredicates(); // This should be implemented in subclass if required 506 | $this->filterCheckSpent += (microtime(TRUE) - $filterCheckStart); 507 | } 508 | // Check if filter is ready + allowed to be updated (reconnect) 509 | if ($this->filterChanged == TRUE && (time() - $lastFilterUpd) >= $this->filterUpdMin) { 510 | $this->log('Reconnecting due to changed filter predicates.','info'); 511 | $this->reconnect(); 512 | $lastFilterUpd = time(); 513 | } 514 | 515 | } // End while-stream-activity 516 | 517 | if (function_exists('pcntl_signal_dispatch')) { 518 | pcntl_signal_dispatch(); 519 | } 520 | 521 | // Some sort of socket error has occured 522 | $this->lastErrorNo = is_resource($this->conn) ? @socket_last_error($this->conn) : NULL; 523 | $this->lastErrorMsg = ($this->lastErrorNo > 0) ? @socket_strerror($this->lastErrorNo) : 'Socket disconnected'; 524 | $this->log('Phirehose connection error occured: ' . $this->lastErrorMsg,'error'); 525 | 526 | // Reconnect 527 | } while ($this->reconnect); 528 | 529 | // Exit 530 | $this->log('Exiting.'); 531 | 532 | } 533 | 534 | 535 | /** 536 | * Called every $this->avgPeriod (default=60) seconds, and this default implementation 537 | * calculates some rates, logs them, and resets the counters. 538 | */ 539 | protected function statusUpdate() 540 | { 541 | $this->log('Consume rate: ' . $this->statusRate . ' status/sec (' . $this->statusCount . ' total), avg ' . 542 | 'enqueueStatus(): ' . $this->enqueueTimeMS . 'ms, avg checkFilterPredicates(): ' . $this->filterCheckTimeMS . 'ms (' . 543 | $this->filterCheckCount . ' total) over ' . $this->avgElapsed . ' seconds, max stream idle period: ' . 544 | $this->maxIdlePeriod . ' seconds.'); 545 | // Reset 546 | $this->statusCount = $this->filterCheckCount = $this->enqueueSpent = 0; 547 | $this->filterCheckSpent = $this->idlePeriod = $this->maxIdlePeriod = 0; 548 | } 549 | 550 | /** 551 | * Returns the last error message (TCP or HTTP) that occured with the streaming API or client. State is cleared upon 552 | * successful reconnect 553 | * @return string 554 | */ 555 | public function getLastErrorMsg() 556 | { 557 | return $this->lastErrorMsg; 558 | } 559 | 560 | /** 561 | * Returns the last error number that occured with the streaming API or client. Numbers correspond to either the 562 | * fsockopen() error states (in the case of TCP errors) or HTTP error codes from Twitter (in the case of HTTP errors). 563 | * 564 | * State is cleared upon successful reconnect. 565 | * 566 | * @return string 567 | */ 568 | public function getLastErrorNo() 569 | { 570 | return $this->lastErrorNo; 571 | } 572 | 573 | 574 | /** 575 | * Connects to the stream URL using the configured method. 576 | * @throws ErrorException 577 | */ 578 | protected function connect() 579 | { 580 | 581 | // Init state 582 | $connectFailures = 0; 583 | $tcpRetry = $this->tcpBackoff / 2; 584 | $httpRetry = $this->httpBackoff / 2; 585 | 586 | // Keep trying until connected (or max connect failures exceeded) 587 | do { 588 | 589 | // Check filter predicates for every connect (for filter method) 590 | if ($this->method == self::METHOD_FILTER) { 591 | $this->checkFilterPredicates(); 592 | } 593 | 594 | // Construct URL/HTTP bits 595 | $url = $this->URL_BASE . $this->method . '.' . $this->format; 596 | $urlParts = parse_url($url); 597 | 598 | // Setup params appropriately 599 | $requestParams=array(); 600 | 601 | //$requestParams['delimited'] = 'length'; //No, we don't want this any more 602 | 603 | // Setup the language of the stream 604 | if($this->lang) { 605 | $requestParams['language'] = $this->lang; 606 | } 607 | 608 | // Filter takes additional parameters 609 | if (($this->method == self::METHOD_FILTER || $this->method == self::METHOD_USER) && count($this->trackWords) > 0) { 610 | $requestParams['track'] = implode(',', $this->trackWords); 611 | } 612 | if ( ($this->method == self::METHOD_FILTER || $this->method == self::METHOD_SITE) 613 | && count($this->followIds) > 0) { 614 | $requestParams['follow'] = implode(',', $this->followIds); 615 | } 616 | if ($this->method == self::METHOD_FILTER && count($this->locationBoxes) > 0) { 617 | $requestParams['locations'] = implode(',', $this->locationBoxes); 618 | } 619 | if ($this->count <> 0) { 620 | $requestParams['count'] = $this->count; 621 | } 622 | 623 | // Debugging is useful 624 | $this->log('Connecting to twitter stream: ' . $url . ' with params: ' . str_replace("\n", '', 625 | var_export($requestParams, TRUE))); 626 | 627 | /** 628 | * Open socket connection to make POST request. It'd be nice to use stream_context_create with the native 629 | * HTTP transport but it hides/abstracts too many required bits (like HTTP error responses). 630 | */ 631 | $errNo = $errStr = NULL; 632 | $scheme = ($urlParts['scheme'] == 'https') ? 'ssl://' : 'tcp://'; 633 | $port = ($urlParts['scheme'] == 'https') ? $this->secureHostPort : $this->hostPort; 634 | 635 | /** 636 | * We must perform manual host resolution here as Twitter's IP regularly rotates (ie: DNS TTL of 60 seconds) and 637 | * PHP appears to cache it the result if in a long running process (as per Phirehose). 638 | */ 639 | $streamIPs = gethostbynamel($urlParts['host']); 640 | if(empty($streamIPs)) { 641 | throw new PhirehoseNetworkException("Unable to resolve hostname: '" . $urlParts['host'] . '"'); 642 | } 643 | 644 | // Choose one randomly (if more than one) 645 | $this->log('Resolved host ' . $urlParts['host'] . ' to ' . implode(', ', $streamIPs)); 646 | $streamIP = $streamIPs[rand(0, (count($streamIPs) - 1))]; 647 | $this->log("Connecting to {$scheme}{$streamIP}, port={$port}, connectTimeout={$this->connectTimeout}"); 648 | 649 | @$this->conn = fsockopen($scheme . $streamIP, $port, $errNo, $errStr, $this->connectTimeout); 650 | 651 | // No go - handle errors/backoff 652 | if (!$this->conn || !is_resource($this->conn)) { 653 | $this->lastErrorMsg = $errStr; 654 | $this->lastErrorNo = $errNo; 655 | $connectFailures++; 656 | if ($connectFailures > $this->connectFailuresMax) { 657 | $msg = 'TCP failure limit exceeded with ' . $connectFailures . ' failures. Last error: ' . $errStr; 658 | $this->log($msg,'error'); 659 | throw new PhirehoseConnectLimitExceeded($msg, $errNo); // Throw an exception for other code to handle 660 | } 661 | // Increase retry/backoff up to max 662 | $tcpRetry = ($tcpRetry < $this->tcpBackoffMax) ? $tcpRetry * 2 : $this->tcpBackoffMax; 663 | $this->log('TCP failure ' . $connectFailures . ' of ' . $this->connectFailuresMax . ' connecting to stream: ' . 664 | $errStr . ' (' . $errNo . '). Sleeping for ' . $tcpRetry . ' seconds.','info'); 665 | sleep($tcpRetry); 666 | continue; 667 | } 668 | 669 | // TCP connect OK, clear last error (if present) 670 | $this->log('Connection established to ' . $streamIP); 671 | $this->lastErrorMsg = NULL; 672 | $this->lastErrorNo = NULL; 673 | 674 | // If we have a socket connection, we can attempt a HTTP request - Ensure blocking read for the moment 675 | stream_set_blocking($this->conn, 1); 676 | 677 | // Encode request data 678 | $postData = http_build_query($requestParams, NULL, '&'); 679 | $postData = str_replace('+','%20',$postData); //Change it from RFC1738 to RFC3986 (see 680 | //enc_type parameter in http://php.net/http_build_query and note that enc_type is 681 | //not available as of php 5.3) 682 | $authCredentials = $this->getAuthorizationHeader($url,$requestParams); 683 | 684 | // Do it 685 | $s = "POST " . $urlParts['path'] . " HTTP/1.1\r\n"; 686 | $s.= "Host: " . $urlParts['host'] . ':' . $port . "\r\n"; 687 | $s .= "Connection: Close\r\n"; 688 | $s.= "Content-type: application/x-www-form-urlencoded\r\n"; 689 | $s.= "Content-length: " . strlen($postData) . "\r\n"; 690 | $s.= "Accept: */*\r\n"; 691 | $s.= 'Authorization: ' . $authCredentials . "\r\n"; 692 | $s.= 'User-Agent: ' . $this->userAgent . "\r\n"; 693 | $s.= "\r\n"; 694 | $s.= $postData . "\r\n"; 695 | $s.= "\r\n"; 696 | 697 | fwrite($this->conn, $s); 698 | $this->log($s); 699 | 700 | // First line is response 701 | list($httpVer, $httpCode, $httpMessage) = preg_split('/\s+/', trim(fgets($this->conn, 1024)), 3); 702 | 703 | // Response buffers 704 | $respHeaders = $respBody = ''; 705 | $isChunking = false; 706 | 707 | // Consume each header response line until we get to body 708 | while ($hLine = trim(fgets($this->conn, 4096))) { 709 | $respHeaders .= $hLine."\n"; 710 | if(strtolower($hLine) == 'transfer-encoding: chunked') $isChunking = true; 711 | } 712 | 713 | // If we got a non-200 response, we need to backoff and retry 714 | if ($httpCode != 200) { 715 | $connectFailures++; 716 | 717 | // Twitter will disconnect on error, but we want to consume the rest of the response body (which is useful) 718 | //TODO: this might be chunked too? In which case this contains some bad characters?? 719 | while ($bLine = trim(fgets($this->conn, 4096))) { 720 | $respBody .= $bLine; 721 | } 722 | 723 | // Construct error 724 | $errStr = 'HTTP ERROR ' . $httpCode . ': ' . $httpMessage . ' (' . $respBody . ')'; 725 | 726 | // Set last error state 727 | $this->lastErrorMsg = $errStr; 728 | $this->lastErrorNo = $httpCode; 729 | 730 | // Have we exceeded maximum failures? 731 | if ($connectFailures > $this->connectFailuresMax) { 732 | $msg = 'Connection failure limit exceeded with ' . $connectFailures . ' failures. Last error: ' . $errStr; 733 | $this->log($msg,'error'); 734 | throw new PhirehoseConnectLimitExceeded($msg, $httpCode); // We eventually throw an exception for other code to handle 735 | } 736 | // Increase retry/backoff up to max 737 | $httpRetry = ($httpRetry < $this->httpBackoffMax) ? $httpRetry * 2 : $this->httpBackoffMax; 738 | $this->log('HTTP failure ' . $connectFailures . ' of ' . $this->connectFailuresMax . ' connecting to stream: ' . 739 | $errStr . '. Sleeping for ' . $httpRetry . ' seconds.','info'); 740 | sleep($httpRetry); 741 | continue; 742 | 743 | } // End if not http 200 744 | else{ 745 | if(!$isChunking)throw new Exception("Twitter did not send a chunking header. Is this really HTTP/1.1? Here are headers:\n$respHeaders"); //TODO: rather crude! 746 | } 747 | 748 | // Loop until connected OK 749 | } while (!is_resource($this->conn) || $httpCode != 200); 750 | 751 | // Connected OK, reset connect failures 752 | $connectFailures = 0; 753 | $this->lastErrorMsg = NULL; 754 | $this->lastErrorNo = NULL; 755 | 756 | // Switch to non-blocking to consume the stream (important) 757 | stream_set_blocking($this->conn, 0); 758 | 759 | // Connect always causes the filterChanged status to be cleared 760 | $this->filterChanged = FALSE; 761 | 762 | // Flush stream buffer & (re)assign fdrPool (for reconnect) 763 | $this->fdrPool = array($this->conn); 764 | $this->buff = ''; 765 | 766 | } 767 | 768 | protected function getAuthorizationHeader($url,$requestParams) 769 | { 770 | throw new Exception("Basic auth no longer works with Twitter. You must derive from OauthPhirehose, not directly from the Phirehose class."); 771 | $authCredentials = base64_encode($this->username . ':' . $this->password); 772 | return "Basic: ".$authCredentials; 773 | } 774 | 775 | /** 776 | * Method called as frequently as practical (every 5+ seconds) that is responsible for checking if filter predicates 777 | * (ie: track words or follow IDs) have changed. If they have, they should be set using the setTrack() and setFollow() 778 | * methods respectively within the overridden implementation. 779 | * 780 | * Note that even if predicates are changed every 5 seconds, an actual reconnect will not happen more frequently than 781 | * every 2 minutes (as per Twitter Streaming API documentation). 782 | * 783 | * Note also that this method is called upon every connect attempt, so if your predicates are causing connection 784 | * errors, they should be checked here and corrected. 785 | * 786 | * This should be implemented/overridden in any subclass implementing the FILTER method. 787 | * 788 | * @see setTrack() 789 | * @see setFollow() 790 | * @see Phirehose::METHOD_FILTER 791 | */ 792 | protected function checkFilterPredicates() 793 | { 794 | // Override in subclass 795 | } 796 | 797 | /** 798 | * Basic log function that outputs logging to the standard error_log() handler. This should generally be overridden 799 | * to suit the application environment. 800 | * 801 | * @see error_log() 802 | * @param string $messages 803 | * @param String $level 'error', 'info', 'notice'. Defaults to 'notice', so you should set this 804 | * parameter on the more important error messages. 805 | * 'info' is used for problems that the class should be able to recover from automatically. 806 | * 'error' is for exceptional conditions that may need human intervention. (For instance, emailing 807 | * them to a system administrator may make sense.) 808 | */ 809 | protected function log($message,$level='notice') 810 | { 811 | @error_log('Phirehose: ' . $message, 0); 812 | } 813 | 814 | /** 815 | * Performs forcible disconnect from stream (if connected) and cleanup. 816 | */ 817 | protected function disconnect() 818 | { 819 | if (is_resource($this->conn)) { 820 | $this->log('Closing Phirehose connection.'); 821 | fclose($this->conn); 822 | } 823 | $this->conn = NULL; 824 | $this->reconnect = FALSE; 825 | } 826 | 827 | /** 828 | * Reconnects as quickly as possible. Should be called whenever a reconnect is required rather that connect/disconnect 829 | * to preserve streams reconnect state 830 | */ 831 | private function reconnect() 832 | { 833 | $reconnect = $this->reconnect; 834 | $this->disconnect(); // Implicitly sets reconnect to FALSE 835 | $this->reconnect = $reconnect; // Restore state to prev 836 | $this->connect(); 837 | } 838 | 839 | /** 840 | * This is the one and only method that must be implemented additionally. As per the streaming API documentation, 841 | * statuses should NOT be processed within the same process that is performing collection 842 | * 843 | * @param string $status 844 | */ 845 | abstract public function enqueueStatus($status); 846 | 847 | /** 848 | * Reports a periodic heartbeat. Keep execution time minimal. 849 | * 850 | * @return NULL 851 | */ 852 | public function heartbeat() {} 853 | 854 | /** 855 | * Set host port 856 | * 857 | * @param string $host 858 | * @return void 859 | */ 860 | public function setHostPort($port) 861 | { 862 | $this->hostPort = $port; 863 | } 864 | 865 | /** 866 | * Set secure host port 867 | * 868 | * @param int $port 869 | * @return void 870 | */ 871 | public function setSecureHostPort($port) 872 | { 873 | $this->secureHostPort = $port; 874 | } 875 | 876 | } // End of class 877 | 878 | class PhirehoseException extends \Exception {} 879 | class PhirehoseNetworkException extends PhirehoseException {} 880 | class PhirehoseConnectLimitExceeded extends PhirehoseException {} -------------------------------------------------------------------------------- /lib/Phirehose/gpl.txt: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 2, June 1991 3 | 4 | Copyright (C) 1989, 1991 Free Software Foundation, Inc., 5 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 6 | Everyone is permitted to copy and distribute verbatim copies 7 | of this license document, but changing it is not allowed. 8 | 9 | Preamble 10 | 11 | The licenses for most software are designed to take away your 12 | freedom to share and change it. By contrast, the GNU General Public 13 | License is intended to guarantee your freedom to share and change free 14 | software--to make sure the software is free for all its users. This 15 | General Public License applies to most of the Free Software 16 | Foundation's software and to any other program whose authors commit to 17 | using it. (Some other Free Software Foundation software is covered by 18 | the GNU Lesser General Public License instead.) You can apply it to 19 | your programs, too. 20 | 21 | When we speak of free software, we are referring to freedom, not 22 | price. Our General Public Licenses are designed to make sure that you 23 | have the freedom to distribute copies of free software (and charge for 24 | this service if you wish), that you receive source code or can get it 25 | if you want it, that you can change the software or use pieces of it 26 | in new free programs; and that you know you can do these things. 27 | 28 | To protect your rights, we need to make restrictions that forbid 29 | anyone to deny you these rights or to ask you to surrender the rights. 30 | These restrictions translate to certain responsibilities for you if you 31 | distribute copies of the software, or if you modify it. 32 | 33 | For example, if you distribute copies of such a program, whether 34 | gratis or for a fee, you must give the recipients all the rights that 35 | you have. You must make sure that they, too, receive or can get the 36 | source code. And you must show them these terms so they know their 37 | rights. 38 | 39 | We protect your rights with two steps: (1) copyright the software, and 40 | (2) offer you this license which gives you legal permission to copy, 41 | distribute and/or modify the software. 42 | 43 | Also, for each author's protection and ours, we want to make certain 44 | that everyone understands that there is no warranty for this free 45 | software. If the software is modified by someone else and passed on, we 46 | want its recipients to know that what they have is not the original, so 47 | that any problems introduced by others will not reflect on the original 48 | authors' reputations. 49 | 50 | Finally, any free program is threatened constantly by software 51 | patents. We wish to avoid the danger that redistributors of a free 52 | program will individually obtain patent licenses, in effect making the 53 | program proprietary. To prevent this, we have made it clear that any 54 | patent must be licensed for everyone's free use or not licensed at all. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | GNU GENERAL PUBLIC LICENSE 60 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 61 | 62 | 0. This License applies to any program or other work which contains 63 | a notice placed by the copyright holder saying it may be distributed 64 | under the terms of this General Public License. The "Program", below, 65 | refers to any such program or work, and a "work based on the Program" 66 | means either the Program or any derivative work under copyright law: 67 | that is to say, a work containing the Program or a portion of it, 68 | either verbatim or with modifications and/or translated into another 69 | language. (Hereinafter, translation is included without limitation in 70 | the term "modification".) Each licensee is addressed as "you". 71 | 72 | Activities other than copying, distribution and modification are not 73 | covered by this License; they are outside its scope. The act of 74 | running the Program is not restricted, and the output from the Program 75 | is covered only if its contents constitute a work based on the 76 | Program (independent of having been made by running the Program). 77 | Whether that is true depends on what the Program does. 78 | 79 | 1. You may copy and distribute verbatim copies of the Program's 80 | source code as you receive it, in any medium, provided that you 81 | conspicuously and appropriately publish on each copy an appropriate 82 | copyright notice and disclaimer of warranty; keep intact all the 83 | notices that refer to this License and to the absence of any warranty; 84 | and give any other recipients of the Program a copy of this License 85 | along with the Program. 86 | 87 | You may charge a fee for the physical act of transferring a copy, and 88 | you may at your option offer warranty protection in exchange for a fee. 89 | 90 | 2. You may modify your copy or copies of the Program or any portion 91 | of it, thus forming a work based on the Program, and copy and 92 | distribute such modifications or work under the terms of Section 1 93 | above, provided that you also meet all of these conditions: 94 | 95 | a) You must cause the modified files to carry prominent notices 96 | stating that you changed the files and the date of any change. 97 | 98 | b) You must cause any work that you distribute or publish, that in 99 | whole or in part contains or is derived from the Program or any 100 | part thereof, to be licensed as a whole at no charge to all third 101 | parties under the terms of this License. 102 | 103 | c) If the modified program normally reads commands interactively 104 | when run, you must cause it, when started running for such 105 | interactive use in the most ordinary way, to print or display an 106 | announcement including an appropriate copyright notice and a 107 | notice that there is no warranty (or else, saying that you provide 108 | a warranty) and that users may redistribute the program under 109 | these conditions, and telling the user how to view a copy of this 110 | License. (Exception: if the Program itself is interactive but 111 | does not normally print such an announcement, your work based on 112 | the Program is not required to print an announcement.) 113 | 114 | These requirements apply to the modified work as a whole. If 115 | identifiable sections of that work are not derived from the Program, 116 | and can be reasonably considered independent and separate works in 117 | themselves, then this License, and its terms, do not apply to those 118 | sections when you distribute them as separate works. But when you 119 | distribute the same sections as part of a whole which is a work based 120 | on the Program, the distribution of the whole must be on the terms of 121 | this License, whose permissions for other licensees extend to the 122 | entire whole, and thus to each and every part regardless of who wrote it. 123 | 124 | Thus, it is not the intent of this section to claim rights or contest 125 | your rights to work written entirely by you; rather, the intent is to 126 | exercise the right to control the distribution of derivative or 127 | collective works based on the Program. 128 | 129 | In addition, mere aggregation of another work not based on the Program 130 | with the Program (or with a work based on the Program) on a volume of 131 | a storage or distribution medium does not bring the other work under 132 | the scope of this License. 133 | 134 | 3. You may copy and distribute the Program (or a work based on it, 135 | under Section 2) in object code or executable form under the terms of 136 | Sections 1 and 2 above provided that you also do one of the following: 137 | 138 | a) Accompany it with the complete corresponding machine-readable 139 | source code, which must be distributed under the terms of Sections 140 | 1 and 2 above on a medium customarily used for software interchange; or, 141 | 142 | b) Accompany it with a written offer, valid for at least three 143 | years, to give any third party, for a charge no more than your 144 | cost of physically performing source distribution, a complete 145 | machine-readable copy of the corresponding source code, to be 146 | distributed under the terms of Sections 1 and 2 above on a medium 147 | customarily used for software interchange; or, 148 | 149 | c) Accompany it with the information you received as to the offer 150 | to distribute corresponding source code. (This alternative is 151 | allowed only for noncommercial distribution and only if you 152 | received the program in object code or executable form with such 153 | an offer, in accord with Subsection b above.) 154 | 155 | The source code for a work means the preferred form of the work for 156 | making modifications to it. For an executable work, complete source 157 | code means all the source code for all modules it contains, plus any 158 | associated interface definition files, plus the scripts used to 159 | control compilation and installation of the executable. However, as a 160 | special exception, the source code distributed need not include 161 | anything that is normally distributed (in either source or binary 162 | form) with the major components (compiler, kernel, and so on) of the 163 | operating system on which the executable runs, unless that component 164 | itself accompanies the executable. 165 | 166 | If distribution of executable or object code is made by offering 167 | access to copy from a designated place, then offering equivalent 168 | access to copy the source code from the same place counts as 169 | distribution of the source code, even though third parties are not 170 | compelled to copy the source along with the object code. 171 | 172 | 4. You may not copy, modify, sublicense, or distribute the Program 173 | except as expressly provided under this License. Any attempt 174 | otherwise to copy, modify, sublicense or distribute the Program is 175 | void, and will automatically terminate your rights under this License. 176 | However, parties who have received copies, or rights, from you under 177 | this License will not have their licenses terminated so long as such 178 | parties remain in full compliance. 179 | 180 | 5. You are not required to accept this License, since you have not 181 | signed it. However, nothing else grants you permission to modify or 182 | distribute the Program or its derivative works. These actions are 183 | prohibited by law if you do not accept this License. Therefore, by 184 | modifying or distributing the Program (or any work based on the 185 | Program), you indicate your acceptance of this License to do so, and 186 | all its terms and conditions for copying, distributing or modifying 187 | the Program or works based on it. 188 | 189 | 6. Each time you redistribute the Program (or any work based on the 190 | Program), the recipient automatically receives a license from the 191 | original licensor to copy, distribute or modify the Program subject to 192 | these terms and conditions. You may not impose any further 193 | restrictions on the recipients' exercise of the rights granted herein. 194 | You are not responsible for enforcing compliance by third parties to 195 | this License. 196 | 197 | 7. If, as a consequence of a court judgment or allegation of patent 198 | infringement or for any other reason (not limited to patent issues), 199 | conditions are imposed on you (whether by court order, agreement or 200 | otherwise) that contradict the conditions of this License, they do not 201 | excuse you from the conditions of this License. If you cannot 202 | distribute so as to satisfy simultaneously your obligations under this 203 | License and any other pertinent obligations, then as a consequence you 204 | may not distribute the Program at all. For example, if a patent 205 | license would not permit royalty-free redistribution of the Program by 206 | all those who receive copies directly or indirectly through you, then 207 | the only way you could satisfy both it and this License would be to 208 | refrain entirely from distribution of the Program. 209 | 210 | If any portion of this section is held invalid or unenforceable under 211 | any particular circumstance, the balance of the section is intended to 212 | apply and the section as a whole is intended to apply in other 213 | circumstances. 214 | 215 | It is not the purpose of this section to induce you to infringe any 216 | patents or other property right claims or to contest validity of any 217 | such claims; this section has the sole purpose of protecting the 218 | integrity of the free software distribution system, which is 219 | implemented by public license practices. Many people have made 220 | generous contributions to the wide range of software distributed 221 | through that system in reliance on consistent application of that 222 | system; it is up to the author/donor to decide if he or she is willing 223 | to distribute software through any other system and a licensee cannot 224 | impose that choice. 225 | 226 | This section is intended to make thoroughly clear what is believed to 227 | be a consequence of the rest of this License. 228 | 229 | 8. If the distribution and/or use of the Program is restricted in 230 | certain countries either by patents or by copyrighted interfaces, the 231 | original copyright holder who places the Program under this License 232 | may add an explicit geographical distribution limitation excluding 233 | those countries, so that distribution is permitted only in or among 234 | countries not thus excluded. In such case, this License incorporates 235 | the limitation as if written in the body of this License. 236 | 237 | 9. The Free Software Foundation may publish revised and/or new versions 238 | of the General Public License from time to time. Such new versions will 239 | be similar in spirit to the present version, but may differ in detail to 240 | address new problems or concerns. 241 | 242 | Each version is given a distinguishing version number. If the Program 243 | specifies a version number of this License which applies to it and "any 244 | later version", you have the option of following the terms and conditions 245 | either of that version or of any later version published by the Free 246 | Software Foundation. If the Program does not specify a version number of 247 | this License, you may choose any version ever published by the Free Software 248 | Foundation. 249 | 250 | 10. If you wish to incorporate parts of the Program into other free 251 | programs whose distribution conditions are different, write to the author 252 | to ask for permission. For software which is copyrighted by the Free 253 | Software Foundation, write to the Free Software Foundation; we sometimes 254 | make exceptions for this. Our decision will be guided by the two goals 255 | of preserving the free status of all derivatives of our free software and 256 | of promoting the sharing and reuse of software generally. 257 | 258 | NO WARRANTY 259 | 260 | 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY 261 | FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN 262 | OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES 263 | PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED 264 | OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 265 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS 266 | TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE 267 | PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, 268 | REPAIR OR CORRECTION. 269 | 270 | 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 271 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR 272 | REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, 273 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING 274 | OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED 275 | TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY 276 | YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER 277 | PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE 278 | POSSIBILITY OF SUCH DAMAGES. 279 | 280 | END OF TERMS AND CONDITIONS 281 | 282 | How to Apply These Terms to Your New Programs 283 | 284 | If you develop a new program, and you want it to be of the greatest 285 | possible use to the public, the best way to achieve this is to make it 286 | free software which everyone can redistribute and change under these terms. 287 | 288 | To do so, attach the following notices to the program. It is safest 289 | to attach them to the start of each source file to most effectively 290 | convey the exclusion of warranty; and each file should have at least 291 | the "copyright" line and a pointer to where the full notice is found. 292 | 293 | 294 | Copyright (C) 295 | 296 | This program is free software; you can redistribute it and/or modify 297 | it under the terms of the GNU General Public License as published by 298 | the Free Software Foundation; either version 2 of the License, or 299 | (at your option) any later version. 300 | 301 | This program is distributed in the hope that it will be useful, 302 | but WITHOUT ANY WARRANTY; without even the implied warranty of 303 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 304 | GNU General Public License for more details. 305 | 306 | You should have received a copy of the GNU General Public License along 307 | with this program; if not, write to the Free Software Foundation, Inc., 308 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 309 | 310 | Also add information on how to contact you by electronic and paper mail. 311 | 312 | If the program is interactive, make it output a short notice like this 313 | when it starts in an interactive mode: 314 | 315 | Gnomovision version 69, Copyright (C) year name of author 316 | Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 317 | This is free software, and you are welcome to redistribute it 318 | under certain conditions; type `show c' for details. 319 | 320 | The hypothetical commands `show w' and `show c' should show the appropriate 321 | parts of the General Public License. Of course, the commands you use may 322 | be called something other than `show w' and `show c'; they could even be 323 | mouse-clicks or menu items--whatever suits your program. 324 | 325 | You should also get your employer (if you work as a programmer) or your 326 | school, if any, to sign a "copyright disclaimer" for the program, if 327 | necessary. Here is a sample; alter the names: 328 | 329 | Yoyodyne, Inc., hereby disclaims all copyright interest in the program 330 | `Gnomovision' (which makes passes at compilers) written by James Hacker. 331 | 332 | , 1 April 1989 333 | Ty Coon, President of Vice 334 | 335 | This General Public License does not permit incorporating your program into 336 | proprietary programs. If your program is a subroutine library, you may 337 | consider it more useful to permit linking proprietary applications with the 338 | library. If this is what you want to do, use the GNU Lesser General 339 | Public License instead of this License. 340 | -------------------------------------------------------------------------------- /lib/TwitterHandle.class.php: -------------------------------------------------------------------------------- 1 | consumerKey = \OpenFuego\TWITTER_CONSUMER_KEY; 13 | $this->consumerSecret = \OpenFuego\TWITTER_CONSUMER_SECRET; 14 | $this->accessToken = \OpenFuego\TWITTER_OAUTH_TOKEN; 15 | $this->accessTokenSecret = \OpenFuego\TWITTER_OAUTH_SECRET; 16 | 17 | try { 18 | parent::__construct( 19 | $this->consumerKey, 20 | $this->consumerSecret, 21 | $this->accessToken, 22 | $this->accessTokenSecret 23 | ); 24 | } 25 | 26 | catch (\PDOException $e) { 27 | Logger::error($e); 28 | } 29 | } 30 | 31 | // Overloading TwitterOAuth's get(), post(), delete() methods to decode JSON as array 32 | public function get($url, $parameters = array()) { 33 | $response = $this->oAuthRequest($url, 'GET', $parameters); 34 | if ($this->format === 'json' && $this->decode_json) { 35 | return json_decode($response, TRUE); 36 | } 37 | return $response; 38 | } 39 | 40 | function post($url, $parameters = array()) { 41 | $response = $this->oAuthRequest($url, 'POST', $parameters); 42 | if ($this->format === 'json' && $this->decode_json) { 43 | return json_decode($response, TRUE); 44 | } 45 | return $response; 46 | } 47 | 48 | function delete($url, $parameters = array()) { 49 | $response = $this->oAuthRequest($url, 'DELETE', $parameters); 50 | if ($this->format === 'json' && $this->decode_json) { 51 | return json_decode($response, TRUE); 52 | } 53 | return $response; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /lib/TwitterOAuth/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2009 Abraham Williams - http://abrah.am - abraham@abrah.am 2 | 3 | Permission is hereby granted, free of charge, to any person 4 | obtaining a copy of this software and associated documentation 5 | files (the "Software"), to deal in the Software without 6 | restriction, including without limitation the rights to use, 7 | copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the 9 | Software is furnished to do so, subject to the following 10 | conditions: 11 | 12 | The above copyright notice and this permission notice shall be 13 | included in all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 17 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 18 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 19 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 20 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 21 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /lib/TwitterOAuth/OAuth.class.php: -------------------------------------------------------------------------------- 1 | key = $key; 16 | $this->secret = $secret; 17 | $this->callback_url = $callback_url; 18 | } 19 | 20 | function __toString() { 21 | return "OAuthConsumer[key=$this->key,secret=$this->secret]"; 22 | } 23 | } 24 | 25 | class OAuthToken { 26 | // access tokens and request tokens 27 | public $key; 28 | public $secret; 29 | 30 | /** 31 | * key = the token 32 | * secret = the token secret 33 | */ 34 | function __construct($key, $secret) { 35 | $this->key = $key; 36 | $this->secret = $secret; 37 | } 38 | 39 | /** 40 | * generates the basic string serialization of a token that a server 41 | * would respond to request_token and access_token calls with 42 | */ 43 | function to_string() { 44 | return "oauth_token=" . 45 | OAuthUtil::urlencode_rfc3986($this->key) . 46 | "&oauth_token_secret=" . 47 | OAuthUtil::urlencode_rfc3986($this->secret); 48 | } 49 | 50 | function __toString() { 51 | return $this->to_string(); 52 | } 53 | } 54 | 55 | /** 56 | * A class for implementing a Signature Method 57 | * See section 9 ("Signing Requests") in the spec 58 | */ 59 | abstract class OAuthSignatureMethod { 60 | /** 61 | * Needs to return the name of the Signature Method (ie HMAC-SHA1) 62 | * @return string 63 | */ 64 | abstract public function get_name(); 65 | 66 | /** 67 | * Build up the signature 68 | * NOTE: The output of this function MUST NOT be urlencoded. 69 | * the encoding is handled in OAuthRequest when the final 70 | * request is serialized 71 | * @param OAuthRequest $request 72 | * @param OAuthConsumer $consumer 73 | * @param OAuthToken $token 74 | * @return string 75 | */ 76 | abstract public function build_signature($request, $consumer, $token); 77 | 78 | /** 79 | * Verifies that a given signature is correct 80 | * @param OAuthRequest $request 81 | * @param OAuthConsumer $consumer 82 | * @param OAuthToken $token 83 | * @param string $signature 84 | * @return bool 85 | */ 86 | public function check_signature($request, $consumer, $token, $signature) { 87 | $built = $this->build_signature($request, $consumer, $token); 88 | return $built == $signature; 89 | } 90 | } 91 | 92 | /** 93 | * The HMAC-SHA1 signature method uses the HMAC-SHA1 signature algorithm as defined in [RFC2104] 94 | * where the Signature Base String is the text and the key is the concatenated values (each first 95 | * encoded per Parameter Encoding) of the Consumer Secret and Token Secret, separated by an '&' 96 | * character (ASCII code 38) even if empty. 97 | * - Chapter 9.2 ("HMAC-SHA1") 98 | */ 99 | class OAuthSignatureMethod_HMAC_SHA1 extends OAuthSignatureMethod { 100 | function get_name() { 101 | return "HMAC-SHA1"; 102 | } 103 | 104 | public function build_signature($request, $consumer, $token) { 105 | $base_string = $request->get_signature_base_string(); 106 | $request->base_string = $base_string; 107 | 108 | $key_parts = array( 109 | $consumer->secret, 110 | ($token) ? $token->secret : "" 111 | ); 112 | 113 | $key_parts = OAuthUtil::urlencode_rfc3986($key_parts); 114 | $key = implode('&', $key_parts); 115 | 116 | return base64_encode(hash_hmac('sha1', $base_string, $key, true)); 117 | } 118 | } 119 | 120 | /** 121 | * The PLAINTEXT method does not provide any security protection and SHOULD only be used 122 | * over a secure channel such as HTTPS. It does not use the Signature Base String. 123 | * - Chapter 9.4 ("PLAINTEXT") 124 | */ 125 | class OAuthSignatureMethod_PLAINTEXT extends OAuthSignatureMethod { 126 | public function get_name() { 127 | return "PLAINTEXT"; 128 | } 129 | 130 | /** 131 | * oauth_signature is set to the concatenated encoded values of the Consumer Secret and 132 | * Token Secret, separated by a '&' character (ASCII code 38), even if either secret is 133 | * empty. The result MUST be encoded again. 134 | * - Chapter 9.4.1 ("Generating Signatures") 135 | * 136 | * Please note that the second encoding MUST NOT happen in the SignatureMethod, as 137 | * OAuthRequest handles this! 138 | */ 139 | public function build_signature($request, $consumer, $token) { 140 | $key_parts = array( 141 | $consumer->secret, 142 | ($token) ? $token->secret : "" 143 | ); 144 | 145 | $key_parts = OAuthUtil::urlencode_rfc3986($key_parts); 146 | $key = implode('&', $key_parts); 147 | $request->base_string = $key; 148 | 149 | return $key; 150 | } 151 | } 152 | 153 | /** 154 | * The RSA-SHA1 signature method uses the RSASSA-PKCS1-v1_5 signature algorithm as defined in 155 | * [RFC3447] section 8.2 (more simply known as PKCS#1), using SHA-1 as the hash function for 156 | * EMSA-PKCS1-v1_5. It is assumed that the Consumer has provided its RSA public key in a 157 | * verified way to the Service Provider, in a manner which is beyond the scope of this 158 | * specification. 159 | * - Chapter 9.3 ("RSA-SHA1") 160 | */ 161 | abstract class OAuthSignatureMethod_RSA_SHA1 extends OAuthSignatureMethod { 162 | public function get_name() { 163 | return "RSA-SHA1"; 164 | } 165 | 166 | // Up to the SP to implement this lookup of keys. Possible ideas are: 167 | // (1) do a lookup in a table of trusted certs keyed off of consumer 168 | // (2) fetch via http using a url provided by the requester 169 | // (3) some sort of specific discovery code based on request 170 | // 171 | // Either way should return a string representation of the certificate 172 | protected abstract function fetch_public_cert(&$request); 173 | 174 | // Up to the SP to implement this lookup of keys. Possible ideas are: 175 | // (1) do a lookup in a table of trusted certs keyed off of consumer 176 | // 177 | // Either way should return a string representation of the certificate 178 | protected abstract function fetch_private_cert(&$request); 179 | 180 | public function build_signature($request, $consumer, $token) { 181 | $base_string = $request->get_signature_base_string(); 182 | $request->base_string = $base_string; 183 | 184 | // Fetch the private key cert based on the request 185 | $cert = $this->fetch_private_cert($request); 186 | 187 | // Pull the private key ID from the certificate 188 | $privatekeyid = openssl_get_privatekey($cert); 189 | 190 | // Sign using the key 191 | $ok = openssl_sign($base_string, $signature, $privatekeyid); 192 | 193 | // Release the key resource 194 | openssl_free_key($privatekeyid); 195 | 196 | return base64_encode($signature); 197 | } 198 | 199 | public function check_signature($request, $consumer, $token, $signature) { 200 | $decoded_sig = base64_decode($signature); 201 | 202 | $base_string = $request->get_signature_base_string(); 203 | 204 | // Fetch the public key cert based on the request 205 | $cert = $this->fetch_public_cert($request); 206 | 207 | // Pull the public key ID from the certificate 208 | $publickeyid = openssl_get_publickey($cert); 209 | 210 | // Check the computed signature against the one passed in the query 211 | $ok = openssl_verify($base_string, $decoded_sig, $publickeyid); 212 | 213 | // Release the key resource 214 | openssl_free_key($publickeyid); 215 | 216 | return $ok == 1; 217 | } 218 | } 219 | 220 | class OAuthRequest { 221 | private $parameters; 222 | private $http_method; 223 | private $http_url; 224 | // for debug purposes 225 | public $base_string; 226 | public static $version = '1.0'; 227 | public static $POST_INPUT = 'php://input'; 228 | 229 | function __construct($http_method, $http_url, $parameters=NULL) { 230 | @$parameters or $parameters = array(); 231 | $parameters = array_merge( OAuthUtil::parse_parameters(parse_url($http_url, PHP_URL_QUERY)), $parameters); 232 | $this->parameters = $parameters; 233 | $this->http_method = $http_method; 234 | $this->http_url = $http_url; 235 | } 236 | 237 | 238 | /** 239 | * attempt to build up a request from what was passed to the server 240 | */ 241 | public static function from_request($http_method=NULL, $http_url=NULL, $parameters=NULL) { 242 | $scheme = (!isset($_SERVER['HTTPS']) || $_SERVER['HTTPS'] != "on") 243 | ? 'http' 244 | : 'https'; 245 | @$http_url or $http_url = $scheme . 246 | '://' . $_SERVER['HTTP_HOST'] . 247 | ':' . 248 | $_SERVER['SERVER_PORT'] . 249 | $_SERVER['REQUEST_URI']; 250 | @$http_method or $http_method = $_SERVER['REQUEST_METHOD']; 251 | 252 | // We weren't handed any parameters, so let's find the ones relevant to 253 | // this request. 254 | // If you run XML-RPC or similar you should use this to provide your own 255 | // parsed parameter-list 256 | if (!$parameters) { 257 | // Find request headers 258 | $request_headers = OAuthUtil::get_headers(); 259 | 260 | // Parse the query-string to find GET parameters 261 | $parameters = OAuthUtil::parse_parameters($_SERVER['QUERY_STRING']); 262 | 263 | // It's a POST request of the proper content-type, so parse POST 264 | // parameters and add those overriding any duplicates from GET 265 | if ($http_method == "POST" 266 | && @strstr($request_headers["Content-Type"], 267 | "application/x-www-form-urlencoded") 268 | ) { 269 | $post_data = OAuthUtil::parse_parameters( 270 | file_get_contents(self::$POST_INPUT) 271 | ); 272 | $parameters = array_merge($parameters, $post_data); 273 | } 274 | 275 | // We have a Authorization-header with OAuth data. Parse the header 276 | // and add those overriding any duplicates from GET or POST 277 | if (@substr($request_headers['Authorization'], 0, 6) == "OAuth ") { 278 | $header_parameters = OAuthUtil::split_header( 279 | $request_headers['Authorization'] 280 | ); 281 | $parameters = array_merge($parameters, $header_parameters); 282 | } 283 | 284 | } 285 | 286 | return new OAuthRequest($http_method, $http_url, $parameters); 287 | } 288 | 289 | /** 290 | * pretty much a helper function to set up the request 291 | */ 292 | public static function from_consumer_and_token($consumer, $token, $http_method, $http_url, $parameters=NULL) { 293 | @$parameters or $parameters = array(); 294 | $defaults = array("oauth_version" => OAuthRequest::$version, 295 | "oauth_nonce" => OAuthRequest::generate_nonce(), 296 | "oauth_timestamp" => OAuthRequest::generate_timestamp(), 297 | "oauth_consumer_key" => $consumer->key); 298 | if ($token) 299 | $defaults['oauth_token'] = $token->key; 300 | 301 | $parameters = array_merge($defaults, $parameters); 302 | 303 | return new OAuthRequest($http_method, $http_url, $parameters); 304 | } 305 | 306 | public function set_parameter($name, $value, $allow_duplicates = true) { 307 | if ($allow_duplicates && isset($this->parameters[$name])) { 308 | // We have already added parameter(s) with this name, so add to the list 309 | if (is_scalar($this->parameters[$name])) { 310 | // This is the first duplicate, so transform scalar (string) 311 | // into an array so we can add the duplicates 312 | $this->parameters[$name] = array($this->parameters[$name]); 313 | } 314 | 315 | $this->parameters[$name][] = $value; 316 | } else { 317 | $this->parameters[$name] = $value; 318 | } 319 | } 320 | 321 | public function get_parameter($name) { 322 | return isset($this->parameters[$name]) ? $this->parameters[$name] : null; 323 | } 324 | 325 | public function get_parameters() { 326 | return $this->parameters; 327 | } 328 | 329 | public function unset_parameter($name) { 330 | unset($this->parameters[$name]); 331 | } 332 | 333 | /** 334 | * The request parameters, sorted and concatenated into a normalized string. 335 | * @return string 336 | */ 337 | public function get_signable_parameters() { 338 | // Grab all parameters 339 | $params = $this->parameters; 340 | 341 | // Remove oauth_signature if present 342 | // Ref: Spec: 9.1.1 ("The oauth_signature parameter MUST be excluded.") 343 | if (isset($params['oauth_signature'])) { 344 | unset($params['oauth_signature']); 345 | } 346 | 347 | return OAuthUtil::build_http_query($params); 348 | } 349 | 350 | /** 351 | * Returns the base string of this request 352 | * 353 | * The base string defined as the method, the url 354 | * and the parameters (normalized), each urlencoded 355 | * and the concated with &. 356 | */ 357 | public function get_signature_base_string() { 358 | $parts = array( 359 | $this->get_normalized_http_method(), 360 | $this->get_normalized_http_url(), 361 | $this->get_signable_parameters() 362 | ); 363 | 364 | $parts = OAuthUtil::urlencode_rfc3986($parts); 365 | 366 | return implode('&', $parts); 367 | } 368 | 369 | /** 370 | * just uppercases the http method 371 | */ 372 | public function get_normalized_http_method() { 373 | return strtoupper($this->http_method); 374 | } 375 | 376 | /** 377 | * parses the url and rebuilds it to be 378 | * scheme://host/path 379 | */ 380 | public function get_normalized_http_url() { 381 | $parts = parse_url($this->http_url); 382 | 383 | $port = @$parts['port']; 384 | $scheme = $parts['scheme']; 385 | $host = $parts['host']; 386 | $path = @$parts['path']; 387 | 388 | $port or $port = ($scheme == 'https') ? '443' : '80'; 389 | 390 | if (($scheme == 'https' && $port != '443') 391 | || ($scheme == 'http' && $port != '80')) { 392 | $host = "$host:$port"; 393 | } 394 | return "$scheme://$host$path"; 395 | } 396 | 397 | /** 398 | * builds a url usable for a GET request 399 | */ 400 | public function to_url() { 401 | $post_data = $this->to_postdata(); 402 | $out = $this->get_normalized_http_url(); 403 | if ($post_data) { 404 | $out .= '?'.$post_data; 405 | } 406 | return $out; 407 | } 408 | 409 | /** 410 | * builds the data one would send in a POST request 411 | */ 412 | public function to_postdata() { 413 | return OAuthUtil::build_http_query($this->parameters); 414 | } 415 | 416 | /** 417 | * builds the Authorization: header 418 | */ 419 | public function to_header($realm=null) { 420 | $first = true; 421 | if($realm) { 422 | $out = 'Authorization: OAuth realm="' . OAuthUtil::urlencode_rfc3986($realm) . '"'; 423 | $first = false; 424 | } else 425 | $out = 'Authorization: OAuth'; 426 | 427 | $total = array(); 428 | foreach ($this->parameters as $k => $v) { 429 | if (substr($k, 0, 5) != "oauth") continue; 430 | if (is_array($v)) { 431 | throw new OAuthException('Arrays not supported in headers'); 432 | } 433 | $out .= ($first) ? ' ' : ','; 434 | $out .= OAuthUtil::urlencode_rfc3986($k) . 435 | '="' . 436 | OAuthUtil::urlencode_rfc3986($v) . 437 | '"'; 438 | $first = false; 439 | } 440 | return $out; 441 | } 442 | 443 | public function __toString() { 444 | return $this->to_url(); 445 | } 446 | 447 | 448 | public function sign_request($signature_method, $consumer, $token) { 449 | $this->set_parameter( 450 | "oauth_signature_method", 451 | $signature_method->get_name(), 452 | false 453 | ); 454 | $signature = $this->build_signature($signature_method, $consumer, $token); 455 | $this->set_parameter("oauth_signature", $signature, false); 456 | } 457 | 458 | public function build_signature($signature_method, $consumer, $token) { 459 | $signature = $signature_method->build_signature($this, $consumer, $token); 460 | return $signature; 461 | } 462 | 463 | /** 464 | * util function: current timestamp 465 | */ 466 | private static function generate_timestamp() { 467 | return time(); 468 | } 469 | 470 | /** 471 | * util function: current nonce 472 | */ 473 | private static function generate_nonce() { 474 | $mt = microtime(); 475 | $rand = mt_rand(); 476 | 477 | return md5($mt . $rand); // md5s look nicer than numbers 478 | } 479 | } 480 | 481 | class OAuthServer { 482 | protected $timestamp_threshold = 300; // in seconds, five minutes 483 | protected $version = '1.0'; // hi blaine 484 | protected $signature_methods = array(); 485 | 486 | protected $data_store; 487 | 488 | function __construct($data_store) { 489 | $this->data_store = $data_store; 490 | } 491 | 492 | public function add_signature_method($signature_method) { 493 | $this->signature_methods[$signature_method->get_name()] = 494 | $signature_method; 495 | } 496 | 497 | // high level functions 498 | 499 | /** 500 | * process a request_token request 501 | * returns the request token on success 502 | */ 503 | public function fetch_request_token(&$request) { 504 | $this->get_version($request); 505 | 506 | $consumer = $this->get_consumer($request); 507 | 508 | // no token required for the initial token request 509 | $token = NULL; 510 | 511 | $this->check_signature($request, $consumer, $token); 512 | 513 | // Rev A change 514 | $callback = $request->get_parameter('oauth_callback'); 515 | $new_token = $this->data_store->new_request_token($consumer, $callback); 516 | 517 | return $new_token; 518 | } 519 | 520 | /** 521 | * process an access_token request 522 | * returns the access token on success 523 | */ 524 | public function fetch_access_token(&$request) { 525 | $this->get_version($request); 526 | 527 | $consumer = $this->get_consumer($request); 528 | 529 | // requires authorized request token 530 | $token = $this->get_token($request, $consumer, "request"); 531 | 532 | $this->check_signature($request, $consumer, $token); 533 | 534 | // Rev A change 535 | $verifier = $request->get_parameter('oauth_verifier'); 536 | $new_token = $this->data_store->new_access_token($token, $consumer, $verifier); 537 | 538 | return $new_token; 539 | } 540 | 541 | /** 542 | * verify an api call, checks all the parameters 543 | */ 544 | public function verify_request(&$request) { 545 | $this->get_version($request); 546 | $consumer = $this->get_consumer($request); 547 | $token = $this->get_token($request, $consumer, "access"); 548 | $this->check_signature($request, $consumer, $token); 549 | return array($consumer, $token); 550 | } 551 | 552 | // Internals from here 553 | /** 554 | * version 1 555 | */ 556 | private function get_version(&$request) { 557 | $version = $request->get_parameter("oauth_version"); 558 | if (!$version) { 559 | // Service Providers MUST assume the protocol version to be 1.0 if this parameter is not present. 560 | // Chapter 7.0 ("Accessing Protected Ressources") 561 | $version = '1.0'; 562 | } 563 | if ($version !== $this->version) { 564 | throw new OAuthException("OAuth version '$version' not supported"); 565 | } 566 | return $version; 567 | } 568 | 569 | /** 570 | * figure out the signature with some defaults 571 | */ 572 | private function get_signature_method(&$request) { 573 | $signature_method = 574 | @$request->get_parameter("oauth_signature_method"); 575 | 576 | if (!$signature_method) { 577 | // According to chapter 7 ("Accessing Protected Ressources") the signature-method 578 | // parameter is required, and we can't just fallback to PLAINTEXT 579 | throw new OAuthException('No signature method parameter. This parameter is required'); 580 | } 581 | 582 | if (!in_array($signature_method, 583 | array_keys($this->signature_methods))) { 584 | throw new OAuthException( 585 | "Signature method '$signature_method' not supported " . 586 | "try one of the following: " . 587 | implode(", ", array_keys($this->signature_methods)) 588 | ); 589 | } 590 | return $this->signature_methods[$signature_method]; 591 | } 592 | 593 | /** 594 | * try to find the consumer for the provided request's consumer key 595 | */ 596 | private function get_consumer(&$request) { 597 | $consumer_key = @$request->get_parameter("oauth_consumer_key"); 598 | if (!$consumer_key) { 599 | throw new OAuthException("Invalid consumer key"); 600 | } 601 | 602 | $consumer = $this->data_store->lookup_consumer($consumer_key); 603 | if (!$consumer) { 604 | throw new OAuthException("Invalid consumer"); 605 | } 606 | 607 | return $consumer; 608 | } 609 | 610 | /** 611 | * try to find the token for the provided request's token key 612 | */ 613 | private function get_token(&$request, $consumer, $token_type="access") { 614 | $token_field = @$request->get_parameter('oauth_token'); 615 | $token = $this->data_store->lookup_token( 616 | $consumer, $token_type, $token_field 617 | ); 618 | if (!$token) { 619 | throw new OAuthException("Invalid $token_type token: $token_field"); 620 | } 621 | return $token; 622 | } 623 | 624 | /** 625 | * all-in-one function to check the signature on a request 626 | * should guess the signature method appropriately 627 | */ 628 | private function check_signature(&$request, $consumer, $token) { 629 | // this should probably be in a different method 630 | $timestamp = @$request->get_parameter('oauth_timestamp'); 631 | $nonce = @$request->get_parameter('oauth_nonce'); 632 | 633 | $this->check_timestamp($timestamp); 634 | $this->check_nonce($consumer, $token, $nonce, $timestamp); 635 | 636 | $signature_method = $this->get_signature_method($request); 637 | 638 | $signature = $request->get_parameter('oauth_signature'); 639 | $valid_sig = $signature_method->check_signature( 640 | $request, 641 | $consumer, 642 | $token, 643 | $signature 644 | ); 645 | 646 | if (!$valid_sig) { 647 | throw new OAuthException("Invalid signature"); 648 | } 649 | } 650 | 651 | /** 652 | * check that the timestamp is new enough 653 | */ 654 | private function check_timestamp($timestamp) { 655 | if( ! $timestamp ) 656 | throw new OAuthException( 657 | 'Missing timestamp parameter. The parameter is required' 658 | ); 659 | 660 | // verify that timestamp is recentish 661 | $now = time(); 662 | if (abs($now - $timestamp) > $this->timestamp_threshold) { 663 | throw new OAuthException( 664 | "Expired timestamp, yours $timestamp, ours $now" 665 | ); 666 | } 667 | } 668 | 669 | /** 670 | * check that the nonce is not repeated 671 | */ 672 | private function check_nonce($consumer, $token, $nonce, $timestamp) { 673 | if( ! $nonce ) 674 | throw new OAuthException( 675 | 'Missing nonce parameter. The parameter is required' 676 | ); 677 | 678 | // verify that the nonce is uniqueish 679 | $found = $this->data_store->lookup_nonce( 680 | $consumer, 681 | $token, 682 | $nonce, 683 | $timestamp 684 | ); 685 | if ($found) { 686 | throw new OAuthException("Nonce already used: $nonce"); 687 | } 688 | } 689 | 690 | } 691 | 692 | class OAuthDataStore { 693 | function lookup_consumer($consumer_key) { 694 | // implement me 695 | } 696 | 697 | function lookup_token($consumer, $token_type, $token) { 698 | // implement me 699 | } 700 | 701 | function lookup_nonce($consumer, $token, $nonce, $timestamp) { 702 | // implement me 703 | } 704 | 705 | function new_request_token($consumer, $callback = null) { 706 | // return a new token attached to this consumer 707 | } 708 | 709 | function new_access_token($token, $consumer, $verifier = null) { 710 | // return a new access token attached to this consumer 711 | // for the user associated with this token if the request token 712 | // is authorized 713 | // should also invalidate the request token 714 | } 715 | 716 | } 717 | 718 | class OAuthUtil { 719 | public static function urlencode_rfc3986($input) { 720 | if (is_array($input)) { 721 | return array_map(array('OAuthUtil', 'urlencode_rfc3986'), $input); 722 | } else if (is_scalar($input)) { 723 | return str_replace( 724 | '+', 725 | ' ', 726 | str_replace('%7E', '~', rawurlencode($input)) 727 | ); 728 | } else { 729 | return ''; 730 | } 731 | } 732 | 733 | 734 | // This decode function isn't taking into consideration the above 735 | // modifications to the encoding process. However, this method doesn't 736 | // seem to be used anywhere so leaving it as is. 737 | public static function urldecode_rfc3986($string) { 738 | return urldecode($string); 739 | } 740 | 741 | // Utility function for turning the Authorization: header into 742 | // parameters, has to do some unescaping 743 | // Can filter out any non-oauth parameters if needed (default behaviour) 744 | public static function split_header($header, $only_allow_oauth_parameters = true) { 745 | $pattern = '/(([-_a-z]*)=("([^"]*)"|([^,]*)),?)/'; 746 | $offset = 0; 747 | $params = array(); 748 | while (preg_match($pattern, $header, $matches, PREG_OFFSET_CAPTURE, $offset) > 0) { 749 | $match = $matches[0]; 750 | $header_name = $matches[2][0]; 751 | $header_content = (isset($matches[5])) ? $matches[5][0] : $matches[4][0]; 752 | if (preg_match('/^oauth_/', $header_name) || !$only_allow_oauth_parameters) { 753 | $params[$header_name] = OAuthUtil::urldecode_rfc3986($header_content); 754 | } 755 | $offset = $match[1] + strlen($match[0]); 756 | } 757 | 758 | if (isset($params['realm'])) { 759 | unset($params['realm']); 760 | } 761 | 762 | return $params; 763 | } 764 | 765 | // helper to try to sort out headers for people who aren't running apache 766 | public static function get_headers() { 767 | if (function_exists('apache_request_headers')) { 768 | // we need this to get the actual Authorization: header 769 | // because apache tends to tell us it doesn't exist 770 | $headers = apache_request_headers(); 771 | 772 | // sanitize the output of apache_request_headers because 773 | // we always want the keys to be Cased-Like-This and arh() 774 | // returns the headers in the same case as they are in the 775 | // request 776 | $out = array(); 777 | foreach( $headers AS $key => $value ) { 778 | $key = str_replace( 779 | " ", 780 | "-", 781 | ucwords(strtolower(str_replace("-", " ", $key))) 782 | ); 783 | $out[$key] = $value; 784 | } 785 | } else { 786 | // otherwise we don't have apache and are just going to have to hope 787 | // that $_SERVER actually contains what we need 788 | $out = array(); 789 | if( isset($_SERVER['CONTENT_TYPE']) ) 790 | $out['Content-Type'] = $_SERVER['CONTENT_TYPE']; 791 | if( isset($_ENV['CONTENT_TYPE']) ) 792 | $out['Content-Type'] = $_ENV['CONTENT_TYPE']; 793 | 794 | foreach ($_SERVER as $key => $value) { 795 | if (substr($key, 0, 5) == "HTTP_") { 796 | // this is chaos, basically it is just there to capitalize the first 797 | // letter of every word that is not an initial HTTP and strip HTTP 798 | // code from przemek 799 | $key = str_replace( 800 | " ", 801 | "-", 802 | ucwords(strtolower(str_replace("_", " ", substr($key, 5)))) 803 | ); 804 | $out[$key] = $value; 805 | } 806 | } 807 | } 808 | return $out; 809 | } 810 | 811 | // This function takes a input like a=b&a=c&d=e and returns the parsed 812 | // parameters like this 813 | // array('a' => array('b','c'), 'd' => 'e') 814 | public static function parse_parameters( $input ) { 815 | if (!isset($input) || !$input) return array(); 816 | 817 | $pairs = explode('&', $input); 818 | 819 | $parsed_parameters = array(); 820 | foreach ($pairs as $pair) { 821 | $split = explode('=', $pair, 2); 822 | $parameter = OAuthUtil::urldecode_rfc3986($split[0]); 823 | $value = isset($split[1]) ? OAuthUtil::urldecode_rfc3986($split[1]) : ''; 824 | 825 | if (isset($parsed_parameters[$parameter])) { 826 | // We have already recieved parameter(s) with this name, so add to the list 827 | // of parameters with this name 828 | 829 | if (is_scalar($parsed_parameters[$parameter])) { 830 | // This is the first duplicate, so transform scalar (string) into an array 831 | // so we can add the duplicates 832 | $parsed_parameters[$parameter] = array($parsed_parameters[$parameter]); 833 | } 834 | 835 | $parsed_parameters[$parameter][] = $value; 836 | } else { 837 | $parsed_parameters[$parameter] = $value; 838 | } 839 | } 840 | return $parsed_parameters; 841 | } 842 | 843 | public static function build_http_query($params) { 844 | if (!$params) return ''; 845 | 846 | // Urlencode both keys and values 847 | $keys = OAuthUtil::urlencode_rfc3986(array_keys($params)); 848 | $values = OAuthUtil::urlencode_rfc3986(array_values($params)); 849 | $params = array_combine($keys, $values); 850 | 851 | // Parameters are sorted by name, using lexicographical byte value ordering. 852 | // Ref: Spec: 9.1.1 (1) 853 | uksort($params, 'strcmp'); 854 | 855 | $pairs = array(); 856 | foreach ($params as $parameter => $value) { 857 | if (is_array($value)) { 858 | // If two or more parameters share the same name, they are sorted by their value 859 | // Ref: Spec: 9.1.1 (1) 860 | natsort($value); 861 | foreach ($value as $duplicate_value) { 862 | $pairs[] = $parameter . '=' . $duplicate_value; 863 | } 864 | } else { 865 | $pairs[] = $parameter . '=' . $value; 866 | } 867 | } 868 | // For each parameter, the name is separated from the corresponding value by an '=' character (ASCII code 61) 869 | // Each name-value pair is separated by an '&' character (ASCII code 38) 870 | return implode('&', $pairs); 871 | } 872 | } 873 | -------------------------------------------------------------------------------- /lib/TwitterOAuth/TwitterOAuth.class.php: -------------------------------------------------------------------------------- 1 | http_status; } 54 | function lastAPICall() { return $this->last_api_call; } 55 | 56 | /** 57 | * construct TwitterOAuth object 58 | */ 59 | function __construct($consumer_key, $consumer_secret, $oauth_token = NULL, $oauth_token_secret = NULL) { 60 | $this->sha1_method = new OAuthSignatureMethod_HMAC_SHA1(); 61 | $this->consumer = new OAuthConsumer($consumer_key, $consumer_secret); 62 | if (!empty($oauth_token) && !empty($oauth_token_secret)) { 63 | $this->token = new OAuthConsumer($oauth_token, $oauth_token_secret); 64 | } else { 65 | $this->token = NULL; 66 | } 67 | } 68 | 69 | 70 | /** 71 | * Get a request_token from Twitter 72 | * 73 | * @returns a key/value array containing oauth_token and oauth_token_secret 74 | */ 75 | function getRequestToken($oauth_callback) { 76 | $parameters = array(); 77 | $parameters['oauth_callback'] = $oauth_callback; 78 | $request = $this->oAuthRequest($this->requestTokenURL(), 'GET', $parameters); 79 | $token = OAuthUtil::parse_parameters($request); 80 | $this->token = new OAuthConsumer($token['oauth_token'], $token['oauth_token_secret']); 81 | return $token; 82 | } 83 | 84 | /** 85 | * Get the authorize URL 86 | * 87 | * @returns a string 88 | */ 89 | function getAuthorizeURL($token, $sign_in_with_twitter = TRUE) { 90 | if (is_array($token)) { 91 | $token = $token['oauth_token']; 92 | } 93 | if (empty($sign_in_with_twitter)) { 94 | return $this->authorizeURL() . "?oauth_token={$token}"; 95 | } else { 96 | return $this->authenticateURL() . "?oauth_token={$token}"; 97 | } 98 | } 99 | 100 | /** 101 | * Exchange request token and secret for an access token and 102 | * secret, to sign API calls. 103 | * 104 | * @returns array("oauth_token" => "the-access-token", 105 | * "oauth_token_secret" => "the-access-secret", 106 | * "user_id" => "9436992", 107 | * "screen_name" => "abraham") 108 | */ 109 | function getAccessToken($oauth_verifier) { 110 | $parameters = array(); 111 | $parameters['oauth_verifier'] = $oauth_verifier; 112 | $request = $this->oAuthRequest($this->accessTokenURL(), 'GET', $parameters); 113 | $token = OAuthUtil::parse_parameters($request); 114 | $this->token = new OAuthConsumer($token['oauth_token'], $token['oauth_token_secret']); 115 | return $token; 116 | } 117 | 118 | /** 119 | * One time exchange of username and password for access token and secret. 120 | * 121 | * @returns array("oauth_token" => "the-access-token", 122 | * "oauth_token_secret" => "the-access-secret", 123 | * "user_id" => "9436992", 124 | * "screen_name" => "abraham", 125 | * "x_auth_expires" => "0") 126 | */ 127 | function getXAuthToken($username, $password) { 128 | $parameters = array(); 129 | $parameters['x_auth_username'] = $username; 130 | $parameters['x_auth_password'] = $password; 131 | $parameters['x_auth_mode'] = 'client_auth'; 132 | $request = $this->oAuthRequest($this->accessTokenURL(), 'POST', $parameters); 133 | $token = OAuthUtil::parse_parameters($request); 134 | $this->token = new OAuthConsumer($token['oauth_token'], $token['oauth_token_secret']); 135 | return $token; 136 | } 137 | 138 | /** 139 | * GET wrapper for oAuthRequest. 140 | */ 141 | function get($url, $parameters = array()) { 142 | $response = $this->oAuthRequest($url, 'GET', $parameters); 143 | if ($this->format === 'json' && $this->decode_json) { 144 | return json_decode($response); 145 | } 146 | return $response; 147 | } 148 | 149 | /** 150 | * POST wrapper for oAuthRequest. 151 | */ 152 | function post($url, $parameters = array()) { 153 | $response = $this->oAuthRequest($url, 'POST', $parameters); 154 | if ($this->format === 'json' && $this->decode_json) { 155 | return json_decode($response); 156 | } 157 | return $response; 158 | } 159 | 160 | /** 161 | * DELETE wrapper for oAuthReqeust. 162 | */ 163 | function delete($url, $parameters = array()) { 164 | $response = $this->oAuthRequest($url, 'DELETE', $parameters); 165 | if ($this->format === 'json' && $this->decode_json) { 166 | return json_decode($response); 167 | } 168 | return $response; 169 | } 170 | 171 | /** 172 | * Format and sign an OAuth / API request 173 | */ 174 | function oAuthRequest($url, $method, $parameters) { 175 | if (strrpos($url, 'https://') !== 0 && strrpos($url, 'http://') !== 0) { 176 | $url = "{$this->host}{$url}.{$this->format}"; 177 | } 178 | $request = OAuthRequest::from_consumer_and_token($this->consumer, $this->token, $method, $url, $parameters); 179 | $request->sign_request($this->sha1_method, $this->consumer, $this->token); 180 | switch ($method) { 181 | case 'GET': 182 | return $this->http($request->to_url(), 'GET'); 183 | default: 184 | return $this->http($request->get_normalized_http_url(), $method, $request->to_postdata()); 185 | } 186 | } 187 | 188 | /** 189 | * Make an HTTP request 190 | * 191 | * @return API results 192 | */ 193 | function http($url, $method, $postfields = NULL) { 194 | $this->http_info = array(); 195 | $ci = curl_init(); 196 | /* Curl settings */ 197 | curl_setopt($ci, CURLOPT_USERAGENT, $this->useragent); 198 | curl_setopt($ci, CURLOPT_CONNECTTIMEOUT, $this->connecttimeout); 199 | curl_setopt($ci, CURLOPT_TIMEOUT, $this->timeout); 200 | curl_setopt($ci, CURLOPT_RETURNTRANSFER, TRUE); 201 | curl_setopt($ci, CURLOPT_HTTPHEADER, array('Expect:')); 202 | curl_setopt($ci, CURLOPT_SSL_VERIFYPEER, $this->ssl_verifypeer); 203 | curl_setopt($ci, CURLOPT_HEADERFUNCTION, array($this, 'getHeader')); 204 | curl_setopt($ci, CURLOPT_HEADER, FALSE); 205 | 206 | switch ($method) { 207 | case 'POST': 208 | curl_setopt($ci, CURLOPT_POST, TRUE); 209 | if (!empty($postfields)) { 210 | curl_setopt($ci, CURLOPT_POSTFIELDS, $postfields); 211 | } 212 | break; 213 | case 'DELETE': 214 | curl_setopt($ci, CURLOPT_CUSTOMREQUEST, 'DELETE'); 215 | if (!empty($postfields)) { 216 | $url = "{$url}?{$postfields}"; 217 | } 218 | } 219 | 220 | curl_setopt($ci, CURLOPT_URL, $url); 221 | $response = curl_exec($ci); 222 | $this->http_code = curl_getinfo($ci, CURLINFO_HTTP_CODE); 223 | $this->http_info = array_merge($this->http_info, curl_getinfo($ci)); 224 | $this->url = $url; 225 | curl_close ($ci); 226 | return $response; 227 | } 228 | 229 | /** 230 | * Get the header info to store. 231 | */ 232 | function getHeader($ch, $header) { 233 | $i = strpos($header, ':'); 234 | if (!empty($i)) { 235 | $key = str_replace('-', '_', strtolower(substr($header, 0, $i))); 236 | $value = trim(substr($header, $i + 2)); 237 | $this->http_header[$key] = $value; 238 | } 239 | return strlen($header); 240 | } 241 | } 242 | -------------------------------------------------------------------------------- /lib/UrlExpander.class.php: -------------------------------------------------------------------------------- 1 | _bitly_pro_domains = unserialize(\OpenFuego\BITLY_PRO_DOMAINS); 21 | $this->_short_domains = unserialize(\OpenFuego\SHORT_DOMAINS); 22 | 23 | if (defined('\OpenFuego\BITLY_USERNAME')) { 24 | $this->_bitly_username = \OpenFuego\BITLY_USERNAME; 25 | } 26 | 27 | if (defined('\OpenFuego\BITLY_API_KEY')) { 28 | $this->_bitly_api_key = \OpenFuego\BITLY_API_KEY; 29 | } 30 | 31 | if (defined('\OpenFuego\GOOGL_API_KEY')) { 32 | $this->_googl_api_key = \OpenFuego\GOOGL_API_KEY; 33 | } 34 | } 35 | 36 | 37 | protected function strpos_arr($haystack, $needles, $before = NULL, $after = NULL) { 38 | foreach($needles as $needle) { 39 | if(($pos = strpos($haystack, $before . $needle . $after)) !== FALSE) { 40 | return $pos; 41 | } 42 | } 43 | return FALSE; 44 | } 45 | 46 | 47 | public function expand($inputUrl) { 48 | 49 | $inputUrl = urldecode($inputUrl); 50 | 51 | if ($this->strpos_arr($inputUrl, $this->_short_domains, '://', '/')) { 52 | return $inputUrl; 53 | } 54 | 55 | elseif (strpos($inputUrl, '://' . 'youtu.be' . '/')) { 56 | 57 | $canonicalUrl = 'http://www.youtube.com/watch?v=' . str_replace('http://youtu.be/', '', $inputUrl); 58 | return $canonicalUrl; 59 | } 60 | 61 | $dbh = $this->getDbh(); 62 | $sql = " 63 | SELECT long_url 64 | FROM openfuego_short_links 65 | WHERE input_url = :input_url 66 | LIMIT 1; 67 | "; 68 | $sth = $dbh->prepare($sql); 69 | $sth->bindParam('input_url', $inputUrl); 70 | $sth->execute(); 71 | $cachedUrl = $sth->fetchColumn(0); 72 | 73 | if ($cachedUrl) { // if it exists in cache... 74 | return $cachedUrl; 75 | } 76 | 77 | if (strlen($inputUrl) > 36): // if the URL is unshortened 78 | 79 | $longUrl = $inputUrl; 80 | 81 | elseif (strpos($inputUrl, '://' . 'is.gd' . '/')): 82 | 83 | $longUrl = $this->isgd($inputUrl); 84 | 85 | elseif (strpos($inputUrl, '://' . 'goo.gl' . '/') && $this->_googl_api_key): 86 | 87 | $longUrl = $this->googl($inputUrl); 88 | 89 | elseif (strpos($inputUrl, '://' . 'su.pr' . '/')): 90 | 91 | $longUrl = $this->supr($inputUrl); 92 | 93 | elseif ($this->strpos_arr($inputUrl, $this->_bitly_pro_domains, '://', '/') && $this->_bitly_api_key && $this->_bitly_username): 94 | 95 | $longUrl = $this->bitly($inputUrl); 96 | 97 | else: 98 | $curl = $this->getCurl(); 99 | $longUrl = $curl->getLocation($inputUrl); 100 | $curl = NULL; 101 | 102 | endif; 103 | 104 | // done looping through expansion options. now, do we have a canonical URL? 105 | if ($longUrl) { 106 | $canonicalUrl = $this->getCanonical($longUrl); 107 | 108 | $outputUrl = $canonicalUrl ? $canonicalUrl : $longUrl; 109 | 110 | try { 111 | $sql = " 112 | INSERT INTO openfuego_short_links (input_url, long_url) 113 | VALUES (:input_url, :output_url); 114 | "; 115 | $sth = $dbh->prepare($sql); 116 | $sth->bindParam(':input_url', $inputUrl); 117 | $sth->bindParam(':output_url', $outputUrl); 118 | $sth->execute(); 119 | 120 | return $outputUrl; 121 | 122 | } catch (\PDOException $e) { 123 | Logger::error($e); 124 | return FALSE; 125 | } 126 | 127 | } else { 128 | return FALSE; 129 | } 130 | } 131 | 132 | 133 | public function bitly($shortUrl) { 134 | $shortUrl = urldecode($shortUrl); 135 | $shortUrlEncoded = urlencode($shortUrl); 136 | $query = self::BITLY_API_ROOT . 'expand?shortUrl=' . $shortUrlEncoded . '&login=' . $this->_bitly_username . '&apiKey=' . $this->_bitly_api_key . '&format=json'; 137 | $curl = $this->getCurl(); 138 | 139 | $bitlyExpanded = $curl->get($query); 140 | 141 | $curl = NULL; 142 | 143 | if ($bitlyExpanded) { 144 | $bitlyExpanded = json_decode($bitlyExpanded, TRUE); 145 | } 146 | 147 | else { 148 | return $shortUrl; 149 | } 150 | 151 | if (!empty($bitlyExpanded) && array_key_exists('long_url', $bitlyExpanded['data']['expand'][0])) { 152 | $longUrl = $bitlyExpanded['data']['expand'][0]['long_url']; 153 | return $longUrl; 154 | } 155 | 156 | else { 157 | return $shortUrl; 158 | } 159 | } 160 | 161 | 162 | public function isgd($shortUrl) { 163 | $shortUrl = urldecode($shortUrl); 164 | $shortUrlEncoded = urlencode($shortUrl); 165 | $query = self::ISGD_API_ROOT . '?shorturl=' . $shortUrlEncoded . '&format=json'; 166 | $curl = $this->getCurl(); 167 | 168 | $isgdExpanded = $curl->get($query); 169 | $isgdExpanded = json_decode($isgdExpanded, TRUE); 170 | 171 | $curl = NULL; 172 | 173 | // is.gd only returns errorcode if there is an error 174 | if (!$isgdExpanded || !is_array($isgdExpanded) || array_key_exists('errorcode', $isgdExpanded)) { 175 | Logger::error("is.gd error while expanding {$shortUrl}: {$isgdExpanded['errorcode']}"); 176 | return $shortUrl; 177 | } 178 | 179 | $longUrl = $isgdExpanded['url']; 180 | return $longUrl; 181 | } 182 | 183 | 184 | public function googl($shortUrl) { 185 | 186 | $shortUrl = urldecode($shortUrl); 187 | $shortUrlEncded = urlencode($shortUrl); 188 | $query = self::GOOGL_API_ROOT . '?shortUrl=' . $shortUrlEncded . '&key=' . $this->_googl_api_key; 189 | $curl = $this->getCurl(); 190 | 191 | $googlExpanded = $curl->get($query); 192 | $googlExpanded = json_decode($googlExpanded, TRUE); 193 | 194 | $curl = NULL; 195 | 196 | if ($googlExpanded['status'] != 'OK') { // if there's an error 197 | Logger::error("goo.gl error while expanding {$shortUrl}: {$googlExpanded['status']}"); 198 | return $shortUrl; 199 | } 200 | 201 | $longUrl = $googlExpanded['longUrl']; 202 | return $longUrl; 203 | } 204 | 205 | 206 | public function supr($shortUrl) { 207 | 208 | $shortUrl = urldecode($shortUrl); 209 | $shortUrlEncoded = urlencode($shortUrl); 210 | $query = self::SUPR_API_ROOT . '&shortUrl=' . $shortUrlEncoded; 211 | $curl = $this->getCurl(); 212 | 213 | $suprExpanded = $curl->get($query); 214 | $suprExpanded = json_decode($suprExpanded, TRUE); 215 | 216 | $curl = NULL; 217 | 218 | if ($suprExpanded['errorCode'] || !$suprExpanded) { 219 | Logger::error("su.pr error while expanding {$shortUrl}: {$suprExpanded['errorCode']}"); 220 | return $shortUrl; 221 | } 222 | 223 | $suprExpanded = array_values($suprExpanded); 224 | $suprExpanded = array_values($suprExpanded[2]); 225 | 226 | $longUrl = $suprExpanded[0]['longUrl']; 227 | return $longUrl; 228 | } 229 | 230 | 231 | public function getCanonical($url) { 232 | 233 | $curl = $this->getCurl(); 234 | 235 | if (preg_match('/(\?|\&|#)utm_/', $url, $matches)) { 236 | $url = strstr($url, $matches[1], TRUE); 237 | } 238 | 239 | $source = $curl->getChunk($url); 240 | 241 | $curl = NULL; 242 | 243 | $doc = new \DOMDocument; 244 | @$doc->loadHTML($source); 245 | unset($source); 246 | $xpath = new \DOMXpath($doc); 247 | unset($doc); 248 | $elms = $xpath->query("//link[@rel='canonical']"); 249 | unset($xpath); 250 | // if canonical is specified AND if canonical href is not blank 251 | // AND if canonical is not relative (no leading slash), good to go 252 | if ($elms->length > 0 && strlen($elms->item(0)->getAttribute('href')) > 0 && substr(trim($elms->item(0)->getAttribute('href')), 0, 1) != '/') { 253 | $canonicalUrl = trim($elms->item(0)->getAttribute('href')); 254 | return $canonicalUrl; 255 | 256 | } else { 257 | return $url; 258 | } 259 | } 260 | 261 | 262 | public function getDbh() { 263 | if (!$this->_dbh) { 264 | $this->_dbh = new DbHandle(); 265 | } 266 | 267 | return $this->_dbh; 268 | } 269 | 270 | 271 | public function getCurl() { 272 | if (!$this->_curl) { 273 | $this->_curl = new Curl(); 274 | } 275 | 276 | return $this->_curl; 277 | } 278 | 279 | 280 | public function __destruct() { 281 | $this->_curl = NULL; 282 | $this->_dbh = NULL; 283 | } 284 | } 285 | --------------------------------------------------------------------------------