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.
186 lines
5.5 KiB
Python
186 lines
5.5 KiB
Python
"""
|
|
Persistence and orchestration for bridged accounts.
|
|
|
|
Normalized bridge data (from activitypub.py or rss.py) is stored in the
|
|
same Profile/Post tables used for real Org Social feeds, so every
|
|
existing relay endpoint (/profile/, /search/, /rss.xml, ...) works on
|
|
bridged accounts transparently.
|
|
|
|
Bridged posts never queue Webmentions nor publish notifications: the
|
|
content belongs to external authors, the relay only mirrors it.
|
|
"""
|
|
|
|
import hashlib
|
|
import logging
|
|
|
|
from dateutil import parser as date_parser
|
|
from django.utils import timezone
|
|
|
|
from app.feeds.models import Post, Profile, ProfileLink
|
|
|
|
from . import activitypub, rss
|
|
from .models import (
|
|
BridgedActivityPubAccount,
|
|
BridgedRssFeed,
|
|
activitypub_feed_url,
|
|
rss_feed_url,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def store_bridge_data(feed_url, data):
|
|
"""
|
|
Create or update the Profile and Posts of a bridged account.
|
|
|
|
Args:
|
|
feed_url: public URL of the virtual feed on this relay
|
|
data: normalized dict with "metadata" and "posts"
|
|
|
|
Returns:
|
|
Profile: the stored profile
|
|
"""
|
|
metadata = data["metadata"]
|
|
posts_data = data["posts"]
|
|
|
|
content_hash = hashlib.md5(f"{metadata}{posts_data}".encode()).hexdigest()
|
|
|
|
nick = " ".join((metadata.get("nick") or "").split()).replace(" ", "_")
|
|
profile_fields = {
|
|
"title": metadata.get("title", ""),
|
|
"nick": nick,
|
|
"description": metadata.get("description", ""),
|
|
"avatar": metadata.get("avatar", ""),
|
|
"language": metadata.get("language", ""),
|
|
"version": content_hash,
|
|
}
|
|
|
|
profile, created = Profile.objects.get_or_create(
|
|
feed=feed_url, defaults=profile_fields
|
|
)
|
|
|
|
if not created:
|
|
if profile.version == content_hash:
|
|
return profile
|
|
for field, value in profile_fields.items():
|
|
setattr(profile, field, value)
|
|
profile.save()
|
|
|
|
profile.links.all().delete()
|
|
link = (metadata.get("link") or "").strip()
|
|
if link:
|
|
ProfileLink.objects.create(profile=profile, url=link)
|
|
|
|
fetched_ids = set()
|
|
oldest_created_at = None
|
|
for post_data in posts_data:
|
|
post_id = post_data["id"]
|
|
try:
|
|
post_created_at = date_parser.parse(post_id)
|
|
except (ValueError, OverflowError):
|
|
logger.warning(f"Skipping bridged post with invalid ID: {post_id}")
|
|
continue
|
|
|
|
fetched_ids.add(post_id)
|
|
if oldest_created_at is None or post_created_at < oldest_created_at:
|
|
oldest_created_at = post_created_at
|
|
|
|
post, post_created = Post.objects.get_or_create(
|
|
profile=profile,
|
|
post_id=post_id,
|
|
defaults={
|
|
"content": post_data["content"],
|
|
"tags": post_data.get("tags", "")[:500],
|
|
"language": post_data.get("language", "")[:10],
|
|
"created_at": post_created_at,
|
|
},
|
|
)
|
|
if not post_created:
|
|
post.content = post_data["content"]
|
|
post.tags = post_data.get("tags", "")[:500]
|
|
post.language = post_data.get("language", "")[:10]
|
|
post.save()
|
|
|
|
# Posts deleted at the origin disappear from the fetched window;
|
|
# remove them, but never touch posts older than what was fetched
|
|
if fetched_ids and oldest_created_at is not None:
|
|
Post.objects.filter(profile=profile, created_at__gte=oldest_created_at).exclude(
|
|
post_id__in=fetched_ids
|
|
).delete()
|
|
|
|
return profile
|
|
|
|
|
|
def create_activitypub_bridge(handle):
|
|
"""
|
|
Fetch an ActivityPub account for the first time and create its bridge.
|
|
|
|
Raises BridgeError when the account cannot be fetched.
|
|
"""
|
|
user, instance = handle.split("@", 1)
|
|
data = activitypub.fetch_account(user, instance)
|
|
|
|
profile = store_bridge_data(activitypub_feed_url(handle), data)
|
|
|
|
now = timezone.now()
|
|
bridge = BridgedActivityPubAccount.objects.create(
|
|
handle=handle,
|
|
actor_url=data["actor_url"],
|
|
outbox_url=data["outbox_url"],
|
|
profile=profile,
|
|
last_refreshed_at=now,
|
|
last_accessed_at=now,
|
|
)
|
|
logger.info(f"Created ActivityPub bridge for @{handle}")
|
|
return bridge
|
|
|
|
|
|
def refresh_activitypub_bridge(bridge):
|
|
"""
|
|
Refetch a bridged ActivityPub account and update its stored data.
|
|
|
|
Raises BridgeError when the origin cannot be fetched.
|
|
"""
|
|
user, instance = bridge.handle.split("@", 1)
|
|
data = activitypub.fetch_account(user, instance)
|
|
store_bridge_data(bridge.feed_url, data)
|
|
|
|
bridge.actor_url = data["actor_url"] or bridge.actor_url
|
|
bridge.outbox_url = data["outbox_url"] or bridge.outbox_url
|
|
bridge.last_refreshed_at = timezone.now()
|
|
bridge.save()
|
|
|
|
|
|
def create_rss_bridge(source_url):
|
|
"""
|
|
Fetch an RSS/Atom feed for the first time and create its bridge.
|
|
|
|
Raises BridgeError when the feed cannot be fetched.
|
|
"""
|
|
data = rss.fetch_rss_feed(source_url)
|
|
|
|
profile = store_bridge_data(rss_feed_url(source_url), data)
|
|
|
|
now = timezone.now()
|
|
bridge = BridgedRssFeed.objects.create(
|
|
source_url=source_url,
|
|
profile=profile,
|
|
last_refreshed_at=now,
|
|
last_accessed_at=now,
|
|
)
|
|
logger.info(f"Created RSS bridge for {source_url}")
|
|
return bridge
|
|
|
|
|
|
def refresh_rss_bridge(bridge):
|
|
"""
|
|
Refetch a bridged RSS feed and update its stored data.
|
|
|
|
Raises BridgeError when the origin cannot be fetched.
|
|
"""
|
|
data = rss.fetch_rss_feed(bridge.source_url)
|
|
store_bridge_data(bridge.feed_url, data)
|
|
|
|
bridge.last_refreshed_at = timezone.now()
|
|
bridge.save()
|