Porównaj commity

...

14 Commity

Autor SHA1 Wiadomość Data
Ryan Barrett f02ba80304
switch from gcloud datastore emulator to firestore emulator
...since the datastore emulator evidently doesn't support != query filters: https://github.com/googleapis/python-ndb/issues/962
2024-04-17 11:36:28 -07:00
Ryan Barrett 393605bde9
change ATProto.ABBREV to bsky
🤞, for #961
2024-04-17 06:54:16 -07:00
dependabot[bot] 2036f92ddd build(deps): bump itsdangerous from 2.1.2 to 2.2.0
Bumps [itsdangerous](https://github.com/pallets/itsdangerous) from 2.1.2 to 2.2.0.
- [Release notes](https://github.com/pallets/itsdangerous/releases)
- [Changelog](https://github.com/pallets/itsdangerous/blob/main/CHANGES.rst)
- [Commits](https://github.com/pallets/itsdangerous/compare/2.1.2...2.2.0)

---
updated-dependencies:
- dependency-name: itsdangerous
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-17 05:50:04 -07:00
dependabot[bot] 7190503aea build(deps): bump gunicorn from 21.2.0 to 22.0.0
Bumps [gunicorn](https://github.com/benoitc/gunicorn) from 21.2.0 to 22.0.0.
- [Release notes](https://github.com/benoitc/gunicorn/releases)
- [Commits](https://github.com/benoitc/gunicorn/compare/21.2.0...22.0.0)

---
updated-dependencies:
- dependency-name: gunicorn
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-16 19:30:32 -07:00
Ryan Barrett bf52d80e0f
add protocol logo to user page activities and followers
fixes #939

also drop page size from 30 to 20
2024-04-15 19:41:22 -07:00
Ryan Barrett 12a3bf0862
noop: add messages to asserts in ids, atproto 2024-04-15 16:44:33 -07:00
Ryan Barrett a70702776c
atproto: switch to Bluesky butterfly logo
also minor user page language tweak
2024-04-15 14:53:54 -07:00
Ryan Barrett 86ad33b896
user page tweak for follow accepts
for #825
2024-04-15 14:45:02 -07:00
Ryan Barrett 374af3aa5c
atproto polls: use common.USER_ALLOWLIST 2024-04-15 14:08:36 -07:00
Ryan Barrett e913ad1f53
ATProto.convert: refetch subject for cid if we don't have it 2024-04-15 07:31:05 -07:00
Ryan Barrett 2ec22de09f
abstract redirect.py to be multi-protocol
...mostly. creating the underlying user opportunistically is still Web-only.
2024-04-14 18:26:34 -07:00
Ryan Barrett 5b5ed4173a
fix tests for f840c8b784 2024-04-14 14:57:00 -07:00
Ryan Barrett f840c8b784
temporarily disable is_enabled checks in ATProto/ActivityPub.convert
for manual testing
2024-04-14 14:50:02 -07:00
Ryan Barrett cf633efecf
update tests for snarfed/granary@75a8f45825 2024-04-12 15:11:57 -07:00
24 zmienionych plików z 302 dodań i 153 usunięć

Wyświetl plik

@ -22,7 +22,7 @@ jobs:
echo "deb [signed-by=/usr/share/keyrings/cloud.google.gpg] https://packages.cloud.google.com/apt cloud-sdk main" | sudo tee -a /etc/apt/sources.list.d/google-cloud-sdk.list
curl https://packages.cloud.google.com/apt/doc/apt-key.gpg | sudo apt-key --keyring /usr/share/keyrings/cloud.google.gpg add -
sudo apt-get update
sudo apt-get install -y apt-transport-https ca-certificates gnupg google-cloud-sdk google-cloud-sdk-datastore-emulator default-jre
sudo apt-get install -y apt-transport-https ca-certificates gnupg google-cloud-sdk google-cloud-cli-firestore-emulator default-jre
- run:
name: Python dependencies
@ -40,7 +40,7 @@ jobs:
- run:
name: Build and test
command: |
CLOUDSDK_CORE_PROJECT=bridgy-federated gcloud beta emulators datastore start --no-store-on-disk --use-firestore-in-datastore-mode --host-port=localhost:8089 < /dev/null >& /dev/null &
CLOUDSDK_CORE_PROJECT=brid-gy gcloud emulators firestore start --host-port=:8089 --database-mode=datastore-mode < /dev/null >& /tmp/firestore-emulator.log &
sleep 5s
python -m coverage run --source=. --omit=appengine_config.py,logs.py,tests/\* -m unittest discover -v
python -m coverage html -d /tmp/coverage_html

Wyświetl plik

@ -12,7 +12,8 @@ Development
---
Development reference docs are at [bridgy-fed.readthedocs.io](https://bridgy-fed.readthedocs.io/). Pull requests are welcome! Feel free to [ping me in #indieweb-dev](https://indieweb.org/discuss) with any questions.
First, fork and clone this repo. Then, install the [Google Cloud SDK](https://cloud.google.com/sdk/) and run `gcloud components install beta cloud-datastore-emulator` to install the [datastore emulator](https://cloud.google.com/datastore/docs/tools/datastore-emulator). Once you have them, set up your environment by running these commands in the repo root directory:
First, fork and clone this repo. Then, install the [Google Cloud SDK](https://cloud.google.com/sdk/) and run `gcloud components install cloud-firestore-emulator` to install the [Firestore emulator](https://cloud.google.com/firestore/docs/emulator). Once you have them, set up your environment by running these commands in the repo root directory:
```sh
gcloud config set project bridgy-federated
@ -24,7 +25,7 @@ pip install -r requirements.txt
Now, run the tests to check that everything is set up ok:
```shell
gcloud beta emulators datastore start --use-firestore-in-datastore-mode --no-store-on-disk --host-port=localhost:8089 --quiet < /dev/null >& /dev/null &
gcloud emulators firestore start --host-port=:8089 --database-mode=datastore-mode < /dev/null >& /dev/null &
python3 -m unittest discover
```

Wyświetl plik

@ -362,8 +362,9 @@ class ActivityPub(User, Protocol):
from_proto = PROTOCOLS.get(obj.source_protocol)
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')
# TODO: uncomment
# 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:
return {

Wyświetl plik

@ -34,6 +34,7 @@ from common import (
DOMAINS,
error,
USER_AGENT,
USER_ALLOWLIST,
)
import flask_app
from models import Object, PROTOCOLS, Target, User
@ -85,11 +86,16 @@ class ATProto(User, Protocol):
Key id is DID, currently either did:plc or did:web.
https://atproto.com/specs/did
"""
ABBREV = 'atproto'
ABBREV = 'bsky'
# 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}/'
LOGO_HTML = '<img src="/oauth_dropins_static/bluesky.svg">'
# note that PDS hostname is atproto.brid.gy here, not bsky.brid.gy. Bluesky
# team currently has our hostname as atproto.brid.gy in their federation
# test.
# TODO: switch to bsky.brid.gy once they lift their federation limits? we'd
# need to update serviceEndpoint in all users' DID docs. :/
PDS_URL = f'https://atproto{common.SUPERDOMAIN}/'
CONTENT_TYPE = 'application/json'
def _pre_put_hook(self):
@ -392,8 +398,8 @@ class ATProto(User, Protocol):
copy = base_obj.get_copy(to_cls)
assert copy
copy_did, coll, rkey = parse_at_uri(copy)
assert copy_did == did
assert coll == type
assert copy_did == did, (copy_did, did)
assert coll == type, (coll, type)
logger.info(f'Storing ATProto {action} {type} {rkey}: {dag_json.encode(record).decode()}')
repo.apply_writes([Write(action=action, collection=type, rkey=rkey,
@ -501,8 +507,9 @@ class ATProto(User, Protocol):
"""
from_proto = PROTOCOLS.get(obj.source_protocol)
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')
# TODO: uncomment
# 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:
return obj.bsky
@ -531,13 +538,15 @@ class ATProto(User, Protocol):
if uri := strong_ref.get('uri'):
# TODO: fail if this load fails? since we don't populate CID
if ref_obj := ATProto.load(uri):
if not ref_obj.bsky.get('cid'):
ref_obj = ATProto.load(uri, remote=True)
strong_ref.update({
'cid': ref_obj.bsky.get('cid'),
'uri': ref_obj.key.id(),
})
match ret.get('$type'):
case 'app.bsky.feed.like' | 'app.bsky.feed.repost':
case 'app.bsky.feed.like' | 'app.bsky.feed.repost':
populate_cid(ret['subject'])
case 'app.bsky.feed.post' if ret.get('reply'):
populate_cid(ret['reply']['root'])
@ -572,7 +581,7 @@ def poll_notifications():
for user in users:
# TODO: remove for launch
if not DEBUG and user.key.id() not in ['indieweb.org', 'snarfed.org']:
if not DEBUG and user.key.id() not in USER_ALLOWLIST:
logger.info(f'Skipping {user.key.id()}')
continue
@ -632,7 +641,7 @@ def poll_posts():
for user in users:
# TODO: remove for launch
if not DEBUG and user.key.id() not in ['indieweb.org', 'snarfed.org']:
if not DEBUG and user.key.id() not in USER_ALLOWLIST:
logger.info(f'Skipping {user.key.id()}')
continue

Wyświetl plik

@ -1,25 +1,27 @@
Bridgy Fed developer documentation
----------------------------------
Bridgy Fed connects your web site to
`Mastodon <https://joinmastodon.org>`__ and the
`fediverse <https://en.wikipedia.org/wiki/Fediverse>`__ via
`ActivityPub <https://activitypub.rocks/>`__,
`webmentions <https://webmention.net/>`__, and
`microformats2 <https://microformats.org/wiki/microformats2>`__. Your
site gets its own fediverse profile, posts and avatar and header and
all. Bridgy Fed translates likes, reposts, mentions, follows, and more
back and forth. `See the user docs <https://fed.brid.gy/docs>`__ and
`developer docs <https://bridgy-fed.readthedocs.io/>`__ for more
details.
Bridgy Fed connects different decentralized social network protocols. It
currently supports the
`fediverse <https://en.wikipedia.org/wiki/Fediverse>`__ (eg
`Mastodon <https://joinmastodon.org>`__) via
`ActivityPub <https://activitypub.rocks/>`__, and the
`IndieWeb <https://indieweb.org/>`__ via
`webmentions <https://webmention.net/>`__ and
`microformats2 <https://microformats.org/wiki/microformats2>`__.
`Bluesky/AT
Protocol <https://github.com/snarfed/bridgy-fed/issues/381>`__ and
`Nostr <https://github.com/snarfed/bridgy-fed/issues/446>`__ are planned
for 2024. Bridgy Fed translates profiles, likes, reposts, mentions,
follows, and more from any supported network to any other. `See the user
docs <https://fed.brid.gy/docs>`__ and `developer
docs <https://bridgy-fed.readthedocs.io/>`__ for more details.
https://fed.brid.gy/
Also see the
`original <https://snarfed.org/indieweb-activitypub-bridge>`__
`design <https://snarfed.org/indieweb-ostatus-bridge>`__ blog posts.
License: This project is placed in the public domain.
License: This project is placed in the public domain. You may also use
it under the `CC0
License <https://creativecommons.org/publicdomain/zero/1.0/>`__.
Development
-----------
@ -31,11 +33,11 @@ requests are welcome! Feel free to `ping me in
First, fork and clone this repo. Then, install the `Google Cloud
SDK <https://cloud.google.com/sdk/>`__ and run
``gcloud components install beta cloud-datastore-emulator`` to install
the `datastore
emulator <https://cloud.google.com/datastore/docs/tools/datastore-emulator>`__.
Once you have them, set up your environment by running these commands in
the repo root directory:
``gcloud components install cloud-firestore-emulator`` to install the
`Firestore
emulator <https://cloud.google.com/firestore/docs/emulator>`__. Once you
have them, set up your environment by running these commands in the repo
root directory:
.. code:: sh
@ -48,7 +50,7 @@ Now, run the tests to check that everything is set up ok:
.. code:: shell
gcloud beta emulators datastore start --use-firestore-in-datastore-mode --no-store-on-disk --host-port=localhost:8089 --quiet < /dev/null >& /dev/null &
gcloud emulators firestore start --host-port=:8089 --database-mode=datastore-mode < /dev/null >& /dev/null &
python3 -m unittest discover
Finally, run this in the repo root directory to start the web app
@ -112,8 +114,9 @@ How to add a new protocol
5. TODO: add a new usage section to the docs for the new protocol.
6. TODO: does the new protocol need any new UI or signup functionality?
Unusual, but not impossible. Add that if necessary.
7. Add the new protocols logo to ``static/``, use it in
`templates/user.html <https://github.com/snarfed/bridgy-fed/blob/main/templates/user.html>`__.
7. Protocol logos may be emoji or image files. If this one is a file,
add it ``static/``. Then add the emoji or file ``<img>`` tag in the
``Protocol`` subclasss ``LOGO_HTML`` constant.
Stats
-----

6
ids.py
Wyświetl plik

@ -66,7 +66,7 @@ def translate_user_id(*, id, from_proto, to_proto):
Returns:
str: the corresponding id in ``to_proto``
"""
assert id and from_proto and to_proto
assert id and from_proto and to_proto, (id, from_proto, to_proto)
assert from_proto.owns_id(id) is not False or from_proto.LABEL == 'ui', \
(id, from_proto.LABEL, to_proto.LABEL)
@ -141,7 +141,7 @@ def translate_handle(*, handle, from_proto, to_proto, enhanced):
Returns:
str: the corresponding handle in ``to_proto``
"""
assert handle and from_proto and to_proto
assert handle and from_proto and to_proto, (handle, from_proto, to_proto)
assert from_proto.owns_handle(handle) is not False or from_proto.LABEL == 'ui'
if from_proto == to_proto:
@ -188,7 +188,7 @@ def translate_object_id(*, id, from_proto, to_proto):
Returns:
str: the corresponding id in ``to_proto``
"""
assert id and from_proto and to_proto
assert id and from_proto and to_proto, (id, from_proto, to_proto)
assert from_proto.owns_id(id) is not False or from_proto.LABEL == 'ui'
# bsky.app profile URL to DID

Wyświetl plik

@ -37,7 +37,7 @@ PROTOCOLS = {'ostatus': None}
# 2048 bits makes tests slow, so use 1024 for them
KEY_BITS = 1024 if DEBUG else 2048
PAGE_SIZE = 30
PAGE_SIZE = 20
# auto delete old objects of these types via the Object.expire property
# https://cloud.google.com/datastore/docs/ttl
@ -921,7 +921,7 @@ class Object(StringIdModel):
original objects in their source protocol, eg
``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``
``https://bsky.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``
@ -940,8 +940,7 @@ 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`.
TODO: unify with :meth:`normalize_ids`, :meth:`Object.normalize_ids`.
"""
if not self.as1:
return
@ -1181,7 +1180,7 @@ class Follower(ndb.Model):
query = Follower.query(
Follower.status == 'active',
filter_prop == user.key,
).order(-Follower.updated)
)
followers, before, after = fetch_page(query, Follower, by=Follower.updated)
users = ndb.get_multi(f.from_ if collection == 'followers' else f.to
@ -1231,7 +1230,9 @@ def fetch_objects(query, by=None, user=None):
if inner_type:
type = inner_type
# AS1 verb => human-readable phrase
phrases = {
'accept': 'accepted',
'article': 'posted',
'comment': 'replied',
'delete': 'deleted',
@ -1301,7 +1302,8 @@ def fetch_objects(query, by=None, user=None):
handle = did_to_handle(handle) or handle
content = f'@{handle}{suffix}'
content = common.pretty_link(url, text=content, user=user)
if url:
content = common.pretty_link(url, text=content, user=user)
obj.content = (obj_as1.get('content')
or obj_as1.get('displayName')

Wyświetl plik

@ -497,7 +497,7 @@ class Protocol:
For example, if ``proto`` is :class:`ActivityPub`, the ATProto URI
``at://did:plc:abc/coll/123`` will be converted to
``https://atproto.brid.gy/ap/at://did:plc:abc/coll/123``.
``https://bsky.brid.gy/ap/at://did:plc:abc/coll/123``.
Wraps these AS1 fields:
@ -515,7 +515,7 @@ class Protocol:
same logic is duplicated there!
TODO: unify with :meth:`Object.resolve_ids`,
:meth:`protocol.Protocol.normalize_ids`.
:meth:`models.Object.normalize_ids`.
Args:
to_proto (Protocol subclass)

Wyświetl plik

@ -26,8 +26,9 @@ from oauth_dropins.webutil.flask_util import error
from oauth_dropins.webutil.util import json_dumps, json_loads
from activitypub import ActivityPub
from flask_app import app, cache
from common import CACHE_TIME, CONTENT_TYPE_HTML
from flask_app import app, cache
from protocol import Protocol
from web import Web
logger = logging.getLogger(__name__)
@ -85,51 +86,59 @@ def redir(to):
domains = set((util.domain_from_link(to, minimize=True),
util.domain_from_link(to, minimize=False),
to_domain))
web_user = None
for domain in domains:
if domain:
if domain in DOMAIN_ALLOWLIST:
break
if Web.get_by_id(domain):
if web_user := Web.get_by_id(domain):
logger.info(f'Found web user for domain {domain}')
break
else:
if not accept_as2:
return f'No web user found for any of {domains}', 404, VARY_HEADER
if accept_as2:
# AS2 requested, fetch and convert and serve
obj = Web.load(to, check_backlink=False)
if not obj or obj.deleted:
if not accept_as2:
# redirect. include rel-alternate link to make posts discoverable by entering
# https://fed.brid.gy/r/[URL] in a fediverse instance's search.
logger.info(f'redirecting to {to}')
return f"""\
<!doctype html>
<html>
<head>
<link href="{request.url}" rel="alternate" type="application/activity+json">
</head>
<title>Redirecting...</title>
<h1>Redirecting...</h1>
<p>You should be redirected automatically to the target URL: <a href="{to}">{to}</a>. If not, click the link.
</html>
""", 301, {
'Location': to,
**VARY_HEADER,
}
# AS2 requested, fetch and convert and serve
proto = Protocol.for_id(to)
if not proto:
return f"Couldn't determine protocol for {to}", 404, VARY_HEADER
obj = proto.load(to)
if not obj or obj.deleted:
return f'Object not found: {to}', 404, VARY_HEADER
# TODO: do this for other protocols too?
if proto == Web and not web_user:
web_user = Web.get_or_create(util.domain_from_link(to), direct=False, obj=obj)
if not web_user:
return f'Object not found: {to}', 404, VARY_HEADER
user = Web.get_or_create(util.domain_from_link(to), direct=False, obj=obj)
if not user:
return f'Object not found: {to}', 404, VARY_HEADER
ret = ActivityPub.convert(obj, from_user=web_user)
logger.info(f'Returning: {json_dumps(ret, indent=2)}')
return ret, {
'Content-Type': (as2.CONTENT_TYPE_LD_PROFILE
if accept_type == as2.CONTENT_TYPE_LD
else accept_type),
'Access-Control-Allow-Origin': '*',
**VARY_HEADER,
}
ret = ActivityPub.convert(obj, from_user=user)
logger.info(f'Returning: {json_dumps(ret, indent=2)}')
return ret, {
'Content-Type': (as2.CONTENT_TYPE_LD_PROFILE
if accept_type == as2.CONTENT_TYPE_LD
else accept_type),
'Access-Control-Allow-Origin': '*',
**VARY_HEADER,
}
# redirect. include rel-alternate link to make posts discoverable by entering
# https://fed.brid.gy/r/[URL] in a fediverse instance's search.
logger.info(f'redirecting to {to}')
return f"""\
<!doctype html>
<html>
<head>
<link href="{request.url}" rel="alternate" type="application/activity+json">
</head>
<title>Redirecting...</title>
<h1>Redirecting...</h1>
<p>You should be redirected automatically to the target URL: <a href="{to}">{to}</a>. If not, click the link.
</html>
""", 301, {
'Location': to,
**VARY_HEADER,
}

Wyświetl plik

@ -50,7 +50,7 @@ googleapis-common-protos==1.63.0
grpc-google-iam-v1==0.13.0
grpcio==1.62.1
grpcio-status==1.62.1
gunicorn==21.2.0
gunicorn==22.0.0
h11==0.14.0
html2text==2024.2.26
html5lib==1.1
@ -58,7 +58,7 @@ humanfriendly==10.0
humanize==4.9.0
idna==3.7
iterators==0.2.0
itsdangerous==2.1.2
itsdangerous==2.2.0
Jinja2==3.1.3
jsonschema==4.21.1
lxml==5.2.1

Wyświetl plik

@ -5,6 +5,9 @@
<li class="row">
{% with url=f.user.web_url(), user_as1=f.user.obj.as1 or {} %}
<a class="follower col-xs-10 col-sm-10 col-lg-6" href="{{ url }}">
<span class="logo" title="{{ user.__class__.__name__ }}">
{{ f.user.LOGO_HTML|safe }}
</span>
{% with picture=util.get_url(user_as1, 'icon') or util.get_url(user_as1, 'image') %}
{% if picture %}
<img class="profile u-photo" src="{{ picture }}" width="48px">

Wyświetl plik

@ -3,11 +3,14 @@
{% for obj in objects %}
<li class="row h-entry">
<div class="e-content col-xs-{{ 5 if show_users else 8 }}">
{% if obj.source_protocol %}
<span class="logo">{{ PROTOCOLS[obj.source_protocol].LOGO_HTML|safe }}</span>
{% endif %}
{{ obj.actor_link(user=user)|safe }}
{{ obj.phrase|safe }}
<a target="_blank" href="{{ obj.url }}" class="u-url">
{% if obj.url %}<a target="_blank" href="{{ obj.url }}" class="u-url">{% endif %}
{{ obj.content|default('--', true)|striptags|truncate(50) }}
</a>
{% if obj.url %}</a>{% endif %}
</div>
{% if show_users %}

Wyświetl plik

@ -1,3 +1,4 @@
{% extends "base.html" %}
{% block content %}

Wyświetl plik

@ -42,7 +42,7 @@
<nobr>
<a href="{{ user.web_url() }}"
title="{{ user.__class__.__name__ }} (native)">
title="{{ user.__class__.__name__ }} (original)">
<span class="logo">{{ user.LOGO_HTML|safe }}</span>
{{ user.handle_or_id() }}
</a>

Wyświetl plik

@ -440,7 +440,7 @@ class ActivityPubTest(TestCase):
def test_actor_atproto_not_enabled(self, *_):
self.store_object(id='did:plc:user', raw={'foo': 'baz'})
self.make_user('did:plc:user', cls=ATProto)
got = self.client.get('/ap/did:plc:user', base_url='https://atproto.brid.gy/')
got = self.client.get('/ap/did:plc:user', base_url='https://bsky.brid.gy/')
self.assertEqual(400, got.status_code)
@patch('common.ENABLED_BRIDGES', new=[('activitypub', 'atproto')])
@ -453,7 +453,7 @@ class ActivityPubTest(TestCase):
self.make_user('did:plc:user', cls=ATProto)
got = self.client.get('/ap/did:plc:user', base_url='https://atproto.brid.gy/')
got = self.client.get('/ap/did:plc:user', base_url='https://bsky.brid.gy/')
self.assertEqual(200, got.status_code)
self.assertNotIn('preferredUsername', got.json)
@ -1649,7 +1649,7 @@ class ActivityPubTest(TestCase):
def test_following_collection_page(self, *_):
self.store_following()
after = datetime(1900, 1, 1).isoformat()
prev = Follower.query(Follower.to == ActivityPub(id='http://baz').key,
prev = Follower.query(Follower.to == ActivityPub(id='http://bar').key,
Follower.from_ == self.user.key,
).get().updated.isoformat()
@ -2218,6 +2218,8 @@ class ActivityPubUtilsTest(TestCase):
'object': ACTOR,
}, ActivityPub.convert(obj))
# TODO: remove
@skip
def test_convert_protocols_not_enabled(self):
obj = Object(our_as1={'foo': 'bar'}, source_protocol='atproto')
with self.assertRaises(BadRequest):

Wyświetl plik

@ -73,7 +73,7 @@ class ATProtoTest(TestCase):
protocol='atproto')])
did_doc = copy.deepcopy(DID_DOC)
did_doc['service'][0]['serviceEndpoint'] = 'https://atproto.brid.gy/'
did_doc['service'][0]['serviceEndpoint'] = ATProto.PDS_URL
self.store_object(id='did:plc:user', raw=did_doc)
Repo.create(self.storage, 'did:plc:user', signing_key=ATPROTO_KEY)
@ -493,6 +493,36 @@ class ATProtoTest(TestCase):
'object': 'at://bob.net/app.bsky.feed.post/tid',
})))
@patch('requests.get', return_value=requests_response({ # AppView getRecord
'uri': 'at://did:plc:user/app.bsky.feed.post/tid',
'cid': 'my sidd',
'value': {'$type': 'app.bsky.feed.post'},
}))
def test_convert_populate_cid_refetch_cid(self, mock_get):
# existing Object with post but missing cid
self.store_object(id='did:plc:user', raw=DID_DOC)
self.store_object(id='at://did:plc:user/app.bsky.feed.post/tid', bsky={
'$type': 'app.bsky.feed.post',
'cid': '',
})
self.assertEqual({
'$type': 'app.bsky.feed.like',
'subject': {
'uri': 'at://did:plc:user/app.bsky.feed.post/tid',
'cid': 'my sidd',
},
'createdAt': '2022-01-02T03:04:05.000Z',
}, ATProto.convert(Object(our_as1={
'objectType': 'activity',
'verb': 'like',
'object': 'at://did:plc:user/app.bsky.feed.post/tid',
})))
mock_get.assert_called_with(
'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)
def test_convert_blobs_false(self):
self.assertEqual({
'$type': 'app.bsky.actor.profile',
@ -579,6 +609,8 @@ class ATProtoTest(TestCase):
}],
})))
# TODO: remove
@skip
def test_convert_protocols_not_enabled(self):
obj = Object(our_as1={'foo': 'bar'}, source_protocol='activitypub')
with self.assertRaises(BadRequest):
@ -607,11 +639,11 @@ class ATProtoTest(TestCase):
user = self.make_user('did:plc:user', cls=ATProto)
# TODO? or remove?
# self.assertEqual('@did:plc:user@atproto.brid.gy',
# self.assertEqual('@did:plc:user@bsky.brid.gy',
# user.handle_as('activitypub'))
self.store_object(id='did:plc:user', raw=DID_DOC)
self.assertEqual('@han.dull@atproto.brid.gy', user.handle_as('activitypub'))
self.assertEqual('@han.dull@bsky.brid.gy', user.handle_as('activitypub'))
@patch('requests.get', return_value=requests_response(DID_DOC))
def test_profile_id(self, mock_get):
@ -679,7 +711,7 @@ class ATProtoTest(TestCase):
'actor': 'fake:user',
})
self.assertTrue(ATProto.send(obj, 'https://atproto.brid.gy/'))
self.assertTrue(ATProto.send(obj, 'https://bsky.brid.gy/'))
# check DID doc
user = user.key.get()
@ -743,7 +775,7 @@ class ATProtoTest(TestCase):
Fake.fetchable = {'fake:user': ACTOR_AS}
obj = self.store_object(id='fake:post', source_protocol='fake', our_as1=NOTE_AS)
self.assertTrue(ATProto.send(obj, 'https://atproto.brid.gy/'))
self.assertTrue(ATProto.send(obj, 'https://bsky.brid.gy/'))
# check profile, record
user = Fake.get_by_id('fake:user')
@ -780,7 +812,7 @@ class ATProtoTest(TestCase):
user = self.make_user_and_repo()
obj = self.store_object(id='fake:post', source_protocol='fake',
our_as1=NOTE_AS)
self.assertTrue(ATProto.send(obj, 'https://atproto.brid.gy/'))
self.assertTrue(ATProto.send(obj, 'https://bsky.brid.gy/'))
# check repo, record
did = user.key.get().get_copy(ATProto)
@ -809,7 +841,7 @@ class ATProtoTest(TestCase):
'verb': 'update',
'object': note.our_as1,
})
self.assertTrue(ATProto.send(update, 'https://atproto.brid.gy/'))
self.assertTrue(ATProto.send(update, 'https://bsky.brid.gy/'))
# check repo, record
did = self.user.key.get().get_copy(ATProto)
@ -831,7 +863,7 @@ class ATProtoTest(TestCase):
'actor': 'fake:user',
'object': 'fake:post',
})
self.assertTrue(ATProto.send(update, 'https://atproto.brid.gy/'))
self.assertTrue(ATProto.send(update, 'https://bsky.brid.gy/'))
# check repo, record
did = self.user.key.get().get_copy(ATProto)
@ -863,7 +895,7 @@ class ATProtoTest(TestCase):
'actor': 'fake:user',
'object': 'at://did:plc:bob/app.bsky.feed.post/tid',
})
self.assertTrue(ATProto.send(like_obj, 'https://atproto.brid.gy/'))
self.assertTrue(ATProto.send(like_obj, 'https://bsky.brid.gy/'))
# check repo, record
did = user.get_copy(ATProto)
@ -903,7 +935,7 @@ class ATProtoTest(TestCase):
'actor': 'fake:user',
'object': 'at://did:bob/app.bsky.feed.post/tid',
})
self.assertTrue(ATProto.send(obj, 'https://atproto.brid.gy/'))
self.assertTrue(ATProto.send(obj, 'https://bsky.brid.gy/'))
# check repo, record
did = user.get_copy(ATProto)
@ -938,7 +970,7 @@ class ATProtoTest(TestCase):
'actor': 'fake:user',
'object': 'did:plc:bob',
})
self.assertTrue(ATProto.send(obj, 'https://atproto.brid.gy/'))
self.assertTrue(ATProto.send(obj, 'https://bsky.brid.gy/'))
# check repo, record
did = user.get_copy(ATProto)
@ -973,7 +1005,7 @@ class ATProtoTest(TestCase):
'actor': 'fake:alice',
'object': 'fake:follow',
})
self.assertFalse(ATProto.send(obj, 'https://atproto.brid.gy/'))
self.assertFalse(ATProto.send(obj, 'https://bsky.brid.gy/'))
self.assertEqual(0, AtpBlock.query().count())
self.assertEqual(0, AtpRepo.query().count())
mock_create_task.assert_not_called()
@ -985,7 +1017,7 @@ class ATProtoTest(TestCase):
copies=[Target(uri='did:plc:user', protocol='atproto')])
obj = self.store_object(id='fake:post', source_protocol='fake',
our_as1=NOTE_AS)
self.assertFalse(ATProto.send(obj, 'https://atproto.brid.gy/'))
self.assertFalse(ATProto.send(obj, 'https://bsky.brid.gy/'))
self.assertEqual(0, AtpBlock.query().count())
self.assertEqual(0, AtpRepo.query().count())
mock_create_task.assert_not_called()
@ -1003,7 +1035,7 @@ class ATProtoTest(TestCase):
'actor': 'fake:user',
},
})
self.assertFalse(ATProto.send(obj, 'https://atproto.brid.gy/'))
self.assertFalse(ATProto.send(obj, 'https://bsky.brid.gy/'))
self.assertEqual(0, AtpBlock.query().count())
self.assertEqual(0, AtpRepo.query().count())
mock_create_task.assert_not_called()
@ -1047,7 +1079,7 @@ class ATProtoTest(TestCase):
create = self.store_object(id='fake:reply:post', source_protocol='fake',
our_as1=create_as1)
self.assertTrue(ATProto.send(create, 'https://atproto.brid.gy/'))
self.assertTrue(ATProto.send(create, 'https://bsky.brid.gy/'))
repo = self.storage.load_repo(user.get_copy(ATProto))
last_tid = arroba.util.int_to_tid(arroba.util._tid_ts_last)

Wyświetl plik

@ -64,7 +64,7 @@ class CommonTest(TestCase):
for input, expected in [
('https://fa.brid.gy/', ''),
('https://fa.brid.gy/ap/fake:foo', 'fake:foo'),
('https://atproto.brid.gy/convert/ap/did:plc:123', 'did:plc:123'),
('https://bsky.brid.gy/convert/ap/did:plc:123', 'did:plc:123'),
]:
self.assertEqual(expected, common.unwrap(input))
@ -99,8 +99,8 @@ class CommonTest(TestCase):
with app.test_request_context(base_url='http://bridgy-federated.uc.r.appspot.com'):
self.assertEqual('https://fed.brid.gy/asdf', common.host_url('asdf'))
with app.test_request_context(base_url='https://atproto.brid.gy', path='/foo'):
self.assertEqual('https://atproto.brid.gy/asdf', common.host_url('asdf'))
with app.test_request_context(base_url='https://bsky.brid.gy', path='/foo'):
self.assertEqual('https://bsky.brid.gy/asdf', common.host_url('asdf'))
def test_is_enabled(self):
self.assertTrue(common.is_enabled(Web, ActivityPub))

Wyświetl plik

@ -40,6 +40,7 @@ HTML = """\
A reply
</div>
<a class="u-in-reply-to" href="https://fake.com/123"></a>
<a class="u-in-reply-to" href="tag:fake.com:123"></a>
</article>
</body>
</html>
@ -64,6 +65,7 @@ AUTHOR_HTML = """\
A reply
</div>
<a class="u-in-reply-to" href="https://fake.com/123"></a>
<a class="u-in-reply-to" href="tag:fake.com:123"></a>
</article>
</body>
</html>

Wyświetl plik

@ -40,8 +40,8 @@ class IdsTest(TestCase):
(ATProto, 'did:plc:456', ActivityPub, 'https://inst/user'),
(ATProto, 'did:plc:789', Fake, 'fake:user'),
# no copies
(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', Web, 'https://bsky.brid.gy/web/did:plc:x'),
(ATProto, 'did:plc:x', ActivityPub, 'https://bsky.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'),
@ -111,7 +111,7 @@ class IdsTest(TestCase):
(ActivityPub, '@user@instance', Fake, 'fake:handle:@user@instance'),
(ActivityPub, '@user@instance', Web, 'https://instance/@user'),
(ATProto, 'user.com', ActivityPub, '@user.com@atproto.brid.gy'),
(ATProto, 'user.com', ActivityPub, '@user.com@bsky.brid.gy'),
(ATProto, 'user.com', ATProto, 'user.com'),
(ATProto, 'user.com', Fake, 'fake:handle:user.com'),
(ATProto, 'user.com', Web, 'user.com'),
@ -165,9 +165,9 @@ class IdsTest(TestCase):
(ATProto, 'at://did/ap/post', ActivityPub, 'https://inst/post'),
(ATProto, 'at://did/fa/post', Fake, 'fake:post'),
# no copies
(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, 'did:plc:x', Web, 'https://bsky.brid.gy/convert/web/did:plc:x'),
(ATProto, 'did:plc:x', ActivityPub, 'https://bsky.brid.gy/convert/ap/did:plc:x'),
(ATProto, 'did:plc:x', Fake, 'fake:o:bsky: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',

Wyświetl plik

@ -1,4 +1,5 @@
"""Integration tests."""
import copy
from unittest.mock import patch
from arroba.datastore_storage import DatastoreStorage
@ -10,7 +11,7 @@ from activitypub import ActivityPub
import app
from atproto import ATProto
from dns.resolver import NXDOMAIN
from granary.tests.test_bluesky import ACTOR_PROFILE_BSKY
from granary.tests.test_bluesky import ACTOR_PROFILE_BSKY, POST_BSKY
import hub
from models import Object, Target
from web import Web
@ -109,14 +110,14 @@ class IntegrationTests(TestCase):
web_test.assert_deliveries(mock_post, ['http://inst/bob/inbox'], data={
'@context': 'https://www.w3.org/ns/activitystreams',
'type': 'Create',
'id': 'https://atproto.brid.gy/convert/ap/at://did:plc:alice/app.bsky.feed.post/456#bridgy-fed-create',
'actor': 'https://atproto.brid.gy/ap/did:plc:alice',
'id': 'https://bsky.brid.gy/convert/ap/at://did:plc:alice/app.bsky.feed.post/456#bridgy-fed-create',
'actor': 'https://bsky.brid.gy/ap/did:plc:alice',
'published': '2022-01-02T03:04:05+00:00',
'object': {
'type': 'Note',
'id': 'https://atproto.brid.gy/convert/ap/at://did:plc:alice/app.bsky.feed.post/456',
'id': 'https://bsky.brid.gy/convert/ap/at://did:plc:alice/app.bsky.feed.post/456',
'url': 'http://localhost/r/https://bsky.app/profile/did:plc:alice/post/456',
'attributedTo': 'https://atproto.brid.gy/ap/did:plc:alice',
'attributedTo': 'https://bsky.brid.gy/ap/did:plc:alice',
'content': 'I hereby reply',
'contentMap': {'en': 'I hereby reply'},
'inReplyTo': 'http://inst/post',
@ -127,6 +128,7 @@ class IntegrationTests(TestCase):
'to': ['https://www.w3.org/ns/activitystreams#Public'],
})
@patch('requests.post', return_value=requests_response(''))
@patch('requests.get')
def test_atproto_follow_to_web(self, mock_get, mock_post):
@ -176,10 +178,11 @@ class IntegrationTests(TestCase):
self.assert_req(mock_get, 'https://bob.com/')
self.assert_req(mock_post, 'https://bob.com/webmention', data={
'source': 'https://atproto.brid.gy/convert/web/at://did:plc:alice/app.bsky.graph.follow/123',
'source': 'https://bsky.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=[
@ -213,7 +216,7 @@ class IntegrationTests(TestCase):
# alice profile
requests_response(PROFILE_GETRECORD),
])
def test_web_follow_to_atproto(self, mock_get, mock_post, _, __):
def test_web_follow_of_atproto(self, mock_get, mock_post, _, __):
"""Incoming webmention for a web follow of an ATProto bsky.app profile URL.
Web user bob.com
@ -270,3 +273,82 @@ class IntegrationTests(TestCase):
'subject': 'did:plc:alice',
'createdAt': '2022-01-02T03:04:05.000Z',
}], list(records['app.bsky.graph.follow'].values()))
@patch('oauth_dropins.webutil.appengine_config.tasks_client.create_task')
@patch('requests.get', side_effect=[
# getRecord of original post
# alice profile
requests_response({
'uri': 'at://did:plc:alice/app.bsky.feed.post/123',
'cid': 'sydd',
'value': POST_BSKY,
}),
])
def test_activitypub_like_of_atproto(self, mock_get, _):
"""AP inbox delivery of a Like of an ATProto bsky.app profile URL.
ActivityPub user @bob@inst , https://inst/bob
ATProto user alice.com (did:plc:alice)
Like is https://inst/like
"""
self.store_object(id='did:plc:alice', raw=DID_DOC)
alice = self.make_user(id='did:plc:alice', cls=ATProto)
storage = DatastoreStorage()
Repo.create(storage, 'did:plc:bob', signing_key=ATPROTO_KEY)
bob = self.make_user(id='https://inst/bob', cls=ActivityPub,
copies=[Target(uri='did:plc:bob', protocol='atproto')],
obj_as2={
'type': 'Person',
'id': 'https://inst/bob',
'name': 'Bob',
})
bob_did_doc = copy.deepcopy(test_atproto.DID_DOC)
bob_did_doc['service'][0]['serviceEndpoint'] = ATProto.PDS_URL
bob_did_doc.update({
'id': 'did:plc:bob',
'alsoKnownAs': ['at://bob.inst.ap.brid.gy'],
})
self.store_object(id='did:plc:bob', raw=bob_did_doc)
# existing Object with original post, *without* cid. we should refetch.
Object(id='at://did:plc:alice/app.bsky.feed.post/123', bsky=POST_BSKY).put()
# inbox delivery
like = {
'type': 'Like',
'id': 'http://inst/like',
'actor': 'https://inst/bob',
'object': 'https://bsky.brid.gy/convert/ap/at://did:plc:alice/app.bsky.feed.post/123',
}
resp = self.post('/ap/atproto/did:plc:alice/inbox', json=like)
self.assertEqual(202, resp.status_code)
# check results
self.assertEqual({
**like,
# TODO: stop normalizing this in the original protocol's data
'object': 'at://did:plc:alice/app.bsky.feed.post/123',
}, Object.get_by_id('http://inst/like').as2)
repo = storage.load_repo('did:plc:bob')
records = repo.get_contents()
self.assertEqual(['app.bsky.feed.like'], list(records.keys()))
self.assertEqual([{
'$type': 'app.bsky.feed.like',
'subject': {
'uri': 'at://did:plc:alice/app.bsky.feed.post/123',
'cid': 'sydd',
},
'createdAt': '2022-01-02T03:04:05.000Z',
}], list(records['app.bsky.feed.like'].values()))
# we needed to refetch the original post
self.assert_object(id='at://did:plc:alice/app.bsky.feed.post/123',
source_protocol='atproto', bsky={
**POST_BSKY,
'cid': 'sydd',
})

Wyświetl plik

@ -861,7 +861,7 @@ class ObjectTest(TestCase):
'object': {
'id': 'https://web.brid.gy/fa/fake:reply',
'inReplyTo': 'https://ap.brid.gy/fa/fake:post',
'author': 'https://atproto.brid.gy/ap/did:plc:123',
'author': 'https://bsky.brid.gy/ap/did:plc:123',
'tags': [{
'objectType': 'mention',
'url': 'https://ap.brid.gy/atproto/http://inst.com/@me',

Wyświetl plik

@ -409,7 +409,7 @@ class ProtocolTest(TestCase):
def test_targets_checks_blocklisted_per_protocol(self):
"""_targets should call the target protocol's is_blocklisted()."""
# non-ATProto account, ATProto target (PDS) is atproto.brid.gy
# non-ATProto account, ATProto target (PDS) is bsky.brid.gy
# shouldn't be blocklisted
user = self.make_user(
id='fake:user', cls=Fake,

Wyświetl plik

@ -21,6 +21,7 @@ from .test_web import (
REPOST_AS2,
REPOST_HTML,
TOOT_AS2,
TOOT_AS2_DATA,
)
REPOST_AS2 = {
@ -82,7 +83,8 @@ class RedirectTest(testutil.TestCase):
self._test_as2(as2.CONTENT_TYPE_LD_PROFILE)
def test_as2_creates_user(self):
Object(id='https://user.com/repost', as2=REPOST_AS2).put()
Object(id='https://user.com/repost', source_protocol='web',
as2=REPOST_AS2).put()
self.user.key.delete()
@ -96,34 +98,19 @@ class RedirectTest(testutil.TestCase):
@patch('requests.get')
def test_as2_fetch_post(self, mock_get):
mock_get.side_effect = [
requests_response(REPOST_HTML),
TOOT_AS2,
]
mock_get.return_value = TOOT_AS2 # from Protocol.for_id
resp = self.client.get('/r/https://user.com/repost',
headers={'Accept': as2.CONTENT_TYPE_LD_PROFILE})
self.assertEqual(200, resp.status_code, resp.get_data(as_text=True))
self.assert_equals(REPOST_AS2, resp.json)
self.assert_equals(TOOT_AS2_DATA, resp.json)
self.assertEqual('Accept', resp.headers['Vary'])
@patch('requests.get')
def test_as2_fetch_post_no_backlink(self, mock_get):
mock_get.side_effect = [
requests_response(
REPOST_HTML.replace('<a href="http://localhost/"></a>', '')),
TOOT_AS2,
]
resp = self.client.get('/r/https://user.com/repost',
headers={'Accept': as2.CONTENT_TYPE_LD_PROFILE})
self.assertEqual(200, resp.status_code, resp.get_data(as_text=True))
self.assert_equals(REPOST_AS2, resp.json)
self.assertEqual('Accept', resp.headers['Vary'])
@patch('requests.get')
@patch('requests.get', side_effect=[
requests_response(ACTOR_HTML), # AS2 fetch
requests_response(ACTOR_HTML), # web fetch
])
def test_as2_no_user_fetch_homepage(self, mock_get):
mock_get.return_value = requests_response(ACTOR_HTML)
self.user.key.delete()
self.user.obj_key.delete()
protocol.objects_cache.clear()
@ -174,7 +161,8 @@ class RedirectTest(testutil.TestCase):
self.assertEqual('https://user.com/bar', resp.headers['Location'])
def _test_as2(self, content_type):
self.obj = Object(id='https://user.com/', as2=REPOST_AS2).put()
self.obj = Object(id='https://user.com/', source_protocol='web',
as2=REPOST_AS2).put()
resp = self.client.get('/r/https://user.com/', headers={'Accept': content_type})
self.assertEqual(200, resp.status_code, resp.get_data(as_text=True))
@ -183,7 +171,8 @@ class RedirectTest(testutil.TestCase):
self.assertEqual('Accept', resp.headers['Vary'])
def test_as2_deleted(self):
Object(id='https://user.com/bar', as2={}, deleted=True).put()
Object(id='https://user.com/bar', as2={}, source_protocol='web',
deleted=True).put()
resp = self.client.get('/r/https://user.com/bar',
headers={'Accept': as2.CONTENT_TYPE_LD_PROFILE})
@ -196,3 +185,13 @@ class RedirectTest(testutil.TestCase):
resp = self.client.get('/r/https://user.com/',
headers={'Accept': as2.CONTENT_TYPE_LD_PROFILE})
self.assertEqual(404, resp.status_code, resp.get_data(as_text=True))
def test_as2_atproto_normalize_id(self):
self.obj = Object(id='at://did:plc:foo/app.bsky.feed.post/123',
source_protocol='atproto', as2=REPOST_AS2).put()
resp = self.client.get('/r/https://bsky.app/profile/did:plc:foo/post/123',
headers={'Accept': as2.CONTENT_TYPE_LD_PROFILE})
self.assertEqual(200, resp.status_code, resp.get_data(as_text=True))
self.assertEqual(as2.CONTENT_TYPE_LD_PROFILE, resp.content_type)
self.assert_equals(REPOST_AS2, resp.json)

Wyświetl plik

@ -2552,7 +2552,7 @@ class WebUtilTest(TestCase):
self.assertEqual(False, Web.owns_handle('@foo@bar.com'))
self.assertEqual(False, Web.owns_handle('foo@bar.com'))
self.assertEqual(False, Web.owns_handle('localhost'))
self.assertEqual(False, Web.owns_handle('atproto.brid.gy'))
self.assertEqual(False, Web.owns_handle('bsky.brid.gy'))
def test_handle_to_id(self, *_):
self.assertEqual('foo.com', Web.handle_to_id('foo.com'))