kopia lustrzana https://github.com/snarfed/bridgy-fed
				
				
				
			add Protocol.migrate_in, implement in ATProto
for importing ATProto repos from external PDSes, https://github.com/snarfed/bridgy-fed/issues/1137, https://github.com/snarfed/bounce/issues/12pull/1826/head
							rodzic
							
								
									b50b5b42fb
								
							
						
					
					
						commit
						3e733e798d
					
				
							
								
								
									
										79
									
								
								atproto.py
								
								
								
								
							
							
						
						
									
										79
									
								
								atproto.py
								
								
								
								
							|  | @ -35,6 +35,11 @@ from granary.bluesky import Bluesky, FROM_AS1_TYPES, to_external_embed | |||
| from granary.source import html_to_text, INCLUDE_LINK, Source | ||||
| from lexrpc import Client, ValidationError | ||||
| from requests import RequestException | ||||
| from requests_oauth2client import ( | ||||
|     DPoPToken, | ||||
|     OAuth2AccessTokenAuth, | ||||
| ) | ||||
| import oauth_dropins.bluesky | ||||
| from oauth_dropins.webutil import util | ||||
| from oauth_dropins.webutil.appengine_config import ndb_client | ||||
| from oauth_dropins.webutil.appengine_info import DEBUG | ||||
|  | @ -87,6 +92,12 @@ dns_client = dns.Client(project=DNS_GCP_PROJECT) | |||
| # "Discovery API" https://github.com/googleapis/google-api-python-client | ||||
| dns_discovery_api = googleapiclient.discovery.build('dns', 'v1') | ||||
| 
 | ||||
| # for migrate_in | ||||
| BOUNCE_OAUTH_CLIENT = { | ||||
|     'client_id': 'https://bounce.anew.social/bluesky/client-metadata.json', | ||||
|     'redirect_uris': ['https://bounce.anew.social/bluesky/oauth-callback'], | ||||
| } | ||||
| 
 | ||||
| 
 | ||||
| def chat_client(*, repo, method, **kwargs): | ||||
|     """Returns a new Bluesky chat :class:`Client` for a given XRPC method. | ||||
|  | @ -959,6 +970,73 @@ class ATProto(User, Protocol): | |||
| 
 | ||||
|         return ret | ||||
| 
 | ||||
|     @classmethod | ||||
|     def migrate_in(cls, user, from_user_id, plc_code, dpop_token): | ||||
|         """Migrates an ATProto account on another PDS in to be a bridged account. | ||||
| 
 | ||||
|         Before calling this, the repo must have already been imported with | ||||
|         ``com.atproto.repo.importRepo``! | ||||
| 
 | ||||
|         Args: | ||||
|           user (models.User): native user on another protocol to attach the | ||||
|             newly imported bridged account to | ||||
|           from_user_id (str): DID of the account to be migrated in | ||||
|           plc_code (str): a PLC operation confirmation code from the account's | ||||
|             old PDS, from ``com.atproto.identity.requestPlcOperationSignature`` | ||||
|           dpop_token (requests_oauth2client.DPoPToken): a serialized OAuth DPoP | ||||
|             token for the account from its old PDS | ||||
| 
 | ||||
|         Raises: | ||||
|           ValueError: if ``from_user_id`` is not an ATProto DID, or | ||||
|             ``user`` is an :class:`ATProto`, or ``user`` is already bridged to | ||||
|             Bluesky, or the repo hasn't been imported yet | ||||
|         """ | ||||
|         def _error(msg): | ||||
|             logger.warning(msg) | ||||
|             raise ValueError(msg) | ||||
| 
 | ||||
|         logger.info(f"Migrating in {from_user_id} for {user.key.id()}") | ||||
| 
 | ||||
|         if cls.owns_id(from_user_id) is False: | ||||
|             _error(f"{from_user_id} doesn't look like an {cls.LABEL} id") | ||||
|         elif isinstance(user, cls): | ||||
|             _error(f"{user.handle_or_id()} is on {cls.PHRASE}") | ||||
|         elif user.is_enabled(cls): | ||||
|             _error(f"{user.handle_or_id()} is already bridged to {cls.PHRASE}") | ||||
| 
 | ||||
|         if not (repo := arroba.server.storage.load_repo(from_user_id)): | ||||
|             _error(f"Please import {from_user_id}'s repo first") | ||||
| 
 | ||||
|         # ask old PDS to generate signed PLC operation | ||||
|         pds_url = oauth_dropins.bluesky.pds_for_did(from_user_id) | ||||
|         oauth_client = oauth_dropins.bluesky.oauth_client_for_pds( | ||||
|             BOUNCE_OAUTH_CLIENT, pds_url) | ||||
|         auth = OAuth2AccessTokenAuth(client=oauth_client, token=dpop_token) | ||||
|         pds_client = Client(pds_url, auth=auth) | ||||
| 
 | ||||
|         op = pds_client.com.atproto.identity.signPlcOperation({ | ||||
|             'token': plc_code, | ||||
|             'rotationKeys': [did.encode_did_key(repo.rotation_key.public_key())], | ||||
|             'verificationMethod': [{ | ||||
|                 'id': 'did:plc:user#atproto', | ||||
|                 'type': 'Multikey', | ||||
|                 'controller': 'did:plc:user', | ||||
|                 'publicKeyMultibase': did.encode_did_key(repo.signing_key.public_key()), | ||||
|             }], | ||||
|             'services': { | ||||
|                 'atproto_pds': { | ||||
|                     'type': 'AtprotoPersonalDataServer', | ||||
|                     'endpoint': cls.PDS_URL, | ||||
|                 }, | ||||
|             }, | ||||
|             # TODO: do we need all fields? missing alsoKnownAs | ||||
|         }) | ||||
|         logger.debug(op) | ||||
| 
 | ||||
|         # submit PLC operation to directory | ||||
|         util.requests_post(f'https://{os.environ["PLC_HOST"]}/{from_user_id}', | ||||
|                            json=op['operation']) | ||||
| 
 | ||||
|     @classmethod | ||||
|     def add_source_links(cls, actor, obj, from_user): | ||||
|         """Adds "bridged from ... by Bridgy Fed" text to ``obj.our_as1``. | ||||
|  | @ -1010,6 +1088,7 @@ class ATProto(User, Protocol): | |||
|         obj.our_as1['summary'] = Bluesky('unused').truncate( | ||||
|             summary, url=source_links, punctuation=('', ''), type=obj.type) | ||||
| 
 | ||||
| 
 | ||||
| def create_report(*, input, from_user): | ||||
|     """Sends a ``createReport`` for a ``flag`` activity. | ||||
| 
 | ||||
|  |  | |||
							
								
								
									
										19
									
								
								protocol.py
								
								
								
								
							
							
						
						
									
										19
									
								
								protocol.py
								
								
								
								
							|  | @ -680,7 +680,24 @@ class Protocol: | |||
|           to_user_id (str) | ||||
| 
 | ||||
|         Raises: | ||||
|           ValueError: eg if this protocol doesn't own ``to_user_id`` | ||||
|           ValueError: eg if this protocol doesn't own ``to_user_id``, or if | ||||
|             ``user`` is on this protocol or not bridged to this protocol | ||||
|         """ | ||||
|         raise NotImplementedError() | ||||
| 
 | ||||
|     @classmethod | ||||
|     def migrate_in(cls, user, from_user_id, **kwargs): | ||||
|         """Migrates a native account in to be a bridged account. | ||||
| 
 | ||||
|         Args: | ||||
|           user (models.User): native user on another protocol to attach the | ||||
|             newly imported bridged account to | ||||
|           from_user_id (str) | ||||
|           kwargs: additional protocol-specific parameters | ||||
| 
 | ||||
|         Raises: | ||||
|           ValueError: eg if this protocol doesn't own ``from_user_id``, or if | ||||
|             ``user`` is on this protocol or already bridged to this protocol | ||||
|         """ | ||||
|         raise NotImplementedError() | ||||
| 
 | ||||
|  |  | |||
|  | @ -26,6 +26,7 @@ from oauth_dropins.webutil.appengine_config import tasks_client | |||
| from oauth_dropins.webutil.testutil import NOW, NOW_SECONDS, requests_response | ||||
| from oauth_dropins.webutil.util import json_dumps, json_loads, trim_nulls | ||||
| from requests.exceptions import HTTPError | ||||
| from requests_oauth2client import DPoPKey, DPoPToken, OAuth2Client | ||||
| from werkzeug.exceptions import BadRequest | ||||
| 
 | ||||
| import atproto | ||||
|  | @ -115,6 +116,8 @@ SEND_MESSAGE_OUTPUT = {  # sendMessage | |||
|     'text': 'hello world', | ||||
| } | ||||
| 
 | ||||
| DPOP_TOKEN = DPoPToken(access_token='towkin', _dpop_key=DPoPKey.generate()) | ||||
| 
 | ||||
| 
 | ||||
| @patch('ids.COPIES_PROTOCOLS', ['atproto']) | ||||
| class ATProtoTest(TestCase): | ||||
|  | @ -1086,6 +1089,76 @@ Sed tortor neque, aliquet quis posuere aliquam […] | |||
|             'summary': '<a href="http://foo">bar</a>', | ||||
|         }), from_user=user)) | ||||
| 
 | ||||
|     @patch('oauth_dropins.bluesky.oauth_client_for_pds', | ||||
|            return_value=OAuth2Client(token_endpoint='https://un/used', | ||||
|                                      client_id='unused', client_secret='unused')) | ||||
|     @patch('requests.post', side_effect=[ | ||||
|         requests_response({'operation': {'signed': 'op'}}), | ||||
|         requests_response(), | ||||
|     ]) | ||||
|     @patch('requests.get', side_effect=[ | ||||
|         requests_response(DID_DOC),  # resolve did:plc:user | ||||
|     ]) | ||||
|     def test_migrate_in(self, _, mock_post, mock_oauth2client): | ||||
|         self.make_user_and_repo() | ||||
|         self.user.copies = self.user.enabled_protocols = [] | ||||
|         self.user.put() | ||||
| 
 | ||||
|         ATProto.migrate_in(self.user, 'did:plc:user', plc_code='kode', | ||||
|                            dpop_token=DPOP_TOKEN) | ||||
| 
 | ||||
|         self.assertEqual( | ||||
|             ('https://some.pds/xrpc/com.atproto.identity.signPlcOperation',), | ||||
|             mock_post.call_args_list[0].args) | ||||
|         kwargs = mock_post.call_args_list[0].kwargs | ||||
|         did_key = encode_did_key(self.repo.rotation_key.public_key()) | ||||
|         self.assertEqual({ | ||||
|             'token': 'kode', | ||||
|             'rotationKeys': [did_key], | ||||
|             'verificationMethod': [{ | ||||
|                 'id': 'did:plc:user#atproto', | ||||
|                 'type': 'Multikey', | ||||
|                 'controller': 'did:plc:user', | ||||
|                 'publicKeyMultibase': did_key, | ||||
|             }], | ||||
|             'services': { | ||||
|                 'atproto_pds': { | ||||
|                     'type': 'AtprotoPersonalDataServer', | ||||
|                     'endpoint': 'https://atproto.brid.gy', | ||||
|                 }, | ||||
|             }, | ||||
|         }, kwargs['json']) | ||||
|         self.assertEqual(DPOP_TOKEN, kwargs['auth'].token) | ||||
| 
 | ||||
|         self.assertEqual((f'https://plc.local/did:plc:user',), | ||||
|                          mock_post.call_args_list[1].args) | ||||
|         self.assertEqual({'signed': 'op'}, mock_post.call_args_list[1].kwargs['json']) | ||||
| 
 | ||||
|     def test_migrate_in_bad_user_id(self, *_): | ||||
|         eve = self.make_user('fake:eve', cls=Fake) | ||||
|         with self.assertRaises(ValueError): | ||||
|             ATProto.migrate_in(eve, 'https://foo/', plc_code='kode', | ||||
|                                dpop_token=DPOP_TOKEN) | ||||
| 
 | ||||
|     def test_migrate_in_user_enabled(self, *_): | ||||
|         eve = self.make_user('fake:eve', cls=Fake, enabled_protocols=['atproto']) | ||||
|         with self.assertRaises(ValueError): | ||||
|             ATProto.migrate_in(eve, 'did:plc:xyz', plc_code='kode', | ||||
|                                dpop_token=DPOP_TOKEN) | ||||
| 
 | ||||
|     def test_migrate_in_atproto_user(self, *_): | ||||
|         self.store_object(id='did:plc:eve', raw=DID_DOC) | ||||
|         eve = self.make_user('did:plc:eve', cls=ATProto) | ||||
|         with self.assertRaises(ValueError): | ||||
|             ATProto.migrate_in(eve, 'did:plc:xyz', plc_code='kode', | ||||
|                                dpop_token=DPOP_TOKEN) | ||||
| 
 | ||||
|     def test_migrate_in_missing_repo(self, *_): | ||||
|         eve = self.make_user('fake:eve', cls=Fake) | ||||
|         with self.assertRaises(ValueError): | ||||
|             ATProto.migrate_in(eve, 'did:plc:outside', plc_code='kode', | ||||
|                                dpop_token=DPOP_TOKEN) | ||||
| 
 | ||||
|     @patch('requests.get', return_value=requests_response('', status=404)) | ||||
|     def test_web_url(self, mock_get): | ||||
|         user = self.make_user('did:plc:user', cls=ATProto) | ||||
|  |  | |||
|  | @ -93,8 +93,9 @@ class Fake(User, protocol.Protocol): | |||
|     fetched = [] | ||||
|     created_for = [] | ||||
| 
 | ||||
|     # in-order list of (user, to user id) | ||||
|     # in-order list of (user, to/from user id) | ||||
|     migrated_out = [] | ||||
|     migrated_in = [] | ||||
| 
 | ||||
|     # maps str user id to str username | ||||
|     usernames = {} | ||||
|  | @ -135,6 +136,10 @@ class Fake(User, protocol.Protocol): | |||
|     def migrate_out(cls, user, to_user_id): | ||||
|         cls.migrated_out.append((user, to_user_id)) | ||||
| 
 | ||||
|     @classmethod | ||||
|     def migrate_in(cls, user, from_user_id): | ||||
|         cls.migrated_in.append((user, from_user_id)) | ||||
| 
 | ||||
|     @classmethod | ||||
|     def owns_id(cls, id): | ||||
|         if id.startswith('nope') or id == f'{cls.LABEL}:nope': | ||||
|  | @ -326,6 +331,7 @@ class TestCase(unittest.TestCase, testutil.Asserts): | |||
|             cls.fetched = [] | ||||
|             cls.created_for = [] | ||||
|             cls.migrated_out = [] | ||||
|             cls.migrated_in = [] | ||||
|             cls.usernames = {} | ||||
| 
 | ||||
|         # make random test data deterministic | ||||
|  |  | |||
		Ładowanie…
	
		Reference in New Issue
	
	 Ryan Barrett
						Ryan Barrett