From c6bc53ce5455553aebba9830a589fe3d3ac7d2b1 Mon Sep 17 00:00:00 2001 From: Thomas Sileo Date: Sun, 10 Jul 2022 19:19:55 +0200 Subject: [PATCH] Bootstrap webmention endpoint --- app/main.py | 2 ++ app/templates/object.html | 1 + app/utils/indieauth.py | 43 +++++++++------------------ app/utils/microformats.py | 25 ++++++++++++++++ app/webmentions.py | 62 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 104 insertions(+), 29 deletions(-) create mode 100644 app/utils/microformats.py create mode 100644 app/webmentions.py diff --git a/app/main.py b/app/main.py index 89c57d8..fd339e7 100644 --- a/app/main.py +++ b/app/main.py @@ -38,6 +38,7 @@ from app import httpsig from app import indieauth from app import models from app import templates +from app import webmentions from app.actor import LOCAL_ACTOR from app.actor import get_actors_metadata from app.boxes import public_outbox_objects_count @@ -82,6 +83,7 @@ app.mount("/static", StaticFiles(directory="app/static"), name="static") app.include_router(admin.router, prefix="/admin") app.include_router(admin.unauthenticated_router, prefix="/admin") app.include_router(indieauth.router) +app.include_router(webmentions.router) logger.configure(extra={"request_id": "no_req_id"}) logger.remove() diff --git a/app/templates/object.html b/app/templates/object.html index 1d3a3f2..0a0d75a 100644 --- a/app/templates/object.html +++ b/app/templates/object.html @@ -3,6 +3,7 @@ {% block head %} {% if outbox_object %} + diff --git a/app/utils/indieauth.py b/app/utils/indieauth.py index 22f489b..9143e29 100644 --- a/app/utils/indieauth.py +++ b/app/utils/indieauth.py @@ -1,11 +1,7 @@ from dataclasses import dataclass from typing import Any -import httpx -import mf2py # type: ignore -from loguru import logger - -from app import config +from app.utils import microformats from app.utils.url import make_abs @@ -26,31 +22,20 @@ def _get_prop(props: dict[str, Any], name: str, default=None) -> Any: async def get_client_id_data(url: str) -> IndieAuthClient | None: - async with httpx.AsyncClient() as client: - try: - resp = await client.get( - url, - headers={ - "User-Agent": config.USER_AGENT, - }, - follow_redirects=True, - ) - resp.raise_for_status() - except (httpx.HTTPError, httpx.HTTPStatusError): - logger.exception(f"Failed to discover webmention endpoint for {url}") - return None + maybe_data_and_html = await microformats.fetch_and_parse(url) + if maybe_data_and_html is not None: + data: dict[str, Any] = maybe_data_and_html[0] - data = mf2py.parse(doc=resp.text) - for item in data["items"]: - if "h-x-app" in item["type"] or "h-app" in item["type"]: - props = item.get("properties", {}) - print(props) - logo = _get_prop(props, "logo") - return IndieAuthClient( - logo=make_abs(logo, url) if logo else None, - name=_get_prop(props, "name"), - url=_get_prop(props, "url", url), - ) + for item in data["items"]: + if "h-x-app" in item["type"] or "h-app" in item["type"]: + props = item.get("properties", {}) + print(props) + logo = _get_prop(props, "logo") + return IndieAuthClient( + logo=make_abs(logo, url) if logo else None, + name=_get_prop(props, "name"), + url=_get_prop(props, "url", url), + ) return IndieAuthClient( logo=None, diff --git a/app/utils/microformats.py b/app/utils/microformats.py new file mode 100644 index 0000000..937e4e8 --- /dev/null +++ b/app/utils/microformats.py @@ -0,0 +1,25 @@ +from typing import Any + +import httpx +import mf2py # type: ignore +from loguru import logger + +from app import config + + +async def fetch_and_parse(url: str) -> tuple[dict[str, Any], str] | None: + async with httpx.AsyncClient() as client: + try: + resp = await client.get( + url, + headers={ + "User-Agent": config.USER_AGENT, + }, + follow_redirects=True, + ) + resp.raise_for_status() + except (httpx.HTTPError, httpx.HTTPStatusError): + logger.exception(f"Failed to discover webmention endpoint for {url}") + return None + + return mf2py.parse(doc=resp.text), resp.text diff --git a/app/webmentions.py b/app/webmentions.py new file mode 100644 index 0000000..3e4662a --- /dev/null +++ b/app/webmentions.py @@ -0,0 +1,62 @@ +from bs4 import BeautifulSoup # type: ignore +from fastapi import APIRouter +from fastapi import HTTPException +from fastapi import Request +from fastapi.responses import JSONResponse +from loguru import logger + +from app.utils import microformats +from app.utils.url import check_url +from app.utils.url import is_url_valid + +router = APIRouter() + + +def is_source_containing_target(source_html: str, target_url: str) -> bool: + soup = BeautifulSoup(source_html, "html5lib") + for link in soup.find_all("a"): + h = link.get("href") + if not is_url_valid(h): + continue + + if h == target_url: + return True + + return False + + +@router.post("/webmentions") +async def webmention_endpoint( + request: Request, +) -> JSONResponse: + form_data = await request.form() + try: + source = form_data["source"] + target = form_data["target"] + + if source == target: + raise ValueError("source URL is the same as target") + + check_url(source) + check_url(target) + except Exception: + logger.exception("Invalid webmention request") + raise HTTPException(status_code=400, detail="Invalid payload") + + logger.info(f"Received webmention {source=} {target=}") + + # TODO: get outbox via ap_id (URL is the same as ap_id) + maybe_data_and_html = await microformats.fetch_and_parse(source) + if not maybe_data_and_html: + logger.info("failed to fetch source") + raise HTTPException(status_code=400, detail="failed to fetch source") + + data, html = maybe_data_and_html + + if not is_source_containing_target(html, target): + logger.warning("target not found in source") + raise HTTPException(status_code=400, detail="target not found in source") + + logger.info(f"{data=}") + + return JSONResponse(content={}, status_code=200)