From f34bce180cbb5af5cea113bf26762f4efe9d7496 Mon Sep 17 00:00:00 2001 From: Thomas Sileo Date: Mon, 19 Dec 2022 20:49:19 +0100 Subject: [PATCH] Add support for custom webfinger domain --- app/actor.py | 52 +++++++++++++++++++++++++++++++++++++++++---- app/config.py | 6 ++++++ app/main.py | 3 ++- tests/test_actor.py | 8 +++++-- 4 files changed, 62 insertions(+), 7 deletions(-) diff --git a/app/actor.py b/app/actor.py index 271b88b..7d35bbe 100644 --- a/app/actor.py +++ b/app/actor.py @@ -6,6 +6,7 @@ from functools import cached_property from typing import Union from urllib.parse import urlparse +import httpx from loguru import logger from sqlalchemy import select from sqlalchemy.orm import joinedload @@ -13,6 +14,9 @@ from sqlalchemy.orm import joinedload from app import activitypub as ap from app import media from app.config import BASE_URL +from app.config import USER_AGENT +from app.config import USERNAME +from app.config import WEBFINGER_DOMAIN from app.database import AsyncSession from app.utils.datetime import as_utc from app.utils.datetime import now @@ -27,7 +31,38 @@ def _handle(raw_actor: ap.RawObject) -> str: if not domain.hostname: raise ValueError(f"Invalid actor ID {ap_id}") - return f'@{raw_actor["preferredUsername"]}@{domain.hostname}' # type: ignore + handle = f'@{raw_actor["preferredUsername"]}@{domain.hostname}' # type: ignore + + # TODO: cleanup this + # Next, check for custom webfinger domains + resp: httpx.Response | None = None + for url in { + f"https://{domain.hostname}/.well-known/webfinger", + f"https://{domain.hostname}/.well-known/webfinger", + }: + try: + logger.info(f"Webfinger {handle} at {url}") + resp = httpx.get( + url, + params={"resource": f"acct:{handle[1:]}"}, + headers={ + "User-Agent": USER_AGENT, + }, + follow_redirects=True, + ) + resp.raise_for_status() + break + except Exception: + logger.exception(f"Failed to webfinger {handle}") + + if resp: + try: + json_resp = resp.json() + if json_resp.get("subject", "").startswith("acct:"): + return json_resp["subject"].removeprefix("acct:") + except Exception: + logger.exception(f"Failed to parse webfinger response for {handle}") + return handle class Actor: @@ -61,7 +96,7 @@ class Actor: return self.name return self.preferred_username - @property + @cached_property def handle(self) -> str: return _handle(self.ap_actor) @@ -143,13 +178,18 @@ class Actor: class RemoteActor(Actor): - def __init__(self, ap_actor: ap.RawObject) -> None: + def __init__(self, ap_actor: ap.RawObject, handle: str | None = None) -> None: if (ap_type := ap_actor.get("type")) not in ap.ACTOR_TYPES: raise ValueError(f"Unexpected actor type: {ap_type}") self._ap_actor = ap_actor self._ap_type = ap_type + if handle is None: + handle = _handle(ap_actor) + + self._handle = handle + @property def ap_actor(self) -> ap.RawObject: return self._ap_actor @@ -162,8 +202,12 @@ class RemoteActor(Actor): def is_from_db(self) -> bool: return False + @property + def handle(self) -> str: + return self._handle -LOCAL_ACTOR = RemoteActor(ap_actor=ap.ME) + +LOCAL_ACTOR = RemoteActor(ap_actor=ap.ME, handle=f"@{USERNAME}@{WEBFINGER_DOMAIN}") async def save_actor(db_session: AsyncSession, ap_actor: ap.RawObject) -> "ActorModel": diff --git a/app/config.py b/app/config.py index 47b7b31..fe691d7 100644 --- a/app/config.py +++ b/app/config.py @@ -117,6 +117,8 @@ class Config(pydantic.BaseModel): custom_content_security_policy: str | None = None + webfinger_domain: str | None = None + # Config items to make tests easier sqlalchemy_database: str | None = None key_path: str | None = None @@ -168,6 +170,10 @@ ID = f"{_SCHEME}://{DOMAIN}" if CONFIG.id: ID = CONFIG.id USERNAME = CONFIG.username + +# Allow to use @handle@webfinger-domain.tld while hosting the server at domain.tld +WEBFINGER_DOMAIN = CONFIG.webfinger_domain or DOMAIN + MANUALLY_APPROVES_FOLLOWERS = CONFIG.manually_approves_followers HIDES_FOLLOWERS = CONFIG.hides_followers HIDES_FOLLOWING = CONFIG.hides_following diff --git a/app/main.py b/app/main.py index 0393b2e..8b55e6a 100644 --- a/app/main.py +++ b/app/main.py @@ -62,6 +62,7 @@ from app.config import DOMAIN from app.config import ID from app.config import USER_AGENT from app.config import USERNAME +from app.config import WEBFINGER_DOMAIN from app.config import is_activitypub_requested from app.config import verify_csrf_token from app.customization import get_custom_router @@ -1260,7 +1261,7 @@ async def wellknown_webfinger(resource: str) -> JSONResponse: raise HTTPException(status_code=404) out = { - "subject": f"acct:{USERNAME}@{DOMAIN}", + "subject": f"acct:{USERNAME}@{WEBFINGER_DOMAIN}", "aliases": [ID], "links": [ { diff --git a/tests/test_actor.py b/tests/test_actor.py index 587b1b4..487f28f 100644 --- a/tests/test_actor.py +++ b/tests/test_actor.py @@ -20,12 +20,16 @@ async def test_fetch_actor(async_db_session: AsyncSession, respx_mock) -> None: public_key="pk", ) respx_mock.get(ra.ap_id).mock(return_value=httpx.Response(200, json=ra.ap_actor)) + respx_mock.get( + "https://example.com/.well-known/webfinger", + params={"resource": "acct%3Atoto%40example.com"}, + ).mock(return_value=httpx.Response(200, json={"subject": "acct:toto@example.com"})) # When fetching this actor for the first time saved_actor = await fetch_actor(async_db_session, ra.ap_id) # Then it has been fetched and saved in DB - assert respx.calls.call_count == 1 + assert respx.calls.call_count == 2 assert ( await async_db_session.execute(select(models.Actor)) ).scalar_one().ap_id == saved_actor.ap_id @@ -38,7 +42,7 @@ async def test_fetch_actor(async_db_session: AsyncSession, respx_mock) -> None: assert ( await async_db_session.execute(select(func.count(models.Actor.id))) ).scalar_one() == 1 - assert respx.calls.call_count == 1 + assert respx.calls.call_count == 2 def test_sqlalchemy_factory(db: Session) -> None: