2023-03-08 21:10:41 +00:00
|
|
|
"""Base protocol class and common code."""
|
|
|
|
import logging
|
|
|
|
import threading
|
2023-06-14 21:57:59 +00:00
|
|
|
from urllib.parse import urljoin
|
2023-03-08 21:10:41 +00:00
|
|
|
|
2023-06-20 18:22:54 +00:00
|
|
|
from cachetools import LRUCache
|
2023-06-11 02:50:31 +00:00
|
|
|
from flask import g, request
|
2023-03-08 21:10:41 +00:00
|
|
|
from google.cloud import ndb
|
|
|
|
from google.cloud.ndb import OR
|
2023-06-20 18:22:54 +00:00
|
|
|
from granary import as1
|
2023-06-13 20:17:11 +00:00
|
|
|
import werkzeug.exceptions
|
2023-03-08 21:10:41 +00:00
|
|
|
|
|
|
|
import common
|
|
|
|
from common import error
|
2023-06-11 02:50:31 +00:00
|
|
|
from models import Follower, Object, PROTOCOLS, Target
|
2023-06-20 18:22:54 +00:00
|
|
|
from oauth_dropins.webutil import util
|
2023-03-11 20:58:36 +00:00
|
|
|
from oauth_dropins.webutil.util import json_dumps, json_loads
|
2023-03-08 21:10:41 +00:00
|
|
|
|
|
|
|
SUPPORTED_TYPES = (
|
|
|
|
'accept',
|
|
|
|
'article',
|
|
|
|
'audio',
|
|
|
|
'comment',
|
|
|
|
'create',
|
|
|
|
'delete',
|
|
|
|
'follow',
|
|
|
|
'image',
|
|
|
|
'like',
|
|
|
|
'note',
|
|
|
|
'post',
|
|
|
|
'share',
|
|
|
|
'stop-following',
|
|
|
|
'undo',
|
|
|
|
'update',
|
|
|
|
'video',
|
|
|
|
)
|
|
|
|
|
|
|
|
# activity ids that we've already handled and can now ignore.
|
|
|
|
# used in Protocol.receive
|
|
|
|
seen_ids = LRUCache(100000)
|
|
|
|
seen_ids_lock = threading.Lock()
|
|
|
|
|
2023-04-03 03:36:23 +00:00
|
|
|
# objects that have been loaded in Protocol.load
|
|
|
|
objects_cache = LRUCache(5000)
|
|
|
|
objects_cache_lock = threading.Lock()
|
|
|
|
|
2023-03-08 21:10:41 +00:00
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
2023-06-10 22:07:26 +00:00
|
|
|
# TODO: merge Protocol and User classes?
|
2023-05-26 23:07:36 +00:00
|
|
|
class Protocol:
|
2023-03-08 21:10:41 +00:00
|
|
|
"""Base protocol class. Not to be instantiated; classmethods only.
|
|
|
|
|
|
|
|
Attributes:
|
2023-06-11 15:14:17 +00:00
|
|
|
LABEL: str, human-readable lower case name
|
|
|
|
OTHER_LABELS: sequence of str, label aliases
|
|
|
|
ABBREV: str, lower case abbreviation, used in URL paths
|
2023-03-08 21:10:41 +00:00
|
|
|
"""
|
2023-06-11 15:14:17 +00:00
|
|
|
ABBREV = None
|
|
|
|
OTHER_LABELS = ()
|
2023-03-08 21:10:41 +00:00
|
|
|
|
|
|
|
def __init__(self):
|
|
|
|
assert False
|
|
|
|
|
2023-06-11 15:14:17 +00:00
|
|
|
@classmethod
|
|
|
|
@property
|
|
|
|
def LABEL(cls):
|
|
|
|
return cls.__name__.lower()
|
|
|
|
|
2023-06-11 02:50:31 +00:00
|
|
|
@staticmethod
|
2023-06-13 03:51:32 +00:00
|
|
|
def for_request(fed=None):
|
2023-06-11 02:50:31 +00:00
|
|
|
"""Returns the protocol for the current request.
|
|
|
|
|
|
|
|
...based on the request's hostname.
|
|
|
|
|
2023-06-13 03:51:32 +00:00
|
|
|
Args:
|
|
|
|
fed: :class:`Protocol` subclass to return if the current request is on
|
|
|
|
fed.brid.gy
|
|
|
|
|
2023-06-11 02:50:31 +00:00
|
|
|
Returns:
|
2023-06-13 20:17:11 +00:00
|
|
|
:class:`Protocol` subclass, or None if the provided domain or request
|
|
|
|
hostname domain is not a subdomain of brid.gy or isn't a known protocol
|
2023-06-11 02:50:31 +00:00
|
|
|
"""
|
2023-06-13 05:01:12 +00:00
|
|
|
return Protocol.for_domain(request.host, fed=fed)
|
2023-06-11 02:50:31 +00:00
|
|
|
|
|
|
|
@staticmethod
|
2023-06-13 05:01:12 +00:00
|
|
|
def for_domain(domain_or_url, fed=None):
|
2023-06-11 02:50:31 +00:00
|
|
|
"""Returns the protocol for a brid.gy subdomain.
|
|
|
|
|
2023-06-13 05:01:12 +00:00
|
|
|
Args:
|
|
|
|
domain_or_url: str
|
|
|
|
fed: :class:`Protocol` subclass to return if the domain_or_url is on
|
|
|
|
fed.brid.gy
|
|
|
|
|
2023-06-11 02:50:31 +00:00
|
|
|
Returns:
|
2023-06-13 20:17:11 +00:00
|
|
|
:class:`Protocol` subclass, or None if the request hostname is not a
|
|
|
|
subdomain of brid.gy or isn't a known protocol
|
2023-06-11 02:50:31 +00:00
|
|
|
"""
|
2023-06-13 05:01:12 +00:00
|
|
|
domain = (util.domain_from_link(domain_or_url, minimize=False)
|
|
|
|
if util.is_web(domain_or_url)
|
|
|
|
else domain_or_url)
|
2023-06-11 02:50:31 +00:00
|
|
|
|
2023-06-13 05:01:12 +00:00
|
|
|
if domain == common.PRIMARY_DOMAIN or domain in common.LOCAL_DOMAINS:
|
|
|
|
return fed
|
|
|
|
elif domain and domain.endswith(common.SUPERDOMAIN):
|
|
|
|
label = domain.removesuffix(common.SUPERDOMAIN)
|
|
|
|
return PROTOCOLS.get(label)
|
2023-06-11 02:50:31 +00:00
|
|
|
|
2023-06-14 21:57:59 +00:00
|
|
|
@classmethod
|
|
|
|
def subdomain_url(cls, path=None):
|
|
|
|
"""Returns the URL for a given path on this protocol's subdomain.
|
|
|
|
|
|
|
|
Eg for the path 'foo/bar' on ActivityPub, returns
|
|
|
|
'https://ap.brid.gy/foo/bar'.
|
|
|
|
|
|
|
|
Args:
|
|
|
|
path: str
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
str, URL
|
|
|
|
"""
|
|
|
|
return urljoin(f'https://{cls.ABBREV or "fed"}{common.SUPERDOMAIN}/', path)
|
|
|
|
|
2023-06-13 20:17:11 +00:00
|
|
|
@classmethod
|
|
|
|
def owns_id(cls, id):
|
|
|
|
"""Returns whether this protocol owns the id, or None if it's unclear.
|
|
|
|
|
|
|
|
To be implemented by subclasses.
|
|
|
|
|
|
|
|
Some protocols' ids are more or less deterministic based on the id
|
|
|
|
format, eg AT Protocol owns at:// URIs. Others, like http(s) URLs, could
|
|
|
|
be owned by eg Web or ActivityPub.
|
|
|
|
|
|
|
|
This should be a quick guess without expensive side effects, eg no
|
|
|
|
external HTTP fetches to fetch the id itself or otherwise perform
|
|
|
|
discovery.
|
|
|
|
|
|
|
|
Args:
|
|
|
|
id: str
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
boolean or None
|
|
|
|
"""
|
|
|
|
return False
|
|
|
|
|
2023-06-13 21:30:00 +00:00
|
|
|
@classmethod
|
|
|
|
def key_for(cls, id):
|
2023-06-14 20:46:13 +00:00
|
|
|
"""Returns the :class:`ndb.Key` for a given id's :class:`User`.
|
2023-06-13 21:30:00 +00:00
|
|
|
|
|
|
|
Canonicalizes the id if necessary.
|
2023-06-14 20:46:13 +00:00
|
|
|
|
|
|
|
If called via `Protocol.key_for`, infers the appropriate protocol with
|
|
|
|
:meth:`for_id`. If called with a concrete subclass, uses that subclass
|
|
|
|
as is.
|
2023-06-13 21:30:00 +00:00
|
|
|
"""
|
2023-06-14 20:46:13 +00:00
|
|
|
if cls == Protocol:
|
|
|
|
return Protocol.for_id(id).key_for(id)
|
|
|
|
|
2023-06-13 21:30:00 +00:00
|
|
|
return cls(id=id).key
|
|
|
|
|
2023-06-13 20:17:11 +00:00
|
|
|
@staticmethod
|
|
|
|
def for_id(id):
|
|
|
|
"""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
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
:class:`Protocol` subclass, or None if no known protocol owns this id
|
|
|
|
"""
|
|
|
|
logger.info(f'Determining protocol for id {id}')
|
|
|
|
if not id:
|
|
|
|
return None
|
|
|
|
|
2023-06-18 14:29:54 +00:00
|
|
|
# step 1: check for our per-protocol subdomains
|
2023-06-13 20:43:41 +00:00
|
|
|
if util.is_web(id):
|
|
|
|
by_domain = Protocol.for_domain(id)
|
|
|
|
if by_domain:
|
|
|
|
return by_domain
|
|
|
|
|
2023-06-18 14:29:54 +00:00
|
|
|
# step 2: check if any Protocols say conclusively that they own it
|
2023-06-14 20:46:13 +00:00
|
|
|
# sort to be deterministic
|
|
|
|
protocols = sorted(set(p for p in PROTOCOLS.values() if p),
|
|
|
|
key=lambda p: p.__name__)
|
|
|
|
candidates = []
|
|
|
|
for protocol in protocols:
|
2023-06-13 20:17:11 +00:00
|
|
|
owns = protocol.owns_id(id)
|
|
|
|
if owns:
|
|
|
|
return protocol
|
|
|
|
elif owns is not False:
|
|
|
|
candidates.append(protocol)
|
|
|
|
|
|
|
|
if len(candidates) == 1:
|
|
|
|
return candidates[0]
|
|
|
|
|
2023-06-18 14:29:54 +00:00
|
|
|
# step 3: look for existing Objects in the datastore
|
|
|
|
obj = Protocol.load(id, remote=False)
|
|
|
|
if obj and obj.source_protocol:
|
|
|
|
logger.info(f'{obj.key} has source_protocol {obj.source_protocol}')
|
|
|
|
return PROTOCOLS[obj.source_protocol]
|
|
|
|
|
|
|
|
# step 4: fetch over the network
|
2023-06-13 20:17:11 +00:00
|
|
|
for protocol in candidates:
|
|
|
|
logger.info(f'Trying {protocol.__name__}')
|
|
|
|
try:
|
2023-06-18 14:29:54 +00:00
|
|
|
protocol.load(id, local=False, remote=True)
|
|
|
|
return protocol
|
2023-06-13 20:17:11 +00:00
|
|
|
except werkzeug.exceptions.HTTPException:
|
|
|
|
# internal error we generated ourselves; try next protocol
|
|
|
|
pass
|
|
|
|
except Exception as e:
|
|
|
|
code, _ = util.interpret_http_exception(e)
|
|
|
|
if code:
|
|
|
|
# we tried and failed fetching the id over the network
|
|
|
|
return None
|
2023-06-14 04:36:56 +00:00
|
|
|
raise
|
2023-06-13 20:17:11 +00:00
|
|
|
|
|
|
|
logger.info(f'No matching protocol found for {id} !')
|
|
|
|
return None
|
|
|
|
|
2023-03-08 21:10:41 +00:00
|
|
|
@classmethod
|
2023-03-20 21:28:14 +00:00
|
|
|
def send(cls, obj, url, log_data=True):
|
2023-03-08 21:10:41 +00:00
|
|
|
"""Sends an outgoing activity.
|
|
|
|
|
|
|
|
To be implemented by subclasses.
|
|
|
|
|
|
|
|
Args:
|
2023-03-20 18:23:49 +00:00
|
|
|
obj: :class:`Object` with activity to send
|
2023-03-08 21:10:41 +00:00
|
|
|
url: str, destination URL to send to
|
2023-03-20 18:23:49 +00:00
|
|
|
log_data: boolean, whether to log full data object
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
True if the activity is sent successfully, False if it is ignored due
|
|
|
|
to protocol logic. (Failures are raised as exceptions.)
|
2023-03-08 21:10:41 +00:00
|
|
|
|
2023-03-21 02:17:55 +00:00
|
|
|
Returns:
|
|
|
|
True if the activity was sent successfully, False if it was discarded
|
|
|
|
or ignored due to protocol logic, ie not network or other failures
|
|
|
|
|
2023-03-08 21:10:41 +00:00
|
|
|
Raises:
|
|
|
|
:class:`werkzeug.HTTPException` if the request fails
|
|
|
|
"""
|
|
|
|
raise NotImplementedError()
|
|
|
|
|
|
|
|
@classmethod
|
2023-06-12 22:50:47 +00:00
|
|
|
def fetch(cls, obj, **kwargs):
|
2023-06-18 14:29:54 +00:00
|
|
|
"""Fetches a protocol-specific object and populates it in an :class:`Object`.
|
2023-03-08 21:10:41 +00:00
|
|
|
|
2023-06-18 14:29:54 +00:00
|
|
|
To be implemented by subclasses.
|
2023-03-08 21:10:41 +00:00
|
|
|
|
|
|
|
Args:
|
2023-04-03 14:53:15 +00:00
|
|
|
obj: :class:`Object` with the id to fetch. Data is filled into one of
|
|
|
|
the protocol-specific properties, eg as2, mf2, bsky.
|
2023-06-12 22:50:47 +00:00
|
|
|
**kwargs: subclass-specific
|
2023-03-08 21:10:41 +00:00
|
|
|
|
|
|
|
Raises:
|
|
|
|
:class:`werkzeug.HTTPException` if the fetch fails
|
|
|
|
"""
|
|
|
|
raise NotImplementedError()
|
|
|
|
|
2023-05-24 04:30:57 +00:00
|
|
|
@classmethod
|
|
|
|
def serve(cls, obj):
|
|
|
|
"""Returns this protocol's Flask response for a given :class:`Object`.
|
|
|
|
|
2023-05-27 00:40:29 +00:00
|
|
|
For example, an HTML string and `'text/html'` for :class:`Web`,
|
2023-05-24 04:30:57 +00:00
|
|
|
or a dict with AS2 JSON and `'application/activity+json'` for
|
2023-06-13 02:01:50 +00:00
|
|
|
:class:`ActivityPub`.
|
2023-05-24 04:30:57 +00:00
|
|
|
|
|
|
|
To be implemented by subclasses.
|
|
|
|
|
|
|
|
Args:
|
|
|
|
obj: :class:`Object`
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
(response body, dict with HTTP headers) tuple appropriate to be
|
|
|
|
returned from a Flask handler
|
|
|
|
"""
|
|
|
|
raise NotImplementedError()
|
|
|
|
|
2023-06-16 20:16:17 +00:00
|
|
|
@classmethod
|
|
|
|
def target_for(cls, obj, shared=False):
|
|
|
|
"""Returns a recipient :class:`Object`'s delivery target (endpoint).
|
|
|
|
|
|
|
|
To be implemented by subclasses.
|
|
|
|
|
|
|
|
Examples:
|
|
|
|
|
|
|
|
* If obj has `source_protocol` `'web'`, returns its URL, as a
|
|
|
|
webmention target.
|
|
|
|
* If obj is an `'activitypub'` actor, returns its inbox.
|
|
|
|
* If obj is another `'activitypub'` object, returns `None`.
|
|
|
|
|
|
|
|
Args:
|
|
|
|
obj: :class:`Object`
|
|
|
|
shared: boolean, optional. If `True`, returns a common/shared
|
|
|
|
endpoint, eg ActivityPub's `sharedInbox`, that can be reused for
|
|
|
|
multiple recipients for efficiency
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
str target endpoint or `None`
|
|
|
|
"""
|
|
|
|
raise NotImplementedError()
|
|
|
|
|
2023-03-08 21:10:41 +00:00
|
|
|
@classmethod
|
2023-06-13 03:51:32 +00:00
|
|
|
def receive(from_cls, id, **props):
|
2023-03-08 21:10:41 +00:00
|
|
|
"""Handles an incoming activity.
|
|
|
|
|
|
|
|
Args:
|
|
|
|
id: str, activity id
|
|
|
|
props: property values to populate into the :class:`Object`
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
(response body, HTTP status code) tuple for Flask response
|
|
|
|
|
|
|
|
Raises:
|
|
|
|
:class:`werkzeug.HTTPException` if the request is invalid
|
|
|
|
"""
|
2023-06-13 03:51:32 +00:00
|
|
|
logger.info(f'From {from_cls.__name__}')
|
2023-06-13 20:43:41 +00:00
|
|
|
assert from_cls != Protocol
|
2023-06-13 03:51:32 +00:00
|
|
|
|
2023-03-08 21:10:41 +00:00
|
|
|
if not id:
|
|
|
|
error('Activity has no id')
|
|
|
|
|
|
|
|
# short circuit if we've already seen this activity id
|
|
|
|
with seen_ids_lock:
|
|
|
|
already_seen = id in seen_ids
|
|
|
|
seen_ids[id] = True
|
2023-04-19 00:17:48 +00:00
|
|
|
if already_seen or Object.get_by_id(id):
|
2023-03-08 21:10:41 +00:00
|
|
|
msg = f'Already handled this activity {id}'
|
|
|
|
logger.info(msg)
|
|
|
|
return msg, 200
|
|
|
|
|
2023-04-19 00:17:48 +00:00
|
|
|
obj = Object.get_or_insert(id)
|
2023-03-08 21:10:41 +00:00
|
|
|
obj.clear()
|
2023-06-13 03:51:32 +00:00
|
|
|
obj.populate(source_protocol=from_cls.LABEL, **props)
|
2023-03-08 21:10:41 +00:00
|
|
|
obj.put()
|
|
|
|
|
2023-04-03 14:53:15 +00:00
|
|
|
logger.info(f'Got AS1: {json_dumps(obj.as1, indent=2)}')
|
2023-03-11 20:58:36 +00:00
|
|
|
|
2023-03-08 21:10:41 +00:00
|
|
|
if obj.type not in SUPPORTED_TYPES:
|
|
|
|
error(f'Sorry, {obj.type} activities are not supported yet.', status=501)
|
|
|
|
|
|
|
|
# store inner object
|
|
|
|
inner_obj = as1.get_object(obj.as1)
|
|
|
|
inner_obj_id = inner_obj.get('id')
|
|
|
|
if obj.type in ('post', 'create', 'update') and inner_obj.keys() > set(['id']):
|
2023-04-19 00:17:48 +00:00
|
|
|
to_update = (Object.get_by_id(inner_obj_id)
|
|
|
|
or Object(id=inner_obj_id))
|
2023-06-13 03:51:32 +00:00
|
|
|
to_update.populate(as2=obj.as2['object'], source_protocol=from_cls.LABEL)
|
2023-03-08 21:10:41 +00:00
|
|
|
to_update.put()
|
|
|
|
|
|
|
|
actor = as1.get_object(obj.as1, 'actor')
|
|
|
|
actor_id = actor.get('id')
|
|
|
|
|
|
|
|
# handle activity!
|
|
|
|
if obj.type == 'accept': # eg in response to a Follow
|
|
|
|
return 'OK' # noop
|
|
|
|
|
|
|
|
elif obj.type == 'stop-following':
|
|
|
|
if not actor_id or not inner_obj_id:
|
|
|
|
error(f'Undo of Follow requires actor id and object id. Got: {actor_id} {inner_obj_id} {obj.as1}')
|
|
|
|
|
|
|
|
# deactivate Follower
|
2023-06-13 03:51:32 +00:00
|
|
|
# TODO: avoid import?
|
2023-06-07 21:24:00 +00:00
|
|
|
from web import Web
|
2023-06-14 20:46:13 +00:00
|
|
|
from_ = from_cls.key_for(actor_id)
|
|
|
|
to = (Protocol.for_id(inner_obj_id) or Web).key_for(inner_obj_id)
|
|
|
|
follower = Follower.query(Follower.to == to,
|
|
|
|
Follower.from_ == from_,
|
|
|
|
Follower.status == 'active').get()
|
2023-03-08 21:10:41 +00:00
|
|
|
if follower:
|
2023-04-03 14:53:15 +00:00
|
|
|
logger.info(f'Marking {follower} inactive')
|
2023-03-08 21:10:41 +00:00
|
|
|
follower.status = 'inactive'
|
|
|
|
follower.put()
|
|
|
|
else:
|
2023-06-14 20:46:13 +00:00
|
|
|
logger.warning(f'No Follower found for {from_} => {to}')
|
2023-03-08 21:10:41 +00:00
|
|
|
|
|
|
|
# TODO send webmention with 410 of u-follow
|
|
|
|
|
|
|
|
obj.status = 'complete'
|
|
|
|
obj.put()
|
|
|
|
return 'OK'
|
|
|
|
|
|
|
|
elif obj.type == 'update':
|
|
|
|
if not inner_obj_id:
|
|
|
|
error("Couldn't find id of object to update")
|
|
|
|
|
|
|
|
obj.status = 'complete'
|
|
|
|
obj.put()
|
|
|
|
return 'OK'
|
|
|
|
|
|
|
|
elif obj.type == 'delete':
|
|
|
|
if not inner_obj_id:
|
|
|
|
error("Couldn't find id of object to delete")
|
|
|
|
|
2023-04-19 00:17:48 +00:00
|
|
|
to_delete = Object.get_by_id(inner_obj_id)
|
2023-03-08 21:10:41 +00:00
|
|
|
if to_delete:
|
|
|
|
logger.info(f'Marking Object {inner_obj_id} deleted')
|
|
|
|
to_delete.deleted = True
|
|
|
|
to_delete.put()
|
|
|
|
|
|
|
|
# assume this is an actor
|
|
|
|
# https://github.com/snarfed/bridgy-fed/issues/63
|
2023-06-08 06:51:41 +00:00
|
|
|
logger.info(f'Deactivating Followers from or to = {inner_obj_id}')
|
2023-06-13 03:51:32 +00:00
|
|
|
deleted_user = from_cls(id=inner_obj_id).key
|
2023-06-07 21:24:00 +00:00
|
|
|
followers = Follower.query(OR(Follower.to == deleted_user,
|
|
|
|
Follower.from_ == deleted_user)
|
2023-04-19 00:17:48 +00:00
|
|
|
).fetch()
|
2023-03-08 21:10:41 +00:00
|
|
|
for f in followers:
|
|
|
|
f.status = 'inactive'
|
|
|
|
obj.status = 'complete'
|
|
|
|
ndb.put_multi(followers + [obj])
|
|
|
|
return 'OK'
|
|
|
|
|
|
|
|
# fetch actor if necessary so we have name, profile photo, etc
|
|
|
|
if actor and actor.keys() == set(['id']):
|
2023-06-13 03:51:32 +00:00
|
|
|
actor = obj.as2['actor'] = from_cls.load(actor['id']).as2
|
2023-03-08 21:10:41 +00:00
|
|
|
|
|
|
|
# fetch object if necessary so we can render it in feeds
|
|
|
|
if obj.type == 'share' and inner_obj.keys() == set(['id']):
|
2023-06-16 21:09:28 +00:00
|
|
|
inner_obj = obj.as2['object'] = from_cls.load(inner_obj_id).as_as2()
|
2023-03-08 21:10:41 +00:00
|
|
|
|
|
|
|
if obj.type == 'follow':
|
2023-06-13 03:51:32 +00:00
|
|
|
from_cls.accept_follow(obj)
|
2023-03-08 21:10:41 +00:00
|
|
|
|
2023-03-21 02:17:55 +00:00
|
|
|
# deliver to each target
|
2023-06-13 03:51:32 +00:00
|
|
|
from_cls.deliver(obj)
|
2023-03-08 21:10:41 +00:00
|
|
|
|
|
|
|
# deliver original posts and reposts to followers
|
2023-03-14 00:25:10 +00:00
|
|
|
is_reply = (obj.type == 'comment' or
|
|
|
|
(inner_obj and inner_obj.get('inReplyTo')))
|
2023-06-20 18:22:54 +00:00
|
|
|
if ((obj.type == 'share' or obj.type in ('create', 'post') and not is_reply)
|
|
|
|
and actor and actor_id):
|
2023-03-08 21:10:41 +00:00
|
|
|
logger.info(f'Delivering to followers of {actor_id}')
|
2023-06-13 03:51:32 +00:00
|
|
|
for f in Follower.query(Follower.to == from_cls(id=actor_id).key,
|
2023-06-07 21:24:00 +00:00
|
|
|
Follower.status == 'active'):
|
2023-06-09 19:56:45 +00:00
|
|
|
if f.from_ not in obj.users:
|
|
|
|
obj.users.append(f.from_)
|
|
|
|
if obj.users and 'feed' not in obj.labels:
|
2023-03-08 21:10:41 +00:00
|
|
|
obj.labels.append('feed')
|
|
|
|
|
|
|
|
obj.put()
|
|
|
|
return 'OK'
|
|
|
|
|
2023-03-11 20:58:36 +00:00
|
|
|
@classmethod
|
2023-03-20 21:28:14 +00:00
|
|
|
def accept_follow(cls, obj):
|
2023-03-11 20:58:36 +00:00
|
|
|
"""Replies to an AP Follow request with an Accept request.
|
|
|
|
|
|
|
|
Args:
|
2023-03-21 02:17:55 +00:00
|
|
|
obj: :class:`Object`, follow activity
|
2023-03-11 20:58:36 +00:00
|
|
|
"""
|
|
|
|
logger.info('Replying to Follow with Accept')
|
|
|
|
|
|
|
|
followee = as1.get_object(obj.as1)
|
|
|
|
followee_id = followee.get('id')
|
|
|
|
follower = as1.get_object(obj.as1, 'actor')
|
|
|
|
if not followee or not followee_id or not follower:
|
2023-03-23 03:49:28 +00:00
|
|
|
error(f'Follow activity requires object and actor. Got: {obj.as1}')
|
2023-03-11 20:58:36 +00:00
|
|
|
|
|
|
|
inbox = follower.get('inbox')
|
|
|
|
follower_id = follower.get('id')
|
|
|
|
if not inbox or not follower_id:
|
|
|
|
error(f'Follow actor requires id and inbox. Got: {follower}')
|
|
|
|
|
2023-06-13 02:01:50 +00:00
|
|
|
# store Follower and follower User
|
2023-06-09 19:28:07 +00:00
|
|
|
#
|
|
|
|
# If followee user is already direct, AP follower may not know they're
|
|
|
|
# interacting with a bridge. If followee user is indirect though, AP
|
|
|
|
# follower should know, so they're direct.
|
2023-06-16 04:22:20 +00:00
|
|
|
follower_obj = Object.get_or_insert(follower_id)
|
|
|
|
if not follower_obj.as1:
|
|
|
|
follower_obj.our_as1 = follower
|
|
|
|
follower_obj.put()
|
|
|
|
|
|
|
|
from_ = cls.get_or_create(id=follower_id, obj=follower_obj,
|
2023-06-13 02:01:50 +00:00
|
|
|
direct=not g.user.direct)
|
2023-06-07 21:24:00 +00:00
|
|
|
follower_obj = Follower.get_or_create(to=g.user, from_=from_, follow=obj.key,
|
|
|
|
status='active')
|
2023-03-11 20:58:36 +00:00
|
|
|
|
2023-06-04 22:11:52 +00:00
|
|
|
# send Accept
|
2023-05-31 17:10:14 +00:00
|
|
|
followee_actor_url = g.user.ap_actor()
|
2023-03-11 20:58:36 +00:00
|
|
|
accept = {
|
|
|
|
'@context': 'https://www.w3.org/ns/activitystreams',
|
2023-06-03 15:03:38 +00:00
|
|
|
'id': common.host_url(f'/web/{g.user.key.id()}/followers#accept-{obj.key.id()}'),
|
2023-03-11 20:58:36 +00:00
|
|
|
'type': 'Accept',
|
|
|
|
'actor': followee_actor_url,
|
2023-06-16 21:09:28 +00:00
|
|
|
'object': obj.as_as2()
|
2023-03-11 20:58:36 +00:00
|
|
|
}
|
2023-04-19 00:17:48 +00:00
|
|
|
return cls.send(Object(as2=accept), inbox)
|
2023-03-11 20:58:36 +00:00
|
|
|
|
2023-03-21 02:17:55 +00:00
|
|
|
@classmethod
|
|
|
|
def deliver(cls, obj):
|
|
|
|
"""Delivers an activity to its external recipients.
|
|
|
|
|
|
|
|
Args:
|
|
|
|
obj: :class:`Object`, activity to deliver
|
|
|
|
"""
|
|
|
|
# extract source and targets
|
|
|
|
source = obj.as1.get('url') or obj.as1.get('id')
|
|
|
|
inner_obj = as1.get_object(obj.as1)
|
|
|
|
obj_url = util.get_url(inner_obj) or inner_obj.get('id')
|
|
|
|
|
|
|
|
if not source or obj.type in ('create', 'post', 'update'):
|
|
|
|
source = obj_url
|
|
|
|
if not source:
|
|
|
|
error("Couldn't find source post URL")
|
|
|
|
|
|
|
|
targets = util.get_list(obj.as1, 'inReplyTo')
|
|
|
|
targets.extend(util.get_list(inner_obj, 'inReplyTo'))
|
|
|
|
|
|
|
|
for tag in (util.get_list(obj.as1, 'tags') +
|
|
|
|
util.get_list(as1.get_object(obj.as1), 'tags')):
|
|
|
|
if tag.get('objectType') == 'mention':
|
|
|
|
url = tag.get('url')
|
|
|
|
if url:
|
|
|
|
targets.append(url)
|
|
|
|
|
|
|
|
if obj.type in ('follow', 'like', 'share'):
|
|
|
|
targets.append(obj_url)
|
|
|
|
|
|
|
|
targets = util.dedupe_urls(util.get_url(t) for t in targets)
|
|
|
|
targets = common.remove_blocklisted(t.lower() for t in targets)
|
|
|
|
if not targets:
|
2023-06-13 02:01:50 +00:00
|
|
|
logger.info("Couldn't find any target URLs in inReplyTo, object, or mention tags")
|
2023-03-21 02:17:55 +00:00
|
|
|
return
|
|
|
|
|
|
|
|
logger.info(f'targets: {targets}')
|
|
|
|
|
|
|
|
errors = [] # stores (code, body) tuples
|
2023-06-13 05:01:12 +00:00
|
|
|
|
|
|
|
# TODO: avoid import?
|
|
|
|
from web import Web
|
2023-06-14 20:46:13 +00:00
|
|
|
targets = [Target(uri=uri, protocol=(Protocol.for_id(uri) or Web).LABEL)
|
2023-06-13 05:01:12 +00:00
|
|
|
for uri in targets]
|
|
|
|
no_user_domains = set()
|
|
|
|
|
2023-06-13 04:43:08 +00:00
|
|
|
obj.undelivered = []
|
|
|
|
obj.status = 'in progress'
|
2023-03-21 02:17:55 +00:00
|
|
|
|
2023-06-13 05:01:12 +00:00
|
|
|
obj.populate(
|
|
|
|
undelivered=targets,
|
|
|
|
status='in progress',
|
|
|
|
)
|
2023-03-21 02:17:55 +00:00
|
|
|
|
2023-06-13 05:01:12 +00:00
|
|
|
# send webmentions and update Object
|
2023-03-21 02:17:55 +00:00
|
|
|
while obj.undelivered:
|
|
|
|
target = obj.undelivered.pop()
|
|
|
|
domain = util.domain_from_link(target.uri, minimize=False)
|
|
|
|
if g.user and domain == g.user.key.id():
|
|
|
|
if 'notification' not in obj.labels:
|
|
|
|
obj.labels.append('notification')
|
|
|
|
|
|
|
|
if domain == util.domain_from_link(source, minimize=False):
|
2023-06-13 03:51:32 +00:00
|
|
|
logger.info(f'Skipping same-domain delivery from {source} to {target.uri}')
|
2023-03-21 02:17:55 +00:00
|
|
|
continue
|
|
|
|
|
2023-06-09 19:56:45 +00:00
|
|
|
# only deliver if we have a matching User already.
|
|
|
|
# TODO: consider delivering or at least storing Users for all
|
|
|
|
# targets? need to filter out native targets in this protocol
|
|
|
|
# though, eg mastodon.social targets in AP inbox deliveries.
|
|
|
|
if domain in no_user_domains:
|
|
|
|
continue
|
|
|
|
|
2023-06-13 04:43:08 +00:00
|
|
|
recip = PROTOCOLS[target.protocol](id=domain)
|
2023-06-13 03:51:32 +00:00
|
|
|
logger.info(f'Sending to {recip.key}')
|
2023-06-10 22:07:26 +00:00
|
|
|
if recip.key not in obj.users:
|
|
|
|
if not recip.key.get():
|
2023-06-13 04:43:08 +00:00
|
|
|
logger.info(f'No {recip.key} user found; skipping {target}')
|
2023-06-09 19:56:45 +00:00
|
|
|
no_user_domains.add(domain)
|
|
|
|
continue
|
2023-06-10 22:07:26 +00:00
|
|
|
obj.users.append(recip.key)
|
2023-03-21 02:17:55 +00:00
|
|
|
|
|
|
|
try:
|
2023-06-10 22:07:26 +00:00
|
|
|
if recip.send(obj, target.uri):
|
2023-03-21 02:17:55 +00:00
|
|
|
obj.delivered.append(target)
|
|
|
|
if 'notification' not in obj.labels:
|
|
|
|
obj.labels.append('notification')
|
|
|
|
except BaseException as e:
|
2023-06-20 18:22:54 +00:00
|
|
|
code, body = util.interpret_http_exception(e)
|
|
|
|
if not code and not body:
|
|
|
|
raise
|
|
|
|
errors.append((code, body))
|
|
|
|
obj.failed.append(target)
|
2023-03-21 02:17:55 +00:00
|
|
|
|
|
|
|
obj.put()
|
|
|
|
|
2023-06-09 19:56:45 +00:00
|
|
|
obj.status = ('complete' if obj.delivered or obj.users
|
2023-03-21 02:17:55 +00:00
|
|
|
else 'failed' if obj.failed
|
|
|
|
else 'ignored')
|
|
|
|
|
|
|
|
if errors:
|
|
|
|
msg = 'Errors: ' + ', '.join(f'{code} {body}' for code, body in errors)
|
|
|
|
error(msg, status=int(errors[0][0] or 502))
|
|
|
|
|
2023-03-08 21:10:41 +00:00
|
|
|
@classmethod
|
2023-06-18 14:29:54 +00:00
|
|
|
def load(cls, id, remote=None, local=True, **kwargs):
|
2023-03-08 21:10:41 +00:00
|
|
|
"""Loads and returns an Object from memory cache, datastore, or HTTP fetch.
|
|
|
|
|
|
|
|
Note that :meth:`Object._post_put_hook` updates the cache.
|
|
|
|
|
|
|
|
Args:
|
|
|
|
id: str
|
2023-06-18 14:29:54 +00:00
|
|
|
|
|
|
|
remote: boolean, whether to fetch the object over the network. If True,
|
|
|
|
fetches even if we already have the object stored, and updates our
|
|
|
|
stored copy. If False and we don't have the object stored, returns
|
|
|
|
None. Default (None) means to fetch over the network only if we
|
|
|
|
don't already have it stored.
|
|
|
|
local: boolean, whether to load from the datastore before
|
|
|
|
fetching over the network. If False, still stores back to the
|
|
|
|
datastore after a successful remote fetch.
|
2023-06-14 03:24:09 +00:00
|
|
|
kwargs: passed through to :meth:`fetch()`
|
2023-03-08 21:10:41 +00:00
|
|
|
|
2023-06-18 14:29:54 +00:00
|
|
|
Returns: :class:`Object` or None if it isn't in the datastore and remote
|
|
|
|
is False
|
2023-03-08 21:10:41 +00:00
|
|
|
|
|
|
|
Raises:
|
|
|
|
:class:`requests.HTTPError`, anything else that :meth:`fetch` raises
|
|
|
|
"""
|
2023-06-18 14:29:54 +00:00
|
|
|
assert local or remote is not False
|
|
|
|
|
|
|
|
logger.info(f'Loading Object {id} local={local} remote={remote}')
|
2023-06-17 21:13:17 +00:00
|
|
|
|
2023-06-18 14:29:54 +00:00
|
|
|
if remote is not True:
|
2023-04-03 14:53:15 +00:00
|
|
|
with objects_cache_lock:
|
|
|
|
cached = objects_cache.get(id)
|
|
|
|
if cached:
|
|
|
|
return cached
|
2023-04-03 03:36:23 +00:00
|
|
|
|
2023-06-18 14:29:54 +00:00
|
|
|
obj = orig_as1 = None
|
|
|
|
if local:
|
|
|
|
obj = Object.get_by_id(id)
|
|
|
|
if obj and (obj.as1 or obj.deleted):
|
|
|
|
logger.info(' got from datastore')
|
|
|
|
obj.new = False
|
|
|
|
orig_as1 = obj.as1
|
|
|
|
if remote is not True:
|
|
|
|
with objects_cache_lock:
|
|
|
|
objects_cache[id] = obj
|
|
|
|
return obj
|
|
|
|
|
|
|
|
if remote is True:
|
|
|
|
logger.info(' remote=True, forced refresh requested')
|
2023-06-19 05:26:30 +00:00
|
|
|
elif remote is False:
|
|
|
|
logger.info(' remote=False, {"empty" if obj else "not"} in datastore')
|
|
|
|
return obj
|
2023-04-03 14:53:15 +00:00
|
|
|
|
|
|
|
if obj:
|
|
|
|
obj.clear()
|
2023-06-03 04:53:44 +00:00
|
|
|
obj.new = False
|
2023-04-03 14:53:15 +00:00
|
|
|
else:
|
2023-06-18 14:29:54 +00:00
|
|
|
if local:
|
|
|
|
logger.info(' not in datastore')
|
2023-04-19 00:17:48 +00:00
|
|
|
obj = Object(id=id)
|
2023-04-03 14:53:15 +00:00
|
|
|
obj.new = True
|
|
|
|
obj.changed = False
|
|
|
|
|
2023-04-17 22:36:29 +00:00
|
|
|
cls.fetch(obj, **kwargs)
|
2023-06-03 04:53:44 +00:00
|
|
|
if not obj.new:
|
|
|
|
if orig_as1 and obj.as1:
|
|
|
|
obj.changed = as1.activity_changed(orig_as1, obj.as1)
|
|
|
|
else:
|
|
|
|
obj.changed = bool(orig_as1) != bool(obj.as1)
|
2023-03-08 21:10:41 +00:00
|
|
|
|
|
|
|
obj.source_protocol = cls.LABEL
|
2023-03-27 21:12:06 +00:00
|
|
|
obj.put()
|
2023-04-03 03:36:23 +00:00
|
|
|
|
|
|
|
with objects_cache_lock:
|
|
|
|
objects_cache[id] = obj
|
2023-03-08 21:10:41 +00:00
|
|
|
return obj
|