]> git.mxchange.org Git - quix0rs-gnu-social.git/blob - plugins/Irc/extlib/phergie/Phergie/Plugin/Url.php
Added Phergie PHP IRC library
[quix0rs-gnu-social.git] / plugins / Irc / extlib / phergie / Phergie / Plugin / Url.php
1 <?php
2 /**
3  * Phergie 
4  *
5  * PHP version 5
6  *
7  * LICENSE
8  *
9  * This source file is subject to the new BSD license that is bundled
10  * with this package in the file LICENSE.
11  * It is also available through the world-wide-web at this URL:
12  * http://phergie.org/license
13  *
14  * @category  Phergie 
15  * @package   Phergie_Plugin_Url
16  * @author    Phergie Development Team <team@phergie.org>
17  * @copyright 2008-2010 Phergie Development Team (http://phergie.org)
18  * @license   http://phergie.org/license New BSD License
19  * @link      http://pear.phergie.org/package/Phergie_Plugin_Url
20  */
21
22 /**
23  * Monitors incoming messages for instances of URLs and responds with messages
24  * containing relevant information about detected URLs.
25  *
26  * Has an utility method accessible via 
27  * $this->getPlugin('Url')->getTitle('http://foo..').
28  *
29  * @category Phergie 
30  * @package  Phergie_Plugin_Url
31  * @author   Phergie Development Team <team@phergie.org>
32  * @license  http://phergie.org/license New BSD License
33  * @link     http://pear.phergie.org/package/Phergie_Plugin_Url
34  */
35 class Phergie_Plugin_Url extends Phergie_Plugin_Abstract
36 {
37     /**
38      * Links output format
39      *
40      * Can use the variables %nick%, %title% and %link% in it to display 
41      * page titles and links
42      *
43      * @var string
44      */
45     protected $baseFormat = '%nick%: %message%';
46     protected $messageFormat = '[ %link% ] %title%';
47
48     /**
49      * Flag indicating whether a single response should be sent for a single 
50      * message containing multiple links
51      *
52      * @var bool
53      */
54     protected $mergeLinks = true;
55
56     /**
57      * Max length of the fetched URL title
58      *
59      * @var int
60      */
61     protected $titleLength = 40;
62
63     /**
64      * Url cache to prevent spamming, especially with multiple bots on the 
65      * same channel
66      *
67      * @var array
68      */
69     protected $urlCache = array();
70     protected $shortCache = array();
71
72     /**
73      * Time in seconds to store the cached entries
74      *
75      * Setting it to 0 or below disables the cache expiration
76      *
77      * @var int
78      */
79     protected $expire = 1800;
80
81     /**
82      * Number of entries to keep in the cache at one time per channel
83      *
84      * Setting it to 0 or below disables the cache limit
85      *
86      * @var int
87      */
88     protected $limit = 10;
89
90     /**
91      * Flag that determines if the plugin will fall back to using an HTTP 
92      * stream when a URL using SSL is detected and OpenSSL support isn't 
93      * available in the PHP installation in use 
94      *
95      * @var bool
96      */
97     protected $sslFallback = true;
98
99     /**
100      * Flag that is set to true by the custom error handler if an HTTP error 
101      * code has been received
102      *
103      * @var boolean
104      */
105     protected $errorStatus = false;
106     protected $errorMessage = null;
107
108     /**
109      * Flag indicating whether or not to display error messages as the title 
110      * if a link posted encounters an error
111      *
112      * @var boolean
113      */
114     protected $showErrors = true;
115
116     /**
117      * Flag indicating whether to detect schemeless URLS (i.e. "example.com")
118      *
119      * @var boolean
120      */
121     protected $detectSchemeless = false;
122
123     /**
124      * List of error messages to return when the requested URL returns an 
125      * HTTP error
126      *
127      * @var array
128      */
129     protected $httpErrors = array(
130         100 => '100 Continue',
131         200 => '200 OK',
132         201 => '201 Created',
133         204 => '204 No Content',
134         206 => '206 Partial Content',
135         300 => '300 Multiple Choices',
136         301 => '301 Moved Permanently',
137         302 => '302 Found',
138         303 => '303 See Other',
139         304 => '304 Not Modified',
140         307 => '307 Temporary Redirect',
141         400 => '400 Bad Request',
142         401 => '401 Unauthorized',
143         403 => '403 Forbidden',
144         404 => '404 Not Found',
145         405 => '405 Method Not Allowed',
146         406 => '406 Not Acceptable',
147         408 => '408 Request Timeout',
148         410 => '410 Gone',
149         413 => '413 Request Entity Too Large',
150         414 => '414 Request URI Too Long',
151         415 => '415 Unsupported Media Type',
152         416 => '416 Requested Range Not Satisfiable',
153         417 => '417 Expectation Failed',
154         500 => '500 Internal Server Error',
155         501 => '501 Method Not Implemented',
156         503 => '503 Service Unavailable',
157         506 => '506 Variant Also Negotiates'
158     );
159
160     /**
161      * An array containing a list of TLDs used for non-scheme matches
162      *
163      * @var array
164      */
165     protected $tldList = array();
166
167     /**
168      * Shortener object
169      */
170     protected $shortener;
171
172     /**
173      * Array of renderers
174      */
175     protected $renderers = array();
176
177     /**
178      * Initializes settings, checks dependencies.
179      *
180      * @return void
181      */
182     public function onConnect()
183     {
184         // make the shortener configurable
185         $shortener = $this->getConfig('url.shortener', 'Trim');
186         $shortener = "Phergie_Plugin_Url_Shorten_{$shortener}";
187         $this->shortener = new $shortener;
188
189         if (!$this->shortener instanceof Phergie_Plugin_Url_Shorten_Abstract) {
190             $this->fail("Declared shortener class {$shortener} is not of proper ancestry");
191         }
192
193         // Get a list of valid TLDs
194         if (!is_array($this->tldList) || count($this->tldList) <= 6) {
195             /* Omitted for port
196             if ($this->pluginLoaded('Tld')) {
197                 $this->tldList = Phergie_Plugin_Tld::getTlds();
198                 if (is_array($this->tldList)) {
199                     $this->tldList = array_keys($this->tldList);
200                 }
201             }
202             */
203             if (!is_array($this->tldList) || count($this->tldList) <= 0) {
204                 $this->tldList = array('ac', 'ad', 'ae', 'aero', 'af', 'ag', 'ai', 'al', 'am', 'an', 'ao', 'aq', 'ar', 'arpa', 'as', 'asia', 'at', 'au', 'aw', 'ax', 'az', 'ba', 'bb', 'bd', 'be', 'bf', 'bg', 'bh', 'bi', 'biz', 'bj', 'bl', 'bm', 'bn', 'bo', 'br', 'bs', 'bt', 'bv', 'bw', 'by', 'bz', 'ca', 'cat', 'cc', 'cd', 'cf', 'cg', 'ch', 'ci', 'ck', 'cl', 'cm', 'cn', 'co', 'com', 'coop', 'cr', 'cu', 'cv', 'cx', 'cy', 'cz', 'de', 'dj', 'dk', 'dm', 'do', 'dz', 'ec', 'edu', 'ee', 'eg', 'eh', 'er', 'es', 'et', 'eu', 'fi', 'fj', 'fk', 'fm', 'fo', 'fr', 'ga', 'gb', 'gd', 'ge', 'gf', 'gg', 'gh', 'gi', 'gl', 'gm', 'gn', 'gov', 'gp', 'gq', 'gr', 'gs', 'gt', 'gu', 'gw', 'gy', 'hk', 'hm', 'hn', 'hr', 'ht', 'hu', 'id', 'ie', 'il', 'im', 'in', 'info', 'int', 'io', 'iq', 'ir', 'is', 'it', 'je', 'jm', 'jo', 'jobs', 'jp', 'ke', 'kg', 'kh', 'ki', 'km', 'kn', 'kp', 'kr', 'kw', 'ky', 'kz', 'la', 'lb', 'lc', 'li', 'lk', 'lr', 'ls', 'lt', 'lu', 'lv', 'ly', 'ma', 'mc', 'md', 'me', 'mf', 'mg', 'mh', 'mil', 'mk', 'ml', 'mm', 'mn', 'mo', 'mobi', 'mp', 'mq', 'mr', 'ms', 'mt', 'mu', 'museum', 'mv', 'mw', 'mx', 'my', 'mz', 'na', 'name', 'nc', 'ne', 'net', 'nf', 'ng', 'ni', 'nl', 'no', 'np', 'nr', 'nu', 'nz', 'om', 'org', 'pa', 'pe', 'pf', 'pg', 'ph', 'pk', 'pl', 'pm', 'pn', 'pr', 'pro', 'ps', 'pt', 'pw', 'py', 'qa', 're', 'ro', 'rs', 'ru', 'rw', 'sa', 'sb', 'sc', 'sd', 'se', 'sg', 'sh', 'si', 'sj', 'sk', 'sl', 'sm', 'sn', 'so', 'sr', 'st', 'su', 'sv', 'sy', 'sz', 'tc', 'td', 'tel', 'tf', 'tg', 'th', 'tj', 'tk', 'tl', 'tm', 'tn', 'to', 'tp', 'tr', 'travel', 'tt', 'tv', 'tw', 'tz', 'ua', 'ug', 'uk', 'um', 'us', 'uy', 'uz', 'va', 'vc', 've', 'vg', 'vi', 'vn', 'vu', 'wf', 'ws', 'ye', 'yt', 'yu', 'za', 'zm', 'zw');
205             }
206             rsort($this->tldList);
207         }
208
209         // load config (a bit ugly, but focusing on porting):
210         foreach (
211             array(
212                 'detect_schemeless' => 'detectSchemeless',
213                 'base_format' => 'baseFormat',
214                 'message_format' => 'messageFormat',
215                 'merge_links' => 'mergeLinks',
216                 'title_length' => 'titleLength',
217                 'show_errors' => 'showErrors',
218             ) as $config => $local) {
219             if (isset($this->config["url.{$config}"])) {
220                 $this->$local = $this->config["uri.{$config}"];
221             }
222         }
223     }
224
225     /**
226      * Checks an incoming message for the presence of a URL and, if one is
227      * found, responds with its title if it is an HTML document and the
228      * shortened equivalent of its original URL if it meets length requirements.
229      *
230      * @todo Update this to pull configuration settings from $this->config 
231      *       rather than caching them as class properties
232      * @return void
233      */
234     public function onPrivmsg()
235     {
236         $source = $this->getEvent()->getSource();
237         $user = $this->getEvent()->getNick();
238
239         $pattern = '#'.($this->detectSchemeless ? '' : 'https?://').'(?:([0-9]{1,3}(?:\.[0-9]{1,3}){3})(?![^/]) | ('
240             .($this->detectSchemeless ? '(?<!http:/|https:/)[@/\\\]' : '').')?(?:(?:[a-z0-9_-]+\.?)+\.[a-z0-9]{1,6}))[^\s]*#xis';
241
242         // URL Match
243         if (preg_match_all($pattern, $this->getEvent()->getArgument(1), $matches, PREG_SET_ORDER)) {
244             $responses = array();
245             foreach ($matches as $m) {
246                 $url = trim(rtrim($m[0], ', ].?!;'));
247
248                 // Check to see if the URL was from an email address, is a directory, etc
249                 if (!empty($m[2])) {
250                     $this->debug('Invalid Url: URL is either an email or a directory path. (' . $url . ')');
251                     continue;
252                 }
253
254                 // Parse the given URL
255                 if (!$parsed = $this->parseUrl($url)) {
256                     $this->debug('Invalid Url: Could not parse the URL. (' . $url . ')');
257                     continue;
258                 }
259
260                 // allow out-of-class renderers to handle this URL
261                 foreach ($this->renderers as $renderer) {
262                     if ($renderer->renderUrl($parsed) === true) {
263                         // renderers should return true if they've fully
264                         // rendered the passed URL (they're responsible
265                         // for their own output)
266                         $this->debug('Handled by renderer: ' . get_class($renderer));
267                         continue 2;
268                     }
269                 }
270
271                 // Check to see if the given IP/Host is valid
272                 if (!empty($m[1]) and !$this->checkValidIP($m[1])) {
273                     $this->debug('Invalid Url: ' . $m[1] . ' is not a valid IP address. (' . $url . ')');
274                     continue;
275                 }
276
277                 // Process TLD if it's not an IP
278                 if (empty($m[1])) {
279                     // Get the TLD from the host
280                     $pos = strrpos($parsed['host'], '.');
281                     $parsed['tld'] = ($pos !== false ? substr($parsed['host'], ($pos+1)) : '');
282
283                     // Check to see if the URL has a valid TLD
284                     if (is_array($this->tldList) && !in_array(strtolower($parsed['tld']), $this->tldList)) {
285                         $this->debug('Invalid Url: ' . $parsed['tld'] . ' is not a supported TLD. (' . $url . ')');
286                         continue;
287                     }
288                 }
289
290                 // Check to see if the URL is to a secured site or not and handle it accordingly
291                 if ($parsed['scheme'] == 'https' && !extension_loaded('openssl')) {
292                     if (!$this->sslFallback) {
293                         $this->debug('Invalid Url: HTTPS is an invalid scheme, OpenSSL isn\'t available. (' . $url . ')');
294                         continue;
295                     } else {
296                         $parsed['scheme'] = 'http';
297                     }
298                 }
299
300                 if (!in_array($parsed['scheme'], array('http', 'https'))) {
301                     $this->debug('Invalid Url: ' . $parsed['scheme'] . ' is not a supported scheme. (' . $url . ')');
302                     continue;
303                 }
304                 $url = $this->glueURL($parsed);
305                 unset($parsed);
306
307                 // Convert url
308                 $shortenedUrl = $this->shortener->shorten($url);
309
310                 // Prevent spamfest
311                 if ($this->checkUrlCache($url, $shortenedUrl)) {
312                     $this->debug('Invalid Url: URL is in the cache. (' . $url . ')');
313                     continue;
314                 }
315
316                 $title = self::getTitle($url);
317                 if (!empty($title)) {
318                     $responses[] = str_replace(
319                         array(
320                             '%title%',
321                             '%link%',
322                             '%nick%'
323                         ), array(
324                          $title,
325                             $shortenedUrl,
326                             $user
327                         ), $this->messageFormat
328                     );
329                 }
330
331                 // Update cache
332                 $this->updateUrlCache($url, $shortenedUrl);
333                 unset($title, $shortenedUrl, $title);
334             }
335             /**
336              * Check to see if there were any URL responses, format them and handle if they
337              * get merged into one message or not
338              */
339             if (count($responses) > 0) {
340                 if ($this->mergeLinks) {
341                     $message = str_replace(
342                         array(
343                             '%message%',
344                             '%nick%'
345                         ), array(
346                             implode('; ', $responses),
347                             $user
348                         ), $this->baseFormat
349                     );
350                     $this->doPrivmsg($source, $message);
351                 } else {
352                     foreach ($responses as $response) {
353                         $message = str_replace(
354                             array(
355                                 '%message%',
356                                 '%nick%'
357                             ), array(
358                                 implode('; ', $responses),
359                                 $user
360                             ), $this->baseFormat
361                         );
362                         $this->doPrivmsg($source, $message);
363                     }
364                 }
365             }
366         }
367     }
368
369     /**
370      * Checks a given URL (+shortened) against the cache to verify if they were
371      * previously posted on the channel.
372      *
373      * @param string $url          The URL to check against
374      * @param string $shortenedUrl The shortened URL to check against
375      *
376      * @return bool
377      */
378     protected function checkUrlCache($url, $shortenedUrl)
379     {
380         $source = $this->getEvent()->getSource();
381
382         /**
383          * Transform the URL (+shortened) into a HEX CRC32 checksum to prevent potential problems
384          * and minimize the size of the cache for less cache bloat.
385          */
386         $url = $this->getUrlChecksum($url);
387         $shortenedUrl = $this->getUrlChecksum($shortenedUrl);
388
389         $cache = array(
390             'url' => isset($this->urlCache[$source][$url]) ? $this->urlCache[$source][$url] : null,
391             'shortened' => isset($this->shortCache[$source][$shortenedUrl]) ? $this->shortCache[$source][$shortenedUrl] : null
392         );
393
394         $expire = $this->expire;
395         $this->debug("Cache expire: {$expire}");
396         /**
397          * If cache expiration is enabled, check to see if the given url has expired in the cache
398          * If expire is disabled, simply check to see if the url is listed
399          */
400         if (($expire > 0 && (($cache['url'] + $expire) > time() || ($cache['shortened'] + $expire) > time()))
401             || ($expire <= 0 && (isset($cache['url']) || isset($cache['shortened'])))
402         ) {
403             unset($cache, $url, $shortenedUrl, $expire);
404             return true;
405         }
406         unset($cache, $url, $shortenedUrl, $expire);
407         return false;
408     }
409
410     /**
411      * Updates the cache and adds the given URL (+shortened) to the cache. It
412      * also handles cleaning the cache of old entries as well.
413      *
414      * @param string $url          The URL to add to the cache
415      * @param string $shortenedUrl The shortened to add to the cache
416      *
417      * @return bool
418      */
419     protected function updateUrlCache($url, $shortenedUrl)
420     {
421         $source = $this->getEvent()->getSource();
422
423         /**
424          * Transform the URL (+shortened) into a HEX CRC32 checksum to prevent potential problems
425          * and minimize the size of the cache for less cache bloat.
426          */
427         $url = $this->getUrlChecksum($url);
428         $shortenedUrl = $this->getUrlChecksum($shortenedUrl);
429         $time = time();
430
431         // Handle the URL cache and remove old entries that surpass the limit if enabled
432         $this->urlCache[$source][$url] = $time;
433         if ($this->limit > 0 && count($this->urlCache[$source]) > $this->limit) {
434             asort($this->urlCache[$source], SORT_NUMERIC);
435             array_shift($this->urlCache[$source]);
436         }
437
438         // Handle the shortened cache and remove old entries that surpass the limit if enabled
439         $this->shortCache[$source][$shortenedUrl] = $time;
440         if ($this->limit > 0 && count($this->shortCache[$source]) > $this->limit) {
441             asort($this->shortCache[$source], SORT_NUMERIC);
442             array_shift($this->shortCache[$source]);
443         }
444         unset($url, $shortenedUrl, $time);
445     }
446
447     /**
448      * Transliterates a UTF-8 string into corresponding ASCII characters and
449      * truncates and appends an ellipsis to the string if it exceeds a given
450      * length.
451      *
452      * @param string $str  String to decode
453      * @param int    $trim Maximum string length, optional
454      *
455      * @return string
456      */
457     protected function decode($str, $trim = null)
458     {
459         $out = $this->decodeTranslit($str);
460         if ($trim > 0) {
461             $out = substr($out, 0, $trim) . (strlen($out) > $trim ? '...' : '');
462         }
463         return $out;
464     }
465
466     /**
467      * Custom error handler meant to handle 404 errors and such
468      *
469      * @param int    $errno   the error code
470      * @param string $errstr  the error string
471      * @param string $errfile file the error occured in
472      * @param int    $errline line the error occured on
473      *
474      * @return bool
475      */
476     public function onPhpError($errno, $errstr, $errfile, $errline)
477     {
478         if ($errno === E_WARNING) {
479             // Check to see if there was HTTP warning while connecting to the site
480             if (preg_match('{HTTP/1\.[01] ([0-9]{3})}i', $errstr, $m)) {
481                 $this->errorStatus = $m[1];
482                 $this->errorMessage = (isset($this->httpErrors[$m[1]]) ? $this->httpErrors[$m[1]] : $m[1]);
483                 $this->debug('PHP Warning:  ' . $errstr . 'in ' . $errfile . ' on line ' . $errline);
484                 return true;
485             }
486
487             // Safely ignore these SSL warnings so they don't appear in the log
488             if (stripos($errstr, 'SSL: fatal protocol error in') !== false
489                 || stripos($errstr, 'failed to open stream') !== false
490                 || stripos($errstr, 'HTTP request failed') !== false
491                 || stripos($errstr, 'SSL: An existing connection was forcibly closed by the remote host') !== false
492                 || stripos($errstr, 'Failed to enable crypto in') !== false
493                 || stripos($errstr, 'SSL: An established connection was aborted by the software in your host machine') !== false
494                 || stripos($errstr, 'SSL operation failed with code') !== false
495                 || stripos($errstr, 'unable to connect to') !== false
496             ) {
497                 $this->errorStatus = true;
498                 $this->debug('PHP Warning:  ' . $errstr . 'in ' . $errfile . ' on line ' . $errline);
499                 return true;
500             }
501         }
502         return false;
503     }
504
505     /**
506      * Takes a url, parses and cleans the URL without of all the junk
507      * and then return the hex checksum of the url.
508      *
509      * @param string $url url to checksum
510      *
511      * @return string the hex checksum of the cleaned url
512      */
513     protected function getUrlChecksum($url)
514     {
515         $checksum = strtolower(urldecode($this->glueUrl($url, true)));
516         $checksum = preg_replace('#\s#', '', $this->decodeTranslit($checksum));
517         return dechex(crc32($checksum));
518     }
519
520     /**
521      * Parses a given URI and procceses the output to remove redundant
522      * or missing values.
523      *
524      * @param string $url the url to parse
525      *
526      * @return array the url components
527      */
528     protected function parseUrl($url)
529     {
530         if (is_array($url)) return $url;
531
532         $url = trim(ltrim($url, ' /@\\'));
533         if (!preg_match('&^(?:([a-z][-+.a-z0-9]*):)&xis', $url, $matches)) {
534             $url = 'http://' . $url;
535         }
536         $parsed = parse_url($url);
537
538         if (!isset($parsed['scheme'])) {
539             $parsed['scheme'] = 'http';
540         }
541         $parsed['scheme'] = strtolower($parsed['scheme']);
542
543         if (isset($parsed['path']) && !isset($parsed['host'])) {
544             $host = $parsed['path'];
545             $path = '';
546             if (strpos($parsed['path'], '/') !== false) {
547                 list($host, $path) = array_pad(explode('/', $parsed['path'], 2), 2, null);
548             }
549             $parsed['host'] = $host;
550             $parsed['path'] = $path;
551         }
552
553         return $parsed;
554     }
555
556     /**
557      * Parses a given URI and then glues it back together in the proper format.
558      * If base is set, then it chops off the scheme, user and pass and fragment
559      * information to return a more unique base URI.
560      *
561      * @param string $uri  uri to rebuild
562      * @param string $base set to true to only return the base components
563      *
564      * @return string the rebuilt uri
565      */
566     protected function glueUrl($uri, $base = false)
567     {
568         $parsed = $uri;
569         if (!is_array($parsed)) {
570             $parsed = $this->parseUrl($parsed);
571         }
572
573         if (is_array($parsed)) {
574             $uri = '';
575             if (!$base) {
576                 $uri .= (!empty($parsed['scheme']) ? $parsed['scheme'] . ':' .
577                         ((strtolower($parsed['scheme']) == 'mailto') ? '' : '//') : '');
578                 $uri .= (!empty($parsed['user']) ? $parsed['user'] .
579                         (!empty($parsed['pass']) ? ':' . $parsed['pass'] : '') . '@' : '');
580             }
581             if ($base && !empty($parsed['host'])) {
582                 $parsed['host'] = trim($parsed['host']);
583                 if (substr($parsed['host'], 0, 4) == 'www.') {
584                     $parsed['host'] = substr($parsed['host'], 4);
585                 }
586             }
587             $uri .= (!empty($parsed['host']) ? $parsed['host'] : '');
588             if (!empty($parsed['port'])
589                 && (($parsed['scheme'] == 'http' && $parsed['port'] == 80)
590                 || ($parsed['scheme'] == 'https' && $parsed['port'] == 443))
591             ) {
592                 unset($parsed['port']);
593             }
594             $uri .= (!empty($parsed['port']) ? ':' . $parsed['port'] : '');
595             if (!empty($parsed['path']) && (!$base || $base && $parsed['path'] != '/')) {
596                 $uri .= (substr($parsed['path'], 0, 1) == '/') ? $parsed['path'] : ('/' . $parsed['path']);
597             }
598             $uri .= (!empty($parsed['query']) ? '?' . $parsed['query'] : '');
599             if (!$base) {
600                 $uri .= (!empty($parsed['fragment']) ? '#' . $parsed['fragment'] : '');
601             }
602         }
603         return $uri;
604     }
605
606     /**
607      * Checks the given string to see if its a valid IP4 address
608      *
609      * @param string $ip the ip to validate
610      *
611      * @return bool
612      */
613     protected function checkValidIP($ip)
614     {
615         return long2ip(ip2long($ip)) === $ip;
616     }
617
618     /**
619      * Returns the title of the given page
620      *
621      * @param string $url url to the page
622      *
623      * @return string title
624      */
625     public function getTitle($url)
626     {
627         $opts = array(
628             'http' => array(
629                 'timeout' => 3.5,
630                 'method' => 'GET',
631                 'user_agent' => 'Mozilla/5.0 (Windows; U; Windows NT 5.1; en-US; rv:1.8.1.12) Gecko/20080201 Firefox/2.0.0.12'
632             )
633         );
634         $context = stream_context_create($opts);
635
636         if ($page = fopen($url, 'r', false, $context)) {
637             stream_set_timeout($page, 3.5);
638             $data = stream_get_meta_data($page);
639             foreach ($data['wrapper_data'] as $header) {
640                 if (preg_match('/^Content-Type: ([^;]+)/', $header, $match)
641                     && !preg_match('#^(text/x?html|application/xhtml+xml)$#', $match[1])
642                 ) {
643                     $title = $match[1];
644                 }
645             }
646             if (!isset($title)) {
647                 $content = '';
648                 $tstamp = time() + 5;
649
650                 while ($chunk = fread($page, 64)) {
651                     $data = stream_get_meta_data($page);
652                     if ($data['timed_out']) {
653                         $this->debug('Url Timed Out: ' . $url);
654                         $this->errorStatus = true;
655                         break;
656                     }
657                     $content .= $chunk;
658                     // Check for timeout
659                     if (time() > $tstamp) break;
660                     // Try to read title
661                     if (preg_match('#<title[^>]*>(.*)#is', $content, $m)) {
662                         // Start another loop to grab some more data in order to be sure we have the complete title
663                         $content = $m[1];
664                         $loop = 2;
665                         while (($chunk = fread($page, 64)) && $loop-- && !strstr($content, '<')) {
666                             $content .= $chunk;
667                             // Check for timeout
668                             if (time() > $tstamp) break;
669                         }
670                         preg_match('#^([^<]*)#is', $content, $m);
671                         $title = preg_replace('#\s+#', ' ', $m[1]);
672                         $title = trim($this->decode($title, $this->titleLength));
673                         break;
674                     }
675                     // Title won't appear beyond that point so stop parsing
676                     if (preg_match('#</head>|<body#i', $content)) {
677                         break;
678                     }
679                 }
680             }
681             fclose($page);
682         } else if (!$this->errorStatus) {
683             $this->debug('Couldn\t Open Url: ' . $url);
684         }
685
686         if (empty($title)) {
687             if ($this->errorStatus) {
688                 if (!$this->showErrors || empty($this->errorMessage)) {
689                     return;
690                 }
691                 $title = $this->errorMessage;
692                 $this->errorStatus = false;
693                 $this->errorMessage = null;
694             } else {
695                 $title = 'No Title';
696             }
697         }
698
699         return $title;
700     }
701
702     /**
703      * Output a debug message
704      *
705      * @param string $msg the message to output
706      *
707      * @return void
708      */
709     protected function debug($msg)
710     {
711         echo "(DEBUG:Url) $msg\n";
712     }
713
714     /**
715      * Placeholder/porting helper. Has no function.
716      *
717      * @param string $str a string to return
718      *
719      * @return string
720      */
721     protected function decodeTranslit($str)
722     {
723         // placeholder/porting helper
724         return $str;
725     }
726
727     /**
728      * Add a renderer to the stack
729      *
730      * @param object $obj the renderer to add
731      *
732      * @return void
733      */
734     public function registerRenderer($obj)
735     {
736         $this->renderers[] = $obj;
737         array_unique($this->renderers);
738     }
739 }