kopia lustrzana https://github.com/snarfed/bridgy-fed
rodzic
45f4bd71a2
commit
fe5c3947a8
24
README.md
24
README.md
|
@ -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
2
app.py
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
48
models.py
48
models.py
|
@ -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):
|
||||
|
|
|
@ -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
|
||||
|
|
82
salmon.py
82
salmon.py
|
@ -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 ''
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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)
|
|
@ -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}',
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
144
webmention.py
144
webmention.py
|
@ -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'])
|
||||
|
|
Ładowanie…
Reference in New Issue