diff --git a/app/activitypub.py b/app/activitypub.py index 4b79e8b..e06a999 100644 --- a/app/activitypub.py +++ b/app/activitypub.py @@ -277,6 +277,13 @@ async def get_object(activity: RawObject) -> RawObject: raise ValueError(f"Unexpected object {raw_activity_object}") +def get_object_id(activity: RawObject) -> str: + if "object" not in activity: + raise ValueError(f"No object in {activity}") + + return get_id(activity["object"]) + + def wrap_object(activity: RawObject) -> RawObject: # TODO(tsileo): improve Create VS Update with a `update=True` flag if "updated" in activity: diff --git a/app/boxes.py b/app/boxes.py index 2186854..1f57f33 100644 --- a/app/boxes.py +++ b/app/boxes.py @@ -208,6 +208,11 @@ async def send_announce(db_session: AsyncSession, ap_object_id: str) -> None: async def send_follow(db_session: AsyncSession, ap_actor_id: str) -> None: + await _send_follow(db_session, ap_actor_id) + await db_session.commit() + + +async def _send_follow(db_session: AsyncSession, ap_actor_id: str) -> None: actor = await fetch_actor(db_session, ap_actor_id) follow_id = allocate_outbox_id() @@ -226,10 +231,16 @@ async def send_follow(db_session: AsyncSession, ap_actor_id: str) -> None: raise ValueError("Should never happen") await new_outgoing_activity(db_session, actor.inbox_url, outbox_object.id) - await db_session.commit() + + # Caller should commit async def send_undo(db_session: AsyncSession, ap_object_id: str) -> None: + await _send_undo(db_session, ap_object_id) + await db_session.commit() + + +async def _send_undo(db_session: AsyncSession, ap_object_id: str) -> None: outbox_object_to_undo = await get_outbox_object_by_ap_id(db_session, ap_object_id) if not outbox_object_to_undo: raise ValueError(f"{ap_object_id} not found in the outbox") @@ -309,7 +320,7 @@ async def send_undo(db_session: AsyncSession, ap_object_id: str) -> None: else: raise ValueError("Should never happen") - await db_session.commit() + # called should commit async def fetch_conversation_root( @@ -1139,6 +1150,54 @@ async def _handle_undo_activity( # commit will be perfomed in save_to_inbox +async def _handle_move_activity( + db_session: AsyncSession, + from_actor: models.Actor, + move_activity: models.InboxObject, +) -> None: + logger.info("Processing Move activity") + + # Ensure the object matches the actor + old_actor_id = ap.get_object_id(move_activity.ap_object) + if old_actor_id != from_actor.ap_id: + logger.warning( + f"Object does not match the actor: {old_actor_id}/{from_actor.ap_id}" + ) + return None + + # Fetch the target account + new_actor_id = move_activity.ap_object.get("target") + if not new_actor_id: + logger.warning("Missing target") + return None + + new_actor = await fetch_actor(db_session, new_actor_id) + + # Ensure the target account references the old account + if old_actor_id not in (aks := new_actor.ap_actor.get("alsoKnownAs", [])): + logger.warning( + f"New account does not have have an alias for the old account: {aks}" + ) + return None + + # Unfollow the old account + following = ( + await db_session.execute( + select(models.Following) + .where(models.Following.ap_actor_id == old_actor_id) + .options(joinedload(models.Following.outbox_object)) + ) + ).scalar_one_or_none() + if not following: + logger.warning("Not following the Move actor") + return + + await _send_undo(db_session, following.outbox_object.ap_id) + + # Follow the new one + await _send_follow(db_session, new_actor_id) + + async def _handle_update_activity( db_session: AsyncSession, from_actor: models.Actor, @@ -1576,6 +1635,8 @@ async def save_to_inbox( await _handle_read_activity(db_session, actor, inbox_object) elif activity_ro.ap_type == "Update": await _handle_update_activity(db_session, actor, inbox_object) + elif activity_ro.ap_type == "Move": + await _handle_move_activity(db_session, actor, inbox_object) elif activity_ro.ap_type == "Delete": await _handle_delete_activity( db_session, diff --git a/app/main.py b/app/main.py index c081d4e..a1cf678 100644 --- a/app/main.py +++ b/app/main.py @@ -76,8 +76,8 @@ _RESIZED_CACHE: MutableMapping[tuple[str, int], tuple[bytes, str, Any]] = LFUCac # TODO(ts): # # Next: -# - empty recipients for Share on "was not in the inbox" object -# - support Move +# - fix issue with followers from a blocked server (skip it?) +# - CORS webfinger endpoint # - support actor delete # - allow to share old notes # - allow to interact with object not in anybox (i.e. like from a lookup) diff --git a/tests/factories.py b/tests/factories.py index a3693ef..a311472 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -68,6 +68,21 @@ def build_accept_activity( } +def build_move_activity( + from_remote_actor: actor.RemoteActor, + for_remote_object: actor.RemoteActor, + outbox_public_id: str | None = None, +) -> ap.RawObject: + return { + "@context": ap.AS_CTX, + "type": "Move", + "id": from_remote_actor.ap_id + "/move/" + (outbox_public_id or uuid4().hex), + "actor": from_remote_actor.ap_id, + "object": from_remote_actor.ap_id, + "target": for_remote_object.ap_id, + } + + def build_note_object( from_remote_actor: actor.RemoteActor, outbox_public_id: str | None = None, @@ -123,11 +138,13 @@ class RemoteActorFactory(factory.Factory): "base_url", "username", "public_key", + "also_known_as", ) class Params: icon_url = None summary = "I like unit tests" + also_known_as: list[str] = [] ap_actor = factory.LazyAttribute( lambda o: { @@ -152,6 +169,7 @@ class RemoteActorFactory(factory.Factory): "owner": o.base_url, "publicKeyPem": o.public_key, }, + "alsoKnownAs": o.also_known_as, } ) @@ -240,3 +258,8 @@ class InboxObjectFactory(factory.alchemy.SQLAlchemyModelFactory): class FollowerFactory(factory.alchemy.SQLAlchemyModelFactory): class Meta(BaseModelMeta): model = models.Follower + + +class FollowingFactory(factory.alchemy.SQLAlchemyModelFactory): + class Meta(BaseModelMeta): + model = models.Following diff --git a/tests/test_inbox.py b/tests/test_inbox.py index 1e83e95..efcc3b7 100644 --- a/tests/test_inbox.py +++ b/tests/test_inbox.py @@ -21,6 +21,7 @@ from tests.utils import run_async from tests.utils import setup_inbox_delete from tests.utils import setup_remote_actor from tests.utils import setup_remote_actor_as_follower +from tests.utils import setup_remote_actor_as_following async def _process_next_incoming_activity(db_session: AsyncSession) -> None: @@ -353,3 +354,72 @@ def test_inbox__actor_is_blocked( ) == 0 ) + + +def test_inbox__move_activity( + db: Session, + client: TestClient, + respx_mock: respx.MockRouter, +) -> None: + # Given a remote actor + ra = setup_remote_actor(respx_mock) + + # Which is followed by the local actor + following = setup_remote_actor_as_following(ra) + old_actor = following.actor + assert old_actor + assert following.outbox_object + follow_id = following.outbox_object.ap_id + + # When receiving a Move activity + new_ra = setup_remote_actor( + respx_mock, + base_url="https://new-account.com", + also_known_as=[ra.ap_id], + ) + move_activity = RemoteObject( + factories.build_move_activity(ra, new_ra), + ra, + ) + + with mock_httpsig_checker(ra): + response = client.post( + "/inbox", + headers={"Content-Type": ap.AS_CTX}, + json=move_activity.ap_object, + ) + + # Then the server returns a 204 + assert response.status_code == 202 + + run_async(_process_next_incoming_activity) + + # And the Move activity was saved in the inbox + inbox_activity = db.execute(select(models.InboxObject)).scalar_one() + assert inbox_activity.ap_type == "Move" + assert inbox_activity.actor_id == old_actor.id + + # And the following actor was deleted + assert db.scalar(select(func.count(models.Following.id))) == 0 + + # And the follow was undone + assert ( + db.scalar( + select(func.count(models.OutboxObject.id)).where( + models.OutboxObject.ap_type == "Undo", + models.OutboxObject.activity_object_ap_id == follow_id, + ) + ) + == 1 + ) + + # And the new account was followed + assert ( + db.scalar( + select(func.count(models.OutboxObject.id)).where( + models.OutboxObject.ap_type == "Follow", + models.OutboxObject.activity_object_ap_id == new_ra.ap_id, + ) + ) + == 1 + ) diff --git a/tests/utils.py b/tests/utils.py index 5bb47db..bc243b6 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -40,11 +40,16 @@ def generate_admin_session_cookies() -> dict[str, Any]: return {"session": session_serializer.dumps({"is_logged_in": True})} -def setup_remote_actor(respx_mock: respx.MockRouter) -> actor.RemoteActor: +def setup_remote_actor( + respx_mock: respx.MockRouter, + base_url="https://example.com", + also_known_as=None, +) -> actor.RemoteActor: ra = factories.RemoteActorFactory( - base_url="https://example.com", + base_url=base_url, username="toto", public_key="pk", + also_known_as=also_known_as if also_known_as else [], ) respx_mock.get(ra.ap_id + "/outbox").mock( return_value=httpx.Response( @@ -86,6 +91,30 @@ def setup_remote_actor_as_follower(ra: actor.RemoteActor) -> models.Follower: return follower +def setup_remote_actor_as_following(ra: actor.RemoteActor) -> models.Following: + actor = factories.ActorFactory.from_remote_actor(ra) + + follow_id = uuid4().hex + follow_from_outbox = RemoteObject( + factories.build_follow_activity( + from_remote_actor=LOCAL_ACTOR, + for_remote_actor=ra, + outbox_public_id=follow_id, + ), + LOCAL_ACTOR, + ) + outbox_object = factories.OutboxObjectFactory.from_remote_object( + follow_id, follow_from_outbox + ) + + following = factories.FollowingFactory( + outbox_object_id=outbox_object.id, + actor_id=actor.id, + ap_actor_id=actor.ap_id, + ) + return following + + def setup_inbox_delete( actor: models.Actor, deleted_object_ap_id: str ) -> models.InboxObject: