kopia lustrzana https://github.com/snarfed/bridgy-fed
Porównaj commity
20 Commity
233e6b3815
...
d22f565f71
Autor | SHA1 | Data |
---|---|---|
dependabot[bot] | d22f565f71 | |
dependabot[bot] | 0ed24c19b3 | |
Ryan Barrett | 133d640f1d | |
Ryan Barrett | 817ef1d5d6 | |
Ryan Barrett | 0d549dc039 | |
dependabot[bot] | c43a94d0d7 | |
dependabot[bot] | 2bc877da71 | |
dependabot[bot] | 6ceb417026 | |
Ryan Barrett | 561c763fd4 | |
Ryan Barrett | c1acec1a3f | |
Ryan Barrett | bf296802d8 | |
Ryan Barrett | 7009267bb0 | |
Ryan Barrett | 056644d19e | |
Ryan Barrett | 5767ffabb5 | |
Ryan Barrett | 131cbd9eb6 | |
Ryan Barrett | c57e905204 | |
Ryan Barrett | a45917cec8 | |
Ryan Barrett | e3f2c2e0bc | |
Ryan Barrett | 2b7598cf94 | |
dependabot[bot] | 2f824410ff |
|
@ -54,6 +54,8 @@ _INSTANCE_ACTOR = None
|
|||
# populated in User.status
|
||||
WEB_OPT_OUT_DOMAINS = None
|
||||
|
||||
FEDI_URL_RE = re.compile(r'https://[^/]+/(@|users/)([^/@]+)(@[^/@]+)?(/(?:statuses/)?[0-9]+)?')
|
||||
|
||||
|
||||
def instance_actor():
|
||||
global _INSTANCE_ACTOR
|
||||
|
@ -226,8 +228,8 @@ class ActivityPub(User, Protocol):
|
|||
logger.info(f'Skipping sending to blocklisted {url}')
|
||||
return False
|
||||
|
||||
activity = to_cls.convert(obj, from_user=from_user,
|
||||
orig_obj=to_cls.convert(orig_obj))
|
||||
orig_obj = to_cls.convert(orig_obj, from_user=from_user)
|
||||
activity = to_cls.convert(obj, from_user=from_user, orig_obj=orig_obj)
|
||||
|
||||
return signed_post(url, data=activity, from_user=from_user).ok
|
||||
|
||||
|
@ -359,7 +361,8 @@ class ActivityPub(User, Protocol):
|
|||
return {}
|
||||
|
||||
from_proto = PROTOCOLS.get(obj.source_protocol)
|
||||
if from_proto and not common.is_enabled(cls, from_proto):
|
||||
user_id = from_user.key.id() if from_user and from_user.key else None
|
||||
if from_proto and not common.is_enabled(cls, from_proto, handle_or_id=user_id):
|
||||
error(f'{cls.LABEL} <=> {from_proto.LABEL} not enabled')
|
||||
|
||||
if obj.as2:
|
||||
|
@ -828,7 +831,7 @@ def actor(handle_or_id):
|
|||
cls = Protocol.for_request(fed='web')
|
||||
if not cls:
|
||||
error(f"Couldn't determine protocol", status=404)
|
||||
elif not common.is_enabled(cls, ActivityPub):
|
||||
elif not common.is_enabled(cls, ActivityPub, handle_or_id=handle_or_id):
|
||||
error(f'{cls.LABEL} <=> activitypub not enabled')
|
||||
elif cls.LABEL == 'web' and request.path.startswith('/ap/'):
|
||||
# we started out with web users' AP ids as fed.brid.gy/[domain], so we
|
||||
|
|
59
atproto.py
59
atproto.py
|
@ -43,7 +43,18 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
arroba.server.storage = DatastoreStorage(ndb_client=ndb_client)
|
||||
|
||||
LEXICONS = Client('https://unused').defs
|
||||
appview = Client(f'https://{os.environ["APPVIEW_HOST"]}',
|
||||
headers={'User-Agent': USER_AGENT})
|
||||
LEXICONS = appview.defs
|
||||
|
||||
# https://atproto.com/guides/applications#record-types
|
||||
COLLECTION_TO_TYPE = {
|
||||
'app.bsky.actor.profile': 'profile',
|
||||
'app.bsky.feed.like': 'like',
|
||||
'app.bsky.feed.post': 'post',
|
||||
'app.bsky.feed.repost': 'repost',
|
||||
'app.bsky.graph.follow': 'follow',
|
||||
}
|
||||
|
||||
DNS_GCP_PROJECT = 'brid-gy'
|
||||
DNS_ZONE = 'brid-gy'
|
||||
|
@ -52,6 +63,22 @@ logger.info(f'Using GCP DNS project {DNS_GCP_PROJECT} zone {DNS_ZONE}')
|
|||
dns_client = dns.Client(project=DNS_GCP_PROJECT)
|
||||
|
||||
|
||||
def did_to_handle(did):
|
||||
"""Resolves a DID to a handle _if_ we have the DID doc stored locally.
|
||||
|
||||
Args:
|
||||
did (str)
|
||||
|
||||
Returns:
|
||||
str: handle, or None
|
||||
"""
|
||||
if did_obj := ATProto.load(did, did_doc=True):
|
||||
if aka := util.get_first(did_obj.raw, 'alsoKnownAs', ''):
|
||||
handle, _, _ = parse_at_uri(aka)
|
||||
if handle:
|
||||
return handle
|
||||
|
||||
|
||||
class ATProto(User, Protocol):
|
||||
"""AT Protocol class.
|
||||
|
||||
|
@ -59,6 +86,7 @@ class ATProto(User, Protocol):
|
|||
https://atproto.com/specs/did
|
||||
"""
|
||||
ABBREV = 'atproto'
|
||||
# TODO: add second bsky label? inject into PROTOCOLS?
|
||||
PHRASE = 'Bluesky'
|
||||
LOGO_HTML = '<img src="/static/atproto_logo.png">'
|
||||
PDS_URL = f'https://{ABBREV}{common.SUPERDOMAIN}/'
|
||||
|
@ -82,11 +110,7 @@ class ATProto(User, Protocol):
|
|||
@ndb.ComputedProperty
|
||||
def handle(self):
|
||||
"""Returns handle if the DID document includes one, otherwise None."""
|
||||
if did_obj := ATProto.load(self.key.id(), did_doc=True):
|
||||
if aka := util.get_first(did_obj.raw, 'alsoKnownAs', ''):
|
||||
handle, _, _ = parse_at_uri(aka)
|
||||
if handle:
|
||||
return handle
|
||||
return did_to_handle(self.key.id())
|
||||
|
||||
def web_url(self):
|
||||
return bluesky.Bluesky.user_url(self.handle_or_id())
|
||||
|
@ -243,7 +267,7 @@ class ATProto(User, Protocol):
|
|||
initial_writes = None
|
||||
if user.obj and user.obj.as1:
|
||||
# create user profile
|
||||
profile = cls.convert(user.obj, fetch_blobs=True)
|
||||
profile = cls.convert(user.obj, fetch_blobs=True, from_user=user)
|
||||
profile.setdefault('labels', {'$type': 'com.atproto.label.defs#selfLabels'})
|
||||
profile['labels'].setdefault('values', []).append({
|
||||
'val' : f'bridged-from-bridgy-fed-{user.LABEL}',
|
||||
|
@ -302,7 +326,7 @@ class ATProto(User, Protocol):
|
|||
|
||||
# convert to Bluesky record; short circuits on error
|
||||
try:
|
||||
record = to_cls.convert(base_obj, fetch_blobs=True)
|
||||
record = to_cls.convert(base_obj, fetch_blobs=True, from_user=from_user)
|
||||
except ValueError as e:
|
||||
logger.info(f'Skipping due to {e}')
|
||||
return False
|
||||
|
@ -354,8 +378,7 @@ class ATProto(User, Protocol):
|
|||
assert copy_did == did
|
||||
assert coll == type
|
||||
|
||||
logger.info(f'Storing ATProto {action} {type} {rkey}: ' +
|
||||
json_dumps(dag_json.encode(record).decode(), indent=2))
|
||||
logger.info(f'Storing ATProto {action} {type} {rkey}: {dag_json.encode(record).decode()}')
|
||||
repo.apply_writes([Write(action=action, collection=type, rkey=rkey,
|
||||
record=record)])
|
||||
|
||||
|
@ -431,10 +454,8 @@ class ATProto(User, Protocol):
|
|||
assert repo.startswith('did:')
|
||||
obj.key = ndb.Key(Object, id.replace(f'at://{handle}', f'at://{repo}'))
|
||||
|
||||
client = Client(f'https://{os.environ["APPVIEW_HOST"]}',
|
||||
headers={'User-Agent': USER_AGENT})
|
||||
try:
|
||||
ret = client.com.atproto.repo.getRecord(
|
||||
ret = appview.com.atproto.repo.getRecord(
|
||||
repo=repo, collection=collection, rkey=rkey)
|
||||
except RequestException as e:
|
||||
util.interpret_http_exception(e)
|
||||
|
@ -462,7 +483,8 @@ class ATProto(User, Protocol):
|
|||
dict: JSON object
|
||||
"""
|
||||
from_proto = PROTOCOLS.get(obj.source_protocol)
|
||||
if from_proto and not common.is_enabled(cls, from_proto):
|
||||
user_id = from_user.key.id() if from_user and from_user.key else None
|
||||
if from_proto and not common.is_enabled(cls, from_proto, handle_or_id=user_id):
|
||||
error(f'{cls.LABEL} <=> {from_proto.LABEL} not enabled')
|
||||
|
||||
if obj.bsky:
|
||||
|
@ -481,6 +503,11 @@ class ATProto(User, Protocol):
|
|||
blobs[url] = blob.as_object()
|
||||
|
||||
ret = bluesky.from_as1(cls.translate_ids(obj.as1), blobs=blobs)
|
||||
# TODO: uncomment this and pass through client eventually? would be
|
||||
# nice to start reusing granary's resolving handles and CIDs, but we
|
||||
# do much of that ourselves here in BF beforehand, so granary ends
|
||||
# up duplicating those network requests
|
||||
# client=appview)
|
||||
|
||||
# fill in CIDs from Objects
|
||||
def populate_cid(strong_ref):
|
||||
|
@ -521,6 +548,8 @@ def poll_notifications():
|
|||
for cls in set(PROTOCOLS.values())
|
||||
if cls and cls != ATProto))
|
||||
|
||||
# this client needs to be request-local because we set its service token
|
||||
# below per user that we're polling
|
||||
client = Client(f'https://{os.environ["APPVIEW_HOST"]}',
|
||||
headers={'User-Agent': USER_AGENT})
|
||||
|
||||
|
@ -579,6 +608,8 @@ def poll_posts():
|
|||
for cls in set(PROTOCOLS.values())
|
||||
if cls and cls != ATProto))
|
||||
|
||||
# this client needs to be request-local because we set its service token
|
||||
# below per user that we're polling
|
||||
client = Client(f'https://{os.environ["APPVIEW_HOST"]}',
|
||||
headers={'User-Agent': USER_AGENT})
|
||||
|
||||
|
|
15
common.py
15
common.py
|
@ -83,6 +83,15 @@ util.set_user_agent(USER_AGENT)
|
|||
TASKS_LOCATION = 'us-central1'
|
||||
RUN_TASKS_INLINE = False # overridden by unit tests
|
||||
|
||||
USER_ALLOWLIST = (
|
||||
'snarfed.org',
|
||||
'did:plc:fdme4gb7mu7zrie7peay7tst',
|
||||
'snarfed.bsky.social',
|
||||
'did:plc:3ljmtyyjqcjee2kpewgsifvb',
|
||||
'https://indieweb.social/users/snarfed',
|
||||
'@snarfed@indieweb.social',
|
||||
)
|
||||
|
||||
|
||||
def base64_to_long(x):
|
||||
"""Converts from URL safe base64 encoding to long integer.
|
||||
|
@ -255,12 +264,13 @@ def add(seq, val):
|
|||
seq.append(val)
|
||||
|
||||
|
||||
def is_enabled(proto_a, proto_b):
|
||||
def is_enabled(proto_a, proto_b, handle_or_id=None):
|
||||
"""Returns True if bridging the two input protocols is enabled, False otherwise.
|
||||
|
||||
Args:
|
||||
proto_a (Protocol subclass)
|
||||
proto_b (Protocol subclass)
|
||||
handle_or_id (str): optional user handle or id
|
||||
|
||||
Returns:
|
||||
bool:
|
||||
|
@ -273,6 +283,9 @@ def is_enabled(proto_a, proto_b):
|
|||
if DEBUG and ('fake' in labels or 'other' in labels):
|
||||
return True
|
||||
|
||||
if handle_or_id in USER_ALLOWLIST:
|
||||
return True
|
||||
|
||||
return labels in ENABLED_BRIDGES
|
||||
|
||||
|
||||
|
|
37
ids.py
37
ids.py
|
@ -8,6 +8,7 @@ from urllib.parse import urljoin, urlparse
|
|||
|
||||
from flask import request
|
||||
from google.cloud.ndb.query import FilterNode, Query
|
||||
from granary.bluesky import BSKY_APP_URL_RE, web_url_to_at_uri
|
||||
from oauth_dropins.webutil import util
|
||||
|
||||
from common import subdomain_wrap, LOCAL_DOMAINS, PRIMARY_DOMAIN, SUPERDOMAIN
|
||||
|
@ -24,7 +25,7 @@ _FED_SUBDOMAIN_SITES = None
|
|||
|
||||
|
||||
def web_ap_base_domain(user_domain):
|
||||
"""Returns the full Bridgy Fed domain to user for a given Web user.
|
||||
"""Returns the full Bridgy Fed domain to use for a given Web user.
|
||||
|
||||
Specifically, returns ``http://localhost/` if we're running locally,
|
||||
``https://fed.brid.gy/`` if the given Web user has ``ap_subdomain='fed'``,
|
||||
|
@ -55,6 +56,8 @@ def web_ap_base_domain(user_domain):
|
|||
def translate_user_id(*, id, from_proto, to_proto):
|
||||
"""Translate a user id from one protocol to another.
|
||||
|
||||
TODO: unify with :func:`translate_object_id`.
|
||||
|
||||
Args:
|
||||
id (str)
|
||||
from_proto (protocol.Protocol)
|
||||
|
@ -72,6 +75,20 @@ def translate_user_id(*, id, from_proto, to_proto):
|
|||
# home page; replace with domain
|
||||
id = parsed.netloc
|
||||
|
||||
# bsky.app profile URL to DID
|
||||
if to_proto.LABEL == 'atproto':
|
||||
if match := BSKY_APP_URL_RE.match(id):
|
||||
repo = match.group('id')
|
||||
if repo.startswith('did:'):
|
||||
return repo
|
||||
|
||||
from atproto import ATProto
|
||||
try:
|
||||
return ATProto.handle_to_id(repo)
|
||||
except (AssertionError, ValueError) as e:
|
||||
logger.warning(e)
|
||||
return None
|
||||
|
||||
if from_proto == to_proto:
|
||||
return id
|
||||
|
||||
|
@ -161,6 +178,8 @@ def translate_handle(*, handle, from_proto, to_proto, enhanced):
|
|||
def translate_object_id(*, id, from_proto, to_proto):
|
||||
"""Translates a user handle from one protocol to another.
|
||||
|
||||
TODO: unify with :func:`translate_user_id`.
|
||||
|
||||
Args:
|
||||
id (str)
|
||||
from_proto (protocol.Protocol)
|
||||
|
@ -172,6 +191,22 @@ def translate_object_id(*, id, from_proto, to_proto):
|
|||
assert id and from_proto and to_proto
|
||||
assert from_proto.owns_id(id) is not False or from_proto.LABEL == 'ui'
|
||||
|
||||
# bsky.app profile URL to DID
|
||||
if to_proto.LABEL == 'atproto':
|
||||
if match := BSKY_APP_URL_RE.match(id):
|
||||
repo = match.group('id')
|
||||
handle = None
|
||||
if not repo.startswith('did:'):
|
||||
handle = repo
|
||||
from atproto import ATProto
|
||||
try:
|
||||
repo = ATProto.handle_to_id(repo)
|
||||
except (AssertionError, ValueError) as e:
|
||||
logger.warning(e)
|
||||
return None
|
||||
|
||||
return web_url_to_at_uri(id, handle=handle, did=repo)
|
||||
|
||||
if from_proto == to_proto:
|
||||
return id
|
||||
|
||||
|
|
137
models.py
137
models.py
|
@ -15,7 +15,7 @@ from Crypto.PublicKey import RSA
|
|||
from flask import g, request
|
||||
from google.cloud import ndb
|
||||
from granary import as1, as2, atom, bluesky, microformats2
|
||||
from granary.bluesky import BSKY_APP_URL_RE
|
||||
from granary.bluesky import AT_URI_PATTERN, BSKY_APP_URL_RE
|
||||
from granary.source import html_to_text
|
||||
from oauth_dropins.webutil import util
|
||||
from oauth_dropins.webutil.appengine_info import DEBUG
|
||||
|
@ -28,8 +28,8 @@ from oauth_dropins.webutil.models import (
|
|||
from oauth_dropins.webutil.util import json_dumps, json_loads
|
||||
|
||||
import common
|
||||
from common import add, base64_to_long, long_to_base64, unwrap
|
||||
import ids
|
||||
from common import add, base64_to_long, DOMAIN_RE, long_to_base64, unwrap
|
||||
from ids import translate_handle, translate_object_id, translate_user_id
|
||||
|
||||
# maps string label to Protocol subclass. populated by ProtocolUserMeta.
|
||||
# seed with old and upcoming protocols that don't have their own classes (yet).
|
||||
|
@ -241,7 +241,7 @@ class User(StringIdModel, metaclass=ProtocolUserMeta):
|
|||
|
||||
ATProto = PROTOCOLS['atproto']
|
||||
if propagate and cls.LABEL != 'atproto' and not user.get_copy(ATProto):
|
||||
if common.is_enabled(cls, ATProto):
|
||||
if common.is_enabled(cls, ATProto, handle_or_id=id):
|
||||
ATProto.create_for(user)
|
||||
else:
|
||||
logger.info(f'{cls.LABEL} <=> atproto not enabled, skipping')
|
||||
|
@ -359,8 +359,8 @@ class User(StringIdModel, metaclass=ProtocolUserMeta):
|
|||
if not handle:
|
||||
return None
|
||||
|
||||
return ids.translate_handle(handle=handle, from_proto=self.__class__,
|
||||
to_proto=to_proto, enhanced=False)
|
||||
return translate_handle(handle=handle, from_proto=self.__class__,
|
||||
to_proto=to_proto, enhanced=False)
|
||||
|
||||
def id_as(self, to_proto):
|
||||
"""Returns this user's id in a different protocol.
|
||||
|
@ -374,8 +374,8 @@ class User(StringIdModel, metaclass=ProtocolUserMeta):
|
|||
if isinstance(to_proto, str):
|
||||
to_proto = PROTOCOLS[to_proto]
|
||||
|
||||
return ids.translate_user_id(id=self.key.id(), from_proto=self.__class__,
|
||||
to_proto=to_proto)
|
||||
return translate_user_id(id=self.key.id(), from_proto=self.__class__,
|
||||
to_proto=to_proto)
|
||||
|
||||
def handle_or_id(self):
|
||||
"""Returns handle if we know it, otherwise id."""
|
||||
|
@ -606,7 +606,7 @@ class Object(StringIdModel):
|
|||
def use_urls_as_ids(obj):
|
||||
"""If id field is missing or not a URL, use the url field."""
|
||||
id = obj.get('id')
|
||||
if not id or not util.is_web(id):
|
||||
if not id or not (util.is_web(id) or re.match(DOMAIN_RE, id)):
|
||||
if url := util.get_url(obj):
|
||||
obj['id'] = url
|
||||
|
||||
|
@ -922,6 +922,8 @@ class Object(StringIdModel):
|
|||
``at://did:plc:abc/app.bsky.feed.post/123`` => ``https://mas.to/@user/456``.
|
||||
* Bridgy Fed subdomain URLs to the ids embedded inside them, eg
|
||||
``https://atproto.brid.gy/ap/did:plc:xyz`` => ``did:plc:xyz``
|
||||
* ATProto bsky.app URLs to their DIDs or `at://` URIs, eg
|
||||
``https://bsky.app/profile/a.com`` => ``did:plc:123``
|
||||
|
||||
...in these AS1 fields, in place:
|
||||
|
||||
|
@ -937,6 +939,9 @@ class Object(StringIdModel):
|
|||
|
||||
:meth:`protocol.Protocol.translate_ids` is partly the inverse of this.
|
||||
Much of the same logic is duplicated there!
|
||||
|
||||
TODO: unify with :meth:`normalize_ids`,
|
||||
:meth:`protocol.Protocol.normalize_ids`.
|
||||
"""
|
||||
if not self.as1:
|
||||
return
|
||||
|
@ -974,7 +979,7 @@ class Object(StringIdModel):
|
|||
if copy.protocol in (self_proto.LABEL, self_proto.ABBREV):
|
||||
origs[copy.uri] = obj.key.id()
|
||||
|
||||
logger.debug(f'Replacing copies with originals: {origs}')
|
||||
logger.debug(f'Resolving {self_proto.LABEL} ids; originals: {origs}')
|
||||
replaced = False
|
||||
|
||||
def replace(val):
|
||||
|
@ -1002,12 +1007,87 @@ class Object(StringIdModel):
|
|||
obj[field] = obj[field][0]
|
||||
|
||||
outer_obj['object'] = replace(inner_obj)
|
||||
|
||||
if util.trim_nulls(outer_obj['object']).keys() == {'id'}:
|
||||
outer_obj['object'] = outer_obj['object']['id']
|
||||
|
||||
if replaced:
|
||||
self.our_as1 = util.trim_nulls(outer_obj)
|
||||
|
||||
def normalize_ids(self):
|
||||
"""Normalizes ids to their protocol's canonical representation, if any.
|
||||
|
||||
For example, normalizes ATProto ``https://bsky.app/...`` URLs to DIDs
|
||||
for profiles, ``at://`` URIs for posts.
|
||||
|
||||
Modifies this object in place.
|
||||
|
||||
TODO: unify with :meth:`resolve_ids`, :meth:`Protocol.translate_ids`.
|
||||
"""
|
||||
from protocol import Protocol
|
||||
|
||||
if not self.as1:
|
||||
return
|
||||
|
||||
logger.debug(f'Normalizing ids')
|
||||
outer_obj = copy.deepcopy(self.as1)
|
||||
inner_objs = as1.get_objects(outer_obj)
|
||||
replaced = False
|
||||
|
||||
def replace(val, translate_fn):
|
||||
nonlocal replaced
|
||||
|
||||
orig = val.get('id') if isinstance(val, dict) else val
|
||||
if not orig:
|
||||
return val
|
||||
|
||||
proto = Protocol.for_id(orig, remote=False)
|
||||
if not proto:
|
||||
return val
|
||||
|
||||
translated = translate_fn(id=orig, from_proto=proto, to_proto=proto)
|
||||
if translated and translated != orig:
|
||||
logger.info(f'Normalized {proto.LABEL} id {orig} to {translated}')
|
||||
replaced = True
|
||||
if isinstance(val, dict):
|
||||
val['id'] = translated
|
||||
return val
|
||||
else:
|
||||
return translated
|
||||
|
||||
return val
|
||||
|
||||
# actually replace ids
|
||||
for obj in [outer_obj] + inner_objs:
|
||||
for tag in as1.get_objects(obj, 'tags'):
|
||||
if tag.get('objectType') == 'mention':
|
||||
tag['url'] = replace(tag.get('url'), translate_user_id)
|
||||
for field in ['actor', 'author', 'inReplyTo']:
|
||||
fn = translate_object_id if field == 'inReplyTo' else translate_user_id
|
||||
obj[field] = [replace(val, fn) for val in util.get_list(obj, field)]
|
||||
if len(obj[field]) == 1:
|
||||
obj[field] = obj[field][0]
|
||||
|
||||
outer_obj['object'] = []
|
||||
for inner_obj in inner_objs:
|
||||
translate_fn = (translate_user_id
|
||||
if (as1.object_type(inner_obj) in as1.ACTOR_TYPES
|
||||
or as1.object_type(outer_obj) in
|
||||
('follow', 'stop-following'))
|
||||
else translate_object_id)
|
||||
|
||||
got = replace(inner_obj, translate_fn)
|
||||
if isinstance(got, dict) and util.trim_nulls(got).keys() == {'id'}:
|
||||
got = got['id']
|
||||
|
||||
outer_obj['object'].append(got)
|
||||
|
||||
if len(outer_obj['object']) == 1:
|
||||
outer_obj['object'] = outer_obj['object'][0]
|
||||
|
||||
if replaced:
|
||||
self.our_as1 = util.trim_nulls(outer_obj)
|
||||
|
||||
|
||||
class Follower(ndb.Model):
|
||||
"""A follower of a Bridgy Fed user."""
|
||||
|
@ -1192,23 +1272,32 @@ def fetch_objects(query, by=None, user=None):
|
|||
'url': id,
|
||||
})
|
||||
elif url:
|
||||
# heuristics for sniffing Mastodon and similar fediverse URLs and
|
||||
# converting them to more friendly @-names
|
||||
# heuristics for sniffing URLs and converting them to more friendly
|
||||
# phrases and user handles.
|
||||
# TODO: standardize this into granary.as2 somewhere?
|
||||
if not content:
|
||||
fedi_url = re.match(
|
||||
r'https://[^/]+/(@|users/)([^/@]+)(@[^/@]+)?(/(?:statuses/)?[0-9]+)?', url)
|
||||
if fedi_url:
|
||||
content = '@' + fedi_url.group(2)
|
||||
if fedi_url.group(4):
|
||||
content += "'s post"
|
||||
from activitypub import FEDI_URL_RE
|
||||
from atproto import COLLECTION_TO_TYPE, did_to_handle
|
||||
|
||||
if not content:
|
||||
if bsky_url := BSKY_APP_URL_RE.match(url):
|
||||
if handle := bsky_url.group('id'): # or DID
|
||||
content = '@' + handle
|
||||
if bsky_url.group('tid'):
|
||||
content += "'s post"
|
||||
if match := FEDI_URL_RE.match(url):
|
||||
content = '@' + match.group(2)
|
||||
if match.group(4):
|
||||
content += "'s post"
|
||||
elif match := BSKY_APP_URL_RE.match(url):
|
||||
id = match.group('id')
|
||||
if id.startswith('did:'):
|
||||
id = ATdid_to_handle(id) or id
|
||||
content = '@' + id
|
||||
if match.group('tid'):
|
||||
content += "'s post"
|
||||
elif match := AT_URI_PATTERN.match(url):
|
||||
id = match.group('repo')
|
||||
if id.startswith('did:'):
|
||||
id = did_to_handle(id) or id
|
||||
content = '@' + id
|
||||
if coll := match.group('collection'):
|
||||
content += f"'s {COLLECTION_TO_TYPE.get(coll) or 'post'}"
|
||||
url = bluesky.at_uri_to_web_url(url)
|
||||
|
||||
content = common.pretty_link(url, text=content, user=user)
|
||||
|
||||
|
|
13
pages.py
13
pages.py
|
@ -60,17 +60,16 @@ def load_user(protocol, id):
|
|||
:class:`werkzeug.exceptions.HTTPException` on error or redirect
|
||||
"""
|
||||
assert id
|
||||
if protocol == 'ap' and not id.startswith('@'):
|
||||
id = '@' + id
|
||||
|
||||
cls = PROTOCOLS[protocol]
|
||||
|
||||
if cls.ABBREV == 'ap' and not id.startswith('@'):
|
||||
id = '@' + id
|
||||
user = cls.get_by_id(id)
|
||||
|
||||
if protocol != 'web':
|
||||
if cls.ABBREV != 'web':
|
||||
if not user:
|
||||
user = cls.query(OR(cls.handle == id,
|
||||
cls.readable_id == id),
|
||||
).get()
|
||||
user = cls.query(OR(cls.handle == id, cls.handle == id)).get()
|
||||
if user and user.use_instead:
|
||||
user = user.use_instead.get()
|
||||
|
||||
|
@ -80,7 +79,7 @@ def load_user(protocol, id):
|
|||
elif user and id != user.key.id(): # use_instead redirect
|
||||
error('', status=302, location=user.user_page_path())
|
||||
|
||||
if user and (user.direct or protocol == 'web'):
|
||||
if user and (user.direct or cls.ABBREV != 'ap'):
|
||||
assert not user.use_instead
|
||||
return user
|
||||
|
||||
|
|
25
protocol.py
25
protocol.py
|
@ -226,19 +226,18 @@ class Protocol:
|
|||
|
||||
@cached(LRUCache(20000), lock=Lock())
|
||||
@staticmethod
|
||||
def for_id(id):
|
||||
def for_id(id, remote=True):
|
||||
"""Returns the protocol for a given id.
|
||||
|
||||
May incur expensive side effects like fetching the id itself over the
|
||||
network or other discovery.
|
||||
|
||||
Args:
|
||||
id (str)
|
||||
remote (bool): whether to perform expensive side effects like fetching
|
||||
the id itself over the network, or other discovery.
|
||||
|
||||
Returns:
|
||||
Protocol subclass: matching protocol, or None if no known protocol
|
||||
owns this id
|
||||
"""
|
||||
Protocol subclass: matching protocol, or None if no single known
|
||||
protocol definitively owns this id
|
||||
"""
|
||||
logger.info(f'Determining protocol for id {id}')
|
||||
if not id:
|
||||
return None
|
||||
|
@ -273,7 +272,10 @@ class Protocol:
|
|||
logger.info(f' {obj.key} owned by source_protocol {obj.source_protocol}')
|
||||
return PROTOCOLS[obj.source_protocol]
|
||||
|
||||
# step 4: fetch over the network
|
||||
# step 4: fetch over the network, if necessary
|
||||
if not remote:
|
||||
return None
|
||||
|
||||
for protocol in candidates:
|
||||
logger.info(f'Trying {protocol.LABEL}')
|
||||
try:
|
||||
|
@ -474,7 +476,7 @@ class Protocol:
|
|||
|
||||
@classmethod
|
||||
def translate_ids(to_cls, obj):
|
||||
"""Wraps ids and actors in an AS1 object in subdomain convert URLs.
|
||||
"""Translates all ids in an AS1 object to a specific protocol.
|
||||
|
||||
Infers source protocol for each id value separately.
|
||||
|
||||
|
@ -497,6 +499,9 @@ class Protocol:
|
|||
This is the inverse of :meth:`models.Object.resolve_ids`. Much of the
|
||||
same logic is duplicated there!
|
||||
|
||||
TODO: unify with :meth:`Object.resolve_ids`,
|
||||
:meth:`protocol.Protocol.normalize_ids`.
|
||||
|
||||
Args:
|
||||
to_proto (Protocol subclass)
|
||||
obj (dict): AS1 object or activity (not :class:`models.Object`!)
|
||||
|
@ -792,6 +797,7 @@ class Protocol:
|
|||
from_user = from_cls.get_or_create(id=from_key.id(), obj=from_obj)
|
||||
|
||||
logger.info(f'Follow {from_id} => {to_id}')
|
||||
|
||||
to_cls = Protocol.for_id(to_id)
|
||||
if not to_cls:
|
||||
error(f"Couldn't determine protocol for {to_id}")
|
||||
|
@ -1172,6 +1178,7 @@ class Protocol:
|
|||
return None
|
||||
|
||||
obj.resolve_ids()
|
||||
obj.normalize_ids()
|
||||
|
||||
if obj.new is False:
|
||||
obj.changed = obj.activity_changed(orig_as1)
|
||||
|
|
|
@ -15,7 +15,7 @@ beautifulsoup4==4.12.3
|
|||
bech32==1.2.0
|
||||
brevity==0.2.17
|
||||
cachetools==5.3.0
|
||||
cbor2==5.6.2
|
||||
cbor2==5.6.3
|
||||
certifi==2024.2.2
|
||||
cffi==1.16.0
|
||||
charset-normalizer==3.3.2
|
||||
|
@ -26,12 +26,12 @@ dag-cbor==0.3.3
|
|||
Deprecated==1.2.14
|
||||
dnspython==2.6.1
|
||||
domain2idna==1.12.0
|
||||
ecdsa==0.18.0
|
||||
ecdsa==0.19.0
|
||||
extras==1.0.0
|
||||
feedgen==1.0.0
|
||||
feedparser==6.0.11
|
||||
fixtures==4.1.0
|
||||
Flask==3.0.2
|
||||
Flask==3.0.3
|
||||
Flask-Caching==2.1.0
|
||||
flask-gae-static==1.0
|
||||
flask-sock==0.7.0
|
||||
|
@ -56,7 +56,7 @@ html2text==2024.2.26
|
|||
html5lib==1.1
|
||||
humanfriendly==10.0
|
||||
humanize==4.9.0
|
||||
idna==3.6
|
||||
idna==3.7
|
||||
iterators==0.2.0
|
||||
itsdangerous==2.1.2
|
||||
Jinja2==3.1.3
|
||||
|
@ -74,7 +74,7 @@ pkce==1.0.3
|
|||
praw==7.7.1
|
||||
prawcore==2.4.0
|
||||
proto-plus==1.23.0
|
||||
protobuf==4.24.3
|
||||
protobuf==5.26.1
|
||||
pyasn1==0.6.0
|
||||
pyasn1-modules==0.4.0
|
||||
pycparser==2.22
|
||||
|
|
|
@ -9,7 +9,6 @@ from arroba.datastore_storage import AtpBlock, AtpRemoteBlob, AtpRepo, Datastore
|
|||
from arroba.did import encode_did_key
|
||||
from arroba.repo import Repo
|
||||
import arroba.util
|
||||
import dns.resolver
|
||||
from dns.resolver import NXDOMAIN
|
||||
from flask import g
|
||||
from google.cloud.tasks_v2.types import Task
|
||||
|
@ -137,7 +136,7 @@ class ATProtoTest(TestCase):
|
|||
self.make_user('did:plc:user', cls=ATProto)
|
||||
self.assertEqual('did:plc:user', ATProto.handle_to_id('han.dull'))
|
||||
|
||||
@patch('dns.resolver.resolve', side_effect=dns.resolver.NXDOMAIN())
|
||||
@patch('dns.resolver.resolve', side_effect=NXDOMAIN())
|
||||
# resolving handle, HTTPS method, not found
|
||||
@patch('requests.get', return_value=requests_response('', status=404))
|
||||
def test_handle_to_id_not_found(self, *_):
|
||||
|
@ -192,7 +191,7 @@ class ATProtoTest(TestCase):
|
|||
id='https://bsky.app/profile/did:plc:user/post/123'))
|
||||
self.assertEqual('https://some.pds', got)
|
||||
|
||||
@patch('dns.resolver.resolve', side_effect=dns.resolver.NXDOMAIN())
|
||||
@patch('dns.resolver.resolve', side_effect=NXDOMAIN())
|
||||
@patch('requests.get', side_effect=[
|
||||
# resolving handle, HTTPS method
|
||||
requests_response('did:plc:user', content_type='text/plain'),
|
||||
|
@ -297,7 +296,7 @@ class ATProtoTest(TestCase):
|
|||
with self.assertRaises(AssertionError):
|
||||
ATProto.fetch(Object(id=uri))
|
||||
|
||||
@patch('dns.resolver.resolve', side_effect=dns.resolver.NXDOMAIN())
|
||||
@patch('dns.resolver.resolve', side_effect=NXDOMAIN())
|
||||
@patch('requests.get', return_value=requests_response(status=404))
|
||||
def test_fetch_resolve_handle_fails(self, mock_get, _):
|
||||
obj = Object(id='at://bad.com/app.bsky.feed.post/789')
|
||||
|
@ -313,7 +312,7 @@ class ATProtoTest(TestCase):
|
|||
bsky=ACTOR_PROFILE_BSKY)
|
||||
self.assert_entities_equal(profile, ATProto.load('did:plc:user'))
|
||||
|
||||
@patch('dns.resolver.resolve', side_effect=dns.resolver.NXDOMAIN())
|
||||
@patch('dns.resolver.resolve', side_effect=NXDOMAIN())
|
||||
@patch('requests.get', side_effect=[
|
||||
# resolving handle, HTTPS method
|
||||
requests_response('did:plc:user', content_type='text/plain'),
|
||||
|
@ -430,13 +429,13 @@ class ATProtoTest(TestCase):
|
|||
'inReplyTo': 'at://did:plc:bob/app.bsky.feed.post/tid',
|
||||
})))
|
||||
|
||||
@patch('dns.resolver.resolve', side_effect=dns.resolver.NXDOMAIN())
|
||||
@patch('dns.resolver.resolve', side_effect=NXDOMAIN())
|
||||
@patch('requests.get', side_effect=[
|
||||
# resolving handle, HTTPS method
|
||||
requests_response('did:plc:user', content_type='text/plain'),
|
||||
# AppView getRecord
|
||||
requests_response({
|
||||
'uri': 'at://did:plc:bob/app.bsky.feed.post/tid',
|
||||
'uri': 'at://did:plc:user/app.bsky.feed.post/tid',
|
||||
'cid': 'my sidd',
|
||||
'value': {
|
||||
'$type': 'app.bsky.feed.post',
|
||||
|
@ -464,7 +463,7 @@ class ATProtoTest(TestCase):
|
|||
'https://api.bsky-sandbox.dev/xrpc/com.atproto.repo.getRecord?repo=did%3Aplc%3Auser&collection=app.bsky.feed.post&rkey=tid',
|
||||
json=None, data=None, headers=ANY)
|
||||
|
||||
@patch('dns.resolver.resolve', side_effect=dns.resolver.NXDOMAIN())
|
||||
@patch('dns.resolver.resolve', side_effect=NXDOMAIN())
|
||||
# resolving handle, HTTPS method
|
||||
@patch('requests.get', return_value=requests_response(status=404))
|
||||
def test_convert_populate_cid_fetch_remote_record_bad_handle(self, _, __):
|
||||
|
@ -536,6 +535,39 @@ class ATProtoTest(TestCase):
|
|||
'image': [{'url': 'http://my/pic'}],
|
||||
}), fetch_blobs=True))
|
||||
|
||||
# resolveHandle
|
||||
@patch('requests.get', return_value=requests_response({'did': 'did:plc:user'}))
|
||||
def test_convert_resolve_mention_handle(self, mock_get):
|
||||
self.store_object(id='did:plc:user', raw=DID_DOC)
|
||||
|
||||
self.assertEqual({
|
||||
'$type': 'app.bsky.feed.post',
|
||||
'createdAt': '2022-01-02T03:04:05.000Z',
|
||||
'text': 'hi @han.dull hows it going',
|
||||
'facets': [{
|
||||
'$type': 'app.bsky.richtext.facet',
|
||||
'features': [{
|
||||
'$type': 'app.bsky.richtext.facet#mention',
|
||||
'did': 'did:plc:user',
|
||||
}],
|
||||
'index': {
|
||||
'byteEnd': 12,
|
||||
'byteStart': 3,
|
||||
},
|
||||
}],
|
||||
}, ATProto.convert(Object(our_as1={
|
||||
# this mention has the DID in url, and it will also be extracted
|
||||
# from the link in content. make sure we merge the two and don't end
|
||||
# up with a duplicate mention of the DID or a mention of the handle.
|
||||
'objectType': 'note',
|
||||
'content': 'hi <a href="https://bsky.app/profile/han.dull">@han.dull</a> hows it going',
|
||||
'tags': [{
|
||||
'objectType': 'mention',
|
||||
'url': 'did:plc:user',
|
||||
'displayName': '@han.dull'
|
||||
}],
|
||||
})))
|
||||
|
||||
def test_convert_protocols_not_enabled(self):
|
||||
obj = Object(our_as1={'foo': 'bar'}, source_protocol='activitypub')
|
||||
with self.assertRaises(BadRequest):
|
||||
|
@ -1024,13 +1056,6 @@ class ATProtoTest(TestCase):
|
|||
'cid': 'my sidd',
|
||||
},
|
||||
},
|
||||
'facets': [{
|
||||
'$type': 'app.bsky.richtext.facet',
|
||||
'features': [{
|
||||
'$type': 'app.bsky.richtext.facet#mention',
|
||||
'did': 'did:alice',
|
||||
}],
|
||||
}],
|
||||
}, record)
|
||||
|
||||
at_uri = f'at://did:plc:user/app.bsky.feed.post/{last_tid}'
|
||||
|
|
|
@ -109,3 +109,10 @@ class CommonTest(TestCase):
|
|||
self.assertTrue(common.is_enabled(ATProto, Web))
|
||||
self.assertTrue(common.is_enabled(Fake, OtherFake))
|
||||
self.assertFalse(common.is_enabled(ATProto, ActivityPub))
|
||||
|
||||
self.assertFalse(common.is_enabled(
|
||||
ATProto, ActivityPub, handle_or_id='unknown'))
|
||||
self.assertTrue(common.is_enabled(
|
||||
ATProto, ActivityPub, handle_or_id='snarfed.org'))
|
||||
self.assertTrue(common.is_enabled(
|
||||
ATProto, ActivityPub, handle_or_id='did:plc:fdme4gb7mu7zrie7peay7tst'))
|
||||
|
|
|
@ -19,11 +19,21 @@ class IdsTest(TestCase):
|
|||
Fake(id='fake:user',
|
||||
copies=[Target(uri='did:plc:789', protocol='atproto')]).put()
|
||||
|
||||
# DID doc and ATProto, used to resolve handle in bsky.app URL
|
||||
did = self.store_object(id='did:plc:123', raw={
|
||||
'id': 'did:plc:123',
|
||||
'alsoKnownAs': ['at://user.com'],
|
||||
})
|
||||
ATProto(id='did:plc:123', obj_key=did.key).put()
|
||||
|
||||
for from_, id, to, expected in [
|
||||
(ActivityPub, 'https://inst/user', ActivityPub, 'https://inst/user'),
|
||||
(ActivityPub, 'https://inst/user', ATProto, 'did:plc:456'),
|
||||
(ActivityPub, 'https://inst/user', Fake, 'fake:u:https://inst/user'),
|
||||
(ActivityPub, 'https://inst/user', Web, 'https://inst/user'),
|
||||
(ActivityPub, 'https://bsky.app/profile/user.com', ATProto, 'did:plc:123'),
|
||||
(ActivityPub, 'https://bsky.app/profile/did:plc:123',
|
||||
ATProto, 'did:plc:123'),
|
||||
(ATProto, 'did:plc:456', ATProto, 'did:plc:456'),
|
||||
# copies
|
||||
(ATProto, 'did:plc:123', Web, 'user.com'),
|
||||
|
@ -33,6 +43,8 @@ class IdsTest(TestCase):
|
|||
(ATProto, 'did:plc:x', Web, 'https://atproto.brid.gy/web/did:plc:x'),
|
||||
(ATProto, 'did:plc:x', ActivityPub, 'https://atproto.brid.gy/ap/did:plc:x'),
|
||||
(ATProto, 'did:plc:x', Fake, 'fake:u:did:plc:x'),
|
||||
(ATProto, 'https://bsky.app/profile/user.com', ATProto, 'did:plc:123'),
|
||||
(ATProto, 'https://bsky.app/profile/did:plc:123', ATProto, 'did:plc:123'),
|
||||
(Fake, 'fake:user', ActivityPub, 'https://fa.brid.gy/ap/fake:user'),
|
||||
(Fake, 'fake:user', ATProto, 'did:plc:789'),
|
||||
(Fake, 'fake:user', Fake, 'fake:user'),
|
||||
|
@ -41,6 +53,8 @@ class IdsTest(TestCase):
|
|||
(Web, 'https://user.com/', ActivityPub, 'http://localhost/user.com'),
|
||||
(Web, 'user.com', ATProto, 'did:plc:123'),
|
||||
(Web, 'https://user.com', ATProto, 'did:plc:123'),
|
||||
(Web, 'https://bsky.app/profile/user.com', ATProto, 'did:plc:123'),
|
||||
(Web, 'https://bsky.app/profile/did:plc:123', ATProto, 'did:plc:123'),
|
||||
(Web, 'user.com', Fake, 'fake:u:user.com'),
|
||||
(Web, 'user.com', Web, 'user.com'),
|
||||
(Web, 'https://user.com/', Web, 'user.com'),
|
||||
|
@ -132,6 +146,13 @@ class IdsTest(TestCase):
|
|||
self.store_object(id='fake:post',
|
||||
copies=[Target(uri='at://did/fa/post', protocol='atproto')])
|
||||
|
||||
# DID doc and ATProto, used to resolve handle in bsky.app URL
|
||||
did = self.store_object(id='did:plc:123', raw={
|
||||
'id': 'did:plc:123',
|
||||
'alsoKnownAs': ['at://user.com'],
|
||||
})
|
||||
ATProto(id='did:plc:123', obj_key=did.key).put()
|
||||
|
||||
for from_, id, to, expected in [
|
||||
(ActivityPub, 'https://inst/post', ActivityPub, 'https://inst/post'),
|
||||
(ActivityPub, 'https://inst/post', ATProto, 'at://did/ap/post'),
|
||||
|
@ -147,6 +168,10 @@ class IdsTest(TestCase):
|
|||
(ATProto, 'did:plc:x', Web, 'https://atproto.brid.gy/convert/web/did:plc:x'),
|
||||
(ATProto, 'did:plc:x', ActivityPub, 'https://atproto.brid.gy/convert/ap/did:plc:x'),
|
||||
(ATProto, 'did:plc:x', Fake, 'fake:o:atproto:did:plc:x'),
|
||||
(ATProto, 'https://bsky.app/profile/user.com/post/456',
|
||||
ATProto, 'at://did:plc:123/app.bsky.feed.post/456'),
|
||||
(ATProto, 'https://bsky.app/profile/did:plc:123/post/456',
|
||||
ATProto, 'at://did:plc:123/app.bsky.feed.post/456'),
|
||||
(Fake, 'fake:post',
|
||||
ActivityPub, 'https://fa.brid.gy/convert/ap/fake:post'),
|
||||
(Fake, 'fake:post', ATProto, 'at://did/fa/post'),
|
||||
|
|
|
@ -9,9 +9,10 @@ from oauth_dropins.webutil.testutil import requests_response
|
|||
from activitypub import ActivityPub
|
||||
import app
|
||||
from atproto import ATProto
|
||||
from dns.resolver import NXDOMAIN
|
||||
from granary.tests.test_bluesky import ACTOR_PROFILE_BSKY
|
||||
import hub
|
||||
from models import Target
|
||||
from models import Object, Target
|
||||
from web import Web
|
||||
|
||||
from .testutil import ATPROTO_KEY, TestCase
|
||||
|
@ -23,6 +24,11 @@ DID_DOC = {
|
|||
'id': 'did:plc:alice',
|
||||
'alsoKnownAs': ['at://alice.com'],
|
||||
}
|
||||
PROFILE_GETRECORD = {
|
||||
'uri': 'at://did:plc:alice/app.bsky.actor.profile/self',
|
||||
'cid': 'alice sidd',
|
||||
'value': test_atproto.ACTOR_PROFILE_BSKY,
|
||||
}
|
||||
|
||||
|
||||
class IntegrationTests(TestCase):
|
||||
|
@ -92,11 +98,7 @@ class IntegrationTests(TestCase):
|
|||
}],
|
||||
}),
|
||||
# ATProto getRecord of alice's profile
|
||||
requests_response({
|
||||
'uri': 'at://did:plc:alice/app.bsky.actor.profile/self',
|
||||
'cid': 'alice sidd',
|
||||
'value': test_atproto.ACTOR_PROFILE_BSKY,
|
||||
}),
|
||||
requests_response(PROFILE_GETRECORD),
|
||||
]
|
||||
|
||||
resp = self.post('/queue/atproto-poll-notifs', client=hub.app.test_client())
|
||||
|
@ -164,11 +166,7 @@ class IntegrationTests(TestCase):
|
|||
}],
|
||||
}),
|
||||
# ATProto getRecord of alice's profile
|
||||
requests_response({
|
||||
'uri': 'at://did:plc:alice/app.bsky.actor.profile/self',
|
||||
'cid': 'alice sidd',
|
||||
'value': test_atproto.ACTOR_PROFILE_BSKY,
|
||||
}),
|
||||
requests_response(PROFILE_GETRECORD),
|
||||
# webmention discovery
|
||||
test_web.WEBMENTION_REL_LINK,
|
||||
]
|
||||
|
@ -181,3 +179,94 @@ class IntegrationTests(TestCase):
|
|||
'source': 'https://atproto.brid.gy/convert/web/at://did:plc:alice/app.bsky.graph.follow/123',
|
||||
'target': 'https://bob.com/',
|
||||
}, allow_redirects=False, headers={'Accept': '*/*'})
|
||||
|
||||
@patch('dns.resolver.resolve', side_effect=NXDOMAIN())
|
||||
@patch('oauth_dropins.webutil.appengine_config.tasks_client.create_task')
|
||||
@patch('requests.post', side_effect=[
|
||||
requests_response('OK'), # create DID
|
||||
])
|
||||
@patch('requests.get', side_effect = [
|
||||
# webmention source page, follow HTML
|
||||
requests_response("""\
|
||||
<html>
|
||||
<body class="h-entry">
|
||||
<a class="u-url" href="https://bob.com/follow"></a>
|
||||
<a class="u-follow-of" href="https://bsky.app/profile/alice.com"></a>
|
||||
<a href="http://localhost/"></a>
|
||||
</body>
|
||||
</html>
|
||||
"""),
|
||||
# https://bob.com/ , for authorship
|
||||
requests_response("""\
|
||||
<html>
|
||||
<body class="h-card">
|
||||
<a class="p-name u-url" rel="me" href="https://bob.com/">Bob</a>
|
||||
</body>
|
||||
</html>
|
||||
"""),
|
||||
# alice.com handle resolution, HTTPS method
|
||||
requests_response('did:plc:alice', content_type='text/plain'),
|
||||
# alice profile
|
||||
requests_response(PROFILE_GETRECORD),
|
||||
# alice DID
|
||||
requests_response(DID_DOC),
|
||||
# alice profile
|
||||
requests_response(PROFILE_GETRECORD),
|
||||
])
|
||||
def test_web_follow_to_atproto(self, mock_get, mock_post, _, __):
|
||||
"""Incoming webmention for a web follow of an ATProto bsky.app profile URL.
|
||||
|
||||
Web user bob.com
|
||||
ATProto user alice.com (did:plc:alice)
|
||||
Follow is HTML with mf2 u-follow-of of https://bsky.app/profile/alice.com
|
||||
"""
|
||||
bob = self.make_user(id='bob.com', cls=Web, obj_mf2={
|
||||
'type': ['h-card'],
|
||||
'properties': {
|
||||
'url': ['https://bob.com/'],
|
||||
'name': ['Bob'],
|
||||
},
|
||||
})
|
||||
|
||||
# send webmention
|
||||
resp = self.post('/webmention', data={
|
||||
'source': 'https://bob.com/follow',
|
||||
'target': 'http://localhost/',
|
||||
})
|
||||
self.assertEqual(202, resp.status_code)
|
||||
|
||||
# check results
|
||||
bob = bob.key.get()
|
||||
self.assertEqual(1, len(bob.copies))
|
||||
self.assertEqual('atproto', bob.copies[0].protocol)
|
||||
bob_did = bob.copies[0].uri
|
||||
|
||||
self.assertEqual({
|
||||
'type': ['h-entry'],
|
||||
'properties': {
|
||||
'url': ['https://bob.com/follow'],
|
||||
'follow-of': ['https://bsky.app/profile/alice.com'],
|
||||
'name': [''],
|
||||
'author': [{
|
||||
'type': ['h-card'],
|
||||
'properties': {
|
||||
'name': ['Bob'],
|
||||
'url': ['https://bob.com/'],
|
||||
},
|
||||
}],
|
||||
},
|
||||
}, Object.get_by_id('https://bob.com/follow').mf2)
|
||||
|
||||
storage = DatastoreStorage()
|
||||
repo = storage.load_repo('bob.com.web.brid.gy')
|
||||
self.assertEqual(bob_did, repo.did)
|
||||
|
||||
records = repo.get_contents()
|
||||
self.assertEqual(['app.bsky.actor.profile', 'app.bsky.graph.follow'],
|
||||
list(records.keys()))
|
||||
self.assertEqual(['self'], list(records['app.bsky.actor.profile'].keys()))
|
||||
self.assertEqual([{
|
||||
'$type': 'app.bsky.graph.follow',
|
||||
'subject': 'did:plc:alice',
|
||||
'createdAt': '2022-01-02T03:04:05.000Z',
|
||||
}], list(records['app.bsky.graph.follow'].values()))
|
||||
|
|
|
@ -681,6 +681,14 @@ class ObjectTest(TestCase):
|
|||
self.assertNotIn('id', obj.as1['actor'])
|
||||
self.assertEqual(['c', 'd'], obj.as1['object'])
|
||||
|
||||
obj = Object(mf2={
|
||||
'properties': {
|
||||
'uid': ['z.com'],
|
||||
'url': ['x'],
|
||||
},
|
||||
})
|
||||
self.assertEqual('z.com', obj.as1['id'])
|
||||
|
||||
def test_validate_id(self):
|
||||
# DID repo ids
|
||||
Object(id='at://did:plc:123/app.bsky.feed.post/abc').put()
|
||||
|
@ -877,6 +885,65 @@ class ObjectTest(TestCase):
|
|||
},
|
||||
}, obj.our_as1)
|
||||
|
||||
def test_normalize_ids_empty(self):
|
||||
obj = Object()
|
||||
obj.normalize_ids()
|
||||
self.assertIsNone(obj.as1)
|
||||
|
||||
def test_normalize_ids_follow_atproto(self):
|
||||
# for ATProto handle resolution
|
||||
self.store_object(id='did:plc:user', raw=DID_DOC)
|
||||
alice = self.make_user(id='did:plc:user', cls=ATProto)
|
||||
|
||||
obj = Object(our_as1={
|
||||
'objectType': 'activity',
|
||||
'verb': 'follow',
|
||||
'actor': 'https://bsky.app/profile/did:plc:123',
|
||||
'object': 'https://bsky.app/profile/han.dull',
|
||||
})
|
||||
obj.normalize_ids()
|
||||
self.assert_equals({
|
||||
'objectType': 'activity',
|
||||
'verb': 'follow',
|
||||
'actor': 'did:plc:123',
|
||||
'object': 'did:plc:user',
|
||||
}, obj.our_as1)
|
||||
|
||||
def test_normalize_ids_reply(self):
|
||||
# for ATProto handle resolution
|
||||
self.store_object(id='did:plc:user', raw=DID_DOC)
|
||||
self.make_user(id='did:plc:user', cls=ATProto)
|
||||
|
||||
obj = Object(our_as1={
|
||||
'objectType': 'activity',
|
||||
'verb': 'post',
|
||||
'object': {
|
||||
'id': 'https://bsky.app/profile/han.dull/post/456',
|
||||
'objectType': 'note',
|
||||
'inReplyTo': 'https://bsky.app/profile/did:plc:123/post/789',
|
||||
'author': 'https://bsky.app/profile/han.dull',
|
||||
'tags': [{
|
||||
'objectType': 'mention',
|
||||
'url': 'https://bsky.app/profile/did:plc:123',
|
||||
}],
|
||||
},
|
||||
})
|
||||
obj.normalize_ids()
|
||||
self.assert_equals({
|
||||
'objectType': 'activity',
|
||||
'verb': 'post',
|
||||
'object': {
|
||||
'id': 'at://did:plc:user/app.bsky.feed.post/456',
|
||||
'objectType': 'note',
|
||||
'inReplyTo': 'at://did:plc:123/app.bsky.feed.post/789',
|
||||
'author': 'did:plc:user',
|
||||
'tags': [{
|
||||
'objectType': 'mention',
|
||||
'url': 'did:plc:123',
|
||||
}],
|
||||
},
|
||||
}, obj.our_as1)
|
||||
|
||||
def test_get_originals(self):
|
||||
self.assertEqual([], models.get_originals(['foo', 'did:plc:bar']))
|
||||
|
||||
|
|
|
@ -99,11 +99,12 @@ class PagesTest(TestCase):
|
|||
self.assert_equals(404, got.status_code)
|
||||
|
||||
def test_user_not_direct(self):
|
||||
fake = self.make_user('fake:foo', cls=Fake)
|
||||
fake.direct = False
|
||||
fake.put()
|
||||
|
||||
fake = self.make_user('fake:foo', cls=Fake, direct=False)
|
||||
got = self.client.get('/fake/fake:foo')
|
||||
self.assert_equals(200, got.status_code)
|
||||
|
||||
fake = self.make_user('http://fo/o', cls=ActivityPub, direct=False)
|
||||
got = self.client.get('/ap/@o@fo')
|
||||
self.assert_equals(404, got.status_code)
|
||||
|
||||
def test_user_opted_out(self):
|
||||
|
|
|
@ -93,7 +93,8 @@ class ProtocolTest(TestCase):
|
|||
('https://ap.brid.gy/foo/bar', ActivityPub),
|
||||
('https://web.brid.gy/foo/bar', Web),
|
||||
]:
|
||||
self.assertEqual(expected, Protocol.for_id(id))
|
||||
self.assertEqual(expected, Protocol.for_id(id, remote=False))
|
||||
self.assertEqual(expected, Protocol.for_id(id, remote=True))
|
||||
|
||||
def test_for_id_true_overrides_none(self):
|
||||
class Greedy(Protocol, User):
|
||||
|
@ -137,6 +138,11 @@ class ProtocolTest(TestCase):
|
|||
self.assertIsNone(Protocol.for_id('http://web.site/'))
|
||||
self.assertIn(self.req('http://web.site/'), mock_get.mock_calls)
|
||||
|
||||
@patch('requests.get')
|
||||
def test_for_id_web_remote_false(self, mock_get):
|
||||
self.assertIsNone(Protocol.for_id('http://web.site/', remote=False))
|
||||
mock_get.assert_not_called()
|
||||
|
||||
def test_for_handle_deterministic(self):
|
||||
for handle, expected in [
|
||||
(None, (None, None)),
|
||||
|
|
|
@ -947,6 +947,7 @@ class WebTest(TestCase):
|
|||
type='share',
|
||||
object_ids=['https://mas.to/toot/id'],
|
||||
labels=['user', 'activity', 'notification', 'feed'],
|
||||
ignore=['our_as1'],
|
||||
)
|
||||
|
||||
def test_link_rel_alternate_as2(self, mock_get, mock_post):
|
||||
|
@ -1005,6 +1006,7 @@ class WebTest(TestCase):
|
|||
type='like',
|
||||
labels=['activity', 'user'],
|
||||
status='ignored',
|
||||
ignore=['our_as1'],
|
||||
)
|
||||
|
||||
def test_post_type_discovery_multiple_types(self, mock_get, mock_post):
|
||||
|
@ -1124,6 +1126,7 @@ class WebTest(TestCase):
|
|||
notify=[ndb.Key('ActivityPub', 'https://mas.to/author')],
|
||||
delivered=['https://mas.to/inbox'],
|
||||
status='complete',
|
||||
ignore=['our_as1'],
|
||||
)
|
||||
|
||||
def test_create_non_domain_author(self, mock_get, mock_post):
|
||||
|
@ -1316,16 +1319,19 @@ class WebTest(TestCase):
|
|||
|
||||
self.assert_deliveries(mock_post, ['https://mas.to/inbox'], FOLLOW_AS2)
|
||||
|
||||
follow_as1 = copy.deepcopy(FOLLOW_AS1)
|
||||
follow_as1['actor']['id'] = 'https://user.com/'
|
||||
obj = self.assert_object('https://user.com/follow',
|
||||
users=[self.user.key],
|
||||
notify=[self.mrs_foo],
|
||||
source_protocol='web',
|
||||
status='complete',
|
||||
mf2=FOLLOW_MF2,
|
||||
our_as1=follow_as1,
|
||||
delivered=['https://mas.to/inbox'],
|
||||
type='follow',
|
||||
object_ids=['https://mas.to/mrs-foo'],
|
||||
labels=['user', 'activity', 'notification'],
|
||||
ignore=['our_as1'],
|
||||
)
|
||||
|
||||
to = self.assert_user(ActivityPub, 'https://mas.to/mrs-foo', obj_as2={
|
||||
|
@ -1412,7 +1418,8 @@ class WebTest(TestCase):
|
|||
delivered=['https://mas.to/inbox'],
|
||||
type='follow',
|
||||
object_ids=['https://mas.to/mrs-foo'],
|
||||
labels=['user', 'activity', 'notification',],
|
||||
labels=['user', 'activity', 'notification'],
|
||||
ignore=['our_as1'],
|
||||
)
|
||||
|
||||
followers = Follower.query().fetch()
|
||||
|
@ -1476,7 +1483,8 @@ class WebTest(TestCase):
|
|||
object_ids=['https://mas.to/mrs-foo',
|
||||
'https://mas.to/mr-biff'],
|
||||
labels=['user', 'activity', 'notification',],
|
||||
)
|
||||
ignore=['our_as1'],
|
||||
)
|
||||
|
||||
followers = Follower.query().fetch()
|
||||
self.assertEqual(2, len(followers))
|
||||
|
@ -1590,6 +1598,7 @@ class WebTest(TestCase):
|
|||
type='follow',
|
||||
object_ids=['https://mas.to/mrs-foo'],
|
||||
labels=['user', 'activity', 'notification',],
|
||||
ignore=['our_as1'],
|
||||
)
|
||||
|
||||
def test_repost_twitter_blocklisted(self, *mocks):
|
||||
|
|
|
@ -11,6 +11,7 @@ from unittest.mock import ANY, call
|
|||
from urllib.parse import urlencode
|
||||
import warnings
|
||||
|
||||
from arroba import did
|
||||
import arroba.util
|
||||
from arroba.util import datetime_to_tid
|
||||
from bs4 import MarkupResemblesLocatorWarning
|
||||
|
@ -197,6 +198,9 @@ class TestCase(unittest.TestCase, testutil.Asserts):
|
|||
protocol.Protocol.for_id.cache.clear()
|
||||
common.webmention_discover.cache.clear()
|
||||
User.count_followers.cache.clear()
|
||||
did.resolve_handle.cache.clear()
|
||||
did.resolve_plc.cache.clear()
|
||||
did.resolve_web.cache.clear()
|
||||
|
||||
for cls in Fake, OtherFake:
|
||||
cls.fetchable = {}
|
||||
|
@ -299,8 +303,8 @@ class TestCase(unittest.TestCase, testutil.Asserts):
|
|||
mf2=obj_mf2, source_protocol=cls.LABEL
|
||||
).key
|
||||
|
||||
kwargs.setdefault('direct', True)
|
||||
user = cls(id=id,
|
||||
direct=True,
|
||||
mod=global_user.mod,
|
||||
public_exponent=global_user.public_exponent,
|
||||
private_exponent=global_user.private_exponent,
|
||||
|
|
3
web.py
3
web.py
|
@ -537,7 +537,8 @@ class Web(User, Protocol):
|
|||
obj_as1 = obj.as1
|
||||
from_proto = PROTOCOLS.get(obj.source_protocol)
|
||||
if from_proto:
|
||||
if not common.is_enabled(cls, from_proto):
|
||||
user_id = from_user.key.id() if from_user and from_user.key else None
|
||||
if not common.is_enabled(cls, from_proto, handle_or_id=user_id):
|
||||
error(f'{cls.LABEL} <=> {from_proto.LABEL} not enabled')
|
||||
|
||||
# fill in author/actor if available
|
||||
|
|
Ładowanie…
Reference in New Issue