wm => AP: add delete support

fixes #30
pull/485/head
Ryan Barrett 2023-04-17 15:36:29 -07:00
rodzic ba38d6853b
commit 212f2b11ec
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: 6BE31FDF4776E9D4
7 zmienionych plików z 127 dodań i 20 usunięć

Wyświetl plik

@ -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/

Wyświetl plik

@ -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):

Wyświetl plik

@ -197,6 +197,7 @@ def fetch_objects(query):
phrases = {
'article': 'posted',
'comment': 'replied',
'delete': 'deleted',
'follow': 'followed',
'invite': 'is invited to',
'issue': 'filed issue',

Wyświetl plik

@ -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)

Wyświetl plik

@ -50,6 +50,7 @@ Bridgy Fed takes some technical know-how to set up, and there are simpler (but l
<li><a href="#image">How do I include an image in a post?</a></li>
<li><a href="#hashtags">How do I use hashtags?</a></li>
<li><a href="#update">How do I edit an existing post?</a></li>
<li><a href="#delete">How do I delete a post?</a></li>
<li><a href="#fragment">Can I publish just one part of a page?</a></li>
<li><a href="#backfeed">How do fediverse replies, likes, and other interactions show up on my site?</a></li>
<li><a href="#read">How do I read my fediverse timeline/feed?</a></li>
@ -351,6 +352,12 @@ I love scotch. Scotchy scotchy scotch.
</p>
</li>
<li id="delete" class="question">How do I delete a post?</li>
<li class="answer">
<p>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 <a href="https://www.w3.org/TR/activitypub/#delete-activity-outbox"><code>Delete</code> activity</a> for it to the fediverse.
</p>
</li>
<li id="fragment" class="question">Can I publish just one part of a page?</li>
<li class="answer">
<p>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 <code>bar</code> post here:</p>

Wyświetl plik

@ -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(
'<html><head><link rel="webmention" href="/webmention"></html>')
WEBMENTION_NO_REL_LINK = requests_response('<html></html>')
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 = [

Wyświetl plik

@ -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)