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