diff --git a/activitypub.py b/activitypub.py index 13fbe0f9..a586f909 100644 --- a/activitypub.py +++ b/activitypub.py @@ -372,6 +372,10 @@ def postprocess_as2(activity, target=None): if not activity.get('id'): activity['id'] = util.get_first(activity, 'url') + # Deletes' object is our own id + if type == 'Delete': + activity['object'] = redirect_wrap(activity['object']) + # TODO: find a better way to check this, sometimes or always? # removed for now since it fires on posts without u-id or u-url, eg # https://chrisbeckstrom.com/2018/12/27/32551/ diff --git a/models.py b/models.py index 655b1537..dd10ea99 100644 --- a/models.py +++ b/models.py @@ -247,7 +247,7 @@ class User(StringIdModel): # check home page try: - obj = webmention.Webmention.load(self.homepage) + obj = webmention.Webmention.load(self.homepage, gateway=True) self.actor_as2 = activitypub.postprocess_as2(as2.from_as1(obj.as1)) self.has_hcard = True except (BadRequest, NotFound): diff --git a/pages.py b/pages.py index 41c13c66..1a7cca6d 100644 --- a/pages.py +++ b/pages.py @@ -197,6 +197,7 @@ def fetch_objects(query): phrases = { 'article': 'posted', 'comment': 'replied', + 'delete': 'deleted', 'follow': 'followed', 'invite': 'is invited to', 'issue': 'filed issue', diff --git a/protocol.py b/protocol.py index caaea5d0..58ff05a4 100644 --- a/protocol.py +++ b/protocol.py @@ -360,7 +360,7 @@ class Protocol: error(msg, status=int(errors[0][0] or 502)) @classmethod - def load(cls, id, refresh=False): + def load(cls, id, refresh=False, **kwargs): """Loads and returns an Object from memory cache, datastore, or HTTP fetch. Assumes id is a URL. Any fragment at the end is stripped before loading. @@ -379,6 +379,7 @@ class Protocol: id: str refresh: boolean, whether to fetch the object remotely even if we have it stored + kwargs: passed through to fetch() Returns: :class:`Object` @@ -414,7 +415,7 @@ class Protocol: obj.new = True obj.changed = False - cls.fetch(obj) + cls.fetch(obj, **kwargs) if orig_as1: obj.new = False obj.changed = as1.activity_changed(orig_as1, obj.as1) diff --git a/templates/docs.html b/templates/docs.html index 1bc3b853..c78ce382 100644 --- a/templates/docs.html +++ b/templates/docs.html @@ -50,6 +50,7 @@ Bridgy Fed takes some technical know-how to set up, and there are simpler (but l
  • How do I include an image in a post?
  • How do I use hashtags?
  • How do I edit an existing post?
  • +
  • How do I delete a post?
  • Can I publish just one part of a page?
  • How do fediverse replies, likes, and other interactions show up on my site?
  • How do I read my fediverse timeline/feed?
  • @@ -351,6 +352,12 @@ I love scotch. Scotchy scotchy scotch.

    +
  • How do I delete a post?
  • +
  • +

    First, delete the post on your web site, so that HTTP requests for it return 410 Gone or 404 Not Found. Then, send another webmention to Bridgy Fed for it. Bridgy Fed will refetch the post, see that it's gone, and send an Delete activity for it to the fediverse. +

    +
  • +
  • Can I publish just one part of a page?
  • If that HTML element has its own id, then sure! Just put the id in the fragment of the URL that you publish. For example, to publish the bar post here:

    diff --git a/tests/test_webmention.py b/tests/test_webmention.py index 2b7e7a15..d41a9976 100644 --- a/tests/test_webmention.py +++ b/tests/test_webmention.py @@ -6,7 +6,7 @@ from urllib.parse import urlencode import feedparser from flask import g -from granary import as2, atom, microformats2 +from granary import as1, as2, atom, microformats2 from httpsig.sign import HeaderSigner from oauth_dropins.webutil import appengine_config, util from oauth_dropins.webutil.appengine_config import tasks_client @@ -124,6 +124,21 @@ WEBMENTION_REL_LINK = requests_response( '') WEBMENTION_NO_REL_LINK = requests_response('') +DELETE_AS1 = { + 'objectType': 'activity', + 'verb': 'delete', + 'id': 'https://user.com/post#bridgy-fed-delete', + 'actor': 'http://localhost/user.com', + 'object': 'https://user.com/post', +} +DELETE_AS2 = { + '@context': 'https://www.w3.org/ns/activitystreams', + 'type': 'Delete', + 'id': 'http://localhost/r/https://user.com/post#bridgy-fed-delete', + 'actor': 'http://localhost/user.com', + 'object': 'http://localhost/r/https://user.com/post', + 'to': [as2.PUBLIC_AUDIENCE], +} @mock.patch('requests.post') @mock.patch('requests.get') @@ -354,7 +369,7 @@ class WebmentionTest(testutil.TestCase): for inbox in inboxes: got = json_loads(calls[inbox][1]['data']) - got.get('object', {}).pop('publicKey', None) + as1.get_object(got).pop('publicKey', None) self.assert_equals(data, got, inbox) def assert_object(self, id, **props): @@ -1038,6 +1053,61 @@ class WebmentionTest(testutil.TestCase): self.req('https://user.com/follow'), )) + def test_delete(self, mock_get, mock_post): + mock_get.return_value = requests_response('"unused"', status=410, + url='http://final/delete') + mock_post.return_value = requests_response('unused', status=200) + Object(id='https://user.com/post#bridgy-fed-create', + mf2=self.note_mf2, status='complete').put() + + self.make_followers() + + got = self.client.post('/_ah/queue/webmention', data={ + 'source': 'https://user.com/post', + 'target': 'https://fed.brid.gy/', + }) + self.assertEqual(200, got.status_code, got.text) + + inboxes = ('https://inbox', 'https://public/inbox', 'https://shared/inbox') + self.assert_deliveries(mock_post, inboxes, DELETE_AS2) + + self.assert_object('https://user.com/post#bridgy-fed-delete', + domains=['user.com'], + source_protocol='webmention', + status='complete', + our_as1=DELETE_AS1, + delivered=inboxes, + type='delete', + object_ids=['https://user.com/post'], + labels=['user', 'activity'], + ) + + def test_delete_no_object(self, mock_get, mock_post): + mock_get.side_effect = [ + requests_response('"unused"', status=410, url='http://final/delete'), + ] + got = self.client.post('/_ah/queue/webmention', data={ + 'source': 'https://user.com/post', + 'target': 'https://fed.brid.gy/', + }) + self.assertEqual(304, got.status_code, got.text) + mock_post.assert_not_called() + + def test_delete_incomplete_response(self, mock_get, mock_post): + mock_get.return_value = requests_response('"unused"', status=410, + url='http://final/delete') + + with app.test_request_context('/'): + Object(id='https://user.com/post#bridgy-fed-create', + mf2=self.note_mf2, status='in progress') + + got = self.client.post('/_ah/queue/webmention', data={ + 'source': 'https://user.com/post', + 'target': 'https://fed.brid.gy/', + }) + self.assertEqual(304, got.status_code, got.text) + mock_post.assert_not_called() + def test_error(self, mock_get, mock_post): mock_get.side_effect = [self.follow, self.actor] mock_post.return_value = requests_response( @@ -1204,7 +1274,7 @@ class WebmentionUtilTest(testutil.TestCase): def test_fetch_error(self, mock_get, __): mock_get.return_value = requests_response(REPOST_HTML, status=405) with self.assertRaises(BadGateway) as e: - Webmention.fetch(Object(id='https://foo')) + Webmention.fetch(Object(id='https://foo'), gateway=True) def test_fetch_run_authorship(self, mock_get, __): mock_get.side_effect = [ diff --git a/webmention.py b/webmention.py index 0438a1d6..4997ef2a 100644 --- a/webmention.py +++ b/webmention.py @@ -15,7 +15,7 @@ from oauth_dropins.webutil.appengine_info import APP_ID from oauth_dropins.webutil.flask_util import error, flash from oauth_dropins.webutil.util import json_dumps, json_loads from oauth_dropins.webutil import webmention -import requests +from requests import HTTPError, RequestException, URLRequired from werkzeug.exceptions import BadGateway, BadRequest, HTTPException # import module instead of individual classes/functions to avoid circular import @@ -30,6 +30,8 @@ logger = logging.getLogger(__name__) # https://cloud.google.com/appengine/docs/locations TASKS_LOCATION = 'us-central1' +CHAR_AFTER_SPACE = chr(ord(' ') + 1) + class Webmention(Protocol): """Webmention protocol implementation.""" @@ -50,7 +52,7 @@ class Webmention(Protocol): return True @classmethod - def fetch(cls, obj): + def fetch(cls, obj, gateway=False): """Fetches a URL over HTTP and extracts its microformats2. Follows redirects, but doesn't change the original URL in obj's id! The @@ -59,15 +61,18 @@ class Webmention(Protocol): instead of the final redirect destination URL. See :meth:`Protocol.fetch` for other background. + + Args: + gateway: passed through to :func:`webutil.util.fetch_mf2` """ url = obj.key.id() is_homepage = g.user and g.user.is_homepage(url) require_backlink = common.host_url().rstrip('/') if not is_homepage else None try: - parsed = util.fetch_mf2(url, gateway=True, require_backlink=require_backlink) - except ValueError as e: - logger.info(str(e)) + parsed = util.fetch_mf2(url, gateway=gateway, + require_backlink=require_backlink) + except (ValueError, URLRequired) as e: error(str(e)) if parsed is None: @@ -184,13 +189,32 @@ def webmention_task(): obj = Webmention.load(source, refresh=True) except BadRequest as e: error(str(e.description), status=304) + except HTTPError as e: + if e.response.status_code not in (410, 404): + error(f'{e} ; {e.response.text if e.response else ""}', status=502) - # set actor to user - props = obj.mf2['properties'] - author_urls = microformats2.get_string_urls(props.get('author', [])) - if author_urls and not g.user.is_homepage(author_urls[0]): - logger.info(f'Overriding author {author_urls[0]} with {g.user.actor_id()}') - props['author'] = [g.user.actor_id()] + create_id = f'{source}#bridgy-fed-create' + logger.info(f'Interpreting as Delete. Looking for {create_id}') + create = models.Object.get_by_id(create_id) + if not create or create.status != 'complete': + error(f"Bridgy Fed hasn't successfully published {source}", status=304) + + id = f'{source}#bridgy-fed-delete' + obj = models.Object(id=id, our_as1={ + 'id': id, + 'objectType': 'activity', + 'verb': 'delete', + 'actor': g.user.actor_id(), + 'object': source, + }) + + if obj.mf2: + # set actor to user + props = obj.mf2['properties'] + author_urls = microformats2.get_string_urls(props.get('author', [])) + if author_urls and not g.user.is_homepage(author_urls[0]): + logger.info(f'Overriding author {author_urls[0]} with {g.user.actor_id()}') + props['author'] = [g.user.actor_id()] logger.info(f'Converted to AS1: {obj.type}: {json_dumps(obj.as1, indent=2)}') @@ -334,7 +358,7 @@ def webmention_task(): return last_success.text or 'Sent!', last_success.status_code elif isinstance(err, BadGateway): raise err - elif isinstance(err, requests.HTTPError): + elif isinstance(err, HTTPError): return str(err), err.status_code else: return str(err) @@ -361,7 +385,7 @@ def _activitypub_targets(obj): domain = g.user.key.id() for follower in models.Follower.query().filter( models.Follower.key > Key('Follower', domain + ' '), - models.Follower.key < Key('Follower', domain + chr(ord(' ') + 1))): + models.Follower.key < Key('Follower', domain + CHAR_AFTER_SPACE)): if follower.status != 'inactive' and follower.last_follow: actor = follower.last_follow.get('actor') if actor and isinstance(actor, dict): @@ -382,7 +406,7 @@ def _activitypub_targets(obj): # TODO: make this generic across protocols target_stored = activitypub.ActivityPub.load(target) target_obj = target_stored.as2 or as2.from_as1(target_stored.as1) - except (requests.HTTPError, BadGateway) as e: + except (HTTPError, BadGateway) as e: resp = getattr(e, 'requests_response', None) if resp and resp.ok: type = common.content_type(resp)