Added the caching support to the service
[services_libravatar:services_libravatar.git] / Services / Libravatar.php
1 <?php
2 /* vim: set expandtab tabstop=4 shiftwidth=4 softtabstop=4: */
3 /**
4  * PHP support for the Libravatar.org service.
5  *
6  * PHP version 5
7  *
8  * The MIT License
9  *
10  * Copyright (c) 2011 Services_Libravatar committers.
11  *
12  * Permission is hereby granted, free of charge, to any person obtaining a copy
13  * of this software and associated documentation files (the "Software"), to deal
14  * in the Software without restriction, including without limitation the rights
15  * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
16  * copies of the Software, and to permit persons to whom the Software is
17  * furnished to do so, subject to the following conditions:
18  *
19  * The above copyright notice and this permission notice shall be included in
20  * all copies or substantial portions of the Software.
21  *
22  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
23  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
24  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
25  * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
26  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
27  * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
28  * THE SOFTWARE.
29  *
30  * @category  Services
31  * @package   Services_Libravatar
32  * @author    Melissa Draper <melissa@meldraweb.com>
33  * @copyright 2011 Services_Libravatar committers.
34  * @license   http://www.opensource.org/licenses/mit-license.html  MIT License
35  * @link      http://pear.php.net/package/Services_Libravatar
36  * @since     File available since Release 0.1.0
37  */
38
39 /**
40  * PHP support for the Libravatar.org service.
41  *
42  * Using this class is easy. After including or requiring
43  * Services/Libravatar.php simply do:
44  * <code>
45  * $libravatar = new Services_Libravatar();
46  * $url = $libravatar->getUrl('melissa@meldraweb.com');
47  * </code>
48  *
49  * This would populate $url with the string:
50  * <code>
51  * http://cdn.libravatar.org/avatar/4db84629c121f2d443d33bdb9fd149bc
52  * </code>
53  *
54  * A complicated lookup using all the options is:
55  * <code>
56  * $libravatar = new Services_Libravatar();
57  * $libravatar->setSize(40);
58  * $libravatar->setAlgorithm('sha256');
59  * $libravatar->setHttps(true);
60  * $libravatar->setDefault(
61  *     'http://upload.wikimedia.org/wikipedia/commons/a/af/Tux.png'
62  * );
63  * $url = $libravatar->getUrl('melissa@meldraweb.com');
64  * </code>
65  *
66  * @category  Services
67  * @package   Services_Libravatar
68  * @author    Melissa Draper <melissa@meldraweb.com>
69  * @copyright 2011 Services_Libravatar committers.
70  * @license   http://www.opensource.org/licenses/mit-license.html  MIT License
71  * @version   Release: 0.2.2
72  * @link      http://pear.php.net/package/Services_Libravatar
73  * @since     Class available since Release 0.1.0
74  */
75
76 require ('Cache/Services_Cache_Driver.php');
77 require ('Cache/Services_Cache_DriverCacheLite.php');
78
79 class Services_Libravatar
80 {
81     /**
82      * Hashing algorithm to use
83      *
84      * @var string
85      * @see processAlgorithm()
86      * @see setAlgorithm()
87      */
88     protected $algorithm = 'md5';
89
90     /**
91      * Default image URL to use
92      *
93      * @var string
94      * @see processDefault()
95      * @see setDefault()
96      */
97     protected $default;
98
99     /**
100      * If HTTPS URLs should be used
101      *
102      * @var boolean
103      * @see detectHttps()
104      * @see setHttps()
105      */
106     protected $https;
107
108     /**
109      * Image size in pixels
110      *
111      * @var integer
112      * @see processSize()
113      * @see setSize()
114      */
115     protected $size;
116
117     /**
118      * Caching driver
119      *
120      * @var mixed
121      * @see setCache()
122      * @see getCache()
123      */
124     protected $cache;
125
126
127     /**
128      * Composes a URL for the identifier and options passed in
129      *
130      * Compose a full URL as specified by the Libravatar API, based on the
131      * email address or openid URL passed in, and the options specified.
132      *
133      * @param string $identifier a string of either an email address
134      *                           or an openid url
135      * @param array  $options    an array of (bool) https, (string) algorithm
136      *                           (string) size, (string) default.
137      *                           See the set* methods.
138      *
139      * @return string A string of a full URL for an avatar image
140      *
141      * @since Method available since Release 0.2.0
142      * @deprecated Use getUrl() instead
143      */
144     public function url($identifier, $options = array())
145     {
146         return $this->getUrl($identifier, $options);
147     }
148
149     /**
150      * Composes a URL for the identifier and options passed in
151      *
152      * Compose a full URL as specified by the Libravatar API, based on the
153      * email address or openid URL passed in, and the options specified.
154      *
155      * @param string $identifier a string of either an email address
156      *                           or an openid url
157      * @param array  $options    an array of (bool) https, (string) algorithm
158      *                           (string) size, (string) default.
159      *                           See the set* methods.
160      *
161      * @return string A string of a full URL for an avatar image
162      *
163      * @since  Method available since Release 0.2.0
164      * @throws InvalidArgumentException When an invalid option is passed
165      */
166     public function getUrl($identifier, $options = array())
167     {
168         // If no identifier has been passed, set it to a null.
169         // This way, there'll always be something returned.
170         if (!$identifier) {
171             $identifier = null;
172         } else {
173             $identifier = $this->normalizeIdentifier($identifier);
174         }
175
176         // Load all options
177         $options = $this->checkOptionsArray($options);
178         $https = $this->https;
179         if (isset($options['https'])) {
180             $https = (bool)$options['https'];
181         }
182
183         $algorithm = $this->algorithm;
184         if (isset($options['algorithm'])) {
185             $algorithm = $this->processAlgorithm($options['algorithm']);
186         }
187
188         $default = $this->default;
189         if (isset($options['default'])) {
190             $default = $this->processDefault($options['default']);
191         }
192         $size = $this->size;
193         if (isset($options['size'])) {
194             $size = $this->processSize($options['size']);
195         }
196
197
198         $identifierHash = $this->identifierHash($identifier, $algorithm);
199
200         // Get the domain so we can determine the SRV stuff for federation
201         $domain = $this->domainGet($identifier);
202
203         // If https has been specified in $options, make sure we make the
204         // correct SRV lookup
205         $service  = $this->srvGet($domain, $https);
206         $protocol = $https ? 'https' : 'http';
207
208         $params = array();
209         if ($size !== null) {
210             $params['size'] = $size;
211         }
212         if ($default !== null) {
213             $params['default'] = $default;
214         }
215         $paramString = '';
216         if (count($params) > 0) {
217             $paramString = '?' . http_build_query($params);
218         }
219
220         // Compose the URL from the pieces we generated
221         $url = $protocol . '://' . $service . '/avatar/' . $identifierHash
222             . $paramString;
223
224         // Return the URL string
225         return $url;
226     }
227
228     /**
229      * Checks the options array and verify that only allowed options are in it.
230      *
231      * @param array $options Array of options for getUrl()
232      *
233      * @return void
234      * @throws Exception When an invalid option is used
235      */
236     protected function checkOptionsArray($options)
237     {
238         //this short options are deprecated!
239         if (isset($options['s'])) {
240             $options['size'] = $options['s'];
241             unset($options['s']);
242         }
243         if (isset($options['d'])) {
244             $options['default'] = $options['d'];
245             unset($options['d']);
246         }
247
248         $allowedOptions = array(
249             'algorithm' => true,
250             'default'   => true,
251             'https'     => true,
252             'size'      => true,
253         );
254         foreach ($options as $key => $value) {
255             if (!isset($allowedOptions[$key])) {
256                 throw new InvalidArgumentException(
257                     'Invalid option in array: ' . $key
258                 );
259             }
260         }
261
262         return $options;
263     }
264
265     /**
266      * Normalizes the identifier (E-mail address or OpenID)
267      *
268      * @param string $identifier E-Mail address or OpenID
269      *
270      * @return string Normalized identifier
271      */
272     protected function normalizeIdentifier($identifier)
273     {
274         if (filter_var($identifier, FILTER_VALIDATE_EMAIL)) {
275             return strtolower($identifier);
276         } else {
277             return self::normalizeOpenId($identifier);
278         }
279     }
280
281     /**
282      * Create a hash of the identifier.
283      *
284      * Create a hash of the email address or openid passed in. Algorithm
285      * used for email address ONLY can be varied. Either md5 or sha256
286      * are supported by the Libravatar API. Will be ignored for openid.
287      *
288      * @param string $identifier A string of the email address or openid URL
289      * @param string $hash       A string of the hash algorithm type to make
290      *                           Uses the php implementation of hash()
291      *                           MD5 preferred for Gravatar fallback
292      *
293      * @return string A string hash of the identifier.
294      *
295      * @since Method available since Release 0.1.0
296      */
297     protected function identifierHash($identifier, $hash = 'md5')
298     {
299         if (filter_var($identifier, FILTER_VALIDATE_EMAIL) || $identifier === null) {
300             // If email, we can select our algorithm. Default to md5 for
301             // gravatar fallback.
302             return hash($hash, $identifier);
303         }
304
305         //no email, so the identifier has to be an OpenID
306         return hash('sha256', $identifier);
307     }
308
309     /**
310      * Normalizes an identifier (URI or XRI)
311      *
312      * @param mixed $identifier URI or XRI to be normalized
313      *
314      * @return string Normalized Identifier.
315      *                Empty string when the OpenID is invalid.
316      *
317      * @internal Adapted from OpenID::normalizeIdentifier()
318      */
319     public static function normalizeOpenId($identifier)
320     {
321         // XRI
322         if (preg_match('@^xri://@i', $identifier)) {
323             return preg_replace('@^xri://@i', '', $identifier);
324         }
325
326         if (in_array($identifier[0], array('=', '@', '+', '$', '!'))) {
327             return $identifier;
328         }
329
330         // URL
331         if (!preg_match('@^http[s]?://@i', $identifier)) {
332             $identifier = 'http://' . $identifier;
333         }
334         if (strpos($identifier, '/', 8) === false) {
335             $identifier .= '/';
336         }
337         if (!filter_var($identifier, FILTER_VALIDATE_URL)) {
338             return '';
339         }
340
341         $parts = parse_url($identifier);
342         $parts['scheme'] = strtolower($parts['scheme']);
343         $parts['host']   = strtolower($parts['host']);
344
345         //http://openid.net/specs/openid-authentication-2_0.html#normalization
346         return $parts['scheme'] . '://'
347             . (isset($parts['user']) ? $parts['user'] : '')
348             . (isset($parts['pass']) ? ':' . $parts['pass'] : '')
349             . (isset($parts['user']) || isset($parts['pass']) ? '@' : '')
350             . $parts['host']
351             . (
352                 (isset($parts['port'])
353                 && $parts['scheme'] === 'http' && $parts['port'] != 80)
354                 || (isset($parts['port'])
355                 && $parts['scheme'] === 'https' && $parts['port'] != 443)
356                 ? ':' . $parts['port'] : ''
357             )
358             . $parts['path']
359             . (isset($parts['query']) ? '?' . $parts['query'] : '');
360             //leave out fragment as requested by the spec
361     }
362
363     /**
364      * Grab the domain from the identifier.
365      *
366      * Extract the domain from the Email or OpenID.
367      *
368      * @param string $identifier A string of the email address or openid URL
369      *
370      * @return string A string of the domain to use
371      *
372      * @since Method available since Release 0.1.0
373      */
374     protected function domainGet($identifier)
375     {
376         if ($identifier === null) {
377             return null;
378         }
379
380         // What are we, email or openid? Split ourself up and get the
381         // important bit out.
382         if (filter_var($identifier, FILTER_VALIDATE_EMAIL)) {
383             $email = explode('@', $identifier);
384             return $email[1];
385         }
386
387         //OpenID
388         $url = parse_url($identifier);
389         if (!isset($url['host'])) {
390             //invalid URL
391             return null;
392         }
393
394         $domain = $url['host'];
395         if (isset($url['port']) && $url['scheme'] === 'http'
396             && $url['port'] != 80
397             || isset($url['port']) && $url['scheme'] === 'https'
398             && $url['port'] != 443
399         ) {
400             $domain .= ':' . $url['port'];
401         }
402
403         return $domain;
404     }
405
406     /**
407      * Get the target to use.
408      *
409      * Get the SRV record, filtered by priority and weight. If our domain
410      * has no SRV records, fall back to Libravatar.org
411      *
412      * @param string  $domain A string of the domain we extracted from the
413      *                        provided identifier with domainGet()
414      * @param boolean $https  Whether or not to look for https records
415      *
416      * @return string The target URL.
417      *
418      * @since Method available since Release 0.1.0
419      */
420     protected function srvGet($domain, $https = false)
421     {
422         // Are we going secure? Set up a fallback too.
423         if (isset($https) && $https === true) {
424             $subdomain = '_avatars-sec._tcp.';
425             $fallback  = 'seccdn.';
426         } else {
427             $subdomain = '_avatars._tcp.';
428             $fallback  = 'cdn.';
429         }
430
431         if ($domain === null) {
432             // No domain means invalid email address/openid
433             return $fallback . 'libravatar.org';
434         }
435
436         /** 
437         * Check if a cache exists, instantiate it otherwise
438         **/
439         if (null == $this->cache) {
440             $this->cache = new Services_Cache_DriverCacheLite;
441         }
442
443         $cacheKey = md5($subdomain . $domain);
444         if ($data = $this->cache->get($cacheKey)) {
445             $srv = unserialize($data);
446         } else {
447             // Lets try get us some records based on the choice of subdomain
448             // and the domain we had passed in.
449             $srv = dns_get_record($subdomain . $domain, DNS_SRV);
450             $this->cache->save($cacheKey, serialize($srv));
451         }
452
453         
454
455
456         // Did we get anything? No?
457         if (count($srv) == 0) {
458             // Then let's try Libravatar.org.
459             return $fallback . 'libravatar.org';
460         }
461
462         // Sort by the priority. We must get the lowest.
463         usort($srv, array($this, 'comparePriority'));
464
465         $top = $srv[0];
466         $sum = 0;
467
468         // Try to adhere to RFC2782's weighting algorithm, page 3
469         // "arrange all SRV RRs (that have not been ordered yet) in any order,
470         // except that all those with weight 0 are placed at the beginning of
471         // the list."
472         shuffle($srv);
473         $srvs = array();
474         foreach ($srv as $s) {
475             if ($s['weight'] == 0) {
476                 array_unshift($srvs, $s);
477             } else {
478                 array_push($srvs, $s);
479             }
480         }
481
482         foreach ($srvs as $s) {
483             if ($s['pri'] == $top['pri']) {
484                 // "Compute the sum of the weights of those RRs"
485                 $sum += (int) $s['weight'];
486                 // "and with each RR associate the running sum in the selected
487                 // order."
488                 $pri[$sum] = $s;
489             }
490         }
491
492         // "Then choose a uniform random number between 0 and the sum computed
493         // (inclusive)"
494         $random = rand(0, $sum);
495
496         // "and select the RR whose running sum value is the first in the selected
497         // order which is greater than or equal to the random number selected"
498         foreach ($pri as $k => $v) {
499             if ($k >= $random) {
500                 return $v['target'];
501             }
502         }
503     }
504
505     /**
506      * Sorting function for record priorities.
507      *
508      * @param mixed $a A mixed value passed by usort()
509      * @param mixed $b A mixed value passed by usort()
510      *
511      * @return mixed The result of the comparison
512      *
513      * @since Method available since Release 0.1.0
514      */
515     protected function comparePriority($a, $b)
516     {
517         return $a['pri'] - $b['pri'];
518     }
519
520     /**
521      * Automatically set the https option depending on the current connection
522      * value.
523      *
524      * If the current connection is HTTPS, the https options is activated.
525      * If it is not HTTPS, the https option is deactivated.
526      *
527      * @return self
528      */
529     public function detectHttps()
530     {
531         $this->setHttps(
532             isset($_SERVER['HTTPS']) && $_SERVER['HTTPS']
533         );
534
535         return $this;
536     }
537
538     /**
539      * Verify and cast the email address hashing algorithm to use.
540      *
541      * @param string $algorithm Algorithm to use, "sha256" or "md5".
542      *
543      * @return string Algorithm
544      *
545      * @throws InvalidArgumentException When an unsupported algorithm is given
546      */
547     protected function processAlgorithm($algorithm)
548     {
549         $algorithm = (string)$algorithm;
550         if ($algorithm !== 'md5' && $algorithm !== 'sha256') {
551             throw new InvalidArgumentException(
552                 'Only md5 and sha256 hashing supported'
553             );
554         }
555
556         return $algorithm;
557     }
558
559     /**
560      * Verify and cast the default URL to use when no avatar image can be found.
561      * If none is set, the libravatar logo is returned.
562      *
563      * @param string $url Full URL to use OR one of the following:
564      *                    - "404" - give a "404 File not found" instead of an image
565      *                    - "mm"
566      *                    - "identicon"
567      *                    - "monsterid"
568      *                    - "wavatar"
569      *                    - "retro"
570      *
571      * @return string Default URL
572      *
573      * @throws InvalidArgumentException When an invalid URL is given
574      */
575     protected function processDefault($url)
576     {
577         if ($url === null) {
578             return $url;
579         }
580
581         $url = (string)$url;
582
583         switch ($url) {
584         case '404':
585         case 'mm':
586         case 'identicon':
587         case 'monsterid':
588         case 'wavatar':
589         case 'retro':
590             break;
591         default:
592             $valid = filter_var($url, FILTER_VALIDATE_URL);
593             if (!$valid) {
594                 throw new InvalidArgumentException('Invalid default avatar URL');
595             }
596             break;
597         }
598
599         return $url;
600     }
601
602     /**
603      * Verify and cast the required size of the images.
604      *
605      * @param integer $size Size (width and height in pixels) of the image.
606      *                      NULL for the default width.
607      *
608      * @return integer Size
609      *
610      * @throws InvalidArgumentException When a size <= 0 is given
611      */
612     protected function processSize($size)
613     {
614         if ($size === null) {
615             return $size;
616         }
617
618         $size = (int)$size;
619         if ($size <= 0) {
620             throw new InvalidArgumentException('Size has to be larger than 0');
621         }
622
623         return (int)$size;
624     }
625
626
627     /**
628      * Set the email address hashing algorithm to use.
629      * To keep gravatar compatibility, use "md5".
630      *
631      * @param string $algorithm Algorithm to use, "sha256" or "md5".
632      *
633      * @return self
634      * @throws InvalidArgumentException When an unsupported algorithm is given
635      */
636     public function setAlgorithm($algorithm)
637     {
638         $this->algorithm = $this->processAlgorithm($algorithm);
639
640         return $this;
641     }
642
643     /**
644      * Set the default URL to use when no avatar image can be found.
645      * If none is set, the gravatar logo is returned.
646      *
647      * @param string $url Full URL to use OR one of the following:
648      *                    - "404" - give a "404 File not found" instead of an image
649      *                    - "mm"
650      *                    - "identicon"
651      *                    - "monsterid"
652      *                    - "wavatar"
653      *                    - "retro"
654      *
655      * @return self
656      * @throws InvalidArgumentException When an invalid URL is given
657      */
658     public function setDefault($url)
659     {
660         $this->default = $this->processDefault($url);
661
662         return $this;
663     }
664
665     /**
666      * Set if HTTPS URLs shall be returned.
667      *
668      * @param boolean $useHttps If HTTPS url shall be returned
669      *
670      * @return self
671      *
672      * @see detectHttps()
673      */
674     public function setHttps($useHttps)
675     {
676         $this->https = (bool)$useHttps;
677
678         return $this;
679     }
680
681     /**
682      * Set the required size of the images.
683      * Every avatar image is square sized, which means you need to set only number.
684      *
685      * @param integer $size Size (width and height) of the image
686      *
687      * @return self
688      * @throws InvalidArgumentException When a size <= 0 is given
689      */
690     public function setSize($size)
691     {
692         $this->size = $this->processSize($size);
693
694         return $this;
695     }
696
697
698     public function setCache($cacheDriver) {
699
700         if (false == $cacheDriver instanceof Services_Cache_Driver) {
701             throw new InvalidArgumentException('Cache driver is expected to be ' .
702                 'an instance of Services_Cache_Driver');
703         } else {
704             $this->cache = $cacheDriver;
705         }
706     }
707
708 }
709
710 /*
711  * Local variables:
712  * tab-width: 4
713  * c-basic-offset: 4
714  * c-hanging-comment-ender-p: nil
715  * End:
716  */
717
718 ?>