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 5, 2024
1 parent 19095d3 commit 3ee6f9d
Showing 1 changed file with 49 additions and 2 deletions.
51 changes: 49 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,49 @@ 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/
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).
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.
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 3ee6f9d

Please sign in to comment.