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.
167 lines
5.8 KiB
Python
167 lines
5.8 KiB
Python
from django.test import TestCase
|
|
|
|
from app.bridge.store import store_bridge_data
|
|
from app.feeds.models import Post, Profile
|
|
|
|
FEED_URL = "http://localhost:8080/bridge/activitypub/@alice@m.example/"
|
|
|
|
|
|
def _data(posts=None, nick="alice", title="Alice"):
|
|
return {
|
|
"metadata": {
|
|
"title": title,
|
|
"nick": nick,
|
|
"description": "Bio",
|
|
"avatar": "https://m.example/avatar.png",
|
|
"link": "https://m.example/@alice",
|
|
},
|
|
"posts": posts if posts is not None else [],
|
|
}
|
|
|
|
|
|
def _post(post_id, content, tags=""):
|
|
return {"id": post_id, "content": content, "tags": tags, "language": ""}
|
|
|
|
|
|
class StoreBridgeDataTest(TestCase):
|
|
"""Test cases for storing normalized bridge data."""
|
|
|
|
def test_creates_profile_with_posts_and_link(self):
|
|
# Given: Normalized data with two posts
|
|
data = _data(
|
|
posts=[
|
|
_post("2025-05-01T10:00:00+00:00", "First"),
|
|
_post("2025-05-02T10:00:00+00:00", "Second", tags="emacs"),
|
|
]
|
|
)
|
|
|
|
# When: The data is stored
|
|
profile = store_bridge_data(FEED_URL, data)
|
|
|
|
# Then: Profile, posts and link exist in the database
|
|
self.assertEqual(profile.feed, FEED_URL)
|
|
self.assertEqual(profile.nick, "alice")
|
|
self.assertEqual(profile.posts.count(), 2)
|
|
self.assertEqual(
|
|
list(profile.links.values_list("url", flat=True)),
|
|
["https://m.example/@alice"],
|
|
)
|
|
post = profile.posts.get(post_id="2025-05-02T10:00:00+00:00")
|
|
self.assertEqual(post.tags, "emacs")
|
|
|
|
def test_nick_with_spaces_is_sanitized(self):
|
|
# Given: Metadata with a nick containing spaces
|
|
data = _data(nick="alice cooper")
|
|
|
|
# When: The data is stored
|
|
profile = store_bridge_data(FEED_URL, data)
|
|
|
|
# Then: The nick has no spaces
|
|
self.assertEqual(profile.nick, "alice_cooper")
|
|
|
|
def test_unchanged_data_is_not_rewritten(self):
|
|
# Given: Data already stored once
|
|
data = _data(posts=[_post("2025-05-01T10:00:00+00:00", "First")])
|
|
profile = store_bridge_data(FEED_URL, data)
|
|
version = profile.version
|
|
|
|
# When: The exact same data is stored again
|
|
profile_again = store_bridge_data(FEED_URL, data)
|
|
|
|
# Then: The version hash does not change and posts are intact
|
|
self.assertEqual(profile_again.version, version)
|
|
self.assertEqual(profile_again.posts.count(), 1)
|
|
|
|
def test_changed_post_content_is_updated(self):
|
|
# Given: A stored post whose content changes at the origin
|
|
post_id = "2025-05-01T10:00:00+00:00"
|
|
store_bridge_data(FEED_URL, _data(posts=[_post(post_id, "Original")]))
|
|
|
|
# When: The new version is stored
|
|
store_bridge_data(FEED_URL, _data(posts=[_post(post_id, "Edited")]))
|
|
|
|
# Then: The post content is updated in place
|
|
profile = Profile.objects.get(feed=FEED_URL)
|
|
self.assertEqual(profile.posts.get(post_id=post_id).content, "Edited")
|
|
self.assertEqual(profile.posts.count(), 1)
|
|
|
|
def test_posts_deleted_at_origin_are_removed_within_window(self):
|
|
# Given: Three stored posts
|
|
store_bridge_data(
|
|
FEED_URL,
|
|
_data(
|
|
posts=[
|
|
_post("2025-05-01T10:00:00+00:00", "A"),
|
|
_post("2025-05-02T10:00:00+00:00", "B"),
|
|
_post("2025-05-03T10:00:00+00:00", "C"),
|
|
]
|
|
),
|
|
)
|
|
|
|
# When: The origin no longer returns the middle post
|
|
store_bridge_data(
|
|
FEED_URL,
|
|
_data(
|
|
posts=[
|
|
_post("2025-05-01T10:00:00+00:00", "A"),
|
|
_post("2025-05-03T10:00:00+00:00", "C"),
|
|
]
|
|
),
|
|
)
|
|
|
|
# Then: The deleted post is removed
|
|
profile = Profile.objects.get(feed=FEED_URL)
|
|
ids = set(profile.posts.values_list("post_id", flat=True))
|
|
self.assertEqual(
|
|
ids, {"2025-05-01T10:00:00+00:00", "2025-05-03T10:00:00+00:00"}
|
|
)
|
|
|
|
def test_posts_older_than_fetched_window_are_preserved(self):
|
|
# Given: An old post stored from a previous, deeper fetch
|
|
store_bridge_data(
|
|
FEED_URL, _data(posts=[_post("2025-01-01T10:00:00+00:00", "Old")])
|
|
)
|
|
|
|
# When: A later fetch only returns newer posts
|
|
store_bridge_data(
|
|
FEED_URL, _data(posts=[_post("2025-05-01T10:00:00+00:00", "New")])
|
|
)
|
|
|
|
# Then: The old post outside the fetched window survives
|
|
profile = Profile.objects.get(feed=FEED_URL)
|
|
ids = set(profile.posts.values_list("post_id", flat=True))
|
|
self.assertEqual(
|
|
ids, {"2025-01-01T10:00:00+00:00", "2025-05-01T10:00:00+00:00"}
|
|
)
|
|
|
|
def test_no_webmentions_are_queued_for_bridged_posts(self):
|
|
# Given: A bridged post containing an external link
|
|
from app.feeds.models import OutgoingWebmention
|
|
|
|
data = _data(
|
|
posts=[
|
|
_post(
|
|
"2025-05-01T10:00:00+00:00",
|
|
"Look at [[https://external.example/page][this]]",
|
|
)
|
|
]
|
|
)
|
|
|
|
# When: The data is stored
|
|
store_bridge_data(FEED_URL, data)
|
|
|
|
# Then: No outgoing webmention is created
|
|
self.assertEqual(OutgoingWebmention.objects.count(), 0)
|
|
|
|
def test_post_created_at_comes_from_post_id(self):
|
|
# Given: A post with a known timestamp ID
|
|
store_bridge_data(
|
|
FEED_URL, _data(posts=[_post("2025-05-01T10:00:00+00:00", "A")])
|
|
)
|
|
|
|
# When: The post is read back
|
|
post = Post.objects.get(post_id="2025-05-01T10:00:00+00:00")
|
|
|
|
# Then: created_at matches the ID timestamp
|
|
self.assertEqual(post.created_at.isoformat(), "2025-05-01T10:00:00+00:00")
|