Repository: liberapay/liberapay.com

Issue:
Federated libravatar should be supported
The key feature of libravatar is federation. Please add support for it. The necessary steps are roughly described in https://wiki.libravatar.org/api/ and the available libraries are listed in https://wiki.libravatar.org/libraries/.


Discussion:
There is already an open PR for this: #1043.
I think this should be re-opened as a Feature Request now I closed my PR.

Resolving patch:
diff --git a/liberapay/models/participant.py b/liberapay/models/participant.py
--- a/liberapay/models/participant.py
+++ b/liberapay/models/participant.py
@@ -6,6 +6,7 @@
 from hashlib import pbkdf2_hmac, md5, sha1
 from operator import attrgetter, itemgetter
 from os import urandom
+from random import randint
 from threading import Lock
 from time import sleep
 from types import SimpleNamespace
@@ -16,6 +17,8 @@
 import aspen_jinja2_renderer
 from cached_property import cached_property
 from dateutil.parser import parse as parse_date
+from dns.exception import DNSException
+from dns.resolver import Cache as DNSCache, Resolver as DNSResolver
 from html2text import html2text
 from markupsafe import escape as htmlescape
 from pando import json, Response
@@ -98,6 +101,10 @@
 
 email_lock = Lock()
 
+DNS = DNSResolver()
+DNS.lifetime = 1.0  # 1 second timeout, per https://github.com/liberapay/liberapay.com/pull/1043#issuecomment-377891723
+DNS.cache = DNSCache()
+
 
 class Participant(Model, MixinTeam):
 
@@ -2176,8 +2183,66 @@ def update_avatar(self, src=None, cursor=None, avatar_email=None, check=True):
         if platform == 'libravatar' or platform is None and email:
             if not email:
                 return
-            avatar_id = md5(email.strip().lower().encode('utf8')).hexdigest()
-            avatar_url = 'https://seccdn.libravatar.org/avatar/'+avatar_id
+            # https://wiki.libravatar.org/api/
+            #
+            # We only use the first SRV record that we choose; if there is an
+            # error talking to that server, we give up, instead of retrying with
+            # another record.  pyLibravatar does the same.
+            normalized_email = email.strip().lower()
+            avatar_origin = 'https://seccdn.libravatar.org'
+            try:
+                # Look up the SRV record to use.
+                email_domain = normalized_email.rsplit('@', 1)[1]
+                try:
+                    srv_records = DNS.query('_avatars-sec._tcp.'+email_domain, 'SRV')
+                    scheme = 'https'
+                except Exception:
+                    srv_records = DNS.query('_avatars._tcp.'+email_domain, 'SRV')
+                    scheme = 'http'
+                # Filter down to just the records with the "highest" `.priority`
+                # (lower number = higher priority); for the libravatar API tells us:
+                #
+                # > Libravatar clients MUST only consider servers listed in the
+                # > highest SRV priority.
+                top_priority = min(rec.priority for rec in srv_records)
+                srv_records = [rec for rec in srv_records if rec.priority == top_priority]
+                # Of those, choose randomly based on their relative `.weight`s;
+                # for the libravatar API tells us:
+                #
+                # > They MUST honour relative weights.
+                #
+                # RFC 2782 (at the top of page 4) gives us this algorithm for
+                # randomly selecting a record based on the weights:
+                srv_records.sort(key=attrgetter('weight'))  # ensure that .weight=0 recs are first in the list
+                weight_choice = randint(0, sum(rec.weight for rec in srv_records))
+                weight_sum = 0
+                for rec in srv_records:
+                    weight_sum += rec.weight
+                    if weight_sum >= weight_choice:
+                        choice_record = rec
+                        break
+
+                # Build the `avatar_origin` URL.
+                # The Dnspython library has already validated that `.target` is
+                # a valid DNS name and that `.port` is a uint16.
+                host = choice_record.target.canonicalize().to_text(omit_final_dot=True)
+                port = choice_record.port
+                if port == 0:
+                    # Port zero isn't supposed to be used and typically can't be. The
+                    # Libravatar wiki doesn't clearly specify what to do in this case.
+                    pass
+                elif (scheme == 'http' and port != 80) or (scheme == 'https' and port != 443):
+                    # Only include an explicit port number if it's not the default
+                    # port for the scheme.
+                    avatar_origin = '%s://%s:%d' % (scheme, host, port)
+                else:
+                    avatar_origin = '%s://%s' % (scheme, host)
+            except DNSException:
+                pass
+            except Exception as e:
+                website.tell_sentry(e)
+            avatar_id = md5(normalized_email.encode('utf8')).hexdigest()
+            avatar_url = avatar_origin + '/avatar/' + avatar_id
             avatar_url += AVATAR_QUERY
 
         elif platform is None:


Test patch: