Add support for custom webfinger domain
This commit is contained in:
parent
0b86df413a
commit
f34bce180c
4 changed files with 62 additions and 7 deletions
52
app/actor.py
52
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":
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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": [
|
||||
{
|
||||
|
|
|
@ -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:
|
||||
|
|
Loading…
Reference in a new issue