[WIP] Start a new ActivityPub module
This commit is contained in:
parent
070e39bdfe
commit
d4bf73756f
6 changed files with 1239 additions and 0 deletions
0
little_boxes/__init__.py
Normal file
0
little_boxes/__init__.py
Normal file
1042
little_boxes/activitypub.py
Normal file
1042
little_boxes/activitypub.py
Normal file
File diff suppressed because it is too large
Load diff
49
little_boxes/errors.py
Normal file
49
little_boxes/errors.py
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
"""Errors raised by this package."""
|
||||||
|
from typing import Optional
|
||||||
|
from typing import Dict
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class Error(Exception):
|
||||||
|
"""HTTP-friendly base error, with a status code, a message and an optional payload."""
|
||||||
|
status_code = 400
|
||||||
|
|
||||||
|
def __init__(self, message: str, status_code: Optional[int] = None, payload: Optional[Dict[str, Any]] = None) -> None:
|
||||||
|
Exception.__init__(self)
|
||||||
|
self.message = message
|
||||||
|
if status_code is not None:
|
||||||
|
self.status_code = status_code
|
||||||
|
self.payload = payload
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
rv = dict(self.payload or ())
|
||||||
|
rv['message'] = self.message
|
||||||
|
return rv
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f'{self.__class__.__qualname__}({self.message!r}, payload={self.payload!r}, status_code={self.status_code})'
|
||||||
|
|
||||||
|
|
||||||
|
class ActorBlockedError(Error):
|
||||||
|
"""Raised when an activity from a blocked actor is received."""
|
||||||
|
|
||||||
|
|
||||||
|
class NotFromOutboxError(Error):
|
||||||
|
"""Raised when an activity targets an object from the inbox when an object from the oubox was expected."""
|
||||||
|
|
||||||
|
|
||||||
|
class ActivityNotFoundError(Error):
|
||||||
|
"""Raised when an activity is not found."""
|
||||||
|
status_code = 404
|
||||||
|
|
||||||
|
|
||||||
|
class BadActivityError(Error):
|
||||||
|
"""Raised when an activity could not be parsed/initialized."""
|
||||||
|
|
||||||
|
|
||||||
|
class RecursionLimitExceededError(BadActivityError):
|
||||||
|
"""Raised when the recursion limit for fetching remote object was exceeded (likely a collection)."""
|
||||||
|
|
||||||
|
|
||||||
|
class UnexpectedActivityTypeError(BadActivityError):
|
||||||
|
"""Raised when an another activty was expected."""
|
39
little_boxes/remote_object.py
Normal file
39
little_boxes/remote_object.py
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
from Crypto.PublicKey import RSA
|
||||||
|
|
||||||
|
from .urlutils import check_url
|
||||||
|
from .errors import ActivityNotFoundError
|
||||||
|
from .errors import UnexpectedActivityTypeError
|
||||||
|
|
||||||
|
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(actor_url, headers={
|
||||||
|
'Accept': 'application/activity+json',
|
||||||
|
'User-Agent': self._user_agent,
|
||||||
|
})
|
||||||
|
|
||||||
|
if resp.status_code == 404:
|
||||||
|
raise ActivityNotFoundError(f'{actor_url} cannot be fetched, 404 not found error')
|
||||||
|
|
||||||
|
resp.raise_for_status()
|
||||||
|
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
OBJECT_FETCHER = DefaultRemoteObjectFetcher()
|
||||||
|
|
||||||
|
def set_object_fetcher(object_fetcher: Any):
|
||||||
|
OBJECT_FETCHER = object_fetcher
|
47
little_boxes/urlutils.py
Normal file
47
little_boxes/urlutils.py
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import ipaddress
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from . import strtobool
|
||||||
|
from .errors import Error
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidURLError(Error):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def is_url_valid(url: str) -> bool:
|
||||||
|
parsed = urlparse(url)
|
||||||
|
if parsed.scheme not in ['http', 'https']:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# XXX in debug mode, we want to allow requests to localhost to test the federation with local instances
|
||||||
|
debug_mode = strtobool(os.getenv('MICROBLOGPUB_DEBUG', 'false'))
|
||||||
|
if debug_mode:
|
||||||
|
return True
|
||||||
|
|
||||||
|
if parsed.hostname in ['localhost']:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
ip_address = socket.getaddrinfo(parsed.hostname, parsed.port or 80)[0][4][0]
|
||||||
|
except socket.gaierror:
|
||||||
|
logger.exception(f'failed to lookup url {url}')
|
||||||
|
return False
|
||||||
|
|
||||||
|
if ipaddress.ip_address(ip_address).is_private:
|
||||||
|
logger.info(f'rejecting private URL {url}')
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def check_url(url: str) -> None:
|
||||||
|
if not is_url_valid(url):
|
||||||
|
raise InvalidURLError(f'"{url}" is invalid')
|
||||||
|
|
||||||
|
return None
|
62
little_boxes/utils.py
Normal file
62
little_boxes/utils.py
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
"""Contains some ActivityPub related utils."""
|
||||||
|
from typing import Optional
|
||||||
|
from typing import Dict
|
||||||
|
from typing import List
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from .errors import RecursionLimitExceededError
|
||||||
|
from .errors import UnexpectedActivityTypeError
|
||||||
|
from .remote_object import OBJECT_FETCHER
|
||||||
|
|
||||||
|
|
||||||
|
def parse_collection(
|
||||||
|
payload: Optional[Dict[str, Any]] = None,
|
||||||
|
url: Optional[str] = None,
|
||||||
|
level: int = 0,
|
||||||
|
) -> List[Any]:
|
||||||
|
"""Resolve/fetch a `Collection`/`OrderedCollection`."""
|
||||||
|
if level > 3:
|
||||||
|
raise RecursionLimitExceededError('recursion limit exceeded')
|
||||||
|
|
||||||
|
# Go through all the pages
|
||||||
|
headers = {'Accept': 'application/activity+json'}
|
||||||
|
if user_agent:
|
||||||
|
headers['User-Agent'] = user_agent
|
||||||
|
|
||||||
|
out: List[Any] = []
|
||||||
|
if url:
|
||||||
|
payload = OBJECT_FETCHER.fetch(url)
|
||||||
|
if not payload:
|
||||||
|
raise ValueError('must at least prove a payload or an URL')
|
||||||
|
|
||||||
|
if payload['type'] in ['Collection', 'OrderedCollection']:
|
||||||
|
if 'orderedItems' in payload:
|
||||||
|
return payload['orderedItems']
|
||||||
|
if 'items' in payload:
|
||||||
|
return payload['items']
|
||||||
|
if 'first' in payload:
|
||||||
|
if 'orderedItems' in payload['first']:
|
||||||
|
out.extend(payload['first']['orderedItems'])
|
||||||
|
if 'items' in payload['first']:
|
||||||
|
out.extend(payload['first']['items'])
|
||||||
|
n = payload['first'].get('next')
|
||||||
|
if n:
|
||||||
|
out.extend(parse_collection(url=n, level=level+1))
|
||||||
|
return out
|
||||||
|
|
||||||
|
while payload:
|
||||||
|
if payload['type'] in ['CollectionPage', 'OrderedCollectionPage']:
|
||||||
|
if 'orderedItems' in payload:
|
||||||
|
out.extend(payload['orderedItems'])
|
||||||
|
if 'items' in payload:
|
||||||
|
out.extend(payload['items'])
|
||||||
|
n = payload.get('next')
|
||||||
|
if n is None:
|
||||||
|
break
|
||||||
|
payload = OBJECT_FETCHER.fetch(n)
|
||||||
|
else:
|
||||||
|
raise UnexpectedActivityTypeError('unexpected activity type {}'.format(payload['type']))
|
||||||
|
|
||||||
|
return out
|
Loading…
Reference in a new issue