kopia lustrzana https://github.com/snarfed/bridgy-fed
				
				
				
			AP actor serving: resolve handles
							rodzic
							
								
									6ae57bfd94
								
							
						
					
					
						commit
						325f8b3931
					
				|  | @ -3,6 +3,7 @@ from base64 import b64encode | |||
| from hashlib import sha256 | ||||
| import itertools | ||||
| import logging | ||||
| import re | ||||
| from urllib.parse import quote_plus, urljoin | ||||
| 
 | ||||
| from flask import abort, g, request | ||||
|  | @ -22,11 +23,11 @@ from common import ( | |||
|     add, | ||||
|     CACHE_TIME, | ||||
|     CONTENT_TYPE_HTML, | ||||
|     DOMAIN_RE, | ||||
|     error, | ||||
|     host_url, | ||||
|     redirect_unwrap, | ||||
|     redirect_wrap, | ||||
|     TLD_BLOCKLIST, | ||||
| ) | ||||
| from models import Follower, Object, PROTOCOLS, User | ||||
| from protocol import Protocol | ||||
|  | @ -702,22 +703,30 @@ def postprocess_as2_actor(actor, wrap=True): | |||
|     return actor | ||||
| 
 | ||||
| 
 | ||||
| @app.get(f'/ap/<any({",".join(PROTOCOLS)}):protocol>/<regex("{common.DOMAIN_RE}"):domain>') | ||||
| @app.get(f'/ap/<any({",".join(PROTOCOLS)}):protocol>/<handle_or_id>') | ||||
| # special case Web users without /ap/web/ prefix, for backward compatibility | ||||
| @app.get(f'/<regex("{common.DOMAIN_RE}"):domain>', defaults={'protocol': 'web'}) | ||||
| @app.get(f'/<regex("{DOMAIN_RE}"):handle_or_id>', defaults={'protocol': 'web'}) | ||||
| @flask_util.cached(cache, CACHE_TIME) | ||||
| def actor(protocol, domain): | ||||
| def actor(protocol, handle_or_id): | ||||
|     """Serves a user's AS2 actor from the datastore.""" | ||||
|     tld = domain.split('.')[-1] | ||||
|     if tld in TLD_BLOCKLIST: | ||||
|         error('', status=404) | ||||
| 
 | ||||
|     cls = PROTOCOLS[protocol] | ||||
|     g.user = cls.get_or_create(domain) | ||||
|     if not g.user.obj or not g.user.obj.as1: | ||||
|         g.user.obj = cls.load(f'https://{domain}/', gateway=True) | ||||
| 
 | ||||
|     # TODO: unify with common.actor() | ||||
|     if cls.owns_id(handle_or_id) is False: | ||||
|         if cls.owns_handle(handle_or_id) is False: | ||||
|             error(f"{handle_or_id} doesn't look like a {cls.LABEL} id or handle", | ||||
|                   status=404) | ||||
|         id = cls.handle_to_id(handle_or_id) | ||||
|         if not id: | ||||
|             error(f"Couldn't resolve {handle_or_id} as a {cls.LABEL} handle", | ||||
|                   status=404) | ||||
|     else: | ||||
|         id = handle_or_id | ||||
| 
 | ||||
|     assert id | ||||
|     g.user = cls.get_or_create(id) | ||||
|     if not g.user.obj or not g.user.obj.as1: | ||||
|         g.user.obj = cls.load(g.user.profile_id(), gateway=True) | ||||
| 
 | ||||
|     actor = g.user.as2() or { | ||||
|         '@context': [as2.CONTEXT], | ||||
|         'type': 'Person', | ||||
|  | @ -725,14 +734,15 @@ def actor(protocol, domain): | |||
|     actor = postprocess_as2(actor) | ||||
|     actor.update({ | ||||
|         'id': g.user.ap_actor(), | ||||
|         # This has to be the domain for Mastodon etc interop! It seems like it | ||||
|         # should be the custom username from the acct: u-url in their h-card, | ||||
|         # but that breaks Mastodon's Webfinger discovery. Background: | ||||
|         # This has to be the id (domain for Web) for Mastodon etc interop! It | ||||
|         # seems like it should be the custom username from the acct: u-url in | ||||
|         # their h-card, but that breaks Mastodon's Webfinger discovery. | ||||
|         # Background: | ||||
|         # https://docs.joinmastodon.org/spec/activitypub/#properties-used-1 | ||||
|         # https://docs.joinmastodon.org/spec/webfinger/#mastodons-requirements-for-webfinger | ||||
|         # https://github.com/snarfed/bridgy-fed/issues/302#issuecomment-1324305460 | ||||
|         # https://github.com/snarfed/bridgy-fed/issues/77 | ||||
|         'preferredUsername': domain, | ||||
|         'preferredUsername': id, | ||||
|         'inbox': g.user.ap_actor('inbox'), | ||||
|         'outbox': g.user.ap_actor('outbox'), | ||||
|         'following': g.user.ap_actor('following'), | ||||
|  | @ -740,8 +750,8 @@ def actor(protocol, domain): | |||
|         'endpoints': { | ||||
|             'sharedInbox': host_url('/ap/sharedInbox'), | ||||
|         }, | ||||
|         # add this if we ever change the Web actor ids to be /web/[domain] | ||||
|         # 'alsoKnownAs': [host_url(domain)], | ||||
|         # add this if we ever change the Web actor ids to be /web/[id] | ||||
|         # 'alsoKnownAs': [host_url(id)], | ||||
|     }) | ||||
| 
 | ||||
|     logger.info(f'Returning: {json_dumps(actor, indent=2)}') | ||||
|  | @ -752,10 +762,10 @@ def actor(protocol, domain): | |||
| 
 | ||||
| 
 | ||||
| @app.post('/ap/sharedInbox') | ||||
| @app.post(f'/ap/<any({",".join(PROTOCOLS)}):protocol>/<regex("{common.DOMAIN_RE}"):domain>/inbox') | ||||
| @app.post(f'/ap/<any({",".join(PROTOCOLS)}):protocol>/<regex("{DOMAIN_RE}"):domain>/inbox') | ||||
| # special case Web users without /ap/web/ prefix, for backward compatibility | ||||
| @app.post('/inbox') | ||||
| @app.post(f'/<regex("{common.DOMAIN_RE}"):domain>/inbox', defaults={'protocol': 'web'}) | ||||
| @app.post(f'/<regex("{DOMAIN_RE}"):domain>/inbox', defaults={'protocol': 'web'}) | ||||
| def inbox(protocol=None, domain=None): | ||||
|     """Handles ActivityPub inbox delivery.""" | ||||
|     # parse and validate AS2 activity | ||||
|  | @ -823,9 +833,9 @@ def inbox(protocol=None, domain=None): | |||
|     return ActivityPub.receive(obj) | ||||
| 
 | ||||
| 
 | ||||
| @app.get(f'/ap/<any({",".join(PROTOCOLS)}):protocol>/<regex("{common.DOMAIN_RE}"):domain>/<any(followers,following):collection>') | ||||
| @app.get(f'/ap/<any({",".join(PROTOCOLS)}):protocol>/<regex("{DOMAIN_RE}"):domain>/<any(followers,following):collection>') | ||||
| # special case Web users without /ap/web/ prefix, for backward compatibility | ||||
| @app.get(f'/<regex("{common.DOMAIN_RE}"):domain>/<any(followers,following):collection>', | ||||
| @app.get(f'/<regex("{DOMAIN_RE}"):domain>/<any(followers,following):collection>', | ||||
|          defaults={'protocol': 'web'}) | ||||
| @flask_util.cached(cache, CACHE_TIME) | ||||
| def follower_collection(protocol, domain, collection): | ||||
|  | @ -878,9 +888,9 @@ def follower_collection(protocol, domain, collection): | |||
|     return collection, {'Content-Type': as2.CONTENT_TYPE} | ||||
| 
 | ||||
| 
 | ||||
| @app.get(f'/ap/<any({",".join(PROTOCOLS)}):protocol>/<regex("{common.DOMAIN_RE}"):domain>/outbox') | ||||
| @app.get(f'/ap/<any({",".join(PROTOCOLS)}):protocol>/<regex("{DOMAIN_RE}"):domain>/outbox') | ||||
| # special case Web users without /ap/web/ prefix, for backward compatibility | ||||
| @app.get(f'/<regex("{common.DOMAIN_RE}"):domain>/outbox', defaults={'protocol': 'web'}) | ||||
| @app.get(f'/<regex("{DOMAIN_RE}"):domain>/outbox', defaults={'protocol': 'web'}) | ||||
| def outbox(protocol, domain): | ||||
|     return { | ||||
|             '@context': 'https://www.w3.org/ns/activitystreams', | ||||
|  |  | |||
							
								
								
									
										5
									
								
								app.py
								
								
								
								
							
							
						
						
									
										5
									
								
								app.py
								
								
								
								
							|  | @ -6,7 +6,10 @@ registered. | |||
| from flask_app import app | ||||
| 
 | ||||
| # import all modules to register their Flask handlers | ||||
| import activitypub, atproto, convert, follow, pages, redirect, superfeedr, ui, webfinger, web | ||||
| import atproto, convert, follow, pages, redirect, superfeedr, ui, webfinger, web | ||||
| # import after others because it has URL routes that use PROTOCOLS | ||||
| # TODO: figure out a better way | ||||
| import activitypub | ||||
| 
 | ||||
| import models | ||||
| models.reset_protocol_properties() | ||||
|  |  | |||
|  | @ -30,7 +30,7 @@ from web import Web | |||
| 
 | ||||
| # have to import module, not attrs, to avoid circular import | ||||
| from . import test_web | ||||
| from .test_webfinger import WEBFINGER | ||||
| from . import test_webfinger | ||||
| 
 | ||||
| ACTOR = { | ||||
|     '@context': 'https://www.w3.org/ns/activitystreams', | ||||
|  | @ -73,6 +73,24 @@ ACTOR_BASE_FULL = { | |||
|         'value': '<a rel="me" href="https://user.com/"><span class="invisible">https://</span>user.com<span class="invisible">/</span></a>', | ||||
|     }], | ||||
| } | ||||
| ACTOR_FAKE = { | ||||
|     '@context': ['https://w3id.org/security/v1'], | ||||
|     'type': 'Person', | ||||
|     'id': 'http://bf/fake/fake:user/ap', | ||||
|     'preferredUsername': 'fake:user', | ||||
|     'url': 'http://localhost/r/fake:user', | ||||
|     'summary': '', | ||||
|     'inbox': 'http://bf/fake/fake:user/ap/inbox', | ||||
|     'outbox': 'http://bf/fake/fake:user/ap/outbox', | ||||
|     'following': 'http://bf/fake/fake:user/ap/following', | ||||
|     'followers': 'http://bf/fake/fake:user/ap/followers', | ||||
|     'endpoints': {'sharedInbox': 'http://localhost/ap/sharedInbox'}, | ||||
|     'publicKey': { | ||||
|         'id': 'http://localhost/fake#key', | ||||
|         'owner': 'http://localhost/fake', | ||||
|         'publicKeyPem': 'populated in setUp()', | ||||
|     }, | ||||
| } | ||||
| REPLY_OBJECT = { | ||||
|     '@context': 'https://www.w3.org/ns/activitystreams', | ||||
|     'type': 'Note', | ||||
|  | @ -280,7 +298,9 @@ class ActivityPubTest(TestCase): | |||
|         self.swentel_key = ndb.Key(ActivityPub, 'https://mas.to/users/swentel') | ||||
|         self.masto_actor_key = ndb.Key(ActivityPub, 'https://mas.to/actor') | ||||
| 
 | ||||
|         ACTOR_BASE['publicKey']['publicKeyPem'] = self.user.public_pem().decode() | ||||
|         ACTOR_BASE['publicKey']['publicKeyPem'] = \ | ||||
|             ACTOR_FAKE['publicKey']['publicKeyPem'] = \ | ||||
|                 self.user.public_pem().decode() | ||||
| 
 | ||||
|         self.key_id_obj = Object(id='http://my/key/id', as2={ | ||||
|             **ACTOR, | ||||
|  | @ -316,33 +336,16 @@ class ActivityPubTest(TestCase): | |||
|         return self.client.post(path, data=body, headers=self.sign(path, body)) | ||||
| 
 | ||||
|     def test_actor_fake(self, *_): | ||||
|         self.make_user('user.com', cls=Fake, obj_as2={ | ||||
|         self.make_user('fake:user', cls=Fake, obj_as2={ | ||||
|             'type': 'Person', | ||||
|             'id': 'https://user.com/', | ||||
|             'id': 'fake:user', | ||||
|         }) | ||||
| 
 | ||||
|         got = self.client.get('/ap/fake/user.com') | ||||
|         got = self.client.get('/ap/fake/fake:user') | ||||
|         self.assertEqual(200, got.status_code, got.get_data(as_text=True)) | ||||
|         type = got.headers['Content-Type'] | ||||
|         self.assertTrue(type.startswith(as2.CONTENT_TYPE), type) | ||||
|         self.assertEqual({ | ||||
|             '@context': ['https://w3id.org/security/v1'], | ||||
|             'type': 'Person', | ||||
|             'id': 'http://bf/fake/user.com/ap', | ||||
|             'preferredUsername': 'user.com', | ||||
|             'url': 'http://localhost/r/user.com', | ||||
|             'summary': '', | ||||
|             'inbox': 'http://bf/fake/user.com/ap/inbox', | ||||
|             'outbox': 'http://bf/fake/user.com/ap/outbox', | ||||
|             'following': 'http://bf/fake/user.com/ap/following', | ||||
|             'followers': 'http://bf/fake/user.com/ap/followers', | ||||
|             'endpoints': {'sharedInbox': 'http://localhost/ap/sharedInbox'}, | ||||
|             'publicKey': { | ||||
|                 'id': 'http://localhost/user.com#key', | ||||
|                 'owner': 'http://localhost/user.com', | ||||
|                 'publicKeyPem': self.user.public_pem().decode(), | ||||
|             }, | ||||
|         }, got.json) | ||||
|         self.assertEqual(ACTOR_FAKE, got.json) | ||||
| 
 | ||||
|     def test_actor_web(self, *_): | ||||
|         """Web users are special cased to drop the /web/ prefix.""" | ||||
|  | @ -383,11 +386,33 @@ class ActivityPubTest(TestCase): | |||
|         self.assertEqual(200, got.status_code) | ||||
|         self.assert_equals(ACTOR_BASE, got.json, ignore=['publicKeyPem']) | ||||
| 
 | ||||
|     def test_actor_new_user_fetch_fails(self, _, mock_get, __): | ||||
|     def test_actor_new_user_fetch_fails(self, _, mock_get, ___): | ||||
|         mock_get.side_effect = ReadTimeoutError(None, None, None) | ||||
|         got = self.client.get('/nope.com') | ||||
|         self.assertEqual(504, got.status_code) | ||||
| 
 | ||||
|     def test_actor_handle_existing_user(self, _, __, ___): | ||||
|         self.make_user('fake:user', cls=Fake, obj_as2=ACTOR) | ||||
|         got = self.client.get('/ap/fake/fake:handle:user') | ||||
|         self.assertEqual(200, got.status_code) | ||||
|         self.assert_equals({ | ||||
|             **ACTOR, | ||||
|             **ACTOR_FAKE, | ||||
|         }, got.json, ignore=['publicKeyPem']) | ||||
| 
 | ||||
|     def test_actor_handle_new_user(self, _, __, ___): | ||||
|         Fake.fetchable['fake:user'] = as2.to_as1(ACTOR) | ||||
|         got = self.client.get('/ap/fake/fake:handle:user') | ||||
|         self.assertEqual(200, got.status_code) | ||||
|         self.assert_equals({ | ||||
|             **ACTOR, | ||||
|             **ACTOR_FAKE, | ||||
|         }, got.json, ignore=['publicKeyPem']) | ||||
| 
 | ||||
|     def test_actor_handle_user_fetch_fails(self, _, __, ___): | ||||
|         got = self.client.get('/ap/fake/fake:handle:nope') | ||||
|         self.assertEqual(404, got.status_code) | ||||
| 
 | ||||
|     def test_individual_inbox_no_user(self, mock_head, mock_get, mock_post): | ||||
|         self.user.key.delete() | ||||
| 
 | ||||
|  | @ -1514,8 +1539,9 @@ class ActivityPubUtilsTest(TestCase): | |||
|         self.assertEqual('http://inst.com/@user', | ||||
|                          ActivityPub.handle_to_id('@user@inst.com')) | ||||
| 
 | ||||
|     @patch('requests.get', return_value=requests_response(WEBFINGER)) | ||||
|     @patch('requests.get') | ||||
|     def test_handle_to_id_fetch(self, mock_get): | ||||
|         mock_get.return_value = requests_response(test_webfinger.WEBFINGER) | ||||
|         self.assertEqual('http://localhost/user.com', | ||||
|                          ActivityPub.handle_to_id('@user@inst.com')) | ||||
|         self.assert_req( | ||||
|  |  | |||
|  | @ -84,7 +84,7 @@ class Fake(User, protocol.Protocol): | |||
| 
 | ||||
|     @classmethod | ||||
|     def owns_id(cls, id): | ||||
|         if id.startswith('nope'): | ||||
|         if id.startswith('nope') or id == 'fake:nope': | ||||
|             return False | ||||
| 
 | ||||
|         return ((id.startswith('fake:') and not id.startswith('fake:handle:')) | ||||
|  | @ -96,6 +96,8 @@ class Fake(User, protocol.Protocol): | |||
| 
 | ||||
|     @classmethod | ||||
|     def handle_to_id(cls, handle): | ||||
|         if handle == 'fake:handle:nope': | ||||
|             return None | ||||
|         return handle.replace('fake:handle:', 'fake:') | ||||
| 
 | ||||
|     @classmethod | ||||
|  |  | |||
		Ładowanie…
	
		Reference in New Issue
	
	 Ryan Barrett
						Ryan Barrett