Skip to content

Commit

Permalink
when we get a delete, or a web user opts out, delete their copies in …
Browse files Browse the repository at this point in the history
…other protocols

for #1304
  • Loading branch information
snarfed committed Sep 17, 2024
1 parent 44e916e commit 4df76d0
Show file tree
Hide file tree
Showing 9 changed files with 94 additions and 39 deletions.
5 changes: 3 additions & 2 deletions atproto.py
Original file line number Diff line number Diff line change
Expand Up @@ -496,6 +496,7 @@ def send(to_cls, obj, url, from_user=None, orig_obj=None):
type = as1.object_type(obj.as1)
base_obj = obj
base_obj_as1 = obj.as1
allow_opt_out = (type == 'delete')
if type in ('post', 'update', 'delete', 'undo'):
base_obj_as1 = as1.get_object(obj.as1)
base_id = base_obj_as1['id']
Expand Down Expand Up @@ -523,13 +524,13 @@ def send(to_cls, obj, url, from_user=None, orig_obj=None):

# find user
from_cls = PROTOCOLS[obj.source_protocol]
from_key = from_cls.actor_key(obj)
from_key = from_cls.actor_key(obj, allow_opt_out=allow_opt_out)
if not from_key:
logger.info(f"Couldn't find {obj.source_protocol} user for {obj.key.id()}")
return False

# load user
user = from_cls.get_or_create(from_key.id(), propagate=True)
user = from_cls.get_or_create(from_key.id(), allow_opt_out=allow_opt_out, propagate=True)
did = user.get_copy(ATProto)
assert did
logger.info(f'{user.key.id()} is {did}')
Expand Down
2 changes: 1 addition & 1 deletion dms.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ def receive(*, from_user, obj):
return 'OK', 200

elif content == 'no':
to_proto.delete_user_copy(from_user)
from_user.delete(to_proto)
from_user.disable_protocol(to_proto)
return 'OK', 200

Expand Down
4 changes: 2 additions & 2 deletions ids.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ def translate_user_id(*, id, from_, to):
return id

# follow use_instead
user = from_.get_by_id(id)
user = from_.get_by_id(id, allow_opt_out=True)
if user:
id = user.key.id()

Expand Down Expand Up @@ -192,7 +192,7 @@ def profile_id(*, id, proto):
Examples:
* Web: user.com => https:///user.com/
* Web: user.com => https://user.com/
* ActivityPub: https://inst.ance/alice => https://inst.ance/alice
* ATProto: did:plc:123 => at://did:plc:123/app.bsky.actor.profile/self
Expand Down
19 changes: 19 additions & 0 deletions models.py
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,25 @@ def obj(self, obj):
else:
self._obj = self.obj_key = None

def delete(self, proto=None):
"""Deletes a user's bridged actors in all protocols or a specific one.
Args:
proto (Protocol): optional
"""
now = util.now().isoformat()
proto_label = proto.LABEL if proto else 'all'
delete_id = f'{self.profile_id()}#delete-user-{proto_label}-{now}'
delete = Object(id=delete_id, source_protocol=self.LABEL, our_as1={
'id': delete_id,
'objectType': 'activity',
'verb': 'delete',
'actor': self.key.id(),
'object': self.key.id(),
})
delete.put()
self.deliver(delete, from_user=self, to_proto=proto)

@classmethod
def load_multi(cls, users):
"""Loads :attr:`obj` for multiple users in parallel.
Expand Down
11 changes: 8 additions & 3 deletions pages.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,16 +171,21 @@ def update_profile(protocol, id):
link = f'<a href="{user.web_url()}">{user.handle_or_id()}</a>'

try:
profile_obj = user.load(user.profile_id(), remote=True)
user.obj = user.load(user.profile_id(), remote=True)
except (requests.RequestException, werkzeug.exceptions.HTTPException) as e:
_, msg = util.interpret_http_exception(e)
flash(f"Couldn't update profile for {link}: {msg}")
return redirect(user.user_page_path(), code=302)

if profile_obj:
common.create_task(queue='receive', obj=profile_obj.key.urlsafe(),
if user.obj:
common.create_task(queue='receive', obj=user.obj.key.urlsafe(),
authed_as=user.key.id())
flash(f'Updating profile from {link}...')

if user.LABEL == 'web':
logger.info(f'Disabling web user {user.key.id()}')
user.delete()

else:
flash(f"Couldn't update profile for {link}")

Expand Down
34 changes: 8 additions & 26 deletions protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -432,18 +432,19 @@ def bridged_web_url_for(cls, user, fallback=False):
return user.web_url()

@classmethod
def actor_key(cls, obj):
def actor_key(cls, obj, allow_opt_out=False):
"""Returns the :class:`User`: key for a given object's author or actor.
Args:
obj (models.Object)
allow_opt_out (bool): whether to return a user key if they're opted out
Returns:
google.cloud.ndb.key.Key or None:
"""
owner = as1.get_owner(obj.as1)
if owner:
return cls.key_for(owner)
return cls.key_for(owner, allow_opt_out=allow_opt_out)

@classmethod
def bot_user_id(cls):
Expand Down Expand Up @@ -946,7 +947,7 @@ def receive(from_cls, obj, authed_as=None, internal=False):
elif obj.type == 'block':
if proto := Protocol.for_bridgy_subdomain(inner_obj_id):
# blocking protocol bot user disables that protocol
proto.delete_user_copy(from_user)
from_user.delete(proto)
from_user.disable_protocol(proto)
return 'OK', 200

Expand Down Expand Up @@ -1152,25 +1153,6 @@ def bot_follow(bot_cls, user):
url=target, protocol=user.LABEL,
user=bot.key.urlsafe())

@classmethod
def delete_user_copy(copy_cls, user):
"""Deletes a user's copy actor in a given protocol.
Args:
user (User)
"""
now = util.now().isoformat()
delete_id = f'{ids.profile_id(id=user.key.id(), proto=user)}#delete-copy-{copy_cls.LABEL}-{now}'
delete = Object(id=delete_id, source_protocol=user.LABEL, our_as1={
'id': delete_id,
'objectType': 'activity',
'verb': 'delete',
'actor': user.key.id(),
'object': user.key.id(),
})
delete.put()
user.deliver(delete, from_user=user, to_proto=copy_cls)

@classmethod
def handle_bare_object(cls, obj, authed_as=None):
"""If obj is a bare object, wraps it in a create or update activity.
Expand Down Expand Up @@ -1311,7 +1293,7 @@ def targets(from_cls, obj, from_user, internal=False):
orig_obj = None
targets = {} # maps Target to Object or None
owner = as1.get_owner(obj.as1)

allow_opt_out = (obj.type == 'delete')
inner_obj_as1 = as1.get_object(obj.as1)
inner_obj_id = inner_obj_as1.get('id')
in_reply_tos = as1.get_ids(inner_obj_as1, 'inReplyTo')
Expand Down Expand Up @@ -1423,7 +1405,7 @@ def targets(from_cls, obj, from_user, internal=False):
logger.info(f'Direct (and copy) targets: {targets.keys()}')

# deliver to followers, if appropriate
user_key = from_cls.actor_key(obj)
user_key = from_cls.actor_key(obj, allow_opt_out=allow_opt_out)
if not user_key:
logger.info("Can't tell who this is from! Skipping followers.")
return targets
Expand Down Expand Up @@ -1706,8 +1688,8 @@ def send_task():
target = Target(uri=url, protocol=protocol)

obj = ndb.Key(urlsafe=form['obj']).get()

PROTOCOLS[protocol].check_supported(obj)
allow_opt_out = (obj.type == 'delete')

if (target not in obj.undelivered and target not in obj.failed
and 'force' not in request.values):
Expand All @@ -1718,7 +1700,7 @@ def send_task():
if user_key := form.get('user'):
key = ndb.Key(urlsafe=user_key)
# use get_by_id so that we follow use_instead
user = PROTOCOLS_BY_KIND[key.kind()].get_by_id(key.id())
user = PROTOCOLS_BY_KIND[key.kind()].get_by_id(key.id(), allow_opt_out=allow_opt_out)

orig_obj = (ndb.Key(urlsafe=form['orig_obj']).get()
if form.get('orig_obj') else None)
Expand Down
2 changes: 1 addition & 1 deletion tests/test_dms.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ def test_receive_no_yes_sets_enabled_protocols(self):

# ...and delete copy actor
self.assertEqual(
[('eefake:user#delete-copy-fake-2022-01-02T03:04:05+00:00',
[('eefake:user#delete-user-fake-2022-01-02T03:04:05+00:00',
'fake:shared:target')],
Fake.sent)

Expand Down
52 changes: 50 additions & 2 deletions tests/test_integrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,11 @@ def make_web_user(self, domain, did, enabled_protocols=['activitypub']):
else None)

user = self.make_user(id=domain, cls=Web, ap_subdomain=ap_subdomain,
enabled_protocols=enabled_protocols)
enabled_protocols=enabled_protocols, obj_as1={
'objectType': 'person',
'id': f'https://{domain}/',
})

if did:
self.make_atproto_copy(user, did)

Expand Down Expand Up @@ -587,7 +591,7 @@ def test_atproto_block_ap_bot_user_disables_protocol_deletes_actor(
self.assert_equals({
'@context': 'https://www.w3.org/ns/activitystreams',
'type': 'Delete',
'id': 'https://bsky.brid.gy/convert/ap/at://did:plc:alice/app.bsky.actor.profile/self#delete-copy-activitypub-2022-01-02T03:04:05+00:00',
'id': 'https://bsky.brid.gy/convert/ap/at://did:plc:alice/app.bsky.actor.profile/self#delete-user-activitypub-2022-01-02T03:04:05+00:00',
'actor': 'https://bsky.brid.gy/ap/did:plc:alice',
'object': 'https://bsky.brid.gy/ap/did:plc:alice',
'to': ['https://www.w3.org/ns/activitystreams#Public'],
Expand Down Expand Up @@ -652,6 +656,50 @@ def test_activitypub_delete_user_tombstones_atproto_repo(self, mock_get):
self.storage.load_repo('did:plc:alice')


@patch('requests.post', return_value=requests_response(''))
@patch('requests.get', return_value=requests_response("""\
<html>
<body class="h-card"><a rel="me" href="/">me</a> #nobridge</body>
</html>""", url='https://alice.com/'))
def test_web_nobridge_refresh_profile_deletes_user_tombstones_atproto_repo(
self, mock_get, mock_post):
"""Web user adds #nobridge and refreshes their profile.
Should delete their bridged accounts.
Web user alice.com, did:plc:alice
ActivityPub user bob@inst, https://inst/bob,
"""
# users
alice = self.make_web_user('alice.com', 'did:plc:alice')
self.assertTrue(alice.is_enabled(ATProto))
self.assertTrue(alice.is_enabled(ActivityPub))

bob = self.make_ap_user('https://inst/bob')
Follower.get_or_create(to=alice, from_=bob)

# update profile
resp = self.client.post('/web/alice.com/update-profile')
self.assertEqual(302, resp.status_code)

# should be deleted everywhere
self.assertEqual('opt-out', alice.key.get().status)

with self.assertRaises(TombstonedRepo):
self.storage.load_repo('did:plc:alice')

self.assertEqual(1, mock_post.call_count)
args, kwargs = mock_post.call_args
self.assertEqual((bob.obj.as2['inbox'],), args)
self.assert_equals({
'@context': 'https://www.w3.org/ns/activitystreams',
'type': 'Delete',
'id': 'http://localhost/r/https://alice.com/#delete-user-all-2022-01-02T03:04:05+00:00',
'actor': 'http://localhost/alice.com',
'object': 'http://localhost/alice.com',
}, json_loads(kwargs['data']), ignore=['@context', 'contentMap', 'to', 'cc'])


@patch('requests.post')
@patch('requests.get')
def test_atproto_mention_activitypub(self, mock_get, mock_post):
Expand Down
4 changes: 2 additions & 2 deletions tests/test_protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -2731,11 +2731,11 @@ def test_follow_and_block_protocol_user_sets_enabled_protocols(self):

# ...and delete copy actor
self.assertEqual(
[('eefake:user#delete-copy-fake-2022-01-02T03:04:05+00:00',
[('eefake:user#delete-user-fake-2022-01-02T03:04:05+00:00',
'fake:shared:target')],
Fake.sent)

id = 'eefake:user#delete-copy-fake-2022-01-02T03:04:05+00:00'
id = 'eefake:user#delete-user-fake-2022-01-02T03:04:05+00:00'
self.assert_object(id,
our_as1={
'objectType': 'activity',
Expand Down

0 comments on commit 4df76d0

Please sign in to comment.