Fix bug #19608: E_NOTICE for invalid emails
[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: @package_version@
72  * @link      http://pear.php.net/package/Services_Libravatar
73  * @since     Class available since Release 0.1.0
74  */
75 class Services_Libravatar
76 {
77     /**
78      * Hashing algorithm to use
79      *
80      * @var string
81      * @see processAlgorithm()
82      * @see setAlgorithm()
83      */
84     protected $algorithm = 'md5';
85
86     /**
87      * Default image URL to use
88      *
89      * @var string
90      * @see processDefault()
91      * @see setDefault()
92      */
93     protected $default;
94
95     /**
96      * If HTTPS URLs should be used
97      *
98      * @var boolean
99      * @see detectHttps()
100      * @see setHttps()
101      */
102     protected $https;
103
104     /**
105      * Image size in pixels
106      *
107      * @var integer
108      * @see processSize()
109      * @see setSize()
110      */
111     protected $size;
112
113
114     /**
115      * Composes a URL for the identifier and options passed in
116      *
117      * Compose a full URL as specified by the Libravatar API, based on the
118      * email address or openid URL passed in, and the options specified.
119      *
120      * @param string $identifier a string of either an email address
121      *                           or an openid url
122      * @param array  $options    an array of (bool) https, (string) algorithm
123      *                           (string) size, (string) default.
124      *                           See the set* methods.
125      *
126      * @return string A string of a full URL for an avatar image
127      *
128      * @since Method available since Release 0.2.0
129      * @deprecated Use getUrl() instead
130      */
131     public function url($identifier, $options = array())
132     {
133         return $this->getUrl($identifier, $options);
134     }
135
136     /**
137      * Composes a URL for the identifier and options passed in
138      *
139      * Compose a full URL as specified by the Libravatar API, based on the
140      * email address or openid URL passed in, and the options specified.
141      *
142      * @param string $identifier a string of either an email address
143      *                           or an openid url
144      * @param array  $options    an array of (bool) https, (string) algorithm
145      *                           (string) size, (string) default.
146      *                           See the set* methods.
147      *
148      * @return string A string of a full URL for an avatar image
149      *
150      * @since  Method available since Release 0.2.0
151      * @throws InvalidArgumentException When an invalid option is passed
152      */
153     public function getUrl($identifier, $options = array())
154     {
155         // If no identifier has been passed, set it to a null.
156         // This way, there'll always be something returned.
157         if (!$identifier) {
158             $identifier = null;
159         } else {
160             $identifier = $this->normalizeIdentifier($identifier);
161         }
162
163         // Load all options
164         $options = $this->checkOptionsArray($options);
165         $https = $this->https;
166         if (isset($options['https'])) {
167             $https = (bool)$options['https'];
168         }
169
170         $algorithm = $this->algorithm;
171         if (isset($options['algorithm'])) {
172             $algorithm = $this->processAlgorithm($options['algorithm']);
173         }
174
175         $default = $this->default;
176         if (isset($options['default'])) {
177             $default = $this->processDefault($options['default']);
178         }
179         $size = $this->size;
180         if (isset($options['size'])) {
181             $size = $this->processSize($options['size']);
182         }
183
184
185         $identifierHash = $this->identifierHash($identifier, $algorithm);
186
187         // Get the domain so we can determine the SRV stuff for federation
188         $domain = $this->domainGet($identifier);
189
190         // If https has been specified in $options, make sure we make the
191         // correct SRV lookup
192         $service  = $this->srvGet($domain, $https);
193         $protocol = $https ? 'https' : 'http';
194
195         $params = array();
196         if ($size !== null) {
197             $params['size'] = $size;
198         }
199         if ($default !== null) {
200             $params['default'] = $default;
201         }
202         $paramString = '';
203         if (count($params) > 0) {
204             $paramString = '?' . http_build_query($params);
205         }
206
207         // Compose the URL from the pieces we generated
208         $url = $protocol . '://' . $service . '/avatar/' . $identifierHash
209             . $paramString;
210
211         // Return the URL string
212         return $url;
213     }
214
215     /**
216      * Checks the options array and verify that only allowed options are in it.
217      *
218      * @param array $options Array of options for getUrl()
219      *
220      * @return void
221      * @throws Exception When an invalid option is used
222      */
223     protected function checkOptionsArray($options)
224     {
225         //this short options are deprecated!
226         if (isset($options['s'])) {
227             $options['size'] = $options['s'];
228             unset($options['s']);
229         }
230         if (isset($options['d'])) {
231             $options['default'] = $options['d'];
232             unset($options['d']);
233         }
234
235         $allowedOptions = array(
236             'algorithm' => true,
237             'default'   => true,
238             'https'     => true,
239             'size'      => true,
240         );
241         foreach ($options as $key => $value) {
242             if (!isset($allowedOptions[$key])) {
243                 throw new InvalidArgumentException(
244                     'Invalid option in array: ' . $key
245                 );
246             }
247         }
248
249         return $options;
250     }
251
252     /**
253      * Normalizes the identifier (E-mail address or OpenID)
254      *
255      * @param string $identifier E-Mail address or OpenID
256      *
257      * @return string Normalized identifier
258      */
259     protected function normalizeIdentifier($identifier)
260     {
261         if (filter_var($identifier, FILTER_VALIDATE_EMAIL)) {
262             return strtolower($identifier);
263         } else {
264             return self::normalizeOpenId($identifier);
265         }
266     }
267
268     /**
269      * Create a hash of the identifier.
270      *
271      * Create a hash of the email address or openid passed in. Algorithm
272      * used for email address ONLY can be varied. Either md5 or sha256
273      * are supported by the Libravatar API. Will be ignored for openid.
274      *
275      * @param string $identifier A string of the email address or openid URL
276      * @param string $hash       A string of the hash algorithm type to make
277      *                           Uses the php implementation of hash()
278      *                           MD5 preferred for Gravatar fallback
279      *
280      * @return string A string hash of the identifier.
281      *
282      * @since Method available since Release 0.1.0
283      */
284     protected function identifierHash($identifier, $hash = 'md5')
285     {
286         if (filter_var($identifier, FILTER_VALIDATE_EMAIL) || $identifier === null) {
287             // If email, we can select our algorithm. Default to md5 for
288             // gravatar fallback.
289             return hash($hash, $identifier);
290         }
291
292         //no email, so the identifier has to be an OpenID
293         return hash('sha256', $identifier);
294     }
295
296     /**
297      * Normalizes an identifier (URI or XRI)
298      *
299      * @param mixed $identifier URI or XRI to be normalized
300      *
301      * @return string Normalized Identifier.
302      *                Empty string when the OpenID is invalid.
303      *
304      * @internal Adapted from OpenID::normalizeIdentifier()
305      */
306     public static function normalizeOpenId($identifier)
307     {
308         // XRI
309         if (preg_match('@^xri://@i', $identifier)) {
310             return preg_replace('@^xri://@i', '', $identifier);
311         }
312
313         if (in_array($identifier[0], array('=', '@', '+', '$', '!'))) {
314             return $identifier;
315         }
316
317         // URL
318         if (!preg_match('@^http[s]?://@i', $identifier)) {
319             $identifier = 'http://' . $identifier;
320         }
321         if (strpos($identifier, '/', 8) === false) {
322             $identifier .= '/';
323         }
324         if (!filter_var($identifier, FILTER_VALIDATE_URL)) {
325             return '';
326         }
327
328         $parts = parse_url($identifier);
329         $parts['scheme'] = strtolower($parts['scheme']);
330         $parts['host']   = strtolower($parts['host']);
331
332         //http://openid.net/specs/openid-authentication-2_0.html#normalization
333         return $parts['scheme'] . '://'
334             . (isset($parts['user']) ? $parts['user'] : '')
335             . (isset($parts['pass']) ? ':' . $parts['pass'] : '')
336             . (isset($parts['user']) || isset($parts['pass']) ? '@' : '')
337             . $parts['host']
338             . (
339                 (isset($parts['port'])
340                 && $parts['scheme'] === 'http' && $parts['port'] != 80)
341                 || (isset($parts['port'])
342                 && $parts['scheme'] === 'https' && $parts['port'] != 443)
343                 ? ':' . $parts['port'] : ''
344             )
345             . $parts['path']
346             . (isset($parts['query']) ? '?' . $parts['query'] : '');
347             //leave out fragment as requested by the spec
348     }
349
350     /**
351      * Grab the domain from the identifier.
352      *
353      * Extract the domain from the Email or OpenID.
354      *
355      * @param string $identifier A string of the email address or openid URL
356      *
357      * @return string A string of the domain to use
358      *
359      * @since Method available since Release 0.1.0
360      */
361     protected function domainGet($identifier)
362     {
363         if ($identifier === null) {
364             return null;
365         }
366
367         // What are we, email or openid? Split ourself up and get the
368         // important bit out.
369         if (filter_var($identifier, FILTER_VALIDATE_EMAIL)) {
370             $email = explode('@', $identifier);
371             return $email[1];
372         }
373
374         //OpenID
375         $url = parse_url($identifier);
376         if (!isset($url['host'])) {
377             //invalid URL
378             return null;
379         }
380
381         $domain = $url['host'];
382         if (isset($url['port']) && $url['scheme'] === 'http'
383             && $url['port'] != 80
384             || isset($url['port']) && $url['scheme'] === 'https'
385             && $url['port'] != 443
386         ) {
387             $domain .= ':' . $url['port'];
388         }
389
390         return $domain;
391     }
392
393     /**
394      * Get the target to use.
395      *
396      * Get the SRV record, filtered by priority and weight. If our domain
397      * has no SRV records, fall back to Libravatar.org
398      *
399      * @param string  $domain A string of the domain we extracted from the
400      *                        provided identifier with domainGet()
401      * @param boolean $https  Whether or not to look for https records
402      *
403      * @return string The target URL.
404      *
405      * @since Method available since Release 0.1.0
406      */
407     protected function srvGet($domain, $https = false)
408     {
409         // Are we going secure? Set up a fallback too.
410         if (isset($https) && $https === true) {
411             $subdomain = '_avatars-sec._tcp.';
412             $fallback  = 'seccdn.';
413         } else {
414             $subdomain = '_avatars._tcp.';
415             $fallback  = 'cdn.';
416         }
417
418         if ($domain === null) {
419             // No domain means invalid email address/openid
420             return $fallback . 'libravatar.org';
421         }
422
423         // Lets try get us some records based on the choice of subdomain
424         // and the domain we had passed in.
425         $srv = dns_get_record($subdomain . $domain, DNS_SRV);
426
427         // Did we get anything? No?
428         if (count($srv) == 0) {
429             // Then let's try Libravatar.org.
430             return $fallback . 'libravatar.org';
431         }
432
433         // Sort by the priority. We must get the lowest.
434         usort($srv, array($this, 'comparePriority'));
435
436         $top = $srv[0];
437         $sum = 0;
438
439         // Try to adhere to RFC2782's weighting algorithm, page 3
440         // "arrange all SRV RRs (that have not been ordered yet) in any order,
441         // except that all those with weight 0 are placed at the beginning of
442         // the list."
443         shuffle($srv);
444         $srvs = array();
445         foreach ($srv as $s) {
446             if ($s['weight'] == 0) {
447                 array_unshift($srvs, $s);
448             } else {
449                 array_push($srvs, $s);
450             }
451         }
452
453         foreach ($srvs as $s) {
454             if ($s['pri'] == $top['pri']) {
455                 // "Compute the sum of the weights of those RRs"
456                 $sum += (int) $s['weight'];
457                 // "and with each RR associate the running sum in the selected
458                 // order."
459                 $pri[$sum] = $s;
460             }
461         }
462
463         // "Then choose a uniform random number between 0 and the sum computed
464         // (inclusive)"
465         $random = rand(0, $sum);
466
467         // "and select the RR whose running sum value is the first in the selected
468         // order which is greater than or equal to the random number selected"
469         foreach ($pri as $k => $v) {
470             if ($k >= $random) {
471                 return $v['target'];
472             }
473         }
474     }
475
476     /**
477      * Sorting function for record priorities.
478      *
479      * @param mixed $a A mixed value passed by usort()
480      * @param mixed $b A mixed value passed by usort()
481      *
482      * @return mixed The result of the comparison
483      *
484      * @since Method available since Release 0.1.0
485      */
486     protected function comparePriority($a, $b)
487     {
488         return $a['pri'] - $b['pri'];
489     }
490
491     /**
492      * Automatically set the https option depending on the current connection
493      * value.
494      *
495      * If the current connection is HTTPS, the https options is activated.
496      * If it is not HTTPS, the https option is deactivated.
497      *
498      * @return self
499      */
500     public function detectHttps()
501     {
502         $this->setHttps(
503             isset($_SERVER['HTTPS']) && $_SERVER['HTTPS']
504         );
505
506         return $this;
507     }
508
509     /**
510      * Verify and cast the email address hashing algorithm to use.
511      *
512      * @param string $algorithm Algorithm to use, "sha256" or "md5".
513      *
514      * @return string Algorithm
515      *
516      * @throws InvalidArgumentException When an unsupported algorithm is given
517      */
518     protected function processAlgorithm($algorithm)
519     {
520         $algorithm = (string)$algorithm;
521         if ($algorithm !== 'md5' && $algorithm !== 'sha256') {
522             throw new InvalidArgumentException(
523                 'Only md5 and sha256 hashing supported'
524             );
525         }
526
527         return $algorithm;
528     }
529
530     /**
531      * Verify and cast the default URL to use when no avatar image can be found.
532      * If none is set, the libravatar logo is returned.
533      *
534      * @param string $url Full URL to use OR one of the following:
535      *                    - "404" - give a "404 File not found" instead of an image
536      *                    - "mm"
537      *                    - "identicon"
538      *                    - "monsterid"
539      *                    - "wavatar"
540      *                    - "retro"
541      *
542      * @return string Default URL
543      *
544      * @throws InvalidArgumentException When an invalid URL is given
545      */
546     protected function processDefault($url)
547     {
548         if ($url === null) {
549             return $url;
550         }
551
552         $url = (string)$url;
553
554         switch ($url) {
555         case '404':
556         case 'mm':
557         case 'identicon':
558         case 'monsterid':
559         case 'wavatar':
560         case 'retro':
561             break;
562         default:
563             $valid = filter_var($url, FILTER_VALIDATE_URL);
564             if (!$valid) {
565                 throw new InvalidArgumentException('Invalid default avatar URL');
566             }
567             break;
568         }
569
570         return $url;
571     }
572
573     /**
574      * Verify and cast the required size of the images.
575      *
576      * @param integer $size Size (width and height in pixels) of the image.
577      *                      NULL for the default width.
578      *
579      * @return integer Size
580      *
581      * @throws InvalidArgumentException When a size <= 0 is given
582      */
583     protected function processSize($size)
584     {
585         if ($size === null) {
586             return $size;
587         }
588
589         $size = (int)$size;
590         if ($size <= 0) {
591             throw new InvalidArgumentException('Size has to be larger than 0');
592         }
593
594         return (int)$size;
595     }
596
597
598     /**
599      * Set the email address hashing algorithm to use.
600      * To keep gravatar compatibility, use "md5".
601      *
602      * @param string $algorithm Algorithm to use, "sha256" or "md5".
603      *
604      * @return self
605      * @throws InvalidArgumentException When an unsupported algorithm is given
606      */
607     public function setAlgorithm($algorithm)
608     {
609         $this->algorithm = $this->processAlgorithm($algorithm);
610
611         return $this;
612     }
613
614     /**
615      * Set the default URL to use when no avatar image can be found.
616      * If none is set, the gravatar logo is returned.
617      *
618      * @param string $url Full URL to use OR one of the following:
619      *                    - "404" - give a "404 File not found" instead of an image
620      *                    - "mm"
621      *                    - "identicon"
622      *                    - "monsterid"
623      *                    - "wavatar"
624      *                    - "retro"
625      *
626      * @return self
627      * @throws InvalidArgumentException When an invalid URL is given
628      */
629     public function setDefault($url)
630     {
631         $this->default = $this->processDefault($url);
632
633         return $this;
634     }
635
636     /**
637      * Set if HTTPS URLs shall be returned.
638      *
639      * @param boolean $useHttps If HTTPS url shall be returned
640      *
641      * @return self
642      *
643      * @see detectHttps()
644      */
645     public function setHttps($useHttps)
646     {
647         $this->https = (bool)$useHttps;
648
649         return $this;
650     }
651
652     /**
653      * Set the required size of the images.
654      * Every avatar image is square sized, which means you need to set only number.
655      *
656      * @param integer $size Size (width and height) of the image
657      *
658      * @return self
659      * @throws InvalidArgumentException When a size <= 0 is given
660      */
661     public function setSize($size)
662     {
663         $this->size = $this->processSize($size);
664
665         return $this;
666     }
667
668 }
669
670 /*
671  * Local variables:
672  * tab-width: 4
673  * c-basic-offset: 4
674  * c-hanging-comment-ender-p: nil
675  * End:
676  */
677
678 ?>