diff --git a/activitypub.py b/activitypub.py
index 5796b264..fcb04f31 100644
--- a/activitypub.py
+++ b/activitypub.py
@@ -1,5 +1,4 @@
-"""Handles requests for ActivityPub endpoints: actors, inbox, etc.
-"""
+"""ActivityPub protocol implementation."""
from base64 import b64encode
from hashlib import sha256
import itertools
@@ -29,6 +28,7 @@
)
from models import Follower, Object, Target, User
from protocol import Protocol
+import webmention
logger = logging.getLogger(__name__)
@@ -43,11 +43,11 @@
def default_signature_user():
global _DEFAULT_SIGNATURE_USER
if _DEFAULT_SIGNATURE_USER is None:
- _DEFAULT_SIGNATURE_USER = User.get_or_create('snarfed.org')
+ _DEFAULT_SIGNATURE_USER = webmention.Webmention.get_or_create('snarfed.org')
return _DEFAULT_SIGNATURE_USER
-class ActivityPub(Protocol):
+class ActivityPub(User, Protocol):
"""ActivityPub protocol class."""
LABEL = 'activitypub'
@@ -513,11 +513,12 @@ def actor(domain):
if tld in TLD_BLOCKLIST:
error('', status=404)
- g.user = User.get_by_id(domain)
+ # TODO(#512): parameterize by protocol
+ g.user = webmention.Webmention.get_by_id(domain)
if not g.user:
- return f'User {domain} not found', 404
+ return f'Web user {domain} not found', 404
elif not g.user.actor_as2:
- return f'User {domain} not fully set up', 404
+ return f'Web user {domain} not fully set up', 404
# TODO: unify with common.actor()
actor = postprocess_as2(g.user.actor_as2)
@@ -565,9 +566,10 @@ def inbox(domain=None):
# load user
if domain:
- g.user = User.get_by_id(domain)
+ # TODO(#512): parameterize by protocol
+ g.user = webmention.Webmention.get_by_id(domain)
if not g.user:
- error(f'User {domain} not found', status=404)
+ error(f'Web user {domain} not found', status=404)
ActivityPub.verify_signature(activity)
@@ -603,8 +605,9 @@ def follower_collection(domain, collection):
https://www.w3.org/TR/activitypub/#collections
https://www.w3.org/TR/activitystreams-core/#paging
"""
- if not User.get_by_id(domain):
- return f'User {domain} not found', 404
+ # TODO(#512): parameterize by protocol
+ if not webmention.Webmention.get_by_id(domain):
+ return f'Web user {domain} not found', 404
# page
followers, new_before, new_after = Follower.fetch_page(domain, collection)
diff --git a/app.py b/app.py
index e09d9648..45da5078 100644
--- a/app.py
+++ b/app.py
@@ -6,4 +6,7 @@
from flask_app import app
# import all modules to register their Flask handlers
-import activitypub, convert, follow, pages, redirect, superfeedr, webfinger, webmention, xrpc_actor, xrpc_feed, xrpc_graph
+import activitypub, convert, follow, pages, redirect, superfeedr, ui, webfinger, webmention, xrpc_actor, xrpc_feed, xrpc_graph
+
+import models
+models.reset_protocol_properties()
diff --git a/convert.py b/convert.py
index 7c7b8d32..8402d99f 100644
--- a/convert.py
+++ b/convert.py
@@ -17,8 +17,7 @@
from activitypub import ActivityPub
from common import CACHE_TIME
from flask_app import app, cache
-from models import Object
-from protocol import protocols
+from models import Object, PROTOCOLS
from webmention import Webmention
logger = logging.getLogger(__name__)
@@ -52,7 +51,7 @@ def convert(src, dest, _):
error(f'Expected fully qualified URL; got {url}')
# load, and maybe fetch. if it's a post/update, redirect to inner object.
- obj = protocols[src].load(url)
+ obj = PROTOCOLS[src].load(url)
if not obj.as1:
error(f'Stored object for {id} has no data', status=404)
@@ -60,7 +59,7 @@ def convert(src, dest, _):
if type in ('post', 'update', 'delete'):
obj_id = as1.get_object(obj.as1).get('id')
if obj_id:
- # TODO: protocols[src].load() this instead?
+ # TODO: PROTOCOLS[src].load() this instead?
obj_obj = Object.get_by_id(obj_id)
if (obj_obj and obj_obj.as1 and
not obj_obj.as1.keys() <= set(['id', 'url', 'objectType'])):
@@ -72,7 +71,7 @@ def convert(src, dest, _):
return '', 410
# convert and serve
- return protocols[dest].serve(obj)
+ return PROTOCOLS[dest].serve(obj)
@app.get('/render')
diff --git a/follow.py b/follow.py
index 4d352347..badeefaa 100644
--- a/follow.py
+++ b/follow.py
@@ -20,6 +20,7 @@
from flask_app import app
import common
import models
+from webmention import Webmention
logger = logging.getLogger(__name__)
@@ -80,9 +81,10 @@ def remote_follow():
logger.info(f'Got: {request.values}')
domain = request.values['domain']
- g.user = models.User.get_by_id(domain)
+ # TODO(#512): parameterize by protocol
+ g.user = Webmention.get_by_id(domain)
if not g.user:
- error(f'No Bridgy Fed user found for domain {domain}')
+ error(f'No web user found for domain {domain}')
addr = request.values['address']
webfinger = fetch_webfinger(addr)
@@ -133,9 +135,10 @@ def finish(self, auth_entity, state=None):
session['indieauthed-me'] = me
domain = util.domain_from_link(me)
- g.user = models.User.get_by_id(domain)
+ # TODO(#512): parameterize by protocol
+ g.user = Webmention.get_by_id(domain)
if not g.user:
- error(f'No user for domain {domain}')
+ error(f'No web user for domain {domain}')
domain = g.user.key.id()
addr = state
@@ -220,9 +223,10 @@ def finish(self, auth_entity, state=None):
session['indieauthed-me'] = me
domain = util.domain_from_link(me)
- g.user = models.User.get_by_id(domain)
+ # TODO(#512): parameterize by protocol
+ g.user = Webmention.get_by_id(domain)
if not g.user:
- error(f'No user for domain {domain}')
+ error(f'No web user for domain {domain}')
domain = g.user.key.id()
follower = models.Follower.get_by_id(state)
diff --git a/models.py b/models.py
index 13288e9a..24cf21b3 100644
--- a/models.py
+++ b/models.py
@@ -1,7 +1,6 @@
"""Datastore model classes."""
import base64
from datetime import timedelta, timezone
-import difflib
import itertools
import json
import logging
@@ -22,17 +21,13 @@
from oauth_dropins.webutil.models import ComputedJsonProperty, JsonProperty, StringIdModel
from oauth_dropins.webutil.util import json_dumps, json_loads
import requests
-from werkzeug.exceptions import BadRequest, NotFound
import common
-# https://github.com/snarfed/bridgy-fed/issues/314
-WWW_DOMAINS = frozenset((
- 'www.jvt.me',
-))
-# TODO: eventually load from protocol.protocols instead, if/when we can get
-# around the circular import
-PROTOCOLS = ('activitypub', 'bluesky', 'ostatus', 'webmention', 'ui')
+# maps string label to Protocol subclass. populated by ProtocolUserMeta.
+# seed with old and upcoming protocols that don't have their own classes (yet).
+PROTOCOLS = {'bluesky': None, 'ostatus': None}
+
# 2048 bits makes tests slow, so use 1024 for them
KEY_BITS = 1024 if DEBUG else 2048
PAGE_SIZE = 20
@@ -53,6 +48,21 @@
logger = logging.getLogger(__name__)
+class ProtocolUserMeta(type(ndb.Model)):
+ """:class:`User` metaclass. Registers all subclasses in the PROTOCOLS global."""
+ def __new__(meta, name, bases, class_dict):
+ cls = super().__new__(meta, name, bases, class_dict)
+ if hasattr(cls, 'LABEL'):
+ PROTOCOLS[cls.LABEL] = cls
+ return cls
+
+
+def reset_protocol_properties():
+ """Recreates various protocol properties to include choices PROTOCOLS."""
+ Target.protocol = ndb.StringProperty(choices=list(PROTOCOLS.keys()), required=True)
+ Object.source_protocol = ndb.StringProperty(choices=list(PROTOCOLS.keys()))
+
+
def base64_to_long(x):
"""Converts x from URL safe base64 encoding to a long integer.
@@ -69,24 +79,20 @@ def long_to_base64(x):
return base64.urlsafe_b64encode(number.long_to_bytes(x))
-class User(StringIdModel):
- """Stores a Bridgy Fed user.
-
- The key name is the domain.
+class User(StringIdModel, metaclass=ProtocolUserMeta):
+ """Abstract base class for a Bridgy Fed user.
Stores multiple keypairs needed for the supported protocols. Currently:
* RSA keypair for ActivityPub HTTP Signatures
- properties: mod, public_exponent, private_exponent
+ properties: mod, public_exponent, private_exponent, all encoded as
+ base64url (ie URL-safe base64) strings as described in RFC 4648 and
+ section 5.1 of the Magic Signatures spec
https://tools.ietf.org/html/draft-cavage-http-signatures-12
* P-256 keypair for AT Protocol's signing key
property: p256_key, PEM encoded
https://atproto.com/guides/overview#account-portability
-
- The key pair's modulus and exponent properties are all encoded as base64url
- (ie URL-safe base64) strings as described in RFC 4648 and section 5.1 of the
- Magic Signatures spec.
"""
mod = ndb.StringProperty(required=True)
public_exponent = ndb.StringProperty(required=True)
@@ -101,16 +107,18 @@ class User(StringIdModel):
created = ndb.DateTimeProperty(auto_now_add=True)
updated = ndb.DateTimeProperty(auto_now=True)
+ @classmethod
+ def new(cls, **kwargs):
+ """Try to prevent instantiation. Use subclasses instead."""
+ raise NotImplementedError()
+
+ # TODO(#512): move this and is_homepage to webmention.py?
@property
def homepage(self):
return f'https://{self.key.id()}/'
- @classmethod
- def _get_kind(cls):
- return 'MagicKey'
-
def _post_put_hook(self, future):
- logger.info(f'Wrote User {self.key.id()}')
+ logger.info(f'Wrote {self.key}')
@classmethod
def get_by_id(cls, id):
@@ -121,27 +129,34 @@ def get_by_id(cls, id):
return user
- @staticmethod
+ @classmethod
@ndb.transactional()
- def get_or_create(domain, **kwargs):
+ def get_or_create(cls, domain, **kwargs):
"""Loads and returns a User. Creates it if necessary."""
- user = User.get_by_id(domain)
+ assert cls != User
+ user = cls.get_by_id(domain)
if user:
return user
- # originally from django_salmon.magicsigs
- # this uses urandom(), and does nontrivial math, so it can take a
- # while depending on the amount of randomness available.
- rng = Random.new().read
- rsa_key = RSA.generate(KEY_BITS, rng)
- p256_key = ECC.generate(curve='P-256',
- randfunc=random.randbytes if DEBUG else None)
- user = User(id=domain,
- mod=long_to_base64(rsa_key.n),
- public_exponent=long_to_base64(rsa_key.e),
- private_exponent=long_to_base64(rsa_key.d),
- p256_key=p256_key.export_key(format='PEM'),
- **kwargs)
+ # generate keys for all protocols _except_ our own
+ #
+ # these can use urandom() and do nontrivial math, so they can take time
+ # depending on the amount of randomness available and compute needed.
+ if cls.LABEL != 'activitypub':
+ # originally from django_salmon.magicsigs
+ key = RSA.generate(KEY_BITS, randfunc=random.randbytes if DEBUG else None)
+ kwargs.update({
+ 'mod': long_to_base64(key.n),
+ 'public_exponent': long_to_base64(key.e),
+ 'private_exponent': long_to_base64(key.d),
+ })
+
+ if cls.LABEL != 'atprotocol':
+ key = ECC.generate(
+ curve='P-256', randfunc=random.randbytes if DEBUG else None)
+ kwargs['p256_key'] = key.export_key(format='PEM')
+
+ user = cls(id=domain, **kwargs)
user.put()
return user
@@ -222,67 +237,6 @@ def user_page_link(self):
return f' {name}'
- def verify(self):
- """Fetches site a couple ways to check for redirects and h-card.
-
- Returns: User that was verified. May be different than self! eg if self's
- domain started with www and we switch to the root domain.
- """
- domain = self.key.id()
- logger.info(f'Verifying {domain}')
-
- if domain.startswith('www.') and domain not in WWW_DOMAINS:
- # if root domain redirects to www, use root domain instead
- # https://github.com/snarfed/bridgy-fed/issues/314
- root = domain.removeprefix("www.")
- root_site = f'https://{root}/'
- try:
- resp = util.requests_get(root_site, gateway=False)
- if resp.ok and self.is_homepage(resp.url):
- logger.info(f'{root_site} redirects to {resp.url} ; using {root} instead')
- root_user = User.get_or_create(root)
- self.use_instead = root_user.key
- self.put()
- return root_user.verify()
- except requests.RequestException:
- pass
-
- # check webfinger redirect
- path = f'/.well-known/webfinger?resource=acct:{domain}@{domain}'
- self.has_redirects = False
- self.redirects_error = None
- try:
- url = urllib.parse.urljoin(self.homepage, path)
- resp = util.requests_get(url, gateway=False)
- domain_urls = ([f'https://{domain}/' for domain in common.DOMAINS] +
- [common.host_url()])
- expected = [urllib.parse.urljoin(url, path) for url in domain_urls]
- if resp.ok:
- if resp.url in expected:
- self.has_redirects = True
- elif resp.url:
- diff = '\n'.join(difflib.Differ().compare([resp.url], [expected[0]]))
- self.redirects_error = f'Current vs expected:
{diff}
'
- else:
- lines = [url, f' returned HTTP {resp.status_code}']
- if resp.url != url:
- lines[1:1] = [' redirected to:', resp.url]
- self.redirects_error = '' + '\n'.join(lines) + '
'
- except requests.RequestException:
- pass
-
- # check home page
- try:
- import activitypub, webmention # TODO: actually fix these circular imports
- 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):
- self.actor_as2 = None
- self.has_hcard = False
-
- return self
-
class Target(ndb.Model):
"""Delivery destinations. ActivityPub inboxes, webmention targets, etc.
@@ -301,7 +255,9 @@ class Target(ndb.Model):
https://googleapis.dev/python/python-ndb/latest/model.html#google.cloud.ndb.model.StructuredProperty
"""
uri = ndb.StringProperty(required=True)
- protocol = ndb.StringProperty(choices=PROTOCOLS, required=True)
+ # choices is populated in flask_app, after all User subclasses are created,
+ # so that PROTOCOLS is fully populated
+ protocol = ndb.StringProperty(choices=[], required=True)
class Object(StringIdModel):
@@ -315,8 +271,10 @@ class Object(StringIdModel):
# domains of the Bridgy Fed users this activity is to or from
domains = ndb.StringProperty(repeated=True)
status = ndb.StringProperty(choices=STATUSES)
+ # choices is populated in flask_app, after all User subclasses are created,
+ # so that PROTOCOLS is fully populated
# TODO: remove? is this redundant with the protocol-specific data fields below?
- source_protocol = ndb.StringProperty(choices=PROTOCOLS)
+ source_protocol = ndb.StringProperty(choices=[])
labels = ndb.StringProperty(repeated=True, choices=LABELS)
# TODO: switch back to ndb.JsonProperty if/when they fix it for the web console
diff --git a/pages.py b/pages.py
index 81e7fe5b..b1221284 100644
--- a/pages.py
+++ b/pages.py
@@ -18,6 +18,7 @@
import common
from common import DOMAIN_RE
from models import fetch_page, Follower, Object, PAGE_SIZE, User
+from webmention import Webmention
FOLLOWERS_UI_LIMIT = 999
@@ -49,7 +50,7 @@ def docs():
def enter_web_site():
return render_template('enter_web_site.html')
-
+# TODO(#512): move to webmention.py?
@app.post('/web-site')
def check_web_site():
url = request.values['url']
@@ -58,7 +59,7 @@ def check_web_site():
flash(f'No domain found in {url}')
return render_template('enter_web_site.html')
- g.user = User.get_or_create(domain)
+ g.user = Webmention.get_or_create(domain)
try:
g.user = g.user.verify()
except BaseException as e:
@@ -74,7 +75,7 @@ def check_web_site():
@app.get(f'/user/')
def user(domain):
- g.user = User.get_by_id(domain)
+ g.user = Webmention.get_by_id(domain)
if not g.user:
return USER_NOT_FOUND_HTML, 404
elif g.user.key.id() != domain:
@@ -109,7 +110,7 @@ def user(domain):
@app.get(f'/user//')
def followers_or_following(domain, collection):
- g.user = User.get_by_id(domain) # g.user is used in template
+ g.user = Webmention.get_by_id(domain) # g.user is used in template
if not g.user:
return USER_NOT_FOUND_HTML, 404
@@ -138,7 +139,7 @@ def feed(domain):
if format not in ('html', 'atom', 'rss'):
error(f'format {format} not supported; expected html, atom, or rss')
- g.user = User.get_by_id(domain)
+ g.user = Webmention.get_by_id(domain)
if not g.user:
return render_template('user_not_found.html', domain=domain), 404
diff --git a/protocol.py b/protocol.py
index 6e5f8463..da7e71d1 100644
--- a/protocol.py
+++ b/protocol.py
@@ -10,7 +10,7 @@
import common
from common import error
-from models import Follower, Object, Target, User
+from models import Follower, Object, Target
from oauth_dropins.webutil import util, webmention
from oauth_dropins.webutil.util import json_dumps, json_loads
@@ -45,19 +45,7 @@
logger = logging.getLogger(__name__)
-# maps string label to Protocol subclass. populated by ProtocolMeta.
-protocols = {}
-
-class ProtocolMeta(type):
- """:class:`Protocol` metaclass. Registers all subclasses in the protocols global."""
- def __new__(meta, name, bases, class_dict):
- cls = super().__new__(meta, name, bases, class_dict)
- if cls.LABEL:
- protocols[cls.LABEL] = cls
- return cls
-
-
-class Protocol(metaclass=ProtocolMeta):
+class Protocol:
"""Base protocol class. Not to be instantiated; classmethods only.
Attributes:
diff --git a/redirect.py b/redirect.py
index ef510f22..4736028b 100644
--- a/redirect.py
+++ b/redirect.py
@@ -76,16 +76,18 @@ def redir(to):
to_domain))
for domain in domains:
if domain:
- g.user = User.get_by_id(domain)
+ # TODO(#512): do we need to parameterize this by protocol? or is it
+ # only for web?
+ g.user = Webmention.get_by_id(domain)
if g.user:
- logger.info(f'Found User for domain {domain}')
+ logger.info(f'Found web user for domain {domain}')
break
else:
if accept_as2:
g.external_user = urllib.parse.urljoin(to, '/')
- logging.info(f'No User for {g.external_user}')
+ logging.info(f'No web user for {g.external_user}')
else:
- return f'No user found for any of {domains}', 404
+ return f'No web user found for any of {domains}', 404
if accept_as2:
# AS2 requested, fetch and convert and serve
diff --git a/tests/test_activitypub.py b/tests/test_activitypub.py
index 6ac792b1..2c00497f 100644
--- a/tests/test_activitypub.py
+++ b/tests/test_activitypub.py
@@ -25,10 +25,12 @@
from flask_app import app
import common
import models
-from models import Follower, Object, User
+from models import Follower, Object
import protocol
from protocol import Protocol
-from . import testutil
+from webmention import Webmention
+
+from .testutil import Fake, TestCase
ACTOR = {
'@context': 'https://www.w3.org/ns/activitystreams',
@@ -222,11 +224,12 @@
@patch('requests.post')
@patch('requests.get')
@patch('requests.head')
-class ActivityPubTest(testutil.TestCase):
+class ActivityPubTest(TestCase):
def setUp(self):
super().setUp()
- self.user = self.make_user('user.com', has_hcard=True, actor_as2=ACTOR)
+ self.user = self.make_user('user.com',
+ has_hcard=True, actor_as2=ACTOR)
with self.request_context:
self.key_id_obj = Object(id='http://my/key/id', as2={
**ACTOR,
@@ -1163,12 +1166,12 @@ def test_outbox_empty(self, _, mock_get, __):
}, resp.json)
-class ActivityPubUtilsTest(testutil.TestCase):
+class ActivityPubUtilsTest(TestCase):
def setUp(self):
super().setUp()
self.request_context.push()
- g.user = self.make_user('user.com', has_hcard=True, actor_as2=ACTOR)
-
+ g.user = self.make_user('user.com', has_hcard=True,
+ actor_as2=ACTOR)
def tearDown(self):
self.request_context.pop()
super().tearDown()
@@ -1205,7 +1208,7 @@ def test_postprocess_as2_multiple_image(self):
}))
def test_postprocess_as2_actor_attributedTo(self):
- g.user = User(id='site')
+ g.user = Fake(id='site')
self.assert_equals({
'actor': {
'id': 'baj',
@@ -1255,7 +1258,7 @@ def test_postprocess_as2_hashtag(self):
],
}))
- # TODO: make these generic and use FakeProtocol
+ # TODO: make these generic and use Fake
@patch('requests.get')
def test_load_http(self, mock_get):
mock_get.return_value = AS2
diff --git a/tests/test_common.py b/tests/test_common.py
index e5993d17..8b6954ea 100644
--- a/tests/test_common.py
+++ b/tests/test_common.py
@@ -11,10 +11,10 @@
import common
from models import Object, User
import protocol
-from . import testutil
+from .testutil import Fake, TestCase
-class CommonTest(testutil.TestCase):
+class CommonTest(TestCase):
@classmethod
def setUpClass(cls):
with appengine_config.ndb_client.context():
@@ -24,7 +24,7 @@ def setUpClass(cls):
def setUp(self):
super().setUp()
self.request_context.push()
- g.user = User(id='site')
+ g.user = Fake(id='site')
def tearDown(self):
self.request_context.pop()
diff --git a/tests/test_convert.py b/tests/test_convert.py
index 2ec64e10..86983d6f 100644
--- a/tests/test_convert.py
+++ b/tests/test_convert.py
@@ -9,6 +9,7 @@
from oauth_dropins.webutil.testutil import requests_response
import requests
+import app
from common import CONTENT_TYPE_HTML
from .test_redirect import (
diff --git a/tests/test_follow.py b/tests/test_follow.py
index 2a85419d..34421679 100644
--- a/tests/test_follow.py
+++ b/tests/test_follow.py
@@ -14,6 +14,8 @@
import common
from common import redirect_unwrap
from models import Follower, Object, User
+from webmention import Webmention
+
from . import testutil
WEBFINGER = requests_response({
diff --git a/tests/test_models.py b/tests/test_models.py
index d84b7c0e..95ad80bf 100644
--- a/tests/test_models.py
+++ b/tests/test_models.py
@@ -14,27 +14,24 @@
import common
from models import AtpNode, Follower, Object, OBJECT_EXPIRE_AGE, User
import protocol
-from . import testutil
from .test_activitypub import ACTOR
+from .testutil import Fake, TestCase
-class UserTest(testutil.TestCase):
+
+class UserTest(TestCase):
def setUp(self):
super().setUp()
self.request_context.push()
g.user = self.make_user('y.z')
- self.full_redir = requests_response(
- status=302,
- redirected_url='http://localhost/.well-known/webfinger?resource=acct:y.z@y.z')
-
def tearDown(self):
self.request_context.pop()
super().tearDown()
def test_get_or_create(self):
- user = User.get_or_create('a.b')
+ user = Fake.get_or_create('a.b')
assert user.mod
assert user.public_exponent
@@ -49,15 +46,15 @@ def test_get_or_create(self):
assert isinstance(p256_key, ECC.EccKey)
self.assertEqual('NIST P-256', p256_key.curve)
- same = User.get_or_create('a.b')
+ same = Fake.get_or_create('a.b')
self.assertEqual(same, user)
def test_get_or_create_use_instead(self):
- user = User.get_or_create('a.b')
+ user = Fake.get_or_create('a.b')
user.use_instead = g.user.key
user.put()
- self.assertEqual('y.z', User.get_or_create('a.b').key.id())
+ self.assertEqual('y.z', Fake.get_or_create('a.b').key.id())
def test_href(self):
href = g.user.href()
@@ -90,184 +87,8 @@ def test_address(self):
def test_actor_id(self):
self.assertEqual('http://localhost/y.z', g.user.actor_id())
- def _test_verify(self, redirects, hcard, actor, redirects_error=None):
- got = g.user.verify()
- self.assertEqual(g.user.key, got.key)
-
- with self.subTest(redirects=redirects, hcard=hcard, actor=actor,
- redirects_error=redirects_error):
- self.assert_equals(redirects, bool(g.user.has_redirects))
- self.assert_equals(hcard, bool(g.user.has_hcard))
- if actor is None:
- self.assertIsNone(g.user.actor_as2)
- else:
- got = {k: v for k, v in g.user.actor_as2.items()
- if k in actor}
- self.assert_equals(actor, got)
- self.assert_equals(redirects_error, g.user.redirects_error)
-
- @mock.patch('requests.get')
- def test_verify_neither(self, mock_get):
- empty = requests_response('')
- mock_get.side_effect = [empty, empty]
- self._test_verify(False, False, None)
-
- @mock.patch('requests.get')
- def test_verify_redirect_strips_query_params(self, mock_get):
- half_redir = requests_response(
- status=302, redirected_url='http://localhost/.well-known/webfinger')
- no_hcard = requests_response('')
- mock_get.side_effect = [half_redir, no_hcard]
- self._test_verify(False, False, None, """\
-Current vs expected:- http://localhost/.well-known/webfinger
-+ https://fed.brid.gy/.well-known/webfinger?resource=acct:y.z@y.z
""")
-
- @mock.patch('requests.get')
- def test_verify_multiple_redirects(self, mock_get):
- two_redirs = requests_response(
- status=302, redirected_url=[
- 'https://www.y.z/.well-known/webfinger?resource=acct:y.z@y.z',
- 'http://localhost/.well-known/webfinger?resource=acct:y.z@y.z',
- ])
- no_hcard = requests_response('')
- mock_get.side_effect = [two_redirs, no_hcard]
- self._test_verify(True, False, None)
-
- @mock.patch('requests.get')
- def test_verify_redirect_404(self, mock_get):
- redir_404 = requests_response(status=404, redirected_url='http://this/404s')
- no_hcard = requests_response('')
- mock_get.side_effect = [redir_404, no_hcard]
- self._test_verify(False, False, None, """\
-https://y.z/.well-known/webfinger?resource=acct:y.z@y.z
- redirected to:
-http://this/404s
- returned HTTP 404
""")
-
- @mock.patch('requests.get')
- def test_verify_no_hcard(self, mock_get):
- mock_get.side_effect = [
- self.full_redir,
- requests_response("""
-
-
-
-"""),
- ]
- self._test_verify(True, False, None)
-
- @mock.patch('requests.get')
- def test_verify_non_representative_hcard(self, mock_get):
- bad_hcard = requests_response(
- 'acct:me@y.z',
- url='https://y.z/',
- )
- mock_get.side_effect = [self.full_redir, bad_hcard]
- self._test_verify(True, False, None)
-
- @mock.patch('requests.get')
- def test_verify_both_work(self, mock_get):
- hcard = requests_response("""
-
- me
- Masto
-""",
- url='https://y.z/',
- )
- mock_get.side_effect = [self.full_redir, hcard]
- self._test_verify(True, True, {
- 'type': 'Person',
- 'name': 'me',
- 'url': ['http://localhost/r/https://y.z/', 'acct:myself@y.z'],
- 'preferredUsername': 'y.z',
- })
-
- @mock.patch('requests.get')
- def test_verify_www_redirect(self, mock_get):
- www_user = self.make_user('www.y.z')
-
- empty = requests_response('')
- mock_get.side_effect = [
- requests_response(status=302, redirected_url='https://www.y.z/'),
- empty, empty,
- ]
-
- got = www_user.verify()
- self.assertEqual('y.z', got.key.id())
-
- root_user = User.get_by_id('y.z')
- self.assertEqual(root_user.key, www_user.key.get().use_instead)
- self.assertEqual(root_user.key, User.get_or_create('www.y.z').key)
-
- @mock.patch('requests.get')
- def test_verify_actor_rel_me_links(self, mock_get):
- mock_get.side_effect = [
- self.full_redir,
- requests_response("""
-
-
-
-""", url='https://y.z/'),
- ]
- self._test_verify(True, True, {
- 'attachment': [{
- 'type': 'PropertyValue',
- 'name': 'Mrs. ☕ Foo',
- 'value': 'y.z/about-me',
- }, {
- 'type': 'PropertyValue',
- 'name': 'Web site',
- 'value': 'y.z',
- }, {
- 'type': 'PropertyValue',
- 'name': 'one text',
- 'value': 'one',
- }, {
- 'type': 'PropertyValue',
- 'name': 'two title',
- 'value': 'two',
- }]})
-
- @mock.patch('requests.get')
- def test_verify_override_preferredUsername(self, mock_get):
- mock_get.side_effect = [
- self.full_redir,
- requests_response("""
-
-
- Nick
-
-
-""", url='https://y.z/'),
- ]
- self._test_verify(True, True, {
- # stays y.z despite user's username. since Mastodon queries Webfinger
- # for preferredUsername@fed.brid.gy
- # https://github.com/snarfed/bridgy-fed/issues/77#issuecomment-949955109
- 'preferredUsername': 'y.z',
- })
-
- def test_homepage(self):
- self.assertEqual('https://y.z/', g.user.homepage)
-
- def test_is_homepage(self):
- for url in 'y.z', '//y.z', 'http://y.z', 'https://y.z':
- self.assertTrue(g.user.is_homepage(url), url)
-
- for url in None, '', 'y', 'z', 'z.z', 'ftp://y.z', 'http://y', '://y.z':
- self.assertFalse(g.user.is_homepage(url), url)
-
-
-class ObjectTest(testutil.TestCase):
+
+class ObjectTest(TestCase):
def setUp(self):
super().setUp()
self.request_context.push()
@@ -323,7 +144,7 @@ def test_actor_link(self):
self.assert_multiline_in(expected, obj.actor_link())
def test_actor_link_user(self):
- g.user = User(id='user.com', actor_as2={"name": "Alice"})
+ g.user = Fake(id='user.com', actor_as2={"name": "Alice"})
obj = Object(id='x', source_protocol='ui', domains=['user.com'])
self.assertIn(
'href="/user/user.com"> Alice',
@@ -369,7 +190,7 @@ def test_put_adds_removes_activity_label(self):
self.assertEqual(['user'], obj.labels)
-class FollowerTest(testutil.TestCase):
+class FollowerTest(TestCase):
def setUp(self):
super().setUp()
@@ -391,7 +212,7 @@ def test_to_as2(self):
self.assertEqual(ACTOR, self.outbound.to_as2())
-class AtpNodeTest(testutil.TestCase):
+class AtpNodeTest(TestCase):
def test_create(self):
AtpNode.create(ACTOR_PROFILE_BSKY)
diff --git a/tests/test_pages.py b/tests/test_pages.py
index 2fc7a4bf..f7cbb221 100644
--- a/tests/test_pages.py
+++ b/tests/test_pages.py
@@ -18,15 +18,17 @@
from flask_app import app
import common
from models import Object, Follower, User
-from . import testutil
+from webmention import Webmention
+
from .test_webmention import ACTOR_AS2, ACTOR_HTML, ACTOR_MF2, REPOST_AS2
+from .testutil import Fake, TestCase
def contents(activities):
return [(a.get('object') or a)['content'] for a in activities]
-class PagesTest(testutil.TestCase):
+class PagesTest(TestCase):
EXPECTED = contents([COMMENT, NOTE])
def setUp(self):
@@ -109,7 +111,7 @@ def test_check_web_site(self, mock_get):
self.assert_equals(302, got.status_code)
self.assert_equals('/user/user.com', got.headers['Location'])
- user = User.get_by_id('user.com')
+ user = Webmention.get_by_id('user.com')
self.assertTrue(user.has_hcard)
self.assertEqual('Person', user.actor_as2['type'])
self.assertEqual('http://localhost/user.com', user.actor_as2['id'])
@@ -118,7 +120,7 @@ def test_check_web_site_bad_url(self):
got = self.client.post('/web-site', data={'url': '!!!'})
self.assert_equals(200, got.status_code)
self.assertEqual(['No domain found in !!!'], get_flashed_messages())
- self.assertEqual(1, User.query().count())
+ self.assertEqual(1, Webmention.query().count())
@patch('requests.get')
def test_check_web_site_fetch_fails(self, mock_get):
diff --git a/tests/test_protocol.py b/tests/test_protocol.py
index 312bcc04..cb80443b 100644
--- a/tests/test_protocol.py
+++ b/tests/test_protocol.py
@@ -8,12 +8,11 @@
import protocol
from protocol import Protocol
from flask_app import app
-from models import Follower, Object, User
+from models import Follower, Object, PROTOCOLS, User
from webmention import Webmention
from .test_activitypub import ACTOR, REPLY
-from . import testutil
-from .testutil import FakeProtocol
+from .testutil import Fake, TestCase
REPLY = {
**REPLY,
@@ -25,7 +24,7 @@
}
-class ProtocolTest(testutil.TestCase):
+class ProtocolTest(TestCase):
def setUp(self):
super().setUp()
@@ -38,8 +37,8 @@ def tearDown(self):
super().tearDown()
def test_protocols_global(self):
- self.assertEqual(FakeProtocol, protocol.protocols['fake'])
- self.assertEqual(Webmention, protocol.protocols['webmention'])
+ self.assertEqual(Fake, PROTOCOLS['fake'])
+ self.assertEqual(Webmention, PROTOCOLS['webmention'])
@patch('requests.get')
def test_receive_reply_not_feed_not_notification(self, mock_get):
@@ -65,58 +64,58 @@ def test_receive_reply_not_feed_not_notification(self, mock_get):
)
def test_load(self):
- FakeProtocol.objects['foo'] = {'x': 'y'}
+ Fake.objects['foo'] = {'x': 'y'}
- loaded = FakeProtocol.load('foo')
+ loaded = Fake.load('foo')
self.assert_equals({'x': 'y'}, loaded.our_as1)
self.assertFalse(loaded.changed)
self.assertTrue(loaded.new)
self.assertIsNotNone(Object.get_by_id('foo'))
- self.assertEqual(['foo'], FakeProtocol.fetched)
+ self.assertEqual(['foo'], Fake.fetched)
def test_load_already_stored(self):
stored = Object(id='foo', our_as1={'x': 'y'})
stored.put()
- loaded = FakeProtocol.load('foo')
+ loaded = Fake.load('foo')
self.assert_equals({'x': 'y'}, loaded.our_as1)
self.assertFalse(loaded.changed)
self.assertFalse(loaded.new)
- self.assertEqual([], FakeProtocol.fetched)
+ self.assertEqual([], Fake.fetched)
@patch('requests.get')
def test_load_empty_deleted(self, mock_get):
stored = Object(id='foo', deleted=True)
stored.put()
- loaded = FakeProtocol.load('foo')
+ loaded = Fake.load('foo')
self.assert_entities_equal(stored, loaded)
self.assertFalse(loaded.changed)
self.assertFalse(loaded.new)
- self.assertEqual([], FakeProtocol.fetched)
+ self.assertEqual([], Fake.fetched)
@patch('requests.get')
def test_load_refresh_unchanged(self, mock_get):
obj = Object(id='foo', our_as1={'x': 'stored'})
obj.put()
- FakeProtocol.objects['foo'] = {'x': 'stored'}
+ Fake.objects['foo'] = {'x': 'stored'}
- loaded = FakeProtocol.load('foo', refresh=True)
+ loaded = Fake.load('foo', refresh=True)
self.assert_entities_equal(obj, loaded)
self.assertFalse(obj.changed)
self.assertFalse(obj.new)
- self.assertEqual(['foo'], FakeProtocol.fetched)
+ self.assertEqual(['foo'], Fake.fetched)
@patch('requests.get')
def test_load_refresh_changed(self, mock_get):
Object(id='foo', our_as1={'content': 'stored'}).put()
- FakeProtocol.objects['foo'] = {'content': 'new'}
+ Fake.objects['foo'] = {'content': 'new'}
- loaded = FakeProtocol.load('foo', refresh=True)
+ loaded = Fake.load('foo', refresh=True)
self.assert_equals({'content': 'new'}, loaded.our_as1)
self.assertTrue(loaded.changed)
self.assertFalse(loaded.new)
- self.assertEqual(['foo'], FakeProtocol.fetched)
+ self.assertEqual(['foo'], Fake.fetched)
diff --git a/tests/test_webmention.py b/tests/test_webmention.py
index 8747ec31..0b75a21e 100644
--- a/tests/test_webmention.py
+++ b/tests/test_webmention.py
@@ -162,7 +162,13 @@
class WebmentionTest(testutil.TestCase):
def setUp(self):
super().setUp()
- self.user = self.make_user('user.com')
+ g.user = self.user = self.make_user('user.com')
+
+ self.request_context.push()
+ self.full_redir = requests_response(
+ status=302,
+ redirected_url='http://localhost/.well-known/webfinger?resource=acct:user.com@user.com')
+
self.toot_html = requests_response("""\
@@ -1337,10 +1343,177 @@ def test_update_profile(self, mock_get, mock_post):
labels=['user', 'activity'],
)
+ def _test_verify(self, redirects, hcard, actor, redirects_error=None):
+ got = self.user.verify()
+ self.assertEqual(self.user.key, got.key)
+
+ with self.subTest(redirects=redirects, hcard=hcard, actor=actor,
+ redirects_error=redirects_error):
+ self.assert_equals(redirects, bool(self.user.has_redirects))
+ self.assert_equals(hcard, bool(self.user.has_hcard))
+ if actor is None:
+ self.assertIsNone(self.user.actor_as2)
+ else:
+ got = {k: v for k, v in self.user.actor_as2.items()
+ if k in actor}
+ self.assert_equals(actor, got)
+ self.assert_equals(redirects_error, self.user.redirects_error)
+
+ def test_verify_neither(self, mock_get, _):
+ empty = requests_response('')
+ mock_get.side_effect = [empty, empty]
+ self._test_verify(False, False, None)
+
+ def test_verify_redirect_strips_query_params(self, mock_get, _):
+ half_redir = requests_response(
+ status=302, redirected_url='http://localhost/.well-known/webfinger')
+ no_hcard = requests_response('')
+ mock_get.side_effect = [half_redir, no_hcard]
+ self._test_verify(False, False, None, """\
+Current vs expected:- http://localhost/.well-known/webfinger
++ https://fed.brid.gy/.well-known/webfinger?resource=acct:user.com@user.com
""")
+
+ def test_verify_multiple_redirects(self, mock_get, _):
+ two_redirs = requests_response(
+ status=302, redirected_url=[
+ 'https://www.user.com/.well-known/webfinger?resource=acct:user.com@user.com',
+ 'http://localhost/.well-known/webfinger?resource=acct:user.com@user.com',
+ ])
+ no_hcard = requests_response('')
+ mock_get.side_effect = [two_redirs, no_hcard]
+ self._test_verify(True, False, None)
+
+ def test_verify_redirect_404(self, mock_get, _):
+ redir_404 = requests_response(status=404, redirected_url='http://this/404s')
+ no_hcard = requests_response('')
+ mock_get.side_effect = [redir_404, no_hcard]
+ self._test_verify(False, False, None, """\
+https://user.com/.well-known/webfinger?resource=acct:user.com@user.com
+ redirected to:
+http://this/404s
+ returned HTTP 404
""")
+
+ def test_verify_no_hcard(self, mock_get, _):
+ mock_get.side_effect = [
+ self.full_redir,
+ requests_response("""
+
+
+
+"""),
+ ]
+ self._test_verify(True, False, None)
+
+ def test_verify_non_representative_hcard(self, mock_get, _):
+ bad_hcard = requests_response(
+ 'acct:me@user.com',
+ url='https://user.com/',
+ )
+ mock_get.side_effect = [self.full_redir, bad_hcard]
+ self._test_verify(True, False, None)
+
+ def test_verify_both_work(self, mock_get, _):
+ hcard = requests_response("""
+
+ me
+ Masto
+""",
+ url='https://user.com/',
+ )
+ mock_get.side_effect = [self.full_redir, hcard]
+ self._test_verify(True, True, {
+ 'type': 'Person',
+ 'name': 'me',
+ 'url': ['http://localhost/r/https://user.com/', 'acct:myself@user.com'],
+ 'preferredUsername': 'user.com',
+ })
+
+ def test_verify_www_redirect(self, mock_get, _):
+ www_user = self.make_user('www.user.com')
+
+ empty = requests_response('')
+ mock_get.side_effect = [
+ requests_response(status=302, redirected_url='https://www.user.com/'),
+ empty, empty,
+ ]
+
+ got = www_user.verify()
+ self.assertEqual('user.com', got.key.id())
+
+ root_user = Webmention.get_by_id('user.com')
+ self.assertEqual(root_user.key, www_user.key.get().use_instead)
+ self.assertEqual(root_user.key, Webmention.get_or_create('www.user.com').key)
+
+ def test_verify_actor_rel_me_links(self, mock_get, _):
+ mock_get.side_effect = [
+ self.full_redir,
+ requests_response("""
+
+
+
+""", url='https://user.com/'),
+ ]
+ self._test_verify(True, True, {
+ 'attachment': [{
+ 'type': 'PropertyValue',
+ 'name': 'Mrs. ☕ Foo',
+ 'value': 'user.com/about-me',
+ }, {
+ 'type': 'PropertyValue',
+ 'name': 'Web site',
+ 'value': 'user.com',
+ }, {
+ 'type': 'PropertyValue',
+ 'name': 'one text',
+ 'value': 'one',
+ }, {
+ 'type': 'PropertyValue',
+ 'name': 'two title',
+ 'value': 'two',
+ }]})
+
+ def test_verify_override_preferredUsername(self, mock_get, _):
+ mock_get.side_effect = [
+ self.full_redir,
+ requests_response("""
+
+
+ Nick
+
+
+""", url='https://user.com/'),
+ ]
+ self._test_verify(True, True, {
+ # stays y.z despite user's username. since Mastodon queries Webfinger
+ # for preferredUsername@fed.brid.gy
+ # https://github.com/snarfed/bridgy-fed/issues/77#issuecomment-949955109
+ 'preferredUsername': 'user.com',
+ })
+
+ def test_homepage(self, _, __):
+ self.assertEqual('https://user.com/', self.user.homepage)
+
+ def test_is_homepage(self, _, __):
+ for url in 'user.com', '//user.com', 'http://user.com', 'https://user.com':
+ self.assertTrue(self.user.is_homepage(url), url)
+
+ for url in (None, '', 'user', 'com', 'com.user', 'ftp://user.com',
+ 'https://user', '://user.com'):
+ self.assertFalse(self.user.is_homepage(url), url)
+
@mock.patch('requests.post')
@mock.patch('requests.get')
-class WebmentionUtilTest(testutil.TestCase):
+class WebmentionProtocolTest(testutil.TestCase):
def setUp(self):
super().setUp()
diff --git a/tests/testutil.py b/tests/testutil.py
index 4e7294fa..96577243 100644
--- a/tests/testutil.py
+++ b/tests/testutil.py
@@ -25,23 +25,17 @@
# load all Flask handlers
import app
from flask_app import app, cache, init_globals
-import activitypub, common
+import activitypub
+import common
+import models
from models import Object, PROTOCOLS, Target, User
import protocol
-
+from webmention import Webmention
logger = logging.getLogger(__name__)
-# used in TestCase.make_user() to reuse keys across Users since they're
-# expensive to generate
-requests.post(f'http://{ndb_client.host}/reset')
-with ndb_client.context():
- global_user = User.get_or_create('user.com')
-
-Object.source_protocol = ndb.StringProperty(choices=PROTOCOLS + ('fake',))
-
-class FakeProtocol(protocol.Protocol):
+class Fake(User, protocol.Protocol):
LABEL = 'fake'
# maps string ids to dict AS1 objects. send adds objects here, fetch
@@ -56,14 +50,14 @@ class FakeProtocol(protocol.Protocol):
@classmethod
def send(cls, obj, url, log_data=True):
- logger.info(f'FakeProtocol.send {url}')
+ logger.info(f'Fake.send {url}')
cls.sent.append((obj, url))
cls.objects[obj.key.id()] = obj
@classmethod
def fetch(cls, obj):
id = obj.key.id()
- logger.info(f'FakeProtocol.load {id}')
+ logger.info(f'Fake.load {id}')
cls.fetched.append(id)
if id in cls.objects:
@@ -74,11 +68,21 @@ def fetch(cls, obj):
@classmethod
def serve(cls, obj):
- logger.info(f'FakeProtocol.load {obj.key.id()}')
- return (f'FakeProtocol object {obj.key.id()}',
+ logger.info(f'Fake.load {obj.key.id()}')
+ return (f'Fake object {obj.key.id()}',
{'Accept': 'fake/protocol'})
+# used in TestCase.make_user() to reuse keys across Users since they're
+# expensive to generate
+requests.post(f'http://{ndb_client.host}/reset')
+with ndb_client.context():
+ global_user = Fake.get_or_create('user.com')
+
+
+models.reset_protocol_properties()
+
+
class TestCase(unittest.TestCase, testutil.Asserts):
maxDiff = None
@@ -91,9 +95,9 @@ def setUp(self):
protocol.objects_cache.clear()
common.webmention_discover.cache.clear()
- FakeProtocol.objects = {}
- FakeProtocol.sent = []
- FakeProtocol.fetched = []
+ Fake.objects = {}
+ Fake.sent = []
+ Fake.fetched = []
# make random test data deterministic
arroba.util._clockid = 17
@@ -122,15 +126,16 @@ def tearDown(self):
self.client.__exit__(None, None, None)
super().tearDown()
+ # TODO(#512): switch default to Fake, start using that more
@staticmethod
- def make_user(domain, **kwargs):
+ def make_user(domain, cls=Webmention, **kwargs):
"""Reuse RSA key across Users because generating it is expensive."""
- user = User(id=domain,
- mod=global_user.mod,
- public_exponent=global_user.public_exponent,
- private_exponent=global_user.private_exponent,
- p256_key=global_user.p256_key,
- **kwargs)
+ user = cls(id=domain,
+ mod=global_user.mod,
+ public_exponent=global_user.public_exponent,
+ private_exponent=global_user.private_exponent,
+ p256_key=global_user.p256_key,
+ **kwargs)
user.put()
return user
diff --git a/ui.py b/ui.py
index 7396e46c..a9182c40 100644
--- a/ui.py
+++ b/ui.py
@@ -2,8 +2,9 @@
Needed for serving /convert/ui/webmention/... requests.
"""
+from models import User
from protocol import Protocol
-class UIProtocol(Protocol):
+class UIProtocol(User, Protocol):
LABEL = 'ui'
diff --git a/webfinger.py b/webfinger.py
index 1f62a674..cc321178 100644
--- a/webfinger.py
+++ b/webfinger.py
@@ -45,7 +45,7 @@ def template_vars(self, domain=None, external=False):
if domain.split('.')[-1] in NON_TLDS:
error(f"{domain} doesn't look like a domain", status=404)
- g.user = User.get_by_id(domain)
+ g.user = Webmention.get_by_id(domain)
if g.user:
actor = g.user.to_as1() or {}
homepage = g.user.homepage
diff --git a/webmention.py b/webmention.py
index 213a4d01..6c95bf37 100644
--- a/webmention.py
+++ b/webmention.py
@@ -1,7 +1,7 @@
"""Handles inbound webmentions."""
+import difflib
import logging
-import urllib.parse
-from urllib.parse import urlencode, urlparse
+from urllib.parse import urlencode, urljoin, urlparse
import feedparser
from flask import g, redirect, request
@@ -14,16 +14,15 @@
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 oauth_dropins.webutil.webmention as webutil_webmention
from requests import HTTPError, RequestException, URLRequired
-from werkzeug.exceptions import BadGateway, BadRequest, HTTPException
+from werkzeug.exceptions import BadGateway, BadRequest, HTTPException, NotFound
-from activitypub import ActivityPub
+import activitypub
from flask_app import app
import common
-from models import Follower, Object, Target, User
-import models
-from protocol import Protocol, protocols
+from models import Follower, Object, PROTOCOLS, Target, User
+from protocol import Protocol
logger = logging.getLogger(__name__)
@@ -32,11 +31,85 @@
CHAR_AFTER_SPACE = chr(ord(' ') + 1)
+# https://github.com/snarfed/bridgy-fed/issues/314
+WWW_DOMAINS = frozenset((
+ 'www.jvt.me',
+))
-class Webmention(Protocol):
- """Webmention protocol implementation."""
+
+class Webmention(User, Protocol):
+ """Webmention user and protocol implementation.
+
+ The key name is the domain.
+ """
LABEL = 'webmention'
+ @classmethod
+ def _get_kind(cls):
+ return 'MagicKey'
+
+ def verify(self):
+ """Fetches site a couple ways to check for redirects and h-card.
+
+
+ Returns: :class:`Webmention` that was verified. May be different than
+ self! eg if self's domain started with www and we switch to the root
+ domain.
+ """
+ domain = self.key.id()
+ logger.info(f'Verifying {domain}')
+
+ if domain.startswith('www.') and domain not in WWW_DOMAINS:
+ # if root domain redirects to www, use root domain instead
+ # https://github.com/snarfed/bridgy-fed/issues/314
+ root = domain.removeprefix("www.")
+ root_site = f'https://{root}/'
+ try:
+ resp = util.requests_get(root_site, gateway=False)
+ if resp.ok and self.is_homepage(resp.url):
+ logger.info(f'{root_site} redirects to {resp.url} ; using {root} instead')
+ root_user = Webmention.get_or_create(root)
+ self.use_instead = root_user.key
+ self.put()
+ return root_user.verify()
+ except RequestException:
+ pass
+
+ # check webfinger redirect
+ path = f'/.well-known/webfinger?resource=acct:{domain}@{domain}'
+ self.has_redirects = False
+ self.redirects_error = None
+ try:
+ url = urljoin(self.homepage, path)
+ resp = util.requests_get(url, gateway=False)
+ domain_urls = ([f'https://{domain}/' for domain in common.DOMAINS] +
+ [common.host_url()])
+ expected = [urljoin(url, path) for url in domain_urls]
+ if resp.ok:
+ if resp.url in expected:
+ self.has_redirects = True
+ elif resp.url:
+ diff = '\n'.join(difflib.Differ().compare([resp.url], [expected[0]]))
+ self.redirects_error = f'Current vs expected:{diff}
'
+ else:
+ lines = [url, f' returned HTTP {resp.status_code}']
+ if resp.url != url:
+ lines[1:1] = [' redirected to:', resp.url]
+ self.redirects_error = '' + '\n'.join(lines) + '
'
+ except RequestException:
+ pass
+
+ # check home page
+ try:
+ obj = Webmention.load(self.homepage, gateway=True)
+ self.actor_as2 = activitypub.postprocess_as2(as2.from_as1(obj.as1))
+ self.has_hcard = True
+ except (BadRequest, NotFound):
+ self.actor_as2 = None
+ self.has_hcard = False
+
+ return self
+
@classmethod
def send(cls, obj, url):
"""Sends a webmention to a given target URL.
@@ -48,7 +121,7 @@ def send(cls, obj, url):
endpoint = common.webmention_discover(url).endpoint
if endpoint:
- webmention.send(endpoint, source_url, url)
+ webutil_webmention.send(endpoint, source_url, url)
return True
@classmethod
@@ -130,7 +203,7 @@ def serve(cls, obj):
"""Serves an :class:`Object` as HTML."""
obj_as1 = obj.as1
- from_proto = protocols.get(obj.source_protocol)
+ from_proto = PROTOCOLS.get(obj.source_protocol)
if from_proto:
# fill in author/actor if available
for field in 'author', 'actor':
@@ -167,7 +240,7 @@ def webmention_external():
error(f'Bad URL {source}')
domain = util.domain_from_link(source, minimize=False)
- g.user = User.get_by_id(domain)
+ g.user = Webmention.get_by_id(domain)
if not g.user:
error(f'No user found for domain {domain}')
@@ -215,7 +288,7 @@ def webmention_task():
domain = util.domain_from_link(source, minimize=False)
logger.info(f'webmention from {domain}')
- g.user = User.get_by_id(domain)
+ g.user = Webmention.get_by_id(domain)
if not g.user:
error(f'No user found for domain {domain}', status=304)
@@ -373,7 +446,7 @@ def webmention_task():
obj.target_as2 = target_as2
try:
- last = ActivityPub.send(obj, inbox, log_data=log_data)
+ last = activitypub.ActivityPub.send(obj, inbox, log_data=log_data)
obj.delivered.append(target)
last_success = last
except BaseException as e:
@@ -429,7 +502,7 @@ def _activitypub_targets(obj):
# fetch target page as AS2 object
try:
# TODO: make this generic across protocols
- target_stored = ActivityPub.load(target)
+ target_stored = activitypub.ActivityPub.load(target)
target_obj = target_stored.as2 or as2.from_as1(target_stored.as1)
except (HTTPError, BadGateway) as e:
resp = getattr(e, 'requests_response', None)
@@ -455,7 +528,7 @@ def _activitypub_targets(obj):
if not inbox_url:
# fetch actor as AS object
# TODO: make this generic across protocols
- actor_obj = ActivityPub.load(actor)
+ actor_obj = activitypub.ActivityPub.load(actor)
actor = actor_obj.as2 or as2.from_as1(actor_obj.as1)
inbox_url = actor.get('inbox')
@@ -464,7 +537,7 @@ def _activitypub_targets(obj):
logger.error('Target actor has no inbox')
continue
- inbox_url = urllib.parse.urljoin(target, inbox_url)
+ inbox_url = urljoin(target, inbox_url)
inboxes_to_targets[inbox_url] = target_obj
if not targets or verb == 'share':
diff --git a/xrpc_actor.py b/xrpc_actor.py
index bdf92f7d..a38541cd 100644
--- a/xrpc_actor.py
+++ b/xrpc_actor.py
@@ -10,6 +10,7 @@
from flask_app import xrpc_server
from models import User
+from webmention import Webmention
logger = logging.getLogger(__name__)
@@ -24,7 +25,7 @@ def getProfile(input, actor=None):
if not actor or not re.match(util.DOMAIN_RE, actor):
raise ValueError(f'{actor} is not a domain')
- g.user = User.get_by_id(actor)
+ g.user = Webmention.get_by_id(actor)
if not g.user:
raise ValueError(f'User {actor} not found')
elif not g.user.actor_as2:
diff --git a/xrpc_feed.py b/xrpc_feed.py
index 161c9e65..e496a47f 100644
--- a/xrpc_feed.py
+++ b/xrpc_feed.py
@@ -10,6 +10,7 @@
from flask_app import xrpc_server
from models import Object, PAGE_SIZE, User
+from webmention import Webmention
logger = logging.getLogger(__name__)
@@ -22,7 +23,7 @@ def getAuthorFeed(input, author=None, limit=None, before=None):
if not author or not re.match(util.DOMAIN_RE, author):
raise ValueError(f'{author} is not a domain')
- g.user = User.get_by_id(author)
+ g.user = Webmention.get_by_id(author)
if not g.user:
raise ValueError(f'User {author} not found')
elif not g.user.actor_as2:
diff --git a/xrpc_graph.py b/xrpc_graph.py
index b6f8d27a..de6c52b9 100644
--- a/xrpc_graph.py
+++ b/xrpc_graph.py
@@ -8,6 +8,7 @@
from flask_app import xrpc_server
import common
from models import Follower, User
+from webmention import Webmention
logger = logging.getLogger(__name__)
@@ -25,7 +26,7 @@ def get_followers(query_prop, output_field, user=None, limit=50, before=None):
# TODO: what is user?
if not user or not re.match(util.DOMAIN_RE, user):
raise ValueError(f'{user} is not a domain')
- elif not User.get_by_id(user):
+ elif not Webmention.get_by_id(user):
raise ValueError(f'Unknown user {user}')
collection = 'followers' if output_field == 'followers' else 'following'