kopia lustrzana https://github.com/snarfed/bridgy-fed
rodzic
6e000d348d
commit
c50f0e0106
|
@ -8,8 +8,9 @@ from oauth_dropins.webutil import appengine_info, util
|
|||
# otherwise.
|
||||
SESSION_COOKIE_SECURE = True
|
||||
SESSION_COOKIE_HTTPONLY = True
|
||||
# Change to Lax if/when we add IndieAuth for anything.
|
||||
SESSION_COOKIE_SAMESITE = 'Strict'
|
||||
# Not strict because we flash messages after cross-site redirects for OAuth,
|
||||
# which strict blocks.
|
||||
SESSION_COOKIE_SAMESITE = 'Lax'
|
||||
|
||||
if appengine_info.DEBUG:
|
||||
ENV = 'development'
|
||||
|
|
164
follow.py
164
follow.py
|
@ -8,20 +8,71 @@ import logging
|
|||
import urllib.parse
|
||||
|
||||
from flask import redirect, request
|
||||
from granary import as2
|
||||
from oauth_dropins import indieauth
|
||||
from oauth_dropins.webutil import flask_util, util
|
||||
from oauth_dropins.webutil.flask_util import error, flash
|
||||
from oauth_dropins.webutil import util
|
||||
from oauth_dropins.webutil.util import json_dumps, json_loads
|
||||
|
||||
from app import app
|
||||
import common
|
||||
from models import User
|
||||
from models import Follower, User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SUBSCRIBE_LINK_REL = 'http://ostatus.org/schema/1.0/subscribe'
|
||||
|
||||
|
||||
@app.post('/follow')
|
||||
def fetch_webfinger(addr):
|
||||
"""Fetches and returns an address's Webfinger data.
|
||||
|
||||
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:
|
||||
dict, fetched Webfinger data
|
||||
"""
|
||||
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:
|
||||
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
|
||||
|
||||
|
||||
@app.post('/remote-follow')
|
||||
def remote_follow():
|
||||
"""Discovers and redirects to a remote follow page for a given user."""
|
||||
logger.info(f'Got: {request.values}')
|
||||
|
@ -31,42 +82,11 @@ def remote_follow():
|
|||
if not user:
|
||||
error(f'No Bridgy Fed user found for domain {domain}')
|
||||
|
||||
addr = request.values['address'].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 your fediverse address in @user@domain.social format')
|
||||
webfinger = fetch_webfinger(request.values['address'])
|
||||
if webfinger is None:
|
||||
return redirect(f'/user/{domain}')
|
||||
|
||||
# look up remote user via webfinger
|
||||
try:
|
||||
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 redirect(f'/user/{domain}')
|
||||
raise
|
||||
|
||||
if not resp.ok:
|
||||
flash(f'WebFinger on {addr_domain} returned HTTP {resp.status_code}')
|
||||
return redirect(f'/user/{domain}')
|
||||
|
||||
# find remote follow link and redirect
|
||||
try:
|
||||
data = resp.json()
|
||||
except ValueError as e:
|
||||
logger.warning(f'Got {e}', exc_info=True)
|
||||
flash(f'WebFinger on {domain} returned non-JSON')
|
||||
return redirect(f'/user/{domain}')
|
||||
|
||||
logger.info(f'Got: {json_dumps(data, indent=2)}')
|
||||
for link in data.get('links', []):
|
||||
for link in webfinger.get('links', []):
|
||||
if link.get('rel') == SUBSCRIBE_LINK_REL:
|
||||
template = link.get('template')
|
||||
if template and '{uri}' in template:
|
||||
|
@ -74,3 +94,75 @@ def remote_follow():
|
|||
|
||||
flash(f"Couldn't find remote follow link for {addr}")
|
||||
return redirect(f'/user/{domain}')
|
||||
|
||||
|
||||
class FollowStart(indieauth.Start):
|
||||
"""Starts the IndieAuth flow to add a follower to an existing user."""
|
||||
def dispatch_request(self):
|
||||
address = request.form['address']
|
||||
|
||||
try:
|
||||
return redirect(self.redirect_url(state=address))
|
||||
except Exception as e:
|
||||
if util.is_connection_failure(e) or util.interpret_http_exception(e)[0]:
|
||||
flash(f"Couldn't fetch your web site: {e}")
|
||||
return redirect(f'/user/{domain}/following?address={address}')
|
||||
raise
|
||||
|
||||
|
||||
class FollowCallback(indieauth.Callback):
|
||||
"""IndieAuth callback to add a follower to an existing user."""
|
||||
def finish(self, auth_entity, state=None):
|
||||
if not auth_entity:
|
||||
return
|
||||
|
||||
domain = util.domain_from_link(auth_entity.key.id())
|
||||
if not User.get_by_id(domain):
|
||||
error(f'No user for domain {domain}')
|
||||
|
||||
# addr = state.get('address')
|
||||
# if not addr:
|
||||
# error(f'state missing address field')
|
||||
addr = state
|
||||
assert addr
|
||||
webfinger = fetch_webfinger(addr)
|
||||
if webfinger is None:
|
||||
return redirect(f'/user/{domain}/following')
|
||||
|
||||
as2_url = None
|
||||
for link in webfinger.get('links', []):
|
||||
if link.get('rel') == 'self' and link.get('type') == as2.CONTENT_TYPE:
|
||||
as2_url = link.get('href')
|
||||
|
||||
if not as2_url:
|
||||
flash(f"Couldn't find ActivityPub profile link for {addr}")
|
||||
return redirect(f'/user/{domain}/following')
|
||||
|
||||
resp = common.get_as2(as2_url)
|
||||
obj = resp.json()
|
||||
id = obj.get('id')
|
||||
inbox = obj.get('inbox')
|
||||
if not id or not inbox:
|
||||
flash(f"AS2 profile {as2_url} missing id or inbox")
|
||||
return redirect(f'/user/{domain}/following')
|
||||
|
||||
common.signed_post(inbox, data={
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
'type': 'Follow',
|
||||
'id': 'TODO',
|
||||
'object': id,
|
||||
'actor': common.host_url(domain),
|
||||
'to': [as2.PUBLIC_AUDIENCE],
|
||||
})
|
||||
|
||||
Follower.get_or_create(dest=id, src=domain, status='active',
|
||||
last_follow=json_dumps({}))
|
||||
flash(f'Followed {addr}.')
|
||||
return redirect(f'/user/{domain}/following')
|
||||
|
||||
|
||||
app.add_url_rule('/follow/start',
|
||||
view_func=FollowStart.as_view('follow_start', '/follow/callback'),
|
||||
methods=['POST'])
|
||||
app.add_url_rule('/follow/callback',
|
||||
view_func=FollowCallback.as_view('follow_callback', 'unused'))
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
https://fed.brid.gy/
|
1
pages.py
1
pages.py
|
@ -97,6 +97,7 @@ def user(domain):
|
|||
follow_url=request.values.get('url'),
|
||||
logs=logs,
|
||||
util=util,
|
||||
**request.args,
|
||||
**locals(),
|
||||
)
|
||||
|
||||
|
|
|
@ -6,7 +6,20 @@
|
|||
|
||||
{% include "user_addresses.html" %}
|
||||
|
||||
<div class="row">Following</div>
|
||||
<div class="row big">Following</div>
|
||||
|
||||
<div class="row">
|
||||
<form method="post" action="/follow">
|
||||
<p>
|
||||
<label for="follower-address">Add a follower (requires <a href="https://indieauth.net/">IndieAuth</a>):</label>
|
||||
<input id="follower-address" name="address" type="text" required
|
||||
placeholder="@user@domain.social" alt="fediverse address"
|
||||
value="{{ address or '' }}"></input>
|
||||
<input name="me" type="hidden" value="https://{{ domain }}"></input>
|
||||
<button type="submit" class="btn btn-default">Follow</button>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% include "_followers.html" %}
|
||||
|
||||
|
|
|
@ -49,7 +49,7 @@
|
|||
</div>
|
||||
|
||||
<div class="row">
|
||||
<form method="post" action="/follow">
|
||||
<form method="post" action="/remote-follow">
|
||||
<p>
|
||||
<label for="follow-address">Enter your fediverse address to follow:</label>
|
||||
<input id="follow-address" name="address" type="text" required
|
||||
|
@ -61,8 +61,6 @@
|
|||
</form>
|
||||
</div>
|
||||
|
||||
<!-- <div class="row">Recent activity</div> -->
|
||||
|
||||
{% include "activities.html" %}
|
||||
|
||||
{% endblock %}
|
||||
|
|
|
@ -2,10 +2,15 @@
|
|||
"""
|
||||
from unittest.mock import patch
|
||||
|
||||
from flask import get_flashed_messages
|
||||
from granary import as2
|
||||
from oauth_dropins import indieauth
|
||||
from oauth_dropins.webutil import util
|
||||
from oauth_dropins.webutil.testutil import requests_response
|
||||
from oauth_dropins.webutil.util import json_dumps, json_loads
|
||||
|
||||
import common
|
||||
from models import User
|
||||
from models import Follower, User
|
||||
from . import testutil
|
||||
|
||||
WEBFINGER = requests_response({
|
||||
|
@ -16,31 +21,36 @@ WEBFINGER = requests_response({
|
|||
'links': [{
|
||||
'rel': 'http://ostatus.org/schema/1.0/subscribe',
|
||||
'template': 'https://bar/follow?uri={uri}'
|
||||
}, {
|
||||
'rel': 'self',
|
||||
'type': as2.CONTENT_TYPE,
|
||||
'href': 'https://bar/actor'
|
||||
}],
|
||||
})
|
||||
|
||||
|
||||
@patch('requests.get')
|
||||
class FollowTest(testutil.TestCase):
|
||||
class RemoteFollowTest(testutil.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
User.get_or_create('me')
|
||||
|
||||
def test_follow_no_domain(self, mock_get):
|
||||
got = self.client.post('/follow?address=@foo@bar')
|
||||
got = self.client.post('/remote-follow?address=@foo@bar')
|
||||
self.assertEqual(400, got.status_code)
|
||||
|
||||
def test_follow_no_address(self, mock_get):
|
||||
got = self.client.post('/follow?domain=baz.com')
|
||||
got = self.client.post('/remote-follow?domain=baz.com')
|
||||
self.assertEqual(400, got.status_code)
|
||||
|
||||
def test_follow_no_user(self, mock_get):
|
||||
got = self.client.post('/follow?address=@foo@bar&domain=baz.com')
|
||||
got = self.client.post('/remote-follow?address=@foo@bar&domain=baz.com')
|
||||
self.assertEqual(400, got.status_code)
|
||||
|
||||
def test_follow(self, mock_get):
|
||||
mock_get.return_value = WEBFINGER
|
||||
got = self.client.post('/follow?address=@foo@bar&domain=me')
|
||||
got = self.client.post('/remote-follow?address=@foo@bar&domain=me')
|
||||
self.assertEqual(302, got.status_code)
|
||||
self.assertEqual('https://bar/follow?uri=@me@me',
|
||||
got.headers['Location'])
|
||||
|
@ -51,7 +61,7 @@ class FollowTest(testutil.TestCase):
|
|||
|
||||
def test_follow_url(self, mock_get):
|
||||
mock_get.return_value = WEBFINGER
|
||||
got = self.client.post('/follow?address=https://bar/foo&domain=me')
|
||||
got = self.client.post('/remote-follow?address=https://bar/foo&domain=me')
|
||||
self.assertEqual(302, got.status_code)
|
||||
self.assertEqual('https://bar/follow?uri=@me@me', got.headers['Location'])
|
||||
|
||||
|
@ -65,20 +75,102 @@ class FollowTest(testutil.TestCase):
|
|||
'links': [{'rel': 'other', 'template': 'meh'}],
|
||||
})
|
||||
|
||||
got = self.client.post('/follow?address=https://bar/foo&domain=me')
|
||||
got = self.client.post('/remote-follow?address=https://bar/foo&domain=me')
|
||||
self.assertEqual(302, got.status_code)
|
||||
self.assertEqual('/user/me', got.headers['Location'])
|
||||
|
||||
def test_follow_no_webfinger_subscribe_link(self, mock_get):
|
||||
mock_get.return_value = requests_response(status_code=500)
|
||||
|
||||
got = self.client.post('/follow?address=https://bar/foo&domain=me')
|
||||
got = self.client.post('/remote-follow?address=https://bar/foo&domain=me')
|
||||
self.assertEqual(302, got.status_code)
|
||||
self.assertEqual('/user/me', got.headers['Location'])
|
||||
|
||||
def test_follow_no_webfinger_subscribe_link(self, mock_get):
|
||||
mock_get.return_value = requests_response('<html>not json</html>')
|
||||
|
||||
got = self.client.post('/follow?address=https://bar/foo&domain=me')
|
||||
got = self.client.post('/remote-follow?address=https://bar/foo&domain=me')
|
||||
self.assertEqual(302, got.status_code)
|
||||
self.assertEqual('/user/me', got.headers['Location'])
|
||||
|
||||
|
||||
@patch('requests.post')
|
||||
@patch('requests.get')
|
||||
class AddFollowerTest(testutil.TestCase):
|
||||
|
||||
def test_start(self, mock_get, _):
|
||||
resp = self.client.post('/follow/start', data={
|
||||
'me': 'https://snarfed.org',
|
||||
'address': '@foo@bar',
|
||||
})
|
||||
self.assertEqual(302, resp.status_code)
|
||||
self.assertTrue(resp.headers['Location'].startswith(indieauth.INDIEAUTH_URL),
|
||||
resp.headers['Location'])
|
||||
|
||||
def test_callback(self, mock_get, mock_post):
|
||||
mock_post.side_effect = (
|
||||
requests_response('me=https://snarfed.org'),
|
||||
requests_response('OK'), # AP Follow to inbox
|
||||
)
|
||||
mock_get.side_effect = (
|
||||
# oauth-dropins indieauth https://snarfed.org fetch for user json
|
||||
requests_response(''),
|
||||
WEBFINGER,
|
||||
self.as2_resp({
|
||||
'type': 'Person',
|
||||
'id': 'https://bar/id',
|
||||
'inbox': 'http://bar/inbox',
|
||||
}),
|
||||
)
|
||||
User.get_or_create('snarfed.org')
|
||||
|
||||
state = util.encode_oauth_state({
|
||||
'endpoint': 'http://auth/endpoint',
|
||||
'me': 'https://snarfed.org',
|
||||
'state': '@foo@bar',
|
||||
})
|
||||
with self.client:
|
||||
resp = self.client.get(f'/follow/callback?code=my_code&state={state}')
|
||||
self.assertEqual(302, resp.status_code)
|
||||
self.assertEqual('/user/snarfed.org/following',resp.headers['Location'])
|
||||
self.assertEqual(['Followed @foo@bar.'], get_flashed_messages())
|
||||
|
||||
mock_get.assert_has_calls((
|
||||
self.req('https://bar/.well-known/webfinger?resource=acct:foo@bar'),
|
||||
self.as2_req('https://bar/actor'),
|
||||
))
|
||||
mock_post.assert_has_calls((
|
||||
self.req('http://auth/endpoint', data={
|
||||
'me': 'https://snarfed.org',
|
||||
'state': '@foo@bar',
|
||||
'code': 'my_code',
|
||||
'client_id': indieauth.INDIEAUTH_CLIENT_ID,
|
||||
'redirect_uri': 'http://localhost/follow/callback',
|
||||
}),
|
||||
))
|
||||
inbox_args, inbox_kwargs = mock_post.call_args_list[-1]
|
||||
self.assertEqual(('http://bar/inbox',), inbox_args)
|
||||
self.assert_equals({
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
'type': 'Follow',
|
||||
'id': 'TODO',
|
||||
'actor': 'http://localhost/snarfed.org',
|
||||
'object': 'https://bar/id',
|
||||
'to': [as2.PUBLIC_AUDIENCE],
|
||||
}, json_loads(inbox_kwargs['data']))
|
||||
|
||||
followers = Follower.query().fetch()
|
||||
self.assertEqual(1, len(followers))
|
||||
self.assertEqual('https://bar/id snarfed.org', followers[0].key.id())
|
||||
|
||||
def test_callback_missing_user(self, mock_get, mock_post):
|
||||
mock_post.return_value = requests_response('me=https://snarfed.org')
|
||||
|
||||
state = util.encode_oauth_state({
|
||||
'endpoint': 'http://auth/endpoint',
|
||||
'me': 'https://snarfed.org',
|
||||
'state': '@foo@bar',
|
||||
})
|
||||
with self.client:
|
||||
resp = self.client.get(f'/follow/callback?code=my_code&state={state}')
|
||||
self.assertEqual(400, resp.status_code)
|
||||
|
|
|
@ -120,12 +120,12 @@ class Actor(flask_util.XrdOrJrd):
|
|||
# clue how or why. pay attention here if that happens again.
|
||||
'href': common.host_url(domain),
|
||||
}, {
|
||||
# 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,
|
||||
'href': common.host_url(f'{domain}/inbox'),
|
||||
}, {
|
||||
# AP reads this from the AS2 actor, not webfinger, so strictly
|
||||
# speaking, it's probably not needed here.
|
||||
# https://www.w3.org/TR/activitypub/#sharedInbox
|
||||
'rel': 'sharedInbox',
|
||||
'type': as2.CONTENT_TYPE,
|
||||
|
|
Ładowanie…
Reference in New Issue