kopia lustrzana https://github.com/tsileo/little-boxes
1149 wiersze
37 KiB
Python
1149 wiersze
37 KiB
Python
"""Core ActivityPub classes."""
|
|
import logging
|
|
import json
|
|
import binascii
|
|
import os
|
|
from datetime import datetime
|
|
from enum import Enum
|
|
|
|
from .errors import BadActivityError
|
|
from .errors import UnexpectedActivityTypeError
|
|
from .errors import NotFromOutboxError
|
|
from .errors import ActivityNotFoundError
|
|
from .urlutils import check_url
|
|
from .utils import parse_collection
|
|
|
|
from typing import List
|
|
from typing import Optional
|
|
from typing import Dict
|
|
from typing import Any
|
|
from typing import Union
|
|
from typing import Type
|
|
|
|
import requests
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Helper/shortcut for typing
|
|
ObjectType = Dict[str, Any]
|
|
ObjectOrIDType = Union[str, ObjectType]
|
|
|
|
CTX_AS = "https://www.w3.org/ns/activitystreams"
|
|
CTX_SECURITY = "https://w3id.org/security/v1"
|
|
AS_PUBLIC = "https://www.w3.org/ns/activitystreams#Public"
|
|
|
|
COLLECTION_CTX = [
|
|
"https://www.w3.org/ns/activitystreams",
|
|
"https://w3id.org/security/v1",
|
|
{"Hashtag": "as:Hashtag", "sensitive": "as:sensitive"},
|
|
]
|
|
|
|
# Will be used to keep track of all the defined activities
|
|
_ACTIVITY_CLS: Dict["ActivityTypeEnum", Type["_BaseActivity"]] = {}
|
|
|
|
BACKEND = None
|
|
|
|
|
|
def use_backend(backend_instance):
|
|
global BACKEND
|
|
BACKEND = backend_instance
|
|
|
|
|
|
class ActivityType(Enum):
|
|
"""Supported activity `type`."""
|
|
|
|
ANNOUNCE = "Announce"
|
|
BLOCK = "Block"
|
|
LIKE = "Like"
|
|
CREATE = "Create"
|
|
UPDATE = "Update"
|
|
PERSON = "Person"
|
|
ORDERED_COLLECTION = "OrderedCollection"
|
|
ORDERED_COLLECTION_PAGE = "OrderedCollectionPage"
|
|
COLLECTION_PAGE = "CollectionPage"
|
|
COLLECTION = "Collection"
|
|
NOTE = "Note"
|
|
ACCEPT = "Accept"
|
|
REJECT = "Reject"
|
|
FOLLOW = "Follow"
|
|
DELETE = "Delete"
|
|
UNDO = "Undo"
|
|
IMAGE = "Image"
|
|
TOMBSTONE = "Tombstone"
|
|
|
|
|
|
def parse_activity(
|
|
payload: ObjectType, expected: Optional[ActivityType] = None
|
|
) -> "BaseActivity":
|
|
t = ActivityType(payload["type"])
|
|
|
|
if expected and t != expected:
|
|
raise UnexpectedActivityTypeError(
|
|
f'expected a {expected.name} activity, got a {payload["type"]}'
|
|
)
|
|
|
|
if t not in _ACTIVITY_CLS:
|
|
raise BadActivityError(f'unsupported activity type {payload["type"]}')
|
|
|
|
activity = _ACTIVITY_CLS[t](**payload)
|
|
|
|
return activity
|
|
|
|
|
|
def _to_list(data: Union[List[Any], Any]) -> List[Any]:
|
|
"""Helper to convert fields that can be either an object or a list of objects to a list of object."""
|
|
if isinstance(data, list):
|
|
return data
|
|
return [data]
|
|
|
|
|
|
def clean_activity(activity: ObjectType) -> Dict[str, Any]:
|
|
"""Clean the activity before rendering it.
|
|
- Remove the hidden bco and bcc field
|
|
"""
|
|
for field in ["bto", "bcc"]:
|
|
if field in activity:
|
|
del (activity[field])
|
|
if activity["type"] == "Create" and field in activity["object"]:
|
|
del (activity["object"][field])
|
|
return activity
|
|
|
|
|
|
def _get_actor_id(actor: ObjectOrIDType) -> str:
|
|
"""Helper for retrieving an actor `id`."""
|
|
if isinstance(actor, dict):
|
|
return actor["id"]
|
|
return actor
|
|
|
|
|
|
# FIXME(tsileo): keeps differents list of each `as_actor`, and uses `as_actor` as first arg for everything.
|
|
def track_call(f):
|
|
fname = f.__name__
|
|
|
|
def wrapper(*args, **kwargs):
|
|
args[0]._METHOD_CALLS[args[1].id].append((fname, args, kwargs))
|
|
return f(*args, **kwargs)
|
|
|
|
return wrapper
|
|
|
|
|
|
# FIXME(tsileo): move this to little_boxes.tests.InMemBackend and make this one an abstract classes
|
|
class BaseBackend(object):
|
|
"""In-memory backend meant to be used for the test suite."""
|
|
|
|
DB = {}
|
|
USERS = {}
|
|
FETCH_MOCK = {}
|
|
INBOX_IDX = {}
|
|
OUTBOX_IDX = {}
|
|
FOLLOWERS = {}
|
|
FOLLOWING = {}
|
|
|
|
# For tests purposes only
|
|
_METHOD_CALLS = {}
|
|
|
|
def called_methods(self, p: "Person") -> List[str]:
|
|
data = list(self._METHOD_CALLS[p.id])
|
|
self._METHOD_CALLS[p.id] = []
|
|
return data
|
|
|
|
def assert_called_methods(self, p: "Person", *asserts) -> List[str]:
|
|
calls = self.called_methods(p)
|
|
for i, assert_data in enumerate(asserts):
|
|
if len(calls) < i + 1:
|
|
raise ValueError(f"no methods called at step #{i}")
|
|
error_msg, name, *funcs = assert_data
|
|
if name != calls[i][0]:
|
|
raise ValueError(
|
|
f"expected method {name} to be called at step #{i}, but got {calls[i][0]}"
|
|
)
|
|
if len(funcs) < len(calls[i][1]) - 1:
|
|
raise ValueError(f"args left unchecked for method {name} at step #{i}")
|
|
for z, f in enumerate(funcs):
|
|
if len(calls[i][1]) < z + 2: # XXX(tsileo): 0 will be self
|
|
raise ValueError(f"method {name} has no args at index {Z}")
|
|
try:
|
|
f(calls[i][1][z + 1])
|
|
except AssertionError as ae:
|
|
ae.args = ((error_msg),)
|
|
raise ae
|
|
|
|
if len(asserts) < len(calls):
|
|
raise ValueError(
|
|
f"expecting {len(calls)} assertion, only got {len(asserts)}, leftover: {calls[len(asserts):]!r}"
|
|
)
|
|
|
|
return calls
|
|
|
|
def random_object_id(self) -> str:
|
|
"""Generates a random object ID."""
|
|
return binascii.hexlify(os.urandom(8)).decode("utf-8")
|
|
|
|
def setup_actor(self, name, pusername):
|
|
"""Create a new actor in this backend."""
|
|
p = Person(
|
|
name=name,
|
|
preferredUsername=pusername,
|
|
summary="Hello",
|
|
id=f"https://lol.com/{pusername}",
|
|
inbox=f"https://lol.com/{pusername}/inbox",
|
|
)
|
|
|
|
self.USERS[p.preferredUsername] = p
|
|
self.DB[p.id] = {"inbox": [], "outbox": []}
|
|
self.INBOX_IDX[p.id] = {}
|
|
self.OUTBOX_IDX[p.id] = {}
|
|
self.FOLLOWERS[p.id] = []
|
|
self.FOLLOWING[p.id] = []
|
|
self.FETCH_MOCK[p.id] = p.to_dict()
|
|
self._METHOD_CALLS[p.id] = []
|
|
return p
|
|
|
|
def fetch_iri(self, iri: str):
|
|
return self.FETCH_MOCK[iri]
|
|
|
|
def get_user(self, username: str) -> "Person":
|
|
if username in self.USERS:
|
|
return self.USERS[username]
|
|
else:
|
|
raise ValueError(f"bad username {username}")
|
|
|
|
@track_call
|
|
def outbox_is_blocked(self, as_actor: "Person", actor_id: str) -> bool:
|
|
"""Returns True if `as_actor` has blocked `actor_id`."""
|
|
for activity in self.DB[as_actor.id]["outbox"]:
|
|
if activity.ACTIVITY_TYPE == ActivityType.BLOCK:
|
|
return True
|
|
return False
|
|
|
|
def inbox_get_by_iri(
|
|
self, as_actor: "Person", iri: str
|
|
) -> Optional["BaseActivity"]:
|
|
for activity in self.DB[as_actor.id]["inbox"]:
|
|
if activity.id == iri:
|
|
return activity
|
|
|
|
return None
|
|
|
|
@track_call
|
|
def inbox_new(self, as_actor: "Person", activity: "BaseActivity") -> None:
|
|
if activity.id in self.INBOX_IDX[as_actor.id]:
|
|
return
|
|
self.DB[as_actor.id]["inbox"].append(activity)
|
|
self.INBOX_IDX[as_actor.id][activity.id] = activity
|
|
|
|
def activity_url(self, obj_id: str) -> str:
|
|
# from the random hex ID
|
|
return "TODO"
|
|
|
|
@track_call
|
|
def outbox_new(self, as_actor: "Person", activity: "BaseActivity") -> None:
|
|
print(f"saving {activity!r} to DB")
|
|
actor_id = activity.get_actor().id
|
|
if activity.id in self.OUTBOX_IDX[actor_id]:
|
|
return
|
|
self.DB[actor_id]["outbox"].append(activity)
|
|
self.OUTBOX_IDX[actor_id][activity.id] = activity
|
|
|
|
@track_call
|
|
def new_follower(self, as_actor: "Person", follow: "Follow") -> None:
|
|
self.FOLLOWERS[follow.get_object().id].append(follow.get_actor().id)
|
|
|
|
@track_call
|
|
def undo_new_follower(self, as_actor: "Person", follow: "Follow") -> None:
|
|
self.FOLLOWERS[follow.get_object().id].remove(follow.get_actor().id)
|
|
|
|
@track_call
|
|
def new_following(self, as_actor: "Person", follow: "Follow") -> None:
|
|
print(f"new following {follow!r}")
|
|
self.FOLLOWING[as_actor.id].append(follow.get_object().id)
|
|
|
|
@track_call
|
|
def undo_new_following(self, as_actor: "Person", follow: "Follow") -> None:
|
|
self.FOLLOWING[as_actor.id].remove(follow.get_object().id)
|
|
|
|
def followers(self, as_actor: "Person") -> List[str]:
|
|
return self.FOLLOWERS[as_actor.id]
|
|
|
|
def following(self, as_actor: "Person") -> List[str]:
|
|
return self.FOLLOWING[as_actor.id]
|
|
|
|
@track_call
|
|
def post_to_remote_inbox(
|
|
self, as_actor: "Person", payload_encoded: str, recp: str
|
|
) -> None:
|
|
payload = json.loads(payload_encoded)
|
|
print(f"post_to_remote_inbox {payload} {recp}")
|
|
act = parse_activity(payload)
|
|
as_actor = parse_activity(self.fetch_iri(recp.replace("/inbox", "")))
|
|
act.process_from_inbox(as_actor)
|
|
|
|
def is_from_outbox(self, activity: "BaseActivity") -> bool:
|
|
# return as_actor.id == activity.get_actor().id
|
|
return True # FIXME(tsileo): implement this
|
|
|
|
def inbox_like(self, activity: "Like") -> None:
|
|
pass
|
|
|
|
def inbox_undo_like(self, activity: "Like") -> None:
|
|
pass
|
|
|
|
def outbox_like(self, activity: "Like") -> None:
|
|
pass
|
|
|
|
def outbox_undo_like(self, activity: "Lke") -> None:
|
|
pass
|
|
|
|
def inbox_announce(self, activity: "Announce") -> None:
|
|
pass
|
|
|
|
def inbox_undo_announce(self, activity: "Announce") -> None:
|
|
pass
|
|
|
|
def outbox_announce(self, activity: "Announce") -> None:
|
|
pass
|
|
|
|
def outbox_undo_announce(self, activity: "Announce") -> None:
|
|
pass
|
|
|
|
def inbox_delete(self, activity: "Delete") -> None:
|
|
pass
|
|
|
|
def outbox_delete(self, activity: "Delete") -> None:
|
|
pass
|
|
|
|
def inbox_update(self, activity: "Update") -> None:
|
|
pass
|
|
|
|
def outbox_update(self, activity: "Update") -> None:
|
|
pass
|
|
|
|
def inbox_create(self, activity: "Create") -> None:
|
|
pass
|
|
|
|
def outbox_create(self, activity: "Create") -> None:
|
|
pass
|
|
|
|
|
|
class _ActivityMeta(type):
|
|
"""Metaclass for keeping track of subclass."""
|
|
|
|
def __new__(meta, name, bases, class_dict):
|
|
cls = type.__new__(meta, name, bases, class_dict)
|
|
|
|
# Ensure the class has an activity type defined
|
|
if name != "BaseActivity" and not cls.ACTIVITY_TYPE:
|
|
raise ValueError(f"class {name} has no ACTIVITY_TYPE")
|
|
|
|
# Register it
|
|
_ACTIVITY_CLS[cls.ACTIVITY_TYPE] = cls
|
|
return cls
|
|
|
|
|
|
class BaseActivity(object, metaclass=_ActivityMeta):
|
|
"""Base class for ActivityPub activities."""
|
|
|
|
ACTIVITY_TYPE: Optional[
|
|
ActivityType
|
|
] = None # the ActivityTypeEnum the class will represent
|
|
OBJECT_REQUIRED = False # Whether the object field is required or note
|
|
ALLOWED_OBJECT_TYPES: List[ActivityType] = []
|
|
ACTOR_REQUIRED = (
|
|
True
|
|
) # Most of the object requires an actor, so this flag in on by default
|
|
|
|
def __init__(self, **kwargs) -> None:
|
|
if kwargs.get("type") and kwargs.pop("type") != self.ACTIVITY_TYPE.value:
|
|
raise UnexpectedActivityTypeError(
|
|
f"Expect the type to be {self.ACTIVITY_TYPE.value!r}"
|
|
)
|
|
|
|
# Initialize the dict that will contains all the activity fields
|
|
self._data: Dict[str, Any] = {"type": self.ACTIVITY_TYPE.value}
|
|
logger.debug(f"initializing a {self.ACTIVITY_TYPE.value} activity: {kwargs!r}")
|
|
|
|
# A place to set ephemeral data
|
|
self.__ctx = {}
|
|
|
|
# The id may not be present for new activities
|
|
if "id" in kwargs:
|
|
self._data["id"] = kwargs.pop("id")
|
|
|
|
if self.ACTIVITY_TYPE != ActivityType.PERSON and self.ACTOR_REQUIRED:
|
|
actor = kwargs.get("actor")
|
|
if actor:
|
|
kwargs.pop("actor")
|
|
actor = self._validate_person(actor)
|
|
self._data["actor"] = actor
|
|
else:
|
|
raise BadActivityError("missing actor")
|
|
|
|
if self.OBJECT_REQUIRED and "object" in kwargs:
|
|
obj = kwargs.pop("object")
|
|
if isinstance(obj, str):
|
|
# The object is a just a reference the its ID/IRI
|
|
# FIXME(tsileo): fetch the ref
|
|
self._data["object"] = obj
|
|
else:
|
|
if not self.ALLOWED_OBJECT_TYPES:
|
|
raise UnexpectedActivityTypeError("unexpected object")
|
|
if "type" not in obj or (
|
|
self.ACTIVITY_TYPE != ActivityType.CREATE and "id" not in obj
|
|
):
|
|
raise BadActivityError("invalid object, missing type")
|
|
if ActivityType(obj["type"]) not in self.ALLOWED_OBJECT_TYPES:
|
|
raise UnexpectedActivityTypeError(
|
|
f'unexpected object type {obj["type"]} (allowed={self.ALLOWED_OBJECT_TYPES!r})'
|
|
)
|
|
self._data["object"] = obj
|
|
|
|
if "@context" not in kwargs:
|
|
self._data["@context"] = CTX_AS
|
|
else:
|
|
self._data["@context"] = kwargs.pop("@context")
|
|
|
|
# @context check
|
|
if not isinstance(self._data["@context"], list):
|
|
self._data["@context"] = [self._data["@context"]]
|
|
if CTX_SECURITY not in self._data["@context"]:
|
|
self._data["@context"].append(CTX_SECURITY)
|
|
if isinstance(self._data["@context"][-1], dict):
|
|
self._data["@context"][-1]["Hashtag"] = "as:Hashtag"
|
|
self._data["@context"][-1]["sensitive"] = "as:sensitive"
|
|
else:
|
|
self._data["@context"].append(
|
|
{"Hashtag": "as:Hashtag", "sensitive": "as:sensitive"}
|
|
)
|
|
|
|
# FIXME(tsileo): keys required for some subclasses?
|
|
allowed_keys = None
|
|
try:
|
|
allowed_keys = self._init(**kwargs)
|
|
logger.debug("calling custom init")
|
|
except NotImplementedError:
|
|
pass
|
|
|
|
if allowed_keys:
|
|
# Allows an extra to (like for Accept and Follow)
|
|
kwargs.pop("to", None)
|
|
if len(set(kwargs.keys()) - set(allowed_keys)) > 0:
|
|
raise BadActivityError(f"extra data left: {kwargs!r}")
|
|
else:
|
|
# Remove keys with `None` value
|
|
valid_kwargs = {}
|
|
for k, v in kwargs.items():
|
|
if v is None:
|
|
continue
|
|
valid_kwargs[k] = v
|
|
self._data.update(**valid_kwargs)
|
|
|
|
def ctx(self) -> Dict[str, Any]:
|
|
return self.__ctx
|
|
|
|
def set_ctx(self, ctx: Dict[str, Any]) -> None:
|
|
self.__ctx = ctx
|
|
|
|
def _init(self, **kwargs) -> Optional[List[str]]:
|
|
"""Optional init callback that may returns a list of allowed keys."""
|
|
raise NotImplementedError
|
|
|
|
def __repr__(self) -> str:
|
|
"""Pretty repr."""
|
|
return "{}({!r})".format(self.__class__.__qualname__, self._data.get("id"))
|
|
|
|
def __str__(self) -> str:
|
|
"""Returns the ID/IRI when castign to str."""
|
|
return str(self._data.get("id", f"[new {self.ACTIVITY_TYPE} activity]"))
|
|
|
|
def __getattr__(self, name: str) -> Any:
|
|
"""Allow to access the object field as regular attributes."""
|
|
if self._data.get(name):
|
|
return self._data.get(name)
|
|
|
|
def _outbox_set_id(self, uri: str, obj_id: str) -> None:
|
|
"""Optional callback for subclasses to so something with a newly generated ID (for outbox activities)."""
|
|
raise NotImplementedError
|
|
|
|
def outbox_set_id(self, uri: str, obj_id: str) -> None:
|
|
"""Set the ID for a new activity."""
|
|
logger.debug(f"setting ID {uri} / {obj_id}")
|
|
self._data["id"] = uri
|
|
try:
|
|
self._outbox_set_id(uri, obj_id)
|
|
except NotImplementedError:
|
|
pass
|
|
|
|
def _actor_id(self, obj: ObjectOrIDType) -> str:
|
|
if isinstance(obj, dict) and obj["type"] == ActivityType.PERSON.value:
|
|
obj_id = obj.get("id")
|
|
if not obj_id:
|
|
raise BadActivityError(f"missing object id: {obj!r}")
|
|
return obj_id
|
|
elif isinstance(obj, str):
|
|
return obj
|
|
else:
|
|
raise BadActivityError(f'invalid "actor" field: {obj!r}')
|
|
|
|
def _validate_person(self, obj: ObjectOrIDType) -> str:
|
|
obj_id = self._actor_id(obj)
|
|
try:
|
|
actor = BACKEND.fetch_iri(obj_id)
|
|
except Exception:
|
|
raise BadActivityError(f"failed to validate actor {obj!r}")
|
|
|
|
if not actor or "id" not in actor:
|
|
raise BadActivityError(f"invalid actor {actor}")
|
|
|
|
return actor["id"]
|
|
|
|
def get_object(self) -> "BaseActivity":
|
|
"""Returns the object as a BaseActivity instance."""
|
|
if self.__obj:
|
|
return self.__obj
|
|
if isinstance(self._data["object"], dict):
|
|
p = parse_activity(self._data["object"])
|
|
else:
|
|
obj = BACKEND.fetch_iri(self._data["object"])
|
|
if ActivityType(obj.get("type")) not in self.ALLOWED_OBJECT_TYPES:
|
|
raise UnexpectedActivityTypeError(
|
|
f'invalid object type {obj.get("type")!r}'
|
|
)
|
|
p = parse_activity(obj)
|
|
|
|
self.__obj: Optional["BaseActivity"] = p
|
|
return p
|
|
|
|
def reset_object_cache(self) -> None:
|
|
self.__obj = None
|
|
|
|
def to_dict(
|
|
self, embed: bool = False, embed_object_id_only: bool = False
|
|
) -> ObjectType:
|
|
"""Serializes the activity back to a dict, ready to be JSON serialized."""
|
|
data = dict(self._data)
|
|
if embed:
|
|
for k in ["@context", "signature"]:
|
|
if k in data:
|
|
del (data[k])
|
|
if (
|
|
data.get("object")
|
|
and embed_object_id_only
|
|
and isinstance(data["object"], dict)
|
|
):
|
|
try:
|
|
data["object"] = data["object"]["id"]
|
|
except KeyError:
|
|
raise BadActivityError(
|
|
f'embedded object {data["object"]!r} should have an id'
|
|
)
|
|
|
|
return data
|
|
|
|
def get_actor(self) -> "BaseActivity":
|
|
# FIXME(tsileo): cache the actor (same way as get_object)
|
|
actor = self._data.get("actor")
|
|
if not actor and self.ACTOR_REQUIRED:
|
|
# Quick hack for Note objects
|
|
if self.ACTIVITY_TYPE == ActivityType.NOTE:
|
|
actor = str(self._data.get("attributedTo"))
|
|
else:
|
|
raise BadActivityError(f"failed to fetch actor: {self._data!r}")
|
|
|
|
actor_id = self._actor_id(actor)
|
|
return Person(**BACKEND.fetch_iri(actor_id))
|
|
|
|
def _pre_post_to_outbox(self) -> None:
|
|
raise NotImplementedError
|
|
|
|
def _post_to_outbox(
|
|
self, obj_id: str, activity: ObjectType, recipients: List[str]
|
|
) -> None:
|
|
raise NotImplementedError
|
|
|
|
def _undo_outbox(self) -> None:
|
|
raise NotImplementedError
|
|
|
|
def _pre_process_from_inbox(self, as_actor: "Person") -> None:
|
|
raise NotImplementedError
|
|
|
|
def _process_from_inbox(self, as_actor: "Person") -> None:
|
|
raise NotImplementedError
|
|
|
|
def _undo_inbox(self) -> None:
|
|
raise NotImplementedError
|
|
|
|
def process_from_inbox(self, as_actor: "Person") -> None:
|
|
"""Process the message posted to `as_actor` inbox."""
|
|
logger.debug(f"calling main process from inbox hook for {self}")
|
|
actor = self.get_actor()
|
|
|
|
# Check for Block activity
|
|
if BACKEND.outbox_is_blocked(as_actor, actor.id):
|
|
# TODO(tsileo): raise ActorBlockedError?
|
|
logger.info(
|
|
f"actor {actor!r} is blocked, dropping the received activity {self!r}"
|
|
)
|
|
return
|
|
|
|
if BACKEND.inbox_get_by_iri(as_actor, self.id):
|
|
# The activity is already in the inbox
|
|
logger.info(f"received duplicate activity {self}, dropping it")
|
|
return
|
|
|
|
try:
|
|
self._pre_process_from_inbox(as_actor)
|
|
logger.debug("called pre process from inbox hook")
|
|
except NotImplementedError:
|
|
logger.debug("pre process from inbox hook not implemented")
|
|
|
|
BACKEND.inbox_new(as_actor, self)
|
|
logger.info("activity {self!r} saved")
|
|
|
|
try:
|
|
self._process_from_inbox(as_actor)
|
|
logger.debug("called process from inbox hook")
|
|
except NotImplementedError:
|
|
logger.debug("process from inbox hook not implemented")
|
|
|
|
def post_to_outbox(self) -> None:
|
|
logger.debug(f"calling main post to outbox hook for {self}")
|
|
|
|
# Assign create a random ID
|
|
obj_id = BACKEND.random_object_id()
|
|
self.outbox_set_id(BACKEND.activity_url(obj_id), obj_id)
|
|
|
|
try:
|
|
self._pre_post_to_outbox()
|
|
logger.debug(f"called pre post to outbox hook")
|
|
except NotImplementedError:
|
|
logger.debug("pre post to outbox hook not implemented")
|
|
|
|
BACKEND.outbox_new(self.get_actor(), self)
|
|
|
|
recipients = self.recipients()
|
|
logger.info(f"recipients={recipients}")
|
|
activity = clean_activity(self.to_dict())
|
|
|
|
try:
|
|
self._post_to_outbox(obj_id, activity, recipients)
|
|
logger.debug(f"called post to outbox hook")
|
|
except NotImplementedError:
|
|
logger.debug("post to outbox hook not implemented")
|
|
|
|
payload = json.dumps(activity)
|
|
for recp in recipients:
|
|
logger.debug(f"posting to {recp}")
|
|
|
|
BACKEND.post_to_remote_inbox(self.get_actor(), payload, recp)
|
|
|
|
def _recipients(self) -> List[str]:
|
|
return []
|
|
|
|
def recipients(self) -> List[str]:
|
|
recipients = self._recipients()
|
|
actor_id = self.get_actor().id
|
|
|
|
out: List[str] = []
|
|
for recipient in recipients:
|
|
# if recipient in PUBLIC_INSTANCES:
|
|
# if recipient not in out:
|
|
# out.append(str(recipient))
|
|
# continue
|
|
if recipient in [actor_id, AS_PUBLIC, None]:
|
|
continue
|
|
if isinstance(recipient, Person):
|
|
if recipient.id == actor_id:
|
|
continue
|
|
actor = recipient
|
|
else:
|
|
raw_actor = BACKEND.fetch_iri(recipient)
|
|
if raw_actor["type"] == ActivityType.PERSON.value:
|
|
actor = Person(**raw_actor)
|
|
|
|
if actor.endpoints:
|
|
shared_inbox = actor.endpoints.get("sharedInbox")
|
|
if shared_inbox not in out:
|
|
out.append(shared_inbox)
|
|
continue
|
|
|
|
if actor.inbox and actor.inbox not in out:
|
|
out.append(actor.inbox)
|
|
|
|
# Is the activity a `Collection`/`OrderedCollection`?
|
|
elif raw_actor["type"] in [
|
|
ActivityType.COLLECTION.value,
|
|
ActivityType.ORDERED_COLLECTION.value,
|
|
]:
|
|
for item in parse_collection(raw_actor):
|
|
if item in [actor_id, AS_PUBLIC]:
|
|
continue
|
|
try:
|
|
col_actor = Person(**BACKEND.fetch_iri(item))
|
|
except UnexpectedActivityTypeError:
|
|
logger.exception(f"failed to fetch actor {item!r}")
|
|
|
|
if col_actor.endpoints:
|
|
shared_inbox = col_actor.endpoints.get("sharedInbox")
|
|
if shared_inbox not in out:
|
|
out.append(shared_inbox)
|
|
continue
|
|
if col_actor.inbox and col_actor.inbox not in out:
|
|
out.append(col_actor.inbox)
|
|
else:
|
|
raise BadActivityError(f"failed to parse {raw_actor!r}")
|
|
|
|
return out
|
|
|
|
def build_undo(self) -> "BaseActivity":
|
|
raise NotImplementedError
|
|
|
|
def build_delete(self) -> "BaseActivity":
|
|
raise NotImplementedError
|
|
|
|
|
|
class Person(BaseActivity):
|
|
ACTIVITY_TYPE = ActivityType.PERSON
|
|
OBJECT_REQUIRED = False
|
|
ACTOR_REQUIRED = False
|
|
|
|
|
|
class Block(BaseActivity):
|
|
ACTIVITY_TYPE = ActivityType.BLOCK
|
|
OBJECT_REQUIRED = True
|
|
ACTOR_REQUIRED = True
|
|
|
|
|
|
class Collection(BaseActivity):
|
|
ACTIVITY_TYPE = ActivityType.COLLECTION
|
|
OBJECT_REQUIRED = False
|
|
ACTOR_REQUIRED = False
|
|
|
|
|
|
class Image(BaseActivity):
|
|
ACTIVITY_TYPE = ActivityType.IMAGE
|
|
OBJECT_REQUIRED = False
|
|
ACTOR_REQUIRED = False
|
|
|
|
def _init(self, **kwargs):
|
|
self._data.update(url=kwargs.pop("url"))
|
|
|
|
def __repr__(self):
|
|
return "Image({!r})".format(self._data.get("url"))
|
|
|
|
|
|
class Follow(BaseActivity):
|
|
ACTIVITY_TYPE = ActivityType.FOLLOW
|
|
ALLOWED_OBJECT_TYPES = [ActivityType.PERSON]
|
|
OBJECT_REQUIRED = True
|
|
ACTOR_REQUIRED = True
|
|
|
|
def _build_reply(self, reply_type: ActivityType) -> BaseActivity:
|
|
if reply_type == ActivityType.ACCEPT:
|
|
return Accept(actor=self.get_object().id, object=self.to_dict(embed=True))
|
|
|
|
raise ValueError(f"type {reply_type} is invalid for building a reply")
|
|
|
|
def _recipients(self) -> List[str]:
|
|
return [self.get_object().id]
|
|
|
|
def _process_from_inbox(self, as_actor: "Person") -> None:
|
|
"""Receiving a Follow should trigger an Accept."""
|
|
accept = self.build_accept()
|
|
accept.post_to_outbox()
|
|
|
|
remote_actor = self.get_actor()
|
|
|
|
BACKEND.new_follower(self.get_object(), self)
|
|
|
|
def _post_to_outbox(
|
|
self, obj_id: str, activity: ObjectType, recipients: List[str]
|
|
) -> None:
|
|
# XXX The new_following event will be triggered by Accept
|
|
pass
|
|
|
|
def _undo_inbox(self) -> None:
|
|
BACKEND.undo_new_follower(self.get_object(), self)
|
|
|
|
def _undo_outbox(self) -> None:
|
|
BACKEND.undo_new_following(self.get_actor(), self)
|
|
|
|
def build_accept(self) -> BaseActivity:
|
|
return self._build_reply(ActivityType.ACCEPT)
|
|
|
|
def build_undo(self) -> BaseActivity:
|
|
return Undo(object=self.to_dict(embed=True), actor=self.get_actor().id)
|
|
|
|
|
|
class Accept(BaseActivity):
|
|
ACTIVITY_TYPE = ActivityType.ACCEPT
|
|
ALLOWED_OBJECT_TYPES = [ActivityType.FOLLOW]
|
|
OBJECT_REQUIRED = True
|
|
ACTOR_REQUIRED = True
|
|
|
|
def _recipients(self) -> List[str]:
|
|
return [self.get_object().get_actor().id]
|
|
|
|
def _pre_process_from_inbox(self, as_actor: "Person") -> None:
|
|
# FIXME(tsileo): ensure the actor match the object actor
|
|
pass
|
|
|
|
def _process_from_inbox(self, as_actor: "Person") -> None:
|
|
BACKEND.new_following(as_actor, self.get_object())
|
|
|
|
|
|
class Undo(BaseActivity):
|
|
ACTIVITY_TYPE = ActivityType.UNDO
|
|
ALLOWED_OBJECT_TYPES = [
|
|
ActivityType.FOLLOW,
|
|
ActivityType.LIKE,
|
|
ActivityType.ANNOUNCE,
|
|
]
|
|
OBJECT_REQUIRED = True
|
|
ACTOR_REQUIRED = True
|
|
|
|
def _recipients(self) -> List[str]:
|
|
obj = self.get_object()
|
|
if obj.ACTIVITY_TYPE == ActivityType.FOLLOW:
|
|
return [obj.get_object().id]
|
|
else:
|
|
return [obj.get_object().get_actor().id]
|
|
# TODO(tsileo): handle like and announce
|
|
raise Exception("TODO")
|
|
|
|
def _pre_process_from_inbox(self, as_actor: "Person") -> None:
|
|
"""Ensures an Undo activity comes from the same actor as the updated activity."""
|
|
obj = self.get_object()
|
|
actor = self.get_actor()
|
|
if actor.id != obj.get_actor().id:
|
|
raise BadActivityError(f"{actor!r} cannot update {obj!r}")
|
|
|
|
def _process_from_inbox(self, as_actor: "Person") -> None:
|
|
obj = self.get_object()
|
|
# FIXME(tsileo): move this to _undo_inbox impl
|
|
# DB.inbox.update_one({'remote_id': obj.id}, {'$set': {'meta.undo': True}})
|
|
|
|
try:
|
|
obj._undo_inbox()
|
|
except NotImplementedError:
|
|
pass
|
|
|
|
def _pre_post_to_outbox(self) -> None:
|
|
"""Ensures an Undo activity references an activity owned by the instance."""
|
|
# ABC
|
|
if not BACKEND.is_from_outbox(self):
|
|
raise NotFromOutboxError(f"object {self!r} is not owned by this instance")
|
|
|
|
def _post_to_outbox(
|
|
self, obj_id: str, activity: ObjectType, recipients: List[str]
|
|
) -> None:
|
|
logger.debug("processing undo to outbox")
|
|
logger.debug("self={}".format(self))
|
|
obj = self.get_object()
|
|
logger.debug("obj={}".format(obj))
|
|
|
|
# FIXME(tsileo): move this to _undo_inbox impl
|
|
# DB.outbox.update_one({'remote_id': obj.id}, {'$set': {'meta.undo': True}})
|
|
|
|
try:
|
|
obj._undo_outbox()
|
|
logger.debug(f"_undo_outbox called for {obj}")
|
|
except NotImplementedError:
|
|
logger.debug(f"_undo_outbox not implemented for {obj}")
|
|
pass
|
|
|
|
|
|
class Like(BaseActivity):
|
|
ACTIVITY_TYPE = ActivityType.LIKE
|
|
ALLOWED_OBJECT_TYPES = [ActivityType.NOTE]
|
|
OBJECT_REQUIRED = True
|
|
ACTOR_REQUIRED = True
|
|
|
|
def _recipients(self) -> List[str]:
|
|
return [self.get_object().get_actor().id]
|
|
|
|
def _process_from_inbox(self, as_actor: "Person") -> None:
|
|
# ABC
|
|
self.inbox_like(self)
|
|
|
|
def _undo_inbox(self) -> None:
|
|
# ABC
|
|
self.inbox_undo_like(self)
|
|
|
|
def _post_to_outbox(self, obj_id: str, activity: ObjectType, recipients: List[str]):
|
|
# ABC
|
|
self.outbox_like(self)
|
|
|
|
def _undo_outbox(self) -> None:
|
|
# ABC
|
|
self.outbox_undo_like(self)
|
|
|
|
def build_undo(self) -> BaseActivity:
|
|
return Undo(
|
|
object=self.to_dict(embed=True, embed_object_id_only=True),
|
|
actor=self.get_actor().id,
|
|
)
|
|
|
|
|
|
class Announce(BaseActivity):
|
|
ACTIVITY_TYPE = ActivityType.ANNOUNCE
|
|
ALLOWED_OBJECT_TYPES = [ActivityType.NOTE]
|
|
OBJECT_REQUIRED = True
|
|
ACTOR_REQUIRED = True
|
|
|
|
def _recipients(self) -> List[str]:
|
|
recipients = []
|
|
|
|
for field in ["to", "cc"]:
|
|
if field in self._data:
|
|
recipients.extend(_to_list(self._data[field]))
|
|
|
|
return recipients
|
|
|
|
def _process_from_inbox(self, as_actor: "Person") -> None:
|
|
if isinstance(self._data["object"], str) and not self._data[
|
|
"object"
|
|
].startswith("http"):
|
|
# TODO(tsileo): actually drop it without storing it and better logging, also move the check somewhere else
|
|
logger.warn(
|
|
f'received an Annouce referencing an OStatus notice ({self._data["object"]}), dropping the message'
|
|
)
|
|
return
|
|
|
|
# ABC
|
|
self.inbox_announce(self)
|
|
|
|
def _undo_inbox(self) -> None:
|
|
# ABC
|
|
self.inbox_undo_annnounce(self)
|
|
|
|
def _post_to_outbox(
|
|
self, obj_id: str, activity: ObjectType, recipients: List[str]
|
|
) -> None:
|
|
# ABC
|
|
self.outbox_announce(self)
|
|
|
|
def _undo_outbox(self) -> None:
|
|
# ABC
|
|
self.outbox_undo_announce(self)
|
|
|
|
def build_undo(self) -> BaseActivity:
|
|
return Undo(object=self.to_dict(embed=True))
|
|
|
|
|
|
class Delete(BaseActivity):
|
|
ACTIVITY_TYPE = ActivityType.DELETE
|
|
ALLOWED_OBJECT_TYPES = [ActivityType.NOTE, ActivityType.TOMBSTONE]
|
|
OBJECT_REQUIRED = True
|
|
|
|
def _get_actual_object(self) -> BaseActivity:
|
|
# FIXME(tsileo): overrides get_object instead?
|
|
obj = self.get_object()
|
|
if obj.ACTIVITY_TYPE == ActivityType.TOMBSTONE:
|
|
obj = parse_activity(BACKEND.fetch_iri(obj.id))
|
|
return obj
|
|
|
|
def _recipients(self) -> List[str]:
|
|
obj = self._get_actual_object()
|
|
return obj._recipients()
|
|
|
|
def _pre_process_from_inbox(self, as_actor: "Person") -> None:
|
|
"""Ensures a Delete activity comes from the same actor as the deleted activity."""
|
|
obj = self._get_actual_object()
|
|
actor = self.get_actor()
|
|
if actor.id != obj.get_actor().id:
|
|
raise BadActivityError(f"{actor!r} cannot delete {obj!r}")
|
|
|
|
def _process_from_inbox(self, as_actor: "Person") -> None:
|
|
# ABC
|
|
self.inbox_delete(self)
|
|
# FIXME(tsileo): handle the delete_threads here?
|
|
|
|
def _pre_post_to_outbox(self) -> None:
|
|
"""Ensures the Delete activity references a activity from the outbox (i.e. owned by the instance)."""
|
|
obj = self._get_actual_object()
|
|
# ABC
|
|
if not BACKEND.is_from_outbox(self):
|
|
raise NotFromOutboxError(
|
|
f'object {obj["id"]} is not owned by this instance'
|
|
)
|
|
|
|
def _post_to_outbox(
|
|
self, obj_id: str, activity: ObjectType, recipients: List[str]
|
|
) -> None:
|
|
# ABC
|
|
self.outbox_delete(self)
|
|
|
|
|
|
class Update(BaseActivity):
|
|
ACTIVITY_TYPE = ActivityType.UPDATE
|
|
ALLOWED_OBJECT_TYPES = [ActivityType.NOTE, ActivityType.PERSON]
|
|
OBJECT_REQUIRED = True
|
|
ACTOR_REQUIRED = True
|
|
|
|
def _pre_process_from_inbox(self, as_actor: "Person") -> None:
|
|
"""Ensures an Update activity comes from the same actor as the updated activity."""
|
|
obj = self.get_object()
|
|
actor = self.get_actor()
|
|
if actor.id != obj.get_actor().id:
|
|
raise BadActivityError(f"{actor!r} cannot update {obj!r}")
|
|
|
|
def _process_from_inbox(self, as_actor: "Person") -> None:
|
|
# ABC
|
|
self.inbox_update(self)
|
|
|
|
def _pre_post_to_outbox(self) -> None:
|
|
# ABC
|
|
if not self.is_form_outbox(self):
|
|
raise NotFromOutboxError(f"object {self!r} is not owned by this instance")
|
|
|
|
def _post_to_outbox(
|
|
self, obj_id: str, activity: ObjectType, recipients: List[str]
|
|
) -> None:
|
|
# ABC
|
|
self.outbox_update(self)
|
|
|
|
|
|
class Create(BaseActivity):
|
|
ACTIVITY_TYPE = ActivityType.CREATE
|
|
ALLOWED_OBJECT_TYPES = [ActivityType.NOTE]
|
|
OBJECT_REQUIRED = True
|
|
ACTOR_REQUIRED = True
|
|
|
|
def _set_id(self, uri: str, obj_id: str) -> None:
|
|
self._data["object"]["id"] = uri + "/activity"
|
|
# ABC
|
|
self._data["object"]["url"] = self.note_url(self)
|
|
self.reset_object_cache()
|
|
|
|
def _init(self, **kwargs):
|
|
obj = self.get_object()
|
|
if not obj.attributedTo:
|
|
self._data["object"]["attributedTo"] = self.get_actor().id
|
|
if not obj.published:
|
|
if self.published:
|
|
self._data["object"]["published"] = self.published
|
|
else:
|
|
now = datetime.utcnow().replace(microsecond=0).isoformat() + "Z"
|
|
self._data["published"] = now
|
|
self._data["object"]["published"] = now
|
|
|
|
def _recipients(self) -> List[str]:
|
|
# TODO(tsileo): audience support?
|
|
recipients = []
|
|
for field in ["to", "cc", "bto", "bcc"]:
|
|
if field in self._data:
|
|
recipients.extend(_to_list(self._data[field]))
|
|
|
|
recipients.extend(self.get_object()._recipients())
|
|
|
|
return recipients
|
|
|
|
def _process_from_inbox(self, as_actor: "Person") -> None:
|
|
# ABC
|
|
self.inbox_create(self)
|
|
|
|
def _post_to_outbox(
|
|
self, obj_id: str, activity: ObjectType, recipients: List[str]
|
|
) -> None:
|
|
# ABC
|
|
self.outbox_create(self)
|
|
|
|
|
|
class Tombstone(BaseActivity):
|
|
ACTIVITY_TYPE = ActivityType.TOMBSTONE
|
|
ACTOR_REQUIRED = False
|
|
OBJECT_REQUIRED = False
|
|
|
|
|
|
class Note(BaseActivity):
|
|
ACTIVITY_TYPE = ActivityType.NOTE
|
|
ACTOR_REQUIRED = True
|
|
OBJECT_REQURIED = False
|
|
|
|
def _init(self, **kwargs):
|
|
print(self._data)
|
|
# Remove the `actor` field as `attributedTo` is used for `Note` instead
|
|
if "actor" in self._data:
|
|
del (self._data["actor"])
|
|
if "sensitive" not in kwargs:
|
|
self._data["sensitive"] = False
|
|
|
|
def _recipients(self) -> List[str]:
|
|
# TODO(tsileo): audience support?
|
|
recipients: List[str] = []
|
|
|
|
# FIXME(tsileo): re-add support for the PUBLIC_INSTANCES
|
|
# If the note is public, we publish it to the defined "public instances"
|
|
# if AS_PUBLIC in self._data.get('to', []):
|
|
# recipients.extend(PUBLIC_INSTANCES)
|
|
# print('publishing to public instances')
|
|
# print(recipients)
|
|
|
|
for field in ["to", "cc", "bto", "bcc"]:
|
|
if field in self._data:
|
|
recipients.extend(_to_list(self._data[field]))
|
|
|
|
return recipients
|
|
|
|
def build_create(self) -> BaseActivity:
|
|
"""Wraps an activity in a Create activity."""
|
|
create_payload = {
|
|
"object": self.to_dict(embed=True),
|
|
"actor": self.attributedTo,
|
|
}
|
|
for field in ["published", "to", "bto", "cc", "bcc", "audience"]:
|
|
if field in self._data:
|
|
create_payload[field] = self._data[field]
|
|
|
|
return Create(**create_payload)
|
|
|
|
def build_like(self) -> BaseActivity:
|
|
return Like(object=self.id)
|
|
|
|
def build_announce(self) -> BaseActivity:
|
|
return Announce(
|
|
object=self.id,
|
|
to=[AS_PUBLIC],
|
|
cc=[
|
|
self.follower_collection_id(self.get_actor()),
|
|
self.attributedTo,
|
|
], # ABC
|
|
published=datetime.utcnow().replace(microsecond=0).isoformat() + "Z",
|
|
)
|
|
|
|
def build_delete(self) -> BaseActivity:
|
|
return Delete(object=Tombstone(id=self.id).to_dict(embed=True))
|
|
|
|
def get_tombstone(self, deleted: Optional[str]) -> BaseActivity:
|
|
return Tombstone(
|
|
id=self.id, published=self.published, deleted=deleted, updated=deleted
|
|
)
|
|
|
|
|
|
class Box(object):
|
|
def __init__(self, actor: Person):
|
|
self.actor = actor
|
|
|
|
|
|
class Outbox(Box):
|
|
def post(self, activity: BaseActivity) -> None:
|
|
if activity.get_actor().id != self.actor.id:
|
|
raise ValueError(
|
|
f"{activity.get_actor()!r} cannot post into {self.actor!r} outbox"
|
|
)
|
|
|
|
activity.post_to_outbox()
|
|
|
|
def get(self, activity_iri: str) -> BaseActivity:
|
|
pass
|
|
|
|
def collection(self):
|
|
# TODO(tsileo): figure out an API
|
|
pass
|
|
|
|
|
|
class Inbox(Box):
|
|
def post(self, activity: BaseActivity) -> None:
|
|
activity.process_from_inbox(self.actor)
|