diff --git a/app/ap_object.py b/app/ap_object.py index 6c65b14..6d02bb2 100644 --- a/app/ap_object.py +++ b/app/ap_object.py @@ -2,9 +2,11 @@ import hashlib from datetime import datetime from functools import cached_property from typing import Any +from typing import Optional import pydantic from bs4 import BeautifulSoup # type: ignore +from loguru import logger from markdown import markdown from app import activitypub as ap @@ -74,6 +76,10 @@ class Object: def tags(self) -> list[ap.RawObject]: return ap.as_list(self.ap_object.get("tag", [])) + @property + def quote_url(self) -> str | None: + return self.ap_object.get("quoteUrl") + @cached_property def inlined_images(self) -> set[str]: image_urls: set[str] = set() @@ -278,9 +284,15 @@ class Attachment(BaseModel): class RemoteObject(Object): - def __init__(self, raw_object: ap.RawObject, actor: Actor): + def __init__( + self, + raw_object: ap.RawObject, + actor: Actor, + quoted_object: Object | None = None, + ): self._raw_object = raw_object self._actor = actor + self._quoted_object = quoted_object if self._actor.ap_id != ap.get_actor_id(self._raw_object): raise ValueError(f"Invalid actor {self._actor.ap_id}") @@ -290,6 +302,7 @@ class RemoteObject(Object): cls, raw_object: ap.RawObject, actor: Actor | None = None, + fetch_quoted_url: bool = True, ): # Pre-fetch the actor actor_id = ap.get_actor_id(raw_object) @@ -306,7 +319,17 @@ class RemoteObject(Object): ap_actor=await ap.fetch(ap.get_actor_id(raw_object)), ) - return cls(raw_object, _actor) + quoted_object: Object | None = None + if quote_url := raw_object.get("quoteUrl"): + try: + quoted_object = await RemoteObject.from_raw_object( + await ap.fetch(quote_url), + fetch_quoted_url=fetch_quoted_url, + ) + except Exception: + logger.exception(f"Failed to fetch {quote_url=}") + + return cls(raw_object, _actor, quoted_object=quoted_object) @property def og_meta(self) -> list[dict[str, Any]] | None: @@ -319,3 +342,9 @@ class RemoteObject(Object): @property def actor(self) -> Actor: return self._actor + + @property + def quoted_object(self) -> Optional["RemoteObject"]: + if self._quoted_object: + return self._quoted_object + return None diff --git a/app/boxes.py b/app/boxes.py index 400d5e5..7b17efe 100644 --- a/app/boxes.py +++ b/app/boxes.py @@ -1576,8 +1576,11 @@ async def _process_note_object( from_actor: models.Actor, ro: RemoteObject, forwarded_by_actor: models.Actor | None = None, -) -> None: - if parent_activity.ap_type not in ["Create", "Read"]: + process_quoted_url: bool = True, +) -> models.InboxObject: + if process_quoted_url and parent_activity.quote_url == ro.ap_id: + logger.info(f"Processing quoted URL for {parent_activity.ap_id}") + elif parent_activity.ap_type not in ["Create", "Read"]: raise ValueError(f"Unexpected parent activity {parent_activity.ap_id}") ap_published_at = now() @@ -1620,6 +1623,7 @@ async def _process_note_object( ), # We may already have some replies in DB replies_count=await _get_replies_count(db_session, ro.ap_id), + quoted_inbox_object_id=None, ) db_session.add(inbox_object) @@ -1700,6 +1704,28 @@ async def _process_note_object( ) db_session.add(notif) + await db_session.flush() + + if ro.quote_url and process_quoted_url: + try: + quoted_raw_object = await ap.fetch(ro.quote_url) + quoted_object_actor = await fetch_actor( + db_session, ap.get_actor_id(quoted_raw_object) + ) + quoted_ro = RemoteObject(quoted_raw_object, quoted_object_actor) + quoted_inbox_object = await _process_note_object( + db_session, + inbox_object, + from_actor=quoted_object_actor, + ro=quoted_ro, + process_quoted_url=False, + ) + inbox_object.quoted_inbox_object_id = quoted_inbox_object.id + except Exception: + logger.exception("Failed to process quoted object") + + return inbox_object + async def _handle_vote_answer( db_session: AsyncSession, diff --git a/app/models.py b/app/models.py index 0708341..62417d6 100644 --- a/app/models.py +++ b/app/models.py @@ -113,6 +113,18 @@ class InboxObject(Base, BaseObject): uselist=False, ) + quoted_inbox_object_id = Column( + Integer, + ForeignKey("inbox.id", name="fk_quoted_inbox_object_id"), + nullable=True, + ) + quoted_inbox_object: Mapped[Optional["InboxObject"]] = relationship( + "InboxObject", + foreign_keys=quoted_inbox_object_id, + remote_side=id, + uselist=False, + ) + undone_by_inbox_object_id = Column(Integer, ForeignKey("inbox.id"), nullable=True) # Link the oubox AP ID to allow undo without any extra query @@ -147,6 +159,12 @@ class InboxObject(Base, BaseObject): def is_from_inbox(self) -> bool: return True + @property + def quoted_object(self) -> Optional["InboxObject"]: + if self.quoted_inbox_object_id: + return self.quoted_inbox_object + return None + class OutboxObject(Base, BaseObject): __tablename__ = "outbox" @@ -281,6 +299,10 @@ class OutboxObject(Base, BaseObject): def is_from_outbox(self) -> bool: return True + @property + def quoted_object(self) -> Optional["InboxObject"]: + return None + class Follower(Base): __tablename__ = "follower" diff --git a/app/templates/utils.html b/app/templates/utils.html index 4814e54..7a6c9b4 100644 --- a/app/templates/utils.html +++ b/app/templates/utils.html @@ -395,6 +395,13 @@ {{ object.content | clean_html(object) | safe }} + {% if object.quoted_object %} +
+ {{ display_object(object.quoted_object) }} +
+ {% endif %} + + {% if object.ap_type == "Question" %} {% set can_vote = is_admin and object.is_from_inbox and not object.is_poll_ended and not object.voted_for_answers %} {% if can_vote %} diff --git a/app/utils/opengraph.py b/app/utils/opengraph.py index 02fdf87..c00ffc9 100644 --- a/app/utils/opengraph.py +++ b/app/utils/opengraph.py @@ -66,7 +66,8 @@ async def external_urls( tags_hrefs = set() for tag in ro.tags: if tag_href := tag.get("href"): - tags_hrefs.add(tag_href) + if tag_href and tag_href not in filter(None, [ro.quote_url]): + tags_hrefs.add(tag_href) if tag.get("type") == "Mention": if tag["href"] != LOCAL_ACTOR.ap_id: mentioned_actor = await fetch_actor(db_session, tag["href"])