2022-11-27 15:03:10 +00:00
|
|
|
"""Remote follow handler.
|
|
|
|
|
2023-10-06 06:32:31 +00:00
|
|
|
* https://github.com/snarfed/bridgy-fed/issues/60
|
|
|
|
* https://socialhub.activitypub.rocks/t/what-is-the-current-spec-for-remote-follow/2020
|
|
|
|
* https://www.rfc-editor.org/rfc/rfc7033
|
2022-11-27 15:03:10 +00:00
|
|
|
"""
|
|
|
|
import logging
|
|
|
|
|
2024-05-08 00:01:01 +00:00
|
|
|
from flask import redirect, request, session
|
2023-12-06 18:11:36 +00:00
|
|
|
from granary import as1
|
2023-01-07 17:34:55 +00:00
|
|
|
from oauth_dropins import indieauth
|
|
|
|
from oauth_dropins.webutil import util
|
2023-06-20 18:22:54 +00:00
|
|
|
from oauth_dropins.webutil.flask_util import error, flash
|
2022-11-27 15:03:10 +00:00
|
|
|
|
2023-05-31 17:10:14 +00:00
|
|
|
from activitypub import ActivityPub
|
2023-04-19 00:17:48 +00:00
|
|
|
from flask_app import app
|
2022-11-27 15:03:10 +00:00
|
|
|
import common
|
2023-05-30 23:53:08 +00:00
|
|
|
from models import Follower, Object, PROTOCOLS
|
2024-01-25 03:20:54 +00:00
|
|
|
from protocol import Protocol
|
2023-05-27 00:40:29 +00:00
|
|
|
from web import Web
|
2023-06-06 18:29:36 +00:00
|
|
|
import webfinger
|
2022-11-27 15:03:10 +00:00
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
2023-01-07 17:34:55 +00:00
|
|
|
|
|
|
|
@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}')
|
|
|
|
|
2023-05-30 23:53:08 +00:00
|
|
|
cls = PROTOCOLS.get(request.values['protocol'])
|
|
|
|
if not cls:
|
|
|
|
error(f'Unknown protocol {request.values["protocol"]}')
|
|
|
|
|
2023-01-07 17:34:55 +00:00
|
|
|
domain = request.values['domain']
|
2023-11-20 04:27:48 +00:00
|
|
|
user = cls.get_by_id(domain)
|
|
|
|
if not user:
|
2023-05-26 23:07:36 +00:00
|
|
|
error(f'No web user found for domain {domain}')
|
2023-01-07 17:34:55 +00:00
|
|
|
|
2023-01-09 07:08:31 +00:00
|
|
|
addr = request.values['address']
|
2023-06-06 18:29:36 +00:00
|
|
|
resp = webfinger.fetch(addr)
|
|
|
|
if resp is None:
|
2023-11-20 04:27:48 +00:00
|
|
|
return redirect(user.user_page_path())
|
2023-01-07 17:34:55 +00:00
|
|
|
|
2023-06-06 18:29:36 +00:00
|
|
|
for link in resp.get('links', []):
|
|
|
|
if link.get('rel') == webfinger.SUBSCRIBE_LINK_REL:
|
2022-11-27 15:03:10 +00:00
|
|
|
template = link.get('template')
|
|
|
|
if template and '{uri}' in template:
|
2023-11-30 05:06:55 +00:00
|
|
|
return redirect(template.replace('{uri}', user.handle_as(ActivityPub)))
|
2022-11-27 15:03:10 +00:00
|
|
|
|
|
|
|
flash(f"Couldn't find remote follow link for {addr}")
|
2023-11-20 04:27:48 +00:00
|
|
|
return redirect(user.user_page_path())
|
2023-01-07 17:34:55 +00:00
|
|
|
|
|
|
|
|
|
|
|
class FollowStart(indieauth.Start):
|
|
|
|
"""Starts the IndieAuth flow to add a follower to an existing user."""
|
|
|
|
def dispatch_request(self):
|
2023-06-08 06:51:41 +00:00
|
|
|
logger.info(f'Got: {request.values}')
|
|
|
|
|
2023-01-07 17:34:55 +00:00
|
|
|
address = request.form['address']
|
2023-02-18 00:12:25 +00:00
|
|
|
me = request.form['me']
|
|
|
|
|
|
|
|
session_me = session.get('indieauthed-me')
|
|
|
|
if session_me:
|
|
|
|
logger.info(f'found indieauthed-me: {session_me} in session cookie')
|
|
|
|
if session_me == me:
|
|
|
|
logger.info(' skipping IndieAuth')
|
|
|
|
return FollowCallback('-').finish(indieauth.IndieAuth(id=me), address)
|
2023-01-07 17:34:55 +00:00
|
|
|
|
|
|
|
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}")
|
2023-02-18 00:12:25 +00:00
|
|
|
domain = util.domain_from_link(me)
|
2023-05-30 21:08:13 +00:00
|
|
|
return redirect(f'/web/{domain}/following?address={address}')
|
2023-01-07 17:34:55 +00:00
|
|
|
raise
|
|
|
|
|
|
|
|
|
|
|
|
class FollowCallback(indieauth.Callback):
|
2023-02-18 00:12:25 +00:00
|
|
|
"""IndieAuth callback to add a follower to an existing user."""
|
2023-01-07 17:34:55 +00:00
|
|
|
def finish(self, auth_entity, state=None):
|
|
|
|
if not auth_entity:
|
|
|
|
return
|
|
|
|
|
2023-02-18 00:12:25 +00:00
|
|
|
me = auth_entity.key.id()
|
2023-04-03 14:53:15 +00:00
|
|
|
logger.info(f'Storing indieauthed-me: {me} in session cookie')
|
2023-02-18 00:12:25 +00:00
|
|
|
session['indieauthed-me'] = me
|
|
|
|
|
|
|
|
domain = util.domain_from_link(me)
|
2023-05-30 23:53:08 +00:00
|
|
|
# Web is hard-coded here since this is IndieAuth
|
2023-11-20 04:27:48 +00:00
|
|
|
user = Web.get_by_id(domain)
|
|
|
|
if not user:
|
2023-05-26 23:07:36 +00:00
|
|
|
error(f'No web user for domain {domain}')
|
2023-01-07 17:34:55 +00:00
|
|
|
|
2023-01-08 23:43:32 +00:00
|
|
|
if not state:
|
|
|
|
error('Missing state')
|
2023-01-07 17:34:55 +00:00
|
|
|
|
2024-01-15 15:56:59 +00:00
|
|
|
addr = state
|
|
|
|
if util.is_web(addr):
|
|
|
|
as2_url = addr
|
|
|
|
else: # it's an @-@ handle
|
2024-01-25 03:20:54 +00:00
|
|
|
# if ActivityPub.owns_handle(addr) is False:
|
|
|
|
# flash(f"{addr} isn't a native fediverse account")
|
|
|
|
# return redirect(user.user_page_path('following'))
|
2024-01-15 15:56:59 +00:00
|
|
|
as2_url = webfinger.fetch_actor_url(addr)
|
2024-01-25 03:20:54 +00:00
|
|
|
|
|
|
|
if util.domain_or_parent_in(util.domain_from_link(as2_url), common.DOMAINS):
|
|
|
|
proto = Protocol.for_id(as2_url)
|
|
|
|
flash(f"{addr} is a bridged account. Try following them on {proto.PHRASE}!")
|
|
|
|
return redirect(user.user_page_path('following'))
|
|
|
|
elif ActivityPub.owns_id(as2_url) is False:
|
|
|
|
flash(f"{addr} isn't a native fediverse account")
|
|
|
|
return redirect(user.user_page_path('following'))
|
2023-01-07 17:34:55 +00:00
|
|
|
|
2023-06-13 02:01:50 +00:00
|
|
|
# TODO(#512): follower will always be Web here, but we should generalize
|
|
|
|
# followee support in UI and here across protocols
|
2023-06-06 21:50:20 +00:00
|
|
|
followee = ActivityPub.load(as2_url)
|
2023-07-14 19:45:47 +00:00
|
|
|
if not followee:
|
|
|
|
flash(f"Couldn't load {as2_url} as AS2")
|
2023-11-20 04:27:48 +00:00
|
|
|
return redirect(user.user_page_path('following'))
|
2023-07-14 19:45:47 +00:00
|
|
|
|
2023-06-06 21:50:20 +00:00
|
|
|
followee_id = followee.as1.get('id')
|
2023-12-06 18:26:12 +00:00
|
|
|
timestamp = util.now().replace(microsecond=0, tzinfo=None).isoformat()
|
2024-04-22 18:58:01 +00:00
|
|
|
follow_id = f'{user.web_url()}#follow-{timestamp}-{addr}'
|
2023-12-06 18:11:36 +00:00
|
|
|
follow_as1 = {
|
|
|
|
'objectType': 'activity',
|
|
|
|
'verb': 'follow',
|
2023-01-09 06:39:37 +00:00
|
|
|
'id': follow_id,
|
2023-12-06 18:11:36 +00:00
|
|
|
'actor': user.key.id(),
|
2023-10-25 20:23:11 +00:00
|
|
|
'object': followee_id,
|
2023-03-20 18:23:49 +00:00
|
|
|
}
|
2023-06-16 04:22:20 +00:00
|
|
|
followee_user = ActivityPub.get_or_create(followee_id, obj=followee)
|
2023-12-06 18:11:36 +00:00
|
|
|
follow_obj = Object(id=follow_id, our_as1=follow_as1, source_protocol='ui',
|
|
|
|
labels=['user'])
|
2023-01-07 17:34:55 +00:00
|
|
|
|
2024-02-27 06:52:52 +00:00
|
|
|
resp = Web.receive(follow_obj, authed_as=domain, internal=True)
|
2023-12-06 18:11:36 +00:00
|
|
|
logger.info(f'Web.receive returned {resp}')
|
|
|
|
|
|
|
|
follow_obj = follow_obj.key.get()
|
|
|
|
follow_obj.source_protocol = 'ui'
|
2023-06-06 21:50:20 +00:00
|
|
|
follow_obj.put()
|
2023-01-08 02:00:19 +00:00
|
|
|
|
2023-08-26 14:49:27 +00:00
|
|
|
url = as1.get_url(followee.as1) or followee_id
|
2023-07-15 00:45:49 +00:00
|
|
|
link = common.pretty_link(url, text=addr)
|
2023-01-08 02:00:19 +00:00
|
|
|
flash(f'Followed {link}.')
|
2023-11-20 04:27:48 +00:00
|
|
|
return redirect(user.user_page_path('following'))
|
2023-01-07 17:34:55 +00:00
|
|
|
|
|
|
|
|
2023-01-10 03:01:48 +00:00
|
|
|
class UnfollowStart(indieauth.Start):
|
2023-02-06 04:59:39 +00:00
|
|
|
"""Starts the IndieAuth flow to remove a follower from an existing user."""
|
2023-01-10 03:01:48 +00:00
|
|
|
def dispatch_request(self):
|
2023-06-08 06:51:41 +00:00
|
|
|
logger.info(f'Got: {request.values}')
|
2023-01-10 03:01:48 +00:00
|
|
|
key = request.form['key']
|
2023-02-18 00:12:25 +00:00
|
|
|
me = request.form['me']
|
|
|
|
|
|
|
|
session_me = session.get('indieauthed-me')
|
|
|
|
if session_me:
|
|
|
|
logger.info(f'has IndieAuth session for {session_me}')
|
|
|
|
if session_me == me:
|
|
|
|
return UnfollowCallback('-').finish(indieauth.IndieAuth(id=me), key)
|
2023-01-10 03:01:48 +00:00
|
|
|
|
|
|
|
try:
|
|
|
|
return redirect(self.redirect_url(state=key))
|
|
|
|
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}")
|
2024-01-19 19:33:41 +00:00
|
|
|
domain = util.domain_from_link(me)
|
|
|
|
return redirect(f'/web/{domain}/following')
|
2023-01-10 03:01:48 +00:00
|
|
|
raise
|
|
|
|
|
|
|
|
|
|
|
|
class UnfollowCallback(indieauth.Callback):
|
2023-02-01 05:00:07 +00:00
|
|
|
"""IndieAuth callback to remove a follower."""
|
2023-01-10 03:01:48 +00:00
|
|
|
def finish(self, auth_entity, state=None):
|
|
|
|
if not auth_entity:
|
|
|
|
return
|
|
|
|
|
2023-02-18 00:12:25 +00:00
|
|
|
me = auth_entity.key.id()
|
|
|
|
# store login in a session cookie
|
|
|
|
session['indieauthed-me'] = me
|
|
|
|
|
|
|
|
domain = util.domain_from_link(me)
|
2023-05-30 23:53:08 +00:00
|
|
|
# Web is hard-coded here since this is IndieAuth
|
2023-11-20 04:27:48 +00:00
|
|
|
user = Web.get_by_id(domain)
|
|
|
|
if not user:
|
2023-05-26 23:07:36 +00:00
|
|
|
error(f'No web user for domain {domain}')
|
2023-01-10 03:01:48 +00:00
|
|
|
|
2023-06-08 06:51:41 +00:00
|
|
|
if util.is_int(state):
|
|
|
|
state = int(state)
|
2023-05-30 23:53:08 +00:00
|
|
|
follower = Follower.get_by_id(state)
|
2023-01-10 03:01:48 +00:00
|
|
|
if not follower:
|
|
|
|
error(f'Bad state {state}')
|
|
|
|
|
2023-06-08 06:51:41 +00:00
|
|
|
followee_id = follower.to.id()
|
|
|
|
followee = follower.to.get()
|
2023-02-05 05:23:04 +00:00
|
|
|
|
2023-06-16 04:22:20 +00:00
|
|
|
if not followee.obj or not followee.obj.as1:
|
|
|
|
# fetch to get full followee so we can find its target to deliver to
|
|
|
|
followee.obj = ActivityPub.load(followee_id)
|
2023-07-14 19:45:47 +00:00
|
|
|
if not followee.obj:
|
|
|
|
error("Couldn't load {followee_id} as AS2")
|
2023-06-08 06:51:41 +00:00
|
|
|
followee.put()
|
2023-02-05 05:23:04 +00:00
|
|
|
|
2023-06-16 04:22:20 +00:00
|
|
|
# TODO(#529): generalize
|
2023-12-06 18:26:12 +00:00
|
|
|
timestamp = util.now().replace(microsecond=0, tzinfo=None).isoformat()
|
2024-04-22 18:58:01 +00:00
|
|
|
unfollow_id = f'{user.web_url()}#unfollow-{timestamp}-{followee_id}'
|
2023-12-06 18:11:36 +00:00
|
|
|
unfollow_as1 = {
|
|
|
|
'objectType': 'activity',
|
|
|
|
'verb': 'stop-following',
|
2023-01-10 03:01:48 +00:00
|
|
|
'id': unfollow_id,
|
2023-12-06 18:11:36 +00:00
|
|
|
'actor': user.key.id(),
|
|
|
|
'object': followee.key.id(),
|
2023-03-20 18:23:49 +00:00
|
|
|
}
|
|
|
|
|
2023-06-09 19:56:45 +00:00
|
|
|
# don't include the followee User who's being unfollowed in the users
|
|
|
|
# property, since we don't want to notify or show them. (standard social
|
|
|
|
# network etiquette.)
|
2023-12-06 18:11:36 +00:00
|
|
|
follow_obj = Object(id=unfollow_id, users=[user.key], labels=['user'],
|
|
|
|
source_protocol='ui', our_as1=unfollow_as1)
|
2024-02-27 06:52:52 +00:00
|
|
|
resp = Web.receive(follow_obj, authed_as=domain, internal=True)
|
2023-01-10 03:01:48 +00:00
|
|
|
|
|
|
|
follower.status = 'inactive'
|
|
|
|
follower.put()
|
2023-12-06 18:11:36 +00:00
|
|
|
|
|
|
|
follow_obj = follow_obj.key.get()
|
|
|
|
follow_obj.source_protocol = 'ui'
|
|
|
|
follow_obj.put()
|
2023-01-10 03:01:48 +00:00
|
|
|
|
2023-08-26 14:49:27 +00:00
|
|
|
link = common.pretty_link(as1.get_url(followee.obj.as1) or followee_id)
|
2023-01-10 03:01:48 +00:00
|
|
|
flash(f'Unfollowed {link}.')
|
2023-11-20 04:27:48 +00:00
|
|
|
return redirect(user.user_page_path('following'))
|
2023-01-10 03:01:48 +00:00
|
|
|
|
|
|
|
|
2023-01-07 17:34:55 +00:00
|
|
|
app.add_url_rule('/follow/start',
|
|
|
|
view_func=FollowStart.as_view('follow_start', '/follow/callback'),
|
|
|
|
methods=['POST'])
|
|
|
|
app.add_url_rule('/follow/callback',
|
2023-01-08 02:00:19 +00:00
|
|
|
view_func=FollowCallback.as_view('follow_callback', 'unused'),
|
|
|
|
methods=['GET'])
|
2023-01-10 03:01:48 +00:00
|
|
|
app.add_url_rule('/unfollow/start',
|
|
|
|
view_func=UnfollowStart.as_view('unfollow_start', '/unfollow/callback'),
|
|
|
|
methods=['POST'])
|
|
|
|
app.add_url_rule('/unfollow/callback',
|
|
|
|
view_func=UnfollowCallback.as_view('unfollow_callback', 'unused'),
|
|
|
|
methods=['GET'])
|