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/12
pull/1826/head
Ryan Barrett 2025-03-09 09:18:24 -07:00
rodzic b50b5b42fb
commit 3e733e798d
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: 6BE31FDF4776E9D4
4 zmienionych plików z 177 dodań i 2 usunięć

Wyświetl plik

@ -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.

Wyświetl plik

@ -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()

Wyświetl plik

@ -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)

Wyświetl plik

@ -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