2023-08-24 03:34:32 +00:00
|
|
|
"""ATProto protocol implementation.
|
|
|
|
|
|
|
|
https://atproto.com/
|
2023-08-24 04:04:17 +00:00
|
|
|
|
|
|
|
TODO
|
|
|
|
* signup. resolve DID, fetch DID doc, extract PDS
|
|
|
|
* use alsoKnownAs as handle? or call getProfile on PDS to get handle?
|
|
|
|
* maybe need getProfile to store profile object?
|
2023-08-24 03:34:32 +00:00
|
|
|
"""
|
|
|
|
import logging
|
2023-08-24 03:44:42 +00:00
|
|
|
import re
|
2023-08-24 03:34:32 +00:00
|
|
|
|
|
|
|
from flask import abort, g, request
|
|
|
|
from google.cloud import ndb
|
2023-08-29 19:35:20 +00:00
|
|
|
from granary import as1, bluesky
|
2023-08-24 03:34:32 +00:00
|
|
|
from oauth_dropins.webutil import flask_util, util
|
|
|
|
from oauth_dropins.webutil.util import json_dumps, json_loads
|
|
|
|
import requests
|
|
|
|
|
|
|
|
from flask_app import app, cache
|
2023-08-24 04:04:17 +00:00
|
|
|
from granary.bluesky import Bluesky
|
2023-08-24 03:34:32 +00:00
|
|
|
import common
|
|
|
|
from common import (
|
|
|
|
add,
|
|
|
|
error,
|
|
|
|
is_blocklisted,
|
|
|
|
)
|
|
|
|
from models import Follower, Object, User
|
|
|
|
from protocol import Protocol
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
|
|
class ATProto(User, Protocol):
|
|
|
|
"""AT Protocol class.
|
|
|
|
|
|
|
|
Key id is DID, currently either did:plc or did:web.
|
|
|
|
https://atproto.com/specs/did
|
|
|
|
"""
|
|
|
|
ABBREV = 'atproto'
|
|
|
|
|
2023-08-24 04:04:17 +00:00
|
|
|
@ndb.ComputedProperty
|
|
|
|
def readable_id(self):
|
|
|
|
"""Prefers handle, then DID."""
|
|
|
|
pass # TODO
|
2023-08-24 03:34:32 +00:00
|
|
|
|
2023-08-24 03:44:42 +00:00
|
|
|
def _pre_put_hook(self):
|
|
|
|
"""Validate id, require did:plc or non-blocklisted did:web."""
|
|
|
|
super()._pre_put_hook()
|
|
|
|
id = self.key.id()
|
|
|
|
assert id
|
|
|
|
|
|
|
|
if id.startswith('did:plc:'):
|
|
|
|
assert id.removeprefix('did:plc:')
|
|
|
|
return
|
|
|
|
|
|
|
|
if id.startswith('did:web:'):
|
|
|
|
domain = id.removeprefix('did:web:')
|
|
|
|
assert (re.match(common.DOMAIN_RE, domain)
|
|
|
|
and not is_blocklisted(domain)), domain
|
|
|
|
return
|
|
|
|
|
|
|
|
assert False, f'{id} is not valid did:plc or did:web'
|
|
|
|
|
2023-08-24 04:04:17 +00:00
|
|
|
def handle(self):
|
|
|
|
# TODO get from self.obj
|
|
|
|
pass
|
|
|
|
|
|
|
|
def web_url(self):
|
|
|
|
return Bluesky.user_url(self.handle() or self.key.id())
|
2023-08-24 03:34:32 +00:00
|
|
|
|
|
|
|
# def ap_address(self):
|
|
|
|
# """Returns this user's AP address, eg '@foo.com@foo.com'."""
|
|
|
|
# if self.obj and self.obj.as1:
|
|
|
|
# addr = as2.address(self.as2())
|
|
|
|
# if addr:
|
|
|
|
# return addr
|
|
|
|
|
|
|
|
# return as2.address(self.key.id())
|
|
|
|
|
|
|
|
# def ap_actor(self, rest=None):
|
|
|
|
# """Returns this user's AP/AS2 actor id URL.
|
|
|
|
|
|
|
|
# Eg 'https://fed.brid.gy/foo.com'
|
|
|
|
# """
|
|
|
|
# return self.key.id()
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
def owns_id(cls, id):
|
|
|
|
return (id.startswith('at://')
|
|
|
|
or id.startswith('did:plc:')
|
|
|
|
or id.startswith('did:web:'))
|
|
|
|
|
|
|
|
# @classmethod
|
|
|
|
# def target_for(cls, obj, shared=False):
|
|
|
|
# """Returns a relay that the receiving user uses."""
|
|
|
|
# ...
|
|
|
|
# return actor.get('publicInbox') or actor.get('inbox')
|
|
|
|
|
|
|
|
# @classmethod
|
|
|
|
# def send(cls, obj, url, log_data=True):
|
|
|
|
# """Delivers an event to a relay.
|
|
|
|
# """
|
|
|
|
# if is_blocklisted(url):
|
|
|
|
# logger.info(f'Skipping sending to {url}')
|
|
|
|
# return False
|
|
|
|
|
|
|
|
# # this is set in web.webmention_task()
|
|
|
|
# orig_obj = getattr(obj, 'orig_obj', None)
|
|
|
|
# orig_as2 = orig_obj.as_as2() if orig_obj else None
|
|
|
|
# activity = obj.as2 or postprocess_as2(as2.from_as1(obj.as1),
|
|
|
|
# orig_obj=orig_as2)
|
|
|
|
|
|
|
|
# if g.user:
|
|
|
|
# activity['actor'] = g.user.ap_actor()
|
|
|
|
# elif not activity.get('actor'):
|
|
|
|
# logger.warning('Outgoing AP activity has no actor!')
|
|
|
|
|
|
|
|
# return signed_post(url, log_data=True, data=activity).ok
|
|
|
|
|
|
|
|
# @classmethod
|
|
|
|
# def fetch(cls, obj, **kwargs):
|
|
|
|
# """Tries to fetch a ATProto event.
|
|
|
|
|
|
|
|
# Args:
|
|
|
|
# obj: :class:`Object` with the id to fetch. Fills data into the as2
|
|
|
|
# property.
|
|
|
|
# kwargs: ignored
|
|
|
|
|
|
|
|
# Returns:
|
|
|
|
# True if the object was fetched and populated successfully,
|
|
|
|
# False otherwise
|
|
|
|
|
|
|
|
# Raises:
|
|
|
|
# TODO
|
|
|
|
# """
|
|
|
|
# url = obj.key.id()
|
|
|
|
# if not util.is_web(url):
|
|
|
|
# logger.info(f'{url} is not a URL')
|
|
|
|
# return False
|
|
|
|
|
|
|
|
# resp = None
|
|
|
|
|
|
|
|
# def _error(extra_msg=None):
|
|
|
|
# msg = f"Couldn't fetch {url} as ActivityStreams 2"
|
|
|
|
# if extra_msg:
|
|
|
|
# msg += ': ' + extra_msg
|
|
|
|
# logger.warning(msg)
|
|
|
|
# # protocol.for_id depends on us raising this when an AP network
|
|
|
|
# # fetch fails. if we change that, update for_id too!
|
|
|
|
# err = BadGateway(msg)
|
|
|
|
# err.requests_response = resp
|
|
|
|
# raise err
|
|
|
|
|
|
|
|
# def _get(url, headers):
|
|
|
|
# """Returns None if we fetched and populated, resp otherwise."""
|
|
|
|
# nonlocal resp
|
|
|
|
|
|
|
|
# try:
|
|
|
|
# resp = signed_get(url, headers=headers, gateway=True)
|
|
|
|
# except BadGateway as e:
|
|
|
|
# # ugh, this is ugly, should be something structured
|
|
|
|
# if '406 Client Error' in str(e):
|
|
|
|
# return
|
|
|
|
# raise
|
|
|
|
|
|
|
|
# if not resp.content:
|
|
|
|
# _error('empty response')
|
|
|
|
# elif common.content_type(resp) in as2.CONTENT_TYPES:
|
|
|
|
# try:
|
|
|
|
# return resp.json()
|
|
|
|
# except requests.JSONDecodeError:
|
|
|
|
# _error("Couldn't decode as JSON")
|
|
|
|
|
|
|
|
# obj.as2 = _get(url, CONNEG_HEADERS_AS2_HTML)
|
|
|
|
|
|
|
|
# if obj.as2:
|
|
|
|
# return True
|
|
|
|
# elif not resp:
|
|
|
|
# return False
|
|
|
|
|
|
|
|
# # look in HTML to find AS2 link
|
|
|
|
# if common.content_type(resp) != 'text/html':
|
|
|
|
# logger.info('no AS2 available')
|
|
|
|
# return False
|
|
|
|
|
|
|
|
# parsed = util.parse_html(resp)
|
|
|
|
# link = parsed.find('link', rel=('alternate', 'self'), type=(
|
|
|
|
# as2.CONTENT_TYPE, as2.CONTENT_TYPE_LD))
|
|
|
|
# if not (link and link['href']):
|
|
|
|
# logger.info('no AS2 available')
|
|
|
|
# return False
|
|
|
|
|
|
|
|
# obj.as2 = _get(link['href'], as2.CONNEG_HEADERS)
|
|
|
|
# if obj.as2:
|
|
|
|
# return True
|
|
|
|
|
|
|
|
# return False
|
|
|
|
|
2023-08-24 04:04:17 +00:00
|
|
|
@classmethod
|
|
|
|
def serve(cls, obj):
|
2023-08-29 19:35:20 +00:00
|
|
|
"""Serves an :class:`Object` as AS2.
|
|
|
|
|
|
|
|
This is minimally implemented to serve app.bsky.* lexicon data, but
|
|
|
|
BGSes and other clients will generally receive ATProto commits via
|
|
|
|
`com.atproto.sync.subscribeRepos` subscriptions, not BF-specific
|
|
|
|
/convert/... HTTP requests, so this should never be used in practice.
|
|
|
|
"""
|
|
|
|
return bluesky.from_as1(obj.as1), {'Content-Type': 'application/json'}
|