Files
andros 349a5e1376 Add ActivityPub and RSS bridges serving virtual social.org feeds
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.
2026-07-17 15:26:41 +02:00

140 lines
4.0 KiB
Python

"""
RSS/Atom fetching for the bridge, normalized into the same structure
used by the ActivityPub bridge.
Each entry becomes a post whose body is the entry title as an Org
sub-heading (***), the converted entry content, and a link to the
original article.
"""
import calendar
import logging
import re
from datetime import datetime, timezone as dt_timezone
from urllib.parse import urlparse
import feedparser
from .fetching import BridgeError, safe_get
from .html_to_org import html_to_org
logger = logging.getLogger(__name__)
MAX_POSTS = 40
_NICK_ALLOWED_RE = re.compile(r"[^A-Za-z0-9_-]+")
def _derive_nick(title, source_url):
"""Build a spaces-free nick from the feed title or the feed host."""
candidate = _NICK_ALLOWED_RE.sub("_", (title or "").strip()).strip("_")
if candidate:
return candidate
host = urlparse(source_url).hostname or "feed"
return host.replace(".", "_")
def _entry_post_id(entry):
"""Org Social post ID (UTC RFC 3339) from the entry date, or None."""
parsed_time = entry.get("published_parsed") or entry.get("updated_parsed")
if not parsed_time:
return None
timestamp = calendar.timegm(parsed_time)
return datetime.fromtimestamp(timestamp, dt_timezone.utc).strftime(
"%Y-%m-%dT%H:%M:%S+00:00"
)
def _next_free_id(post_id, seen_ids):
"""
Entries sharing the same timestamp (e.g. date-only feeds) get IDs
shifted forward one second at a time so no post is lost.
"""
while post_id in seen_ids:
parsed = datetime.strptime(post_id, "%Y-%m-%dT%H:%M:%S+00:00")
shifted = parsed.timestamp() + 1
post_id = datetime.fromtimestamp(shifted, dt_timezone.utc).strftime(
"%Y-%m-%dT%H:%M:%S+00:00"
)
return post_id
def _entry_body(entry):
contents = entry.get("content") or []
if contents and contents[0].get("value"):
html = contents[0]["value"]
else:
html = entry.get("summary") or ""
return html_to_org(html)
def _entry_to_post(entry, seen_ids):
post_id = _entry_post_id(entry)
if post_id is None:
logger.warning(
f"Skipping RSS entry without date: {entry.get('link', '(no link)')}"
)
return None
post_id = _next_free_id(post_id, seen_ids)
parts = []
title = " ".join((entry.get("title") or "").split())
if title:
parts.append(f"*** {title}")
body = _entry_body(entry)
if body:
parts.append(body)
link = (entry.get("link") or "").strip()
if link and "]" not in link:
parts.append(f"[[{link}]]")
tags = " ".join(
tag.get("term", "").strip().replace(" ", "-")
for tag in entry.get("tags", [])
if tag.get("term")
)
return {
"id": post_id,
"content": "\n\n".join(parts),
"tags": tags,
"language": "",
}
def fetch_rss_feed(source_url):
"""
Fetch an RSS/Atom feed and normalize it for the bridge.
Returns a dict with metadata and posts.
Raises BridgeError when the feed cannot be fetched or parsed.
"""
raw = safe_get(source_url)
document = feedparser.parse(raw)
feed_info = document.get("feed", {})
if not document.get("entries") and not feed_info.get("title"):
raise BridgeError(f"Not a valid RSS/Atom feed: {source_url}")
title = " ".join((feed_info.get("title") or "").split())
image = feed_info.get("image") or {}
metadata = {
"title": title or source_url,
"nick": _derive_nick(title, source_url),
"description": " ".join((feed_info.get("subtitle") or "").split()),
"avatar": image.get("href", ""),
"link": feed_info.get("link") or source_url,
"language": (feed_info.get("language") or "").split("-")[0],
}
posts = []
seen_ids = set()
for entry in document.get("entries", [])[:MAX_POSTS]:
post = _entry_to_post(entry, seen_ids)
if post is None:
continue
seen_ids.add(post["id"])
posts.append(post)
return {"metadata": metadata, "posts": posts}