drop OStatus! it's been unused for years

fixes #282
pull/354/head
Ryan Barrett 2023-01-04 19:22:11 -08:00
rodzic 45f4bd71a2
commit fe5c3947a8
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: 6BE31FDF4776E9D4
13 zmienionych plików z 46 dodań i 635 usunięć

Wyświetl plik

@ -1,14 +1,11 @@
<img src="https://raw.github.com/snarfed/bridgy-fed/main/static/bridgy_fed_logo.png" width="120" /> [Bridgy Fed](https://fed.brid.gy/) [![Circle CI](https://circleci.com/gh/snarfed/bridgy-fed.svg?style=svg)](https://circleci.com/gh/snarfed/bridgy-fed) [![Coverage Status](https://coveralls.io/repos/github/snarfed/bridgy-fed/badge.svg?branch=main)](https://coveralls.io/github/snarfed/bridgy-fed?branch=main)
===
Bridgy Fed connects your web site to [Mastodon](https://joinmastodon.org) and the [fediverse](https://en.wikipedia.org/wiki/Fediverse) via [ActivityPub](https://activitypub.rocks/), [OStatus](https://en.wikipedia.org/wiki/OStatus), [webmentions](https://webmention.net/), and [microformats2](https://microformats.org/wiki/microformats2). Your site gets its own fediverse profile, posts and avatar and header and all. Bridgy Fed translates likes, reposts, mentions, follows, and more back and forth. [See the user docs](https://fed.brid.gy/docs) for more details.
Bridgy Fed connects your web site to [Mastodon](https://joinmastodon.org) and the [fediverse](https://en.wikipedia.org/wiki/Fediverse) via [ActivityPub](https://activitypub.rocks/), [webmentions](https://webmention.net/), and [microformats2](https://microformats.org/wiki/microformats2). Your site gets its own fediverse profile, posts and avatar and header and all. Bridgy Fed translates likes, reposts, mentions, follows, and more back and forth. [See the user docs](https://fed.brid.gy/docs) for more details.
https://fed.brid.gy/
Original design docs:
* https://snarfed.org/indieweb-activitypub-bridge
* https://snarfed.org/indieweb-ostatus-bridge
Also see the [original](https://snarfed.org/indieweb-activitypub-bridge) [design](https://snarfed.org/indieweb-ostatus-bridge) blog posts.
License: This project is placed in the public domain.
@ -94,23 +91,6 @@ Here are in progress notes on how I'm testing interoperability with various fede
* [Pleroma](https://pleroma.social/)
* [snarfed@cawfee.club](https://cawfee.club/snarfed)
### OStatus / Salmon
* [Friendica](http://friendi.ca/)
* [snarfed@libranet.de](https://libranet.de/profile/snarfed)
* Example post: [HTML](https://libranet.de/display/snarfed/3453879) ([alternate link](https://libranet.de/display/0b6b25a814599c43b430890795887058)), [Atom](https://libranet.de/display/snarfed/3453879.atom)
* Atom has Salmon link rel, `author.dfrn:handle` is user URI (dfrn is http://purl.org/macgirvin/dfrn/1.0))
* [GNU Social](https://gnu.io/social/) (née StatusNet)
* [snarfed@quitter.se](https://quitter.se/snarfed)
* Example post: [HTML](https://quitter.se/notice/17459493), [Atom](https://quitter.se/api/statuses/show/17459493.atom)
* Atom has _no_ Salmon link rels! `author.name` is username (snarfed)
* [Hubzilla](https://project.hubzilla.org/)
* [snarfed@lastauth.com](https://lastauth.com/channel/snarfed)
* Example post: [HTML](https://lastauth.com/channel/snarfed/?mid=7cfa12e54cf97aaed3b0bb185651ae37a1e24027fbf3e845fab261e108392707@lastauth.com)
* Only has Atom `link rel="alternate"` for [full feed](https://lastauth.com/feed/snarfed?f=&top=1), not individual post :/
* Atom feed has Salmon link rels inside top level `feed`, not in individual `entry`s
* Atom entries have `author.name` as username (snarfed)
Stats
---

2
app.py
Wyświetl plik

@ -35,4 +35,4 @@ cache = Cache(app)
util.set_user_agent('Bridgy Fed (https://fed.brid.gy/)')
import activitypub, add_webmention, follow, pages, redirect, render, salmon, superfeedr, webfinger, webmention
import activitypub, add_webmention, follow, pages, redirect, render, superfeedr, webfinger, webmention

Wyświetl plik

@ -228,7 +228,7 @@ def remove_blocklisted(urls):
def send_webmentions(activity_wrapped, proxy=None, **activity_props):
"""Sends webmentions for an incoming Salmon slap or ActivityPub inbox delivery.
"""Sends webmentions for an incoming ActivityPub inbox delivery.
Args:
activity_wrapped: dict, AS1 activity
activity_props: passed through to the newly created Activity entities

Wyświetl plik

@ -1,4 +1,5 @@
"""Datastore model classes."""
import base64
import difflib
import logging
import urllib.parse
@ -6,8 +7,9 @@ import urllib.parse
import requests
from werkzeug.exceptions import BadRequest, NotFound
from Crypto import Random
from Crypto.PublicKey import RSA
from django_salmon import magicsigs
from Crypto.Util import number
from flask import request
from google.cloud import ndb
from granary import as2, microformats2
@ -22,18 +24,33 @@ WWW_DOMAINS = frozenset((
'www.jvt.me',
))
KEY_BITS = 1024
logger = logging.getLogger(__name__)
def base64_to_long(x):
"""Converts x from URL safe base64 encoding to a long integer.
Originally from django_salmon.magicsigs.
"""
return number.bytes_to_long(base64.urlsafe_b64decode(x))
def long_to_base64(x):
"""Converts x from a long integer to base64 URL safe encoding.
Originally from django_salmon.magicsigs.
"""
return base64.urlsafe_b64encode(number.long_to_bytes(x))
class User(StringIdModel):
"""Stores a Bridgy Fed user.
The key name is the domain. The key pair is used for both ActivityPub HTTP
Signatures and Salmon Magic Signatures.
The key name is the domain. The key pair is used for ActivityPub HTTP Signatures.
https://tools.ietf.org/html/draft-cavage-http-signatures-07
http://salmon-protocol.googlecode.com/svn/trunk/draft-panzer-magicsig-01.html
http://salmon-protocol.googlecode.com/svn/trunk/draft-panzer-salmon-00.html
The key pair's modulus and exponent properties are all encoded as base64url
(ie URL-safe base64) strings as described in RFC 4648 and section 5.1 of the
@ -62,11 +79,16 @@ class User(StringIdModel):
user = User.get_by_id(domain)
if not user:
# originally from django_salmon.magicsigs
# this uses urandom(), and does nontrivial math, so it can take a
# while depending on the amount of randomness available.
pubexp, mod, privexp = magicsigs.generate()
user = User(id=domain, mod=mod, public_exponent=pubexp,
private_exponent=privexp, **kwargs)
rng = Random.new().read
key = RSA.generate(KEY_BITS, rng)
user = User(id=domain,
mod=long_to_base64(key.n),
public_exponent=long_to_base64(key.e),
private_exponent=long_to_base64(key.d),
**kwargs)
user.put()
elif user.use_instead:
user = user.use_instead.get()
@ -79,15 +101,15 @@ class User(StringIdModel):
def public_pem(self):
"""Returns: bytes"""
rsa = RSA.construct((magicsigs.base64_to_long(str(self.mod)),
magicsigs.base64_to_long(str(self.public_exponent))))
rsa = RSA.construct((base64_to_long(str(self.mod)),
base64_to_long(str(self.public_exponent))))
return rsa.exportKey(format='PEM')
def private_pem(self):
"""Returns: bytes"""
rsa = RSA.construct((magicsigs.base64_to_long(str(self.mod)),
magicsigs.base64_to_long(str(self.public_exponent)),
magicsigs.base64_to_long(str(self.private_exponent))))
rsa = RSA.construct((base64_to_long(str(self.mod)),
base64_to_long(str(self.public_exponent)),
base64_to_long(str(self.private_exponent))))
return rsa.exportKey(format='PEM')
def username(self):

Wyświetl plik

@ -1,4 +1,3 @@
git+https://github.com/snarfed/django-salmon.git#egg=django_salmon
git+https://github.com/snarfed/httpsig.git@HTTPSignatureAuth-sign-header#egg=httpsig
git+https://github.com/snarfed/oauth-dropins.git#egg=oauth_dropins
git+https://github.com/snarfed/granary.git#egg=granary

Wyświetl plik

@ -1,82 +0,0 @@
"""Handles requests for Salmon endpoints: actors, inbox, etc.
https://github.com/salmon-protocol/salmon-protocol/blob/master/draft-panzer-salmon-00.html
https://github.com/salmon-protocol/salmon-protocol/blob/master/draft-panzer-magicsig-01.html
"""
import logging
import re
from xml.etree.ElementTree import ParseError
from django_salmon import magicsigs, utils
from flask import request
from granary import atom
from oauth_dropins.webutil import util
from oauth_dropins.webutil.flask_util import error
from app import app
import common
logger = logging.getLogger(__name__)
# from django_salmon.feeds
ATOM_NS = 'http://www.w3.org/2005/Atom'
ATOM_THREADING_NS = 'http://purl.org/syndication/thread/1.0'
SUPPORTED_VERBS = (
'checkin',
'create',
'favorite',
'like',
'share',
'tag',
'update',
)
@app.post(f'/<regex("{common.ACCT_RE}|{common.DOMAIN_RE}"):acct>/salmon')
def slap(acct):
"""Accepts POSTs to /[ACCT]/salmon and converts to outbound webmentions."""
body = request.get_data(as_text=True)
logger.info(f'Got: {body}')
try:
parsed = utils.parse_magic_envelope(body)
except ParseError as e:
error('Could not parse POST body as XML', exc_info=True)
data = parsed['data']
logger.info(f'Decoded: {data}')
# check that we support this activity type
try:
activity = atom.atom_to_activity(data)
except ParseError as e:
error('Could not parse envelope data as XML', exc_info=True)
verb = activity.get('verb')
if verb and verb not in SUPPORTED_VERBS:
error(f'Sorry, {verb} activities are not supported yet.', status=501)
# verify author and signature
author = util.get_url(activity.get('actor'))
if ':' not in author:
author = f'acct:{author}'
elif not author.startswith('acct:'):
error(f'Author URI {author} has unsupported scheme; expected acct:')
logger.info(f'Fetching Salmon key for {author}')
if not magicsigs.verify(data, parsed['sig'], author_uri=author):
error('Could not verify magic signature.')
logger.info('Verified magic signature.')
# Verify that the timestamp is recent. Required by spec.
# I get that this helps prevent spam, but in practice it's a bit silly,
# and other major implementations don't (e.g. Mastodon), so forget it.
#
# updated = utils.parse_updated_from_atom(data)
# if not utils.verify_timestamp(updated):
# error('Timestamp is more than 1h old.')
# send webmentions to each target
activity = atom.atom_to_activity(data)
common.send_webmentions(activity, protocol='ostatus', source_atom=data)
return ''

Wyświetl plik

@ -16,7 +16,7 @@
</div>
<p>
Bridgy Fed turns your web site into its own <a href="https://en.wikipedia.org/wiki/Fediverse">fediverse</a> account, visible in <a href="https://joinmastodon.org/">Mastodon</a> and beyond. You can post, reply, like, repost, and follow fediverse accounts by posting on your site with <a href="https://microformats.org/wiki/microformats2">microformats2</a> and sending <a href="https://webmention.net/">webmentions</a>. Bridgy Fed translates those posts to fediverse protocols like <a href="https://activitypub.rocks/">ActivityPub</a> and <a href="https://en.wikipedia.org/wiki/OStatus">OStatus</a>, and sends fediverse interactions back to your site as webmentions.
Bridgy Fed turns your web site into its own <a href="https://en.wikipedia.org/wiki/Fediverse">fediverse</a> account, visible in <a href="https://joinmastodon.org/">Mastodon</a> and beyond. You can post, reply, like, repost, and follow fediverse accounts by posting on your site with <a href="https://microformats.org/wiki/microformats2">microformats2</a> and sending <a href="https://webmention.net/">webmentions</a>. Bridgy Fed translates those posts into <a href="https://activitypub.rocks/">ActivityPub</a>, and when people inside the fediverse respond, it sends those responses back to your site as webmentions.
</p>
<p>
@ -179,32 +179,6 @@ Your site's fediverse profile comes from the <a href="https://microformats.org/w
</p>
</li>
<!-- <li id="sites" class="question">Which sites are supported?</li> -->
<!-- <li class="answer"> -->
<!-- <p> -->
<!-- These sites are currently supported: -->
<!-- </p> -->
<!-- <ul> -->
<!-- <li><em><a href="https://joinmastodon.org/">Mastodon</a></em>: posts, replies, likes, reposts aka boosts, @-mentions, and follows, both directions, via ActivityPub.<br /> -->
<!-- The instance must be running at least <a href="https://hackernoon.com/mastodon-and-the-w3c-f75f376f422">Mastodon 1.6</a>, and more reliably with 2.0 and up. You can find its version on the bottom or right of its <code>/about/more</code> page, e.g. <a href="https://mastodon.social/about/more">mastodon.social/about/more</a>. -->
<!-- </li> -->
<!-- <li><em><a href="https://project.hubzilla.org/">Hubzilla</a></em>: replies, likes, and reposts aka shares, both directions, via OStatus.<br /> -->
<!-- The instance must be running <a href="https://hub.somaton.com/channel/mario/?f=&mid=6db16e0e253c3c376cb921e7b31f94c24522933d7e54c6cf9febaa05359ab2fe@hub.somaton.com">Hubzilla 2.6</a> or higher. You can find its version on its <code>/siteinfo</code> page, e.g. <a href="https://hub.somaton.com/siteinfo">hub.somaton.com/siteinfo</a>. It also needs the GNU Social addon installed and enabled, and you also need to enable it in your account settings on the <em>Feature/Addon settings</em> page (<code>/settings/featured</code>). -->
<!-- </li> -->
<!-- </ul> -->
<!-- <p> -->
<!-- We're aware of the sites below, and we've made progress on some, but they're not yet supported. Click through and vote for their feature requests if you're interested in any of them! -->
<!-- </p> -->
<!-- <ul> -->
<!-- <li><em><a href="https://github.com/snarfed/bridgy-fed/issues/7">Diaspora</a></em>, via OStatus.</li> -->
<!-- <li><em><a href="https://github.com/snarfed/bridgy-fed/issues/9">Friendica</a></em>, via OStatus.</li> -->
<!-- <li><em><a href="https://github.com/snarfed/bridgy-fed/issues/8">GNU Social</a></em> (née StatusNet), via OStatus.</li> -->
<!-- <li><em><a href="https://github.com/snarfed/bridgy-fed/issues/11">MediaGoblin</a></em>, via ActivityPub?</li> -->
<!-- <li><em><a href="https://github.com/snarfed/bridgy-fed/issues/12">Pleroma</a></em>, via ActivityPub.</li> -->
<!-- </ul> -->
<!-- </li> -->
<br>
<h3 id="usage">Usage</h3>

Wyświetl plik

@ -9,11 +9,4 @@
{% for link in links %}
<Link rel='{{ link.rel }}' type='{{ link.type }}' href='{{ link.href }}' />
{% endfor %}
{% for key in magic_keys %}
<Property xmlns:mk="http://salmon-protocol.org/ns/magic-key"
type="http://salmon-protocol.org/ns/magic-key">
{{ key.value }}
</Property>
{% endfor %}
</XRD>

Wyświetl plik

@ -1,154 +0,0 @@
# coding=utf-8
"""Unit tests for salmon.py.
TODO: test error handling
"""
import copy
import datetime
from unittest import mock
from django_salmon import magicsigs
from oauth_dropins.webutil.testutil import requests_response, UrlopenResult
import requests
import common
from models import User, Activity
from . import testutil
@mock.patch('requests.post')
@mock.patch('requests.get')
@mock.patch('requests.head')
@mock.patch('urllib.request.urlopen')
class SalmonTest(testutil.TestCase):
def setUp(self):
super().setUp()
self.key = User.get_or_create('alice')
def send_slap(self, mock_urlopen, mock_head, mock_get, mock_post, atom_slap):
# salmon magic key discovery. first host-meta, then webfinger
mock_urlopen.side_effect = [
UrlopenResult(200, """\
<?xml version='1.0' encoding='UTF-8'?>
<XRD xmlns='http://docs.oasis-open.org/ns/xri/xrd-1.0'>
<Link rel='lrdd' type='application/xrd+xml' template='http://webfinger/{uri}' />
</XRD>"""),
UrlopenResult(200, """\
<?xml version='1.0' encoding='UTF-8'?>
<XRD xmlns='http://docs.oasis-open.org/ns/xri/xrd-1.0'>
<Subject>alice@fedsoc.net</Subject>
<Link rel='magic-public-key' href='%s' />
</XRD>""" % self.key.href()),
]
# webmention discovery
mock_head.return_value = requests_response(url='http://orig/post')
mock_get.return_value = requests_response(
'<html><head><link rel="webmention" href="/webmention"></html>')
# webmention post
mock_post.return_value = requests_response()
slap = magicsigs.magic_envelope(atom_slap, common.CONTENT_TYPE_ATOM, self.key)
got = self.client.post('/foo.com@foo.com/salmon', data=slap)
self.assertEqual(200, got.status_code)
# check salmon magic key discovery
mock_urlopen.assert_has_calls((
mock.call('http://fedsoc.net/.well-known/host-meta'),
mock.call('http://webfinger/alice@fedsoc.net'),
))
# check webmention discovery
self.assert_req(mock_get, 'http://orig/post')
def test_reply(self, mock_urlopen, mock_head, mock_get, mock_post):
atom_reply = """\
<?xml version='1.0' encoding='UTF-8'?>
<entry xmlns='http://www.w3.org/2005/Atom'>
<id>https://my/reply</id>
<uri>https://my/reply</uri>
<author>
<name>Alice</name>
<uri>alice@fedsoc.net</uri>
</author>
<thr:in-reply-to xmlns:thr="http://purl.org/syndication/thread/1.0">
http://orig/post
</thr:in-reply-to>
<content>I hereby reply.</content>
<title>My Reply</title>
<updated>%s</updated>
</entry>""" % datetime.datetime.now().isoformat('T')
self.send_slap(mock_urlopen, mock_head, mock_get, mock_post, atom_reply)
# check webmention post
self.assert_req(
mock_post,
'http://orig/webmention',
data={'source': 'https://my/reply', 'target': 'http://orig/post'},
allow_redirects=False,
headers={'Accept': '*/*'})
# check stored post
activity = Activity.get_by_id('https://my/reply http://orig/post')
self.assertEqual(['orig'], activity.domain)
self.assertEqual('in', activity.direction)
self.assertEqual('ostatus', activity.protocol)
self.assertEqual('complete', activity.status)
self.assertEqual(atom_reply, activity.source_atom)
def test_like(self, mock_urlopen, mock_head, mock_get, mock_post):
atom_like = """\
<?xml version='1.0' encoding='UTF-8'?>
<entry xmlns='http://www.w3.org/2005/Atom'
xmlns:activity='http://activitystrea.ms/spec/1.0/'>
<uri>https://my/like</uri>
<author>
<name>Alice</name>
<uri>alice@fedsoc.net</uri>
</author>
<activity:verb>http://activitystrea.ms/schema/1.0/like</activity:verb>
<activity:object>http://orig/post</activity:object>
<updated>%s</updated>
</entry>""" % datetime.datetime.now().isoformat('T')
self.send_slap(mock_urlopen, mock_head, mock_get, mock_post, atom_like)
# check webmention post
self.assert_req(
mock_post,
'http://orig/webmention',
data={
'source': 'http://localhost/render?source=https%3A%2F%2Fmy%2Flike&target=http%3A%2F%2Forig%2Fpost',
'target': 'http://orig/post',
},
allow_redirects=False,
headers={'Accept': '*/*'})
# check stored post
activity = Activity.get_by_id('https://my/like http://orig/post')
self.assertEqual(['orig'], activity.domain)
self.assertEqual('in', activity.direction)
self.assertEqual('ostatus', activity.protocol)
self.assertEqual('complete', activity.status)
self.assertEqual(atom_like, activity.source_atom)
def test_bad_envelope(self, *mocks):
got = self.client.post('/foo.com/salmon', data='not xml')
self.assertEqual(400, got.status_code)
def test_bad_inner_xml(self, *mocks):
slap = magicsigs.magic_envelope('not xml', common.CONTENT_TYPE_ATOM, self.key)
got = self.client.post('/foo.com/salmon', data=slap)
self.assertEqual(400, got.status_code)
def test_rsvp_not_supported(self, *mocks):
slap = magicsigs.magic_envelope("""\
<?xml version='1.0' encoding='UTF-8'?>
<entry xmlns='http://www.w3.org/2005/Atom'
xmlns:activity='http://activitystrea.ms/spec/1.0/'>
<uri>https://my/rsvp</uri>
<activity:verb>http://activitystrea.ms/schema/1.0/rsvp</activity:verb>
<activity:object>http://orig/event</activity:object>
</entry>""", common.CONTENT_TYPE_ATOM, self.key)
got = self.client.post('/foo.com/salmon', data=slap)
self.assertEqual(501, got.status_code)

Wyświetl plik

@ -77,9 +77,6 @@ class WebfingerTest(testutil.TestCase):
}, {
'rel': 'magic-public-key',
'href': self.key.href(),
}, {
'rel': 'salmon',
'href': 'http://localhost/foo.com/salmon'
}, {
'rel': 'http://ostatus.org/schema/1.0/subscribe',
'template': 'http://localhost/user/foo.com?url={uri}',

Wyświetl plik

@ -7,7 +7,6 @@ import copy
from unittest import mock
from urllib.parse import urlencode
from django_salmon import magicsigs, utils
import feedparser
from granary import as2, atom, microformats2
from httpsig.sign import HeaderSigner
@ -303,17 +302,6 @@ class WebmentionTest(testutil.TestCase):
self.activitypub_gets = [self.reply, self.not_fediverse, self.orig_as2,
self.actor]
def verify_salmon(self, mock_post):
args, kwargs = mock_post.call_args
self.assertEqual(('http://orig/salmon',), args)
self.assertEqual(CONTENT_TYPE_MAGIC_ENVELOPE,
kwargs['headers']['Content-Type'])
env = utils.parse_magic_envelope(kwargs['data'])
assert magicsigs.verify(env['data'], env['sig'].encode(), key=self.user)
return env['data']
def test_bad_source_url(self, mock_get, mock_post):
got = self.client.post('/webmention', data=b'')
self.assertEqual(400, got.status_code)
@ -885,7 +873,7 @@ class WebmentionTest(testutil.TestCase):
})
self.assert_equals(400, got.status_code)
def test_activitypub_error_no_salmon_fallback(self, mock_get, mock_post):
def test_activitypub_error(self, mock_get, mock_post):
mock_get.side_effect = [self.follow, self.actor]
mock_post.return_value = requests_response(
'abc xyz', status=405, url='https://foo.com/inbox')
@ -933,172 +921,3 @@ class WebmentionTest(testutil.TestCase):
'target': 'https://fed.brid.gy/',
})
self.assertEqual(400, got.status_code)
def test_salmon_reply(self, mock_get, mock_post):
mock_get.side_effect = [self.reply, self.not_fediverse,
self.orig_html_atom, self.orig_atom]
got = self.client.post('/webmention', data={
'source': 'http://a/reply',
'target': 'http://orig/post',
})
self.assertEqual(200, got.status_code)
mock_get.assert_has_calls((
self.req('http://a/reply'),
self.as2_req('http://not/fediverse'),
self.as2_req('http://orig/post'),
self.req('http://orig/atom'),
))
data = self.verify_salmon(mock_post)
parsed = feedparser.parse(data)
entry = parsed.entries[0]
self.assertEqual('http://a/reply', entry['id'])
self.assertIn({
'rel': 'alternate',
'href': 'http://a/reply',
'type': 'text/html',
}, entry['links'])
self.assertEqual({
'type': 'text/html',
'href': 'http://orig/post',
'ref': 'tag:fed.brid.gy,2017-08-22:orig-post',
}, entry['thr_in-reply-to'])
self.assertEqual("""\
<a class="u-in-reply-to" href="http://not/fediverse"></a><br />
<a class="u-in-reply-to" href="http://orig/post">foo bar</a><br />
<a href="http://localhost/"></a>""",
entry.content[0]['value'])
activity = Activity.get_by_id('http://a/reply http://orig/post')
self.assertEqual(['a'], activity.domain)
self.assertEqual('out', activity.direction)
self.assertEqual('ostatus', activity.protocol)
self.assertEqual('complete', activity.status)
self.assertEqual(self.reply_mf2, json_loads(activity.source_mf2))
def test_salmon_like(self, mock_get, mock_post):
mock_get.side_effect = [self.like, self.orig_html_atom, self.orig_atom]
got = self.client.post('/webmention', data={
'source': 'http://a/like',
'target': 'http://orig/post',
})
self.assertEqual(200, got.status_code)
mock_get.assert_has_calls((
self.req('http://a/like'),
self.as2_req('http://orig/post'),
self.req('http://orig/atom'),
))
data = self.verify_salmon(mock_post)
parsed = feedparser.parse(data)
entry = parsed.entries[0]
self.assertEqual('tag:fed.brid.gy,2017-08-22:orig-post', entry['id'])
self.assertIn({
'rel': 'alternate',
'href': 'http://a/like',
'type': 'text/html',
}, entry['links'])
self.assertEqual('http://orig/post', entry['activity_object'])
activity = Activity.get_by_id('http://a/like http://orig/post')
self.assertEqual(['a'], activity.domain)
self.assertEqual('out', activity.direction)
self.assertEqual('ostatus', activity.protocol)
self.assertEqual('complete', activity.status)
self.assertEqual(self.like_mf2, json_loads(activity.source_mf2))
def test_salmon_get_salmon_from_webfinger(self, mock_get, mock_post):
orig_atom = requests_response("""\
<?xml version="1.0"?>
<entry xmlns="http://www.w3.org/2005/Atom">
<author>
<name>ryan</name>
<email>ryan@orig</email>
</author>
<id>tag:fed.brid.gy,2017-08-22:orig-post</id>
</entry>
""")
webfinger = requests_response({
'subject': 'acct:ryan@orig',
'links': [{
'rel': 'salmon',
'href': 'http://orig/@ryan/salmon',
}],
})
mock_get.side_effect = [self.reply, self.not_fediverse,
self.orig_html_atom, orig_atom, webfinger]
got = self.client.post('/webmention', data={
'source': 'http://a/reply',
'target': 'http://orig/post',
})
self.assertEqual(200, got.status_code)
self.assert_req(mock_get, 'http://orig/.well-known/webfinger?resource=acct:ryan@orig')
self.assertEqual(('http://orig/@ryan/salmon',), mock_post.call_args[0])
def test_salmon_no_target_atom(self, mock_get, mock_post):
orig_no_atom = requests_response("""\
<html>
<body>foo</body>
</html>""", 'http://orig/url')
mock_get.side_effect = [self.reply, self.not_fediverse, orig_no_atom]
got = self.client.post('/webmention', data={
'source': 'http://a/reply',
'target': 'http://orig/post',
})
self.assertEqual(400, got.status_code)
self.assertIn('Target post http://orig/url has no Atom link',
got.get_data(as_text=True))
activity = Activity.get_by_id('http://a/reply http://orig/url')
self.assertEqual(['a'], activity.domain)
self.assertEqual('out', activity.direction)
self.assertEqual('ostatus', activity.protocol)
self.assertEqual('error', activity.status)
def test_salmon_relative_atom_href(self, mock_get, mock_post):
orig_relative = requests_response("""\
<html>
<meta>
<link href='atom/1' rel='alternate' type='application/atom+xml'>
</meta>
</html>""", 'http://orig/url')
mock_get.side_effect = [self.reply, self.not_fediverse, orig_relative,
self.orig_atom]
got = self.client.post('/webmention', data={
'source': 'http://a/reply',
'target': 'http://orig/post',
})
self.assertEqual(200, got.status_code)
self.assert_req(mock_get, 'http://orig/atom/1')
data = self.verify_salmon(mock_post)
def test_salmon_relative_atom_href_with_base(self, mock_get, mock_post):
orig_base = requests_response("""\
<html>
<meta>
<base href='/base/'>
<link href='atom/1' rel='alternate' type='application/atom+xml'>
</meta>
</html>""", 'http://orig/url')
mock_get.side_effect = [self.reply, self.not_fediverse, orig_base,
self.orig_atom]
got = self.client.post('/webmention', data={
'source': 'http://a/reply',
'target': 'http://orig/post',
})
self.assertEqual(200, got.status_code)
self.assert_req(mock_get, 'http://orig/base/atom/1')
data = self.verify_salmon(mock_post)

Wyświetl plik

@ -143,9 +143,6 @@ class Actor(flask_util.XrdOrJrd):
}, {
'rel': 'magic-public-key',
'href': user.href(),
}, {
'rel': 'salmon',
'href': f'{request.host_url}{domain}/salmon',
},
# remote follow

Wyświetl plik

@ -2,14 +2,11 @@
TODO tests:
* actor/attributedTo could be string URL
* salmon rel via webfinger via author.name + domain
"""
import logging
import urllib.parse
from urllib.parse import urlencode
import django_salmon
from django_salmon import magicsigs
import feedparser
from flask import request
from flask.views import View
@ -33,7 +30,7 @@ SKIP_EMAIL_DOMAINS = frozenset(('localhost', 'snarfed.org'))
class Webmention(View):
"""Handles inbound webmention, converts to ActivityPub or Salmon."""
"""Handles inbound webmention, converts to ActivityPub."""
source_url = None # string
source_domain = None # string
source_mf2 = None # parsed mf2 dict
@ -90,12 +87,8 @@ class Webmention(View):
logger.info(f'Converted webmention to AS1: {type_label}: {json_dumps(self.source_obj, indent=2)}')
self.user = User.get_or_create(self.source_domain)
for method in self.try_activitypub, self.try_salmon:
ret = method()
if ret:
return ret
return ''
ret = self.try_activitypub()
return ret or 'No action taken'
def try_activitypub(self):
"""Attempts ActivityPub delivery.
@ -223,7 +216,6 @@ class Webmention(View):
if self.target_resp and self.target_resp.status_code // 100 == 2:
content_type = common.content_type(self.target_resp) or ''
if content_type.startswith('text/html'):
# TODO: pass e.requests_response to try_salmon's target_resp
continue # give up
raise
target_url = self.target_resp.url or target
@ -256,9 +248,8 @@ class Webmention(View):
inbox_url = actor.get('inbox')
if not inbox_url:
# TODO: probably need a way to save errors like this so that we can
# return them if ostatus fails too.
# error('Target actor has no inbox')
# TODO: probably need a way to surface errors like this
logging.error('Target actor has no inbox')
continue
inbox_url = urllib.parse.urljoin(target_url, inbox_url)
@ -267,131 +258,6 @@ class Webmention(View):
logger.info(f"Delivering to targets' inboxes: {[i for _, i in activities_and_inbox_urls]}")
return activities_and_inbox_urls
def try_salmon(self):
"""
Returns Flask response (string body or tuple) if we attempted OStatus
delivery (whether successful or not), None if we didn't attempt, raises
an exception otherwise.
"""
target = None
if self.target_resp:
target = self.target_resp.url
else:
targets = self._targets()
if targets:
target = targets[0]
if not target:
logger.warning("No targets or followers. Ignoring.")
return
status = None
try:
ret = self._try_salmon(target)
if isinstance(ret, str):
status = 'complete'
return ret
except:
status = 'error'
raise
finally:
if status:
Activity(source=self.source_url, target=target, status=status,
domain=[self.source_domain], direction='out',
protocol = 'ostatus',
source_mf2=json_dumps(self.source_mf2)).put()
def _try_salmon(self, target):
"""
Args:
target: string
"""
# fetch target HTML page, extract Atom rel-alternate link
if not self.target_resp:
self.target_resp = util.requests_get(target)
parsed = util.parse_html(self.target_resp)
atom_url = parsed.find('link', rel='alternate', type=common.CONTENT_TYPE_ATOM)
if not atom_url or not atom_url.get('href'):
error(f'Target post {target} has no Atom link')
# fetch Atom target post, extract and inject id into source object
base_url = ''
base = parsed.find('base')
if base and base.get('href'):
base_url = base['href']
atom_link = parsed.find('link', rel='alternate', type=common.CONTENT_TYPE_ATOM)
atom_url = urllib.parse.urljoin(
target, urllib.parse.urljoin(base_url, atom_link['href']))
feed = util.requests_get(atom_url).text
parsed = feedparser.parse(feed)
entry = parsed.entries[0]
logger.info(f'Parsed: {json_dumps(entry, indent=2)}')
target_id = entry.id
in_reply_to = self.source_obj.get('inReplyTo')
source_obj_obj = self.source_obj.get('object')
if in_reply_to:
for elem in in_reply_to:
if elem.get('url') == target:
elem['id'] = target_id
elif isinstance(source_obj_obj, dict):
source_obj_obj['id'] = target_id
# Mastodon (and maybe others?) require a rel-mentioned link to the
# original post's author to make it show up as a reply:
# app/services/process_interaction_service.rb
# ...so add them as a tag, which atom renders as a rel-mention link.
authors = entry.get('authors', None)
if authors:
url = entry.authors[0].get('href')
if url:
self.source_obj.setdefault('tags', []).append({'url': url})
# extract and discover salmon endpoint
logger.info(f'Discovering Salmon endpoint in {atom_url}')
endpoint = django_salmon.discover_salmon_endpoint(feed)
if not endpoint:
# try webfinger
parsed = urllib.parse.urlparse(target)
# TODO: test missing email
author = entry.get('author_detail', {})
email = author.get('email') or '@'.join(
(author.get('name', ''), parsed.netloc))
try:
# TODO: always https?
profile = util.requests_get(
'%s://%s/.well-known/webfinger?resource=acct:%s' %
(parsed.scheme, parsed.netloc, email))
endpoint = django_salmon.get_salmon_replies_link(profile.json())
except ValueError:
logging.warning("Couldn't parse response as JSON")
except requests.HTTPError:
pass
if not endpoint:
error('No salmon endpoint found!')
logger.info(f'Discovered Salmon endpoint {endpoint}')
# construct reply Atom object
activity = self.source_obj
if self.source_obj.get('verb') not in as1.VERBS_WITH_OBJECT:
activity = {'object': self.source_obj}
entry = atom.activity_to_atom(activity, xml_base=self.source_url)
logger.info(f'Converted {self.source_url} to Atom:\n{entry}')
# sign reply and wrap in magic envelope
domain = urllib.parse.urlparse(self.source_url).netloc
magic_envelope = magicsigs.magic_envelope(
entry, common.CONTENT_TYPE_ATOM, self.user).decode()
logger.info(f'Sending Salmon slap to {endpoint}')
util.requests_post(
endpoint, data=common.XML_UTF8 + magic_envelope,
headers={'Content-Type': common.CONTENT_TYPE_MAGIC_ENVELOPE})
return 'Sent!'
app.add_url_rule('/webmention', view_func=Webmention.as_view('webmention'),
methods=['POST'])