Display webmentions on notes

This commit is contained in:
Thomas Sileo 2022-07-10 21:30:50 +02:00
parent 339757ebd2
commit 54334e1667
3 changed files with 111 additions and 9 deletions

View file

@ -22,7 +22,7 @@
{% macro display_replies_tree(replies_tree_node) %} {% macro display_replies_tree(replies_tree_node) %}
{% if replies_tree_node.is_requested %} {% 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 %} {% else %}
{{ utils.display_object(replies_tree_node.ap_object) }} {{ utils.display_object(replies_tree_node.ap_object) }}
{% endif %} {% endif %}

View file

@ -234,7 +234,7 @@
{% endif %} {% endif %}
{% endmacro %} {% endmacro %}
{% macro display_object_expanded(object, likes=[], shares=[]) %} {% macro display_object_expanded(object, likes=[], shares=[], webmentions=[]) %}
<div class="activity-expanded h-entry"> <div class="activity-expanded h-entry">
@ -307,6 +307,18 @@
</div> </div>
</div> </div>
{% endif %} {% endif %}
{% if webmentions %}
<div style="flex: 0 1 50%;max-width: 50%;">Webmentions
<div style="display: flex;column-gap: 20px;row-gap:20px;flex-wrap: wrap;margin-top:20px;">
{% for webmention in webmentions %}
<a href="{{ webmention.url }}" style="height:50px;" rel="noreferrer">
<img src="{{ webmention.actor_icon_url | media_proxy_url }}" alt="{{ webmention.actor_name }}" style="max-width:50px;">
</a>
{% endfor %}
</div>
</div>
{% endif %}
</div> </div>
{% endif %} {% endif %}
@ -315,7 +327,7 @@
{% endmacro %} {% 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"] %} {% if object.ap_type in ["Note", "Article", "Video"] %}
<div class="ap-object {% if expanded %}ap-object-expanded {% endif %}h-entry" id="{{ object.permalink_id }}"> <div class="ap-object {% if expanded %}ap-object-expanded {% endif %}h-entry" id="{{ object.permalink_id }}">
{{ display_actor(object.actor, {}, embedded=True) }} {{ display_actor(object.actor, {}, embedded=True) }}
@ -375,6 +387,13 @@
<a href="{{ object.url }}"><strong>{{ object.announces_count }}</strong> share{{ object.announces_count | pluralize }}</a> <a href="{{ object.url }}"><strong>{{ object.announces_count }}</strong> share{{ object.announces_count | pluralize }}</a>
</li> </li>
{% endif %} {% endif %}
{% if object.webmentions %}
<li>
<a href="{{ object.url }}"><strong>{{ object.webmentions | length }}</strong> webmention{{ object.webmentions | length | pluralize }}</a>
</li>
{% endif %}
{% endif %} {% endif %}
{% if (object.is_from_outbox or is_admin) and object.replies_count %} {% if (object.is_from_outbox or is_admin) and object.replies_count %}
@ -442,10 +461,10 @@
{% endif %} {% endif %}
{% if likes or shares %} {% if likes or shares or webmentions %}
<div style="display: flex;column-gap: 20px;margin-top:20px;"> <div style="display: flex;column-gap: 20px;flex-wrap: wrap;margin-top:20px;">
{% if likes %} {% if likes %}
<div style="flex: 0 1 50%;max-width: 50%;">Likes <div style="flex: 0 1 30%;max-width: 50%;">Likes
<div style="display: flex;column-gap: 20px;row-gap:20px;flex-wrap: wrap;margin-top:20px;"> <div style="display: flex;column-gap: 20px;row-gap:20px;flex-wrap: wrap;margin-top:20px;">
{% for like in likes %} {% for like in likes %}
<a href="{% if is_admin %}{{ url_for("admin_profile") }}?actor_id={{ like.actor.ap_id }}{% else %}{{ like.actor.url }}{% endif %}" title="{{ like.actor.handle }}" style="height:50px;" rel="noreferrer"> <a href="{% if is_admin %}{{ url_for("admin_profile") }}?actor_id={{ like.actor.ap_id }}{% else %}{{ like.actor.url }}{% endif %}" title="{{ like.actor.handle }}" style="height:50px;" rel="noreferrer">
@ -457,7 +476,7 @@
{% endif %} {% endif %}
{% if shares %} {% if shares %}
<div style="flex: 0 1 50%;max-width: 50%;">Shares <div style="flex: 0 1 30%;max-width: 50%;">Shares
<div style="display: flex;column-gap: 20px;row-gap:20px;flex-wrap: wrap;margin-top:20px;"> <div style="display: flex;column-gap: 20px;row-gap:20px;flex-wrap: wrap;margin-top:20px;">
{% for share in shares %} {% for share in shares %}
<a href="{% if is_admin %}{{ url_for("admin_profile") }}?actor_id={{ share.actor.ap_id }}{% else %}{{ share.actor.url }}{% endif %}" title="{{ share.actor.handle }}" style="height:50px;" rel="noreferrer"> <a href="{% if is_admin %}{{ url_for("admin_profile") }}?actor_id={{ share.actor.ap_id }}{% else %}{{ share.actor.url }}{% endif %}" title="{{ share.actor.handle }}" style="height:50px;" rel="noreferrer">
@ -467,6 +486,19 @@
</div> </div>
</div> </div>
{% endif %} {% endif %}
{% if webmentions %}
<div style="flex: 0 1 30%;max-width: 50%;">Webmentions
<div style="display: flex;column-gap: 20px;row-gap:20px;flex-wrap: wrap;margin-top:20px;">
{% for webmention in webmentions %}
<a href="{{ webmention.url }}" title="{{ webmention.actor_name }}" style="height:50px;" rel="noreferrer">
<img src="{{ webmention.actor_icon_url | media_proxy_url }}" alt="{{ webmention.actor_name }}" style="max-width:50px;">
</a>
{% endfor %}
</div>
</div>
{% endif %}
</div> </div>
{% endif %} {% endif %}

View file

@ -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 bs4 import BeautifulSoup # type: ignore
from fastapi import APIRouter from fastapi import APIRouter
from fastapi import Depends
from fastapi import HTTPException from fastapi import HTTPException
from fastapi import Request from fastapi import Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from loguru import logger 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 import microformats
from app.utils.url import check_url from app.utils.url import check_url
from app.utils.url import is_url_valid from app.utils.url import is_url_valid
from app.utils.url import make_abs
router = APIRouter() 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: def is_source_containing_target(source_html: str, target_url: str) -> bool:
soup = BeautifulSoup(source_html, "html5lib") soup = BeautifulSoup(source_html, "html5lib")
for link in soup.find_all("a"): 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") @router.post("/webmentions")
async def webmention_endpoint( async def webmention_endpoint(
request: Request, request: Request,
db_session: AsyncSession = Depends(get_db_session),
) -> JSONResponse: ) -> JSONResponse:
form_data = await request.form() form_data = await request.form()
try: try:
@ -45,7 +92,11 @@ async def webmention_endpoint(
logger.info(f"Received webmention {source=} {target=}") 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) maybe_data_and_html = await microformats.fetch_and_parse(source)
if not maybe_data_and_html: if not maybe_data_and_html:
logger.info("failed to fetch source") logger.info("failed to fetch source")
@ -57,6 +108,25 @@ async def webmention_endpoint(
logger.warning("target not found in source") logger.warning("target not found in source")
raise HTTPException(status_code=400, detail="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) return JSONResponse(content={}, status_code=200)