]> git.mxchange.org Git - quix0rs-gnu-social.git/blob - plugins/Irc/extlib/phergie/Phergie/Plugin/Url.php
Merge remote branch 'statusnet/1.0.x' into irc-plugin
[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  * @uses     Phergie_Plugin_Http pear.phergie.org
35  */
36 class Phergie_Plugin_Url extends Phergie_Plugin_Abstract
37 {
38     /**
39      * Links output format
40      *
41      * Can use the variables %nick%, %title% and %link% in it to display
42      * page titles and links
43      *
44      * @var string
45      */
46     protected $baseFormat = '%message%';
47     protected $messageFormat = '[ %link% ] %title%';
48
49     /**
50      * Flag indicating whether a single response should be sent for a single
51      * message containing multiple links
52      *
53      * @var bool
54      */
55     protected $mergeLinks = true;
56
57     /**
58      * Max length of the fetched URL title
59      *
60      * @var int
61      */
62     protected $titleLength = 40;
63
64     /**
65      * Url cache to prevent spamming, especially with multiple bots on the
66      * same channel
67      *
68      * @var array
69      */
70     protected $urlCache = array();
71     protected $shortCache = array();
72
73     /**
74      * Time in seconds to store the cached entries
75      *
76      * Setting it to 0 or below disables the cache expiration
77      *
78      * @var int
79      */
80     protected $expire = 1800;
81
82     /**
83      * Number of entries to keep in the cache at one time per channel
84      *
85      * Setting it to 0 or below disables the cache limit
86      *
87      * @var int
88      */
89     protected $limit = 10;
90
91     /**
92      * Flag that determines if the plugin will fall back to using an HTTP
93      * stream when a URL using SSL is detected and OpenSSL support isn't
94      * available in the PHP installation in use
95      *
96      * @var bool
97      */
98     protected $sslFallback = true;
99
100     /**
101      * Flag that is set to true by the custom error handler if an HTTP error
102      * code has been received
103      *
104      * @var boolean
105      */
106     protected $errorStatus = false;
107     protected $errorMessage = null;
108
109     /**
110      * Flag indicating whether or not to display error messages as the title
111      * if a link posted encounters an error
112      *
113      * @var boolean
114      */
115     protected $showErrors = true;
116
117     /**
118      * Flag indicating whether to detect schemeless URLS (i.e. "example.com")
119      *
120      * @var boolean
121      */
122     protected $detectSchemeless = false;
123
124     /**
125      * List of error messages to return when the requested URL returns an
126      * HTTP error
127      *
128      * @var array
129      */
130     protected $httpErrors = array(
131         100 => '100 Continue',
132         200 => '200 OK',
133         201 => '201 Created',
134         204 => '204 No Content',
135         206 => '206 Partial Content',
136         300 => '300 Multiple Choices',
137         301 => '301 Moved Permanently',
138         302 => '302 Found',
139         303 => '303 See Other',
140         304 => '304 Not Modified',
141         307 => '307 Temporary Redirect',
142         400 => '400 Bad Request',
143         401 => '401 Unauthorized',
144         403 => '403 Forbidden',
145         404 => '404 Not Found',
146         405 => '405 Method Not Allowed',
147         406 => '406 Not Acceptable',
148         408 => '408 Request Timeout',
149         410 => '410 Gone',
150         413 => '413 Request Entity Too Large',
151         414 => '414 Request URI Too Long',
152         415 => '415 Unsupported Media Type',
153         416 => '416 Requested Range Not Satisfiable',
154         417 => '417 Expectation Failed',
155         500 => '500 Internal Server Error',
156         501 => '501 Method Not Implemented',
157         503 => '503 Service Unavailable',
158         506 => '506 Variant Also Negotiates'
159     );
160
161     /**
162      * An array containing a list of TLDs used for non-scheme matches
163      *
164      * @var array
165      */
166     protected $tldList = array();
167
168     /**
169      * Shortener object
170      */
171     protected $shortener;
172
173     /**
174      * Array of renderers
175      */
176     protected $renderers = array();
177
178     /**
179      * Initializes settings, checks dependencies.
180      *
181      * @return void
182      */
183     public function onConnect()
184     {
185         // make the shortener configurable
186         $shortener = $this->getConfig('url.shortener', 'Trim');
187         $shortener = "Phergie_Plugin_Url_Shorten_{$shortener}";
188         $this->shortener = new $shortener($this->plugins->getPlugin('Http'));
189
190         if (!$this->shortener instanceof Phergie_Plugin_Url_Shorten_Abstract) {
191             $this->fail("Declared shortener class {$shortener} is not of proper ancestry");
192         }
193
194         // Get a list of valid TLDs
195         if (!is_array($this->tldList) || count($this->tldList) <= 6) {
196             $tldPath = dirname(__FILE__) . '/Url/url.tld.txt';
197             $this->tldList = explode("\n", file_get_contents($tldPath));
198             $this->debug('Loaded ' . count($this->tldList) . ' tlds');
199             rsort($this->tldList);
200         }
201
202         // load config (a bit ugly, but focusing on porting):
203         foreach (
204             array(
205                 'detect_schemeless' => 'detectSchemeless',
206                 'base_format' => 'baseFormat',
207                 'message_format' => 'messageFormat',
208                 'merge_links' => 'mergeLinks',
209                 'title_length' => 'titleLength',
210                 'show_errors' => 'showErrors',
211                 'expire' => 'expire',
212             ) as $config => $local) {
213             if (isset($this->config["url.{$config}"])) {
214                 $this->$local = $this->config["uri.{$config}"];
215             }
216         }
217     }
218
219     /**
220      * Checks an incoming message for the presence of a URL and, if one is
221      * found, responds with its title if it is an HTML document and the
222      * shortened equivalent of its original URL if it meets length requirements.
223      *
224      * @todo Update this to pull configuration settings from $this->config
225      *       rather than caching them as class properties
226      * @return void
227      */
228     public function onPrivmsg()
229     {
230         $source = $this->getEvent()->getSource();
231         $user = $this->getEvent()->getNick();
232
233         $pattern = '#'.($this->detectSchemeless ? '' : 'https?://').'(?:([0-9]{1,3}(?:\.[0-9]{1,3}){3})(?![^/]) | ('
234             .($this->detectSchemeless ? '(?<!http:/|https:/)[@/\\\]' : '').')?(?:(?:[a-z0-9_-]+\.?)+\.[a-z0-9]{1,6}))[^\s]*#xis';
235
236         // URL Match
237         if (preg_match_all($pattern, $this->getEvent()->getArgument(1), $matches, PREG_SET_ORDER)) {
238             $responses = array();
239             foreach ($matches as $m) {
240                 $url = trim(rtrim($m[0], ', ].?!;'));
241
242                 // Check to see if the URL was from an email address, is a directory, etc
243                 if (!empty($m[2])) {
244                     $this->debug('Invalid Url: URL is either an email or a directory path. (' . $url . ')');
245                     continue;
246                 }
247
248                 // Parse the given URL
249                 if (!$parsed = $this->parseUrl($url)) {
250                     $this->debug('Invalid Url: Could not parse the URL. (' . $url . ')');
251                     continue;
252                 }
253
254                 // allow out-of-class renderers to handle this URL
255                 foreach ($this->renderers as $renderer) {
256                     if ($renderer->renderUrl($parsed) === true) {
257                         // renderers should return true if they've fully
258                         // rendered the passed URL (they're responsible
259                         // for their own output)
260                         $this->debug('Handled by renderer: ' . get_class($renderer));
261                         continue 2;
262                     }
263                 }
264
265                 // Check to see if the given IP/Host is valid
266                 if (!empty($m[1]) and !$this->checkValidIP($m[1])) {
267                     $this->debug('Invalid Url: ' . $m[1] . ' is not a valid IP address. (' . $url . ')');
268                     continue;
269                 }
270
271                 // Process TLD if it's not an IP
272                 if (empty($m[1])) {
273                     // Get the TLD from the host
274                     $pos = strrpos($parsed['host'], '.');
275                     $parsed['tld'] = ($pos !== false ? substr($parsed['host'], ($pos+1)) : '');
276
277                     // Check to see if the URL has a valid TLD
278                     if (is_array($this->tldList) && !in_array(strtolower($parsed['tld']), $this->tldList)) {
279                         $this->debug('Invalid Url: ' . $parsed['tld'] . ' is not a supported TLD. (' . $url . ')');
280                         continue;
281                     }
282                 }
283
284                 // Check to see if the URL is to a secured site or not and handle it accordingly
285                 if ($parsed['scheme'] == 'https' && !extension_loaded('openssl')) {
286                     if (!$this->sslFallback) {
287                         $this->debug('Invalid Url: HTTPS is an invalid scheme, OpenSSL isn\'t available. (' . $url . ')');
288                         continue;
289                     } else {
290                         $parsed['scheme'] = 'http';
291                     }
292                 }
293
294                 if (!in_array($parsed['scheme'], array('http', 'https'))) {
295                     $this->debug('Invalid Url: ' . $parsed['scheme'] . ' is not a supported scheme. (' . $url . ')');
296                     continue;
297                 }
298                 $url = $this->glueURL($parsed);
299                 unset($parsed);
300
301                 // Convert url
302                 $shortenedUrl = $this->shortener->shorten($url);
303                 if (!$shortenedUrl) {
304                     $this->debug('Invalid Url: Unable to shorten. (' . $url . ')');
305                     continue;
306                 }
307
308                 // Prevent spamfest
309                 if ($this->checkUrlCache($url, $shortenedUrl)) {
310                     $this->debug('Invalid Url: URL is in the cache. (' . $url . ')');
311                     continue;
312                 }
313
314                 $title = self::getTitle($url);
315                 if (!empty($title)) {
316                     $responses[] = str_replace(
317                         array(
318                             '%title%',
319                             '%link%',
320                             '%nick%'
321                         ), array(
322                          $title,
323                             $shortenedUrl,
324                             $user
325                         ), $this->messageFormat
326                     );
327                 }
328
329                 // Update cache
330                 $this->updateUrlCache($url, $shortenedUrl);
331                 unset($title, $shortenedUrl, $title);
332             }
333             /**
334              * Check to see if there were any URL responses, format them and handle if they
335              * get merged into one message or not
336              */
337             if (count($responses) > 0) {
338                 if ($this->mergeLinks) {
339                     $message = str_replace(
340                         array(
341                             '%message%',
342                             '%nick%'
343                         ), array(
344                             implode('; ', $responses),
345                             $user
346                         ), $this->baseFormat
347                     );
348                     $this->doPrivmsg($source, $message);
349                 } else {
350                     foreach ($responses as $response) {
351                         $message = str_replace(
352                             array(
353                                 '%message%',
354                                 '%nick%'
355                             ), array(
356                                 implode('; ', $responses),
357                                 $user
358                             ), $this->baseFormat
359                         );
360                         $this->doPrivmsg($source, $message);
361                     }
362                 }
363             }
364         }
365     }
366
367     /**
368      * Checks a given URL (+shortened) against the cache to verify if they were
369      * previously posted on the channel.
370      *
371      * @param string $url          The URL to check against
372      * @param string $shortenedUrl The shortened URL to check against
373      *
374      * @return bool
375      */
376     protected function checkUrlCache($url, $shortenedUrl)
377     {
378         $source = $this->getEvent()->getSource();
379
380         /**
381          * Transform the URL (+shortened) into a HEX CRC32 checksum to prevent potential problems
382          * and minimize the size of the cache for less cache bloat.
383          */
384         $url = $this->getUrlChecksum($url);
385         $shortenedUrl = $this->getUrlChecksum($shortenedUrl);
386
387         $cache = array(
388             'url' => isset($this->urlCache[$source][$url]) ? $this->urlCache[$source][$url] : null,
389             'shortened' => isset($this->shortCache[$source][$shortenedUrl]) ? $this->shortCache[$source][$shortenedUrl] : null
390         );
391
392         $expire = $this->expire;
393         $this->debug("Cache expire: {$expire}");
394         /**
395          * If cache expiration is enabled, check to see if the given url has expired in the cache
396          * If expire is disabled, simply check to see if the url is listed
397          */
398         if (($expire > 0 && (($cache['url'] + $expire) > time() || ($cache['shortened'] + $expire) > time()))
399             || ($expire <= 0 && (isset($cache['url']) || isset($cache['shortened'])))
400         ) {
401             unset($cache, $url, $shortenedUrl, $expire);
402             return true;
403         }
404         unset($cache, $url, $shortenedUrl, $expire);
405         return false;
406     }
407
408     /**
409      * Updates the cache and adds the given URL (+shortened) to the cache. It
410      * also handles cleaning the cache of old entries as well.
411      *
412      * @param string $url          The URL to add to the cache
413      * @param string $shortenedUrl The shortened to add to the cache
414      *
415      * @return bool
416      */
417     protected function updateUrlCache($url, $shortenedUrl)
418     {
419         $source = $this->getEvent()->getSource();
420
421         /**
422          * Transform the URL (+shortened) into a HEX CRC32 checksum to prevent potential problems
423          * and minimize the size of the cache for less cache bloat.
424          */
425         $url = $this->getUrlChecksum($url);
426         $shortenedUrl = $this->getUrlChecksum($shortenedUrl);
427         $time = time();
428
429         // Handle the URL cache and remove old entries that surpass the limit if enabled
430         $this->urlCache[$source][$url] = $time;
431         if ($this->limit > 0 && count($this->urlCache[$source]) > $this->limit) {
432             asort($this->urlCache[$source], SORT_NUMERIC);
433             array_shift($this->urlCache[$source]);
434         }
435
436         // Handle the shortened cache and remove old entries that surpass the limit if enabled
437         $this->shortCache[$source][$shortenedUrl] = $time;
438         if ($this->limit > 0 && count($this->shortCache[$source]) > $this->limit) {
439             asort($this->shortCache[$source], SORT_NUMERIC);
440             array_shift($this->shortCache[$source]);
441         }
442         unset($url, $shortenedUrl, $time);
443     }
444
445     /**
446      * Transliterates a UTF-8 string into corresponding ASCII characters and
447      * truncates and appends an ellipsis to the string if it exceeds a given
448      * length.
449      *
450      * @param string $str  String to decode
451      * @param int    $trim Maximum string length, optional
452      *
453      * @return string
454      */
455     protected function decode($str, $trim = null)
456     {
457         $out = $this->decodeTranslit($str);
458         if ($trim > 0) {
459             $out = substr($out, 0, $trim) . (strlen($out) > $trim ? '...' : '');
460         }
461         return $out;
462     }
463
464     /**
465      * Custom error handler meant to handle 404 errors and such
466      *
467      * @param int    $errno   the error code
468      * @param string $errstr  the error string
469      * @param string $errfile file the error occured in
470      * @param int    $errline line the error occured on
471      *
472      * @return bool
473      */
474     public function onPhpError($errno, $errstr, $errfile, $errline)
475     {
476         if ($errno === E_WARNING) {
477             // Check to see if there was HTTP warning while connecting to the site
478             if (preg_match('{HTTP/1\.[01] ([0-9]{3})}i', $errstr, $m)) {
479                 $this->errorStatus = $m[1];
480                 $this->errorMessage = (isset($this->httpErrors[$m[1]]) ? $this->httpErrors[$m[1]] : $m[1]);
481                 $this->debug('PHP Warning:  ' . $errstr . 'in ' . $errfile . ' on line ' . $errline);
482                 return true;
483             }
484
485             // Safely ignore these SSL warnings so they don't appear in the log
486             if (stripos($errstr, 'SSL: fatal protocol error in') !== false
487                 || stripos($errstr, 'failed to open stream') !== false
488                 || stripos($errstr, 'HTTP request failed') !== false
489                 || stripos($errstr, 'SSL: An existing connection was forcibly closed by the remote host') !== false
490                 || stripos($errstr, 'Failed to enable crypto in') !== false
491                 || stripos($errstr, 'SSL: An established connection was aborted by the software in your host machine') !== false
492                 || stripos($errstr, 'SSL operation failed with code') !== false
493                 || stripos($errstr, 'unable to connect to') !== false
494             ) {
495                 $this->errorStatus = true;
496                 $this->debug('PHP Warning:  ' . $errstr . 'in ' . $errfile . ' on line ' . $errline);
497                 return true;
498             }
499         }
500         return false;
501     }
502
503     /**
504      * Takes a url, parses and cleans the URL without of all the junk
505      * and then return the hex checksum of the url.
506      *
507      * @param string $url url to checksum
508      *
509      * @return string the hex checksum of the cleaned url
510      */
511     protected function getUrlChecksum($url)
512     {
513         $checksum = strtolower(urldecode($this->glueUrl($url, true)));
514         $checksum = preg_replace('#\s#', '', $this->decodeTranslit($checksum));
515         return dechex(crc32($checksum));
516     }
517
518     /**
519      * Parses a given URI and procceses the output to remove redundant
520      * or missing values.
521      *
522      * @param string $url the url to parse
523      *
524      * @return array the url components
525      */
526     protected function parseUrl($url)
527     {
528         if (is_array($url)) return $url;
529
530         $url = trim(ltrim($url, ' /@\\'));
531         if (!preg_match('&^(?:([a-z][-+.a-z0-9]*):)&xis', $url, $matches)) {
532             $url = 'http://' . $url;
533         }
534         $parsed = parse_url($url);
535
536         if (!isset($parsed['scheme'])) {
537             $parsed['scheme'] = 'http';
538         }
539         $parsed['scheme'] = strtolower($parsed['scheme']);
540
541         if (isset($parsed['path']) && !isset($parsed['host'])) {
542             $host = $parsed['path'];
543             $path = '';
544             if (strpos($parsed['path'], '/') !== false) {
545                 list($host, $path) = array_pad(explode('/', $parsed['path'], 2), 2, null);
546             }
547             $parsed['host'] = $host;
548             $parsed['path'] = $path;
549         }
550
551         return $parsed;
552     }
553
554     /**
555      * Parses a given URI and then glues it back together in the proper format.
556      * If base is set, then it chops off the scheme, user and pass and fragment
557      * information to return a more unique base URI.
558      *
559      * @param string $uri  uri to rebuild
560      * @param string $base set to true to only return the base components
561      *
562      * @return string the rebuilt uri
563      */
564     protected function glueUrl($uri, $base = false)
565     {
566         $parsed = $uri;
567         if (!is_array($parsed)) {
568             $parsed = $this->parseUrl($parsed);
569         }
570
571         if (is_array($parsed)) {
572             $uri = '';
573             if (!$base) {
574                 $uri .= (!empty($parsed['scheme']) ? $parsed['scheme'] . ':' .
575                         ((strtolower($parsed['scheme']) == 'mailto') ? '' : '//') : '');
576                 $uri .= (!empty($parsed['user']) ? $parsed['user'] .
577                         (!empty($parsed['pass']) ? ':' . $parsed['pass'] : '') . '@' : '');
578             }
579             if ($base && !empty($parsed['host'])) {
580                 $parsed['host'] = trim($parsed['host']);
581                 if (substr($parsed['host'], 0, 4) == 'www.') {
582                     $parsed['host'] = substr($parsed['host'], 4);
583                 }
584             }
585             $uri .= (!empty($parsed['host']) ? $parsed['host'] : '');
586             if (!empty($parsed['port'])
587                 && (($parsed['scheme'] == 'http' && $parsed['port'] == 80)
588                 || ($parsed['scheme'] == 'https' && $parsed['port'] == 443))
589             ) {
590                 unset($parsed['port']);
591             }
592             $uri .= (!empty($parsed['port']) ? ':' . $parsed['port'] : '');
593             if (!empty($parsed['path']) && (!$base || $base && $parsed['path'] != '/')) {
594                 $uri .= (substr($parsed['path'], 0, 1) == '/') ? $parsed['path'] : ('/' . $parsed['path']);
595             }
596             $uri .= (!empty($parsed['query']) ? '?' . $parsed['query'] : '');
597             if (!$base) {
598                 $uri .= (!empty($parsed['fragment']) ? '#' . $parsed['fragment'] : '');
599             }
600         }
601         return $uri;
602     }
603
604     /**
605      * Checks the given string to see if its a valid IP4 address
606      *
607      * @param string $ip the ip to validate
608      *
609      * @return bool
610      */
611     protected function checkValidIP($ip)
612     {
613         return long2ip(ip2long($ip)) === $ip;
614     }
615
616     /**
617      * Returns the title of the given page
618      *
619      * @param string $url url to the page
620      *
621      * @return string title
622      */
623     public function getTitle($url)
624     {
625         $http = $this->plugins->getPlugin('Http');
626         $options = array(
627             'timeout' => 3.5,
628             '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'
629         );
630
631         $response = $http->get($url, array(), $options);
632
633         $header = $response->getHeaders('Content-Type');
634         if (!preg_match('#^(text/x?html|application/xhtml+xml)(?:;.*)?$#', $header)) {
635             $title = $header;
636         }
637
638         $content = $response->getContent();
639         if (empty($title)) {
640             if (preg_match('#<title[^>]*>(.*?)</title>#is', $content, $match)) {
641                 $title = html_entity_decode(trim($match[1]));
642             }
643         }
644
645         if (empty($title)) {
646             if ($response->isError()) {
647                 $title = $response->getCodeAsString();
648             } else {
649                 $title = 'No Title';
650             }
651         }
652
653         return $title;
654     }
655
656     /**
657      * Output a debug message
658      *
659      * @param string $msg the message to output
660      *
661      * @return void
662      */
663     protected function debug($msg)
664     {
665         echo "(DEBUG:Url) $msg\n";
666     }
667
668     /**
669      * Placeholder/porting helper. Has no function.
670      *
671      * @param string $str a string to return
672      *
673      * @return string
674      */
675     protected function decodeTranslit($str)
676     {
677         // placeholder/porting helper
678         return $str;
679     }
680
681     /**
682      * Add a renderer to the stack
683      *
684      * @param object $obj the renderer to add
685      *
686      * @return void
687      */
688     public function registerRenderer($obj)
689     {
690         $this->renderers[] = $obj;
691         array_unique($this->renderers);
692     }
693 }