Add support for Move activity
parent
4ae198d074
commit
02c09f2363
|
@ -277,6 +277,13 @@ async def get_object(activity: RawObject) -> RawObject:
|
||||||
raise ValueError(f"Unexpected object {raw_activity_object}")
|
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:
|
def wrap_object(activity: RawObject) -> RawObject:
|
||||||
# TODO(tsileo): improve Create VS Update with a `update=True` flag
|
# TODO(tsileo): improve Create VS Update with a `update=True` flag
|
||||||
if "updated" in activity:
|
if "updated" in activity:
|
||||||
|
|
65
app/boxes.py
65
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:
|
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)
|
actor = await fetch_actor(db_session, ap_actor_id)
|
||||||
|
|
||||||
follow_id = allocate_outbox_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")
|
raise ValueError("Should never happen")
|
||||||
|
|
||||||
await new_outgoing_activity(db_session, actor.inbox_url, outbox_object.id)
|
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:
|
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)
|
outbox_object_to_undo = await get_outbox_object_by_ap_id(db_session, ap_object_id)
|
||||||
if not outbox_object_to_undo:
|
if not outbox_object_to_undo:
|
||||||
raise ValueError(f"{ap_object_id} not found in the outbox")
|
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:
|
else:
|
||||||
raise ValueError("Should never happen")
|
raise ValueError("Should never happen")
|
||||||
|
|
||||||
await db_session.commit()
|
# called should commit
|
||||||
|
|
||||||
|
|
||||||
async def fetch_conversation_root(
|
async def fetch_conversation_root(
|
||||||
|
@ -1139,6 +1150,54 @@ async def _handle_undo_activity(
|
||||||
# commit will be perfomed in save_to_inbox
|
# 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(
|
async def _handle_update_activity(
|
||||||
db_session: AsyncSession,
|
db_session: AsyncSession,
|
||||||
from_actor: models.Actor,
|
from_actor: models.Actor,
|
||||||
|
@ -1576,6 +1635,8 @@ async def save_to_inbox(
|
||||||
await _handle_read_activity(db_session, actor, inbox_object)
|
await _handle_read_activity(db_session, actor, inbox_object)
|
||||||
elif activity_ro.ap_type == "Update":
|
elif activity_ro.ap_type == "Update":
|
||||||
await _handle_update_activity(db_session, actor, inbox_object)
|
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":
|
elif activity_ro.ap_type == "Delete":
|
||||||
await _handle_delete_activity(
|
await _handle_delete_activity(
|
||||||
db_session,
|
db_session,
|
||||||
|
|
|
@ -76,8 +76,8 @@ _RESIZED_CACHE: MutableMapping[tuple[str, int], tuple[bytes, str, Any]] = LFUCac
|
||||||
# TODO(ts):
|
# TODO(ts):
|
||||||
#
|
#
|
||||||
# Next:
|
# Next:
|
||||||
# - empty recipients for Share on "was not in the inbox" object
|
# - fix issue with followers from a blocked server (skip it?)
|
||||||
# - support Move
|
# - CORS webfinger endpoint
|
||||||
# - support actor delete
|
# - support actor delete
|
||||||
# - allow to share old notes
|
# - allow to share old notes
|
||||||
# - allow to interact with object not in anybox (i.e. like from a lookup)
|
# - allow to interact with object not in anybox (i.e. like from a lookup)
|
||||||
|
|
|
@ -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(
|
def build_note_object(
|
||||||
from_remote_actor: actor.RemoteActor,
|
from_remote_actor: actor.RemoteActor,
|
||||||
outbox_public_id: str | None = None,
|
outbox_public_id: str | None = None,
|
||||||
|
@ -123,11 +138,13 @@ class RemoteActorFactory(factory.Factory):
|
||||||
"base_url",
|
"base_url",
|
||||||
"username",
|
"username",
|
||||||
"public_key",
|
"public_key",
|
||||||
|
"also_known_as",
|
||||||
)
|
)
|
||||||
|
|
||||||
class Params:
|
class Params:
|
||||||
icon_url = None
|
icon_url = None
|
||||||
summary = "I like unit tests"
|
summary = "I like unit tests"
|
||||||
|
also_known_as: list[str] = []
|
||||||
|
|
||||||
ap_actor = factory.LazyAttribute(
|
ap_actor = factory.LazyAttribute(
|
||||||
lambda o: {
|
lambda o: {
|
||||||
|
@ -152,6 +169,7 @@ class RemoteActorFactory(factory.Factory):
|
||||||
"owner": o.base_url,
|
"owner": o.base_url,
|
||||||
"publicKeyPem": o.public_key,
|
"publicKeyPem": o.public_key,
|
||||||
},
|
},
|
||||||
|
"alsoKnownAs": o.also_known_as,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -240,3 +258,8 @@ class InboxObjectFactory(factory.alchemy.SQLAlchemyModelFactory):
|
||||||
class FollowerFactory(factory.alchemy.SQLAlchemyModelFactory):
|
class FollowerFactory(factory.alchemy.SQLAlchemyModelFactory):
|
||||||
class Meta(BaseModelMeta):
|
class Meta(BaseModelMeta):
|
||||||
model = models.Follower
|
model = models.Follower
|
||||||
|
|
||||||
|
|
||||||
|
class FollowingFactory(factory.alchemy.SQLAlchemyModelFactory):
|
||||||
|
class Meta(BaseModelMeta):
|
||||||
|
model = models.Following
|
||||||
|
|
|
@ -21,6 +21,7 @@ from tests.utils import run_async
|
||||||
from tests.utils import setup_inbox_delete
|
from tests.utils import setup_inbox_delete
|
||||||
from tests.utils import setup_remote_actor
|
from tests.utils import setup_remote_actor
|
||||||
from tests.utils import setup_remote_actor_as_follower
|
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:
|
async def _process_next_incoming_activity(db_session: AsyncSession) -> None:
|
||||||
|
@ -353,3 +354,72 @@ def test_inbox__actor_is_blocked(
|
||||||
)
|
)
|
||||||
== 0
|
== 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
|
||||||
|
)
|
||||||
|
|
|
@ -40,11 +40,16 @@ def generate_admin_session_cookies() -> dict[str, Any]:
|
||||||
return {"session": session_serializer.dumps({"is_logged_in": True})}
|
return {"session": session_serializer.dumps({"is_logged_in": True})}
|
||||||
|
|
||||||
|
|
||||||
def setup_remote_actor(respx_mock: respx.MockRouter) -> actor.RemoteActor:
|
def setup_remote_actor(
|
||||||
ra = factories.RemoteActorFactory(
|
respx_mock: respx.MockRouter,
|
||||||
base_url="https://example.com",
|
base_url="https://example.com",
|
||||||
|
also_known_as=None,
|
||||||
|
) -> actor.RemoteActor:
|
||||||
|
ra = factories.RemoteActorFactory(
|
||||||
|
base_url=base_url,
|
||||||
username="toto",
|
username="toto",
|
||||||
public_key="pk",
|
public_key="pk",
|
||||||
|
also_known_as=also_known_as if also_known_as else [],
|
||||||
)
|
)
|
||||||
respx_mock.get(ra.ap_id + "/outbox").mock(
|
respx_mock.get(ra.ap_id + "/outbox").mock(
|
||||||
return_value=httpx.Response(
|
return_value=httpx.Response(
|
||||||
|
@ -86,6 +91,30 @@ def setup_remote_actor_as_follower(ra: actor.RemoteActor) -> models.Follower:
|
||||||
return 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(
|
def setup_inbox_delete(
|
||||||
actor: models.Actor, deleted_object_ap_id: str
|
actor: models.Actor, deleted_object_ap_id: str
|
||||||
) -> models.InboxObject:
|
) -> models.InboxObject:
|
||||||
|
|
Loading…
Reference in New Issue