diff --git a/ap.brid.gy.as2.json b/ap.brid.gy.as2.json index 5e96ba8f..94b8e92b 100644 --- a/ap.brid.gy.as2.json +++ b/ap.brid.gy.as2.json @@ -12,7 +12,7 @@ "https://ap.brid.gy/" ], "preferredUsername": "ap.brid.gy", - "summary": "Bridgy Fed (https://fed.brid.gy/) bot user for the fediverse. To bridge your Bluesky account to the fediverse, follow this account.\n\nTo ask a fediverse user to bridge their account, send their address (eg @user@instance) to this account in a chat message.\n\nMore info: https://fed.brid.gy/docs", + "summary": "Bridgy Fed (https://fed.brid.gy/) bot user for the fediverse. To bridge your account to the fediverse, follow this account.\n\nTo ask a fediverse user to bridge their account, send their address (eg @user@instance) to this account in a chat message.\n\nMore info: https://fed.brid.gy/docs", "name": "Bridgy Fed for the fediverse", "image": [ { diff --git a/app.py b/app.py index 75492825..06aa5ebe 100644 --- a/app.py +++ b/app.py @@ -6,7 +6,7 @@ registered. from flask_app import app # import all modules to register their Flask handlers -import activitypub, atproto, convert, follow, pages, redirect, ui, webfinger, web +import activitypub, atproto, convert, follow, nostr, pages, redirect, ui, webfinger, web import models models.reset_protocol_properties() diff --git a/atproto.py b/atproto.py index 5a367639..2de93312 100644 --- a/atproto.py +++ b/atproto.py @@ -313,7 +313,7 @@ class ATProto(User, Protocol): @classmethod def owns_handle(cls, handle, allow_internal=False): - # TODO: implement allow_internal + # TODO: implement allow_internal? if not did.HANDLE_RE.fullmatch(handle): return False diff --git a/atproto_hub.py b/atproto_hub.py index 78cd4044..e0c50cdd 100644 --- a/atproto_hub.py +++ b/atproto_hub.py @@ -18,7 +18,7 @@ from oauth_dropins.webutil import appengine_config, flask_util import pytz # all protocols -import activitypub, atproto, web +import activitypub, atproto, nostr, web import atproto_firehose import common import models diff --git a/bsky.brid.gy.as2.json b/bsky.brid.gy.as2.json index f844f4d3..993fd70f 100644 --- a/bsky.brid.gy.as2.json +++ b/bsky.brid.gy.as2.json @@ -12,7 +12,7 @@ "https://fed.brid.gy/" ], "preferredUsername": "bsky.brid.gy", - "summary": "

Bridgy Fed bot user for Bluesky. To bridge your fediverse account to Bluesky, follow this account. More info here.

After you follow this account, it will follow you back. Accept its follow to make sure your fediverse posts get sent to the bridge and make it into Bluesky.

To ask a Bluesky user to bridge their account, DM their handle (eg snarfed.bsky.social) to this account.

", + "summary": "

Bridgy Fed bot user for Bluesky. To bridge your account to Bluesky, follow this account. More info here.

After you follow this account, it will follow you back. Accept its follow to make sure your posts get sent to the bridge.

To ask a Bluesky user to bridge their account, DM their handle (eg snarfed.bsky.social) to this account.

", "name": "Bridgy Fed for Bluesky", "image": [ { diff --git a/common.py b/common.py index 2b32f5fb..f24794b0 100644 --- a/common.py +++ b/common.py @@ -56,6 +56,7 @@ PROTOCOL_DOMAINS = ( 'efake.brid.gy', 'fa.brid.gy', 'other.brid.gy', + 'nostr.brid.gy', ) OTHER_DOMAINS = ( 'bridgy-federated.appspot.com', diff --git a/ids.py b/ids.py index c1fe8ba1..f2925dff 100644 --- a/ids.py +++ b/ids.py @@ -31,14 +31,14 @@ logger = logging.getLogger(__name__) # populated in models.reset_protocol_properties COPIES_PROTOCOLS = None -# Webfinger allows all sorts of characters that ATProto handles don't, -# notably _ and ~. Map those to -. +# Webfinger allows all sorts of characters that ATProto handles and Nostr usernames +# don't, notably _ and ~. Map those to -. # ( : (colon) is mostly just used in the fake protocols in unit tests.) # https://www.rfc-editor.org/rfc/rfc7565.html#section-7 # https://atproto.com/specs/handle # https://github.com/snarfed/bridgy-fed/issues/982 # https://github.com/swicg/activitypub-webfinger/issues/9 -ATPROTO_DASH_CHARS = ('_', '~', ':') +DASH_CHARS = ('_', '~', ':') # can't use translate_user_id because Web.owns_id checks valid_domain, which # doesn't allow our protocol subdomains @@ -90,6 +90,9 @@ def web_ap_base_domain(user_domain): def translate_user_id(*, id, from_, to): """Translate a user id from one protocol to another. + *NOTE*: unlike :func:`translate_object_id`, if ``to`` is a ``HAS_COPIES`` protocol + and has no copy object for ``id``, this function returns None, not ``id``! + TODO: unify with :func:`translate_object_id`. Args: @@ -206,6 +209,8 @@ def normalize_user_id(*, id, proto): normalized = util.domain_from_link(normalized) elif proto.LABEL == 'atproto' and id.startswith('at://'): normalized, _, _ = parse_at_uri(id) + elif proto.LABEL == 'nostr': + normalized = id.removeprefix('nostr:') elif proto.LABEL in ('fake', 'efake', 'other'): normalized = normalized.replace(':profile:', ':') @@ -276,23 +281,42 @@ def translate_handle(*, handle, from_, to, enhanced): if from_.owns_handle(handle, allow_internal=True) is False: raise ValueError(f'input handle {handle} is not valid for {from_.LABEL}') + if from_.LABEL == 'nostr': + # _ username is NIP-05 shortcut for just the domain itself + # https://nips.nostr.com/5#showing-just-the-domain-as-an-identifier + handle = handle.removeprefix('_@') + + def flattened_user_at_domain(): + # "flatten" [@]user@domain handles to just domain-like, eg user.domain, + # and then append @[protocol domain], so we end up with + # user.domain@proto.brid.gy. + domain = f'{from_.ABBREV}{SUPERDOMAIN}' + + flattened = handle.lstrip('@').replace('@', '.') + for from_char in DASH_CHARS: + flattened = flattened.replace(from_char, '-') + + if enhanced or handle == PRIMARY_DOMAIN or handle in PROTOCOL_DOMAINS: + domain = flattened + + return f'{flattened}@{domain}' + output = None match from_.LABEL, to.LABEL: case _, 'activitypub': - domain = f'{from_.ABBREV}{SUPERDOMAIN}' - if enhanced or handle == PRIMARY_DOMAIN or handle in PROTOCOL_DOMAINS: - domain = handle - output = f'@{handle}@{domain}' + output = '@' + flattened_user_at_domain() case _, 'atproto': - output = handle.lstrip('@').replace('@', '.') - for from_char in ATPROTO_DASH_CHARS: - output = output.replace(from_char, '-') + if handle == PRIMARY_DOMAIN or handle in PROTOCOL_DOMAINS: + return handle + + output = flattened_user_at_domain().replace('@', '.') - if enhanced or handle == PRIMARY_DOMAIN or handle in PROTOCOL_DOMAINS: - pass - else: - output = f'{output}.{from_.ABBREV}{SUPERDOMAIN}' + case _, 'nostr': + if handle == PRIMARY_DOMAIN or handle in PROTOCOL_DOMAINS: + return f'_@{handle}' + + output = flattened_user_at_domain() case 'activitypub', 'web': user, instance = handle.lstrip('@').split('@') @@ -319,6 +343,9 @@ def translate_handle(*, handle, from_, to, enhanced): def translate_object_id(*, id, from_, to): """Translates a user handle from one protocol to another. + *NOTE*: unlike :func:`translate_user_id`, if ``to`` is a ``HAS_COPIES`` protocol + and has no copy object for ``id``, this function returns ``id``, not None! + TODO: unify with :func:`translate_user_id`. Args: diff --git a/models.py b/models.py index d6e1f08e..d6fb2c30 100644 --- a/models.py +++ b/models.py @@ -49,6 +49,7 @@ PROTOCOLS = {label: None for label in ( 'atproto', 'bsky', 'ostatus', + 'nostr', 'web', 'webmention', 'ui', diff --git a/nostr.brid.gy.as2.json b/nostr.brid.gy.as2.json new file mode 100644 index 00000000..516b69bb --- /dev/null +++ b/nostr.brid.gy.as2.json @@ -0,0 +1,38 @@ +{ + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://purl.archive.org/miscellany", + "https://w3id.org/security/v1", + {"alsoKnownAs": {"@id": "as:alsoKnownAs", "@type": "@id"}} + ], + "type": "Service", + "id": "https://nostr.brid.gy/nostr.brid.gy", + "url": [ + "https://nostr.brid.gy/", + "https://fed.brid.gy/" + ], + "preferredUsername": "nostr.brid.gy", + "summary": "

Bridgy Fed bot user for Nostr. To bridge your account to Nostr, follow this account. More info here.

After you follow this account, it will follow you back. Accept its follow to make sure your posts get sent to the bridge.

To ask a Nostr user to bridge their account, DM their NIP-05 handle (eg ryan@snarfed.org) to this account.

", + "name": "Bridgy Fed for Nostr", + "image": [ + { + "type": "Image", + "url": "https://fed.brid.gy/static/bridgy_fed_banner.png" + }, + { + "name": "Bridgy Fed for Nostr", + "type": "Image", + "url": "https://fed.brid.gy/static/bridgy_logo_square.jpg" + } + ], + "icon": { + "name": "Bridgy Fed for Nostr", + "type": "Image", + "url": "https://fed.brid.gy/static/bridgy_logo_square.jpg" + }, + "alsoKnownAs": [ + "https://nostr.brid.gy/", + "https://fed.brid.gy/" + ], + "manuallyApprovesFollowers": false +} diff --git a/nostr.py b/nostr.py new file mode 100644 index 00000000..49a16dba --- /dev/null +++ b/nostr.py @@ -0,0 +1,172 @@ +"""Nostr protocol implementation. + +https://github.com/nostr-protocol/nostr +https://github.com/nostr-protocol/nips/blob/master/01.md +https://github.com/nostr-protocol/nips#list +""" +import logging + +from google.cloud import ndb +from granary import as1, nostr +from requests import RequestException +from oauth_dropins.webutil import util +from oauth_dropins.webutil.util import add, json_dumps, json_loads + +import common +from common import ( + DOMAIN_BLOCKLIST, + DOMAIN_RE, + DOMAINS, + error, + USER_AGENT, +) +import ids +from models import Object, PROTOCOLS, Target, User +from protocol import Protocol + +logger = logging.getLogger(__name__) + + +class Nostr(User, Protocol): + """Nostr class. + + Key id is bech32 npub id. + https://github.com/nostr-protocol/nips/blob/master/19.md + """ + ABBREV = 'nostr' + PHRASE = 'Nostr' + LOGO_HTML = '' + CONTENT_TYPE = 'application/json' + HAS_COPIES = True + REQUIRES_AVATAR = True + REQUIRES_NAME = True + DEFAULT_ENABLED_PROTOCOLS = ('web',) + SUPPORTED_AS1_TYPES = frozenset( + tuple(as1.ACTOR_TYPES) + + tuple(as1.POST_TYPES) + + ('post', 'delete', 'undo') # no update/edit (I think?) + + ('follow', 'like', 'share', 'stop-following') + ) + SUPPORTS_DMS = False # NIP-17 + + @ndb.ComputedProperty + def handle(self): + """TODO: NIP-05""" + return None + + def web_url(self): + return None # TODO + + def id_uri(self): + return f'nostr:{self.key.id()}' + + @classmethod + def owns_id(cls, id): + return (id.startswith('npub') + or id.startswith('nevent') + or id.startswith('note') + or id.startswith('nprofile') + or id.startswith('naddr') + or id.startswith('nostr:')) + + @classmethod + def owns_handle(cls, handle, allow_internal=False): + if not handle: + return False + + # TODO: implement allow_internal? + return (handle.startswith('npub') + or cls.is_user_at_domain(handle, allow_internal=True)) + + @classmethod + def handle_to_id(cls, handle): + if cls.owns_handle(handle) is False: + return None + + if handle.startswith('npub'): + return handle + + # TODO: implement NIP-05 resolution + return None + + @classmethod + def bridged_web_url_for(cls, user, fallback=False): + """TODO: which client? coracle? + """ + return None + + @classmethod + def target_for(cls, obj, shared=False): + """Look up the author's relays and return one? + """ + return None + + @classmethod + def create_for(cls, user): + """Creates a Nostr profile for a non-Nostr user. + + Args: + user (models.User) + """ + pass # TODO + + @classmethod + def set_username(to_cls, user, username): + """check NIP-05 DNS, then update profile event with nip05?""" + if not user.is_enabled(Nostr): + raise ValueError("First, you'll need to bridge your account into Nostr by following this account.") + + npub = user.get_copy(Nostr) + username = username.removeprefix('@') + + # TODO: implement NIP-05 setup + logger.info(f'Setting Nostr NIP-05 for {user.key.id()} to {username}') + pass + + @classmethod + def send(to_cls, obj, url, from_user=None, orig_obj_id=None): + """Sends an object to a relay. + """ + # TODO: send to relay + return False + + @classmethod + def fetch(cls, obj, **kwargs): + """Tries to fetch a Nostr event from a relay. + + Args: + obj (models.Object): with the id to fetch. Fills data into the ``as2`` + property. + kwargs: ignored + + Returns: + bool: True if the object was fetched and populated successfully, + False otherwise + """ + id = obj.key.id() + if not cls.owns_id(id): + logger.info(f"Nostr can't fetch {id}") + return False + + # TODO: fetch from relay + return False + + + @classmethod + def _convert(cls, obj, from_user=None): + """Converts a :class:`models.Object` to a Nostr event. + + Args: + obj (models.Object) + from_user (models.User): user (actor) this activity/object is from + + Returns: + dict: JSON Nostr event + """ + from_proto = PROTOCOLS.get(obj.source_protocol) + + # TODO: implement actual conversion + if not obj.as1: + return {} + + return {} # TODO diff --git a/pages.py b/pages.py index e33f3c20..2379e8db 100644 --- a/pages.py +++ b/pages.py @@ -50,6 +50,7 @@ from models import ( PROTOCOLS, USER_STATUS_DESCRIPTIONS, ) +from nostr import Nostr from protocol import Protocol from web import Web import webfinger @@ -70,6 +71,7 @@ TEMPLATE_VARS = { 'ids': ids, 'isinstance': isinstance, 'logs': logs, + 'Nostr': Nostr, 'PROTOCOLS': PROTOCOLS, 'set': set, 'util': util, diff --git a/router.py b/router.py index c25e5771..b5f678ba 100644 --- a/router.py +++ b/router.py @@ -10,7 +10,7 @@ from oauth_dropins.webutil import ( ) # all protocols -import activitypub, atproto, web +import activitypub, atproto, nostr, web import common import dms import models diff --git a/scripts/firehose_consumer.py b/scripts/firehose_consumer.py index 346c60e3..14c84fdb 100644 --- a/scripts/firehose_consumer.py +++ b/scripts/firehose_consumer.py @@ -10,7 +10,7 @@ from oauth_dropins.webutil.appengine_info import DEBUG, LOCAL_SERVER from oauth_dropins.webutil import appengine_config, flask_util # all protocols -import activitypub, atproto, web +import activitypub, atproto, nostr, web import atproto_firehose import models diff --git a/scripts/firehose_server.py b/scripts/firehose_server.py index 370134f4..7dd24d99 100644 --- a/scripts/firehose_server.py +++ b/scripts/firehose_server.py @@ -17,7 +17,7 @@ from oauth_dropins.webutil import appengine_config, flask_util import pytz # all protocols -import activitypub, atproto, web +import activitypub, atproto, nostr, web import common import models diff --git a/tests/test_activitypub.py b/tests/test_activitypub.py index 59694554..30503cad 100644 --- a/tests/test_activitypub.py +++ b/tests/test_activitypub.py @@ -93,7 +93,7 @@ ACTOR_FAKE = { 'following': 'https://fa.brid.gy/ap/fake:user/following', 'followers': 'https://fa.brid.gy/ap/fake:user/followers', 'endpoints': {'sharedInbox': 'https://fa.brid.gy/ap/sharedInbox'}, - 'preferredUsername': 'fake:handle:user', + 'preferredUsername': 'fake-handle-user', 'summary': '', 'alsoKnownAs': ['uri:fake:user'], 'manuallyApprovesFollowers': False, @@ -106,7 +106,7 @@ ACTOR_FAKE_USER = { activitypub.SECURITY_CONTEXT, activitypub.AKA_CONTEXT, ], - 'name': 'fake:handle:user', + 'name': 'fake-handle-user', 'type': 'Person', 'summary': '[bridged from fake:handle:user on fake-phrase by Bridgy Fed]', 'discoverable': True, diff --git a/tests/test_follow.py b/tests/test_follow.py index 619eb70a..67d9704b 100644 --- a/tests/test_follow.py +++ b/tests/test_follow.py @@ -117,7 +117,7 @@ class RemoteFollowTest(TestCase): mock_get.return_value = WEBFINGER got = self.client.post('/remote-follow?address=@foo@ba.r&id=fake:user&protocol=fake') self.assertEqual(302, got.status_code) - self.assertEqual('https://ba.r/follow?uri=@fake:handle:user@fa.brid.gy', + self.assertEqual('https://ba.r/follow?uri=@fake-handle-user@fa.brid.gy', got.headers['Location']) mock_get.assert_has_calls(( diff --git a/tests/test_ids.py b/tests/test_ids.py index a3d2b8b4..7f0afb12 100644 --- a/tests/test_ids.py +++ b/tests/test_ids.py @@ -7,6 +7,7 @@ from flask_app import app import ids from ids import translate_handle, translate_object_id, translate_user_id from models import Target +from nostr import Nostr from .testutil import Fake, TestCase from web import Web @@ -16,6 +17,7 @@ class IdsTest(TestCase): super().setUp() Web(id='bsky.brid.gy', ap_subdomain='bsky', has_redirects=True).put() Web(id='fed.brid.gy', ap_subdomain='fed', has_redirects=True).put() + Web(id='nostr.brid.gy', ap_subdomain='nostr', has_redirects=True).put() def test_translate_user_id(self): Web(id='user.com', @@ -23,7 +25,7 @@ class IdsTest(TestCase): ActivityPub(id='https://inst/user', copies=[Target(uri='did:plc:456', protocol='atproto')]).put() fake_user = Fake(id='fake:user', - copies=[Target(uri='did:plc:789', protocol='atproto')]) + copies=[Target(uri='did:plc:789', protocol='atproto')]) fake_user.put() # ATProto with DID docs, used to resolve handle in bsky.app URL @@ -51,8 +53,9 @@ class IdsTest(TestCase): ATProto, 'did:plc:456'), (ActivityPub, 'https://bsky.brid.gy/ap/did:plc:456', Fake, 'fake:u:did:plc:456'), - (ATProto, 'did:plc:456', ATProto, 'did:plc:456'), + (Nostr, 'npub123', Nostr, 'npub123'), + # copies (ATProto, 'did:plc:123', Web, 'user.com'), (ATProto, 'did:plc:456', ActivityPub, 'https://inst/user'), @@ -62,9 +65,19 @@ class IdsTest(TestCase): (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, 'did:plc:456', Nostr, None), (ATProto, 'https://bsky.app/profile/user.com', ATProto, 'did:plc:123'), (ATProto, 'https://bsky.app/profile/did:plc:123', ATProto, 'did:plc:123'), + (Nostr, 'npub123', Web, 'https://nostr.brid.gy/web/npub123'), + (Nostr, 'npub123', ActivityPub, 'https://nostr.brid.gy/ap/npub123'), + (Nostr, 'npub123', ATProto, None), + (Nostr, 'npub123', Fake, 'fake:u:npub123'), + + (ActivityPub, 'https://inst/user', Nostr, None), + (Web, 'user.com', Nostr, None), + (Fake, 'fake:user', Nostr, None), + # user, not enabled, no copy (ATProto, 'did:plc:000', ActivityPub, 'https://bsky.app/profile/zero.com'), @@ -88,6 +101,7 @@ class IdsTest(TestCase): # instance actor / protocol bot users (Web, 'fed.brid.gy', ActivityPub, 'https://fed.brid.gy/fed.brid.gy'), (Web, 'bsky.brid.gy', ActivityPub, 'https://bsky.brid.gy/bsky.brid.gy'), + (Web, 'nostr.brid.gy', ActivityPub, 'https://nostr.brid.gy/nostr.brid.gy'), ]: with self.subTest(id=id, from_=from_.LABEL, to=to.LABEL): self.assertEqual(expected, translate_user_id( @@ -152,6 +166,8 @@ class IdsTest(TestCase): (Web, 'https://user.com/', 'user.com'), (Web, 'https://www.user.com/', 'user.com'), (Web, 'm.user.com', 'user.com'), + (Nostr, 'npub123', 'npub123'), + (Nostr, 'nostr:nprofile1234abcd', 'nprofile1234abcd'), ]: with self.subTest(id=id, proto=proto): self.assertEqual(expected, ids.normalize_user_id(id=id, proto=proto)) @@ -162,6 +178,7 @@ class IdsTest(TestCase): (ATProto, 'did:plc:123', 'at://did:plc:123/app.bsky.actor.profile/self'), (Fake, 'fake:user', 'fake:profile:user'), (Web, 'user.com', 'https://user.com/'), + (Nostr, 'npub123', 'npub123'), ]: with self.subTest(id=id, proto=proto): self.assertEqual(expected, ids.profile_id(id=id, proto=proto)) @@ -174,23 +191,39 @@ class IdsTest(TestCase): (Web, 'user.com', Fake, 'fake:handle:user.com'), (Web, 'u_se-r.com', Fake, 'fake:handle:u_se-r.com'), (Web, 'user.com', Web, 'user.com'), + (Web, 'user.com', Nostr, 'user.com@web.brid.gy'), (ActivityPub, '@user@instance', ActivityPub, '@user@instance'), (ActivityPub, '@user@instance', ATProto, 'user.instance.ap.brid.gy'), (ActivityPub, '@u_se~r@instance', ATProto, 'u-se-r.instance.ap.brid.gy'), (ActivityPub, '@user@instance', Fake, 'fake:handle:@user@instance'), (ActivityPub, '@user@instance', Web, 'https://instance/@user'), + (ActivityPub, '@user@instance', Nostr, 'user.instance@ap.brid.gy'), (ATProto, 'user.com', ActivityPub, '@user.com@bsky.brid.gy'), (ATProto, 'u-se-r.com', ActivityPub, '@u-se-r.com@bsky.brid.gy'), (ATProto, 'user.com', ATProto, 'user.com'), (ATProto, 'user.com', Fake, 'fake:handle:user.com'), (ATProto, 'user.com', Web, 'user.com'), + (ATProto, 'user.com', Nostr, 'user.com@bsky.brid.gy'), - (Fake, 'fake:handle:user', ActivityPub, '@fake:handle:user@fa.brid.gy'), + (Fake, 'fake:handle:user', ActivityPub, '@fake-handle-user@fa.brid.gy'), (Fake, 'fake:handle:user', ATProto, 'fake-handle-user.fa.brid.gy'), (Fake, 'fake:handle:user', Fake, 'fake:handle:user'), (Fake, 'fake:handle:user', Web, 'fake:handle:user'), + (Fake, 'fake:handle:user', Nostr, 'fake-handle-user@fa.brid.gy'), + + (Nostr, 'user@dom.ain', Nostr, 'user@dom.ain'), + (Nostr, 'user@dom.ain', ActivityPub, '@user.dom.ain@nostr.brid.gy'), + (Nostr, 'user@dom.ain', ATProto, 'user.dom.ain.nostr.brid.gy'), + (Nostr, 'user@dom.ain', Web, 'user@dom.ain'), + (Nostr, 'user@dom.ain', Fake, 'fake:handle:user@dom.ain'), + + (Nostr, '_@dom.ain', Nostr, '_@dom.ain'), + (Nostr, '_@dom.ain', ActivityPub, '@dom.ain@nostr.brid.gy'), + (Nostr, '_@dom.ain', ATProto, 'dom.ain.nostr.brid.gy'), + (Nostr, '_@dom.ain', Web, 'dom.ain'), + (Nostr, '_@dom.ain', Fake, 'fake:handle:dom.ain'), # instance actor, protocol bot users (Web, 'fed.brid.gy', ActivityPub, '@fed.brid.gy@fed.brid.gy'), @@ -225,11 +258,16 @@ class IdsTest(TestCase): def test_translate_object_id(self): self.store_object(id='http://po.st', - copies=[Target(uri='at://did/web/post', protocol='atproto')]) + copies=[Target(uri='at://did/web/post', protocol='atproto'), + Target(uri='nevent123web', protocol='nostr')]) self.store_object(id='https://inst/post', - copies=[Target(uri='at://did/ap/post', protocol='atproto')]) + copies=[Target(uri='at://did/ap/post', protocol='atproto'), + Target(uri='nevent123ap', protocol='atproto')]) self.store_object(id='fake:post', - copies=[Target(uri='at://did/fa/post', protocol='atproto')]) + copies=[Target(uri='at://did/fa/post', protocol='atproto'), + Target(uri='nevent123fa', protocol='nostr')]) + self.store_object(id='nevent123', + copies=[Target(uri='at://did/no/post', protocol='atproto')]) # DID doc and ATProto, used to resolve handle in bsky.app URL did = self.store_object(id='did:plc:123', raw={ @@ -240,15 +278,26 @@ class IdsTest(TestCase): for from_, id, to, expected in [ (ActivityPub, 'https://inst/post', ActivityPub, 'https://inst/post'), - (ActivityPub, 'https://inst/post', ATProto, 'at://did/ap/post'), (ActivityPub, 'https://inst/post', Fake, 'fake:o:ap:https://inst/post'), (ActivityPub, 'https://inst/post', Web, 'https://ap.brid.gy/convert/web/https://inst/post'), (ATProto, 'at://did/atp/post', ATProto, 'at://did/atp/post'), + (Nostr, 'nevent123', Nostr, 'nevent123'), + # copies + (ActivityPub, 'https://inst/post', ATProto, 'at://did/ap/post'), (ATProto, 'at://did/web/post', Web, 'http://po.st'), (ATProto, 'at://did/ap/post', ActivityPub, 'https://inst/post'), (ATProto, 'at://did/fa/post', Fake, 'fake:post'), + (ATProto, 'at://did/no/post', Nostr, 'nevent123'), + (Nostr, 'nevent123web', Web, 'http://po.st'), + (Nostr, 'nevent123ap', ActivityPub, 'https://inst/post'), + (Nostr, 'nevent123fa', Fake, 'fake:post'), + (Nostr, 'nevent123', ATProto, 'at://did/no/post'), + (Web, 'http://po.st', ATProto, 'at://did/web/post'), + (Web, 'http://po.st', Nostr, 'nevent123web'), + (Fake, 'fake:post', Nostr, 'nevent123fa'), + # no copies (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'), @@ -257,17 +306,23 @@ class IdsTest(TestCase): 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'), + (ATProto, 'did:plc:x', Nostr, 'did:plc:x'), (Fake, 'fake:post', ActivityPub, 'https://fa.brid.gy/convert/ap/fake:post'), (Fake, 'fake:post', ATProto, 'at://did/fa/post'), (Fake, 'fake:post', Fake, 'fake:post'), (Fake, 'fake:post', Web, 'https://fa.brid.gy/convert/web/fake:post'), + (Fake, 'fake:other-post', Nostr, 'fake:other-post'), (Web, 'http://po.st', ActivityPub, 'http://localhost/r/http://po.st'), - (Web, 'http://po.st', ATProto, 'at://did/web/post'), (Web, 'http://po.st', Fake, 'fake:o:web:http://po.st'), (Web, 'http://po.st', Web, 'http://po.st'), + (Nostr, 'nevent456', Fake, 'fake:o:nostr:nevent456'), + (Nostr, 'nevent456', ActivityPub, + 'https://nostr.brid.gy/convert/ap/nevent456'), + (Nostr, 'nevent456', ATProto, 'nevent456'), + (Nostr, 'nevent456', Web, 'https://nostr.brid.gy/convert/web/nevent456'), ]: - with self.subTest(from_=from_.LABEL, to=to.LABEL): + with self.subTest(id=id, from_=from_.LABEL, to=to.LABEL): self.assertEqual(expected, translate_object_id( id=id, from_=from_, to=to)) diff --git a/tests/test_models.py b/tests/test_models.py index 1c449dcb..2e001559 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -288,7 +288,7 @@ class UserTest(TestCase): user = self.make_user('fake:user', cls=Fake) self.assertEqual('fake:handle:user', user.handle_as(Fake)) self.assertEqual('fake:handle:user', user.handle_as('fake')) - self.assertEqual('@fake:handle:user@fa.brid.gy', user.handle_as('ap')) + self.assertEqual('@fake-handle-user@fa.brid.gy', user.handle_as('ap')) def test_handle_as_web_custom_username(self, *_): self.user.obj.our_as1 = { diff --git a/tests/test_nostr.py b/tests/test_nostr.py new file mode 100644 index 00000000..77b5b814 --- /dev/null +++ b/tests/test_nostr.py @@ -0,0 +1,15 @@ +"""Unit tests for nostr.py.""" +from unittest.mock import patch + +from flask_app import app +import ids +from ids import translate_handle, translate_object_id, translate_user_id +from models import Target +from nostr import Nostr +from .testutil import Fake, TestCase + + +class NostrTest(TestCase): + + def setUp(self): + common.RUN_TASKS_INLINE = False diff --git a/tests/test_webfinger.py b/tests/test_webfinger.py index 32a4eb51..aeaa1940 100644 --- a/tests/test_webfinger.py +++ b/tests/test_webfinger.py @@ -94,7 +94,7 @@ WEBFINGER_NO_HCARD = { }], } WEBFINGER_FAKE = { - 'subject': 'acct:fake:handle:user@fa.brid.gy', + 'subject': 'acct:fake-handle-user@fa.brid.gy', 'aliases': ['web:fake:user'], 'links': [{ 'rel': 'canonical_uri', @@ -290,7 +290,7 @@ class WebfingerTest(TestCase): def test_missing_user(self): for acct in ('nope.com', 'nope.com@nope.com', 'nope.com@web.brid.gy', - 'nope.com@fed.brid.gy', 'fake:handle:user@fake.brid.gy'): + 'nope.com@fed.brid.gy', 'fake-handle-user@fake.brid.gy'): got = self.client.get(f'/.well-known/webfinger?resource=acct:{acct}') self.assertEqual(404, got.status_code) diff --git a/tests/testutil.py b/tests/testutil.py index d475b93b..f5413905 100644 --- a/tests/testutil.py +++ b/tests/testutil.py @@ -284,12 +284,12 @@ from memcache import ( memcache, pickle_memcache, ) -from web import Web from flask_app import app +from nostr import Nostr +from web import Web -ActivityPub.DEFAULT_ENABLED_PROTOCOLS += ('fake', 'other') -ATProto.DEFAULT_ENABLED_PROTOCOLS += ('fake', 'other') -Web.DEFAULT_ENABLED_PROTOCOLS += ('fake', 'other') +for proto in (ActivityPub, ATProto, Nostr, Web): + proto.DEFAULT_ENABLED_PROTOCOLS += ('fake', 'other') # used in TestCase.make_user() to reuse keys across Users since they're # expensive to generate. diff --git a/web.py b/web.py index 53321390..ab87bce7 100644 --- a/web.py +++ b/web.py @@ -143,7 +143,7 @@ class Web(User, Protocol): """ ap_subdomain = ndb.StringProperty( - choices=['ap', 'bsky', 'fed', 'web', 'fake', 'other', 'efake'], + choices=['ap', 'bsky', 'efake', 'fake', 'fed', 'nostr', 'other', 'web'], default='web') """Originally, BF served Web users' AP actor ids on fed.brid.gy, eg https://fed.brid.gy/snarfed.org . When we started adding new protocols, we