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