[WIP] Start a new ActivityPub module

This commit is contained in:
Thomas Sileo 2018-06-07 00:00:35 +02:00
parent 070e39bdfe
commit d4bf73756f
6 changed files with 1239 additions and 0 deletions

0
little_boxes/__init__.py Normal file
View 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
View 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."""

View 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
View 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
View 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