Improve Create activity handling

This commit is contained in:
Thomas Sileo 2022-07-05 20:47:00 +02:00
parent 09a7287877
commit 7624342ed7
4 changed files with 116 additions and 51 deletions

View file

@ -193,7 +193,9 @@ async def admin_inbox(
filter_by: str | None = None, filter_by: str | None = None,
cursor: str | None = None, cursor: str | None = None,
) -> templates.TemplateResponse: ) -> templates.TemplateResponse:
where = [models.InboxObject.ap_type.not_in(["Accept", "Delete"])] where = [
models.InboxObject.ap_type.not_in(["Accept", "Delete", "Create", "Update"])
]
if filter_by: if filter_by:
where.append(models.InboxObject.ap_type == filter_by) where.append(models.InboxObject.ap_type == filter_by)
if cursor: if cursor:

View file

@ -4,6 +4,7 @@ from collections import defaultdict
from dataclasses import dataclass from dataclasses import dataclass
from urllib.parse import urlparse from urllib.parse import urlparse
import fastapi
import httpx import httpx
from dateutil.parser import isoparse from dateutil.parser import isoparse
from loguru import logger from loguru import logger
@ -16,6 +17,8 @@ from sqlalchemy.orm import joinedload
from app import activitypub as ap from app import activitypub as ap
from app import config from app import config
from app import httpsig
from app import ldsig
from app import models from app import models
from app.actor import LOCAL_ACTOR from app.actor import LOCAL_ACTOR
from app.actor import Actor from app.actor import Actor
@ -590,10 +593,47 @@ async def _handle_undo_activity(
async def _handle_create_activity( async def _handle_create_activity(
db_session: AsyncSession, db_session: AsyncSession,
from_actor: models.Actor, from_actor: models.Actor,
created_object: models.InboxObject, create_activity: models.InboxObject,
) -> None: ) -> None:
logger.info("Processing Create activity") logger.info("Processing Create activity")
tags = created_object.ap_object.get("tag") wrapped_object = ap.unwrap_activity(create_activity.ap_object)
if create_activity.actor.ap_id != ap.get_actor_id(wrapped_object):
raise ValueError("Object actor does not match activity")
ro = RemoteObject(wrapped_object, actor=from_actor)
ap_published_at = now()
if "published" in ro.ap_object:
ap_published_at = isoparse(ro.ap_object["published"])
inbox_object = models.InboxObject(
server=urlparse(ro.ap_id).netloc,
actor_id=from_actor.id,
ap_actor_id=from_actor.ap_id,
ap_type=ro.ap_type,
ap_id=ro.ap_id,
ap_context=ro.ap_context,
ap_published_at=ap_published_at,
ap_object=ro.ap_object,
visibility=ro.visibility,
relates_to_inbox_object_id=create_activity.id,
relates_to_outbox_object_id=None,
activity_object_ap_id=ro.activity_object_ap_id,
# Hide replies from the stream
is_hidden_from_stream=(
True
if (ro.in_reply_to and not ro.in_reply_to.startswith(BASE_URL))
else False
), # TODO: handle mentions
)
db_session.add(inbox_object)
await db_session.flush()
await db_session.refresh(inbox_object)
create_activity.relates_to_inbox_object_id = inbox_object.id
tags = inbox_object.ap_object.get("tag")
if not tags: if not tags:
logger.info("No tags to process") logger.info("No tags to process")
@ -603,11 +643,11 @@ async def _handle_create_activity(
logger.info(f"Invalid tags: {tags}") logger.info(f"Invalid tags: {tags}")
return None return None
if created_object.in_reply_to and created_object.in_reply_to.startswith(BASE_URL): if inbox_object.in_reply_to and inbox_object.in_reply_to.startswith(BASE_URL):
await db_session.execute( await db_session.execute(
update(models.OutboxObject) update(models.OutboxObject)
.where( .where(
models.OutboxObject.ap_id == created_object.in_reply_to, models.OutboxObject.ap_id == inbox_object.in_reply_to,
) )
.values(replies_count=models.OutboxObject.replies_count + 1) .values(replies_count=models.OutboxObject.replies_count + 1)
) )
@ -617,104 +657,115 @@ async def _handle_create_activity(
notif = models.Notification( notif = models.Notification(
notification_type=models.NotificationType.MENTION, notification_type=models.NotificationType.MENTION,
actor_id=from_actor.id, actor_id=from_actor.id,
inbox_object_id=created_object.id, inbox_object_id=inbox_object.id,
) )
db_session.add(notif) db_session.add(notif)
async def save_to_inbox(db_session: AsyncSession, raw_object: ap.RawObject) -> None: async def save_to_inbox(
db_session: AsyncSession,
raw_object: ap.RawObject,
httpsig_info: httpsig.HTTPSigInfo,
) -> None:
try: try:
actor = await fetch_actor(db_session, ap.get_id(raw_object["actor"])) actor = await fetch_actor(db_session, ap.get_id(raw_object["actor"]))
except httpx.HTTPStatusError: except httpx.HTTPStatusError:
logger.exception("Failed to fetch actor") logger.exception("Failed to fetch actor")
return return
ap_published_at = now() raw_object_id = ap.get_id(raw_object)
if "published" in raw_object:
ap_published_at = isoparse(raw_object["published"])
ra = RemoteObject(ap.unwrap_activity(raw_object), actor=actor) # Ensure forwarded activities have a valid LD sig
if httpsig_info.signed_by_ap_actor_id != actor.ap_id:
logger.info(f"Processing a forwarded activity {httpsig_info=}/{actor.ap_id}")
if not (await ldsig.verify_signature(db_session, raw_object)):
raise fastapi.HTTPException(status_code=401, detail="Invalid LD sig")
if ( if (
await db_session.scalar( await db_session.scalar(
select(func.count(models.InboxObject.id)).where( select(func.count(models.InboxObject.id)).where(
models.InboxObject.ap_id == ra.ap_id models.InboxObject.ap_id == raw_object_id
) )
) )
> 0 > 0
): ):
logger.info(f"Received duplicate {ra.ap_type} activity: {ra.ap_id}") logger.info(
f'Received duplicate {raw_object["type"]} activity: {raw_object_id}'
)
return return
ap_published_at = now()
if "published" in raw_object:
ap_published_at = isoparse(raw_object["published"])
activity_ro = RemoteObject(raw_object, actor=actor)
relates_to_inbox_object: models.InboxObject | None = None relates_to_inbox_object: models.InboxObject | None = None
relates_to_outbox_object: models.OutboxObject | None = None relates_to_outbox_object: models.OutboxObject | None = None
if ra.activity_object_ap_id: if activity_ro.activity_object_ap_id:
if ra.activity_object_ap_id.startswith(BASE_URL): if activity_ro.activity_object_ap_id.startswith(BASE_URL):
relates_to_outbox_object = await get_outbox_object_by_ap_id( relates_to_outbox_object = await get_outbox_object_by_ap_id(
db_session, db_session,
ra.activity_object_ap_id, activity_ro.activity_object_ap_id,
) )
else: else:
relates_to_inbox_object = await get_inbox_object_by_ap_id( relates_to_inbox_object = await get_inbox_object_by_ap_id(
db_session, db_session,
ra.activity_object_ap_id, activity_ro.activity_object_ap_id,
) )
inbox_object = models.InboxObject( inbox_object = models.InboxObject(
server=urlparse(ra.ap_id).netloc, server=urlparse(activity_ro.ap_id).netloc,
actor_id=actor.id, actor_id=actor.id,
ap_actor_id=actor.ap_id, ap_actor_id=actor.ap_id,
ap_type=ra.ap_type, ap_type=activity_ro.ap_type,
ap_id=ra.ap_id, ap_id=activity_ro.ap_id,
ap_context=ra.ap_context, ap_context=activity_ro.ap_context,
ap_published_at=ap_published_at, ap_published_at=ap_published_at,
ap_object=ra.ap_object, ap_object=activity_ro.ap_object,
visibility=ra.visibility, visibility=activity_ro.visibility,
relates_to_inbox_object_id=relates_to_inbox_object.id relates_to_inbox_object_id=relates_to_inbox_object.id
if relates_to_inbox_object if relates_to_inbox_object
else None, else None,
relates_to_outbox_object_id=relates_to_outbox_object.id relates_to_outbox_object_id=relates_to_outbox_object.id
if relates_to_outbox_object if relates_to_outbox_object
else None, else None,
activity_object_ap_id=ra.activity_object_ap_id, activity_object_ap_id=activity_ro.activity_object_ap_id,
# Hide replies from the stream # Hide replies from the stream
is_hidden_from_stream=( is_hidden_from_stream=True,
True
if (ra.in_reply_to and not ra.in_reply_to.startswith(BASE_URL))
else False
), # TODO: handle mentions
) )
db_session.add(inbox_object) db_session.add(inbox_object)
await db_session.flush() await db_session.flush()
await db_session.refresh(inbox_object) await db_session.refresh(inbox_object)
if ra.ap_type == "Note": # TODO: handle create better if activity_ro.ap_type == "Create":
await _handle_create_activity(db_session, actor, inbox_object) await _handle_create_activity(db_session, actor, inbox_object)
elif ra.ap_type == "Update": elif activity_ro.ap_type == "Update":
pass pass
elif ra.ap_type == "Delete": elif activity_ro.ap_type == "Delete":
if relates_to_inbox_object: if relates_to_inbox_object:
await _handle_delete_activity(db_session, actor, relates_to_inbox_object) await _handle_delete_activity(db_session, actor, relates_to_inbox_object)
else: else:
# TODO(ts): handle delete actor # TODO(ts): handle delete actor
logger.info( logger.info(
f"Received a Delete for an unknown object: {ra.activity_object_ap_id}" "Received a Delete for an unknown object: "
f"{activity_ro.activity_object_ap_id}"
) )
elif ra.ap_type == "Follow": elif activity_ro.ap_type == "Follow":
await _handle_follow_follow_activity(db_session, actor, inbox_object) await _handle_follow_follow_activity(db_session, actor, inbox_object)
elif ra.ap_type == "Undo": elif activity_ro.ap_type == "Undo":
if relates_to_inbox_object: if relates_to_inbox_object:
await _handle_undo_activity( await _handle_undo_activity(
db_session, actor, inbox_object, relates_to_inbox_object db_session, actor, inbox_object, relates_to_inbox_object
) )
else: else:
logger.info("Received Undo for an unknown activity") logger.info("Received Undo for an unknown activity")
elif ra.ap_type in ["Accept", "Reject"]: elif activity_ro.ap_type in ["Accept", "Reject"]:
if not relates_to_outbox_object: if not relates_to_outbox_object:
logger.info( logger.info(
f"Received {raw_object['type']} for an unknown activity: " f"Received {raw_object['type']} for an unknown activity: "
f"{ra.activity_object_ap_id}" f"{activity_ro.activity_object_ap_id}"
) )
else: else:
if relates_to_outbox_object.ap_type == "Follow": if relates_to_outbox_object.ap_type == "Follow":
@ -729,18 +780,20 @@ async def save_to_inbox(db_session: AsyncSession, raw_object: ap.RawObject) -> N
"Received an Accept for an unsupported activity: " "Received an Accept for an unsupported activity: "
f"{relates_to_outbox_object.ap_type}" f"{relates_to_outbox_object.ap_type}"
) )
elif ra.ap_type == "EmojiReact": elif activity_ro.ap_type == "EmojiReact":
if not relates_to_outbox_object: if not relates_to_outbox_object:
logger.info( logger.info(
f"Received a like for an unknown activity: {ra.activity_object_ap_id}" "Received a reaction for an unknown activity: "
f"{activity_ro.activity_object_ap_id}"
) )
else: else:
# TODO(ts): support reactions # TODO(ts): support reactions
pass pass
elif ra.ap_type == "Like": elif activity_ro.ap_type == "Like":
if not relates_to_outbox_object: if not relates_to_outbox_object:
logger.info( logger.info(
f"Received a like for an unknown activity: {ra.activity_object_ap_id}" "Received a like for an unknown activity: "
f"{activity_ro.activity_object_ap_id}"
) )
else: else:
relates_to_outbox_object.likes_count = models.OutboxObject.likes_count + 1 relates_to_outbox_object.likes_count = models.OutboxObject.likes_count + 1
@ -752,7 +805,7 @@ async def save_to_inbox(db_session: AsyncSession, raw_object: ap.RawObject) -> N
inbox_object_id=inbox_object.id, inbox_object_id=inbox_object.id,
) )
db_session.add(notif) db_session.add(notif)
elif raw_object["type"] == "Announce": elif activity_ro.ap_type == "Announce":
if relates_to_outbox_object: if relates_to_outbox_object:
# This is an announce for a local object # This is an announce for a local object
relates_to_outbox_object.announces_count = ( relates_to_outbox_object.announces_count = (
@ -772,9 +825,9 @@ async def save_to_inbox(db_session: AsyncSession, raw_object: ap.RawObject) -> N
logger.info("Nothing to do, we already know about this object") logger.info("Nothing to do, we already know about this object")
else: else:
# Save it as an inbox object # Save it as an inbox object
if not ra.activity_object_ap_id: if not activity_ro.activity_object_ap_id:
raise ValueError("Should never happen") raise ValueError("Should never happen")
announced_raw_object = await ap.fetch(ra.activity_object_ap_id) announced_raw_object = await ap.fetch(activity_ro.activity_object_ap_id)
announced_actor = await fetch_actor( announced_actor = await fetch_actor(
db_session, ap.get_actor_id(announced_raw_object) db_session, ap.get_actor_id(announced_raw_object)
) )
@ -794,14 +847,14 @@ async def save_to_inbox(db_session: AsyncSession, raw_object: ap.RawObject) -> N
db_session.add(announced_inbox_object) db_session.add(announced_inbox_object)
await db_session.flush() await db_session.flush()
inbox_object.relates_to_inbox_object_id = announced_inbox_object.id inbox_object.relates_to_inbox_object_id = announced_inbox_object.id
elif ra.ap_type in ["Like", "Announce"]: elif activity_ro.ap_type in ["Like", "Announce"]:
if not relates_to_outbox_object: if not relates_to_outbox_object:
logger.info( logger.info(
f"Received {ra.ap_type} for an unknown activity: " f"Received {activity_ro.ap_type} for an unknown activity: "
f"{ra.activity_object_ap_id}" f"{activity_ro.activity_object_ap_id}"
) )
else: else:
if ra.ap_type == "Like": if activity_ro.ap_type == "Like":
# TODO(ts): notification # TODO(ts): notification
relates_to_outbox_object.likes_count = ( relates_to_outbox_object.likes_count = (
models.OutboxObject.likes_count + 1 models.OutboxObject.likes_count + 1
@ -814,7 +867,7 @@ async def save_to_inbox(db_session: AsyncSession, raw_object: ap.RawObject) -> N
inbox_object_id=inbox_object.id, inbox_object_id=inbox_object.id,
) )
db_session.add(notif) db_session.add(notif)
elif raw_object["type"] == "Announce": elif activity_ro.ap_type == "Announce":
# TODO(ts): notification # TODO(ts): notification
relates_to_outbox_object.announces_count = ( relates_to_outbox_object.announces_count = (
models.OutboxObject.announces_count + 1 models.OutboxObject.announces_count + 1

View file

@ -9,6 +9,8 @@ from Crypto.Signature import PKCS1_v1_5
from pyld import jsonld # type: ignore from pyld import jsonld # type: ignore
from app import activitypub as ap from app import activitypub as ap
from app.database import AsyncSession
from app.httpsig import _get_public_key
if typing.TYPE_CHECKING: if typing.TYPE_CHECKING:
from app.key import Key from app.key import Key
@ -52,7 +54,15 @@ def _doc_hash(doc: ap.RawObject) -> str:
return h.hexdigest() return h.hexdigest()
def verify_signature(doc: ap.RawObject, key: "Key") -> bool: async def verify_signature(
db_session: AsyncSession,
doc: ap.RawObject,
) -> bool:
if "signature" not in doc:
raise ValueError("No embedded signature")
key_id = doc["signature"]["creator"]
key = await _get_public_key(db_session, key_id)
to_be_signed = _options_hash(doc) + _doc_hash(doc) to_be_signed = _options_hash(doc) + _doc_hash(doc)
signature = doc["signature"]["signatureValue"] signature = doc["signature"]["signatureValue"]
signer = PKCS1_v1_5.new(key.pubkey or key.privkey) # type: ignore signer = PKCS1_v1_5.new(key.pubkey or key.privkey) # type: ignore

View file

@ -633,7 +633,7 @@ async def inbox(
logger.info(f"headers={request.headers}") logger.info(f"headers={request.headers}")
payload = await request.json() payload = await request.json()
logger.info(f"{payload=}") logger.info(f"{payload=}")
await save_to_inbox(db_session, payload) await save_to_inbox(db_session, payload, httpsig_info)
return Response(status_code=204) return Response(status_code=204)