diff --git a/follow.py b/follow.py index 08cd38f..34ace4e 100644 --- a/follow.py +++ b/follow.py @@ -89,7 +89,7 @@ def remote_follow(): addr = request.values['address'] webfinger = fetch_webfinger(addr) if webfinger is None: - return redirect(f'/user/{domain}') + return redirect(g.user.user_page_path()) for link in webfinger.get('links', []): if link.get('rel') == SUBSCRIBE_LINK_REL: @@ -98,7 +98,7 @@ def remote_follow(): return redirect(template.replace('{uri}', g.user.address())) flash(f"Couldn't find remote follow link for {addr}") - return redirect(f'/user/{domain}') + return redirect(g.user.user_page_path()) class FollowStart(indieauth.Start): @@ -120,7 +120,7 @@ class FollowStart(indieauth.Start): if util.is_connection_failure(e) or util.interpret_http_exception(e)[0]: flash(f"Couldn't fetch your web site: {e}") domain = util.domain_from_link(me) - return redirect(f'/user/{domain}/following?address={address}') + return redirect(f'/web/{domain}/following?address={address}') raise @@ -149,7 +149,7 @@ class FollowCallback(indieauth.Callback): else: webfinger = fetch_webfinger(addr) if webfinger is None: - return redirect(f'/user/{domain}/following') + return redirect(g.user.user_page_path('following')) as2_url = None for link in webfinger.get('links', []): @@ -159,7 +159,7 @@ class FollowCallback(indieauth.Callback): if not as2_url: flash(f"Couldn't find ActivityPub profile link for {addr}") - return redirect(f'/user/{domain}/following') + return redirect(g.user.user_page_path('following')) # TODO: make this generic across protocols followee = ActivityPub.load(as2_url).as2 @@ -167,10 +167,10 @@ class FollowCallback(indieauth.Callback): inbox = followee.get('inbox') if not id or not inbox: flash(f"AS2 profile {as2_url} missing id or inbox") - return redirect(f'/user/{domain}/following') + return redirect(g.user.user_page_path('following')) timestamp = NOW.replace(microsecond=0, tzinfo=None).isoformat() - follow_id = common.host_url(f'/user/{domain}/following#{timestamp}-{addr}') + follow_id = common.host_url(g.user.user_page_path(f'following#{timestamp}-{addr}')) follow_as2 = { '@context': 'https://www.w3.org/ns/activitystreams', 'type': 'Follow', @@ -189,7 +189,7 @@ class FollowCallback(indieauth.Callback): link = common.pretty_link(util.get_url(followee) or id, text=addr) flash(f'Followed {link}.') - return redirect(f'/user/{domain}/following') + return redirect(g.user.user_page_path('following')) class UnfollowStart(indieauth.Start): @@ -209,7 +209,7 @@ class UnfollowStart(indieauth.Start): 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') + return redirect(g.user.user_page_path('following')) raise @@ -246,10 +246,10 @@ class UnfollowCallback(indieauth.Callback): inbox = followee.get('inbox') if not inbox: flash(f"AS2 profile {followee_id} missing inbox") - return redirect(f'/user/{domain}/following') + return redirect(g.user.user_page_path('following')) timestamp = NOW.replace(microsecond=0, tzinfo=None).isoformat() - unfollow_id = common.host_url(f'/user/{domain}/following#undo-{timestamp}-{followee_id}') + unfollow_id = common.host_url(g.user.user_page_path(f'following#undo-{timestamp}-{followee_id}')) unfollow_as2 = { '@context': 'https://www.w3.org/ns/activitystreams', 'type': 'Undo', @@ -268,7 +268,7 @@ class UnfollowCallback(indieauth.Callback): link = common.pretty_link(util.get_url(followee) or followee_id) flash(f'Unfollowed {link}.') - return redirect(f'/user/{domain}/following') + return redirect(g.user.user_page_path('following')) app.add_url_rule('/follow/start', diff --git a/models.py b/models.py index 9a87395..f3ba02c 100644 --- a/models.py +++ b/models.py @@ -230,6 +230,13 @@ class User(StringIdModel, metaclass=ProtocolUserMeta): and not parsed.path and not parsed.query and not parsed.params and not parsed.fragment) + def user_page_path(self, rest=None): + """Returns the user's Bridgy Fed user page path.""" + path = f'/{self.LABEL}/{self.key.id()}' + if rest: + path += f'/{rest}' + return path + def user_page_link(self): """Returns a pretty user page link with the user's name and profile picture.""" domain = self.key.id() @@ -239,7 +246,7 @@ class User(StringIdModel, metaclass=ProtocolUserMeta): util.domain_from_link(self.username())) img = util.get_url(actor, 'icon') or '' - return f' {name}' + return f' {name}' class Target(ndb.Model): diff --git a/pages.py b/pages.py index 9b3b033..88dccbd 100644 --- a/pages.py +++ b/pages.py @@ -17,7 +17,7 @@ from oauth_dropins.webutil.util import json_dumps, json_loads from flask_app import app, cache import common from common import DOMAIN_RE -from models import fetch_page, Follower, Object, PAGE_SIZE, User +from models import fetch_page, Follower, Object, PAGE_SIZE, PROTOCOLS, User from web import Web FOLLOWERS_UI_LIMIT = 999 @@ -70,16 +70,24 @@ def check_web_site(): raise g.user.put() - return redirect(f'/user/{g.user.key.id()}') + return redirect(f'/web/{g.user.key.id()}') @app.get(f'/user/') -def user(domain): +@app.get(f'/user//feed') +@app.get(f'/user//') +def web_user_redirects(**kwargs): + path = request.url.removeprefix(request.root_url).removeprefix('user/') + return redirect(f'/web/{path}', code=301) + + +@app.get(f'//') +def user(protocol, domain): g.user = Web.get_by_id(domain) if not g.user or not g.user.direct: return USER_NOT_FOUND_HTML, 404 elif g.user.key.id() != domain: - return redirect(f'/user/{g.user.key.id()}', code=301) + return redirect(f'/{protocol}/{g.user.key.id()}', code=301) assert not g.user.use_instead @@ -108,7 +116,7 @@ def user(domain): ) -@app.get(f'/user//') +@app.get(f'/web//') def followers_or_following(domain, collection): g.user = Web.get_by_id(domain) # g.user is used in template if not g.user: @@ -133,7 +141,7 @@ def followers_or_following(domain, collection): ) -@app.get(f'/user//feed') +@app.get(f'/web//feed') def feed(domain): format = request.args.get('format', 'html') if format not in ('html', 'atom', 'rss'): diff --git a/tests/test_follow.py b/tests/test_follow.py index 1bf5955..c1b0c1a 100644 --- a/tests/test_follow.py +++ b/tests/test_follow.py @@ -41,17 +41,17 @@ FOLLOWEE = { FOLLOW_ADDRESS = { '@context': 'https://www.w3.org/ns/activitystreams', 'type': 'Follow', - 'id': f'http://localhost/user/alice.com/following#2022-01-02T03:04:05-@foo@bar', + 'id': f'http://localhost/web/alice.com/following#2022-01-02T03:04:05-@foo@bar', 'actor': 'http://localhost/alice.com', 'object': FOLLOWEE, 'to': [as2.PUBLIC_AUDIENCE], } FOLLOW_URL = copy.deepcopy(FOLLOW_ADDRESS) -FOLLOW_URL['id'] = f'http://localhost/user/alice.com/following#2022-01-02T03:04:05-https://bar/actor' +FOLLOW_URL['id'] = f'http://localhost/web/alice.com/following#2022-01-02T03:04:05-https://bar/actor' UNDO_FOLLOW = { '@context': 'https://www.w3.org/ns/activitystreams', 'type': 'Undo', - 'id': f'http://localhost/user/alice.com/following#undo-2022-01-02T03:04:05-https://bar/id', + 'id': f'http://localhost/web/alice.com/following#undo-2022-01-02T03:04:05-https://bar/id', 'actor': 'http://localhost/alice.com', 'object': FOLLOW_ADDRESS, } @@ -105,21 +105,21 @@ class RemoteFollowTest(testutil.TestCase): 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']) + self.assertEqual('/web/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('/remote-follow?address=https://bar/foo&domain=me') self.assertEqual(302, got.status_code) - self.assertEqual('/user/me', got.headers['Location']) + self.assertEqual('/web/me', got.headers['Location']) def test_follow_no_webfinger_subscribe_link(self, mock_get): mock_get.return_value = requests_response('not json') 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']) + self.assertEqual('/web/me', got.headers['Location']) @patch('requests.post') @@ -182,7 +182,7 @@ class FollowTest(testutil.TestCase): def check(self, input, resp, expected_follow, mock_get, mock_post): self.assertEqual(302, resp.status_code) - self.assertEqual('/user/alice.com/following',resp.headers['Location']) + self.assertEqual('/web/alice.com/following',resp.headers['Location']) self.assertEqual([f'Followed {input}.'], get_flashed_messages()) @@ -205,7 +205,7 @@ class FollowTest(testutil.TestCase): followers, ignore=['created', 'updated']) - id = f'http://localhost/user/alice.com/following#2022-01-02T03:04:05-{input}' + id = f'http://localhost/web/alice.com/following#2022-01-02T03:04:05-{input}' self.assert_object(id, domains=['alice.com'], status='complete', labels=['user', 'activity'], source_protocol='ui', as2=expected_follow, as1=as2.to_as1(expected_follow)) @@ -237,9 +237,9 @@ class FollowTest(testutil.TestCase): state = util.encode_oauth_state(self.state) resp = self.client.get(f'/follow/callback?code=my_code&state={state}') self.assertEqual(302, resp.status_code) - self.assertEqual('/user/www.alice.com/following', resp.headers['Location']) + self.assertEqual('/web/www.alice.com/following', resp.headers['Location']) - id = 'http://localhost/user/www.alice.com/following#2022-01-02T03:04:05-https://bar/actor' + id = 'http://localhost/web/www.alice.com/following#2022-01-02T03:04:05-https://bar/actor' expected_follow = { '@context': 'https://www.w3.org/ns/activitystreams', 'type': 'Follow', @@ -360,7 +360,7 @@ class UnfollowTest(testutil.TestCase): def check(self, resp, expected_undo, mock_get, mock_post): self.assertEqual(302, resp.status_code) - self.assertEqual('/user/alice.com/following', resp.headers['Location']) + self.assertEqual('/web/alice.com/following', resp.headers['Location']) self.assertEqual([f'Unfollowed bar/url.'], get_flashed_messages()) @@ -377,7 +377,7 @@ class UnfollowTest(testutil.TestCase): self.assertEqual('inactive', follower.status) self.assert_object( - 'http://localhost/user/alice.com/following#undo-2022-01-02T03:04:05-https://bar/id', + 'http://localhost/web/alice.com/following#undo-2022-01-02T03:04:05-https://bar/id', domains=['alice.com'], status='complete', source_protocol='ui', labels=['user', 'activity'], as2=expected_undo, @@ -411,9 +411,9 @@ class UnfollowTest(testutil.TestCase): }) resp = self.client.get(f'/unfollow/callback?code=my_code&state={state}') self.assertEqual(302, resp.status_code) - self.assertEqual('/user/www.alice.com/following', resp.headers['Location']) + self.assertEqual('/web/www.alice.com/following', resp.headers['Location']) - id = 'http://localhost/user/www.alice.com/following#undo-2022-01-02T03:04:05-https://bar/id' + id = 'http://localhost/web/www.alice.com/following#undo-2022-01-02T03:04:05-https://bar/id' expected_undo = { '@context': 'https://www.w3.org/ns/activitystreams', 'type': 'Undo', diff --git a/tests/test_models.py b/tests/test_models.py index 0fdab56..3eca549 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -75,6 +75,16 @@ class UserTest(TestCase): self.assertTrue(pem.decode().startswith('-----BEGIN RSA PRIVATE KEY-----\n'), pem) self.assertTrue(pem.decode().endswith('-----END RSA PRIVATE KEY-----'), pem) + def test_user_page_path(self): + self.assertEqual('/web/y.z', g.user.user_page_path()) + self.assertEqual('/web/y.z/followers', g.user.user_page_path('followers')) + self.assertEqual('/fake/foo', self.make_user('foo', cls=Fake).user_page_path()) + + def test_user_page_link(self): + self.assertEqual(' y.z', g.user.user_page_link()) + g.user.actor_as2 = ACTOR + self.assertEqual(' Mrs. ☕ Foo', g.user.user_page_link()) + def test_address(self): self.assertEqual('@y.z@y.z', g.user.address()) @@ -156,7 +166,7 @@ class ObjectTest(TestCase): g.user = Fake(id='user.com', actor_as2={"name": "Alice"}) obj = Object(id='x', source_protocol='ui', domains=['user.com']) self.assertIn( - 'href="/user/user.com"> Alice', + 'href="/fake/user.com"> Alice', obj.actor_link()) def test_put_updates_load_cache(self): diff --git a/tests/test_pages.py b/tests/test_pages.py index c58d611..fd156b5 100644 --- a/tests/test_pages.py +++ b/tests/test_pages.py @@ -36,39 +36,44 @@ class PagesTest(TestCase): self.user = self.make_user('user.com') def test_user(self): - got = self.client.get('/user/user.com') + got = self.client.get('/web/user.com') self.assert_equals(200, got.status_code) def test_user_objects(self): self.add_objects() - got = self.client.get('/user/user.com') + got = self.client.get('/web/user.com') self.assert_equals(200, got.status_code) def test_user_not_found(self): - got = self.client.get('/user/bar.com') + got = self.client.get('/web/bar.com') self.assert_equals(404, got.status_code) def test_user_not_direct(self): self.user.direct = False self.user.put() - got = self.client.get('/user/user.com') + got = self.client.get('/web/user.com') self.assert_equals(404, got.status_code) + def test_user_redirect(self): + got = self.client.get('/user/user.com') + self.assert_equals(301, got.status_code) + self.assert_equals('/web/user.com', got.headers['Location']) + def test_user_use_instead(self): bar = self.make_user('bar.com') bar.use_instead = self.user.key bar.put() - got = self.client.get('/user/bar.com') + got = self.client.get('/web/bar.com') self.assert_equals(301, got.status_code) - self.assert_equals('/user/user.com', got.headers['Location']) + self.assert_equals('/web/user.com', got.headers['Location']) def test_user_object_bare_string_id(self): with self.request_context: Object(id='a', domains=['user.com'], labels=['notification'], as2=REPOST_AS2).put() - got = self.client.get('/user/user.com') + got = self.client.get('/web/user.com') self.assert_equals(200, got.status_code) def test_user_object_url_object(self): @@ -81,27 +86,27 @@ class PagesTest(TestCase): }, }).put() - got = self.client.get('/user/user.com') + got = self.client.get('/web/user.com') self.assert_equals(200, got.status_code) def test_user_before(self): self.add_objects() - got = self.client.get(f'/user/user.com?before={util.now().isoformat()}') + got = self.client.get(f'/web/user.com?before={util.now().isoformat()}') self.assert_equals(200, got.status_code) def test_user_after(self): self.add_objects() - got = self.client.get(f'/user/user.com?after={util.now().isoformat()}') + got = self.client.get(f'/web/user.com?after={util.now().isoformat()}') self.assert_equals(200, got.status_code) def test_user_before_bad(self): self.add_objects() - got = self.client.get('/user/user.com?before=nope') + got = self.client.get('/web/user.com?before=nope') self.assert_equals(400, got.status_code) def test_user_before_and_after(self): self.add_objects() - got = self.client.get('/user/user.com?before=2024-01-01+01:01:01&after=2023-01-01+01:01:01') + got = self.client.get('/web/user.com?before=2024-01-01+01:01:01&after=2023-01-01+01:01:01') self.assert_equals(400, got.status_code) @patch('requests.get') @@ -115,12 +120,12 @@ class PagesTest(TestCase): got = self.client.post('/web-site', data={'url': 'https://user.com/'}) self.assert_equals(302, got.status_code) - self.assert_equals('/user/user.com', got.headers['Location']) + self.assert_equals('/web/user.com', got.headers['Location']) user = Web.get_by_id('user.com') self.assertTrue(user.has_hcard) self.assertEqual('Person', user.actor_as2['type']) - self.assertEqual('http://localhost/user.com', user.actor_as2['id']) + self.assertEqual('http://localhost/web/user.com', user.actor_as2['id']) def test_check_web_site_bad_url(self): got = self.client.post('/web-site', data={'url': '!!!'}) @@ -146,7 +151,7 @@ class PagesTest(TestCase): Follower.get_or_create('bar.com', 'https://no/stored/follow') Follower.get_or_create('bar.com', 'https://masto/user', last_follow=FOLLOW_WITH_ACTOR) - got = self.client.get('/user/bar.com/followers') + got = self.client.get('/web/bar.com/followers') self.assert_equals(200, got.status_code) body = got.get_data(as_text=True) @@ -155,20 +160,25 @@ class PagesTest(TestCase): def test_followers_empty(self): self.make_user('bar.com') - got = self.client.get('/user/bar.com/followers') + got = self.client.get('/web/bar.com/followers') self.assert_equals(200, got.status_code) self.assertNotIn('class="follower', got.get_data(as_text=True)) def test_followers_user_not_found(self): - got = self.client.get('/user/bar.com/followers') + got = self.client.get('/web/bar.com/followers') self.assert_equals(404, got.status_code) + def test_followers_redirect(self): + got = self.client.get('/user/user.com/followers') + self.assert_equals(301, got.status_code) + self.assert_equals('/web/user.com/followers', got.headers['Location']) + def test_following(self): Follower.get_or_create('https://no/stored/follow', 'bar.com') Follower.get_or_create('https://masto/user', 'bar.com', last_follow=FOLLOW_WITH_OBJECT) self.make_user('bar.com') - got = self.client.get('/user/bar.com/following') + got = self.client.get('/web/bar.com/following') self.assert_equals(200, got.status_code) body = got.get_data(as_text=True) @@ -177,59 +187,69 @@ class PagesTest(TestCase): def test_following_empty(self): self.make_user('bar.com') - got = self.client.get('/user/bar.com/following') + got = self.client.get('/web/bar.com/following') self.assert_equals(200, got.status_code) self.assertNotIn('class="follower', got.get_data(as_text=True)) def test_following_user_not_found(self): - got = self.client.get('/user/bar.com/following') + got = self.client.get('/web/bar.com/following') self.assert_equals(404, got.status_code) + def test_following_redirect(self): + got = self.client.get('/user/user.com/following') + self.assert_equals(301, got.status_code) + self.assert_equals('/web/user.com/following', got.headers['Location']) + def test_following_before_empty(self): self.make_user('bar.com') - got = self.client.get(f'/user/bar.com/following?before={util.now().isoformat()}') + got = self.client.get(f'/web/bar.com/following?before={util.now().isoformat()}') self.assert_equals(200, got.status_code) def test_following_after_empty(self): self.make_user('bar.com') - got = self.client.get(f'/user/bar.com/following?after={util.now().isoformat()}') + got = self.client.get(f'/web/bar.com/following?after={util.now().isoformat()}') self.assert_equals(200, got.status_code) def test_feed_user_not_found(self): - got = self.client.get('/user/bar.com/feed') + got = self.client.get('/web/bar.com/feed') self.assert_equals(404, got.status_code) - def test_feed_html_empty(self): + def test_feed_redirect(self): got = self.client.get('/user/user.com/feed') + self.assert_equals(301, got.status_code) + self.assert_equals('/web/user.com/feed', got.headers['Location']) + + def test_feed_html_empty(self): + got = self.client.get('/web/user.com/feed') self.assert_equals(200, got.status_code) self.assert_equals([], microformats2.html_to_activities(got.text)) def test_feed_html(self): self.add_objects() - got = self.client.get('/user/user.com/feed') + got = self.client.get('/web/user.com/feed') self.assert_equals(200, got.status_code) self.assert_equals(self.EXPECTED, contents(microformats2.html_to_activities(got.text))) def test_feed_atom_empty(self): - got = self.client.get('/user/user.com/feed?format=atom') + got = self.client.get('/web/user.com/feed?format=atom') self.assert_equals(200, got.status_code) self.assert_equals([], atom.atom_to_activities(got.text)) def test_feed_atom(self): self.add_objects() - got = self.client.get('/user/user.com/feed?format=atom') + got = self.client.get('/web/user.com/feed?format=atom') self.assert_equals(200, got.status_code) self.assert_equals(self.EXPECTED, contents(atom.atom_to_activities(got.text))) def test_feed_rss_empty(self): - got = self.client.get('/user/user.com/feed?format=rss') + got = self.client.get('/web/user.com/feed?format=rss') self.assert_equals(200, got.status_code) self.assert_equals([], rss.to_activities(got.text)) def test_feed_rss(self): self.add_objects() - got = self.client.get('/user/user.com/feed?format=rss') + got = self.client.get('/web/user.com/feed?format=rss') self.assert_equals(200, got.status_code) self.assert_equals(self.EXPECTED, contents(rss.to_activities(got.text)))