mirror of
https://github.com/tanrax/org-social-relay
synced 2026-07-25 02:34:58 +02:00
Expose external accounts as virtual Org Social feeds that any client
can follow with a plain #+FOLLOW: line:
- /bridge/activitypub/@{user}@{instance}/ bridges a Mastodon or any
ActivityPub account (WebFinger, actor and paginated outbox; only
public top-level notes, with CW, hashtags, language and attachments)
- /bridge/rss/?url={feed} bridges any RSS/Atom feed (entry title as
*** sub-heading, converted body and link to the original article)
Registration is implicit on first GET. Bridged data is stored in the
existing Profile/Post tables so /profile/, /search/ and the rest of
the API work on bridged feeds. Active bridges are refreshed every 15
minutes; bridges unrequested for 90 days are cleaned up.
Remote HTML is converted to Org text, escaping headline-like lines so
external content cannot inject posts. Fetches enforce the Webmention
SSRF protections, a 10s timeout and a 5MB size cap. Bridged posts
never queue Webmentions nor publish notifications.
216 lines
6.7 KiB
Python
216 lines
6.7 KiB
Python
"""
|
|
ActivityPub fetching for the bridge: WebFinger resolution, actor document
|
|
and outbox pagination, normalized into a bridge-agnostic structure.
|
|
|
|
Only public top-level notes are bridged: replies and boosts (Announce)
|
|
are skipped, mirroring what Mastodon exposes in its public RSS feeds.
|
|
Instances that require signed fetches (authorized fetch mode) will
|
|
answer 401/403 and the account cannot be bridged.
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
import re
|
|
from urllib.parse import quote
|
|
|
|
from dateutil import parser as date_parser
|
|
|
|
from .fetching import BridgeError, safe_get
|
|
from .html_to_org import html_to_org, html_to_text
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
HANDLE_RE = re.compile(r"^[a-z0-9._-]{1,64}@[a-z0-9-]+(\.[a-z0-9-]+)+$")
|
|
|
|
ACTIVITYPUB_ACCEPT = (
|
|
"application/activity+json, "
|
|
'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'
|
|
)
|
|
MAX_POSTS = 40
|
|
MAX_OUTBOX_PAGES = 3
|
|
|
|
|
|
def normalize_handle(raw_handle):
|
|
"""
|
|
Normalize a user@instance handle: lowercase and without the optional
|
|
leading @. Returns None if the result is not a valid handle.
|
|
"""
|
|
handle = (raw_handle or "").strip().lstrip("@").lower()
|
|
if not HANDLE_RE.match(handle):
|
|
return None
|
|
return handle
|
|
|
|
|
|
def _get_json(url, description):
|
|
try:
|
|
return json.loads(safe_get(url, accept=ACTIVITYPUB_ACCEPT))
|
|
except json.JSONDecodeError as e:
|
|
raise BridgeError(f"Invalid JSON in {description} at {url}") from e
|
|
|
|
|
|
def _resolve_actor_url(user, instance):
|
|
"""Resolve the actor URL of user@instance through WebFinger."""
|
|
resource = quote(f"acct:{user}@{instance}", safe="")
|
|
webfinger_url = f"https://{instance}/.well-known/webfinger?resource={resource}"
|
|
try:
|
|
document = json.loads(safe_get(webfinger_url, accept="application/jrd+json"))
|
|
except json.JSONDecodeError as e:
|
|
raise BridgeError(f"Invalid WebFinger response from {instance}") from e
|
|
|
|
for link in document.get("links", []):
|
|
if not isinstance(link, dict) or link.get("rel") != "self":
|
|
continue
|
|
if "activity+json" in (link.get("type") or "") and link.get("href"):
|
|
return link["href"]
|
|
for link in document.get("links", []):
|
|
if isinstance(link, dict) and link.get("rel") == "self" and link.get("href"):
|
|
return link["href"]
|
|
raise BridgeError(f"No ActivityPub actor found for {user}@{instance}")
|
|
|
|
|
|
def _format_post_id(published):
|
|
"""Convert an ActivityPub published date to an Org Social post ID."""
|
|
parsed = date_parser.parse(published)
|
|
if parsed.tzinfo is None:
|
|
return None
|
|
from datetime import timezone as dt_timezone
|
|
|
|
return parsed.astimezone(dt_timezone.utc).strftime("%Y-%m-%dT%H:%M:%S+00:00")
|
|
|
|
|
|
def _note_to_post(note):
|
|
"""Normalize an ActivityPub Note object into a bridge post dict."""
|
|
published = note.get("published")
|
|
if not published:
|
|
return None
|
|
try:
|
|
post_id = _format_post_id(published)
|
|
except (ValueError, OverflowError):
|
|
logger.warning(f"Skipping note with unparseable date: {published}")
|
|
return None
|
|
if post_id is None:
|
|
return None
|
|
|
|
parts = []
|
|
summary = note.get("summary")
|
|
if summary:
|
|
parts.append(f"CW: {html_to_text(summary)}")
|
|
body = html_to_org(note.get("content") or "")
|
|
if body:
|
|
parts.append(body)
|
|
attachment_links = []
|
|
for attachment in note.get("attachment", []):
|
|
if isinstance(attachment, dict) and attachment.get("url"):
|
|
attachment_links.append(f"[[{attachment['url']}]]")
|
|
if attachment_links:
|
|
parts.append("\n".join(attachment_links))
|
|
|
|
tags = []
|
|
for tag in note.get("tag", []):
|
|
if isinstance(tag, dict) and tag.get("type") == "Hashtag" and tag.get("name"):
|
|
tags.append(tag["name"].lstrip("#"))
|
|
|
|
language = ""
|
|
content_map = note.get("contentMap")
|
|
if isinstance(content_map, dict) and content_map:
|
|
language = next(iter(content_map.keys()), "")
|
|
|
|
return {
|
|
"id": post_id,
|
|
"content": "\n\n".join(parts),
|
|
"tags": " ".join(tags),
|
|
"language": language,
|
|
}
|
|
|
|
|
|
def _collect_outbox_posts(outbox_url):
|
|
"""Walk the outbox pages collecting public top-level notes."""
|
|
document = _get_json(outbox_url, "outbox")
|
|
|
|
if "orderedItems" in document:
|
|
page = document
|
|
else:
|
|
first = document.get("first")
|
|
if isinstance(first, str):
|
|
page = _get_json(first, "outbox page")
|
|
elif isinstance(first, dict):
|
|
page = first
|
|
else:
|
|
return []
|
|
|
|
posts = []
|
|
seen_ids = set()
|
|
pages_read = 0
|
|
|
|
while page and pages_read < MAX_OUTBOX_PAGES and len(posts) < MAX_POSTS:
|
|
pages_read += 1
|
|
for item in page.get("orderedItems", []):
|
|
if len(posts) >= MAX_POSTS:
|
|
break
|
|
if not isinstance(item, dict) or item.get("type") != "Create":
|
|
continue
|
|
note = item.get("object")
|
|
if not isinstance(note, dict) or note.get("type") != "Note":
|
|
continue
|
|
if note.get("inReplyTo"):
|
|
continue
|
|
post = _note_to_post(note)
|
|
if post is None or post["id"] in seen_ids:
|
|
continue
|
|
seen_ids.add(post["id"])
|
|
posts.append(post)
|
|
|
|
next_url = page.get("next")
|
|
if isinstance(next_url, str) and len(posts) < MAX_POSTS:
|
|
page = _get_json(next_url, "outbox page")
|
|
else:
|
|
page = None
|
|
|
|
return posts
|
|
|
|
|
|
def _actor_avatar(actor):
|
|
icon = actor.get("icon")
|
|
if isinstance(icon, list):
|
|
icon = icon[0] if icon else None
|
|
if isinstance(icon, dict):
|
|
return icon.get("url") or ""
|
|
if isinstance(icon, str):
|
|
return icon
|
|
return ""
|
|
|
|
|
|
def fetch_account(user, instance):
|
|
"""
|
|
Fetch an ActivityPub account and normalize it for the bridge.
|
|
|
|
Returns a dict with actor_url, outbox_url, metadata and posts.
|
|
Raises BridgeError when the account cannot be fetched.
|
|
"""
|
|
actor_url = _resolve_actor_url(user, instance)
|
|
actor = _get_json(actor_url, "actor document")
|
|
|
|
outbox_url = actor.get("outbox")
|
|
nick = actor.get("preferredUsername") or user
|
|
metadata = {
|
|
"title": actor.get("name") or f"{nick}@{instance}",
|
|
"nick": nick,
|
|
"description": html_to_text(actor.get("summary") or ""),
|
|
"avatar": _actor_avatar(actor),
|
|
"link": actor.get("url") or actor_url,
|
|
}
|
|
|
|
posts = []
|
|
if isinstance(outbox_url, str) and outbox_url:
|
|
posts = _collect_outbox_posts(outbox_url)
|
|
else:
|
|
logger.warning(f"Actor {actor_url} has no outbox")
|
|
outbox_url = ""
|
|
|
|
return {
|
|
"actor_url": actor_url,
|
|
"outbox_url": outbox_url,
|
|
"metadata": metadata,
|
|
"posts": posts,
|
|
}
|