kopia lustrzana https://github.com/snarfed/bridgy-fed
rodzic
a27ebc4697
commit
32d9e2bf6c
|
@ -11,13 +11,14 @@ from google.cloud import ndb
|
|||
from granary import as2, microformats2
|
||||
import mf2util
|
||||
from oauth_dropins.webutil import util
|
||||
from oauth_dropins.webutil.flask_util import error
|
||||
from oauth_dropins.webutil.handlers import cache_response
|
||||
from oauth_dropins.webutil.util import json_dumps, json_loads
|
||||
import webapp2
|
||||
|
||||
from app import app, cache
|
||||
import common
|
||||
from common import error, redirect_unwrap, redirect_wrap
|
||||
from common import redirect_unwrap, redirect_wrap
|
||||
from models import Follower, MagicKey
|
||||
from httpsig.requests_auth import HTTPSignatureAuth
|
||||
|
||||
|
@ -85,7 +86,7 @@ def actor(domain):
|
|||
"""Serves /[DOMAIN], fetches its mf2, converts to AS Actor, and serves it."""
|
||||
tld = domain.split('.')[-1]
|
||||
if tld in common.TLD_BLOCKLIST:
|
||||
return error('', status=404)
|
||||
error('', status=404)
|
||||
|
||||
mf2 = util.fetch_mf2('http://%s/' % domain, gateway=True,
|
||||
headers=common.HEADERS)
|
||||
|
@ -94,7 +95,7 @@ def actor(domain):
|
|||
hcard = mf2util.representative_hcard(mf2, mf2['url'])
|
||||
logging.info('Representative h-card: %s', json_dumps(hcard, indent=2))
|
||||
if not hcard:
|
||||
return error("""\
|
||||
error("""\
|
||||
Coul find a representative h-card (http://microformats.org/wiki/representative-hcard-parsing) on %s""" % mf2['url'])
|
||||
|
||||
key = MagicKey.get_or_create(domain)
|
||||
|
@ -125,7 +126,7 @@ def inbox(domain):
|
|||
activity = request.json
|
||||
assert activity
|
||||
except (TypeError, ValueError, AssertionError):
|
||||
return error("Couldn't parse body as JSON", exc_info=True)
|
||||
error("Couldn't parse body as JSON", exc_info=True)
|
||||
|
||||
obj = activity.get('object') or {}
|
||||
if isinstance(obj, str):
|
||||
|
@ -137,7 +138,7 @@ def inbox(domain):
|
|||
if type == 'Create':
|
||||
type = obj.get('type')
|
||||
elif type not in SUPPORTED_TYPES:
|
||||
return error('Sorry, %s activities are not supported yet.' % type,
|
||||
error('Sorry, %s activities are not supported yet.' % type,
|
||||
status=501)
|
||||
|
||||
# TODO: verify signature if there is one
|
||||
|
@ -196,12 +197,12 @@ def accept_follow(follow, follow_unwrapped):
|
|||
followee_unwrapped = follow_unwrapped.get('object')
|
||||
follower = follow.get('actor')
|
||||
if not followee or not followee_unwrapped or not follower:
|
||||
return error('Follow activity requires object and actor. Got: %s' % follow)
|
||||
error('Follow activity requires object and actor. Got: %s' % follow)
|
||||
|
||||
inbox = follower.get('inbox')
|
||||
follower_id = follower.get('id')
|
||||
if not inbox or not follower_id:
|
||||
return error('Follow actor requires id and inbox. Got: %s', follower)
|
||||
error('Follow actor requires id and inbox. Got: %s', follower)
|
||||
|
||||
# store Follower
|
||||
user_domain = util.domain_from_link(followee_unwrapped)
|
||||
|
@ -242,7 +243,7 @@ def undo_follow(undo_unwrapped):
|
|||
follower = follow.get('actor')
|
||||
followee = follow.get('object')
|
||||
if not follower or not followee:
|
||||
return error('Undo of Follow requires object with actor and object. Got: %s' % follow)
|
||||
error('Undo of Follow requires object with actor and object. Got: %s' % follow)
|
||||
|
||||
# deactivate Follower
|
||||
user_domain = util.domain_from_link(followee)
|
||||
|
|
|
@ -6,11 +6,11 @@ import urllib.parse
|
|||
import flask
|
||||
from flask import request
|
||||
from oauth_dropins.webutil import flask_util
|
||||
from oauth_dropins.webutil.flask_util import error
|
||||
import requests
|
||||
|
||||
from app import app, cache
|
||||
import common
|
||||
from common import error
|
||||
|
||||
LINK_HEADER = '<%s>; rel="webmention"'
|
||||
CACHE_TIME = datetime.timedelta(seconds=15)
|
||||
|
@ -23,14 +23,14 @@ def add_wm(url=None):
|
|||
"""Proxies HTTP requests and adds Link header to our webmention endpoint."""
|
||||
url = urllib.parse.unquote(url)
|
||||
if not url.startswith('http://') and not url.startswith('https://'):
|
||||
return error('URL must start with http:// or https://')
|
||||
error('URL must start with http:// or https://')
|
||||
|
||||
try:
|
||||
got = common.requests_get(url)
|
||||
except requests.exceptions.Timeout as e:
|
||||
return error(str(e), status=504, exc_info=True)
|
||||
error(str(e), status=504, exc_info=True)
|
||||
except requests.exceptions.RequestException as e:
|
||||
return error(str(e), status=502, exc_info=True)
|
||||
error(str(e), status=502, exc_info=True)
|
||||
|
||||
resp = flask.make_response(got.content, got.status_code, dict(got.headers))
|
||||
resp.headers.add('Link', LINK_HEADER % (request.args.get('endpoint') or
|
||||
|
|
17
common.py
17
common.py
|
@ -23,8 +23,6 @@ HEADERS = {
|
|||
'User-Agent': 'Bridgy Fed (https://fed.brid.gy/)',
|
||||
}
|
||||
XML_UTF8 = "<?xml version='1.0' encoding='UTF-8'?>\n"
|
||||
# USERNAME = 'me'
|
||||
# USERNAME_EMOJI = '🌎' # globe
|
||||
LINK_HEADER_RE = re.compile(r""" *< *([^ >]+) *> *; *rel=['"]([^'"]+)['"] *""")
|
||||
AS2_PUBLIC_AUDIENCE = 'https://www.w3.org/ns/activitystreams#Public'
|
||||
|
||||
|
@ -147,13 +145,6 @@ def content_type(resp):
|
|||
return type.split(';')[0]
|
||||
|
||||
|
||||
def error(msg, status=None, exc_info=False):
|
||||
if not status:
|
||||
status = 400
|
||||
logging.info('Returning %s: %s' % (status, msg), exc_info=exc_info)
|
||||
return (msg, status)
|
||||
|
||||
|
||||
def send_webmentions(activity_wrapped, proxy=None, **response_props):
|
||||
"""Sends webmentions for an incoming Salmon slap or ActivityPub inbox delivery.
|
||||
Args:
|
||||
|
@ -164,7 +155,7 @@ def send_webmentions(activity_wrapped, proxy=None, **response_props):
|
|||
|
||||
verb = activity.get('verb')
|
||||
if verb and verb not in SUPPORTED_VERBS:
|
||||
return error('%s activities are not supported yet.' % verb)
|
||||
error('%s activities are not supported yet.' % verb)
|
||||
|
||||
# extract source and targets
|
||||
source = activity.get('url') or activity.get('id')
|
||||
|
@ -192,9 +183,9 @@ def send_webmentions(activity_wrapped, proxy=None, **response_props):
|
|||
|
||||
targets = util.dedupe_urls(util.get_url(t) for t in targets)
|
||||
if not source:
|
||||
return error("Couldn't find original post URL")
|
||||
error("Couldn't find original post URL")
|
||||
if not targets:
|
||||
return error("Couldn't find any target URLs in inReplyTo, object, or mention tags")
|
||||
error("Couldn't find any target URLs in inReplyTo, object, or mention tags")
|
||||
|
||||
# send webmentions and store Responses
|
||||
errors = []
|
||||
|
@ -226,7 +217,7 @@ def send_webmentions(activity_wrapped, proxy=None, **response_props):
|
|||
|
||||
if errors:
|
||||
msg = 'Errors:\n' + '\n'.join(str(e) for e in errors)
|
||||
return error(msg, status=getattr(errors[0], 'http_status', None))
|
||||
error(msg, status=getattr(errors[0], 'http_status', None))
|
||||
|
||||
|
||||
def postprocess_as2(activity, target=None, key=None):
|
||||
|
|
2
logs.py
2
logs.py
|
@ -15,9 +15,9 @@ import humanize
|
|||
from oauth_dropins.webutil import flask_util, util
|
||||
from oauth_dropins.webutil.appengine_info import APP_ID
|
||||
import webapp2
|
||||
from oauth_dropins.webutil.flask_util import error
|
||||
|
||||
from app import app, cache
|
||||
from common import error
|
||||
from models import Response
|
||||
|
||||
LEVELS = {
|
||||
|
|
|
@ -17,12 +17,12 @@ from flask import redirect, request
|
|||
from granary import as2, microformats2
|
||||
import mf2util
|
||||
from oauth_dropins.webutil import flask_util, util
|
||||
from oauth_dropins.webutil.flask_util import error
|
||||
from oauth_dropins.webutil.util import json_dumps
|
||||
from werkzeug.exceptions import abort
|
||||
|
||||
from app import app, cache
|
||||
import common
|
||||
from common import error
|
||||
from models import MagicKey
|
||||
|
||||
CACHE_TIME = datetime.timedelta(seconds=15)
|
||||
|
@ -43,7 +43,7 @@ def redir(to):
|
|||
to = re.sub(r'^(https?:/)([^/])', r'\1/\2', to)
|
||||
|
||||
if not to.startswith('http://') and not to.startswith('https://'):
|
||||
return error(f'Expected fully qualified URL; got {to}')
|
||||
error(f'Expected fully qualified URL; got {to}')
|
||||
|
||||
# check that we've seen this domain before so we're not an open redirect
|
||||
domains = set((util.domain_from_link(to),
|
||||
|
|
|
@ -5,11 +5,11 @@ import datetime
|
|||
from flask import request
|
||||
from granary import as2, atom, microformats2
|
||||
from oauth_dropins.webutil import flask_util
|
||||
from oauth_dropins.webutil.flask_util import error
|
||||
from oauth_dropins.webutil.util import json_loads
|
||||
|
||||
from app import app, cache
|
||||
import common
|
||||
from common import error
|
||||
from models import Response
|
||||
|
||||
CACHE_TIME = datetime.timedelta(minutes=15)
|
||||
|
@ -26,7 +26,7 @@ def render():
|
|||
id = f'{source} {target}'
|
||||
resp = Response.get_by_id(id)
|
||||
if not resp:
|
||||
return error(f'No stored response for {id}', status=404)
|
||||
error(f'No stored response for {id}', status=404)
|
||||
|
||||
if resp.source_mf2:
|
||||
as1 = microformats2.json_to_object(json_loads(resp.source_mf2))
|
||||
|
@ -35,7 +35,7 @@ def render():
|
|||
elif resp.source_atom:
|
||||
as1 = atom.atom_to_activity(resp.source_atom)
|
||||
else:
|
||||
return error(f'Stored response for {id} has no data', status=404)
|
||||
error(f'Stored response for {id} has no data', status=404)
|
||||
|
||||
# add HTML meta redirect to source page. should trigger for end users in
|
||||
# browsers but not for webmention receivers (hopefully).
|
||||
|
|
14
salmon.py
14
salmon.py
|
@ -11,10 +11,10 @@ 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
|
||||
from common import error
|
||||
|
||||
# from django_salmon.feeds
|
||||
ATOM_NS = 'http://www.w3.org/2005/Atom'
|
||||
|
@ -41,7 +41,7 @@ def slap(acct):
|
|||
try:
|
||||
parsed = utils.parse_magic_envelope(body)
|
||||
except ParseError as e:
|
||||
return error('Could not parse POST body as XML', exc_info=True)
|
||||
error('Could not parse POST body as XML', exc_info=True)
|
||||
data = parsed['data']
|
||||
logging.info(f'Decoded: {data}')
|
||||
|
||||
|
@ -49,22 +49,22 @@ def slap(acct):
|
|||
try:
|
||||
activity = atom.atom_to_activity(data)
|
||||
except ParseError as e:
|
||||
return error('Could not parse envelope data as XML', exc_info=True)
|
||||
error('Could not parse envelope data as XML', exc_info=True)
|
||||
|
||||
verb = activity.get('verb')
|
||||
if verb and verb not in SUPPORTED_VERBS:
|
||||
return error(f'Sorry, {verb} activities are not supported yet.', status=501)
|
||||
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:'):
|
||||
return error(f'Author URI {author} has unsupported scheme; expected acct:')
|
||||
error(f'Author URI {author} has unsupported scheme; expected acct:')
|
||||
|
||||
logging.info(f'Fetching Salmon key for {author}')
|
||||
if not magicsigs.verify(data, parsed['sig'], author_uri=author):
|
||||
return error('Could not verify magic signature.')
|
||||
error('Could not verify magic signature.')
|
||||
logging.info('Verified magic signature.')
|
||||
|
||||
# Verify that the timestamp is recent. Required by spec.
|
||||
|
@ -73,7 +73,7 @@ def slap(acct):
|
|||
#
|
||||
# updated = utils.parse_updated_from_atom(data)
|
||||
# if not utils.verify_timestamp(updated):
|
||||
# return error('Timestamp is more than 1h old.')
|
||||
# error('Timestamp is more than 1h old.')
|
||||
|
||||
# send webmentions to each target
|
||||
activity = atom.atom_to_activity(data)
|
||||
|
|
|
@ -5,6 +5,7 @@ to test:
|
|||
* user URL that redirects
|
||||
* error handling
|
||||
"""
|
||||
import html
|
||||
from unittest import mock
|
||||
import urllib.parse
|
||||
|
||||
|
@ -179,7 +180,8 @@ class WebfingerTest(testutil.TestCase):
|
|||
def test_user_handler_bad_tld(self):
|
||||
got = client.get('/acct:foo.json')
|
||||
self.assertEqual(404, got.status_code)
|
||||
self.assertIn("doesn't look like a domain", got.get_data(as_text=True))
|
||||
self.assertIn("doesn't look like a domain",
|
||||
html.unescape(got.get_data(as_text=True)))
|
||||
|
||||
@mock.patch('requests.get')
|
||||
def test_webfinger_handler(self, mock_get):
|
||||
|
|
|
@ -857,7 +857,11 @@ class WebmentionTest(testutil.TestCase):
|
|||
self.assertEqual(400, got.status_code)
|
||||
self.assertIn('Target post http://orig/url has no Atom link',
|
||||
got.get_data(as_text=True))
|
||||
self.assertEqual(0, Response.query().count())
|
||||
|
||||
resp = Response.get_by_id('http://a/reply http://orig/url')
|
||||
self.assertEqual('out', resp.direction)
|
||||
self.assertEqual('ostatus', resp.protocol)
|
||||
self.assertEqual('error', resp.status)
|
||||
|
||||
def test_salmon_relative_atom_href(self, mock_get, mock_post):
|
||||
orig_relative = requests_response("""\
|
||||
|
|
|
@ -12,12 +12,12 @@ from flask import render_template, request
|
|||
from granary.microformats2 import get_text
|
||||
import mf2util
|
||||
from oauth_dropins.webutil import flask_util, handlers, util
|
||||
from oauth_dropins.webutil.flask_util import error
|
||||
from oauth_dropins.webutil.util import json_dumps
|
||||
import webapp2
|
||||
|
||||
from app import app, cache
|
||||
import common
|
||||
from common import error
|
||||
import models
|
||||
|
||||
CACHE_TIME = datetime.timedelta(seconds=15)
|
||||
|
@ -38,7 +38,7 @@ class User(flask_util.XrdOrJrd):
|
|||
logging.debug(f'Headers: {list(request.headers.items())}')
|
||||
|
||||
if domain.split('.')[-1] in NON_TLDS:
|
||||
return error(f"{domain} doesn't look like a domain", status=404)
|
||||
error(f"{domain} doesn't look like a domain", status=404)
|
||||
|
||||
# find representative h-card. try url, then url's home page, then domain
|
||||
urls = [f'http://{domain}/']
|
||||
|
@ -55,7 +55,7 @@ class User(flask_util.XrdOrJrd):
|
|||
logging.info(f'Representative h-card: {json_dumps(hcard, indent=2)}')
|
||||
break
|
||||
else:
|
||||
return error(f"didn't find a representative h-card (http://microformats.org/wiki/representative-hcard-parsing) on {resp.url}")
|
||||
error(f"didn't find a representative h-card (http://microformats.org/wiki/representative-hcard-parsing) on {resp.url}")
|
||||
|
||||
logging.info(f'Generating WebFinger data for {domain}')
|
||||
key = models.MagicKey.get_or_create(domain)
|
||||
|
|
|
@ -17,6 +17,7 @@ from google.cloud.ndb import Key
|
|||
from granary import as2, atom, microformats2, source
|
||||
import mf2util
|
||||
from oauth_dropins.webutil import flask_util, util
|
||||
from oauth_dropins.webutil.flask_util import error
|
||||
from oauth_dropins.webutil.util import json_dumps, json_loads
|
||||
import requests
|
||||
import webapp2
|
||||
|
@ -25,7 +26,6 @@ from webob import exc
|
|||
import activitypub
|
||||
from app import app
|
||||
import common
|
||||
from common import error
|
||||
from models import Follower, MagicKey, Response
|
||||
|
||||
SKIP_EMAIL_DOMAINS = frozenset(('localhost', 'snarfed.org'))
|
||||
|
@ -55,12 +55,12 @@ class Webmention(View):
|
|||
# source's intent to federate to mastodon)
|
||||
if (request.host_url not in source_resp.text and
|
||||
urllib.parse.quote(request.host_url, safe='') not in source_resp.text):
|
||||
return error("Couldn't find link to {request.host_url}")
|
||||
error("Couldn't find link to {request.host_url}")
|
||||
|
||||
# convert source page to ActivityStreams
|
||||
entry = mf2util.find_first_entry(self.source_mf2, ['h-entry'])
|
||||
if not entry:
|
||||
return error('No microformats2 found on {self.source_url}')
|
||||
error('No microformats2 found on {self.source_url}')
|
||||
|
||||
logging.info(f'First entry: {json_dumps(entry, indent=2)}')
|
||||
# make sure it has url, since we use that for AS2 id, which is required
|
||||
|
@ -191,9 +191,9 @@ class Webmention(View):
|
|||
inbox_url = actor.get('inbox')
|
||||
actor = actor.get('url') or actor.get('id')
|
||||
if not inbox_url and not actor:
|
||||
return error('Target object has no actor or attributedTo with URL or id.')
|
||||
error('Target object has no actor or attributedTo with URL or id.')
|
||||
elif not isinstance(actor, str):
|
||||
return error(f'Target actor or attributedTo has unexpected url or id object: {actor}')
|
||||
error(f'Target actor or attributedTo has unexpected url or id object: {actor}')
|
||||
|
||||
if not inbox_url:
|
||||
# fetch actor as AS object
|
||||
|
@ -203,7 +203,7 @@ class Webmention(View):
|
|||
if not inbox_url:
|
||||
# TODO: probably need a way to save errors like this so that we can
|
||||
# return them if ostatus fails too.
|
||||
# return error('Target actor has no inbox')
|
||||
# error('Target actor has no inbox')
|
||||
continue
|
||||
|
||||
inbox_url = urllib.parse.urljoin(target_url, inbox_url)
|
||||
|
@ -255,7 +255,7 @@ class Webmention(View):
|
|||
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'):
|
||||
return error(f'Target post {target} has no Atom link')
|
||||
error(f'Target post {target} has no Atom link')
|
||||
|
||||
# fetch Atom target post, extract and inject id into source object
|
||||
base_url = ''
|
||||
|
@ -311,7 +311,7 @@ class Webmention(View):
|
|||
pass
|
||||
|
||||
if not endpoint:
|
||||
return error('No salmon endpoint found!')
|
||||
error('No salmon endpoint found!')
|
||||
logging.info(f'Discovered Salmon endpoint {endpoint}')
|
||||
|
||||
# construct reply Atom object
|
||||
|
|
Ładowanie…
Reference in New Issue