mirror of
https://git.sr.ht/~tsileo/microblog.pub
synced 2024-11-15 03:04:28 +00:00
[WIP] Start an in-mem backend
This commit is contained in:
parent
f0880f0119
commit
b75da521e4
4 changed files with 119 additions and 68 deletions
|
@ -1,6 +1,6 @@
|
||||||
# Little Boxes
|
# Little Boxes
|
||||||
|
|
||||||
Tiny ActivityPub framework.
|
Tiny ActivityPub framework written in Python, both database and server agnostic.
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
|
|
|
@ -9,8 +9,9 @@ from enum import Enum
|
||||||
from .errors import BadActivityError
|
from .errors import BadActivityError
|
||||||
from .errors import UnexpectedActivityTypeError
|
from .errors import UnexpectedActivityTypeError
|
||||||
from .errors import NotFromOutboxError
|
from .errors import NotFromOutboxError
|
||||||
|
from .errors import ActivityNotFoundError
|
||||||
|
from .urlutils import check_url
|
||||||
from .utils import parse_collection
|
from .utils import parse_collection
|
||||||
from .remote_object import OBJECT_FETCHER
|
|
||||||
|
|
||||||
from typing import List
|
from typing import List
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
@ -19,6 +20,9 @@ from typing import Any
|
||||||
from typing import Union
|
from typing import Union
|
||||||
from typing import Type
|
from typing import Type
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Helper/shortcut for typing
|
# Helper/shortcut for typing
|
||||||
|
@ -48,6 +52,30 @@ def use_backend(backend_instance):
|
||||||
BACKEND = backend_instance
|
BACKEND = backend_instance
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class DefaultRemoteObjectFetcher(object):
|
||||||
|
"""Not meant to be used on production, a caching layer, and DB shortcut fox inbox/outbox should be hooked."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._user_agent = 'Little Boxes (+https://github.com/tsileo/little_boxes)'
|
||||||
|
|
||||||
|
def fetch(self, iri):
|
||||||
|
print('OLD FETCHER')
|
||||||
|
check_url(iri)
|
||||||
|
|
||||||
|
resp = requests.get(iri, headers={
|
||||||
|
'Accept': 'application/activity+json',
|
||||||
|
'User-Agent': self._user_agent,
|
||||||
|
})
|
||||||
|
|
||||||
|
if resp.status_code == 404:
|
||||||
|
raise ActivityNotFoundError(f'{iri} cannot be fetched, 404 not found error')
|
||||||
|
|
||||||
|
resp.raise_for_status()
|
||||||
|
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
class ActivityType(Enum):
|
class ActivityType(Enum):
|
||||||
"""Supported activity `type`."""
|
"""Supported activity `type`."""
|
||||||
ANNOUNCE = 'Announce'
|
ANNOUNCE = 'Announce'
|
||||||
|
@ -116,38 +144,95 @@ def _get_actor_id(actor: ObjectOrIDType) -> str:
|
||||||
|
|
||||||
|
|
||||||
class BaseBackend(object):
|
class BaseBackend(object):
|
||||||
|
"""In-memory backend meant to be used for the test suite."""
|
||||||
|
DB = {}
|
||||||
|
USERS = {}
|
||||||
|
FETCH_MOCK = {}
|
||||||
|
INBOX_IDX = {}
|
||||||
|
OUTBOX_IDX = {}
|
||||||
|
FOLLOWERS = {}
|
||||||
|
FOLLOWING = {}
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._setup_user('Thomas2', 'tom2')
|
||||||
|
self._setup_user('Thomas', 'tom')
|
||||||
|
|
||||||
|
def _setup_user(self, name, pusername):
|
||||||
|
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()
|
||||||
|
|
||||||
|
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}')
|
||||||
|
|
||||||
def outbox_is_blocked(self, as_actor: 'Person', actor_id: str) -> bool:
|
def outbox_is_blocked(self, as_actor: 'Person', actor_id: str) -> bool:
|
||||||
"""Returns True if `as_actor` has blocked `actor_id`."""
|
"""Returns True if `as_actor` has blocked `actor_id`."""
|
||||||
pass
|
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) -> 'BaseActivity':
|
def inbox_get_by_iri(self, as_actor: 'Person', iri: str) -> 'BaseActivity':
|
||||||
pass
|
for activity in self.DB[as_actor.id]['inbox']:
|
||||||
|
if activity.id == iri:
|
||||||
|
return activity
|
||||||
|
|
||||||
|
raise ActivityNotFoundError()
|
||||||
|
|
||||||
def inbox_new(self, as_actor: 'Person', activity: 'BaseActivity') -> None:
|
def inbox_new(self, as_actor: 'Person', activity: 'BaseActivity') -> None:
|
||||||
pass
|
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:
|
def activity_url(self, obj_id: str) -> str:
|
||||||
# from the random hex ID
|
# from the random hex ID
|
||||||
return 'TODO'
|
return 'TODO'
|
||||||
|
|
||||||
def outbox_new(self, activity: 'BaseActivity') -> None:
|
def outbox_new(self, activity: 'BaseActivity') -> None:
|
||||||
pass
|
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
|
||||||
|
|
||||||
def new_follower(self, actor: 'Person') -> None:
|
def new_follower(self, as_actor: 'Person', actor: 'Person') -> None:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def undo_new_follower(self, actor: 'Person') -> None:
|
def undo_new_follower(self, actor: 'Person') -> None:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def new_following(self, actor: 'Person') -> None:
|
def new_following(self, as_actor: 'Person', actor: 'Person') -> None:
|
||||||
pass
|
print(f'new following {actor!r}')
|
||||||
|
self.FOLLOWING[as_actor.id].append(actor)
|
||||||
|
|
||||||
def undo_new_following(self, actor: 'Person') -> None:
|
def undo_new_following(self, actor: 'Person') -> None:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def post_to_remote_inbox(self, payload: ObjectType, recp: str) -> None:
|
def post_to_remote_inbox(self, payload: ObjectType, recp: str) -> None:
|
||||||
pass
|
print(f'post_to_remote_inbox {payload} {recp}')
|
||||||
|
|
||||||
def is_from_outbox(self, activity: 'BaseActivity') -> None:
|
def is_from_outbox(self, activity: 'BaseActivity') -> None:
|
||||||
pass
|
pass
|
||||||
|
@ -201,7 +286,7 @@ class _ActivityMeta(type):
|
||||||
cls = type.__new__(meta, name, bases, class_dict)
|
cls = type.__new__(meta, name, bases, class_dict)
|
||||||
|
|
||||||
# Ensure the class has an activity type defined
|
# Ensure the class has an activity type defined
|
||||||
if not cls.ACTIVITY_TYPE:
|
if name != 'BaseActivity' and not cls.ACTIVITY_TYPE:
|
||||||
raise ValueError(f'class {name} has no ACTIVITY_TYPE')
|
raise ValueError(f'class {name} has no ACTIVITY_TYPE')
|
||||||
|
|
||||||
# Register it
|
# Register it
|
||||||
|
@ -321,7 +406,7 @@ class BaseActivity(object, metaclass=_ActivityMeta):
|
||||||
logger.debug(f'setting ID {uri} / {obj_id}')
|
logger.debug(f'setting ID {uri} / {obj_id}')
|
||||||
self._data['id'] = uri
|
self._data['id'] = uri
|
||||||
try:
|
try:
|
||||||
self._set_id(uri, obj_id)
|
self._outbox_set_id(uri, obj_id)
|
||||||
except NotImplementedError:
|
except NotImplementedError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@ -339,7 +424,7 @@ class BaseActivity(object, metaclass=_ActivityMeta):
|
||||||
def _validate_person(self, obj: ObjectOrIDType) -> str:
|
def _validate_person(self, obj: ObjectOrIDType) -> str:
|
||||||
obj_id = self._actor_id(obj)
|
obj_id = self._actor_id(obj)
|
||||||
try:
|
try:
|
||||||
actor = OBJECT_FETCHER.fetch(obj_id)
|
actor = BACKEND.fetch_iri(obj_id)
|
||||||
except Exception:
|
except Exception:
|
||||||
raise BadActivityError(f'failed to validate actor {obj!r}')
|
raise BadActivityError(f'failed to validate actor {obj!r}')
|
||||||
|
|
||||||
|
@ -355,7 +440,7 @@ class BaseActivity(object, metaclass=_ActivityMeta):
|
||||||
if isinstance(self._data['object'], dict):
|
if isinstance(self._data['object'], dict):
|
||||||
p = parse_activity(self._data['object'])
|
p = parse_activity(self._data['object'])
|
||||||
else:
|
else:
|
||||||
obj = OBJECT_FETCHER.fetch(self._data['object'])
|
obj = BACKEND.fetch_iri(self._data['object'])
|
||||||
if ActivityType(obj.get('type')) not in self.ALLOWED_OBJECT_TYPES:
|
if ActivityType(obj.get('type')) not in self.ALLOWED_OBJECT_TYPES:
|
||||||
raise UnexpectedActivityTypeError(f'invalid object type {obj.get("type")!r}')
|
raise UnexpectedActivityTypeError(f'invalid object type {obj.get("type")!r}')
|
||||||
p = parse_activity(obj)
|
p = parse_activity(obj)
|
||||||
|
@ -392,7 +477,7 @@ class BaseActivity(object, metaclass=_ActivityMeta):
|
||||||
raise BadActivityError(f'failed to fetch actor: {self._data!r}')
|
raise BadActivityError(f'failed to fetch actor: {self._data!r}')
|
||||||
|
|
||||||
actor_id = self._actor_id(actor)
|
actor_id = self._actor_id(actor)
|
||||||
return Person(**OBJECT_FETCHER.fetch(actor_id))
|
return Person(**BACKEND.fetch_iri(actor_id))
|
||||||
|
|
||||||
def _pre_post_to_outbox(self) -> None:
|
def _pre_post_to_outbox(self) -> None:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
@ -449,7 +534,7 @@ class BaseActivity(object, metaclass=_ActivityMeta):
|
||||||
# Assign create a random ID
|
# Assign create a random ID
|
||||||
obj_id = random_object_id()
|
obj_id = random_object_id()
|
||||||
# ABC
|
# ABC
|
||||||
self.set_id(self.activity_url(obj_id), obj_id)
|
self.outbox_set_id(BACKEND.activity_url(obj_id), obj_id)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self._pre_post_to_outbox()
|
self._pre_post_to_outbox()
|
||||||
|
@ -457,8 +542,7 @@ class BaseActivity(object, metaclass=_ActivityMeta):
|
||||||
except NotImplementedError:
|
except NotImplementedError:
|
||||||
logger.debug('pre post to outbox hook not implemented')
|
logger.debug('pre post to outbox hook not implemented')
|
||||||
|
|
||||||
# ABC
|
BACKEND.outbox_new(self)
|
||||||
self.outbox_new(self)
|
|
||||||
|
|
||||||
recipients = self.recipients()
|
recipients = self.recipients()
|
||||||
logger.info(f'recipients={recipients}')
|
logger.info(f'recipients={recipients}')
|
||||||
|
@ -475,7 +559,7 @@ class BaseActivity(object, metaclass=_ActivityMeta):
|
||||||
logger.debug(f'posting to {recp}')
|
logger.debug(f'posting to {recp}')
|
||||||
|
|
||||||
# ABC
|
# ABC
|
||||||
self.post_to_remote_inbox(payload, recp)
|
BACKEND.post_to_remote_inbox(payload, recp)
|
||||||
|
|
||||||
def _recipients(self) -> List[str]:
|
def _recipients(self) -> List[str]:
|
||||||
return []
|
return []
|
||||||
|
@ -497,8 +581,8 @@ class BaseActivity(object, metaclass=_ActivityMeta):
|
||||||
continue
|
continue
|
||||||
actor = recipient
|
actor = recipient
|
||||||
else:
|
else:
|
||||||
raw_actor = OBJECT_FETCHER.fetch(recipient)
|
raw_actor = BACKEND.fetch_iri(recipient)
|
||||||
if raw_actor['type'] == ActivityType.PERSON.name:
|
if raw_actor['type'] == ActivityType.PERSON.value:
|
||||||
actor = Person(**raw_actor)
|
actor = Person(**raw_actor)
|
||||||
|
|
||||||
if actor.endpoints:
|
if actor.endpoints:
|
||||||
|
@ -517,7 +601,7 @@ class BaseActivity(object, metaclass=_ActivityMeta):
|
||||||
if item in [actor_id, AS_PUBLIC]:
|
if item in [actor_id, AS_PUBLIC]:
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
col_actor = Person(**OBJECT_FETCHER.fetch(item))
|
col_actor = Person(**BACKEND.fetch_iri(item))
|
||||||
except UnexpectedActivityTypeError:
|
except UnexpectedActivityTypeError:
|
||||||
logger.exception(f'failed to fetch actor {item!r}')
|
logger.exception(f'failed to fetch actor {item!r}')
|
||||||
|
|
||||||
|
@ -599,6 +683,9 @@ class Follow(BaseActivity):
|
||||||
# ABC
|
# ABC
|
||||||
self.new_follower(remote_actor)
|
self.new_follower(remote_actor)
|
||||||
|
|
||||||
|
def _post_to_outbox(self, obj_id: str, activity: ObjectType, recipients: List[str]) -> None:
|
||||||
|
BACKEND.new_following(self.get_actor(), self.get_object())
|
||||||
|
|
||||||
def _undo_inbox(self) -> None:
|
def _undo_inbox(self) -> None:
|
||||||
# ABC
|
# ABC
|
||||||
self.undo_new_follower(self.get_actor())
|
self.undo_new_follower(self.get_actor())
|
||||||
|
@ -770,7 +857,7 @@ class Delete(BaseActivity):
|
||||||
# FIXME(tsileo): overrides get_object instead?
|
# FIXME(tsileo): overrides get_object instead?
|
||||||
obj = self.get_object()
|
obj = self.get_object()
|
||||||
if obj.type_enum == ActivityType.TOMBSTONE:
|
if obj.type_enum == ActivityType.TOMBSTONE:
|
||||||
obj = parse_activity(OBJECT_FETCHER.fetch(obj.id))
|
obj = parse_activity(BACKEND.fetch_iri(obj.id))
|
||||||
return obj
|
return obj
|
||||||
|
|
||||||
def _recipients(self) -> List[str]:
|
def _recipients(self) -> List[str]:
|
||||||
|
@ -961,10 +1048,10 @@ class Outbox(Box):
|
||||||
|
|
||||||
def collection(self):
|
def collection(self):
|
||||||
# TODO(tsileo): figure out an API
|
# TODO(tsileo): figure out an API
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class Inbox(Box):
|
class Inbox(Box):
|
||||||
|
|
||||||
def post(self, activity: BaseActivity) -> None:
|
def post(self, activity: BaseActivity) -> None:
|
||||||
|
|
||||||
activity.process_from_inbox(self.actor)
|
activity.process_from_inbox(self.actor)
|
||||||
|
|
|
@ -1,39 +0,0 @@
|
||||||
import logging
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import requests
|
|
||||||
|
|
||||||
from .urlutils import check_url
|
|
||||||
from .errors import ActivityNotFoundError
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class DefaultRemoteObjectFetcher(object):
|
|
||||||
"""Not meant to be used on production, a caching layer, and DB shortcut fox inbox/outbox should be hooked."""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self._user_agent = 'Little Boxes (+https://github.com/tsileo/little_boxes)'
|
|
||||||
|
|
||||||
def fetch(self, iri):
|
|
||||||
check_url(iri)
|
|
||||||
|
|
||||||
resp = requests.get(iri, headers={
|
|
||||||
'Accept': 'application/activity+json',
|
|
||||||
'User-Agent': self._user_agent,
|
|
||||||
})
|
|
||||||
|
|
||||||
if resp.status_code == 404:
|
|
||||||
raise ActivityNotFoundError(f'{iri} cannot be fetched, 404 not found error')
|
|
||||||
|
|
||||||
resp.raise_for_status()
|
|
||||||
|
|
||||||
return resp.json()
|
|
||||||
|
|
||||||
|
|
||||||
OBJECT_FETCHER = DefaultRemoteObjectFetcher()
|
|
||||||
|
|
||||||
|
|
||||||
def set_object_fetcher(object_fetcher: Any):
|
|
||||||
global OBJECT_FETCHER
|
|
||||||
OBJECT_FETCHER = object_fetcher
|
|
|
@ -1,5 +1,6 @@
|
||||||
"""Contains some ActivityPub related utils."""
|
"""Contains some ActivityPub related utils."""
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
from typing import Callable
|
||||||
from typing import Dict
|
from typing import Dict
|
||||||
from typing import List
|
from typing import List
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
@ -7,22 +8,24 @@ from typing import Any
|
||||||
|
|
||||||
from .errors import RecursionLimitExceededError
|
from .errors import RecursionLimitExceededError
|
||||||
from .errors import UnexpectedActivityTypeError
|
from .errors import UnexpectedActivityTypeError
|
||||||
from .remote_object import OBJECT_FETCHER
|
|
||||||
|
|
||||||
|
|
||||||
def parse_collection(
|
def parse_collection(
|
||||||
payload: Optional[Dict[str, Any]] = None,
|
payload: Optional[Dict[str, Any]] = None,
|
||||||
url: Optional[str] = None,
|
url: Optional[str] = None,
|
||||||
level: int = 0,
|
level: int = 0,
|
||||||
|
fetcher: Optional[Callable[[str], Dict[str, Any]]] = None,
|
||||||
) -> List[Any]:
|
) -> List[Any]:
|
||||||
"""Resolve/fetch a `Collection`/`OrderedCollection`."""
|
"""Resolve/fetch a `Collection`/`OrderedCollection`."""
|
||||||
|
if not fetcher:
|
||||||
|
raise Exception('must provide a fetcher')
|
||||||
if level > 3:
|
if level > 3:
|
||||||
raise RecursionLimitExceededError('recursion limit exceeded')
|
raise RecursionLimitExceededError('recursion limit exceeded')
|
||||||
|
|
||||||
# Go through all the pages
|
# Go through all the pages
|
||||||
out: List[Any] = []
|
out: List[Any] = []
|
||||||
if url:
|
if url:
|
||||||
payload = OBJECT_FETCHER.fetch(url)
|
payload = fetcher(url)
|
||||||
if not payload:
|
if not payload:
|
||||||
raise ValueError('must at least prove a payload or an URL')
|
raise ValueError('must at least prove a payload or an URL')
|
||||||
|
|
||||||
|
@ -38,7 +41,7 @@ def parse_collection(
|
||||||
out.extend(payload['first']['items'])
|
out.extend(payload['first']['items'])
|
||||||
n = payload['first'].get('next')
|
n = payload['first'].get('next')
|
||||||
if n:
|
if n:
|
||||||
out.extend(parse_collection(url=n, level=level+1))
|
out.extend(parse_collection(url=n, level=level+1, fetcher=fetcher))
|
||||||
return out
|
return out
|
||||||
|
|
||||||
while payload:
|
while payload:
|
||||||
|
@ -50,7 +53,7 @@ def parse_collection(
|
||||||
n = payload.get('next')
|
n = payload.get('next')
|
||||||
if n is None:
|
if n is None:
|
||||||
break
|
break
|
||||||
payload = OBJECT_FETCHER.fetch(n)
|
payload = fetcher(n)
|
||||||
else:
|
else:
|
||||||
raise UnexpectedActivityTypeError('unexpected activity type {}'.format(payload['type']))
|
raise UnexpectedActivityTypeError('unexpected activity type {}'.format(payload['type']))
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue