kopia lustrzana https://github.com/snarfed/bridgy-fed
AP actor serving: resolve handles
rodzic
6ae57bfd94
commit
325f8b3931
|
@ -3,6 +3,7 @@ from base64 import b64encode
|
|||
from hashlib import sha256
|
||||
import itertools
|
||||
import logging
|
||||
import re
|
||||
from urllib.parse import quote_plus, urljoin
|
||||
|
||||
from flask import abort, g, request
|
||||
|
@ -22,11 +23,11 @@ from common import (
|
|||
add,
|
||||
CACHE_TIME,
|
||||
CONTENT_TYPE_HTML,
|
||||
DOMAIN_RE,
|
||||
error,
|
||||
host_url,
|
||||
redirect_unwrap,
|
||||
redirect_wrap,
|
||||
TLD_BLOCKLIST,
|
||||
)
|
||||
from models import Follower, Object, PROTOCOLS, User
|
||||
from protocol import Protocol
|
||||
|
@ -702,22 +703,30 @@ def postprocess_as2_actor(actor, wrap=True):
|
|||
return actor
|
||||
|
||||
|
||||
@app.get(f'/ap/<any({",".join(PROTOCOLS)}):protocol>/<regex("{common.DOMAIN_RE}"):domain>')
|
||||
@app.get(f'/ap/<any({",".join(PROTOCOLS)}):protocol>/<handle_or_id>')
|
||||
# special case Web users without /ap/web/ prefix, for backward compatibility
|
||||
@app.get(f'/<regex("{common.DOMAIN_RE}"):domain>', defaults={'protocol': 'web'})
|
||||
@app.get(f'/<regex("{DOMAIN_RE}"):handle_or_id>', defaults={'protocol': 'web'})
|
||||
@flask_util.cached(cache, CACHE_TIME)
|
||||
def actor(protocol, domain):
|
||||
def actor(protocol, handle_or_id):
|
||||
"""Serves a user's AS2 actor from the datastore."""
|
||||
tld = domain.split('.')[-1]
|
||||
if tld in TLD_BLOCKLIST:
|
||||
error('', status=404)
|
||||
|
||||
cls = PROTOCOLS[protocol]
|
||||
g.user = cls.get_or_create(domain)
|
||||
if not g.user.obj or not g.user.obj.as1:
|
||||
g.user.obj = cls.load(f'https://{domain}/', gateway=True)
|
||||
|
||||
# TODO: unify with common.actor()
|
||||
if cls.owns_id(handle_or_id) is False:
|
||||
if cls.owns_handle(handle_or_id) is False:
|
||||
error(f"{handle_or_id} doesn't look like a {cls.LABEL} id or handle",
|
||||
status=404)
|
||||
id = cls.handle_to_id(handle_or_id)
|
||||
if not id:
|
||||
error(f"Couldn't resolve {handle_or_id} as a {cls.LABEL} handle",
|
||||
status=404)
|
||||
else:
|
||||
id = handle_or_id
|
||||
|
||||
assert id
|
||||
g.user = cls.get_or_create(id)
|
||||
if not g.user.obj or not g.user.obj.as1:
|
||||
g.user.obj = cls.load(g.user.profile_id(), gateway=True)
|
||||
|
||||
actor = g.user.as2() or {
|
||||
'@context': [as2.CONTEXT],
|
||||
'type': 'Person',
|
||||
|
@ -725,14 +734,15 @@ def actor(protocol, domain):
|
|||
actor = postprocess_as2(actor)
|
||||
actor.update({
|
||||
'id': g.user.ap_actor(),
|
||||
# This has to be the domain for Mastodon etc interop! It seems like it
|
||||
# should be the custom username from the acct: u-url in their h-card,
|
||||
# but that breaks Mastodon's Webfinger discovery. Background:
|
||||
# This has to be the id (domain for Web) for Mastodon etc interop! It
|
||||
# seems like it should be the custom username from the acct: u-url in
|
||||
# their h-card, but that breaks Mastodon's Webfinger discovery.
|
||||
# Background:
|
||||
# https://docs.joinmastodon.org/spec/activitypub/#properties-used-1
|
||||
# https://docs.joinmastodon.org/spec/webfinger/#mastodons-requirements-for-webfinger
|
||||
# https://github.com/snarfed/bridgy-fed/issues/302#issuecomment-1324305460
|
||||
# https://github.com/snarfed/bridgy-fed/issues/77
|
||||
'preferredUsername': domain,
|
||||
'preferredUsername': id,
|
||||
'inbox': g.user.ap_actor('inbox'),
|
||||
'outbox': g.user.ap_actor('outbox'),
|
||||
'following': g.user.ap_actor('following'),
|
||||
|
@ -740,8 +750,8 @@ def actor(protocol, domain):
|
|||
'endpoints': {
|
||||
'sharedInbox': host_url('/ap/sharedInbox'),
|
||||
},
|
||||
# add this if we ever change the Web actor ids to be /web/[domain]
|
||||
# 'alsoKnownAs': [host_url(domain)],
|
||||
# add this if we ever change the Web actor ids to be /web/[id]
|
||||
# 'alsoKnownAs': [host_url(id)],
|
||||
})
|
||||
|
||||
logger.info(f'Returning: {json_dumps(actor, indent=2)}')
|
||||
|
@ -752,10 +762,10 @@ def actor(protocol, domain):
|
|||
|
||||
|
||||
@app.post('/ap/sharedInbox')
|
||||
@app.post(f'/ap/<any({",".join(PROTOCOLS)}):protocol>/<regex("{common.DOMAIN_RE}"):domain>/inbox')
|
||||
@app.post(f'/ap/<any({",".join(PROTOCOLS)}):protocol>/<regex("{DOMAIN_RE}"):domain>/inbox')
|
||||
# special case Web users without /ap/web/ prefix, for backward compatibility
|
||||
@app.post('/inbox')
|
||||
@app.post(f'/<regex("{common.DOMAIN_RE}"):domain>/inbox', defaults={'protocol': 'web'})
|
||||
@app.post(f'/<regex("{DOMAIN_RE}"):domain>/inbox', defaults={'protocol': 'web'})
|
||||
def inbox(protocol=None, domain=None):
|
||||
"""Handles ActivityPub inbox delivery."""
|
||||
# parse and validate AS2 activity
|
||||
|
@ -823,9 +833,9 @@ def inbox(protocol=None, domain=None):
|
|||
return ActivityPub.receive(obj)
|
||||
|
||||
|
||||
@app.get(f'/ap/<any({",".join(PROTOCOLS)}):protocol>/<regex("{common.DOMAIN_RE}"):domain>/<any(followers,following):collection>')
|
||||
@app.get(f'/ap/<any({",".join(PROTOCOLS)}):protocol>/<regex("{DOMAIN_RE}"):domain>/<any(followers,following):collection>')
|
||||
# special case Web users without /ap/web/ prefix, for backward compatibility
|
||||
@app.get(f'/<regex("{common.DOMAIN_RE}"):domain>/<any(followers,following):collection>',
|
||||
@app.get(f'/<regex("{DOMAIN_RE}"):domain>/<any(followers,following):collection>',
|
||||
defaults={'protocol': 'web'})
|
||||
@flask_util.cached(cache, CACHE_TIME)
|
||||
def follower_collection(protocol, domain, collection):
|
||||
|
@ -878,9 +888,9 @@ def follower_collection(protocol, domain, collection):
|
|||
return collection, {'Content-Type': as2.CONTENT_TYPE}
|
||||
|
||||
|
||||
@app.get(f'/ap/<any({",".join(PROTOCOLS)}):protocol>/<regex("{common.DOMAIN_RE}"):domain>/outbox')
|
||||
@app.get(f'/ap/<any({",".join(PROTOCOLS)}):protocol>/<regex("{DOMAIN_RE}"):domain>/outbox')
|
||||
# special case Web users without /ap/web/ prefix, for backward compatibility
|
||||
@app.get(f'/<regex("{common.DOMAIN_RE}"):domain>/outbox', defaults={'protocol': 'web'})
|
||||
@app.get(f'/<regex("{DOMAIN_RE}"):domain>/outbox', defaults={'protocol': 'web'})
|
||||
def outbox(protocol, domain):
|
||||
return {
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
|
|
5
app.py
5
app.py
|
@ -6,7 +6,10 @@ registered.
|
|||
from flask_app import app
|
||||
|
||||
# import all modules to register their Flask handlers
|
||||
import activitypub, atproto, convert, follow, pages, redirect, superfeedr, ui, webfinger, web
|
||||
import atproto, convert, follow, pages, redirect, superfeedr, ui, webfinger, web
|
||||
# import after others because it has URL routes that use PROTOCOLS
|
||||
# TODO: figure out a better way
|
||||
import activitypub
|
||||
|
||||
import models
|
||||
models.reset_protocol_properties()
|
||||
|
|
|
@ -30,7 +30,7 @@ from web import Web
|
|||
|
||||
# have to import module, not attrs, to avoid circular import
|
||||
from . import test_web
|
||||
from .test_webfinger import WEBFINGER
|
||||
from . import test_webfinger
|
||||
|
||||
ACTOR = {
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
|
@ -73,6 +73,24 @@ ACTOR_BASE_FULL = {
|
|||
'value': '<a rel="me" href="https://user.com/"><span class="invisible">https://</span>user.com<span class="invisible">/</span></a>',
|
||||
}],
|
||||
}
|
||||
ACTOR_FAKE = {
|
||||
'@context': ['https://w3id.org/security/v1'],
|
||||
'type': 'Person',
|
||||
'id': 'http://bf/fake/fake:user/ap',
|
||||
'preferredUsername': 'fake:user',
|
||||
'url': 'http://localhost/r/fake:user',
|
||||
'summary': '',
|
||||
'inbox': 'http://bf/fake/fake:user/ap/inbox',
|
||||
'outbox': 'http://bf/fake/fake:user/ap/outbox',
|
||||
'following': 'http://bf/fake/fake:user/ap/following',
|
||||
'followers': 'http://bf/fake/fake:user/ap/followers',
|
||||
'endpoints': {'sharedInbox': 'http://localhost/ap/sharedInbox'},
|
||||
'publicKey': {
|
||||
'id': 'http://localhost/fake#key',
|
||||
'owner': 'http://localhost/fake',
|
||||
'publicKeyPem': 'populated in setUp()',
|
||||
},
|
||||
}
|
||||
REPLY_OBJECT = {
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
'type': 'Note',
|
||||
|
@ -280,7 +298,9 @@ class ActivityPubTest(TestCase):
|
|||
self.swentel_key = ndb.Key(ActivityPub, 'https://mas.to/users/swentel')
|
||||
self.masto_actor_key = ndb.Key(ActivityPub, 'https://mas.to/actor')
|
||||
|
||||
ACTOR_BASE['publicKey']['publicKeyPem'] = self.user.public_pem().decode()
|
||||
ACTOR_BASE['publicKey']['publicKeyPem'] = \
|
||||
ACTOR_FAKE['publicKey']['publicKeyPem'] = \
|
||||
self.user.public_pem().decode()
|
||||
|
||||
self.key_id_obj = Object(id='http://my/key/id', as2={
|
||||
**ACTOR,
|
||||
|
@ -316,33 +336,16 @@ class ActivityPubTest(TestCase):
|
|||
return self.client.post(path, data=body, headers=self.sign(path, body))
|
||||
|
||||
def test_actor_fake(self, *_):
|
||||
self.make_user('user.com', cls=Fake, obj_as2={
|
||||
self.make_user('fake:user', cls=Fake, obj_as2={
|
||||
'type': 'Person',
|
||||
'id': 'https://user.com/',
|
||||
'id': 'fake:user',
|
||||
})
|
||||
|
||||
got = self.client.get('/ap/fake/user.com')
|
||||
got = self.client.get('/ap/fake/fake:user')
|
||||
self.assertEqual(200, got.status_code, got.get_data(as_text=True))
|
||||
type = got.headers['Content-Type']
|
||||
self.assertTrue(type.startswith(as2.CONTENT_TYPE), type)
|
||||
self.assertEqual({
|
||||
'@context': ['https://w3id.org/security/v1'],
|
||||
'type': 'Person',
|
||||
'id': 'http://bf/fake/user.com/ap',
|
||||
'preferredUsername': 'user.com',
|
||||
'url': 'http://localhost/r/user.com',
|
||||
'summary': '',
|
||||
'inbox': 'http://bf/fake/user.com/ap/inbox',
|
||||
'outbox': 'http://bf/fake/user.com/ap/outbox',
|
||||
'following': 'http://bf/fake/user.com/ap/following',
|
||||
'followers': 'http://bf/fake/user.com/ap/followers',
|
||||
'endpoints': {'sharedInbox': 'http://localhost/ap/sharedInbox'},
|
||||
'publicKey': {
|
||||
'id': 'http://localhost/user.com#key',
|
||||
'owner': 'http://localhost/user.com',
|
||||
'publicKeyPem': self.user.public_pem().decode(),
|
||||
},
|
||||
}, got.json)
|
||||
self.assertEqual(ACTOR_FAKE, got.json)
|
||||
|
||||
def test_actor_web(self, *_):
|
||||
"""Web users are special cased to drop the /web/ prefix."""
|
||||
|
@ -383,11 +386,33 @@ class ActivityPubTest(TestCase):
|
|||
self.assertEqual(200, got.status_code)
|
||||
self.assert_equals(ACTOR_BASE, got.json, ignore=['publicKeyPem'])
|
||||
|
||||
def test_actor_new_user_fetch_fails(self, _, mock_get, __):
|
||||
def test_actor_new_user_fetch_fails(self, _, mock_get, ___):
|
||||
mock_get.side_effect = ReadTimeoutError(None, None, None)
|
||||
got = self.client.get('/nope.com')
|
||||
self.assertEqual(504, got.status_code)
|
||||
|
||||
def test_actor_handle_existing_user(self, _, __, ___):
|
||||
self.make_user('fake:user', cls=Fake, obj_as2=ACTOR)
|
||||
got = self.client.get('/ap/fake/fake:handle:user')
|
||||
self.assertEqual(200, got.status_code)
|
||||
self.assert_equals({
|
||||
**ACTOR,
|
||||
**ACTOR_FAKE,
|
||||
}, got.json, ignore=['publicKeyPem'])
|
||||
|
||||
def test_actor_handle_new_user(self, _, __, ___):
|
||||
Fake.fetchable['fake:user'] = as2.to_as1(ACTOR)
|
||||
got = self.client.get('/ap/fake/fake:handle:user')
|
||||
self.assertEqual(200, got.status_code)
|
||||
self.assert_equals({
|
||||
**ACTOR,
|
||||
**ACTOR_FAKE,
|
||||
}, got.json, ignore=['publicKeyPem'])
|
||||
|
||||
def test_actor_handle_user_fetch_fails(self, _, __, ___):
|
||||
got = self.client.get('/ap/fake/fake:handle:nope')
|
||||
self.assertEqual(404, got.status_code)
|
||||
|
||||
def test_individual_inbox_no_user(self, mock_head, mock_get, mock_post):
|
||||
self.user.key.delete()
|
||||
|
||||
|
@ -1514,8 +1539,9 @@ class ActivityPubUtilsTest(TestCase):
|
|||
self.assertEqual('http://inst.com/@user',
|
||||
ActivityPub.handle_to_id('@user@inst.com'))
|
||||
|
||||
@patch('requests.get', return_value=requests_response(WEBFINGER))
|
||||
@patch('requests.get')
|
||||
def test_handle_to_id_fetch(self, mock_get):
|
||||
mock_get.return_value = requests_response(test_webfinger.WEBFINGER)
|
||||
self.assertEqual('http://localhost/user.com',
|
||||
ActivityPub.handle_to_id('@user@inst.com'))
|
||||
self.assert_req(
|
||||
|
|
|
@ -84,7 +84,7 @@ class Fake(User, protocol.Protocol):
|
|||
|
||||
@classmethod
|
||||
def owns_id(cls, id):
|
||||
if id.startswith('nope'):
|
||||
if id.startswith('nope') or id == 'fake:nope':
|
||||
return False
|
||||
|
||||
return ((id.startswith('fake:') and not id.startswith('fake:handle:'))
|
||||
|
@ -96,6 +96,8 @@ class Fake(User, protocol.Protocol):
|
|||
|
||||
@classmethod
|
||||
def handle_to_id(cls, handle):
|
||||
if handle == 'fake:handle:nope':
|
||||
return None
|
||||
return handle.replace('fake:handle:', 'fake:')
|
||||
|
||||
@classmethod
|
||||
|
|
Ładowanie…
Reference in New Issue