2025-05-30 20:00:50 +00:00
|
|
|
"""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
|
2025-05-30 20:29:36 +00:00
|
|
|
|
|
|
|
Nostr Object key ids are NIP-21 nostr:... URIs.
|
|
|
|
https://nips.nostr.com/21
|
2025-05-30 20:00:50 +00:00
|
|
|
"""
|
|
|
|
import logging
|
|
|
|
|
|
|
|
from google.cloud import ndb
|
2025-05-31 03:38:40 +00:00
|
|
|
from granary import as1
|
|
|
|
import granary.nostr
|
2025-05-30 20:00:50 +00:00
|
|
|
from requests import RequestException
|
|
|
|
from oauth_dropins.webutil import util
|
|
|
|
from oauth_dropins.webutil.util import add, json_dumps, json_loads
|
2025-06-02 15:39:52 +00:00
|
|
|
import secp256k1
|
|
|
|
from websockets.exceptions import ConnectionClosedOK
|
|
|
|
from websockets.sync.client import connect
|
2025-05-30 20:00:50 +00:00
|
|
|
|
|
|
|
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'
|
2025-05-31 03:38:40 +00:00
|
|
|
LOGO_HTML = '<img src="/static/nostr_logo.png">'
|
2025-05-30 20:00:50 +00:00
|
|
|
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):
|
2025-05-31 03:38:40 +00:00
|
|
|
if self.obj_key:
|
|
|
|
return granary.nostr.Nostr.user_url(
|
|
|
|
self.obj_key.id().removeprefix("nostr:"))
|
2025-05-30 20:00:50 +00:00
|
|
|
|
|
|
|
def id_uri(self):
|
|
|
|
return f'nostr:{self.key.id()}'
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
def owns_id(cls, id):
|
2025-05-31 03:38:40 +00:00
|
|
|
return id.startswith('nostr:') or bool(granary.nostr.is_bech32(id))
|
2025-05-30 20:00:50 +00:00
|
|
|
|
|
|
|
@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
|
2025-05-31 03:38:40 +00:00
|
|
|
elif handle.startswith('npub'):
|
2025-05-30 20:00:50 +00:00
|
|
|
return handle
|
|
|
|
|
2025-05-31 03:38:40 +00:00
|
|
|
return granary.nostr.nip05_to_npub(handle)
|
2025-05-30 20:00:50 +00:00
|
|
|
|
|
|
|
@classmethod
|
|
|
|
def bridged_web_url_for(cls, user, fallback=False):
|
2025-05-31 03:38:40 +00:00
|
|
|
if not isinstance(user, Nostr) and user.obj:
|
|
|
|
if nprofile := user.obj.get_copy(Nostr):
|
|
|
|
return granary.nostr.Nostr.user_url(nprofile)
|
2025-05-30 20:00:50 +00:00
|
|
|
|
|
|
|
@classmethod
|
|
|
|
def target_for(cls, obj, shared=False):
|
2025-05-31 03:38:40 +00:00
|
|
|
"""Look up the author's relays and return one?"""
|
2025-05-30 20:00:50 +00:00
|
|
|
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 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
|
2025-06-02 15:39:52 +00:00
|
|
|
def _convert(to_cls, obj, from_user=None):
|
2025-05-30 20:00:50 +00:00
|
|
|
"""Converts a :class:`models.Object` to a Nostr event.
|
|
|
|
|
|
|
|
Args:
|
|
|
|
obj (models.Object)
|
2025-05-31 03:38:40 +00:00
|
|
|
from_user (models.User): user this object is from
|
2025-05-30 20:00:50 +00:00
|
|
|
|
|
|
|
Returns:
|
|
|
|
dict: JSON Nostr event
|
|
|
|
"""
|
2025-06-02 15:39:52 +00:00
|
|
|
privkey = None
|
|
|
|
if from_user and from_user.nostr_key_bytes:
|
|
|
|
privkey = granary.nostr.bech32_encode(
|
|
|
|
'nsec', from_user.nostr_key_bytes.hex())
|
|
|
|
|
|
|
|
translated = to_cls.translate_ids(obj.as1)
|
|
|
|
return granary.nostr.from_as1(translated, privkey=privkey)
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
def send(to_cls, obj, relay_url, from_user=None, **kwargs):
|
|
|
|
"""Sends an event to a relay."""
|
|
|
|
assert from_user
|
|
|
|
assert from_user.nostr_key_bytes
|
|
|
|
|
|
|
|
event = to_cls.convert(obj, from_user=from_user)
|
|
|
|
pubkey = granary.nostr.pubkey_from_privkey(from_user.nostr_key_bytes.hex())
|
|
|
|
assert event.get('pubkey') == pubkey, event
|
|
|
|
assert event.get('sig'), event
|
|
|
|
|
|
|
|
with connect(relay_url, open_timeout=util.HTTP_TIMEOUT,
|
|
|
|
close_timeout=util.HTTP_TIMEOUT) as websocket:
|
|
|
|
try:
|
|
|
|
websocket.send(json_dumps(['EVENT', event]))
|
|
|
|
msg = websocket.recv(timeout=util.HTTP_TIMEOUT)
|
|
|
|
except ConnectionClosedOK as cc:
|
|
|
|
logger.warning(cc)
|
|
|
|
return False
|
|
|
|
|
|
|
|
return True
|