AP actor serving: resolve handles

pull/646/head
Ryan Barrett 2023-09-23 13:53:17 -07:00
rodzic 6ae57bfd94
commit 325f8b3931
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: 6BE31FDF4776E9D4
4 zmienionych plików z 92 dodań i 51 usunięć

Wyświetl plik

@ -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
Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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