2017-08-19 15:21:05 +00:00
|
|
|
"""Handles requests for WebFinger endpoints.
|
|
|
|
|
2023-09-22 19:48:00 +00:00
|
|
|
* https://webfinger.net/
|
|
|
|
* https://tools.ietf.org/html/rfc7033
|
2017-08-19 15:21:05 +00:00
|
|
|
"""
|
2024-05-30 21:55:35 +00:00
|
|
|
from datetime import timedelta
|
2017-08-19 15:21:05 +00:00
|
|
|
import logging
|
2023-11-30 23:43:38 +00:00
|
|
|
from urllib.parse import urljoin, urlparse
|
2017-08-19 15:21:05 +00:00
|
|
|
|
2024-05-08 00:01:01 +00:00
|
|
|
from flask import render_template, request
|
2023-02-10 04:00:58 +00:00
|
|
|
from granary import as2
|
2021-08-16 18:47:31 +00:00
|
|
|
from oauth_dropins.webutil import flask_util, util
|
2023-11-30 23:43:38 +00:00
|
|
|
from oauth_dropins.webutil.flask_util import error, flash, Found
|
2023-02-10 04:00:58 +00:00
|
|
|
from oauth_dropins.webutil.util import json_dumps, json_loads
|
2017-08-19 15:21:05 +00:00
|
|
|
|
2023-11-30 05:06:55 +00:00
|
|
|
import activitypub
|
2017-08-19 15:21:05 +00:00
|
|
|
import common
|
2024-05-30 21:55:35 +00:00
|
|
|
from common import (
|
2024-06-04 21:19:04 +00:00
|
|
|
CACHE_CONTROL,
|
2024-05-30 21:55:35 +00:00
|
|
|
LOCAL_DOMAINS,
|
|
|
|
PRIMARY_DOMAIN,
|
|
|
|
PROTOCOL_DOMAINS,
|
|
|
|
SUPERDOMAIN,
|
|
|
|
)
|
2024-06-01 14:07:00 +00:00
|
|
|
from flask_app import app
|
2023-06-12 22:37:17 +00:00
|
|
|
from protocol import Protocol
|
2024-04-22 20:24:24 +00:00
|
|
|
from web import Web
|
2017-08-19 15:21:05 +00:00
|
|
|
|
2023-06-06 18:29:36 +00:00
|
|
|
SUBSCRIBE_LINK_REL = 'http://ostatus.org/schema/1.0/subscribe'
|
|
|
|
|
2022-02-12 06:38:56 +00:00
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
2017-08-19 15:21:05 +00:00
|
|
|
|
2023-06-12 20:32:21 +00:00
|
|
|
class Webfinger(flask_util.XrdOrJrd):
|
|
|
|
"""Serves a user's WebFinger profile.
|
|
|
|
|
|
|
|
Supports both JRD and XRD; defaults to JRD.
|
|
|
|
https://tools.ietf.org/html/rfc7033#section-4
|
|
|
|
"""
|
2024-06-04 21:19:04 +00:00
|
|
|
@flask_util.headers(CACHE_CONTROL)
|
2022-12-26 21:34:50 +00:00
|
|
|
def dispatch_request(self, *args, **kwargs):
|
2024-06-04 21:19:04 +00:00
|
|
|
return super().dispatch_request(*args, **kwargs)
|
2022-12-26 21:34:50 +00:00
|
|
|
|
2021-07-11 23:30:14 +00:00
|
|
|
def template_prefix(self):
|
|
|
|
return 'webfinger_user'
|
|
|
|
|
2023-06-12 20:32:21 +00:00
|
|
|
def template_vars(self):
|
2024-05-29 04:14:33 +00:00
|
|
|
# logger.debug(f'Headers: {list(request.headers.items())}')
|
2021-07-11 23:30:14 +00:00
|
|
|
|
2023-06-12 20:32:21 +00:00
|
|
|
resource = flask_util.get_required_param('resource').strip()
|
|
|
|
resource = resource.removeprefix(common.host_url())
|
|
|
|
|
|
|
|
# handle Bridgy Fed actor URLs, eg https://fed.brid.gy/snarfed.org
|
|
|
|
host = util.domain_from_link(common.host_url())
|
|
|
|
if resource in ('', '/', f'acct:{host}', f'acct:@{host}'):
|
|
|
|
error('Expected other domain, not *.brid.gy')
|
|
|
|
|
|
|
|
allow_indirect = False
|
2023-06-12 22:37:17 +00:00
|
|
|
cls = None
|
2023-06-12 20:32:21 +00:00
|
|
|
try:
|
2024-06-02 00:52:05 +00:00
|
|
|
username, server = util.parse_acct_uri(resource)
|
|
|
|
id = server
|
2023-09-27 20:55:16 +00:00
|
|
|
cls = Protocol.for_bridgy_subdomain(id, fed='web')
|
2023-06-15 17:52:11 +00:00
|
|
|
if cls:
|
2024-06-02 00:52:05 +00:00
|
|
|
id = username
|
2023-06-20 18:22:54 +00:00
|
|
|
allow_indirect = True
|
2023-06-12 20:32:21 +00:00
|
|
|
except ValueError:
|
2024-06-02 00:52:05 +00:00
|
|
|
id = username = server = urlparse(resource).netloc or resource
|
2023-06-12 20:32:21 +00:00
|
|
|
|
2024-04-22 20:24:24 +00:00
|
|
|
if id == PRIMARY_DOMAIN or id in PROTOCOL_DOMAINS:
|
|
|
|
cls = Web
|
|
|
|
elif not cls:
|
2023-09-27 20:55:16 +00:00
|
|
|
cls = Protocol.for_request(fed='web')
|
2021-07-11 23:30:14 +00:00
|
|
|
|
2023-10-13 00:55:44 +00:00
|
|
|
if not cls:
|
2024-06-21 16:37:11 +00:00
|
|
|
error(f"Couldn't determine protocol for f{resource}")
|
2023-10-13 00:55:44 +00:00
|
|
|
|
2023-09-29 17:48:59 +00:00
|
|
|
# is this a handle?
|
2023-06-15 17:52:11 +00:00
|
|
|
if cls.owns_id(id) is False:
|
2023-09-22 22:14:15 +00:00
|
|
|
logger.info(f'{id} is not a {cls.LABEL} id')
|
|
|
|
handle = id
|
|
|
|
id = None
|
|
|
|
if cls.owns_handle(handle) is not False:
|
|
|
|
logger.info(' ...might be a handle, trying to resolve')
|
|
|
|
id = cls.handle_to_id(handle)
|
|
|
|
|
|
|
|
if not id:
|
2024-06-21 16:37:11 +00:00
|
|
|
error(f'{resource} is not a valid handle for a {cls.LABEL} user',
|
|
|
|
status=404)
|
2023-09-22 22:14:15 +00:00
|
|
|
|
|
|
|
logger.info(f'Protocol {cls.LABEL}, user id {id}')
|
2023-06-12 22:37:17 +00:00
|
|
|
|
|
|
|
# only allow indirect users if this id is "on" a brid.gy subdomain,
|
|
|
|
# eg user.com@bsky.brid.gy but not user.com@user.com
|
2023-05-30 03:16:15 +00:00
|
|
|
if allow_indirect:
|
2023-11-20 04:27:48 +00:00
|
|
|
user = cls.get_or_create(id)
|
2023-05-23 18:53:32 +00:00
|
|
|
else:
|
2023-11-20 04:27:48 +00:00
|
|
|
user = cls.get_by_id(id)
|
|
|
|
if user and not user.direct:
|
2024-08-02 15:02:36 +00:00
|
|
|
error(f"{user.key.id()} hasn't signed up yet", status=404)
|
2023-05-30 03:16:15 +00:00
|
|
|
|
2024-06-02 00:52:05 +00:00
|
|
|
if (not user
|
|
|
|
or not user.is_enabled(activitypub.ActivityPub)
|
|
|
|
or (cls == Web and username not in (user.key.id(), user.username()))):
|
2023-06-12 22:37:17 +00:00
|
|
|
error(f'No {cls.LABEL} user found for {id}', status=404)
|
2023-01-26 04:00:54 +00:00
|
|
|
|
2023-12-01 23:46:37 +00:00
|
|
|
ap_handle = user.handle_as('activitypub')
|
|
|
|
if not ap_handle:
|
|
|
|
error(f'{cls.LABEL} user {id} has no handle', status=404)
|
|
|
|
|
2023-11-30 23:43:38 +00:00
|
|
|
# backward compatibility for initial Web users whose AP actor ids are on
|
|
|
|
# fed.brid.gy, not web.brid.gy
|
|
|
|
subdomain = request.host.split('.')[0]
|
|
|
|
if (user.LABEL == 'web'
|
|
|
|
and subdomain not in (LOCAL_DOMAINS + (user.ap_subdomain,))):
|
|
|
|
url = urljoin(f'https://{user.ap_subdomain}{common.SUPERDOMAIN}/',
|
|
|
|
request.full_path)
|
|
|
|
raise Found(location=url)
|
|
|
|
|
2023-11-20 04:27:48 +00:00
|
|
|
actor = user.obj.as1 if user.obj and user.obj.as1 else {}
|
2024-08-02 15:02:36 +00:00
|
|
|
logger.info(f'Generating WebFinger data for {user.key.id()}')
|
2024-05-29 04:14:33 +00:00
|
|
|
|
|
|
|
actor_id = user.id_as(activitypub.ActivityPub)
|
|
|
|
logger.info(f'AS1 actor: {actor_id}')
|
|
|
|
|
2023-02-10 04:00:58 +00:00
|
|
|
urls = util.dedupe_urls(util.get_list(actor, 'urls') +
|
|
|
|
util.get_list(actor, 'url') +
|
2023-11-20 04:27:48 +00:00
|
|
|
[user.web_url()])
|
2023-02-10 04:00:58 +00:00
|
|
|
logger.info(f'URLs: {urls}')
|
2021-07-11 23:30:14 +00:00
|
|
|
canonical_url = urls[0]
|
|
|
|
|
|
|
|
# generate webfinger content
|
|
|
|
data = util.trim_nulls({
|
2023-12-01 23:46:37 +00:00
|
|
|
'subject': 'acct:' + ap_handle.lstrip('@'),
|
2021-07-11 23:30:14 +00:00
|
|
|
'aliases': urls,
|
2023-02-10 04:00:58 +00:00
|
|
|
'links':
|
|
|
|
[{
|
2021-07-11 23:30:14 +00:00
|
|
|
'rel': 'http://webfinger.net/rel/profile-page',
|
|
|
|
'type': 'text/html',
|
|
|
|
'href': url,
|
2023-02-10 04:00:58 +00:00
|
|
|
} for url in urls if util.is_web(url)] +
|
|
|
|
|
|
|
|
[{
|
2021-07-11 23:30:14 +00:00
|
|
|
'rel': 'http://webfinger.net/rel/avatar',
|
2023-02-10 04:00:58 +00:00
|
|
|
'href': url,
|
|
|
|
} for url in util.get_urls(actor, 'image')] +
|
|
|
|
|
|
|
|
[{
|
2021-07-11 23:30:14 +00:00
|
|
|
'rel': 'canonical_uri',
|
|
|
|
'type': 'text/html',
|
|
|
|
'href': canonical_url,
|
|
|
|
},
|
|
|
|
|
|
|
|
# ActivityPub
|
2024-05-05 15:39:32 +00:00
|
|
|
#
|
|
|
|
# include two self links, one for each AP content type, since some
|
|
|
|
# fediverse servers (eg Pleroma, Akkoma) are unnecessarily picky
|
|
|
|
# about which one they use from XRD vs JRD.
|
|
|
|
# https://github.com/snarfed/bridgy-fed/issues/995
|
2021-07-11 23:30:14 +00:00
|
|
|
{
|
|
|
|
'rel': 'self',
|
2024-02-27 19:38:00 +00:00
|
|
|
'type': as2.CONTENT_TYPE_LD_PROFILE,
|
2024-05-05 15:39:32 +00:00
|
|
|
'href': actor_id,
|
|
|
|
}, {
|
|
|
|
'rel': 'self',
|
|
|
|
'type': as2.CONTENT_TYPE,
|
2023-12-01 00:31:41 +00:00
|
|
|
'href': actor_id,
|
2021-07-11 23:30:14 +00:00
|
|
|
}, {
|
2023-01-07 17:34:55 +00:00
|
|
|
# AP reads this and sharedInbox from the AS2 actor, not
|
|
|
|
# webfinger, so strictly speaking, it's probably not needed here.
|
2021-07-11 23:30:14 +00:00
|
|
|
'rel': 'inbox',
|
2024-02-27 19:38:00 +00:00
|
|
|
'type': as2.CONTENT_TYPE_LD_PROFILE,
|
2023-12-01 00:31:41 +00:00
|
|
|
'href': actor_id + '/inbox',
|
2022-11-16 18:09:24 +00:00
|
|
|
}, {
|
|
|
|
# https://www.w3.org/TR/activitypub/#sharedInbox
|
|
|
|
'rel': 'sharedInbox',
|
2024-02-27 19:38:00 +00:00
|
|
|
'type': as2.CONTENT_TYPE_LD_PROFILE,
|
2023-10-23 22:44:32 +00:00
|
|
|
'href': common.subdomain_wrap(cls, '/ap/sharedInbox'),
|
2021-07-11 23:30:14 +00:00
|
|
|
},
|
|
|
|
|
2022-12-01 16:15:48 +00:00
|
|
|
# remote follow
|
|
|
|
# https://socialhub.activitypub.rocks/t/what-is-the-current-spec-for-remote-follow/2020/11?u=snarfed
|
|
|
|
# https://github.com/snarfed/bridgy-fed/issues/60#issuecomment-1325589750
|
|
|
|
{
|
|
|
|
'rel': 'http://ostatus.org/schema/1.0/subscribe',
|
2023-09-27 20:55:16 +00:00
|
|
|
# always use fed.brid.gy for UI pages, not protocol subdomain
|
2023-06-13 02:01:50 +00:00
|
|
|
# TODO: switch to:
|
2023-11-20 04:27:48 +00:00
|
|
|
# 'template': common.host_url(user.user_page_path('?url={uri}')),
|
2023-09-25 19:33:24 +00:00
|
|
|
# the problem is that user_page_path() uses handle_or_id, which uses
|
2023-06-03 15:03:38 +00:00
|
|
|
# custom username instead of domain, which may not be unique
|
2023-09-27 20:55:16 +00:00
|
|
|
'template': f'https://{common.PRIMARY_DOMAIN}' +
|
2023-11-20 04:27:48 +00:00
|
|
|
user.user_page_path('?url={uri}'),
|
2021-07-11 23:30:14 +00:00
|
|
|
}]
|
2021-07-11 20:39:19 +00:00
|
|
|
})
|
2023-01-26 04:41:29 +00:00
|
|
|
|
2024-05-29 04:14:33 +00:00
|
|
|
# logger.info(f'Returning WebFinger data: {json_dumps(data, indent=2)}')
|
2021-07-11 23:30:14 +00:00
|
|
|
return data
|
2017-08-19 15:21:05 +00:00
|
|
|
|
2021-07-11 23:30:14 +00:00
|
|
|
|
2021-07-18 04:22:13 +00:00
|
|
|
class HostMeta(flask_util.XrdOrJrd):
|
2023-10-06 06:32:31 +00:00
|
|
|
"""Renders and serves the ``/.well-known/host-meta`` file.
|
2021-07-11 23:50:44 +00:00
|
|
|
|
|
|
|
Supports both JRD and XRD; defaults to XRD.
|
|
|
|
https://tools.ietf.org/html/rfc6415#section-3
|
|
|
|
"""
|
2021-07-18 04:22:13 +00:00
|
|
|
DEFAULT_TYPE = flask_util.XrdOrJrd.XRD
|
2021-07-11 23:50:44 +00:00
|
|
|
|
|
|
|
def template_prefix(self):
|
|
|
|
return 'host-meta'
|
|
|
|
|
|
|
|
def template_vars(self):
|
2023-01-05 23:03:21 +00:00
|
|
|
return {'host_uri': common.host_url()}
|
2021-07-11 23:50:44 +00:00
|
|
|
|
|
|
|
|
|
|
|
@app.get('/.well-known/host-meta.xrds')
|
2024-06-04 21:19:04 +00:00
|
|
|
@flask_util.headers(CACHE_CONTROL)
|
2021-07-11 23:50:44 +00:00
|
|
|
def host_meta_xrds():
|
2023-10-06 06:32:31 +00:00
|
|
|
"""Renders and serves the ``/.well-known/host-meta.xrds`` XRDS-Simple file."""
|
2024-05-30 21:55:35 +00:00
|
|
|
return render_template('host-meta.xrds', host_uri=common.host_url()), {
|
|
|
|
'Content-Type': 'application/xrds+xml',
|
|
|
|
}
|
2021-07-11 23:50:44 +00:00
|
|
|
|
|
|
|
|
2023-06-06 18:29:36 +00:00
|
|
|
def fetch(addr):
|
2023-09-22 20:11:15 +00:00
|
|
|
"""Fetches and returns an address's WebFinger data.
|
2023-06-06 18:29:36 +00:00
|
|
|
|
|
|
|
On failure, flashes a message and returns None.
|
|
|
|
|
|
|
|
TODO: switch to raising exceptions instead of flashing messages and
|
|
|
|
returning None
|
|
|
|
|
|
|
|
Args:
|
2023-10-06 06:32:31 +00:00
|
|
|
addr (str): a Webfinger-compatible address, eg ``@x@y``, ``acct:x@y``, or
|
|
|
|
``https://x/y``
|
2023-06-06 18:29:36 +00:00
|
|
|
|
|
|
|
Returns:
|
2023-09-22 20:11:15 +00:00
|
|
|
dict: fetched WebFinger data, or None on error
|
2023-06-06 18:29:36 +00:00
|
|
|
"""
|
|
|
|
addr = addr.strip().strip('@')
|
|
|
|
split = addr.split('@')
|
|
|
|
if len(split) == 2:
|
|
|
|
addr_domain = split[1]
|
|
|
|
resource = f'acct:{addr}'
|
|
|
|
elif util.is_web(addr):
|
|
|
|
addr_domain = util.domain_from_link(addr, minimize=False)
|
|
|
|
resource = addr
|
|
|
|
else:
|
|
|
|
flash('Enter a fediverse address in @user@domain.social format')
|
|
|
|
return None
|
|
|
|
|
|
|
|
try:
|
Revert "cache outbound HTTP request responses, locally to each inbound request"
This reverts commit 30debfc8faf730190bd51a3aef49df6c6bfbd50a.
seemed promising, but broke in production. Saw a lot of `IncompleteRead`s on both GETs and POSTs. Rolled back for now.
```
('Connection broken: IncompleteRead(9172 bytes read, -4586 more expected)', IncompleteRead(9172 bytes read, -4586 more expected))
...
File "oauth_dropins/webutil/util.py", line 1673, in call
resp = getattr((session or requests), fn)(url, *args, **kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "requests_cache/session.py", line 102, in get
return self.request('GET', url, params=params, **kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "requests_cache/session.py", line 158, in request
return super().request(method, url, *args, headers=headers, **kwargs) # type: ignore
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "requests/sessions.py", line 589, in request
resp = self.send(prep, **send_kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "requests_cache/session.py", line 205, in send
response = self._send_and_cache(request, actions, cached_response, **kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "requests_cache/session.py", line 233, in _send_and_cache
self.cache.save_response(response, actions.cache_key, actions.expires)
File "requests_cache/backends/base.py", line 89, in save_response
cached_response = CachedResponse.from_response(response, expires=expires)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "requests_cache/models/response.py", line 102, in from_response
obj.raw = CachedHTTPResponse.from_response(response)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "requests_cache/models/raw_response.py", line 69, in from_response
_ = response.content # This property reads, decodes, and stores response content
^^^^^^^^^^^^^^^^
File "requests/models.py", line 899, in content
self._content = b"".join(self.iter_content(CONTENT_CHUNK_SIZE)) or b""
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "requests/models.py", line 818, in generate
raise ChunkedEncodingError(e)
```
2024-03-08 21:24:28 +00:00
|
|
|
resp = util.requests_get(
|
2023-06-06 18:29:36 +00:00
|
|
|
f'https://{addr_domain}/.well-known/webfinger?resource={resource}')
|
|
|
|
except BaseException as e:
|
|
|
|
if util.is_connection_failure(e):
|
|
|
|
flash(f"Couldn't connect to {addr_domain}")
|
|
|
|
return None
|
|
|
|
raise
|
|
|
|
|
|
|
|
if not resp.ok:
|
|
|
|
flash(f'WebFinger on {addr_domain} returned HTTP {resp.status_code}')
|
|
|
|
return None
|
|
|
|
|
|
|
|
try:
|
|
|
|
data = resp.json()
|
|
|
|
except ValueError as e:
|
|
|
|
logger.warning(f'Got {e}', exc_info=True)
|
|
|
|
flash(f'WebFinger on {addr_domain} returned non-JSON')
|
|
|
|
return None
|
|
|
|
|
2024-06-01 14:17:44 +00:00
|
|
|
logger.info(f'Got WebFinger for {addr}')
|
2023-06-06 18:29:36 +00:00
|
|
|
return data
|
|
|
|
|
|
|
|
|
2023-09-22 19:48:00 +00:00
|
|
|
def fetch_actor_url(addr):
|
|
|
|
"""Fetches and returns a WebFinger address's ActivityPub actor URL.
|
|
|
|
|
|
|
|
On failure, flashes a message and returns None.
|
|
|
|
|
|
|
|
Args:
|
|
|
|
addr (str): a Webfinger-compatible address, eg ``@x@y``, ``acct:x@y``, or
|
|
|
|
``https://x/y``
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
str: ActivityPub actor URL, or None on error or not fouund
|
|
|
|
"""
|
|
|
|
data = fetch(addr)
|
|
|
|
if not data:
|
|
|
|
return None
|
|
|
|
|
|
|
|
for link in data.get('links', []):
|
|
|
|
type = link.get('type', '').split(';')[0]
|
|
|
|
if link.get('rel') == 'self' and type in as2.CONTENT_TYPES:
|
|
|
|
return link.get('href')
|
|
|
|
|
|
|
|
|
2021-07-11 23:30:14 +00:00
|
|
|
app.add_url_rule('/.well-known/webfinger', view_func=Webfinger.as_view('webfinger'))
|
2021-07-11 23:50:44 +00:00
|
|
|
app.add_url_rule('/.well-known/host-meta', view_func=HostMeta.as_view('hostmeta'))
|
|
|
|
app.add_url_rule('/.well-known/host-meta.json', view_func=HostMeta.as_view('hostmeta-json'))
|