diff --git a/atproto.py b/atproto.py index 7af53146..b7c50c04 100644 --- a/atproto.py +++ b/atproto.py @@ -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'] @@ -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}') diff --git a/dms.py b/dms.py index 4b8df65c..64f5c95f 100644 --- a/dms.py +++ b/dms.py @@ -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 diff --git a/ids.py b/ids.py index 8b421c7a..1bf9a237 100644 --- a/ids.py +++ b/ids.py @@ -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() @@ -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 diff --git a/models.py b/models.py index a3b47f4e..cced71af 100644 --- a/models.py +++ b/models.py @@ -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. diff --git a/pages.py b/pages.py index 92245488..e4b34525 100644 --- a/pages.py +++ b/pages.py @@ -171,16 +171,21 @@ def update_profile(protocol, id): link = f'{user.handle_or_id()}' 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}") diff --git a/protocol.py b/protocol.py index 381176ce..94994904 100644 --- a/protocol.py +++ b/protocol.py @@ -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): @@ -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 @@ -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. @@ -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') @@ -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 @@ -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): @@ -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) diff --git a/tests/test_dms.py b/tests/test_dms.py index 8875e625..80c94b58 100644 --- a/tests/test_dms.py +++ b/tests/test_dms.py @@ -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) diff --git a/tests/test_integrations.py b/tests/test_integrations.py index 50fb996e..e19e1f3a 100644 --- a/tests/test_integrations.py +++ b/tests/test_integrations.py @@ -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) @@ -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'], @@ -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("""\ + + me #nobridge +""", 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): diff --git a/tests/test_protocol.py b/tests/test_protocol.py index 9b12b525..a8211e99 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -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',