bridgy-fed/webfinger.py

279 wiersze
9.5 KiB
Python
Czysty Zwykły widok Historia

2017-08-19 15:21:05 +00:00
"""Handles requests for WebFinger endpoints.
* https://webfinger.net/
* https://tools.ietf.org/html/rfc7033
2017-08-19 15:21:05 +00:00
"""
import logging
from urllib.parse import urljoin, urlparse
2017-08-19 15:21:05 +00:00
from flask import g, render_template, request
from granary import as2
from oauth_dropins.webutil import flask_util, util
from oauth_dropins.webutil.flask_util import error, flash, Found
from oauth_dropins.webutil.util import json_dumps, json_loads
2017-08-19 15:21:05 +00:00
import activitypub
2017-08-19 15:21:05 +00:00
import common
from common import LOCAL_DOMAINS, PRIMARY_DOMAIN, PROTOCOL_DOMAINS, SUPERDOMAIN
from flask_app import app, cache
from protocol import Protocol
from web import Web
2017-08-19 15:21:05 +00:00
SUBSCRIBE_LINK_REL = 'http://ostatus.org/schema/1.0/subscribe'
logger = logging.getLogger(__name__)
2017-08-19 15:21:05 +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
"""
@flask_util.cached(cache, common.CACHE_TIME, headers=['Accept'])
def dispatch_request(self, *args, **kwargs):
return super().dispatch_request(*args, **kwargs)
def template_prefix(self):
return 'webfinger_user'
def template_vars(self):
logger.debug(f'Headers: {list(request.headers.items())}')
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
cls = None
try:
user, id = util.parse_acct_uri(resource)
cls = Protocol.for_bridgy_subdomain(id, fed='web')
if cls:
id = user
noop, lint fixes from flake8 remaining: $ flake8 --extend-ignore=E501 *.py tests/*.py "pyflakes" failed during execution due to "'FlakesChecker' object has no attribute 'NAMEDEXPR'" Run flake8 with greater verbosity to see more details activitypub.py:15:1: F401 'oauth_dropins.webutil.util.json_loads' imported but unused activitypub.py:36:1: F401 'web' imported but unused activitypub.py:48:1: E302 expected 2 blank lines, found 1 activitypub.py:51:9: F811 redefinition of unused 'web' from line 36 app.py:6:1: F401 'flask_app.app' imported but unused app.py:9:1: F401 'activitypub' imported but unused app.py:9:1: F401 'convert' imported but unused app.py:9:1: F401 'follow' imported but unused app.py:9:1: F401 'pages' imported but unused app.py:9:1: F401 'redirect' imported but unused app.py:9:1: F401 'superfeedr' imported but unused app.py:9:1: F401 'ui' imported but unused app.py:9:1: F401 'webfinger' imported but unused app.py:9:1: F401 'web' imported but unused app.py:9:1: F401 'xrpc_actor' imported but unused app.py:9:1: F401 'xrpc_feed' imported but unused app.py:9:1: F401 'xrpc_graph' imported but unused app.py:9:19: E401 multiple imports on one line models.py:19:1: F401 'oauth_dropins.webutil.util.json_loads' imported but unused models.py:364:31: E114 indentation is not a multiple of four (comment) models.py:364:31: E116 unexpected indentation (comment) protocol.py:17:1: F401 'oauth_dropins.webutil.util.json_loads' imported but unused redirect.py:26:1: F401 'oauth_dropins.webutil.util.json_loads' imported but unused web.py:18:1: F401 'oauth_dropins.webutil.util.json_loads' imported but unused webfinger.py:13:1: F401 'oauth_dropins.webutil.util.json_loads' imported but unused webfinger.py:110:13: E122 continuation line missing indentation or outdented webfinger.py:111:13: E122 continuation line missing indentation or outdented webfinger.py:131:13: E122 continuation line missing indentation or outdented webfinger.py:132:13: E122 continuation line missing indentation or outdented webfinger.py:133:13: E122 continuation line missing indentation or outdented webfinger.py:134:13: E122 continuation line missing indentation or outdented tests/__init__.py:2:1: F401 'oauth_dropins.webutil.tests' imported but unused tests/test_follow.py:11:1: F401 'oauth_dropins.webutil.util.json_dumps' imported but unused tests/test_follow.py:14:1: F401 '.testutil.Fake' imported but unused tests/test_models.py:156:15: E122 continuation line missing indentation or outdented tests/test_models.py:157:15: E122 continuation line missing indentation or outdented tests/test_models.py:158:11: E122 continuation line missing indentation or outdented tests/test_web.py:12:1: F401 'oauth_dropins.webutil.util.json_dumps' imported but unused tests/test_web.py:17:1: F401 '.testutil' imported but unused tests/test_web.py:1513:13: E128 continuation line under-indented for visual indent tests/test_web.py:1514:9: E124 closing bracket does not match visual indentation tests/testutil.py:106:1: E402 module level import not at top of file tests/testutil.py:107:1: E402 module level import not at top of file tests/testutil.py:108:1: E402 module level import not at top of file tests/testutil.py:109:1: E402 module level import not at top of file tests/testutil.py:110:1: E402 module level import not at top of file tests/testutil.py:301:24: E203 whitespace before ':' tests/testutil.py:301:25: E701 multiple statements on one line (colon) tests/testutil.py:301:25: E231 missing whitespace after ':'
2023-06-20 18:22:54 +00:00
allow_indirect = True
except ValueError:
id = urlparse(resource).netloc or resource
if id == PRIMARY_DOMAIN or id in PROTOCOL_DOMAINS:
cls = Web
elif not cls:
cls = Protocol.for_request(fed='web')
if not cls:
error('Unknown protocol')
# is this a handle?
if cls.owns_id(id) is False:
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:
error(f'{id} is not a valid {cls.LABEL} id')
logger.info(f'Protocol {cls.LABEL}, user id {id}')
# 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
if allow_indirect:
user = cls.get_or_create(id)
else:
user = cls.get_by_id(id)
if user and not user.direct:
error(f"{user.key} hasn't signed up yet", status=404)
if not user:
error(f'No {cls.LABEL} user found for {id}', status=404)
ap_handle = user.handle_as('activitypub')
if not ap_handle:
error(f'{cls.LABEL} user {id} has no handle', status=404)
# 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)
actor = user.obj.as1 if user.obj and user.obj.as1 else {}
logger.info(f'Generating WebFinger data for {user.key}')
logger.info(f'AS1 actor: {actor}')
urls = util.dedupe_urls(util.get_list(actor, 'urls') +
util.get_list(actor, 'url') +
[user.web_url()])
logger.info(f'URLs: {urls}')
canonical_url = urls[0]
# generate webfinger content
actor_id = user.id_as(activitypub.ActivityPub)
data = util.trim_nulls({
'subject': 'acct:' + ap_handle.lstrip('@'),
'aliases': urls,
'links':
[{
'rel': 'http://webfinger.net/rel/profile-page',
'type': 'text/html',
'href': url,
} for url in urls if util.is_web(url)] +
[{
'rel': 'http://webfinger.net/rel/avatar',
'href': url,
} for url in util.get_urls(actor, 'image')] +
[{
'rel': 'canonical_uri',
'type': 'text/html',
'href': canonical_url,
},
# ActivityPub
{
'rel': 'self',
'type': as2.CONTENT_TYPE_LD_PROFILE,
# WARNING: in python 2 sometimes request.host_url lost port,
# http://localhost:8080 would become just http://localhost. no
# clue how or why. pay attention here if that happens again.
'href': actor_id,
}, {
# AP reads this and sharedInbox from the AS2 actor, not
# webfinger, so strictly speaking, it's probably not needed here.
'rel': 'inbox',
'type': as2.CONTENT_TYPE_LD_PROFILE,
'href': actor_id + '/inbox',
}, {
# https://www.w3.org/TR/activitypub/#sharedInbox
'rel': 'sharedInbox',
'type': as2.CONTENT_TYPE_LD_PROFILE,
'href': common.subdomain_wrap(cls, '/ap/sharedInbox'),
},
# 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',
# always use fed.brid.gy for UI pages, not protocol subdomain
# TODO: switch to:
# 'template': common.host_url(user.user_page_path('?url={uri}')),
# the problem is that user_page_path() uses handle_or_id, which uses
# custom username instead of domain, which may not be unique
'template': f'https://{common.PRIMARY_DOMAIN}' +
user.user_page_path('?url={uri}'),
}]
})
logger.info(f'Returning WebFinger data: {json_dumps(data, indent=2)}')
return data
2017-08-19 15:21:05 +00:00
class HostMeta(flask_util.XrdOrJrd):
"""Renders and serves the ``/.well-known/host-meta`` file.
Supports both JRD and XRD; defaults to XRD.
https://tools.ietf.org/html/rfc6415#section-3
"""
DEFAULT_TYPE = flask_util.XrdOrJrd.XRD
def template_prefix(self):
return 'host-meta'
def template_vars(self):
return {'host_uri': common.host_url()}
@app.get('/.well-known/host-meta.xrds')
def host_meta_xrds():
"""Renders and serves the ``/.well-known/host-meta.xrds`` XRDS-Simple file."""
return (render_template('host-meta.xrds', host_uri=common.host_url()),
{'Content-Type': 'application/xrds+xml'})
def fetch(addr):
"""Fetches and returns an address's WebFinger data.
On failure, flashes a message and returns None.
TODO: switch to raising exceptions instead of flashing messages and
returning None
Args:
addr (str): a Webfinger-compatible address, eg ``@x@y``, ``acct:x@y``, or
``https://x/y``
Returns:
dict: fetched WebFinger data, or None on error
"""
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(
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
logger.info(f'Got: {json_dumps(data, indent=2)}')
return data
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')
app.add_url_rule('/.well-known/webfinger', view_func=Webfinger.as_view('webfinger'))
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'))