Skip to content

Commit

Permalink
Properly implement the libravatar federation API
Browse files Browse the repository at this point in the history
I have no strong opinions about whether WebFinger should be supported
instead of Libravatar (as suggested in
#1043), but as long as
there's a thing in Liberapay that says "libravatar", it should do what
the libravatar API docs say.  It is surprising and confusing that
Liberapay will tell users "We were unable to get an avatar for you
from libravatar" while https://www.libravatar.org/tools/check/ is
telling them that their avatar is A-OK.

Unlike the old #1043,
this implements the libravatar API in Liberapay, instead of using the
unmaintained (since 2016) pyLibravatar library (which in turn uses the
py3dns library, instead of the Dnspython library that Liberapay
prefers).
  • Loading branch information
LukeShu committed Apr 8, 2024
1 parent 19095d3 commit 2f84ab9
Showing 1 changed file with 63 additions and 2 deletions.
65 changes: 63 additions & 2 deletions liberapay/models/participant.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -16,6 +17,7 @@
import aspen_jinja2_renderer
from cached_property import cached_property
from dateutil.parser import parse as parse_date
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
Expand Down Expand Up @@ -98,6 +100,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):

Expand Down Expand Up @@ -2176,8 +2182,63 @@ 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()
try:
# Look up the SRV record to use.
_, email_domain = normalized_email.rsplit('@', 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=lambda rec: rec.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

# Validate.
# 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:
raise ValueError("invalid port number")

# Build `avatar_origin` from that.
# Only include an explicit port number if it's not the default
# port for the scheme.
if (scheme == 'http' and port != 80) or (scheme == 'https' and port != 443):
avatar_origin = '%s://%s:%d' % (scheme, host, port)
else:
avatar_origin = '%s://%s' % (scheme, host)
except Exception:
avatar_origin = 'https://seccdn.libravatar.org'
avatar_id = md5(normalized_email.encode('utf8')).hexdigest()
avatar_url = avatar_origin + '/avatar/' + avatar_id
avatar_url += AVATAR_QUERY

elif platform is None:
Expand Down

0 comments on commit 2f84ab9

Please sign in to comment.