diff --git a/app/templates/object.html b/app/templates/object.html index 0a0d75a..abd704d 100644 --- a/app/templates/object.html +++ b/app/templates/object.html @@ -22,7 +22,7 @@ {% macro display_replies_tree(replies_tree_node) %} {% if replies_tree_node.is_requested %} - {{ utils.display_object(replies_tree_node.ap_object, likes=likes, shares=shares, expanded=not replies_tree_node.is_root) }} + {{ utils.display_object(replies_tree_node.ap_object, likes=likes, shares=shares, webmentions=replies_tree_node.ap_object.webmentions or [], expanded=not replies_tree_node.is_root) }} {% else %} {{ utils.display_object(replies_tree_node.ap_object) }} {% endif %} diff --git a/app/templates/utils.html b/app/templates/utils.html index b940126..d21c362 100644 --- a/app/templates/utils.html +++ b/app/templates/utils.html @@ -234,7 +234,7 @@ {% endif %} {% endmacro %} -{% macro display_object_expanded(object, likes=[], shares=[]) %} +{% macro display_object_expanded(object, likes=[], shares=[], webmentions=[]) %}
@@ -307,6 +307,18 @@
{% endif %} + + {% if webmentions %} +
Webmentions +
+ {% for webmention in webmentions %} + + {{ webmention.actor_name }} + + {% endfor %} +
+
+ {% endif %} {% endif %} @@ -315,7 +327,7 @@ {% endmacro %} -{% macro display_object(object, likes=[], shares=[], expanded=False) %} +{% macro display_object(object, likes=[], shares=[], webmentions=[], expanded=False) %} {% if object.ap_type in ["Note", "Article", "Video"] %}
{{ display_actor(object.actor, {}, embedded=True) }} @@ -375,6 +387,13 @@ {{ object.announces_count }} share{{ object.announces_count | pluralize }} {% endif %} + + {% if object.webmentions %} +
  • + {{ object.webmentions | length }} webmention{{ object.webmentions | length | pluralize }} +
  • + {% endif %} + {% endif %} {% if (object.is_from_outbox or is_admin) and object.replies_count %} @@ -442,10 +461,10 @@ {% endif %} - {% if likes or shares %} -
    + {% if likes or shares or webmentions %} +
    {% if likes %} -
    Likes +
    Likes
    {% for like in likes %} @@ -457,7 +476,7 @@ {% endif %} {% if shares %} - {% endif %} diff --git a/app/webmentions.py b/app/webmentions.py index 3e4662a..d9769c6 100644 --- a/app/webmentions.py +++ b/app/webmentions.py @@ -1,17 +1,63 @@ +from dataclasses import asdict +from dataclasses import dataclass +from typing import Any +from typing import Optional + from bs4 import BeautifulSoup # type: ignore from fastapi import APIRouter +from fastapi import Depends from fastapi import HTTPException from fastapi import Request from fastapi.responses import JSONResponse from loguru import logger +from app.boxes import get_outbox_object_by_ap_id +from app.database import AsyncSession +from app.database import get_db_session +from app.database import now from app.utils import microformats from app.utils.url import check_url from app.utils.url import is_url_valid +from app.utils.url import make_abs router = APIRouter() +@dataclass +class Webmention: + actor_icon_url: str + actor_name: str + url: str + received_at: str + + @classmethod + def from_microformats( + cls, items: list[dict[str, Any]], url: str + ) -> Optional["Webmention"]: + for item in items: + if item["type"][0] == "h-card": + return cls( + actor_icon_url=make_abs( + item["properties"]["photo"][0], url + ), # type: ignore + actor_name=item["properties"]["name"][0], + url=url, + received_at=now().isoformat(), + ) + if item["type"][0] == "h-entry": + author = item["properties"]["author"][0] + return cls( + actor_icon_url=make_abs( + author["properties"]["photo"][0], url + ), # type: ignore + actor_name=author["properties"]["name"][0], + url=url, + received_at=now().isoformat(), + ) + + return None + + def is_source_containing_target(source_html: str, target_url: str) -> bool: soup = BeautifulSoup(source_html, "html5lib") for link in soup.find_all("a"): @@ -28,6 +74,7 @@ def is_source_containing_target(source_html: str, target_url: str) -> bool: @router.post("/webmentions") async def webmention_endpoint( request: Request, + db_session: AsyncSession = Depends(get_db_session), ) -> JSONResponse: form_data = await request.form() try: @@ -45,7 +92,11 @@ async def webmention_endpoint( logger.info(f"Received webmention {source=} {target=}") - # TODO: get outbox via ap_id (URL is the same as ap_id) + mentioned_object = await get_outbox_object_by_ap_id(db_session, target) + if not mentioned_object: + logger.info(f"Invalid target {target=}") + raise HTTPException(status_code=400, detail="Invalid target") + maybe_data_and_html = await microformats.fetch_and_parse(source) if not maybe_data_and_html: logger.info("failed to fetch source") @@ -57,6 +108,25 @@ async def webmention_endpoint( logger.warning("target not found in source") raise HTTPException(status_code=400, detail="target not found in source") - logger.info(f"{data=}") + try: + webmention = Webmention.from_microformats(data["items"], source) + if not webmention: + raise ValueError("Failed to fetch target data") + except Exception: + logger.warning("Failed build Webmention for {source=} with {data=}") + return JSONResponse(content={}, status_code=200) + + logger.info(f"{webmention=}") + + if mentioned_object.webmentions is None: + mentioned_object.webmentions = [asdict(webmention)] + else: + mentioned_object.webmentions = [asdict(webmention)] + [ + wm # type: ignore + for wm in mentioned_object.webmentions # type: ignore + if wm["url"] != source # type: ignore + ] + + await db_session.commit() return JSONResponse(content={}, status_code=200)